Nouveaux blocs et outils

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 de roadmap, contact à la fin d'un deck, sondage dans une story, questionnaire en fin de report.
  • Une source, plusieurs rendus (plus tard, FO6). Le même :::form pourra 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 de report et de book, et un écran final avec QR code dans motion. 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-directive comme ::chart, schéma zod 4 dans @recit/core/schema, forme JSON typée (09), langues par id (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 de marked et yaml), @formdown/ui (dépend de lit), @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 HTML form="id" (ils peuvent être n'importe où dans la page) ; conditions visible-if, required-if, enabled-if ; champs calculés compute ; diagnostics codés avec position ; libellé dérivé du nom (first_name → « First Name ») ; options valeur=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 en GET ..
  • Verdict : on s'en inspire, on ne l'installe pas. marked doublerait unified/remark, lit doublerait 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

ApprocheCe qu'on retientCe 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 JSONYAML seul, pas de Markdown autour
react-jsonschema-form, JSON Formsle JSON Schema décrit les données, une couche légère décrit l'UIdeux schémas parallèles lourds, ajv dans le navigateur
8fold formdown-spec, gem Ruby formdown, mdform, OneGov Formcodedes syntaxes Markdown de formulaire existent depuis longtempsASCII-art illisible pour un non-initié, difficile à traduire
FormspreeAccept: application/json, erreurs par champformat 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)

BriqueOùUsage pour les formulaires
remark-directive 3.0.1packages/core:::form, ::field, ::consent, ::submit, sans nouvelle syntaxe
zod 4.6.5 (catalogue)partoutschéma du formulaire, z.toJSONSchema pour publier *.schema.json
@apidevtools/json-schema-ref-parser 15.5.0, @scalar/openapi-parser 0.29.12packages/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.0packages/site (playground, clés d'API, auth)admissible si un besoin réel apparaît, non requis en v1 (React 19 suffit)
buildRequestBody.tspackages/site/src/lib/plugins/openapi/playground/modèle à reproduire (json, multipart, urlencoded), pas à importer (site est privé)
qrcode 1.5.4packages/cli, packages/formats/deckQR code vers la version en ligne dans les rendus PDF et vidéo
Chromium de Playwrightrecit export --to pdfrendu 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/json ou un en-tête personnalisé (Idempotency-Key, Authorization) déclenche un OPTIONS que 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 errors avec pointer (JSON Pointer) et detail : 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é

3. Décisions proposées

#SujetOptionsRecommandation
F1Bloc 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)
F2Syntaxe(a) formdown ; (b) YAML à la GitHub ; (c) directives remark-directive(c), même grammaire que ::chart et :value. Libellés entre crochets, donc traduisibles
F3Dépendance formdownl'installer, la traduire, l'ignorerne pas l'installer, et pas d'import recit convert --from formdown (tranché, §8 question 9)
F4Mode de soumission par défautnative, fetch, webhookfetch 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
F5Contrat d'APIaucun, propriétaire, standard« Récit Form Submission API v1 », erreurs en RFC 9457, publié en OpenAPI
F6Validation clientajv, react-hook-form, natif + zod/minicontraintes HTML natives, puis une couche zod/mini générée au build pour les règles croisées
F7OpenAPI → formulairez.fromJSONSchema, table maisontable fermée maison, au build ; un champ sans correspondance est une erreur avec ligne
F8Backend Récitservice hébergé, rien, handler de référenceaucun service hébergé. Un handler de référence web-standard (Request → Response) livré comme sous-chemin
F9Place dans la feuille de routeavant la 1.0, après la 1.0lot avancé en P10 (contrat FO0, bloc minimal FO1), puis P13 Form après la 1.0 (06, « Backlog »)
F10Packageformat, fondationfondation @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.

