# 24 — Webtoon : le format `toon` et webtoon.recit.dev

**Statut : proposé · Phase : P7 · Mis à jour : 02/11/2026.**

Ce plan détaille la phase P7 de [06 · Feuille de route](/06-roadmap) : le format `toon` (webtoon,
ruban vertical de cases), son jumeau JSON, son lecteur, ses exports (tranches, PDF, film) et le site
webtoon.recit.dev. Il part de la spécification de [08 · Formats](/08-formats) §2, qu'il précise sans
revenir sur les décisions acceptées ; chaque désaccord avec un plan accepté est posé en question
ouverte (§12), jamais tranché ici. Il s'appuie sur le code tel qu'il est au 02/11/2026, pas sur
l'ancien texte.

Il dépend de [15 · Export PDF](/15-export-pdf) (§3.5 et PDF2, accepté), de
[09 · Langues et JSON](/09-langues-et-json), de [14 · IA et scénario](/14-ia-scenario) (lot IA0 logé
en P7, bible et attributs de scène), de [11 · Images](/11-images) et
[16 · Atelier de personnages](/16-atelier-personnages) (personnages, Broc), de
[03 · Packaging](/03-packaging) §6 et §8, de [07 · Domaines](/07-domaines) et de
[21 · Tests](/21-tests). Le plan 23 (report, P6) livre la chaîne d'impression dont P7 a besoin ; le
plan 25 (book, P8) partage la question des récits en plusieurs fichiers (séries ici, chapitres
là-bas).

## 1. Pourquoi

- **Le webtoon est la forme native de la lecture au pouce.** Un ruban vertical de cases se lit sur
  un téléphone sans zoom ni pagination. C'est le premier format de Récit où l'image porte le récit
  et où le texte se pose dessus.
- **Le lettrage est du texte.** Dans une BD classique, les bulles sont peintes dans l'image : la
  traduire, c'est la redessiner. Ici les cases sont des images sans texte et les bulles sont des
  données typées (position, forme, texte). Le texte est donc traduisible, sélectionnable, indexé par
  la recherche, lu par un lecteur d'écran, et une seule série d'images sert toutes les langues.
- **Une source, quatre sorties.** Le même épisode se lit au scroll sur le web, s'exporte en tranches
  d'images pour les plateformes, en PDF (une décision acceptée : le webtoon **doit** l'avoir) et
  devient un film vertical `9:16` par le pont toon → motion.
- **Le premier client du scénario.** Le plan 14 a choisi le webtoon pour son épisode de
  démonstration (`sites/plans/pages/annexes/broc/scenario-3.md`, « Tempo a filé », 12 cases). Le
  format doit pouvoir l'exprimer sans exception, même si sa publication attend P16 et la licence de
  l'image de référence.

## 2. Ce qui existe déjà et ce qu'on réutilise

Relevé dans le dépôt le 02/11/2026.

| Élément                                   | État                                                                                                                                                                                                                                         | Ce que P7 en fait                                                                                                                       |
| ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `packages/formats/toon`                   | stub : `package.json` privé (`@recit/toon` 0.0.0, `scope:format`), `src/index.ts` vide                                                                                                                                                       | devient un paquet publiable, sur le modèle de `packages/formats/deck` (tsdown, `README.md`, `LICENSE.md`, `./styles.css`)               |
| `@recit/core/schema` (`source.ts`)        | `ToonSourceSchema = genericSource("toon")` : scènes `GenericSceneSourceSchema` (objet lâche), `settings` lâche. `toon` est déjà dans `STORY_FORMATS`, et `packages/core/schema/v1/toon.json` est publié                                      | le schéma devient strict, avec des champs typés, comme `DeckSourceSchema` et `MotionSourceSchema` ; ajout compatible en `v1` (règle B1) |
| `convert.ts`, `parity.ts`, `assemble.ts`  | conversion Markdown ⇄ JSON par format (`deckScene`, `docsScene`, `motionScene`, `genericScene`) ; parité des variantes de langue ; jumeau (`kind: "story"`) et compagnon (`kind: "sidecar"`) par `resolveStoryFiles`                         | une fonction `toonScene` et son écriture inverse ; la parité compare aussi le lettrage                                                  |
| Séparateur de scène (`split-slides.ts`)   | attributs `--- clé=valeur`, valeurs entre guillemets acceptées (`alt="…"`), typage par `SlideMetadataSchema`. La forme sur une ligne `:::notes texte :::` est un cas particulier de `:::notes` (l. 235)                                      | réutilisé tel quel pour `src`, `alt`, `height`, `gutter`, `background`                                                                  |
| `remark-directive` dans le cœur           | les directives `:::nom{…}` sont lues par le pipeline unifié de `@recit/core`                                                                                                                                                                 | `:::bubble`, `:::caption`, `:::sfx` : noms au registre du cœur (règle B4), sens dans `@recit/toon`                                      |
| Round-trip des exemples                   | `packages/core/src/__tests__/source-conversion.spec.ts` vérifie toute story de `examples/` et `sites/*/stories`, que son JSON soit sur disque ou non                                                                                         | `examples/toon-*` y entrent sans code de test en plus                                                                                   |
| `@recit/motion` et `@recit/motion-blocks` | le pont `storyToFilm` (`source/story.ts`) ; huit blocs (title, statement, stat, compare-bars, chapter, list, ui-mock, ui-scene) ; **aucun bloc ne montre une image**                                                                         | le pont toon → motion demande un bloc nouveau, `panel` (§5.4)                                                                           |
| La CLI                                    | `recit dev` a un aperçu propre au motion (`src/motion/client.tsx`, bundlé par esbuild) ; `recit export` sort le PDF ou le HTML du deck, **sans `--to`** ; `recit render` rend un récit motion ; `recit convert --to json\|md\|film`          | un aperçu toon sur le même modèle ; `--to images` et `--to pdf` arrivent avec la chaîne de P6 ; `render` accepte un toon                |
| `@recit/paged/runner`                     | n'existe pas encore : le stub est `packages/formats/paged`, déplacé dans `packages/tools/paged` en PDF1 (P6, plan 23)                                                                                                                        | **prérequis** de W3 (tranches) et de PDF2                                                                                               |
| `@recit/site-stories`                     | `plugin.tsx` choisit le lecteur par format : `MotionViewer` pour `motion`, `StoryViewer` (deck) sinon ; `vite.ts` compile les stories et tente `storyToFilm`                                                                                 | une branche `toon` vers `ToonReader`, chargée paresseusement                                                                            |
| `@recit/site-kit`                         | `formats.ts` décrit déjà `toon` (« Webtoon ») ; `SITES` (`sites.ts`) ne connaît pas encore `webtoon`                                                                                                                                         | ajout de `webtoon` (port 3005, `07` §4)                                                                                                 |
| `@recit/components`                       | `useCssVariable` (valeurs dynamiques sous la loi du plan 19), composants partagés                                                                                                                                                            | positions des bulles en variables CSS                                                                                                   |
| `sharp`                                   | absent du lockfile ; `allowBuilds.sharp: false` dans `pnpm-workspace.yaml`                                                                                                                                                                   | voir §5.2 et question 4 : la découpe peut se passer de `sharp`                                                                          |
| Scénario de Broc                          | `sites/plans/pages/annexes/broc/scenario-3.md` : source Markdown complète et brouillon du jumeau JSON d'un épisode de 12 cases ; image de référence `examples/scenario-lebrocoli/cast/broc/reference.png` (© vguerand, tous droits réservés) | banc d'essai du modèle (W0) ; publication en P16 seulement, dans `examples/scenario-lebrocoli`                                          |

