# Charte d'interface du Kit

> Faire du premier coup une interface Capibara : la voix, la densité, chaque surface, quel composant pour quel besoin, couleurs et icônes, accessibilité, le temps et le mouvement, et des recettes complètes validées par la plateforme.
> Catégorie : Référence · mis à jour le 2026-10-01 · plateforme v1

## Pourquoi cette page

Une app Capibara vit au milieu des écrans de Capibara : même voix, mêmes
composants, même densité. Une personne ne doit pas sentir la couture. Cette
charte dit comment y arriver du premier coup, et ses recettes sont copiables
telles quelles : chacune est compilée, empaquetée par le bundler de la
plateforme et rendue dans le harnais, qui refuse tout écran que Capibara
refuserait. Le niveau visé est celui de l'app « Météo (exemple) »
(téléchargeable depuis l'[index](/dev/docs)) : un handler court par surface,
de petites fonctions nommées pour la donnée et pour chaque morceau d'écran,
un commentaire qui dit POURQUOI.

Le vocabulaire des composants est sur [Le Kit](/dev/docs/kit), le contrat
d'exécution sur [Le runtime](/dev/docs/runtime).

## Les principes

### La voix

- On s'adresse à la personne avec **« vous »**. L'app ne dit ni « je » ni
  « nous » : elle dit ce qui se passe (« Intervention enregistrée. »).
- **Une majuscule en tête, le reste en minuscules** : « Créer une
  intervention », jamais « Créer Une Intervention » ni de capitales pour
  insister.
- **Le verbe d'abord** sur tout ce qui agit (bouton, élément de menu) :
  « Enregistrer », « Ajouter une ville », « Voir les factures ». Pas de « OK »,
  pas de « Cliquez ici ».
- **Pas d'emoji**, pas de symbole décoratif : les icônes du Kit suffisent.
- Des mots simples et français : « tableau de bord », « facture »,
  « réglages », « assistant » — pas « dashboard » ni « settings ».
- Phrases courtes, une idée par ligne. Un état vide peut être chaleureux
  (« Aucune intervention pour l'instant : créez la première. »), jamais
  familier sur l'argent, le droit ou les personnes.

### Les nombres et les dates

Le Kit affiche tel quel ce que vous lui donnez (sauf les cellules de tableau
typées) : mettez en forme vous-même, à la française, dans le fuseau de
l'organisation.

| Valeur | Écrire | Avec |
|---|---|---|
| Montant | `1 234,56 €` | `new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(centimes / 100)` |
| Nombre | `1 234` | `n.toLocaleString('fr-FR')` |
| Pourcentage | `12 %` | `new Intl.NumberFormat('fr-FR', { style: 'percent' }).format(0.12)` |
| Date | `3 oct.`, `3 octobre 2026` | `new Intl.DateTimeFormat('fr-FR', { timeZone: ctx.tenant.timezone, day: 'numeric', month: 'short' })` |
| Heure | `14:30` | `new Intl.DateTimeFormat('fr-FR', { timeZone: ctx.tenant.timezone, hour: '2-digit', minute: '2-digit' })` |

« Maintenant », c'est `ctx.now()` (le harnais le fige pour vos tests), et le
jour se calcule dans `ctx.tenant.timezone`, jamais dans le fuseau du serveur.

### La densité et la hiérarchie

- **Une action principale par surface** (`variant: 'primary'`), au plus.
  Les autres sont `secondary` ou `ghost` ; une action destructrice est
  `danger`, avec `confirm`.
- **Un en-tête par page** (`pageHeader` : titre, sous-titre, actions), puis
  des blocs empilés (`stack`, `gap: 'lg'` entre les blocs, `'sm'` à
  l'intérieur).
- **Le plus important en haut à gauche** : le chiffre clé avant le détail,
  la liste avant le formulaire de réglages.
- **Une carte = un sujet**, avec un titre. Pas de carte dans une carte.
- Trois niveaux d'imbrication suffisent presque toujours (la borne est
  8) : un écran qui en demande plus est à découper en
  onglets ou en pages.
- **Montrer peu, bien** : 3 à 5 lignes dans un widget, 25 à 50 dans un
  tableau (« Charger plus » pour la suite), un formulaire de 7 champs au plus
  par étape (au-delà : `stepper`).

## Par surface

### Widget (tableau de bord)

- **Taille** : 4 colonnes sur 12 par défaut (un tiers de l'écran, environ
  360 px), redimensionnable ; toute la largeur sur téléphone. Hauteur :
  `minHeight` du manifeste (240 px par défaut). Le titre du widget (manifeste)
  et le nom de votre app sont affichés au-dessus de votre contenu : ne les
  répétez pas.
- **Structure type** : un chiffre clé (`kpi`), une courte liste (`list`, 3
  à 5 lignes), au plus une action (`button` ou lien) — ou un `empty`.
- **Y va** : ce qu'on veut savoir d'un coup d'œil, ce qui demande d'agir
  aujourd'hui.
- **N'y va pas** : `pageHeader`, formulaire long, tableau, `columns`,
  `grid` ou `statsRow` à plus de 2 colonnes (les colonnes suivent la
  largeur de l'ÉCRAN, pas celle de la carte : trois colonnes dans un widget
  s'écrasent).
- **Cliquable en entier** : `"link": { "page": "<entrée de menu>" }` au
  manifeste ; un widget lié ne défile pas, tout ce qui dépasse est coupé.
- 10 widgets au plus par app : un widget par
  question, pas un widget par donnée.

### Page d'app

- **Taille** : la zone de contenu de Capibara, pleine largeur ; une colonne
  sur téléphone (`columns` et `split` s'empilent sous 768 et 1 024 px).
- **Au-dessus de votre contenu**, Capibara affiche l'icône de l'app, le
  `title` du document (« Interventions » ; à défaut, le nom de l'app) et, si
  l'app a plusieurs entrées de menu, un onglet par entrée. Le `title` nomme
  la page ; votre `pageHeader` porte le titre de ce qu'on voit (le même pour
  une liste, le nom de la fiche pour une fiche), son sous-titre et ses
  actions.
- **Structure type** : `pageHeader` (titre, sous-titre, action principale,
  onglets éventuels) → `statsRow` si des chiffres comptent → `toolbar`
  (recherche, filtres) → le contenu (`table`, `list`, cartes) → les
  réglages en bas ou dans un onglet.
- **Y va** : le travail complet d'un sujet. **N'y va pas** : plusieurs
  sujets sans rapport (une entrée de menu par sujet, 5 au plus).
- L'adresse `/apps/<clé>/<entrée>` ne transmet aucun paramètre : une fiche,
  un filtre, une page de résultats passent par l'état (`state`).

### Panneau de fiche (CRM)

- **Taille** : une carte dans la colonne principale de la fiche contact ou
  société, sous l'historique ; sa largeur est celle de la colonne. Le libellé
  du panneau et le nom de l'app sont au-dessus.
- **Structure type** : 1 à 3 chiffres ou badges, une courte liste, une ou
  deux actions qui concernent CETTE fiche (`ctx.subject.id`).
- **N'y va pas** : un en-tête de page, une navigation, de longs formulaires
  (ouvrez un formulaire avec `openForm`).

### Bloc et page du site public

- **Le lecteur est un visiteur** : pas de `viewer`, peut-être un membre du
  site (`visitor`). Ne montrez rien d'interne à l'organisation.
- **Structure type** : une `card` ou une section du site (`siteSection`),
  un texte court, au plus un bouton ; une page de site peut commencer par une
  section `hero`.
- **N'y va pas** : `openForm` (inexistant sur le site : dessinez le
  formulaire dans la surface), une action d'administration, un jargon interne.
- Le rendu pour un visiteur anonyme est gardé 60 s en cache : il ne doit
  dépendre ni de la personne, ni de la seconde.

### Formulaire (fenêtre ouverte par `openForm`)

- **Taille** : une fenêtre d'environ 670 px de large.
- **Structure type** : un `form` (2 à 7 champs, `layout: 'grid'` pour les
  champs courts) et son bouton `submit` (« Enregistrer », « Créer… »). Pas
  de `cancel` : la fenêtre a déjà sa croix. Réponse : `{ close: true }` + un `toast` en cas de succès, le
  formulaire redessiné avec ses erreurs sinon.

## Quel composant pour quel besoin

| Besoin | Composant | À éviter |
|---|---|---|
| Un chiffre clé | `kpi` (`statsRow` pour 2 à 6) | un `heading` avec un nombre |
| Une courte liste d'éléments | `list` (titre, sous-titre, badge, action) | un `table` de deux colonnes |
| Des lignes à comparer, trier, paginer | `table` (colonnes typées, `pagination`) | une `list` de 50 éléments |
| Un statut | `badge` (`tone`) ; dans un tableau, cellule `{ text, tone }` | du texte en couleur seule |
| Un message à lire (aide, avertissement, erreur) | `note` (`info`, `success`, `warning`, `error`) | un `toast` qui disparaît |
| Rien à montrer | `empty` (titre, phrase, icône, action) | une liste vide sans explication |
| Le résultat d'une action | `toast` (court, éphémère) | une `note` qui reste |
| Une évolution dans le temps | `chart` `timeseries` | un tableau de chiffres |
| Comparer quelques valeurs | `chart` `bars` (40 au plus) | un camembert : il n'existe pas |
| Une progression | `progress` | un pourcentage seul |
| Le temps qui reste avant un événement daté | `countdown` (`to` avec son fuseau, `endText`) | un `kpi` « J-12 » calculé au rendu, faux le lendemain |
| L'heure d'une équipe répartie sur plusieurs fuseaux | `clock` (une par ville, `timezone`) | une heure écrite dans un `text`, figée au rendu |
| Attirer l'œil sur un titre, un mot, un état qui dure | `heading` ou `text` avec `animate` (`fade-in`, `typewriter`, `highlight`, `pulse`) | deux animations dans le même écran |
| Un historique | `timeline` | une liste de dates |
| Rechercher, filtrer une liste | `toolbar` (`search` + champs) | un formulaire avec un bouton « Filtrer » |
| Changer de vue dans une page | onglets du `pageHeader` (avec `action`) ou `tabs` | plusieurs entrées de menu |
| Des étapes successives | `stepper` | un très long formulaire |
| Du contenu repliable (FAQ, détails) | `accordion` | des `card` empilées |
| Une liste et son détail côte à côte | `split` (s'empile sur téléphone) | deux pages |
| Une action principale | `button` `variant: 'primary'` | deux boutons `primary` |
| Plusieurs actions secondaires | `menu` (3 éléments ou plus) | une rangée de 5 boutons |
| Une saisie | `field` dans un `form` (`label` toujours) | un champ sans libellé |
| Un choix parmi des options | `field` `select` (`multiselect` pour plusieurs) | des boutons radio dessinés à la main |
| Une personne | `avatar` (initiales ou image) | une image nue |
| Une section de site public | `siteSection` (`hero`, `faq`, `pricing`…) | une `card` déguisée en héro |

## Couleurs et icônes

- **La plume** (`plume`, émeraude `#039e8e`) est la couleur du principal
  et du positif : action principale, réussi, à jour. C'est le ton par défaut.
- **La gorge** (`gorge`, magenta `#e91286`) est un accent **rare** : une
  nouveauté, un point à remarquer, la seconde série d'un graphique. Une fois
  par écran au plus, jamais pour une erreur.
- **Le pollen** (chartreuse) n'est **jamais** dans une interface : il n'existe
  pas dans le Kit, il est réservé aux illustrations de la marque.
- `amber` pour l'attention (en attente, bientôt dû, sous le seuil), `red`
  pour l'erreur et le danger (en retard, refusé, supprimer), `neutral` pour
  le secondaire (archivé, inactif).
- **La couleur ne porte jamais seule le sens** : un badge rouge dit aussi
  « En retard » en toutes lettres.

Le tableau complet des tons et des couleurs de graphique, avec leurs valeurs,
est sur [Le Kit › Couleurs](/dev/docs/kit). Pour les icônes (`icon` de
`kpi`, `empty`, `button`, `menu` et des entrées de menu) :

| Pour | Icône |
|---|---|
| Ajouter, créer | `plus` |
| Modifier | `pencil` |
| Supprimer | `trash-2` |
| Réglages, préférences | `settings` |
| Rechercher, filtrer | `search`, `filter` |
| Actualiser | `refresh-cw` |
| Ouvrir, aller plus loin | `arrow-right`, `chevron-right`, `external-link` (site externe) |
| Télécharger, envoyer un fichier | `download`, `upload` |
| Réussi, à jour | `circle-check` |
| Attention | `triangle-alert` |
| Erreur, refus | `circle-x`, `ban` |
| Information, aide | `info`, `circle-help` |
| Argent, facture | `euro`, `receipt-euro`, `wallet`, `credit-card` |
| Date, rendez-vous | `calendar`, `calendar-check`, `calendar-clock`, `clock` |
| Personne, équipe | `user`, `users`, `contact` |
| Stock, livraison | `package`, `boxes`, `warehouse`, `truck` |
| Tâche, liste | `list-checks`, `clipboard-list`, `check-square` |
| Messages | `mail`, `message-square`, `bell` |
| Tendance | `trending-up`, `trending-down`, `chart-column`, `chart-line` |
| Journée | `sunrise`, `sun`, `moon` |

Une icône accompagne un libellé ; seule (bouton-icône), elle exige
`ariaLabel`. La liste complète (et le seul vocabulaire accepté) :
[Le Kit › Icônes](/dev/docs/kit#icones).

## Accessibilité

- **Tout ce qui se saisit a un `label`**, même dans une barre d'outils (il
  sert de nom accessible) ; le format attendu se met dans `placeholder` (un
  exemple) et `hint` — le message d'erreur du navigateur reste générique
  (« Obligatoire », « Format invalide »).
- **Tout bouton se nomme** : `label`, ou `ariaLabel` pour un bouton-icône
  (« Ouvrir la fiche », pas « Bouton »).
- **Toute image a un `alt`** qui dit ce qu'elle montre (vide de sens :
  « image »).
- **L'ordre de lecture est l'ordre du document** : écrivez les enfants dans
  l'ordre où on doit les lire (le titre avant la donnée, la donnée avant
  l'action). La disposition sur grand écran ne change pas cet ordre.
- **Un titre par niveau** : le `pageHeader` porte le titre de la page, les
  `card` et `section` leurs propres titres ; `heading` niveau 2 ou 3
  dans une carte.
- **Un tableau a une `caption`** quand son sujet n'est pas évident, et
  des colonnes aux libellés explicites.
- Une note `error` est annoncée aux lecteurs d'écran : réservez-la aux vraies
  erreurs.

## Le temps et le mouvement

- **Un compte à rebours** (`countdown`) pour un événement daté : l'ouverture
  d'un salon, la fin d'une offre, la clôture des inscriptions. Il se tient à
  jour tout seul : votre code ne renvoie que la date, AVEC son fuseau, et ce
  qui s'affiche ensuite (`endText`, une phrase : « Les inscriptions sont
  closes »).
- **Une horloge** (`clock`) pour une équipe répartie sur plusieurs fuseaux :
  une par ville, la ville en `label`. Sans `timezone`, l'heure de
  l'organisation. Dans un widget, trois au plus, en `stack` horizontal avec
  `wrap: true`.
- **Le texte animé** (`animate` de `text` et `heading`) avec
  parcimonie : une animation par écran au plus, sur ce qui mérite l'œil — le
  titre d'accueil (`typewriter`, `fade-in`), le mot à retenir
  (`highlight`), un état qui dure (`pulse` : « En direct »). Jamais pour
  une erreur (une `note` la dit), jamais dans une liste ou un tableau.
- **Pour qui a réduit les animations**, Capibara affiche le texte immobile :
  le sens ne passe jamais par le mouvement seul. Un « En direct » le dit en
  toutes lettres.

## À faire, à éviter

| À faire | À éviter |
|---|---|
| « Ajouter une ville » | « Ajout » · « Nouvelle Ville » · « + » sans `ariaLabel` |
| « Aucune facture à encaisser : tout est réglé. » | « Aucune donnée » · une liste vide sans phrase |
| « Les factures ne sont pas lisibles pour le moment. » + `ctx.log.warn(e.message)` | `JSON.stringify(e)` à l'écran, une pile d'appels |
| `1 234,56 €` | `1234.56` · `€1,234.56` · `1 234.56 EUR` |
| `3 oct.` à l'heure de l'organisation | `2026-10-03T08:00:00.000Z` |
| Un `kpi` + 3 lignes dans un widget | un tableau de 10 colonnes dans un widget |
| `toast` « Intervention enregistrée. » après succès | une fenêtre « Succès ! » à fermer |
| Un état gardé dans `state` (filtre, page, fiche) | le même état stocké dans `ctx.kv` pour tout le monde |
| `confirm` sur « Supprimer » | supprimer au premier clic |
| `ctx.now()` | `new Date()` dans un rendu |
| `ui.countdown({ to: '2026-11-14T09:00:00+01:00' })` | un `kpi` « J-12 » recalculé à chaque rendu |
| Une animation par écran, sur le titre ou l'état qui dure | un titre qui s'écrit, un texte qui respire et un surlignage ensemble |

## Recette 1 — Widget : un chiffre clé, une courte liste, une action

« À encaisser » : le total des factures émises non réglées, les trois plus
proches de l'échéance, un lien vers la facturation. Le widget se dessine en
trois temps : lire, dire ce qui manque, montrer l'essentiel.

```json
{
  "main": "src/index.ts",
  "permissions": ["billing-fr.documents:read"],
  "capabilities": ["ui.widget"],
  "contributes": {
    "widgets": [{ "key": "a-encaisser", "title": "À encaisser", "kind": "kit", "minHeight": 280 }]
  }
}
```

```ts
import { defineApp, ui, type AppContext, type BillingDocumentsListOutput, type KitTone } from '@capibara-dev/sdk';

type Facture = BillingDocumentsListOutput['items'][number];

/** Les façades parlent en centimes ; l'écran en euros à la française (« 1 234,56 € »). */
const EUR = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' });
const euros = (centimes: number): string => EUR.format(centimes / 100);

/** « 3 oct. », dans le fuseau de l'organisation. */
function jour(iso: string, ctx: AppContext): string {
  return new Intl.DateTimeFormat('fr-FR', { timeZone: ctx.tenant.timezone, day: 'numeric', month: 'short' }).format(new Date(iso));
}

/** Le badge d'échéance : en retard (rouge) ou bientôt dû (ambre). La couleur ne parle jamais seule. */
function echeance(f: Facture, ctx: AppContext): { text: string; tone: KitTone } | undefined {
  if (f.dueDate === null) return undefined;
  const retard = Date.parse(f.dueDate) < ctx.now().getTime();
  return { text: (retard ? 'Échue le ' : 'Le ') + jour(f.dueDate, ctx), tone: retard ? 'red' : 'amber' };
}

export default defineApp({
  widgets: {
    'a-encaisser': async ({ ctx }) => {
      // 1. Lire : les factures émises, ni réglées ni annulées, l'échéance la plus proche d'abord.
      let factures: Facture[];
      try {
        factures = (await ctx.capibara.billing.documents.list({ unpaid: true, sort: 'dueDate', take: 5 })).items;
      } catch (e) {
        // Scope non accordé, droit manquant de la personne : on le dit sobrement, le détail va au journal.
        ctx.log.warn('factures illisibles', String((e as Error)?.message ?? e));
        return ui.note({ tone: 'warning', text: 'Les factures ne sont pas lisibles pour le moment.' });
      }

      // 2. Rien à montrer : une phrase qui dit pourquoi, jamais une liste vide muette.
      if (factures.length === 0) {
        return ui.empty({ title: 'Tout est encaissé', description: "Aucune facture émise n'attend de règlement.", icon: 'circle-check' });
      }

      // 3. Le chiffre clé, puis trois lignes, puis UNE action.
      const total = factures.reduce((somme, f) => somme + f.totalTtcCents, 0);
      const enRetard = factures.filter((f) => echeance(f, ctx)?.tone === 'red').length;
      return ui.stack({ gap: 'sm' }, [
        ui.kpi({ label: 'À encaisser', value: euros(total), hint: enRetard > 0 ? enRetard + ' en retard' : 'Rien en retard', icon: 'receipt-euro' }),
        ui.list({
          items: factures.slice(0, 3).map((f) => ({
            id: f.id,
            title: f.buyerName ?? 'Client sans nom',
            subtitle: (f.number ?? 'Sans numéro') + ' · ' + euros(f.totalTtcCents),
            badge: echeance(f, ctx),
          })),
        }),
        ui.button({ label: 'Voir les factures', href: '/facturation/documents', icon: 'arrow-right', variant: 'ghost', size: 'sm' }),
      ]);
    },
  },
});
```

## Recette 2 — Le widget « Ma journée »

Une carte qui répond à « qu'est-ce qui m'attend aujourd'hui ? » : les
rendez-vous de la personne, ses tâches dues, les factures à échéance cette
semaine, les articles sous leur seuil et les absents du jour. Elle est
**cliquable en entier** (`link` vers la page « Ma journée », qui montre le
détail), et chaque personne choisit ce qu'elle y voit — une **préférence par
personne**, gardée dans le stockage clé/valeur de l'app.

Le manifeste complet :

```json
{
  "key": "ma-journee",
  "name": "Ma journée",
  "version": "1.0.0",
  "main": "src/index.ts",
  "author": { "name": "Votre nom", "devAccountSlug": "votre-slug" },
  "compatibility": { "colibri": ">=1.0", "modules": [] },
  "permissions": ["planning.bookings:read", "crm.activities:read", "billing-fr.documents:read", "inventory.items:read", "hr.absences:read"],
  "capabilities": ["ui.menu", "ui.widget", "storage.tenant.ro", "storage.tenant.rw"],
  "contributes": {
    "menuEntries": [{ "key": "ma-journee", "label": "Ma journée", "view": "ma-journee", "icon": "sunrise" }],
    "widgets": [{ "key": "ma-journee", "title": "Ma journée", "kind": "kit", "minHeight": 450, "link": { "page": "ma-journee" } }]
  },
  "pricing": { "model": "free" },
  "i18n": {
    "fr": "Vos rendez-vous, tâches, factures, ruptures et absences du jour, sur l'accueil.",
    "en": "Your day at a glance: appointments, tasks, invoices, low stock and absences."
  },
  "support": { "email": "support@example.com" }
}
```

- Cinq scopes, un par façade lue : l'organisation les voit et les accepte à
  l'installation. Une app plus modeste n'en garde que ce qu'elle lit.
- `storage.tenant.rw` : la préférence s'écrit et se relit (`rw` couvre la
  lecture ; `ro` déclaré en plus ne change rien).
- `link.page` vaut la clé de l'entrée de menu : la carte mène à
  `/apps/ma-journee/ma-journee`.
- `minHeight: 450` : cinq rangées du tableau de bord (390 px). Un widget lié
  ne défile pas : ce qui dépasse est coupé — d'où le réglage placé en haut.

**La préférence par personne.** Il n'y a pas de stockage propre à une
personne : la recette range ses choix sous la clé `pref:<viewer.userId>`.
`viewer` est fourni sur toutes les surfaces du back-office (widget compris)
et `userId` est stable pour une personne dans l'organisation. Trois limites
à connaître :

- **Rien de sensible** : ce stockage appartient à l'app dans l'organisation,
  pas à la personne. Votre code peut relire les préférences de TOUT le monde
  (`ctx.kv.list('pref:')`, 100 clés par appel) ; n'y mettez que des choix
  d'affichage — jamais un secret, une donnée de santé ou un commentaire sur
  quelqu'un.
- **La borne** : 200 clés par installation, toutes clés confondues. Au-delà
  d'environ 150 personnes, rangez les préférences dans une collection
  (`ctx.data.prefs.set({ … }, viewer.userId)`, quota de lignes bien plus
  large).
- **Pas de `viewer`** sur le site public, ni dans un événement ou une
  planification : il n'y a alors personne à qui appartiendrait une
  préférence.

```ts
import { defineApp, ui, toast, type AppContext, type KitNode, type KitTone } from '@capibara-dev/sdk';

// ── Les sections, et la préférence de chaque personne ─────────────────────────

type Section = 'rdv' | 'taches' | 'factures' | 'stock' | 'absences';
const SECTIONS: ReadonlyArray<{ key: Section; label: string }> = [
  { key: 'rdv', label: 'les rendez-vous' },
  { key: 'taches', label: 'les tâches' },
  { key: 'factures', label: 'les factures' },
  { key: 'stock', label: 'le stock' },
  { key: 'absences', label: 'les absences' },
];
const estSection = (v: unknown): v is Section => SECTIONS.some((s) => s.key === v);

/**
 * Préférence d'UNE personne, clé « pref:<id de la personne> » du KV de l'app.
 * L'app peut relire celles de tout le monde : des choix d'affichage seulement.
 */
interface Prefs { masquees: Section[] }

async function lirePrefs(ctx: AppContext): Promise<Prefs> {
  if (!ctx.viewer) return { masquees: [] };
  const row = await ctx.kv.get('pref:' + ctx.viewer.userId);
  const v = row?.value as { masquees?: unknown } | undefined; // « unknown » : on vérifie avant de s'en servir
  return { masquees: Array.isArray(v?.masquees) ? v.masquees.filter(estSection) : [] };
}

// ── L'heure : ctx.now() et le fuseau de l'organisation, jamais new Date() seul ──

/** Le jour « AAAA-MM-JJ » d'un instant, dans un fuseau. */
function jourDe(instant: Date, fuseau: string): string {
  return new Intl.DateTimeFormat('en-CA', { timeZone: fuseau, year: 'numeric', month: '2-digit', day: '2-digit' }).format(instant);
}

/** Le décalage « +02:00 » d'un fuseau à un instant. */
function decalage(instant: Date, fuseau: string): string {
  const nom = new Intl.DateTimeFormat('en-US', { timeZone: fuseau, timeZoneName: 'longOffset' })
    .formatToParts(instant).find((p) => p.type === 'timeZoneName')?.value ?? 'GMT';
  return nom === 'GMT' ? '+00:00' : nom.slice(3);
}

/** Minuit d'un jour dans un fuseau — deux passes : juste aussi les jours de changement d'heure. */
function minuit(jour: string, fuseau: string): Date {
  const essai = new Date(jour + 'T00:00:00' + decalage(new Date(jour + 'T12:00:00Z'), fuseau));
  return new Date(jour + 'T00:00:00' + decalage(essai, fuseau));
}

const EUR = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' });
const heure = (iso: string, fuseau: string) => new Intl.DateTimeFormat('fr-FR', { timeZone: fuseau, hour: '2-digit', minute: '2-digit' }).format(new Date(iso));
const pluriel = (n: number, un: string, plusieurs: string) => n.toLocaleString('fr-FR') + ' ' + (n > 1 ? plusieurs : un);

// ── Lire sans casser ─────────────────────────────────────────────────────────

/** Une lecture peut manquer (scope non accordé, droit de la personne, module coupé) : on le dira. */
type Lecture<T> = { ok: true; valeur: T } | { ok: false };

async function lire<T>(ctx: AppContext, quoi: string, appel: () => Promise<T>): Promise<Lecture<T>> {
  try {
    return { ok: true, valeur: await appel() };
  } catch (e) {
    ctx.log.warn(quoi + ' : lecture impossible', String((e as Error)?.message ?? e));
    return { ok: false };
  }
}

interface Ligne { id: string; title: string; subtitle?: string; badge?: { text: string; tone?: KitTone } }

const indisponible = (id: string, titre: string): Ligne =>
  ({ id, title: titre, subtitle: 'Lecture impossible pour le moment.', badge: { text: 'Indisponible', tone: 'neutral' } });

/** Les lignes de la journée, une par section visible ; « detail » = combien d'éléments citer. */
async function journee(ctx: AppContext, masquees: Section[], detail: number): Promise<Ligne[]> {
  const fuseau = ctx.tenant.timezone;
  const maintenant = ctx.now();
  const aujourdhui = jourDe(maintenant, fuseau);
  const debut = minuit(aujourdhui, fuseau);
  const demain = minuit(jourDe(new Date(debut.getTime() + 36 * 3600 * 1000), fuseau), fuseau);
  const dansSeptJours = new Date(debut.getTime() + 8 * 24 * 3600 * 1000);
  const voir = (s: Section) => !masquees.includes(s);
  const rien = Promise.resolve(null);

  // Les cinq lectures partent ensemble (100 appels permis par interaction).
  const [rdv, taches, factures, stock, absences] = await Promise.all([
    voir('rdv') ? lire(ctx, 'Rendez-vous', () => ctx.capibara.planning.bookings.list({ from: debut, to: demain, mine: true })) : rien,
    voir('taches') ? lire(ctx, 'Tâches', () => ctx.capibara.crm.activities.list({ mine: true, completed: false, dueBefore: demain })) : rien,
    voir('factures') ? lire(ctx, 'Factures', () => ctx.capibara.billing.documents.list({ unpaid: true, dueBefore: dansSeptJours, sort: 'dueDate', take: 5 })) : rien,
    voir('stock') ? lire(ctx, 'Stock', () => ctx.capibara.inventory.items.lowStock({ take: 5 })) : rien,
    voir('absences') ? lire(ctx, 'Absences', () => ctx.capibara.hr.absences.list({ from: aujourdhui })) : rien,
  ]);

  const lignes: Ligne[] = [];
  if (rdv) {
    if (!rdv.ok) lignes.push(indisponible('rdv', 'Rendez-vous'));
    else {
      const items = rdv.valeur.items.filter((b) => b.status !== 'CANCELLED');
      const aVenir = items.filter((b) => Date.parse(b.endsAt) > maintenant.getTime());
      lignes.push({
        id: 'rdv',
        title: items.length === 0 ? "Aucun rendez-vous aujourd'hui" : pluriel(items.length, 'rendez-vous', 'rendez-vous') + " aujourd'hui",
        subtitle: aVenir.length > 0
          ? 'Prochain : ' + aVenir.slice(0, detail).map((b) => heure(b.startsAt, fuseau) + ' · ' + b.title).join(' ; ')
          : items.length > 0 ? 'Tous passés' : undefined,
      });
    }
  }
  if (taches) {
    if (!taches.ok) lignes.push(indisponible('taches', 'Tâches'));
    else {
      const items = taches.valeur.items;
      const retard = items.filter((a) => a.dueAt !== null && Date.parse(a.dueAt) < maintenant.getTime()).length;
      lignes.push({
        id: 'taches',
        title: items.length === 0 ? 'Aucune tâche pour aujourd’hui' : pluriel(items.length, 'tâche à faire', 'tâches à faire'),
        subtitle: items.slice(0, detail).map((a) => a.subject).join(' ; ') || undefined,
        badge: retard > 0 ? { text: retard + ' en retard', tone: 'red' } : undefined,
      });
    }
  }
  if (factures) {
    if (!factures.ok) lignes.push(indisponible('factures', 'Factures'));
    else {
      const items = factures.valeur.items;
      const echues = items.filter((f) => f.dueDate !== null && Date.parse(f.dueDate) < debut.getTime()).length;
      lignes.push({
        id: 'factures',
        title: items.length === 0 ? 'Aucune facture à échéance cette semaine' : pluriel(items.length, 'facture à encaisser', 'factures à encaisser'),
        subtitle: items.slice(0, detail).map((f) => (f.number ?? 'Sans numéro') + ' · ' + EUR.format(f.totalTtcCents / 100)).join(' ; ') || undefined,
        badge: echues > 0 ? { text: echues + ' échue' + (echues > 1 ? 's' : ''), tone: 'red' } : items.length > 0 ? { text: 'Cette semaine', tone: 'amber' } : undefined,
      });
    }
  }
  if (stock) {
    if (!stock.ok) lignes.push(indisponible('stock', 'Stock'));
    else {
      const { items, total } = stock.valeur;
      lignes.push({
        id: 'stock',
        title: total === 0 ? 'Aucun article sous son seuil' : pluriel(total, 'article sous son seuil', 'articles sous leur seuil'),
        subtitle: items.slice(0, detail).map((i) => i.productName + (i.variantName ? ' (' + i.variantName + ')' : '') + ' : ' + i.available + ' disponibles, seuil ' + i.reorderLevel).join(' ; ') || undefined,
        badge: total > 0 ? { text: 'À commander', tone: 'amber' } : undefined,
      });
    }
  }
  if (absences) {
    if (!absences.ok) lignes.push(indisponible('absences', 'Absences'));
    else if (absences.valeur.perimeter === 'none') {
      // « none » : la personne ne voit les absences de personne — ne jamais dire « personne d'absent ».
      lignes.push({ id: 'absences', title: 'Absences', subtitle: 'Vous ne voyez pas les absences de votre organisation.' });
    } else {
      const items = absences.valeur.items;
      lignes.push({
        id: 'absences',
        title: items.length === 0 ? "Personne d'absent aujourd'hui" : pluriel(items.length, "absent aujourd'hui", "absents aujourd'hui"),
        subtitle: items.slice(0, detail).map((a) => a.name + (a.halfStart || a.halfEnd ? ' (demi-journée)' : '')).join(', ') || undefined,
      });
    }
  }
  return lignes;
}

/** Le choix des sections : un menu (il reste cliquable dans un widget lié) ; en icône seule, il porte son nom. */
function menuPreferences(prefs: Prefs, compact: boolean): KitNode {
  return ui.menu({
    ...(compact ? { ariaLabel: 'Personnaliser ma journée' } : { label: 'Personnaliser' }),
    icon: 'settings',
    items: SECTIONS.map((s) => ({
      label: (prefs.masquees.includes(s.key) ? 'Afficher ' : 'Masquer ') + s.label,
      action: 'preference.basculer',
      params: { section: s.key },
    })),
  });
}

export default defineApp({
  widgets: {
    /**
     * Le widget : une ligne par section, le premier élément de chacune. Un widget lié ne défile pas :
     * le réglage passe EN HAUT (une icône), la liste coupée en bas ne perd que ses dernières lignes.
     */
    'ma-journee': async ({ ctx }) => {
      const prefs = await lirePrefs(ctx);
      const lignes = await journee(ctx, prefs.masquees, 1);
      return ui.stack({ gap: 'sm' }, [
        ui.stack({ direction: 'horizontal', justify: 'end' }, [menuPreferences(prefs, true)]),
        ui.list({ items: lignes, emptyText: 'Toutes les sections sont masquées : utilisez le réglage ci-dessus.' }),
      ]);
    },
  },
  pages: {
    /** La page où mène la carte : les mêmes sections, avec plus de détail. */
    'ma-journee': async ({ ctx, viewer }) => {
      const prefs = await lirePrefs(ctx);
      const lignes = await journee(ctx, prefs.masquees, 5);
      const date = new Intl.DateTimeFormat('fr-FR', { timeZone: ctx.tenant.timezone, weekday: 'long', day: 'numeric', month: 'long' }).format(ctx.now());
      return ui.doc(ui.stack({ gap: 'lg' }, [
        ui.pageHeader({ title: 'Ma journée', subtitle: (viewer?.name ? viewer.name + ', voici votre ' : 'Votre ') + date + '.' }),
        ui.card({ title: "Aujourd'hui" }, [
          ui.list({ items: lignes, emptyText: 'Toutes les sections sont masquées.' }),
        ]),
        menuPreferences(prefs, false),
      ]), { title: 'Ma journée' });
    },
  },
  actions: {
    /** Masquer ou afficher une section, pour la personne qui clique seulement. */
    'preference.basculer': async ({ ctx, params }) => {
      if (!ctx.viewer) return toast('Préférence réservée à une personne connectée.', 'warning');
      const section = params.section;
      if (!estSection(section)) return toast('Section inconnue.', 'warning');
      const prefs = await lirePrefs(ctx);
      const masquees = prefs.masquees.includes(section) ? prefs.masquees.filter((s) => s !== section) : [...prefs.masquees, section];
      try {
        await ctx.kv.put('pref:' + ctx.viewer.userId, { masquees });
      } catch (e) {
        // Le plus souvent : la borne de 200 clés du KV est atteinte.
        ctx.log.warn('préférence non enregistrée', String((e as Error)?.message ?? e));
        return toast("Votre préférence n'a pas pu être enregistrée.", 'error');
      }
      return { refresh: true };
    },
  },
});
```

- Le widget et la page partagent `journee()` : une seule définition de ce
  qu'on lit et de ce qu'on dit.
- `mine: true` exige une personne : ces lectures n'ont de sens que sur une
  surface du back-office, jamais dans une planification.
- `perimeter: 'none'` se dit « vous ne voyez pas les absences », jamais
  « personne d'absent » ; une section illisible se dit « Indisponible », jamais
  « rien ».

## Recette 3 — Page liste avec filtres, état vide, et sa fiche

« Interventions » : une collection de l'app, une page qui la liste avec une
recherche et un filtre de statut, deux états vides distincts (rien du tout,
rien qui corresponde), et la fiche d'une intervention. Toute la navigation
passe par l'état : la page n'a qu'une adresse.

```json
{
  "main": "src/index.ts",
  "capabilities": ["ui.menu", "data.collections"],
  "collections": {
    "interventions": {
      "fields": { "titre": "string", "client": "string", "statut": "string", "prevueLe": "date", "montantCents": "number", "notes": "string" }
    }
  },
  "contributes": {
    "menuEntries": [{ "key": "interventions", "label": "Interventions", "view": "interventions", "icon": "wrench" }]
  }
}
```

```ts
import { defineApp, ui, toast, type AppCollection, type AppContext, type AppWhere, type KitNode, type KitTone } from '@capibara-dev/sdk';

interface Intervention { titre: string; client: string; statut: string; prevueLe: string | null; montantCents: number | null; notes: string | null }

const STATUTS: ReadonlyArray<{ value: string; label: string; tone: KitTone }> = [
  { value: 'a-planifier', label: 'À planifier', tone: 'amber' },
  { value: 'planifiee', label: 'Planifiée', tone: 'plume' },
  { value: 'terminee', label: 'Terminée', tone: 'neutral' },
];
const statut = (v: string) => STATUTS.find((s) => s.value === v) ?? { value: v, label: v, tone: 'neutral' as const };

/** L'état de la page : la recherche, le filtre, combien de lignes, la fiche ouverte. */
interface Etat { q: string; statut: string; limite: number; fiche: string | null }
const PAR_PAGE = 25;

function lireEtat(state: unknown): Etat {
  const s = (state ?? {}) as Partial<Etat>;
  return {
    q: typeof s.q === 'string' ? s.q : '',
    statut: typeof s.statut === 'string' ? s.statut : 'tous',
    limite: typeof s.limite === 'number' ? Math.min(100, Math.max(PAR_PAGE, s.limite)) : PAR_PAGE,
    fiche: typeof s.fiche === 'string' ? s.fiche : null,
  };
}

/** La collection déclarée au manifeste, avec la forme de ses lignes (le SDK la type « Record » par défaut). */
const lignes = (ctx: AppContext) => ctx.data.interventions as unknown as AppCollection<Intervention>;

/** La liste, ses filtres, et ses deux états vides. */
async function vueListe(ctx: AppContext, etat: Etat): Promise<KitNode> {
  const where: AppWhere = {};
  if (etat.q) where.titre = { contains: etat.q };
  if (etat.statut !== 'tous') where.statut = etat.statut;
  const filtre = etat.q !== '' || etat.statut !== 'tous';
  const page = await lignes(ctx).query({ where, limit: etat.limite });

  const barre = ui.toolbar({ search: { name: 'q', placeholder: 'Rechercher une intervention', action: 'liste.filtrer', value: etat.q } }, [
    ui.field({ kind: 'select', name: 'statut', label: 'Statut', value: etat.statut, options: [{ value: 'tous', label: 'Tous' }, ...STATUTS.map((s) => ({ value: s.value, label: s.label }))] }),
  ]);

  let contenu: KitNode;
  if (page.items.length === 0 && !filtre) {
    contenu = ui.empty({
      title: "Aucune intervention pour l'instant",
      description: 'Créez la première : elle apparaîtra ici.',
      icon: 'wrench',
      action: ui.button({ label: 'Créer une intervention', action: 'fiche.creer', icon: 'plus', variant: 'primary' }),
    });
  } else if (page.items.length === 0) {
    contenu = ui.empty({
      title: 'Aucun résultat',
      description: etat.q ? 'Aucune intervention ne correspond à « ' + etat.q + ' ».' : 'Aucune intervention avec ce statut.',
      icon: 'search',
      action: ui.button({ label: 'Effacer les filtres', action: 'liste.effacer', variant: 'secondary' }),
    });
  } else {
    contenu = ui.table({
      caption: 'Interventions, les plus récentes d’abord',
      columns: [
        { key: 'titre', label: 'Intervention' },
        { key: 'client', label: 'Client' },
        { key: 'prevueLe', label: 'Prévue le', kind: 'date' },
        { key: 'montant', label: 'Montant', kind: 'money', align: 'end' },
        { key: 'statut', label: 'Statut', kind: 'badge' },
      ],
      rows: page.items.map((r) => ({
        id: r.key,
        cells: {
          titre: r.data.titre,
          client: r.data.client,
          prevueLe: r.data.prevueLe,
          montant: r.data.montantCents,                       // en CENTIMES : le Kit affiche « 1 234,56 € »
          statut: { text: statut(r.data.statut).label, tone: statut(r.data.statut).tone },
        },
        actions: [ui.button({ ariaLabel: 'Ouvrir la fiche', icon: 'chevron-right', action: 'fiche.ouvrir', params: { key: r.key }, variant: 'ghost', size: 'sm' })],
      })),
      pagination: page.nextCursor && etat.limite < 100 ? { hasMore: true, action: 'liste.plus' } : undefined,
    });
  }
  // Une seule action principale : quand la liste est vide, c'est l'état vide qui la porte.
  const vide = page.items.length === 0 && !filtre;
  return ui.stack({ gap: 'lg' }, [
    ui.pageHeader({
      title: 'Interventions',
      subtitle: filtre ? 'Liste filtrée.' : 'Toutes les interventions de votre organisation.',
      actions: vide ? [] : [ui.button({ label: 'Créer une intervention', action: 'fiche.creer', icon: 'plus', variant: 'primary' })],
    }),
    ...(vide ? [] : [barre]),
    contenu,
  ]);
}

/** La fiche d'une intervention (ou ce qu'il en reste si elle a été supprimée entre-temps). */
async function vueFiche(ctx: AppContext, key: string): Promise<KitNode> {
  const retour = ui.button({ label: 'Retour à la liste', action: 'fiche.fermer', icon: 'chevron-left', variant: 'ghost' });
  const r = await lignes(ctx).get(key);
  if (!r) {
    return ui.stack({ gap: 'md' }, [ui.note({ tone: 'warning', text: "Cette intervention n'existe plus : elle a été supprimée." }), retour]);
  }
  const s = statut(r.data.statut);
  const date = r.data.prevueLe
    ? new Intl.DateTimeFormat('fr-FR', { timeZone: ctx.tenant.timezone, day: 'numeric', month: 'long', year: 'numeric' }).format(new Date(r.data.prevueLe))
    : 'Non planifiée';
  const montant = r.data.montantCents === null ? '—' : new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(r.data.montantCents / 100);
  return ui.stack({ gap: 'lg' }, [
    ui.pageHeader({
      title: r.data.titre,
      subtitle: r.data.client,
      actions: [
        retour,
        ui.button({ label: 'Supprimer', action: 'fiche.supprimer', params: { key: r.key }, icon: 'trash-2', variant: 'danger', confirm: 'Supprimer cette intervention ? Elle ne pourra pas être récupérée.' }),
      ],
    }),
    ui.statsRow({ items: [
      { type: 'kpi', label: 'Statut', value: s.label },
      { type: 'kpi', label: 'Prévue le', value: date, icon: 'calendar' },
      { type: 'kpi', label: 'Montant', value: montant, icon: 'euro' },
    ] }),
    ui.card({ title: 'Notes' }, [
      r.data.notes ? ui.text({ markdown: r.data.notes }) : ui.text({ markdown: 'Aucune note.', tone: 'neutral' }),
    ]),
  ]);
}

export default defineApp({
  pages: {
    interventions: async ({ ctx, state }) => {
      const etat = lireEtat(state);
      return ui.doc(etat.fiche ? await vueFiche(ctx, etat.fiche) : await vueListe(ctx, etat), { title: 'Interventions', state: etat });
    },
  },
  actions: {
    // La barre d'outils envoie la recherche ET ses champs dans params, 350 ms après la dernière saisie.
    'liste.filtrer': async ({ params, state }) => ({
      state: { ...lireEtat(state), q: typeof params.q === 'string' ? params.q.trim().slice(0, 80) : '', statut: typeof params.statut === 'string' ? params.statut : 'tous', limite: PAR_PAGE },
      refresh: true,
    }),
    'liste.effacer': async ({ state }) => ({ state: { ...lireEtat(state), q: '', statut: 'tous', limite: PAR_PAGE }, refresh: true }),
    'liste.plus': async ({ state }) => {
      const etat = lireEtat(state);
      return { state: { ...etat, limite: Math.min(100, etat.limite + PAR_PAGE) }, refresh: true };
    },
    'fiche.ouvrir': async ({ params, state }) => (typeof params.key === 'string' ? { state: { ...lireEtat(state), fiche: params.key }, refresh: true } : toast('Intervention inconnue.', 'warning')),
    'fiche.fermer': async ({ state }) => ({ state: { ...lireEtat(state), fiche: null }, refresh: true }),
    // Une intervention « à planifier », ouverte aussitôt (la recette 4 montre un vrai formulaire de création).
    'fiche.creer': async ({ ctx, state }) => {
      const r = await lignes(ctx).set({ titre: 'Nouvelle intervention', client: 'Client à préciser', statut: 'a-planifier', prevueLe: null, montantCents: null, notes: null });
      return { ...toast('Intervention créée.', 'success'), state: { ...lireEtat(state), fiche: r.key }, refresh: true };
    },
    'fiche.supprimer': async ({ ctx, params, state }) => {
      if (typeof params.key !== 'string') return toast('Intervention inconnue.', 'warning');
      await lignes(ctx).delete(params.key);
      return { ...toast('Intervention supprimée.', 'success'), state: { ...lireEtat(state), fiche: null }, refresh: true };
    },
  },
});
```

- Les deux états vides ne disent pas la même chose : « rien encore » propose
  de créer, « rien qui corresponde » propose d'effacer les filtres.
- La cellule `money` reçoit des centimes, la cellule `date` une date ISO :
  le Kit les met en forme. Un `kpi` reçoit un texte déjà mis en forme.
- « Charger plus » augmente la limite par pas de 25, jusqu'à 100 lignes (la
  borne d'un tableau) : au-delà, la recherche fait mieux qu'un défilement.
- `ctx.data.interventions` est une `AppCollection` de `Record<string,
  unknown>` par défaut : la recette donne une fois pour toutes la forme de
  ses lignes (`lignes()`).

## Recette 4 — Formulaire avec erreurs

Le formulaire s'ouvre en fenêtre (`openForm`), le navigateur vérifie
l'obligatoire et les bornes, l'action revérifie TOUT, et en cas de refus le
formulaire revient avec ses valeurs et une note qui dit quoi corriger.

```json
{
  "main": "src/index.ts",
  "capabilities": ["ui.menu", "data.collections"],
  "collections": {
    "interventions": {
      "fields": { "titre": "string", "client": "string", "statut": "string", "prevueLe": "date", "montantCents": "number", "notes": "string" }
    }
  },
  "contributes": {
    "menuEntries": [{ "key": "interventions", "label": "Interventions", "view": "interventions", "icon": "wrench" }]
  }
}
```

```ts
import { defineApp, ui, toast, type AppContext, type KitNode } from '@capibara-dev/sdk';

/** Les valeurs du formulaire telles qu'on les réaffiche (texte pour tout, comme le navigateur les renvoie). */
interface Saisie { titre: string; client: string; prevueLe: string; montant: number | ''; notes: string }
const VIDE: Saisie = { titre: '', client: '', prevueLe: '', montant: '', notes: '' };

/** Lit « form » sans lui faire confiance : chaque valeur est vérifiée avant usage. */
function saisieDe(form: Record<string, unknown> | null): Saisie {
  const f = form ?? {};
  const texte = (v: unknown) => (typeof v === 'string' ? v.trim() : '');
  return {
    titre: texte(f.titre),
    client: texte(f.client),
    prevueLe: texte(f.prevueLe),
    montant: typeof f.montant === 'number' && Number.isFinite(f.montant) ? f.montant : '',
    notes: texte(f.notes),
  };
}

/** Les règles, côté serveur : jamais seulement celles du navigateur. */
function erreurs(s: Saisie, aujourdhui: string): string[] {
  const out: string[] = [];
  if (!s.titre) out.push("Donnez un titre à l'intervention.");
  else if (s.titre.length > 120) out.push('Le titre dépasse 120 caractères.');
  if (!s.client) out.push('Indiquez le client.');
  if (!/^\d{4}-\d{2}-\d{2}$/.test(s.prevueLe)) out.push("Choisissez la date de l'intervention.");
  else if (s.prevueLe < aujourdhui) out.push('La date est passée : choisissez aujourd’hui ou plus tard.');
  if (s.montant !== '' && (s.montant < 0 || s.montant > 1000000)) out.push('Le montant doit être compris entre 0 et 1 000 000 €.');
  return out;
}

/** Le formulaire, avec les valeurs déjà saisies et, s'il y en a, la liste des corrections. */
function formulaire(s: Saisie, problemes: string[]): KitNode {
  return ui.stack({ gap: 'md' }, [
    ...(problemes.length > 0 ? [ui.note({ tone: 'error', title: 'Corrigez ces points', text: problemes.map((p) => '- ' + p).join('\n') })] : []),
    ui.form({
      name: 'intervention',
      layout: 'grid',
      submit: ui.button({ label: "Créer l'intervention", action: 'intervention.creer', variant: 'primary' }),
    }, [
      ui.field({ kind: 'text', name: 'titre', label: 'Titre', value: s.titre, placeholder: 'Remplacement de chaudière', required: true, max: 120 }),
      ui.field({ kind: 'text', name: 'client', label: 'Client', value: s.client, placeholder: 'Dupont & fils', required: true }),
      ui.field({ kind: 'date', name: 'prevueLe', label: 'Prévue le', value: s.prevueLe || null, required: true }),
      ui.field({ kind: 'money', name: 'montant', label: 'Montant HT', value: s.montant === '' ? null : s.montant, min: 0, hint: 'En euros, laissez vide si non chiffré.' }),
      ui.field({ kind: 'textarea', name: 'notes', label: 'Notes', value: s.notes, rows: 3, placeholder: 'Accès, matériel, interlocuteur…' }),
    ]),
  ]);
}

export default defineApp({
  pages: {
    interventions: async ({ ctx }) => {
      const { count } = await ctx.data.interventions.count();
      return ui.doc(ui.stack({ gap: 'lg' }, [
        ui.pageHeader({
          title: 'Interventions',
          subtitle: count === 0 ? "Aucune intervention pour l'instant." : count.toLocaleString('fr-FR') + ' intervention' + (count > 1 ? 's' : '') + '.',
          actions: [ui.button({ label: 'Créer une intervention', action: 'intervention.ouvrir', icon: 'plus', variant: 'primary' })],
        }),
      ]), { title: 'Interventions' });
    },
  },
  forms: {
    /** La fenêtre : vide à l'ouverture. */
    intervention: async () => formulaire(VIDE, []),
  },
  actions: {
    'intervention.ouvrir': async () => ({ openForm: { key: 'intervention', title: 'Nouvelle intervention' } }),
    'intervention.creer': async ({ ctx, form }) => {
      const s = saisieDe(form);
      const aujourdhui = new Intl.DateTimeFormat('en-CA', { timeZone: ctx.tenant.timezone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(ctx.now());
      const problemes = erreurs(s, aujourdhui);
      // Refus : le formulaire revient, valeurs gardées, corrections en tête (annoncées aux lecteurs d'écran).
      if (problemes.length > 0) return { ui: formulaire(s, problemes) };
      await ctx.data.interventions.set({
        titre: s.titre,
        client: s.client,
        statut: 'planifiee',
        prevueLe: s.prevueLe,
        montantCents: s.montant === '' ? null : Math.round(s.montant * 100), // saisie en euros, stockage en centimes
        notes: s.notes || null,
      });
      return { ...toast('Intervention créée.', 'success'), close: true };
    },
  },
});
```

- Le champ `money` renvoie des EUROS (`12.5`) ; la recette stocke des
  centimes, comme les façades.
- Le message d'erreur du navigateur est générique (« Obligatoire », « Format
  invalide ») : l'exemple attendu va dans `placeholder`, la consigne dans
  `hint`.
