10 — Les formulaires : bloc :::form et contrat d'API (form.recit.dev)
Statut : accepté · Phase : P13 · Mis à jour : 24/10/2026.
Ce document part de l'idée « form.recit.dev » de sites/plans/pages/archive/BACKLOG_IDEA.md
(référence : formdown) et la pousse jusqu'à un plan exécutable. Il suit la forme de 08-formats.md
(une spec par format) et s'appuie sur 02-architecture.md (modèle Story), 03-packaging.md
(groupes, exports), 07-domaines.md (sites) et 09-langues-et-json.md (JSON typé, langues). Chaque
décision porte une recommandation. Les questions du §8 ont été tranchées le 24/10/2026, sans
l'utilisateur et à sa demande (décisions réversibles) : les recommandations sont retenues, et le §8
garde la trace des réponses.
Placement, ports, domaines et règles communes aux plans 10 à 14 : 06-roadmap.md, section « Backlog
: plans 10 à 14 ». Ce plan s'y conforme. En résumé : le bloc :::form et le contrat d'API sont le
cœur du plan (lot avancé en P10, puis P13 Form). Le questionnaire multi-étapes (format form) n'est
qu'une option, repoussée à plus tard.
1. L'idée en une phrase
Un formulaire s'écrit en Markdown, une seule fois, et Récit le rend partout : interactif sur le web, imprimable dans un PDF, sous forme de QR code dans une vidéo. Le contrat qui relie ce formulaire à une API externe est publié, versionné et vérifiable.
Ce que cela apporte à Récit
- « Écrire une fois, raconter partout » devient bidirectionnel. Jusqu'ici, toutes les scènes
parlent au lecteur. Un formulaire lui permet de répondre : inscription à un événement du
calendar, retour sur un item deroadmap, contact à la fin d'undeck, sondage dans unestory, questionnaire en fin dereport. - Une source, plusieurs rendus (plus tard, FO6). Le même
:::formpourra donner un formulaire HTML accessible sur le web, une version papier (cases et lignes vides, avec un QR code vers la version en ligne) dans les PDF dereportet debook, et un écran final avec QR code dansmotion. La v1 ne promet que le web. - Un contrat plutôt qu'un service. Récit reste statique (07 §5 : Vercel ne sert que du statique). Il ne stocke aucune réponse. Il fixe ce que l'API de l'auteur doit respecter (« Récit Form Submission API v1 »), le publie en OpenAPI, fournit un handler de référence sans dépendance et une commande qui vérifie un endpoint. C'est la réponse à la question du backlog : « comment le relier à une API externe, et quelle vision de ce que l'API doit respecter ? ».
- le framework de sites sert enfin son métier d'origine. Le contrat est un document OpenAPI
rendu par le plugin OpenAPI du framework de sites sur docs.recit.dev. Dans l'autre sens, un
formulaire peut être généré depuis une opération OpenAPI (
requestBody). - Même vision que le reste. Directives
remark-directivecomme::chart, schéma zod 4 dans@recit/core/schema, forme JSON typée (09), langues parid(09 §3), aucune dépendance nouvelle imposée, et la règle « aucune métrique inventée » : Récit n'affiche jamais de compteur de réponses ou de résultat de sondage qui ne viendrait pas de l'API.
2. Ce que la recherche a appris
2.1 formdown (iyulab), la référence du backlog
Sources : https://github.com/iyulab/formdown, docs/SYNTAX.md, docs/HIDDEN_FORM_ARCHITECTURE.md,
docs/EXTENSION_SYSTEM.md, https://registry.npmjs.org/@formdown/core/latest.
- Syntaxe à la Markdown qui produit des formulaires HTML via des web components Lit. Licence MIT.
Packages
@formdown/core(dépend demarkedetyaml),@formdown/ui(dépend delit),@formdown/editor. - Versions 0.x à rythme très rapide (0.19.1 → 0.24.0 entre le 16 et le 22 octobre 2026) : API instable, communauté très réduite (11 étoiles au relevé).
- Bonnes idées : champs rattachés à un
<form>caché par l'attribut HTMLform="id"(ils peuvent être n'importe où dans la page) ; conditionsvisible-if,required-if,enabled-if; champs calculéscompute; diagnostics codés avec position ; libellé dérivé du nom (first_name→ « First Name ») ; optionsvaleur=Libellé; choix « autre » libre ; groupes repliables. - Manques : aucun contrat d'API, aucune gestion de la réponse ou des erreurs serveur, aucun hook de
soumission documenté. Sans
@form, le formulaire part enGET .. - Verdict : on s'en inspire, on ne l'installe pas.
markeddoublerait unified/remark,litdoublerait React 19 (règle « une seule stack »). La syntaxe ASCII compacte (@email*: @[],T4[]) est aussi difficile à lire, à traduire et à valider pour un non-initié.
2.2 Les autres approches déclaratives
| Approche | Ce qu'on retient | Ce qu'on écarte |
|---|---|---|
GitHub issue forms (YAML, body[].type) | liste ordonnée de blocs typés, libellé séparé de la validation : le bon modèle pour la forme JSON | YAML seul, pas de Markdown autour |
| react-jsonschema-form, JSON Forms | le JSON Schema décrit les données, une couche légère décrit l'UI | deux schémas parallèles lourds, ajv dans le navigateur |
| 8fold formdown-spec, gem Ruby formdown, mdform, OneGov Formcode | des syntaxes Markdown de formulaire existent depuis longtemps | ASCII-art illisible pour un non-initié, difficile à traduire |
| Formspree | Accept: application/json, erreurs par champ | format d'erreur propriétaire ({ errors: [{ message, field? }] }) |
Sources : https://github.com/8fold/formdown-spec/blob/main/spec.md ; https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/syntax-for-issue-forms ; https://github.com/rjsf-team/react-jsonschema-form ; https://jsonforms.io ; https://help.formspree.io/hc/en-us/articles/360013470814.
2.3 Ce que le dépôt fournit déjà (vérifié dans pnpm-lock.yaml et les package.json)
| Brique | Où | Usage pour les formulaires |
|---|---|---|
remark-directive 3.0.1 | packages/core | :::form, ::field, ::consent, ::submit, sans nouvelle syntaxe |
| zod 4.6.5 (catalogue) | partout | schéma du formulaire, z.toJSONSchema pour publier *.schema.json |
@apidevtools/json-schema-ref-parser 15.5.0, @scalar/openapi-parser 0.29.12 | packages/site (pipeline OpenAPI, CLAUDE.md) | génération de champs depuis un requestBody, côté build seulement, mêmes versions |
react-hook-form 7.89.0 | packages/site (playground, clés d'API, auth) | admissible si un besoin réel apparaît, non requis en v1 (React 19 suffit) |
buildRequestBody.ts | packages/site/src/lib/plugins/openapi/playground/ | modèle à reproduire (json, multipart, urlencoded), pas à importer (site est privé) |
qrcode 1.5.4 | packages/cli, packages/formats/deck | QR code vers la version en ligne dans les rendus PDF et vidéo |
| Chromium de Playwright | recit export --to pdf | rendu imprimable via @recit/paged et son runner (@recit/paged/runner) |
z.fromJSONSchema existe dans zod 4 mais reste marqué expérimental, sans garantie d'aller-retour
(https://zod.dev/json-schema). On ne s'appuie pas dessus : la conversion OpenAPI → champs est une
table de correspondance fermée maison, exécutée au build.
2.4 Soumettre depuis un site statique
- CORS (https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS) : un POST « simple »
(
application/x-www-form-urlencoded,multipart/form-data,text/plain, en-têtes safelistés) part sans preflight.application/jsonou un en-tête personnalisé (Idempotency-Key,Authorization) déclenche unOPTIONSque l'API doit servir.Access-Control-Allow-Origin: *est interdit avec des credentials. - Un
<form action method>HTML natif n'est pas soumis à CORS (c'est une navigation) et marche sans JS, mais les erreurs par champ ne remontent pas finement. - Beaucoup de webhooks (Slack, Discord…) ne renvoient pas d'en-têtes CORS : un appel direct depuis le navigateur échoue, il faut un relais.
- Erreurs : RFC 9457 « Problem Details » (https://www.rfc-editor.org/rfc/rfc9457.html). Son exemple
§3 montre un membre
errorsavecpointer(JSON Pointer) etdetail: extension non enregistrée, à documenter comme extension Récit. Le client doit tolérer les membres inconnus (§3.2). - Idempotence :
Idempotency-Key(draft-ietf-httpapi-idempotency-key-header-07, octobre 2025, expiré en avril 2026 sans RFC trouvée). L'en-tête déclenche un preflight, donc la clé doit aussi voyager dans le corps.
2.5 Anti-spam, RGPD, accessibilité
- Honeypot : champ hors écran (pas
display:none),tabindex="-1",aria-hidden="true",autocomplete="off", nom non suggestif. Il arrête les bots basiques, pas les navigateurs headless. Sources secondaires : https://blog.openreplay.com/honeypot-fields-stop-bots/. - Cloudflare Turnstile : le widget seul ne protège rien, l'API doit appeler
siteverifyavec le secret. Jeton à usage unique, valable 300 s (https://developers.cloudflare.com/turnstile/get-started/server-side-validation/). C'est un script tiers : opt-in, déclaré dans NOTICE.md et dans la notice vie privée. - RGPD art. 13 : informer au moment de la collecte (responsable, finalités, base légale, destinataires, durée, droits, réclamation). Sources trouvées secondaires (https://donneespersonnelles.fr/formulaire-contact-rgpd, modèle CNB) : à recouper sur cnil.fr.
- Accessibilité : WCAG 3.3.1, techniques ARIA21 (
aria-invalid), ARIA19 (live region), SCR32 (https://www.w3.org/WAI/WCAG21/Understanding/error-identification). Résumé d'erreurs en tête qui reçoit le focus après un échec (modèle GOV.UK, source secondaire à vérifier).
3. Décisions proposées
| # | Sujet | Options | Recommandation |
|---|---|---|---|
| F1 | Bloc ou format ? | (a) bloc transverse seul ; (b) format seul ; (c) les deux | (a) d'abord : le bloc :::form et son contrat. Le format multi-étapes vient plus tard (tranché, question 1) |
| F2 | Syntaxe | (a) formdown ; (b) YAML à la GitHub ; (c) directives remark-directive | (c), même grammaire que ::chart et :value. Libellés entre crochets, donc traduisibles |
| F3 | Dépendance formdown | l'installer, la traduire, l'ignorer | ne pas l'installer, et pas d'import recit convert --from formdown (tranché, §8 question 9) |
| F4 | Mode de soumission par défaut | native, fetch, webhook | fetch en application/x-www-form-urlencoded (multipart/form-data s'il y a un fichier) : aucun preflight CORS. JSON en opt-in (encoding=json). Le même <form> marche en native sans JS |
| F5 | Contrat d'API | aucun, propriétaire, standard | « Récit Form Submission API v1 », erreurs en RFC 9457, publié en OpenAPI |
| F6 | Validation client | ajv, react-hook-form, natif + zod/mini | contraintes HTML natives, puis une couche zod/mini générée au build pour les règles croisées |
| F7 | OpenAPI → formulaire | z.fromJSONSchema, table maison | table fermée maison, au build ; un champ sans correspondance est une erreur avec ligne |
| F8 | Backend Récit | service hébergé, rien, handler de référence | aucun service hébergé. Un handler de référence web-standard (Request → Response) livré comme sous-chemin |
| F9 | Place dans la feuille de route | avant la 1.0, après la 1.0 | lot avancé en P10 (contrat FO0, bloc minimal FO1), puis P13 Form après la 1.0 (06, « Backlog ») |
| F10 | Package | format, fondation | fondation @recit/form (packages/form) : bloc, client, serveur. Le format multi-étapes, s'il est retenu, va dans un package de format séparé |
3.1 Syntaxe Markdown du bloc (F2)
Le bloc vit dans n'importe quelle scène, de n'importe quel format.
Markdown
| Élément | Rôle |
|---|---|
:::form{#id …} | le formulaire. id obligatoire et unique dans le projet (il nomme l'envoi et le schéma publié). Attributs : endpoint, contract (recit | openapi), submit (fetch | native | mock), encoding (form par défaut | json), method (POST par défaut), antispam (honeypot ; turnstile plus tard), auth (none | publishable-key ; bearer plus tard), privacy |
::field[Libellé]{name=… type=…} | un champ. name obligatoire, structurel, jamais traduit. type par défaut text. Attributs HTML repris tels quels : required, min, max, minlength, maxlength, pattern, step, autocomplete, placeholder, rows, accept, max-size, value (préremplissage) |
options="valeur=Libellé,…" | options de select, radio, checkbox-group. La valeur est structurelle, le libellé est traduisible. other="Libellé" ajoute un choix libre |
visible-if, required-if, enabled-if | conditions. Opérateurs fermés : =, !=, nom seul (vrai si rempli), !nom. Un champ masqué n'est pas envoyé (contrairement à formdown, par minimisation) |
help="…" | aide reliée par aria-describedby |
::consent[Texte]{name version} | case de consentement jamais pré-cochée. version est envoyée dans meta.consent |
::submit[Libellé] | bouton d'envoi. Absent : un bouton par défaut traduit par le pack d'interface |
:::success, :::error | contenus Markdown affichés après la réponse. Le message renvoyé par l'API les remplace s'il est présent |
:::fieldset[Légende] | regroupement (<fieldset>/<legend>) |
::field{…} hors de :::form avec form=id | champ rattaché à un formulaire d'une autre partie de la scène (idée formdown, attribut HTML form) |
Clés de frontmatter : forms: donne les valeurs par défaut des blocs :::form d'une story (et
forms dans recit.config.ts). form: reste réservé à la section du format multi-étapes, s'il est
retenu (§3.2). On sépare ainsi le transverse et le format, comme terms et glossary dans le
plan 12.
Types v1 : text, email, tel, url, number, date, textarea, select, radio,
checkbox, checkbox-group, file, hidden. Plus tard : range, time, datetime-local,
compute (champ calculé).
Variante OpenAPI (F7, plus tard : FO4) : les champs sont générés au build depuis le requestBody de
l'opération. Un ::field{name=…} explicite surcharge le libellé, l'ordre, l'aide ou masque le champ
(hidden).
Markdown
Règles :
idde formulaire en double,nameen double,visible-ifvers un nom inconnu, option sans valeur,typeinconnu : erreur avec numéro de ligne (signup.md:14 …), jamais de devinette ;contract=openapisansoperation, opération introuvable, schéma qui contient une forme absente de la table §3.4 : erreur avec ligne ;- formulaire qui collecte
email,telou un nom sans::consentniprivacy: avertissement (erreur en--strict) ; - champ non requis sans
help: avertissement de minimisation (« ce champ est-il nécessaire ? ») ; endpointabsent etsubmitdifférent demock: erreur au build (pas deGET .implicite) ;- toute valeur qui ressemble à un secret dans les attributs ou la config (clé privée, jeton
sk_…,Bearer …) : erreur, sans exception.
3.2 Option, plus tard : le format form (questionnaire multi-étapes)
Cette section décrit une option que le backlog ne demande pas. Elle n'entre ni dans le lot P10 ni
dans P13 : elle vient plus tard, sur un besoin réel (question 1 tranchée). Elle vivra dans un
package de format séparé de la fondation @recit/form, et form rejoint StoryFormat comme ajout
compatible en v1 (règle de versionnement de 06, « Backlog »).
Promesse. Un questionnaire à la Typeform s'écrit comme un deck : une scène = une étape (une ou quelques questions), avec progression, navigation au clavier, récapitulatif avant envoi et branches fermées.
Markdown
| Élément | Rôle |
|---|---|
section form: | le formulaire unique du récit (même attributs que :::form) |
--- id=… | une étape. Les ::field d'une étape sont validés avant de passer à la suivante |
when="…" | branche : l'étape n'est jouée que si la condition est vraie. Mêmes opérateurs fermés que visible-if |
end | écran de fin (succès). Une seule par récit. Pas type=end : type= est réservé aux blocs motion |
review: true | étape de récapitulatif générée, avec liens « modifier » vers chaque étape |
Règles : when vers un nom défini dans une étape ultérieure ou inconnu : erreur avec ligne ;
étape sans champ ni texte : erreur ; plus d'un end : erreur. Une étape dont la densité dépasse les
seuils de densité existants (Scene density) : avertissement.
3.3 Schéma JSON typé (@recit/core/schema)
Deux schémas, sur le modèle de 09-langues-et-json.md §2 :
FormBlock: la forme d'un:::form. Règle commune des plans 10 à 14 (06, « Backlog ») : en v1, un bloc transverse reste une directive dans lebodyMarkdown de la scène, sans nouveau champ dansSceneSource.FormBlockvalide les attributs de la directive et décrit le fichier publié/forms/<id>.json. Un champ typéblocks?serait un ajout compatible plus tard.FormSource(seulement si le format multi-étapes est retenu) : la varianteformat: "form"de l'unionStorySourceSchema. Une scène JSON contientid,when?,end?,body?etfields. L'exemple JSON ci-dessous illustre cette option.
JSON
fieldsest une liste ordonnée (modèle des GitHub issue forms).label,help,placeholder, les libellés d'options,successeterrorsontLocalized.name,type,valuedes options,required, bornes,pattern,endpoint,versionne le sont jamais (règle 09 §2.3).- Deux sorties publiées par formulaire au build, à côté de la page :
/forms/<id>.schema.json(JSON Schema des données envoyées, parz.toJSONSchema, pour que l'API valide avec le même schéma) et/forms/<id>.json(la définition du formulaire). - Équivalence (09 §2.4) :
compile(md) == compile(toJson(md))testée pour chaque exemple de formulaire. - Ligne à ajouter dans 09 §2.3, seulement si le format est retenu :
| form | id, when?, end?, fields[] (name, type, label, options, contraintes), consent? | label, help, libellés d'options, success |.
3.4 OpenAPI → champs : table fermée (F7, plus tard)
@recit/form/openapi (Node, build seulement) bundle la spec avec
@apidevtools/json-schema-ref-parser, la passe en OAS 3.1 avec @scalar/openapi-parser (mêmes
versions que packages/site, aucun doublon), lit paths[p][m].requestBody.content[ct].schema, puis
applique :
| JSON Schema | Champ Récit |
|---|---|
string + format: email / uri / date | email / url / date |
string + maxLength > seuil form.textareaAt | textarea |
string, minLength, maxLength, pattern | text avec les attributs HTML équivalents |
enum (≤ form.radioMax valeurs, sinon select) | radio ou select |
boolean | checkbox |
integer / number + minimum / maximum | number avec min / max / step |
array d'enum | checkbox-group |
contentMediaType ou format: binary | file (envoi en multipart) |
description | help |
title | libellé, sinon libellé dérivé du nom |
x-recit-* | indices d'UI (ordre, masquage, autocomplete) |
objet imbriqué, oneOf, anyOf hors null, array d'objets | erreur avec ligne en v1 (pas de devinette) |
Les seuils textareaAt et radioMax sont des options de config sans valeur imposée par ce plan :
leur défaut sera choisi à l'implémentation et documenté.
3.5 Le formulaire dans chaque format (« raconter partout »)
En P10 et P13, seuls docs et les pages MDX rendent le bloc. Les autres lignes du tableau décrivent
l'option FO6 (plus tard), y compris register= et feedback=.
| Format | Rendu du :::form | Pertinence |
|---|---|---|
docs | formulaire interactif dans la page. Variante OpenAPI et auth=bearer via @recit/site-stories (§4.1) | contact, demande d'accès, ticket |
deck | formulaire interactif dans la slide ; en console présentateur, un QR code vers la slide publiée. Export PDF : version imprimable | appel à l'action en fin de talk |
report | web : interactif ; PDF via @recit/paged : version imprimable (lignes, cases vides, consentement) + QR code + URL | questionnaire de fin de rapport |
book | PDF et EPUB : version imprimable + QR code + URL (pas de script dans l'EPUB) | rare |
stories | une carte = un formulaire court. Au-delà des seuils de densité : avertissement, et l'auteur passe au format form | carte finale « s'inscrire » |
motion | écran final : titre du formulaire, QR code, URL. Aucun champ | appel à l'action en fin de vidéo |
toon | lien après la dernière case | rare |
calendar | register=#signup sur un événement : bouton « S'inscrire » qui ouvre le formulaire, meta.story = id de l'événement | inscription, pont naturel |
roadmap | feedback=#idea sur un item : formulaire de retour pré-rempli avec l'id de l'item | retour produit |
form | questionnaire multi-étapes (§3.2) | sondage, candidature |
Ponts à ajouter dans le tableau de 08 §0 (correspondances fermées seulement) :
| De → vers | Correspondance |
|---|---|
| form → deck | une étape → une slide, le formulaire reste un seul <form> réparti |
| form → report (PDF) | tout le questionnaire en version imprimable, branches when annotées en clair |
| form → stories | une étape → une carte, si chaque étape respecte les seuils de densité |
| tous → motion | :::form → écran final QR code + URL |
Le QR code réutilise qrcode 1.5.4, déjà dépendance de packages/cli et packages/formats/deck.
3.6 Langues
- Libellés, aides, options, consentement,
success,errorpassent par les fichiers*.fr.md/*.fr.jsonavec les mêmesidet les mêmesname(09 §3). Unnameou une valeur d'option qui diffère entre langues : erreur de parité (recit i18n check). - Les messages de validation du client viennent des packs d'interface
en/fr(09 §4), indexés par lecoded'erreur (aligné sur les mots-clés JSON Schema :required,format,maxLength,enum…). Ledetailde l'API, s'il est présent, est affiché tel quel. - La langue courante est envoyée (
localede l'enveloppe), pour que l'API réponde dans la bonne langue. - Dates et nombres :
Intlpour l'affichage, valeurs envoyées en format neutre (ISO 8601, nombre JSON). - Ligne à ajouter dans le tableau 09 §5 :
| form | libellés et messages traduits par id ; name et valeurs jamais traduits ; locale envoyée à l'API ; RTL via propriétés logiques |.
4. Le contrat d'API, les packages, le site
4.1 « Récit Form Submission API » v1 (F5)
Publié en OpenAPI 3.1 sur https://recit.dev/schema/v1/form-submission.openapi.json, rendu par le
plugin OpenAPI du framework de sites sur docs.recit.dev/api/form-submission (section API de la
doc, 07 §2).
Requête POST <endpoint>
Content-Type:application/x-www-form-urlencoded(DOIT, défaut : requête « simple », sans preflight, identique enfetchet ennative) ;multipart/form-data(DOIT s'il y a un champfile) ;application/json(DEVRAIT, seulement avecencoding=json, qui déclenche un preflight).Accept: application/json, application/problem+json.- Aucun en-tête personnalisé par défaut, pour rester sans preflight.
Idempotency-Keyn'est envoyé qu'enencoding=json.
Corps par défaut (encoding=form, contract=recit) : les champs du formulaire à plat, plus des
champs réservés préfixés _recit. pour le méta : _recit.form, _recit.version, _recit.locale,
_recit.submission (clé d'idempotence), _recit.story, _recit.elapsed, _recit.consent (version
du texte accepté), plus le champ honeypot. Un champ d'auteur ne peut pas commencer par _recit.
(erreur avec ligne).
Corps JSON (encoding=json, opt-in) : une enveloppe qui sépare données et méta.
JSON
data(ou les champs à plat) respecte/forms/<id>.schema.json.contract=openapi(plus tard, FO4) : le corps est exactement lerequestBodyde l'opération, à plat ; le méta part dans les en-têtesRecit-Form,Recit-Locale,Recit-Submission-Id(preflight assumé).
Réponses
| Statut | Sens | Corps |
|---|---|---|
| 200, 201, 202 | accepté | application/json : { "id"?, "message"? (déjà localisé), "redirect"? } |
| 303 | accepté, mode native | Location vers la page de succès |
| 400 | JSON mal formé | application/problem+json |
| 401, 403 | auth | application/problem+json |
| 409 | même Idempotency-Key, corps différent | application/problem+json |
| 413, 415 | trop gros, type refusé | application/problem+json |
| 422 | validation | RFC 9457 + errors[] : { "type": "https://recit.dev/problems/validation", "title", "status": 422, "detail"?, "errors": [{ "pointer": "#/data/email", "code": "format", "detail"? }] } |
| 429 | trop de requêtes | Retry-After obligatoire |
| 5xx | erreur serveur | application/problem+json générique |
Règles du contrat (ce que l'API DOIT respecter)
- Valider côté serveur avec le schéma publié. La validation du client n'est qu'une aide.
- Rejouer la même réponse pour le même
_recit.submission(oumeta.submissionId, ouIdempotency-Keyen JSON). - Accepter
application/x-www-form-urlencodedetmultipart/form-data, et poserAccess-Control-Allow-Origin(liste des origines du site) sur chaque réponse : sans lui, le navigateur reçoit l'envoi mais le client ne peut pas lire les erreurs, et se replie sur le modenative. ServirOPTIONS(Allow-Methods: POST,Allow-Headers: Content-Type, Idempotency-Key, Authorization,Expose-Headers: Location, Retry-After) n'est exigé que pourencoding=jsonou une auth par en-tête. Jamais*avec credentials. - Rejeter un honeypot rempli en silence (2xx sans traitement) pour ne pas renseigner le bot.
- (Plus tard) Vérifier Turnstile côté serveur (
siteverify) siantispam=turnstile. - Ne jamais renvoyer de donnée personnelle d'un autre envoi.
- Le client tolère les membres inconnus ; l'API peut en ajouter.
Auth (auth=) : none (origine filtrée + anti-spam) ; publishable-key (clé publique non
secrète, restreinte à l'origine, envoyée dans le corps pour rester sans preflight). Plus tard
(question 7, tranchée : hors v1) : bearer (jeton de l'utilisateur connecté au site le framework de
sites, fourni par @recit/site-stories via un tokenProvider injecté, jamais lu par @recit/form
lui-même). Jamais de secret dans le bundle.
Les webhooks sans en-têtes CORS (Slack, Discord…) restent hors d'atteinte d'un appel direct, quel que soit l'encodage : la recette de relais (FO3.4) reste la réponse.
Adaptateurs (plus tard, §6 FO6) : contract=formspree et autres services, par simple traduction
d'enveloppe et d'erreurs, sans SDK.
4.2 Packages
| Package | Groupe | Statut | Publié | Contenu | Dépendances notables |
|---|---|---|---|---|---|
@recit/form | fondations (packages/form) | 📅 P10 | oui | plugin remark des directives (enregistrées dans le registre de directives du cœur), composant React 19 (<form action>, useActionState, useFormStatus), client de soumission (native / mock en P10, fetch en P13, mapping problem+json → champs, idempotence). Plus tard : vue imprimable (imprimée par la CLI, @recit/form n'importe pas @recit/paged), écran QR pour motion | @recit/core, @recit/components, zod/mini côté navigateur. qrcode (déjà présent) seulement avec FO6 |
@recit/form/openapi | sous-chemin (Node) | plus tard | oui | OpenAPI → champs (table §3.4) | @apidevtools/json-schema-ref-parser, @scalar/openapi-parser aux versions de packages/site (à mettre au catalogue) |
@recit/form/server | sous-chemin (runtime web) | 📅 P13 | oui | handler de référence createFormHandler({ forms, onSubmit, origins }) : (req: Request) => Promise<Response>. Construit le schéma zod depuis la définition du formulaire, valide, répond en problem+json, gère CORS, honeypot, idempotence en mémoire (interface de stockage injectable). Aucun stockage des réponses : onSubmit appartient à l'intégrateur | zod seulement |
@recit/core/schema | fondations | 📅 P10 | oui | FormBlock, FieldSchema. FormSource et "form" dans StoryFormat seulement si le format est retenu | zod |
Justifications :
- Une fondation, deux sous-chemins (00 §5.1 : une raison d'être installé seul). Règle commune
des plans 10 à 14 (06, « Backlog ») : le cœur ne garde qu'un registre des noms de directives,
et le plugin de chaque bloc transverse vit dans la fondation qui porte ce bloc (
@recit/form,@recit/geo,@recit/scenario). Les formats ne dépendent donc pas les uns des autres : un format qui rencontre:::formsans@recit/forminstallé produit une erreur claire « installez @recit/form ». Le format multi-étapes, s'il est retenu, vivra danspackages/formats/et dépendra de cette fondation. - Aucune dépendance nouvelle : zod,
qrcodeet les deux parseurs OpenAPI sont déjà dans le lockfile.react-hook-formn'est pas ajouté en v1. - Le handler web-standard tourne tel quel sur Node 22 (via un petit adaptateur
node:httpdans l'exemple), Cloudflare Workers, une fonction Vercel ou une passerelle d'API, sans framework (pas de Hono ni d'Express ajouté). - Aucun patch du cœur du framework de sites. L'intégration
bearerpasse par@recit/site-stories. - Tags nx :
scope:foundation. CLI :@recit/cligagnerecit form check-endpoint(P13) etrecit form schema(plus tard, §6). - À reporter :
@recit/formdans03-packaging.md§1 (glob des fondations) et §6, nom npm à réserver,README.md(« Règles du cœur ») règle 2, et la ligne Récit de NOTICE.md (packages/{…,form}).
4.3 Site form.recit.dev
| Domaine | Rôle | Dossier | Contenu | Phase |
|---|---|---|---|---|
| form.recit.dev | site d'outil : bloc :::form et contrat | sites/form | hero = un formulaire jouable écrit en :::form dans une page (mode mock, bannière « démo, rien n'est envoyé »), source dépliable, galerie, contrat rendu par le framework de sites | P13 |
- Catégorie « site d'outil » de
07-domaines.md§1 (comme ai.recit.dev) tant qu'aucun formatformn'existe. Pas d'alias :formest à la fois le mot du backlog et le nom technique. - Vercel : projet
recit-form, racinesites/form, buildnx run form:build, statique seulement. - Port local : 3011, d'après la table d'attribution unique de
07-domaines.md§4 (components 3010, form 3011, directory 3012, maps 3013, ai 3014). - Composants site-kit :
| Composant | Rôle | Exemple sur form.recit.dev |
|---|---|---|
FormatHero | démo jouable + source dépliable | questionnaire Lorem en 4 étapes, mode mock |
SourceToggle | rendu / Markdown / JSON | même questionnaire en .md et en .json |
LocaleDemo | bascule en/fr | libellés et messages d'erreur traduits, name inchangés |
ExportPanel | exports (composant unique de @recit/site-kit, lit exports.json) | *.schema.json, définition .json : variante kind: "json" (origine block, form) de ExportManifestSchema (@recit/core), ajoutée en FO7 ; PDF imprimable plus tard (FO6) |
BridgeCard | ponts (plus tard, FO6) | le même formulaire en slide, en carte de story, en écran final vidéo |
ComponentShowcase | composants | Form, Field, Consent, ErrorSummary |
TemplateCard | gabarits | npm create recit -- --template form-contact |
DocsLinks | renvoi vers la doc (règle anti-doublon) | docs.recit.dev/components/form et docs.recit.dev/api/form-submission |
- Démo : uniquement
mock(succès, 422, 429, réseau coupé, sélectionnables), pour qu'aucune donnée ne parte de form.recit.dev. Pas de vrai endpoint de démo (tranché, §8 question 4). - Page vie privée du site : rappelle que la démo n'envoie rien.
4.4 Exemples et e2e
| Exemple | Contenu (Lorem Ipsum, en et fr) | Phase |
|---|---|---|
examples/form-contact | bloc :::form dans une page docs, consentement, honeypot, mode mock | lot P10 |
examples/form-api-node | serveur de référence : @recit/form/server + adaptateur node:http, stockage en mémoire, sans dépendance | P13 |
examples/form-survey | format form multi-étapes, branches when, récapitulatif, export PDF imprimable | plus tard |
examples/form-openapi | formulaire généré depuis lorem.yaml (spec Lorem), surcharges ::field | plus tard |
Nommage : form est un bloc, pas un format. La règle commune (06, « Backlog ») autorise
examples/<bloc ou outil>-* pour un bloc transverse. Un formulaire réel pour le gabarit
lebrocoli.fr (inscription aux événements) serait une section optionnelle du dossier unique
examples/scenario-lebrocoli porté par le plan 14, jamais un second dossier.
e2e/form.e2e.ts: captures (vide, erreurs client, 422 serveur mappé sur les champs, succès, mobile, sombre, fr), navigation au clavier, focus sur le résumé d'erreurs, axe sans violation, rejouabilité (double clic = un seul envoi), brancheswhen.- Test d'intégration :
recit form check-endpointcontreexamples/form-api-nodelancé en CI. - Aucun réseau externe en CI : endpoints
mockou serveur local.
5. Sécurité, vie privée, licences
5.1 Sécurité
- La sécurité est côté API. La doc et le contrat le disent noir sur blanc : le client n'empêche rien, il aide.
- Aucun secret dans un site statique : lint bloquant sur les attributs, la config et les variables d'environnement exposées au client.
- CSP : si le site déclare une CSP,
connect-src(modefetch) etform-action(modenative) doivent inclure l'origine de l'endpoint.recit buildliste les endpoints de tous les formulaires pour aider à l'écrire. - Anti-spam par défaut sans tiers : honeypot + piège temporel (
meta.elapsedMs, le seuil reste côté API) + limitation de débit côté API (429). Turnstile seulement en opt-in. - Fichiers :
acceptetmax-sizevérifiés côté client à titre indicatif, et côté serveur (413, 415). Aucun téléversement présigné en v1. - Idempotence : clé UUID par tentative logique, réutilisée sur les nouvelles tentatives, dans
l'en-tête et dans
meta.submissionId. - Pas d'
innerHTML:messageetdetailde l'API sont affichés en texte.
5.2 Vie privée (RGPD)
::consentjamais pré-coché, version du texte envoyée.- Mention courte près du bouton d'envoi + lien
privacyvers la politique complète (art. 13, en cascade). Le lint signale un formulaire à données personnelles sans les deux. - Minimisation : avertissement sur les champs non requis sans justification ; un champ masqué par condition n'est pas envoyé.
- Aucun appel tiers sans action de l'utilisateur ; aucun script tiers par défaut.
- Aucun stockage local des réponses : pas de brouillons (
draft: localabandonné le 24/10, §8 question 8). - Récit ne stocke rien et n'héberge aucun endpoint : la conservation, les droits d'accès et d'effacement relèvent de l'intégrateur, ce que la doc du contrat rappelle.
- À recouper sur cnil.fr avant d'écrire les gabarits de mention (les sources trouvées sont secondaires).
5.3 Accessibilité
- Libellés toujours visibles (pas de placeholder seul),
autocompleterenseigné, marqueur « requis » textuel. - Erreurs :
aria-invalid+aria-describedbypar champ, résumé d'erreurs en tête avec liens, focus déplacé dessus après un échec, live region pour l'état d'envoi. - Questionnaire : progression annoncée, retour arrière sans perte, aucune limite de temps.
- Tests axe dans l'e2e.
5.4 Lignes NOTICE.md à prévoir
| Item | Used in | License / terms |
|---|---|---|
| formdown (iyulab), syntax ideas only, no code copied | @recit/form (design reference) | MIT. Credited as prior art |
@apidevtools/json-schema-ref-parser | @recit/form/openapi | MIT |
@scalar/openapi-parser | @recit/form/openapi | MIT |
qrcode | @recit/form (and existing users) | MIT |
Cloudflare Turnstile (later; opt-in, loaded only with antispam=turnstile) | @recit/form | Cloudflare terms of service; third-party script that receives the visitor's IP, to be listed in the site's privacy notice |
| RFC 9457 Problem Details | form submission contract | IETF Trust, referenced (not copied) |
Licences à revérifier au moment de l'ajout (les versions du lockfile font foi). Les deux parseurs
OpenAPI et qrcode n'entrent qu'avec FO4 et FO6. Ligne « What comes from where » à étendre avec
packages/form (fondation).
6. Phases (lot avancé en P10, puis P13 Form)
Préfixe de tâche : FO, lots FO0 à FO7, sous-tâches FOn.m (convention commune de 06, « Backlog
»). Placement :
| Lot | Contenu | Phase |
|---|---|---|
| FO0, FO1 | contrat (la « vision de l'API ») et bloc minimal | lot P10 |
| FO2, FO3, FO7 | envoi fetch, « TODO API », site | P13 Form |
| FO4, FO5, FO6 | OpenAPI, format multi-étapes, autres formats | plus tard |
Nouveau package : @recit/form (fondation). Version alpha suivante à la sortie de chaque jalon
livré, selon la règle de 03 §10.
FO0 — Contrat (lot P10)
- FO0.1 — Rédiger la spec « Récit Form Submission API v1 » (corps par défaut et enveloppe JSON, réponses, CORS, auth, idempotence, anti-spam) en anglais pour docs.recit.dev. C'est la réponse à « vision de ce que l'API doit respecter ».
- FO0.2 — Écrire le document OpenAPI 3.1 du contrat, publié sur
recit.dev/schema/v1/form-submission.openapi.json, rendu par le framework de sites. - FO0.3 —
FormBlock,FieldSchemadans@recit/core/schema, JSON Schema généré parz.toJSONSchema. - FO0.4 — Exemples de réponses 201, 422 et 429 validés par des tests contre le document OpenAPI.
- FO0.5 — Écrire le « TODO API » comme checklist publiée avec le contrat : ce qu'un intégrateur doit coder (validation, CORS, idempotence, honeypot, conservation et droits RGPD).
Sortie : contrat relu par l'utilisateur ; schémas publiés ; exemples de réponses validés en test.
FO1 — Bloc :::form minimal et rendu natif (lot P10)
- FO1.1 — Plugin remark :
:::form,::field,::consent,::submit,:::fieldset,:::success,:::error. Erreurs avec numéro de ligne.form=idhors bloc : plus tard. - FO1.2 — Composant React 19 : HTML natif d'abord (fonctionne sans JS en mode
native), contraintes HTML, conditionsvisible-if/required-if. - FO1.3 — Mode
mock(succès, 422, 429, réseau) pour le dev et l'e2e. - FO1.4 — Honeypot, piège temporel,
::consent, lint de minimisation et de secrets. - FO1.5 — Accessibilité : résumé d'erreurs,
aria-*, focus, live region. - FO1.6 — Langues : libellés par
id/name, packsen/frdes messages, parité dansrecit i18n check.
Sortie : examples/form-contact (en/fr) rendu dans une page docs ; e2e verts ; axe sans violation
; compile(md) == compile(toJson(md)).
FO2 — Envoi fetch, erreurs et idempotence (P13)
- FO2.1 — Client
fetch: urlencoded par défaut (sans preflight), multipart dès qu'un champfileexiste, JSON seulement avecencoding=json. - FO2.2 — Mapping problem+json → champs (
pointer), messages localisés parcode, membres inconnus tolérés. - FO2.3 — Idempotence (en-tête +
meta.submissionId), double clic sans double envoi, respect deRetry-After. - FO2.4 —
auth=publishable-key.auth=bearer(via untokenProviderfourni par@recit/site-stories) reste plus tard (question 7).
Sortie : e2e sur un endpoint mock qui couvre tous les statuts du tableau §4.1.
FO3 — Serveur de référence et vérification d'un endpoint (P13, le « TODO API » du backlog)
Question 3 tranchée (24/10) : checklist (FO0.5) et handler de référence, sans service hébergé par
Récit. FO3 livre donc le handler (FO3.1), l'exemple (FO3.2), check-endpoint (FO3.3) et les
recettes (FO3.4).
- FO3.1 —
@recit/form/server:createFormHandlerweb-standard, validation zod, CORS, honeypot, idempotence avec stockage injectable. - FO3.2 —
examples/form-api-node: adaptateurnode:http, stockage en mémoire, README. - FO3.3 —
recit form check-endpoint <url> --form <id>: preflight, POST valide, POST invalide (422 problem+json avecpointer), rejeu de l'Idempotency-Key, 429. - FO3.4 — Recettes documentées (sans service Récit) : Cloudflare Worker, fonction Vercel, passerelle d'API, relais vers un webhook sans CORS.
Sortie : check-endpoint passe contre l'exemple en CI et échoue de façon lisible contre un serveur
non conforme (fixture).
FO4 — Génération depuis OpenAPI (plus tard)
- FO4.1 —
@recit/form/openapi: bundle, upgrade 3.1, table §3.4, erreurs avec ligne. - FO4.2 — Surcharges
::field{name=…}(libellé, ordre, aide, masquage). - FO4.3 —
contract=openapi: corps à plat, méta en en-têtesRecit-*. - FO4.4 — Publication de
/forms/<id>.schema.jsonet/forms/<id>.jsonau build ;recit form schema <id>en CLI.
Sortie : examples/form-openapi généré depuis une spec Lorem, conforme au contrat, validé par
check-endpoint contre un mock.
FO5 — Format form multi-étapes (plus tard, sur un besoin réel)
- FO5.1 — Package de format séparé, ajout de
"form"àStoryFormatcomme ajout compatible en v1 (02 §2, 09 §2.1, 09 §2.3, 09 §5). - FO5.2 — Lecteur : étapes, progression, clavier,
when, récapitulatif, écran de fin. Pas de brouillons (question 8). - FO5.3 — Composants
Form,Field,Consent,ErrorSummary,FormSteps,FormReview, utilisables en MDX via<StoryEmbed slug="survey" format="form" />.
Sortie : examples/form-survey (en/fr) jouable de bout en bout ; branches testées.
FO6 — Rendus dans les autres formats (plus tard)
- FO6.1 — Vue imprimable (report, book, deck PDF, imprimés par la CLI et
@recit/paged) avec QR code et URL. - FO6.2 — Écran final motion (QR + URL), carte stories, bouton
register=decalendar,feedback=deroadmap. - FO6.3 — Ponts form → deck, form → report (PDF), form → stories.
Sortie : la même source examples/form-survey produit le questionnaire web, un PDF imprimable et un
écran final vidéo ; goldens stables.
FO7 — Site et publication (P13)
- FO7.1 —
examples/form-contact(en/fr ;examples/form-api-nodevient de FO3.2) +sites/form(form.recit.dev, site d'outil, port 3011, projet Vercelrecit-form) +sites/docs› Components › Form et API › Form submission (contrat rendu par le framework de sites). - FO7.2 — Icône
brand/formats/fmt-form-*.svgdans la teinte unique, gabaritnpm create recit -- --template form-contact,describe_block("form")dans@recit/mcp. - Tests : goldens des états du formulaire, e2e clavier et axe, erreurs avec ligne pour chaque règle de §3.1, conformité au contrat, équivalence md/json.
Sortie : site en ligne ; exemples publiés en en et fr ; version alpha suivante publiée (après validation explicite de l'utilisateur, 03 §10).
Plus tard (après FO7)
- FO4, FO5 et FO6 ci-dessus.
- Turnstile en option (
antispam=turnstile, opt-in) ; la v1 s'en tient au honeypot (et à son piège temporel), sans tiers (question 6). -
auth=bearervia@recit/site-stories,form=idhors bloc. Pas de brouillons (question 8). - Téléversement présigné en deux temps.
- Champs calculés
compute, typesrange,time,datetime-local. - Adaptateurs de contrat (
contract=formspree…).
7. Risques
| Risque | Parade |
|---|---|
| Faire croire qu'un formulaire statique est sécurisé | contrat qui impose la validation serveur, doc explicite, check-endpoint |
| Secret embarqué par erreur dans le site | lint bloquant, clés publiques seulement, bearer fourni à l'exécution par @recit/site-stories |
| Fuite RGPD (champs inutiles, tiers chargés d'office, brouillons) | ::consent, lint de minimisation, tiers en opt-in déclarés, pas de brouillons |
| CORS mal configuré chez l'intégrateur | check-endpoint, mode native en repli, recette de relais |
| Dérive entre le formulaire et l'API | *.schema.json publié et versionné, version dans l'enveloppe, check-endpoint en CI |
z.fromJSONSchema expérimental | table de correspondance maison au build |
Draft Idempotency-Key expiré | clé doublée dans meta.submissionId |
Spec OpenAPI trop riche (oneOf, objets imbriqués) | erreur avec ligne en v1, surcharge manuelle, élargissement de la table plus tard |
| Formulaires longs dans une story ou une slide | seuils de densité existants, renvoi vers le format form |
| Démo de form.recit.dev prise pour un vrai service | mode mock seul, bannière « rien n'est envoyé » |
| Collision de port ou de phase avec les autres plans du backlog | table unique des ports dans 07 §4, phases dans 06 « Backlog » |
Le bloc :::form entre en conflit avec :::card, :::step | noms réservés vérifiés dans le registre de directives et d'attributs de @recit/core (06, « Backlog ») |
L'API ne pose pas Access-Control-Allow-Origin | envoi reçu mais erreurs illisibles : repli automatique sur native, check-endpoint le signale |
8. Questions ouvertes pour l'utilisateur
Les questions communes au backlog (« Pet », « le skill », « le projet basic », la mascotte,
l'exception lebrocoli.fr, les domaines, l'ordre des phases) sont regroupées une seule fois dans
06-roadmap.md, « Questions ouvertes ». Les questions propres aux formulaires ont toutes été
tranchées le 24/10/2026, sans l'utilisateur et à sa demande (décisions réversibles) :
Format multi-étapes : le blocTranché (24/10) : bloc et contrat d'abord, le format multi-étapes plus tard, sur un besoin réel.:::formet le contrat suffisent-ils, ou voulez-vous aussi un questionnaire à la Typeform (formatform, §3.2) ?Placement : contrat et bloc minimal en lot avancé de P10, envoiTranché (24/10) : oui (ordre du backlog accepté, 06 question 14).fetchet site en P13 Form (06) : d'accord ?« Créer un TODO afin de mettre en place une API » : checklist et handler de référence, ou vrai service hébergé par Récit ?Tranché (24/10) : checklist (FO0.5) et handler de référence (FO3), pas de service hébergé par Récit.form.recit.dev doit-il avoir un vrai endpoint de démo, ou seulement le modeTranché (24/10) : modemock?mockseulement.Encodage par défaut : urlencoded à plat avec champsTranché (24/10) : urlencoded par défaut avec champs_recit.*, et l'enveloppe JSON en opt-in ?_recit.*(marche sans JS, pas de preflight CORS), JSON{form, data, meta}en option (encoding=json).Anti-spam au-delà du honeypot : Turnstile ou preuve de travail auto-hébergée ?Tranché (24/10) : honeypot seul en v1 (avec son piège temporel, sans tiers) ; Turnstile plus tard, en option. Pas de preuve de travail.L'authTranché (24/10) : plus tard, hors v1.bearervia la session du framework de sites est-elle utile, et quand ?Brouillons (Tranché (24/10) : pas de brouillons.draft: local) : opt-in plus tard, ou jamais ?ImportTranché (24/10) : pas d'import formdown.recit convert --from formdown: utile, ou on ignore la syntaxe formdown ?