Ce qu'on **ne** crée pas : pas de second parseur Markdown, pas de moteur de mise en page, pas de
bibliothèque de bulles, pas de lanceur de Chromium dans `@recit/toon` (la CLI capture par
`@recit/paged/runner`, `03` §8), pas de schéma de type hors de `@recit/core`.

## 3. Vocabulaire

- Une **scène** de `toon` est une **case** (panel) : « case » est le mot du lecteur, de l'interface
  et de la doc, comme « slide » pour le deck. Le modèle garde `Scene`, `sceneIndex`.
- Un **épisode** est une story `format: toon`. Une **série** est un dossier d'épisodes (§4.6).
- Le **lettrage** désigne les bulles, encarts et onomatopées d'une case : ce qui se traduit.
- Une **tranche** (slice) est une image découpée du ruban pour une plateforme ; une **page** est une
  page du PDF. Les deux découpages sont distincts (`15` §3.5).
- Le rendu `motion` d'un épisode est un **film**, jamais un « deck ».

Ces mots sont à ajouter au glossaire de `AGENTS.md` par l'intégrateur (W7), ce plan ne le modifie
pas.

## 4. Le modèle

### 4.1 Markdown

Même grammaire que tous les formats : frontmatter, scènes séparées par `---` avec leurs attributs,
directives `:::`. Réglages à plat dans le frontmatter (`08`, préambule).

```md
---
title: Lorem Ipsum — Episode 1
format: toon
lang: en
width: 800
gutter: 120
background: "#101014"
font: lettering
---

--- id=p1 src=./panels/01.webp alt="A lorem street at night, empty, under a single lamp."

:::caption
Somewhere, dolor sit amet.
:::

:::bubble{x=62% y=18% tail=bottom-left}
Lorem ipsum?
:::

--- id=p2 src=./panels/02.webp height=1400 gutter=40 alt="A close-up on two ipsum figures."

:::bubble{x=30% y=12% shape=shout}
Ipsum!
:::

:::sfx{x=70% y=60% rotate=-12}
BOOM
:::

:::notes
Author note, never published. Becomes the voice-over in the film.
:::
```

| Élément                                         | Rôle                                                                                                                                                                            |
| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `src`                                           | image de la case (chemin relatif, partagée entre langues ; variante `01.fr.webp` possible, `09` §1)                                                                             |
| `alt`                                           | description de l'image, localisable ; manquante = avertissement (`15` Q6, tranché)                                                                                              |
| `height`                                        | hauteur en px à la largeur de référence ; par défaut, celle de l'image mise à l'échelle                                                                                         |
| `gutter`                                        | espace après la case, en px ; par défaut le `gutter` de l'épisode                                                                                                               |
| `background`                                    | couleur d'une case sans image (case de transition, noir de fin) ; un seul nom, jamais `bg` (règle B4)                                                                           |
| `:::bubble{x y w tail shape cast}`              | bulle. `x`, `y` : centre de la bulle en % de la case ; `w` : largeur maximale en % (40 par défaut) ; `tail` ; `shape` : `speech`, `thought`, `shout`, `whisper` ; `cast` (§4.5) |
| `:::caption{x y w}`                             | encart narratif                                                                                                                                                                 |
| `:::sfx{x y rotate size}`                       | onomatopée en texte vectoriel                                                                                                                                                   |
| `:::notes`                                      | notes d'auteur, jamais publiées dans le lecteur ; voix off dans le film (§5.4)                                                                                                  |
| `beat`, `cast`, `set`, `shot`, `pose`, `prompt` | attributs réservés du scénario (B4, `14` D4) : gardés dans `attrs` sans erreur en P7, vérifiés par `@recit/scenario` en P16                                                     |

Règles :

- **L'ordre du Markdown est l'ordre de lecture** des éléments de lettrage d'une case, et l'ordre du
  DOM. Rien n'est trié par position (règle d'emaki : jamais de devinette).
- Un élément de lettrage **sans `x` ni `y`** est empilé en haut de la case, dans l'ordre : c'est la
  forme demandée par le plan 14 D6 pour les répliques venues d'un `:::dialogue` ou d'un pont.
- `x`, `y`, `w` s'écrivent en pourcentage (`62%`), de 0 à 100 ; une valeur hors bornes est une
  erreur avec numéro de ligne. Le débordement réel (bulle trop grande pour son texte) ne se mesure
  qu'au rendu : avertissement du lecteur en dev et du runner à l'export.