- `close: true` ferme la fenêtre et redessine la page qui l'a ouverte (son
  compteur se met à jour).
- Pas de bouton « Annuler » dans le formulaire : la fenêtre a sa croix de
  fermeture. Ailleurs, `form.cancel` déclenche son action sans valider ni
  envoyer les champs.

## Recette 5 — Panneau de réglages

Deux sortes de réglages, deux endroits :

- **Ceux de l'organisation** (une ville par défaut, une clé d'API, une unité)
  se déclarent au manifeste (`settings.fields`) : un administrateur les
  saisit dans Modules › Apps › votre app › Réglages, les secrets y sont
  chiffrés, et votre code les lit dans `ctx.install.config`. Ne les
  redessinez pas dans votre page.
- **Ceux de chaque personne** (sa vue préférée, un affichage compact) se
  dessinent dans votre page et se rangent sous `pref:<viewer.userId>` (cf.
  recette 2 pour les limites).

```json
{
  "main": "src/index.ts",
  "capabilities": ["ui.menu", "storage.tenant.ro", "storage.tenant.rw"],
  "settings": {
    "fields": [
      { "key": "villeParDefaut", "label": "Ville par défaut", "type": "text", "placeholder": "Paris" },
      { "key": "unite", "label": "Unité de température", "type": "select", "options": [{ "value": "c", "label": "Celsius" }, { "value": "f", "label": "Fahrenheit" }] }
    ]
  },
  "contributes": {
    "menuEntries": [{ "key": "reglages", "label": "Réglages", "view": "reglages", "icon": "settings" }]
  }
}
```

