Documentation

Référence

Charte d'interface du Kit

Mis à jour le 1er octobre 2026Plateforme v1 Markdown brut

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) : 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, le contrat
d’exécution sur Le 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. 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.

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.

{
  "main": "src/index.ts",
  "permissions": ["billing-fr.documents:read"],
  "capabilities": ["ui.widget"],
  "contributes": {
    "widgets": [{ "key": "a-encaisser", "title": "À encaisser", "kind": "kit", "minHeight": 280 }]
  }
}
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 :

{
  "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.
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.

{
  "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" }]
  }
}
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.

{
  "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" }]
  }
}
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).
{
  "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" }]
  }
}
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.
{
  "main": "src/index.ts",
  "permissions": ["shop.orders:read"],
  "capabilities": ["ui.widget"],
  "contributes": {
    "widgets": [{ "key": "a-expedier", "title": "À expédier", "kind": "kit" }]
  }
}
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.

{
  "main": "src/index.ts",
  "capabilities": ["ui.widget"],
  "contributes": {
    "widgets": [{ "key": "salon", "title": "Salon", "kind": "kit", "minHeight": 280 }]
  }
}
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.
Charte d'interface du Kit · Capibara for Developers