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 sontsecondaryoughost; une action destructrice est
danger, avecconfirm. - 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 :
minHeightdu 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 (buttonou lien) — ou unempty. - 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,
gridoustatsRowà 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 (columnsetsplits’empilent sous 768 et 1 024 px). - Au-dessus de votre contenu, Capibara affiche l’icône de l’app, le
titledu document (« Interventions » ; à défaut, le nom de l’app) et, si
l’app a plusieurs entrées de menu, un onglet par entrée. Letitlenomme
la page ; votrepageHeaderporte 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) →statsRowsi 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 avecopenForm).
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
cardou une section du site (siteSection),
un texte court, au plus un bouton ; une page de site peut commencer par une
sectionhero. - 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 boutonsubmit(« Enregistrer », « Créer… »). Pas
decancel: la fenêtre a déjà sa croix. Réponse :{ close: true }+ untoasten 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. amberpour l’attention (en attente, bientôt dû, sous le seuil),red
pour l’erreur et le danger (en retard, refusé, supprimer),neutralpour
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 dansplaceholder(un
exemple) ethint— le message d’erreur du navigateur reste générique
(« Obligatoire », « Format invalide »). - Tout bouton se nomme :
label, ouariaLabelpour un bouton-icône
(« Ouvrir la fiche », pas « Bouton »). - Toute image a un
altqui 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
pageHeaderporte le titre de la page, les
cardetsectionleurs propres titres ;headingniveau 2 ou 3
dans une carte. - Un tableau a une
captionquand son sujet n’est pas évident, et
des colonnes aux libellés explicites. - Une note
errorest 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 enlabel. Sanstimezone, l’heure de
l’organisation. Dans un widget, trois au plus, enstackhorizontal avec
wrap: true. - Le texte animé (
animatedetextetheading) 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 (unenotela 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 (rwcouvre la
lecture ;rodéclaré en plus ne change rien).link.pagevaut 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
viewersur 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: trueexige 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
moneyreçoit des centimes, la celluledateune date ISO :
le Kit les met en forme. Unkpireç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.interventionsest uneAppCollectiondeRecord<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
moneyrenvoie 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 dansplaceholder, la consigne dans
hint. close: trueferme 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.canceldé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 dansctx.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 souspref:<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(warningsi l’on peut réessayer,
errorsi 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 avecrenderSurface(qui valide d’office)
et chaque action avecrunAction— avec des données, sans données, et
avec une façade qui lève. - [ ]
capibara kit checkou 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,
avecctx.now(). - [ ] Chaque liste a son état vide, chaque lecture son état d’erreur, et
aucune erreur ne s’affiche en JSON. - [ ] Une seule action
primarypar surface ;confirmsur 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),
stackhorizontal avecwrap: true. - [ ] Les capabilities et scopes du manifeste couvrent tout ce que le code
appelle (storage.tenant.rwcouvre 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.