```ts
import { defineApp, ui, toast, type AppContext, type KitNode } from '@capibara-dev/sdk';

interface Prefs { vue: 'liste' | 'cartes'; compact: boolean }
const DEFAUT: Prefs = { vue: 'liste', compact: false };

async function lirePrefs(ctx: AppContext): Promise<Prefs> {
  if (!ctx.viewer) return DEFAUT;
  const v = (await ctx.kv.get('pref:' + ctx.viewer.userId))?.value as Partial<Prefs> | undefined;
  return { vue: v?.vue === 'cartes' ? 'cartes' : 'liste', compact: v?.compact === true };
}

/** Les réglages de l'organisation : lus, jamais modifiés ici (c'est l'affaire d'un administrateur). */
function reglagesOrganisation(ctx: AppContext): KitNode {
  const ville = typeof ctx.install.config.villeParDefaut === 'string' && ctx.install.config.villeParDefaut ? ctx.install.config.villeParDefaut : 'non renseignée';
  const unite = ctx.install.config.unite === 'f' ? 'Fahrenheit' : 'Celsius';
  return ui.card({ title: "Réglages de l'organisation", subtitle: 'Un administrateur les change dans Modules › Apps › cette app › Réglages.' }, [
    ui.table({
      columns: [{ key: 'reglage', label: 'Réglage' }, { key: 'valeur', label: 'Valeur' }],
      rows: [
        { id: 'ville', cells: { reglage: 'Ville par défaut', valeur: ville } },
        { id: 'unite', cells: { reglage: 'Unité de température', valeur: unite } },
      ],
    }),
  ]);
}

function preferences(p: Prefs): KitNode {
  return ui.card({ title: 'Vos préférences', subtitle: 'Elles ne valent que pour vous.' }, [
    ui.form({ name: 'preferences', submit: ui.button({ label: 'Enregistrer', action: 'preferences.enregistrer', variant: 'primary' }) }, [
      ui.field({ kind: 'select', name: 'vue', label: 'Affichage', value: p.vue, options: [{ value: 'liste', label: 'En liste' }, { value: 'cartes', label: 'En cartes' }] }),
      ui.field({ kind: 'toggle', name: 'compact', label: 'Affichage compact', value: p.compact, hint: 'Moins d’espace entre les lignes.' }),
    ]),
  ]);
}

export default defineApp({
  pages: {
    reglages: async ({ ctx }) => ui.doc(ui.stack({ gap: 'lg' }, [
      ui.pageHeader({ title: 'Réglages', subtitle: 'Vos préférences et celles de votre organisation.' }),
      preferences(await lirePrefs(ctx)),
      reglagesOrganisation(ctx),
    ]), { title: 'Réglages' }),
  },
  actions: {
    'preferences.enregistrer': async ({ ctx, form }) => {
      if (!ctx.viewer) return toast('Préférences réservées à une personne connectée.', 'warning');
      const f = form ?? {};
      const p: Prefs = { vue: f.vue === 'cartes' ? 'cartes' : 'liste', compact: f.compact === true };
      await ctx.kv.put('pref:' + ctx.viewer.userId, p);
      return { ...toast('Préférences enregistrées.', 'success'), refresh: true };
    },
  },
});
```