MarkdownMarkdown
--- title: Lorem Meetup format: docs lang: en forms: endpoint: https://api.example.com/v1/forms # default for every :::form of the story contract: recit privacy: /privacy --- ## Join the next meetup Lorem ipsum dolor sit amet, consectetur adipiscing elit. :::form{#signup submit=fetch antispam=honeypot} ::field[Your name]{name=name required autocomplete=name maxlength=120} ::field[Email]{name=email type=email required autocomplete=email} ::field[Attendance]{name=mode type=radio required options="onsite=On site,remote=Remote"} ::field[Company]{name=company visible-if="mode=onsite" required-if="mode=onsite"} ::field[Dietary needs]{name=diet type=checkbox-group options="veg=Vegetarian,vegan=Vegan,none=None" other="Other"} ::field[Message]{name=message type=textarea rows=4 maxlength=2000 help="Lorem ipsum dolor sit amet."} ::consent[I agree to be contacted about this meetup]{name=consent required version=2026-10} ::submit[Register] :::success Thanks, lorem ipsum. :t[signup.confirm] ::: :::error Lorem ipsum, please try again later. ::: :::
ÉlémentRô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-ifconditions. 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, :::errorcontenus 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=idchamp 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).

MarkdownMarkdown
:::form{#ticket openapi=./api/lorem.yaml operation=createTicket} ::field[Short summary]{name=title} ::field{name=internalRef hidden} :::

Règles :

  • id de formulaire en double, name en double, visible-if vers un nom inconnu, option sans valeur, type inconnu : erreur avec numéro de ligne (signup.md:14 …), jamais de devinette ;
  • contract=openapi sans operation, opération introuvable, schéma qui contient une forme absente de la table §3.4 : erreur avec ligne ;
  • formulaire qui collecte email, tel ou un nom sans ::consent ni privacy : avertissement (erreur en --strict) ;
  • champ non requis sans help : avertissement de minimisation (« ce champ est-il nécessaire ? ») ;
  • endpoint absent et submit différent de mock : erreur au build (pas de GET . 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.

MarkdownMarkdown
--- title: Lorem Survey format: form lang: en form: id: survey endpoint: https://api.example.com/v1/forms contract: recit progress: steps # steps | bar | none review: true # recap step before sending privacy: /privacy --- --- id=intro # Lorem survey Lorem ipsum dolor sit amet, consectetur adipiscing elit. --- id=profile ::field[Which best describes you?]{name=role type=radio required options="dev=Developer,design=Designer,other=Other"} --- id=tools when="role=dev" ::field[Which tools do you use?]{name=tools type=checkbox-group options="cli=CLI,ide=IDE,web=Web"} --- id=feedback ::field[Anything else?]{name=feedback type=textarea maxlength=2000} ::consent[I agree that my answers are stored for this survey]{name=consent required version=2026-10} --- id=thanks end Thanks, lorem ipsum.
ÉlémentRô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 le body Markdown de la scène, sans nouveau champ dans SceneSource. FormBlock valide 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 variante format: "form" de l'union StorySourceSchema. Une scène JSON contient id, when?, end?, body? et fields. L'exemple JSON ci-dessous illustre cette option.
JSONJSON
{ "$schema": "https://recit.dev/schema/v1/form.json", "kind": "story", "version": 1, "format": "form", "meta": { "title": { "en": "Lorem Survey", "fr": "Enquête Lorem" }, "lang": "en" }, "settings": { "form": { "id": "survey", "endpoint": "https://api.example.com/v1/forms", "contract": "recit", "progress": "steps", "review": true, "privacy": "/privacy" } }, "scenes": [ { "id": "profile", "fields": [ { "name": "role", "type": "radio", "label": { "en": "Which best describes you?", "fr": "Qu'est-ce qui vous décrit le mieux ?" }, "required": true, "options": [ { "value": "dev", "label": { "en": "Developer", "fr": "Développeur" } }, { "value": "design", "label": { "en": "Designer", "fr": "Designer" } } ] } ] }, { "id": "feedback", "fields": [ { "name": "feedback", "type": "textarea", "label": "Anything else?", "maxlength": 2000 } ], "consent": { "name": "consent", "label": "Lorem ipsum consent", "required": true, "version": "2026-10" } } ] }
  • fields est une liste ordonnée (modèle des GitHub issue forms). label, help, placeholder, les libellés d'options, success et error sont Localized. name, type, value des options, required, bornes, pattern, endpoint, version ne 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, par z.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 SchemaChamp Récit
string + format: email / uri / dateemail / url / date
string + maxLength > seuil form.textareaAttextarea
string, minLength, maxLength, patterntext avec les attributs HTML équivalents
enum (≤ form.radioMax valeurs, sinon select)radio ou select
booleancheckbox
integer / number + minimum / maximumnumber avec min / max / step
array d'enumcheckbox-group
contentMediaType ou format: binaryfile (envoi en multipart)
descriptionhelp
titlelibellé, sinon libellé dérivé du nom
x-recit-*indices d'UI (ordre, masquage, autocomplete)
objet imbriqué, oneOf, anyOf hors null, array d'objetserreur 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=.

FormatRendu du :::formPertinence
docsformulaire interactif dans la page. Variante OpenAPI et auth=bearer via @recit/site-stories (§4.1)contact, demande d'accès, ticket
deckformulaire interactif dans la slide ; en console présentateur, un QR code vers la slide publiée. Export PDF : version imprimableappel à l'action en fin de talk
reportweb : interactif ; PDF via @recit/paged : version imprimable (lignes, cases vides, consentement) + QR code + URLquestionnaire de fin de rapport
bookPDF et EPUB : version imprimable + QR code + URL (pas de script dans l'EPUB)rare
storiesune carte = un formulaire court. Au-delà des seuils de densité : avertissement, et l'auteur passe au format formcarte finale « s'inscrire »
motionécran final : titre du formulaire, QR code, URL. Aucun champappel à l'action en fin de vidéo
toonlien après la dernière caserare
calendarregister=#signup sur un événement : bouton « S'inscrire » qui ouvre le formulaire, meta.story = id de l'événementinscription, pont naturel
roadmapfeedback=#idea sur un item : formulaire de retour pré-rempli avec l'id de l'itemretour produit
formquestionnaire multi-étapes (§3.2)sondage, candidature

Ponts à ajouter dans le tableau de 08 §0 (correspondances fermées seulement) :

De → versCorrespondance
form → deckune é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 → storiesune é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, error passent par les fichiers *.fr.md / *.fr.json avec les mêmes id et les mêmes name (09 §3). Un name ou 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 le code d'erreur (aligné sur les mots-clés JSON Schema : required, format, maxLength, enum…). Le detail de l'API, s'il est présent, est affiché tel quel.
  • La langue courante est envoyée (locale de l'enveloppe), pour que l'API réponde dans la bonne langue.
  • Dates et nombres : Intl pour 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 en fetch et en native) ; multipart/form-data (DOIT s'il y a un champ file) ; application/json (DEVRAIT, seulement avec encoding=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-Key n'est envoyé qu'en encoding=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.

JSONJSON
{ "form": "signup", "version": 1, "locale": "en", "data": { "name": "Lorem Ipsum", "email": "lorem@example.com", "mode": "remote" }, "meta": { "submissionId": "3f2c0d8e-0000-4000-8000-000000000000", "story": "meetup", "page": "https://example.com/meetup", "elapsedMs": 8421, "consent": { "name": "consent", "version": "2026-10", "granted": true }, "antispam": { "honeypot": "", "turnstile": null } } }
  • data (ou les champs à plat) respecte /forms/<id>.schema.json.
  • contract=openapi (plus tard, FO4) : le corps est exactement le requestBody de l'opération, à plat ; le méta part dans les en-têtes Recit-Form, Recit-Locale, Recit-Submission-Id (preflight assumé).

Réponses

StatutSensCorps
200, 201, 202acceptéapplication/json : { "id"?, "message"? (déjà localisé), "redirect"? }
303accepté, mode nativeLocation vers la page de succès
400JSON mal forméapplication/problem+json
401, 403authapplication/problem+json
409même Idempotency-Key, corps différentapplication/problem+json
413, 415trop gros, type refuséapplication/problem+json
422validationRFC 9457 + errors[] : { "type": "https://recit.dev/problems/validation", "title", "status": 422, "detail"?, "errors": [{ "pointer": "#/data/email", "code": "format", "detail"? }] }
429trop de requêtesRetry-After obligatoire
5xxerreur serveurapplication/problem+json générique

Règles du contrat (ce que l'API DOIT respecter)

  1. Valider côté serveur avec le schéma publié. La validation du client n'est qu'une aide.
  2. Rejouer la même réponse pour le même _recit.submission (ou meta.submissionId, ou Idempotency-Key en JSON).
  3. Accepter application/x-www-form-urlencoded et multipart/form-data, et poser Access-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 mode native. Servir OPTIONS (Allow-Methods: POST, Allow-Headers: Content-Type, Idempotency-Key, Authorization, Expose-Headers: Location, Retry-After) n'est exigé que pour encoding=json ou une auth par en-tête. Jamais * avec credentials.
  4. Rejeter un honeypot rempli en silence (2xx sans traitement) pour ne pas renseigner le bot.
  5. (Plus tard) Vérifier Turnstile côté serveur (siteverify) si antispam=turnstile.
  6. Ne jamais renvoyer de donnée personnelle d'un autre envoi.
  7. 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

PackageGroupeStatutPubliéContenuDépendances notables
@recit/formfondations (packages/form)📅 P10ouiplugin 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/openapisous-chemin (Node)plus tardouiOpenAPI → champs (table §3.4)@apidevtools/json-schema-ref-parser, @scalar/openapi-parser aux versions de packages/site (à mettre au catalogue)
@recit/form/serversous-chemin (runtime web)📅 P13ouihandler 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égrateurzod seulement
@recit/core/schemafondations📅 P10ouiFormBlock, FieldSchema. FormSource et "form" dans StoryFormat seulement si le format est retenuzod

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 :::form sans @recit/form installé produit une erreur claire « installez @recit/form ». Le format multi-étapes, s'il est retenu, vivra dans packages/formats/ et dépendra de cette fondation.
  • Aucune dépendance nouvelle : zod, qrcode et les deux parseurs OpenAPI sont déjà dans le lockfile. react-hook-form n'est pas ajouté en v1.
  • Le handler web-standard tourne tel quel sur Node 22 (via un petit adaptateur node:http dans 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 bearer passe par @recit/site-stories.
  • Tags nx : scope:foundation. CLI : @recit/cli gagne recit form check-endpoint (P13) et recit form schema (plus tard, §6).
  • À reporter : @recit/form dans 03-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

DomaineRôleDossierContenuPhase
form.recit.devsite d'outil : bloc :::form et contratsites/formhero = 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 sitesP13
  • Catégorie « site d'outil » de 07-domaines.md §1 (comme ai.recit.dev) tant qu'aucun format form n'existe. Pas d'alias : form est à la fois le mot du backlog et le nom technique.
  • Vercel : projet recit-form, racine sites/form, build nx 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 :
ComposantRôleExemple sur form.recit.dev
FormatHerodémo jouable + source dépliablequestionnaire Lorem en 4 étapes, mode mock
SourceTogglerendu / Markdown / JSONmême questionnaire en .md et en .json
LocaleDemobascule en/frlibellés et messages d'erreur traduits, name inchangés
ExportPanelexports (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)
BridgeCardponts (plus tard, FO6)le même formulaire en slide, en carte de story, en écran final vidéo
ComponentShowcasecomposantsForm, Field, Consent, ErrorSummary
TemplateCardgabaritsnpm create recit -- --template form-contact
DocsLinksrenvoi 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

ExempleContenu (Lorem Ipsum, en et fr)Phase
examples/form-contactbloc :::form dans une page docs, consentement, honeypot, mode mocklot P10
examples/form-api-nodeserveur de référence : @recit/form/server + adaptateur node:http, stockage en mémoire, sans dépendanceP13
examples/form-surveyformat form multi-étapes, branches when, récapitulatif, export PDF imprimableplus tard
examples/form-openapiformulaire généré depuis lorem.yaml (spec Lorem), surcharges ::fieldplus 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), branches when.
  • Test d'intégration : recit form check-endpoint contre examples/form-api-node lancé en CI.
  • Aucun réseau externe en CI : endpoints mock ou 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 (mode fetch) et form-action (mode native) doivent inclure l'origine de l'endpoint. recit build liste 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 : accept et max-size vé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 : message et detail de l'API sont affichés en texte.

5.2 Vie privée (RGPD)

  • ::consent jamais pré-coché, version du texte envoyée.
  • Mention courte près du bouton d'envoi + lien privacy vers 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: local abandonné 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), autocomplete renseigné, marqueur « requis » textuel.
  • Erreurs : aria-invalid + aria-describedby par 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

ItemUsed inLicense / 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/openapiMIT
@scalar/openapi-parser@recit/form/openapiMIT
qrcode@recit/form (and existing users)MIT
Cloudflare Turnstile (later; opt-in, loaded only with antispam=turnstile)@recit/formCloudflare terms of service; third-party script that receives the visitor's IP, to be listed in the site's privacy notice
RFC 9457 Problem Detailsform submission contractIETF 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 :

LotContenuPhase
FO0, FO1contrat (la « vision de l'API ») et bloc minimallot P10
FO2, FO3, FO7envoi fetch, « TODO API », siteP13 Form
FO4, FO5, FO6OpenAPI, format multi-étapes, autres formatsplus 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, FieldSchema dans @recit/core/schema, JSON Schema généré par z.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=id hors bloc : plus tard.
  • FO1.2 — Composant React 19 : HTML natif d'abord (fonctionne sans JS en mode native), contraintes HTML, conditions visible-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, packs en/fr des messages, parité dans recit 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 champ file existe, JSON seulement avec encoding=json.
  • FO2.2 — Mapping problem+json → champs (pointer), messages localisés par code, membres inconnus tolérés.
  • FO2.3 — Idempotence (en-tête + meta.submissionId), double clic sans double envoi, respect de Retry-After.
  • FO2.4 — auth=publishable-key. auth=bearer (via un tokenProvider fourni 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 : createFormHandler web-standard, validation zod, CORS, honeypot, idempotence avec stockage injectable.
  • FO3.2 — examples/form-api-node : adaptateur node:http, stockage en mémoire, README.
  • FO3.3 — recit form check-endpoint <url> --form <id> : preflight, POST valide, POST invalide (422 problem+json avec pointer), 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êtes Recit-*.
  • FO4.4 — Publication de /forms/<id>.schema.json et /forms/<id>.json au 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" à StoryFormat comme 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= de calendar, feedback= de roadmap.
  • 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-node vient de FO3.2) + sites/form (form.recit.dev, site d'outil, port 3011, projet Vercel recit-form) + sites/docs › Components › Form et API › Form submission (contrat rendu par le framework de sites).
  • FO7.2 — Icône brand/formats/fmt-form-*.svg dans la teinte unique, gabarit npm 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=bearer via @recit/site-stories, form=id hors bloc. Pas de brouillons (question 8).
  • Téléversement présigné en deux temps.
  • Champs calculés compute, types range, time, datetime-local.
  • Adaptateurs de contrat (contract=formspree…).

7. Risques

RisqueParade
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 sitelint 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égrateurcheck-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érimentaltable 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 slideseuils de densité existants, renvoi vers le format form
Démo de form.recit.dev prise pour un vrai servicemode mock seul, bannière « rien n'est envoyé »
Collision de port ou de phase avec les autres plans du backlogtable unique des ports dans 07 §4, phases dans 06 « Backlog »
Le bloc :::form entre en conflit avec :::card, :::stepnoms 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-Originenvoi 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) :

  1. Format multi-étapes : le bloc :::form et le contrat suffisent-ils, ou voulez-vous aussi un questionnaire à la Typeform (format form, §3.2) ? Tranché (24/10) : bloc et contrat d'abord, le format multi-étapes plus tard, sur un besoin réel.
  2. Placement : contrat et bloc minimal en lot avancé de P10, envoi fetch et site en P13 Form (06) : d'accord ? Tranché (24/10) : oui (ordre du backlog accepté, 06 question 14).
  3. « 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.
  4. form.recit.dev doit-il avoir un vrai endpoint de démo, ou seulement le mode mock ? Tranché (24/10) : mode mock seulement.
  5. Encodage par défaut : urlencoded à plat avec champs _recit.*, et l'enveloppe JSON en opt-in ? Tranché (24/10) : urlencoded par défaut avec champs _recit.* (marche sans JS, pas de preflight CORS), JSON {form, data, meta} en option (encoding=json).
  6. 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.
  7. L'auth bearer via la session du framework de sites est-elle utile, et quand ? Tranché (24/10) : plus tard, hors v1.
  8. Brouillons (draft: local) : opt-in plus tard, ou jamais ? Tranché (24/10) : pas de brouillons.
  9. Import recit convert --from formdown : utile, ou on ignore la syntaxe formdown ? Tranché (24/10) : pas d'import formdown.