- Le corps d'une case ne contient **que** du lettrage et `:::notes`. Toute autre chose (paragraphe,
  titre, autre directive) est une erreur avec numéro de ligne, pas un texte affiché au hasard. Les
  blocs transverses (` ```pixel `, plus tard) s'ajouteront par la règle B2.
- Une case sans `src` ni `background` est dessinée comme une **case de remplacement** : un cadre
  neutre qui montre l'`alt` et, s'il existe, l'`id` de `prompt=`. Avertissement, erreur en
  `--strict`. C'est le point d'accroche des images de remplacement du plan 14 D7.
- Image introuvable : erreur avec numéro de ligne (`08` §2). Poids d'une image et de l'épisode
  au-delà du budget (`budget.panelBytes`, `budget.episodeBytes`) : avertissement.
- La forme canonique des directives est **sur plusieurs lignes** (ouverture, texte, `:::`). La forme
  sur une ligne de `08` §2 (`:::bubble{…} Lorem ipsum? :::`) n'est pas lue par le pipeline actuel,
  qui ne la connaît que pour `:::notes` : question 2.

### 4.2 Le jumeau JSON

Le schéma vit dans `packages/core/src/schema/source.ts`, en zod/mini comme les autres ; les types
sont dérivés (`z.infer`) et exportés depuis `@recit/core/schema`. Esquisse :

```ts
/** A position or a width, in percent of the panel (written `62%` in Markdown) */
const PercentSchema = z.number().check(z.gte(0), z.lte(100));

const letteringFields = {
  x: z.optional(PercentSchema),
  y: z.optional(PercentSchema),
  /** Inline Markdown (emphasis only); one text per language */
  text: LocalizedSchema,
};

export const ToonBubbleSchema = z.strictObject({
  type: z.literal("bubble"),
  ...letteringFields,
  w: z.optional(PercentSchema),
  tail: z.optional(z.enum(TOON_TAILS)), // bottom-left, bottom, bottom-right, top-…, left, right, none
  shape: z.optional(z.enum(["speech", "thought", "shout", "whisper"])),
  /** Speaker: an id of the bible (plan 14); its name is the accessible label */
  cast: z.optional(z.string().check(z.minLength(1))),
});

export const ToonCaptionSchema = z.strictObject({
  type: z.literal("caption"),
  ...letteringFields,
  w: z.optional(PercentSchema),
});

export const ToonSfxSchema = z.strictObject({
  type: z.literal("sfx"),
  ...letteringFields,
  rotate: z.optional(z.number().check(z.gte(-180), z.lte(180))),
  size: z.optional(z.enum(["s", "m", "l", "xl"])),
});

export const ToonLetteringSchema = z.discriminatedUnion("type", [
  ToonBubbleSchema,
  ToonCaptionSchema,
  ToonSfxSchema,
]);

export const ToonSceneSourceSchema = z.strictObject({
  id: sceneFields.id,
  attrs: sceneFields.attrs,
  src: z.optional(z.string().check(z.minLength(1))),
  alt: z.optional(LocalizedSchema),
  height: z.optional(z.int().check(z.positive())),
  gutter: z.optional(z.int().check(z.gte(0))),
  background: z.optional(z.string()),
  /** Bubbles, captions and sound effects, in reading order */
  lettering: z.optional(z.array(ToonLetteringSchema)),
  notes: sceneFields.notes,
});

export const ToonSettingsSourceSchema = z.looseObject({
  width: z.optional(z.int().check(z.positive())), // 800
  gutter: z.optional(z.int().check(z.gte(0))), // 120
  background: z.optional(z.string()),
  /** A font role of the theme (`lettering`) or a family it ships */
  font: z.optional(z.string()),
  direction: z.optional(z.enum(["ltr", "rtl"])),
  budget: z.optional(z.strictObject({ panelBytes: …, episodeBytes: … })),
  pdf: z.optional(ToonPdfOptionsSchema), // PDF2.1, plan 15
  images: z.optional(ToonImagesOptionsSchema), // W3
});

export const ToonSourceSchema = z.strictObject({
  ...storyFields,
  format: z.literal("toon"),
  settings: z.optional(ToonSettingsSourceSchema),
  scenes: z.array(ToonSceneSourceSchema),
});
```

- **Un seul tableau ordonné, `lettering`.** `09` §2.3 (accepté) écrit trois tableaux, `bubbles[]`,
  `captions[]` et `sfx[]`. Trois tableaux perdent l'ordre entre un encart et une bulle, donc l'ordre
  de lecture, et l'aller-retour Markdown → JSON → Markdown ne rend plus le même texte (dans le
  scénario 3, la case `c01` ouvre sur l'encart, la case `c12` le met à la fin). Ce plan propose un
  tableau unique discriminé sur `type` ; c'est la question 1, à trancher avant W0. `type` ne désigne
  ici que la sorte d'élément de lettrage, à l'intérieur de l'objet ; l'attribut de scène `type=`
  reste réservé aux blocs motion (B4).
- **`x` et `y` sont des nombres** en JSON et des pourcentages en Markdown (`62%` ⇄ `62`). Le
  brouillon du scénario 3 gardait des chaînes `"58%"` en laissant le choix à W1 : le nombre se
  valide par le schéma (bornes), la chaîne demanderait un `refine`.
- **`src` et `alt` restent à plat** (`09` §2.3), et non un objet `image: { src, alt }` comme le
  `DeckImageSchema` du deck : l'`alt` d'une case vaut aussi sans image (case de remplacement,
  balisage du PDF).
- `ToonPdfOptionsSchema` et `ToonImagesOptionsSchema` vivent dans
  `packages/core/src/config/validate.ts` avec les autres options d'export (`15` §3.2), et la section
  `toon:` de `recit.config.ts` est `ToonConfigSchema` (même champs, forme imbriquée, `08`
  préambule). Aucun réglage ne s'appelle `kind`.
- `@recit/toon` ne redéfinit aucun de ces types : il les importe.

### 4.3 Conversion et aller-retour

- `markdownToSource` gagne `toonScene` (dans `convert.ts`, à côté de `motionScene`) : les attributs
  typés du séparateur passent dans leurs champs, les autres restent dans `attrs` ; les directives de
  lettrage du corps, lues par le même AST que le reste du cœur, deviennent `lettering`, dans l'ordre
  ; `:::notes` devient `notes`. Un reste dans le corps est une erreur avec sa ligne (§4.1).
- `sourceToMarkdown` écrit la forme canonique : attributs dans l'ordre
  `id src alt height gutter background`, puis `attrs`, directives sur plusieurs lignes, attributs de
  directive dans l'ordre du schéma, pourcentages avec `%`.
- **Propriété testée** : `compile(md) == compile(toJson(md))` et `toMd(toJson(md)) == md` pour toute
  source canonique (`09` §2.4), sur des fixtures Lorem et sur les exemples.
- Le **compagnon** (`kind: "sidecar"`) complète une case par son `id` : utile pour des `alt` ou des
  textes venus d'un CMS. Il ne redéfinit jamais la liste des cases (règle d'`AGENTS.md`).

### 4.4 Langues

- `episode-1.fr.md` est **la même page en français** (`09` §1) : mêmes `id`, mêmes images, mêmes
  positions, textes traduits. `checkStoryParity` vérifie en plus que le lettrage a le même nombre
  d'éléments, les mêmes `type`, `x`, `y`, `w`, `shape` ; le texte identique à la langue par défaut
  donne l'avertissement `untranslated`.
- `08` §2 et le scénario 3 (§4.3) décrivent un fichier de langue qui **ne contient que les textes**.
  Ce fichier n'est ni une variante complète (la parité refuse des attributs de structure manquants),
  ni un compagnon. Ce plan ne le crée pas : la traduction partielle passe par `episode-1.fr.json`
  (`kind: "sidecar"`), et `recit i18n` sait déjà produire la variante complète. Question 3.
- Dans le JSON, un texte porte toutes ses langues (`{ "en": "…", "fr": "…" }`) : positions écrites
  une fois, textes par langue. C'est la forme la plus sûre pour un outil de traduction.
- Les bulles grandissent avec leur texte (largeur maximale `w`, hauteur libre) ; un débordement de
  la case est un avertissement, par langue.
- `direction: rtl` pose `dir="rtl"` sur le lecteur et retourne l'interface. Il ne réordonne pas le
  lettrage : l'auteur écrit dans l'ordre de lecture (question 7, en lien avec `15` §3.5).

### 4.5 Scénario, personnages et images

- Les attributs `beat`, `cast`, `set`, `shot`, `pose`, `prompt` sont réservés (B4) et lus par le
  plugin de `@recit/scenario` en P16. En P7, `@recit/toon` les garde dans `attrs` et ne les
  interprète pas, sauf `prompt=` dont il affiche l'`id` sur une case de remplacement.
- **Qui parle.** Sans `:::dialogue` (plan 14, plus tard), le locuteur n'est donné que par la queue
  de la bulle, invisible pour un lecteur d'écran. Ce plan propose `cast=` sur `:::bubble` : le nom
  affiché au lecteur d'écran est celui de la bible quand elle existe (P16), sinon l'`id` lui-même.
  Question 5.
- Les images ne sont **jamais générées par Récit** (`14` D7, `11`). Les panneaux viennent de
  l'auteur ; leur provenance et la mention « généré par IA » arrivent avec le registre d'assets du
  plan 14.
- **Broc.** Son épisode (scénario 3) vit dans `examples/scenario-lebrocoli/toon/` (règle B7, un seul
  dossier lebrocoli), en P16, après la licence de l'image de référence. P7 s'en sert comme banc
  d'essai **du schéma** (W0) avec une copie Lorem de sa structure, sans copier ni son texte ni ses
  images dans un exemple publié.
- Les blocs ` ```pixel ` et `sprite` (plan 11, plan 16) pourront fournir l'image d'une case plus
  tard, « sur besoin réel » (`11` D7) : rien en P7.

### 4.6 Séries

Un dossier = une série. `08` §2 propose un `series.yaml` ou le frontmatter du premier épisode. Tout
document de Récit a un jumeau JSON typé : ce plan propose un `series.json` (`kind: "series"`, schéma
`SeriesSchema` dans `@recit/core/schema`, titre localisable, couverture, ordre des épisodes), ajout
compatible en `v1` (B1), et pas de YAML. Le plan 25 pose la même question pour les chapitres d'un
livre (`settings.chapters`) : une seule réponse pour les deux. Question 8. Les séries sont le lot
W6, après les critères de sortie de P7.

## 5. Le rendu

### 5.1 Le lecteur web

Composants de `@recit/toon`, tous anonymes, chacun avec son `X.module.css`, sa story et son `play` :

| Composant          | Rôle                                                                                                                                                     |
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ToonReader`       | racine : ruban vertical, largeur `min(100%, width)`, `dir`, langue ; sommaire de l'épisode                                                               |
| `Panel`            | une case : `<figure>`, image avec `width`/`height` intrinsèques (pas de décalage de mise en page), `loading="lazy"`, `decoding="async"`, gouttière après |
| `Bubble`           | une bulle : texte HTML posé sur une forme SVG (`speech`, `thought`, `shout`, `whisper`) avec sa queue ; position par variables CSS (`useCssVariable`)    |
| `Caption`          | un encart                                                                                                                                                |
| `Sfx`              | une onomatopée en SVG `<text>`, `aria-hidden` sauf si l'auteur la marque comme porteuse du récit                                                         |
| `PlaceholderPanel` | case de remplacement (§4.1)                                                                                                                              |
| `EpisodeNav`       | épisode précédent et suivant (W6)                                                                                                                        |

- **Mobile d'abord.** La case suit la largeur de l'écran ; la taille du lettrage suit la largeur de
  la case par les unités de conteneur (`cqw`), en CSS simple (pas de PostCSS hors du framework de
  sites, plan 19). Rien ne demande de zoom à 360 px.
- **Bulles en HTML et SVG.** Le texte reste sélectionnable, indexé par Pagefind
  (`site-stories/search-index.ts`), lu dans l'ordre du DOM. Chaque case est une `figure` dont
  l'`alt` décrit l'image et dont le lettrage suit en texte.
- **Apparition au scroll** avec `motion` : `m` et `LazyMotion` avec `domAnimation` chargé à part
  (règle « Bundle Size » d'`AGENTS.md`), coupée sous `prefers-reduced-motion`.
- **Mode de couleur.** Les cases sont des images ; la gouttière prend `background` de l'épisode ; le
  reste de l'interface suit `data-mantine-color-scheme` par `light-dark()`. Pas de `.dark`.
- **Poids.** Images servies telles que l'auteur les fournit, au format choisi par lui (WebP ou AVIF
  recommandés) ; la CLI vérifie le budget. Les variantes responsives (`srcset`) supposent un
  encodeur : question 4.
- **Plus tard** : un mode « case par case » au tap (lecture guidée), sur demande réelle.

Lettrage : une police de lettrage OFL servie localement par `@recit/theme`, avec son `OFL.txt`,
comme Lexend, et un jeton de rôle (`--recit-font-lettering`). `font: lettering` la choisit ; une
autre famille du thème reste possible (le scénario 3 utilise `lexend`). Le choix de la police est la
question 6.

### 5.2 Les tranches pour les plateformes (W3)

- `recit export episode.md --to images --preset webtoon-canvas` produit des PNG, JPEG ou WebP de la
  largeur du préréglage, coupés à hauteur fixe, lettrage compris (une série de tranches par langue,
  `--all-locales`). Les préréglages sont des données versionnées, avec leur date de vérification
  (`08` §9) ; les valeurs des plateformes sont à vérifier au moment de W3, aucune n'est promise ici.
- La CLI sert le build du lecteur, l'ouvre par `@recit/paged/runner` et capture **chaque tranche par
  une capture découpée** (`clip`) de la page : pas d'image géante en mémoire, pas de limite de
  texture. L'encodage WebP ou JPEG se fait dans Chromium (`canvas.toBlob`). `sharp` n'est alors plus
  nécessaire ; `08` §2 et `03` §6 le prévoient : question 4.
- `08` §2 dit « en évitant de couper une bulle », `15` §3.5 dit « coupées à hauteur fixe sans égard
  aux cases ». Les plateformes recollent les tranches sans joint : une coupe ne se voit pas. Ce plan
  propose la hauteur fixe par défaut et une option `avoidLettering` (la coupe remonte au-dessus d'un
  élément de lettrage, dans une limite) : question 9.
- Les fichiers sont listés dans `exports.json` (`ExportManifestSchema`, variante `kind: "images"`,
  `15` §3.2) ; le panneau d'exports du site les lit.

### 5.3 Le PDF (PDF2, plan 15)

Tout est décidé dans `15` §3.5 et PDF2 ; ce plan ne fait que le loger :

- `pagination: "panels"` par défaut : pages de la largeur du préréglage, hauteur `pageHeight`
  indépendante des tranches, coupe toujours entre deux cases (`break-inside: avoid`), case trop
  haute sur sa propre page avec un avertissement, fond de gouttière en bas de page.
- `@recit/toon` fournit la **vue imprimable** (`ToonPrintView`, même composants, sans animation,
  variantes JPEG des cases) ; la CLI mesure la géométrie et imprime par `@recit/paged/runner`.
- Les variantes JPEG sont produites dans Chromium au moment de l'impression (comme les tranches),
  sauf si la question 4 retient `sharp`.
- `maxBytes` : budget par PDF, avertissement, erreur en `--strict`. Un PDF par langue, balisé, avec
  signets (un par case qui porte un `caption` ou par `id`, à fixer en PDF2.2) et `lang`/`dir`.
- `pagination: "continuous"` seulement après le spike PDF2.3.

### 5.4 Le film (pont toon → motion, W4)

Correspondance fermée (`08` §0) : **une case → un plan `9:16`**.

| Case                       | Plan du film                                                                                   |
| -------------------------- | ---------------------------------------------------------------------------------------------- |
| `src`, `height`            | bloc `panel` : l'image remplit la largeur, panoramique vertical du haut vers le bas de la case |
| `lettering` (dans l'ordre) | bulles, encarts, sfx en apparition successive, mêmes formes que le lecteur                     |
| `notes`                    | `voiceover` du plan (comme `:::notes` en motion)                                               |
| durée                      | temps de lecture du lettrage (`wordsPerSecond`) + `hold`, ou `dur` donné par l'auteur          |
| `background` sans image    | plan de couleur unie                                                                           |
| case de remplacement       | erreur : pas de film sans images                                                               |

- Le bloc `panel` n'existe pas : son schéma va dans le registre de `@recit/motion`
  (`schema/blocks.ts`), son rendu dans `@recit/motion-blocks` (`blocks/panel.tsx`, CSS module, story
  et `play`), avec la couche par image du plan 19 (allowlist de l'animation par frame). Le
  chargement d'images au rendu Remotion est à vérifier en W4.1.
- `toonToFilm(story)` vit dans `@recit/toon/motion` (sous-chemin : le lecteur n'importe jamais
  `@recit/motion`). Format → format : autorisé par `03` §8.
- `recit render episode.md` reconnaît `format: toon` et passe par `toonToFilm` : pas d'option
  `--as motion`, `render` ne produit que des films (question 10). `<StoryEmbed format="motion">` sur
  un épisode joue le même film, par `storyMotion` de `site-stories/vite.ts`.

## 6. CLI et MCP

| Commande                                         | Effet                                                                           | Lot    |
| ------------------------------------------------ | ------------------------------------------------------------------------------- | ------ |
| `recit init --format toon`                       | épisode Lorem de trois cases, images de remplacement                            | W1     |
| `recit dev episode.md`                           | aperçu du lecteur, rechargement à chaud (client `src/toon/client.tsx`, esbuild) | W1     |
| `recit build episode.md`                         | lecteur statique (HTML, images copiées, budget vérifié)                         | W1     |
| `recit validate [--strict] episode.md`           | schéma, lettrage, images, budget, `alt`, parité des langues                     | W0, W2 |
| `recit convert episode.md --to json\|md`         | jumeau, forme canonique                                                         | W0     |
| `recit export episode.md --to images --preset …` | tranches                                                                        | W3     |
| `recit export episode.md --to pdf`               | PDF, un par langue avec `--all-locales`                                         | PDF2   |
| `recit render episode.md`                        | film `9:16`                                                                     | W4     |

MCP (`@recit/mcp`), sans aucun appel de modèle :

- `describe_format` accepte `toon` (aujourd'hui `motion` et `deck`) : syntaxe et JSON Schema du
  jumeau, même texte que docs.recit.dev ;
- `validate_story` (nouveau, générique, utile à tous les formats) : diagnostics avec lignes ;
- ressource `recit://guide/toon` : écrire un épisode, poser le lettrage, ne jamais inventer d'image
  ; reprise par `recit skills export` (plan 14, B9) ;
- plus tard : des opérations ciblées (déplacer une bulle, changer un texte) sur le modèle
  d'`apply_ops`.

## 7. Le site webtoon.recit.dev

- `sites/webtoon` : Markdown et config seulement (`recit.config.ts` avec
  `createSiteConfig({ site: "webtoon" })`, `styles.css`, `vercel.json`), comme `sites/motion`.
  Composants dans `@recit/site-kit`.
- Pages : accueil (hero = un épisode lisible au scroll, sélecteur de langue sur la même planche),
  galerie, « une planche, deux langues », exports (panneau lu dans `exports.json` : tranches, PDF,
  film), « épisode → film ». Aucune doc propre : la doc est sur docs.recit.dev › Formats › Webtoon.
- `publish.entries` pointe vers `examples/toon-*`, que le site déclare en `devDependencies` (audit
  ST-20). Lecteur plein écran sous `/present/<slug>`.
- Port local 3005, e2e 4305 (`e2e/ports.ts`), projet Vercel `recit-webtoon`, alias 301
  `toon.recit.dev` (`07`). La création du projet et du DNS reste à l'utilisateur.
- Les fichiers générés (tranches, PDF, MP4) sont produits en CI, jamais sur Vercel (`15`, question 8
  de ce plan-là, encore ouverte).

## 8. Les exemples

| Dossier                   | Contenu                                                                                                                       | Langues | Lot |
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | ------- | --- |
| `examples/toon-episode`   | un épisode Lorem de 8 à 10 cases : toutes les formes de bulle, encarts, sfx, une case sans image, une case haute (PDF), notes | en, fr  | W5  |
| `examples/toon-series`    | deux épisodes courts et `series.json`, préréglage de tranches                                                                 | en      | W6  |
| `examples/scenario-lorem` | micro-récit `toon` du lot IA0.3 (plan 14), cases de remplacement avec `prompt=`                                               | en, fr  | W6  |

- Contenu Lorem Ipsum, aucun nom réel, aucune image sous droits.
- Les cases sont des **SVG abstraits** (formes et dégradés), écrits par un script de l'exemple
  (`scripts/panels.ts`) et commités ; deux d'entre elles ont aussi une version WebP, produite une
  fois par Chromium, pour que le budget de poids et les variantes JPEG du PDF soient testés sur des
  images matricielles. Ligne au `NOTICE.md` dans le même changement.
- `package.json` : `scope:example`, scripts `dev`, `build`, `export:images`, `export:pdf`, `render`.
- Les sources Markdown ne passent jamais par oxfmt.

## 9. Tests

| Niveau           | Ce qui est vérifié                                                                                                                                                                                        | Où                                                                             |
| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| Schéma et jumeau | `toonScene` et son inverse ; aller-retour sur des fixtures Lorem (ordre du lettrage, `%`, guillemets, case vide) ; erreurs avec ligne (bornes, reste dans le corps, image manquante) ; parité des langues | `packages/core/src/__tests__/` ; `source-conversion.spec.ts` pour les exemples |
| Modèle de rendu  | `compileToon` (story → cases), budget, case de remplacement, `toonToFilm` (durées, ordre, voix off)                                                                                                       | `packages/formats/toon/src/__tests__/`                                         |
| Composants       | une story par composant avec `play` : rôles et noms (`img` nommée par l'`alt`, texte des bulles, ordre de lecture), axe en erreur ; `Bubble` dans ses quatre formes, ses queues, un texte long, RTL       | `X.stories.tsx` à côté de `X.tsx`, `pnpm test:stories`                         |
| Bloc motion      | `panel` : story et `play`, golden de frames comme les autres blocs                                                                                                                                        | `@recit/motion-blocks`                                                         |
| CLI              | `dev`, `build`, `convert`, `validate`, `export --to images` (nombre et taille des tranches), `render` (durée du film)                                                                                     | `packages/cli/src/__tests__/`                                                  |
| e2e, site        | webtoon.recit.dev : pages en clair et sombre, mobile (390 px) et bureau, chargement paresseux (les images hors écran ne sont pas demandées), changement de langue, recherche d'un texte de bulle          | `e2e/site-webtoon.spec.ts`, captures `-linux`                                  |
| e2e, exports     | PDF : aucune case coupée, PNG des pages, budget de poids, deux langues (PDF2.4) ; tranches : largeur, hauteur, nombre                                                                                     | `e2e/toon-export.spec.ts`                                                      |
| Storybook        | surface `toon` dans la galerie de captures                                                                                                                                                                | `e2e/storybook-toon.spec.ts`                                                   |

Le lecteur de PDF des tests (`pdfjs-dist` ou poppler) est la question 7 de `15`, encore ouverte :
PDF2.4 la suppose tranchée. Seuils de couverture : une ligne `packages/formats/toon/**` dans le
`vitest.config.ts` racine, en `autoUpdate`.

## 10. Lots

Identifiants W0 à W7 : W1 à W5 reprennent ceux de `08` §2, W0, W6 et W7 sont nouveaux. Chaque lot
est assez petit pour un agent ; les règles de travail concurrent sont celles de
[20 · Exécution multi-agent](/20-multi-agents).

```
W0 ─► W1 ─┬─► W2 ─┐
          ├─► W4 ─┼─► W5 ─► W7
          └─► W6 ─┘
P6 (PDF1, runner) ─► W3, PDF2 ─► W5
```

### W0 — Le jumeau (M)

Dépend de : rien dans P7 (questions 1 à 3 tranchées). Fichiers :
`packages/core/src/schema/source.ts`, `convert.ts`, `parity.ts`,
`packages/core/src/config/validate.ts` (`ToonConfigSchema` sans options d'export),
`packages/core/schema/v1/toon.json` (régénéré), registre des directives, tests.

- [ ] W0.1 — `ToonSceneSourceSchema`, `ToonLetteringSchema`, `ToonSettingsSourceSchema`,
      `ToonSourceSchema` strict ; types exportés.
- [ ] W0.2 — `toonScene` et l'écriture canonique ; erreurs avec ligne.
- [ ] W0.3 — Parité du lettrage entre langues.
- [ ] W0.4 — Fixtures Lorem, dont une copie Lorem de la structure du scénario 3 (12 cases, lettrage
      mêlé, attributs de scénario) : elle valide sans perte, hors des champs que P16 doit fixer
      (`cast:` du frontmatter).
- [ ] W0.5 — `pnpm -F @recit/core schema:json` ; version plan `prerelease` pour `@recit/core`.

Sortie : `recit convert` fait l'aller-retour sur toutes les fixtures, octet pour octet ; un toon
JSON invalide échoue avec un chemin JSON, un Markdown invalide avec sa ligne.

```bash
pnpm exec vitest run packages/core/src/__tests__/source-conversion.spec.ts
pnpm test:related packages/core/src/schema/convert.ts && pnpm check
```

### W1 — Le paquet et le lecteur (L)

Dépend de : W0. Fichiers : `packages/formats/toon/**` (paquet publiable : `tsdown.config.ts`,
`tsconfig.json`, `README.md`, `LICENSE.md`, `./styles.css` par `scripts/build-css-modules.js`),
`packages/theme` (police de lettrage, `OFL.txt`), `packages/cli/src/toon/`,
`packages/cli/src/index.ts` (`init`, `dev`, `build`), `NOTICE.md` (police).

- [ ] W1.1 — `compileToon(story)` : cases typées, dimensions lues dans l'en-tête des images (PNG,
      JPEG, WebP, AVIF ; sans dépendance, question 4), budget.
- [ ] W1.2 — Composants du §5.1, un module CSS chacun, stories avec `play`.
- [ ] W1.3 — Police de lettrage et jeton de rôle dans `@recit/theme`.
- [ ] W1.4 — `recit init --format toon`, `recit dev`, `recit build`.
- [ ] W1.5 — Version plan `preminor` pour `@recit/toon` (premier jalon de la phase), `prerelease`
      pour `@recit/theme` et `@recit/cli`.

Sortie : `recit dev` d'une fixture se lit au scroll à 360 px et à 1280 px, en clair et en sombre ;
`node scripts/check-style-rule.js packages/formats/toon` et `node scripts/check-stories.js` à 0 ;
`scripts/check-package-boundaries.js` sans nouvelle tolérance.

```bash
pnpm exec nx run toon:build && pnpm -F @recit/toon typecheck
pnpm test:stories packages/formats/toon && pnpm check
```

### W2 — Langues et site-stories (M)

Dépend de : W1. Fichiers :
`packages/integrations/site-stories/src/{plugin.tsx,vite.ts,search-index.ts}`, `package.json` de
`site-stories` (dépendance `@recit/toon`), `packages/core/src/schema/parity.ts`.

- [ ] W2.1 — Branche `toon` de `plugin.tsx` vers `ToonReader`, chargée paresseusement ; `StoryEmbed`
      avec `format="toon"`.
- [ ] W2.2 — Routes par langue, sélecteur, `hreflang` (comme les autres formats, `09` §4).
- [ ] W2.3 — Lettrage indexé par Pagefind.

Sortie : une fixture en/fr se lit dans `examples/site-embed` en deux langues ; `recit validate`
signale une bulle non traduite.

### W3 — Tranches (M)

Dépend de : W1 et `@recit/paged/runner` (P6, PDF1). Fichiers : `ToonImagesOptionsSchema` dans
`packages/core/src/config/validate.ts`, préréglages, `packages/cli/src/commands/export-*.ts`,
`ExportManifestSchema` (variante `images`).

- [ ] W3.1 — Options typées (`preset`, `width`, `sliceHeight`, `format`, `quality`, `maxBytes`,
      `avoidLettering` selon la question 9).
- [ ] W3.2 — Capture découpée par le runner, encodage dans Chromium.
- [ ] W3.3 — `exports.json`, `--all-locales`.

Sortie : `recit export examples/toon-episode/episode-1.md --to images --preset webtoon-canvas`
produit des tranches de la bonne largeur, sous le budget, listées dans `exports.json`.

### PDF2 — Le PDF du webtoon (M)

Tâches PDF2.1 à PDF2.4 de `15` §6, sans changement. Dépend de : W1 et PDF1. Sortie : celle de `15`,
reprise dans les critères de P7.

### W4 — Le pont vers motion (M)

Dépend de : W1. Fichiers : `packages/formats/motion/src/schema/blocks.ts` (bloc `panel`),
`packages/formats/motion-blocks/src/blocks/panel.tsx` et son module, story et golden,
`packages/formats/toon/src/motion/`, `packages/cli/src/motion/source.ts` (lecture d'un toon),
`site-stories/vite.ts` (`storyMotion`), `scripts/style-rule-allowlist.json` si la couche d'animation
le demande.

- [ ] W4.1 — Spike : une image dans un plan rendu par Remotion (chemin, chargement, attente).
- [ ] W4.2 — Bloc `panel` (schéma, rendu, story, golden).
- [ ] W4.3 — `toonToFilm`, durées, voix off ; `recit render episode.md`.

Sortie : `recit render examples/toon-episode/episode-1.md` produit un MP4 `9:16` ; le film se joue
dans `<StoryEmbed format="motion">` avec les mêmes plans. Version plan `prerelease` pour
`@recit/motion` et `@recit/motion-blocks`.

### W5 — Exemple, site, doc (M)

Dépend de : W2, W4, et W3 + PDF2 pour la page d'exports. Fichiers : `examples/toon-episode/**`,
`sites/webtoon/**`, `packages/tools/site-kit/src/sites.ts`, `e2e/ports.ts`,
`scripts/e2e-projects.js`, `e2e/site-webtoon.spec.ts`, `e2e/toon-export.spec.ts`,
`e2e/storybook-toon.spec.ts`, `sites/docs/pages/…/formats/webtoon.mdx`, `NOTICE.md`.

- [ ] W5.1 — `examples/toon-episode` (en, fr), panneaux du §8.
- [ ] W5.2 — `sites/webtoon`, entrée `webtoon` de `SITES`.
- [ ] W5.3 — Page docs.recit.dev › Formats › Webtoon, syntaxe et JSON Schema.
- [ ] W5.4 — e2e et captures : `pnpm test:e2e:update:linux e2e/site-webtoon.spec.ts`.

Sortie : les critères de P7 ci-dessous, sauf la mise en ligne.

### W6 — Séries, MCP, scénario (M)

Dépend de : W1 (et la question 8 pour les séries). Fichiers : `SeriesSchema` dans `@recit/core`,
`EpisodeNav`, `examples/toon-series`, `packages/mcp/src/{server.ts,guide.ts}`,
`examples/scenario-lorem` (avec le plan 14, IA0.3).

- [ ] W6.1 — `describe_format` pour `toon`, `validate_story`, `recit://guide/toon`.
- [ ] W6.2 — Micro-récit `toon` de `examples/scenario-lorem`, cases de remplacement.
- [ ] W6.3 — Séries : `series.json`, navigation entre épisodes, `examples/toon-series`.

Sortie : un agent qui lit `recit://guide/toon` écrit un épisode Lorem qui passe
`recit validate --strict` du premier coup ou avec les seules corrections signalées.

### W7 — Clôture (S)

`pnpm verify`,
`pnpm test:e2e:linux e2e/site-webtoon.spec.ts e2e/toon-export.spec.ts e2e/storybook-toon.spec.ts`,
seuils de couverture, version plans relus, glossaire d'`AGENTS.md` proposé à l'intégrateur (case,
épisode, lettrage, tranche), note datée dans `journal.md`.

### Critères de sortie de P7

Ceux de `06` P7, inchangés :

- `examples/toon-episode` se lit au scroll, en deux langues ;
- l'export en tranches respecte les préréglages des plateformes ;
- `recit render` produit un film `9:16` (la forme exacte de la commande : question 10) ;
- `examples/toon-episode` produit un PDF par langue, sans case coupée, sous son budget de poids,
  listé dans `exports.json` ;
- webtoon.recit.dev est en ligne (`toon.recit.dev` redirige), et la version `0.3.0-alpha` est
  publiée (après le go de l'utilisateur).

S'y ajoutent, proposés par ce plan : le jumeau de chaque exemple fait l'aller-retour sans perte ;
tous les composants ont leur story avec `play` et passent axe.

## 11. Risques

| Risque                                                               | Parade                                                                                                                         |
| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| P6 en retard : pas de runner, pas de `--to`                          | W0, W1, W2, W4 n'en dépendent pas ; W3 et PDF2 attendent, P7 ne sort pas sans eux                                              |
| Poids des images sur mobile                                          | budget avertissant, formats modernes recommandés, chargement paresseux, dimensions intrinsèques ; `srcset` selon la question 4 |
| Débordement des bulles après traduction (l'allemand allonge de 30 %) | bulles extensibles, avertissement par langue au rendu, captures en deux langues                                                |
| Le bloc `panel` ne se rend pas proprement dans Remotion              | spike W4.1 avant le reste ; le pont peut sortir après P7 sans bloquer le lecteur                                               |
| Le tableau unique `lettering` contredit `09` §2.3                    | question 1, tranchée avant W0                                                                                                  |
| Préréglages de plateformes qui changent                              | données versionnées, date de vérification, e2e sur la largeur et la hauteur seulement                                          |
| Captures instables (chargement paresseux, animations)                | `?settled=1` comme le motion, animations coupées en e2e, attente des images                                                    |
| Lecteur d'écran : bulles sans locuteur                               | `cast=` sur la bulle (question 5)                                                                                              |
| Tentation d'ajouter `sharp` « pour plus tard »                       | règle « une seule stack » ; question 4                                                                                         |

## 12. Questions ouvertes

1. **Lettrage en un tableau.** `09` §2.3 (accepté) écrit `bubbles[]`, `captions[]`, `sfx[]`. Ce plan
   propose un seul tableau ordonné `lettering[]` discriminé sur `type`, parce que trois tableaux
   perdent l'ordre de lecture et cassent l'aller-retour. D'accord pour corriger `09` ?
2. **Directives sur une ligne.** Garder la forme `:::bubble{…} texte :::` de `08` §2 (il faut alors
   l'apprendre au parseur, comme `:::notes`), ou ne garder que la forme sur plusieurs lignes
   (recommandé : une seule forme canonique) ?
3. **Fichier de langue « textes seulement ».** `08` §2 et le scénario 3 le décrivent ; il n'entre ni
   dans la variante complète de `09` ni dans le compagnon. Recommandation : variante complète
   (`episode.fr.md`) ou compagnon (`episode.fr.json`), pas de troisième forme. Sinon, il faut
   changer la parité de tous les formats.
4. **`sharp`.** `08` §2 et `03` §6 le prévoient pour la découpe. Ce plan propose de s'en passer :
   capture découpée et encodage dans Chromium, dimensions lues dans l'en-tête des images par un
   petit module maison. Contrepartie : pas de `srcset` généré au build en P7. D'accord ?
5. **Locuteur d'une bulle.** `cast=` sur `:::bubble` (nom tiré de la bible en P16), ou attendre
   `:::dialogue` ?
6. **Police de lettrage.** Quelle famille OFL par défaut (par exemple Comic Neue, licence à vérifier
   au moment de W1.3) ?
7. **`direction: rtl`.** `15` §3.5 dit qu'il « change l'ordre de lecture des bulles ». Ce plan
   propose que l'ordre du Markdown fasse toujours foi et que `rtl` ne change que `dir` et
   l'interface. D'accord ?
8. **Séries et récits en plusieurs fichiers.** `series.json` (`kind: "series"`) plutôt que
   `series.yaml`, et la même forme que les chapitres du livre (plan 25) ?
9. **Coupe des tranches.** Hauteur fixe sans égard au contenu (`15` §3.5) ou en évitant les bulles
   (`08` §2) ? Proposition : fixe par défaut, `avoidLettering` en option.
10. **Commande du film.** `recit render episode.md` (format reconnu) au lieu de
    `recit render --as motion` écrit dans `06` P7 et `08` §2 ?
11. **Second exemple.** `06` P12 demande deux exemples par format : `examples/toon-series` suffit-il
    (W6), ou faut-il un exemple en sens `rtl` ?