## Recette 6 — Chargement, vide, erreur

- **Le chargement** est dessiné par Capibara : « Chargement de l'app… »
  pendant votre rendu, puis l'écran, ou « L'app n'a pas répondu » avec la
  raison. Pendant une action, les boutons de la surface sont désactivés. Votre
  code ne dessine donc jamais de « chargement » : il garde son rendu rapide
  (lectures en parallèle, quelques façades), sous la seconde si possible, 15 s
  au plus.
- **Le vide** se dit avec `empty` : un titre qui constate, une phrase qui
  explique, une action qui débloque. Un vide n'est dit que sur une liste
  vraiment vide — jamais quand la lecture a échoué.
- **L'erreur** se dit avec `note` (`warning` si l'on peut réessayer,
  `error` si une action a échoué), en mots simples, avec « Réessayer » ; le
  détail technique va au journal (`ctx.log`), jamais à l'écran.

```json
{
  "main": "src/index.ts",
  "permissions": ["shop.orders:read"],
  "capabilities": ["ui.widget"],
  "contributes": {
    "widgets": [{ "key": "a-expedier", "title": "À expédier", "kind": "kit" }]
  }
}
```

```ts
import { defineApp, ui, type AppContext, type KitNode, type ShopOrdersListOutput } from '@capibara-dev/sdk';

/** Trois états, une seule fonction : la lecture a échoué, il n'y a rien, ou voici la donnée. */
async function commandesAExpedier(ctx: AppContext): Promise<KitNode> {
  let commandes: ShopOrdersListOutput['items'];
  try {
    commandes = (await ctx.capibara.shop.orders.list({ status: 'PAID', take: 5 })).items;
  } catch (e) {
    ctx.log.warn('commandes illisibles', String((e as Error)?.message ?? e));
    return ui.stack({ gap: 'sm' }, [
      ui.note({ tone: 'warning', text: 'Les commandes ne sont pas lisibles pour le moment.' }),
      ui.button({ label: 'Réessayer', action: 'actualiser', icon: 'refresh-cw', size: 'sm' }),
    ]);
  }
  if (commandes.length === 0) {
    return ui.empty({ title: 'Rien à expédier', description: 'Les commandes payées apparaîtront ici.', icon: 'package-check' });
  }
  return ui.list({
    items: commandes.map((c) => ({
      id: c.id,
      title: 'Commande ' + c.number,
      // Une commande porte sa devise : jamais de conversion implicite.
      subtitle: (c.customerName ?? c.customerEmail) + ' · ' + new Intl.NumberFormat('fr-FR', { style: 'currency', currency: c.currency }).format(c.totalCents / 100),
      badge: { text: 'Payée', tone: 'plume' },
    })),
  });
}

export default defineApp({
  widgets: {
    'a-expedier': async ({ ctx }) => commandesAExpedier(ctx),
  },
  actions: {
    actualiser: async () => ({ refresh: true }),
  },
});
```

