# 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

| 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/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é

- 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 `siteverify` avec 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.

```md
---
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é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`).

```md
:::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.

```md
---
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é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 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.

```json
{
  "$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 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`, `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.

```json
{
  "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**

| 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)

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

| 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 `:::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

| 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 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 :

| 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), 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

| 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`, `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

| 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) :

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.