## Recette 7 — Un événement : compte à rebours, horloges, « En direct »

« Salon » : avant l'ouverture, le temps qui reste ; pendant le salon, un « En
direct » qui respire ; toujours, l'heure des trois bureaux de l'équipe. Le
widget ne lit rien : les dates sont des constantes de l'app, et c'est
Capibara qui fait avancer le temps — un rendu suffit.

```json
{
  "main": "src/index.ts",
  "capabilities": ["ui.widget"],
  "contributes": {
    "widgets": [{ "key": "salon", "title": "Salon", "kind": "kit", "minHeight": 280 }]
  }
}
```

```ts
import { defineApp, ui, type AppContext, type KitClockProps, type KitNode } from '@capibara-dev/sdk';

/** Le salon : des dates ISO 8601 AVEC leur fuseau (une date sans fuseau est refusée). */
const SALON = { nom: "Salon de l'artisanat", ouverture: '2026-11-14T09:00:00+01:00', fermeture: '2026-11-16T18:00:00+01:00' };

/** Une horloge par bureau ; sans `timezone`, l'heure de l'organisation. */
const BUREAUX: KitClockProps[] = [
  { label: 'Paris' },
  { label: 'Montréal', timezone: 'America/Montreal' },
  { label: 'Dakar', timezone: 'Africa/Dakar' },
];

/** Avant : le compte à rebours ; pendant : « En direct » ; après : une phrase. Une seule animation à l'écran. */
function moment(ctx: AppContext): KitNode {
  const maintenant = ctx.now().getTime();
  if (maintenant < Date.parse(SALON.ouverture)) {
    return ui.countdown({ to: SALON.ouverture, label: 'Ouverture', endText: 'Le salon est ouvert', size: 'md' });
  }
  if (maintenant < Date.parse(SALON.fermeture)) {
    return ui.text({ markdown: '**En direct** du salon', tone: 'gorge', animate: 'pulse' });
  }
  return ui.text({ markdown: 'Le salon est terminé : merci de votre visite.', tone: 'neutral' });
}

export default defineApp({
  widgets: {
    salon: ({ ctx }) => ui.stack({ gap: 'md' }, [
      ui.heading({ text: SALON.nom, level: 3 }),
      moment(ctx),
      ui.stack({ direction: 'horizontal', gap: 'lg', wrap: true }, BUREAUX.map((b) => ui.clock(b))),
    ]),
  },
});
```

Le compte à rebours bascule tout seul sur « Le salon est ouvert » à 9 h, dans
le navigateur, sans nouveau rendu ; « En direct », lui, vient du rendu : il
paraît au prochain affichage du widget (page rechargée, retour au tableau de
bord). Un titre qui s'écrit en plus ? Ce serait une seconde animation :
gardez-la pour un écran sans « En direct ».

## Avant de déployer

- [ ] Chaque surface passe `validateKit` : vos tests rendent chaque page,
      widget, panneau et formulaire avec `renderSurface` (qui valide d'office)
      et chaque action avec `runAction` — avec des données, sans données, et
      avec une façade qui lève.
- [ ] `capibara kit check` ou le Kit Studio sur les documents écrits à la
      main (gabarits).
- [ ] Les textes : « vous », une majuscule en tête, le verbe d'abord sur les
      actions, pas d'emoji, pas de « nous », pas d'anglais (« Settings »).
- [ ] Les nombres et les dates à la française, dans `ctx.tenant.timezone`,
      avec `ctx.now()`.
- [ ] Chaque liste a son état vide, chaque lecture son état d'erreur, et
      aucune erreur ne s'affiche en JSON.
- [ ] Une seule action `primary` par surface ; `confirm` sur ce qui
      supprime ; une animation par écran au plus.
- [ ] La largeur d'un téléphone : pas plus de 2 colonnes dans un widget ou un
      panneau, des libellés courts dans les tableaux (ils défilent sinon),
      `stack` horizontal avec `wrap: true`.
- [ ] Les capabilities et scopes du manifeste couvrent tout ce que le code
      appelle (`storage.tenant.rw` couvre la lecture de ce qu'on écrit), et
      rien de plus.
- [ ] `capibara dev --org <slug>` : l'app tourne sur une vraie organisation
      de test, le journal est propre.
