# 23 — Report : le format `report` et report.recit.dev

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

Ce plan détaille la phase P6 de [06 · Feuille de route](/06-roadmap) : le format `report` (rapports
de données en HTML et en PDF paginé), la fondation `@recit/data` qui calcule les chiffres, et le
site report.recit.dev. Il précise et met à jour la spécification de [08 · Formats](/08-formats) §1
(tâches R1 à R6) sans revenir sur les décisions acceptées : d3 sans DOM pour les graphiques
(question 10 de `06`), table des matières **sans numéros** en P6 (question 2 de
[15 · Export PDF](/15-export-pdf)), réglages à plat (question 4 de `15`), blocs en directives dans
le `body` (règle B2 de `06`). Là où le code impose de préciser une de ces décisions, la précision
est posée en question ouverte (§12).

L'export PDF du report n'est pas redécrit ici : il appartient à PDF0 et PDF1 de
[15 · Export PDF](/15-export-pdf), logés dans P6. Ce plan ne dit que ce que le report y apporte (sa
vue imprimable, son extension de schéma) et dans quel ordre les lots s'enchaînent.

## 1. Pourquoi

**Un rapport trimestriel, une étude ou un tableau de bord figé s'écrit en Markdown, et chaque
chiffre affiché vient des données, jamais du texte.**

- **Des chiffres justes par construction.** Dans un rapport écrit à la main, le texte et le
  graphique divergent à la première mise à jour des données. Ici, `:value` et `::chart` lisent la
  même source au build : on change le CSV, on reconstruit, le texte suit.
- **Une source, deux sorties.** La même story donne une page web continue (sommaire, ancres, thème
  clair ou sombre) et un PDF A4 ou Letter balisé, avec page de titre et tableaux paginés, par la
  chaîne unique de `15`.
- **Le premier client de `@recit/data`.** `::chart`, `::table` et `:value` servent ensuite au deck,
  aux docs, aux cartes ([13 · Cartes](/13-cartes), qui partagent d3) et aux stories web (pont report
  → stories en P11) : P6 pose une fondation, pas un format isolé.
- **Un format lisible par les agents.** Les directives sont typées par zod, les erreurs donnent le
  fichier, la ligne et la colonne la plus proche (« did you mean »), et le MCP décrit les colonnes
  d'une source : un agent écrit un rapport juste sans deviner.

Non-objectifs de P6 : tableaux de bord interactifs (filtres, info-bulles en JS), requêtes SQL,
Parquet et DuckDB-wasm, camembert (`pie`, v2 selon `08`), table des matières numérotée (P8, moteur
`pagedjs`), mise à jour des données sans rebuild.

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

Relevé dans le code au 02/11/2026.

| Élément                                        | Où                                                                                  | Ce que le report en fait                                                                                                        |
| ---------------------------------------------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `StoryFormatSchema` (inclut `report`)          | `packages/core/src/types/index.ts`                                                  | rien à ajouter : le format est déjà déclaré, et `frontmatter.ts` l'accepte                                                      |
| `ReportSourceSchema = genericSource(...)`      | `packages/core/src/schema/source.ts`                                                | remplacé par un schéma strict (§3.4) : réglages et scènes typés, comme `DeckSourceSchema` et `MotionSourceSchema`               |
| `DataSourceSchema = z.record(..., unknown)`    | `packages/core/src/schema/source.ts` (champ `data` de chaque story)                 | typé (§3.2) ; le champ `data` existe déjà dans `storyFields` et dans `SidecarSchema`, donc le jumeau et le compagnon le portent |
| JSON Schema `report.json`                      | `packages/core/schema/v1/report.json`                                               | régénéré par `pnpm -F @recit/core schema:json` après R0                                                                         |
| `markdownToSource`, `sourceToMarkdown`         | `packages/core/src/schema/convert.ts`                                               | aller-retour du report sans code propre au format : le frontmatter `data:` va dans `data`, le reste dans `settings`             |
| Test d'aller-retour de tous les exemples       | `packages/core/src/__tests__/source-conversion.spec.ts`                             | couvre `examples/report-*` dès qu'ils existent, avec ou sans `.json` sur disque                                                 |
| `remarkRecitDirectives`, sanitisation          | `packages/core/src/parser/unified-pipeline.ts`, `compile/compile-story.ts`          | point d'accroche des directives de données (§3.3) ; aucun crochet d'extension n'existe aujourd'hui, R0 l'ajoute                 |
| `StoryDocument` (format `docs`)                | `packages/integrations/site-stories/src/StoryDocument.tsx`                          | modèle du rendu continu : scènes l'une après l'autre, titres rétrogradés ; le report y ajoute sommaire et ancres                |
| `Callout`, `Card`, `Columns`, `Mermaid`…       | `packages/components/src`                                                           | directives existantes, utilisables telles quelles dans une section                                                              |
| Runtime autonome du deck                       | `packages/formats/deck/src/runtime` (`DeckApp`, `mount`), `packages/cli/src/render` | modèle de `@recit/report/runtime` pour `recit dev` et `recit build` hors site                                                   |
| Aiguillage par format dans la CLI              | `packages/cli/src/motion/source.ts` (`readMotionKind`), `commands/dev.ts`           | généralisé en `readStoryFormat()` (§6.1)                                                                                        |
| Export PDF du deck                             | `packages/cli/src/commands/export-pdf.ts`                                           | migre dans `@recit/paged` en PDF1 ; le report s'y branche par sa vue imprimable                                                 |
| `@recit/data`, `@recit/report`, `@recit/paged` | `packages/data`, `packages/formats/report`, `packages/formats/paged`                | stubs privés (`export {}`, `0.0.0`) avec leur tag `scope:*` déjà posé                                                           |
| Fiche du format sur les sites                  | `packages/tools/site-kit/src/formats.ts` (`status: "planned"`, `phase: "P6"`)       | passe à `available` avec `site: "report"` en R8                                                                                 |
| `d3-dsv`, `d3-scale`, `d3-shape` 3             | lockfile (transitifs de Mermaid)                                                    | deviennent des dépendances directes de `@recit/data`, sans nouvelle bibliothèque                                                |
| `yaml` 2                                       | lockfile                                                                            | lecture des sources YAML                                                                                                        |

Ce qui manque et que ce plan crée : les schémas des directives de données, le crochet de directives
dans `compileStory`, `@recit/data` (chargement, requêtes, formatage, SVG), `@recit/report` (vue web,
vue imprimable, pont vers le deck), l'aiguillage de la CLI, les exemples, le site.

## 3. Le modèle

### 3.1 Une story `report`

Une scène est une **section** : le séparateur `---` en ouvre une, son premier titre est l'entrée du
sommaire, son `id` est l'ancre (`#regions`). Le titre du rapport est `meta.title`, jamais un titre
de scène. Les réglages s'écrivent à plat dans le frontmatter (pas de clé `report:`), et sont la
section `report` de `recit.config.ts` (`09-langues-et-json.md` §2.2).

```md
---
title: Lorem Ipsum Quarterly
format: report
lang: en
date: 2026-10-01
data:
  sales: ./data/sales.csv
  regions:
    src: ./data/regions.json
    columns: { name: string, revenue: number, growth: number }
toc: true
pdf:
  page:
    size: A4
  toc: true
---

--- id=summary

## Summary

Revenue reached :value{data=sales field=revenue agg=sum format=currency currency=EUR}, up
:value{data=sales field=revenue agg=growth by=month period=quarter format=percent} over the
previous quarter.

:::kpi
- :value{data=sales field=revenue agg=sum format=compact} Revenue
- :value{data=sales field=orders agg=sum} Orders
- :value{data=regions agg=count} Regions
:::

::chart{data=sales type=line x=month y=revenue title="Monthly revenue"}

--- id=regions

## Regions

::table{data=regions columns="name,revenue,growth" sort=-revenue limit=10}

:::callout{type=info}
Lorem ipsum dolor sit amet.
:::
```

### 3.2 Sources de données

Les sources sont nommées dans `data:`. Une entrée est un chemin (relatif au fichier de la story), un
objet qui précise le format et les colonnes, ou des lignes inline. La même forme vaut dans le
frontmatter, dans le jumeau (`data`) et dans un compagnon (`kind: "sidecar"`, qui peut remplacer le
chemin d'une source, par exemple en CI).

```ts
// packages/core/src/schema/data.ts — zod/mini, comme source.ts
import * as z from "zod/mini";

export const DataNameSchema = z.string().check(z.regex(/^[a-z][a-z0-9_-]*$/i));
export const DataColumnTypeSchema = z.enum(["string", "number", "date", "boolean"]);
export const DataCellSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]);

const columns = z.optional(z.record(z.string(), DataColumnTypeSchema));

export const DataSourceEntrySchema = z.union([
  z.string(), // ./data/sales.csv — the format comes from the extension
  z.strictObject({
    src: z.string(),
    format: z.optional(z.enum(["csv", "json", "yaml"])),
    /** Declared types; otherwise inferred from the first 100 rows */
    columns,
  }),
  z.strictObject({ rows: z.array(z.record(z.string(), DataCellSchema)), columns }),
]);

export const DataSourcesSchema = z.record(DataNameSchema, DataSourceEntrySchema);
export type DataSources = z.infer<typeof DataSourcesSchema>;
```

`DataSourcesSchema` remplace `DataSourceSchema` dans `storyFields` et `SidecarSchema` : roadmap et
calendar (P9, P10) en héritent. Règles :

- une source introuvable, illisible ou vide est une **erreur** avec la ligne du frontmatter ;
- une colonne déclarée absente, ou une cellule qui ne se lit pas dans le type déclaré, est une
  erreur avec le fichier de données et sa ligne (`data/sales.csv:14: "n/a" is not a number`) ;
- les dates s'écrivent en ISO 8601 (`2026-10`, `2026-10-01`) ; aucun format local n'est deviné ;
- les nombres d'un CSV s'écrivent avec un point décimal, sans séparateur de milliers. Le formatage
  local se fait à l'affichage (`Intl`), jamais à la lecture.

### 3.3 Les directives de données

Les quatre directives sont des **directives dans le `body`** de la scène (règle B2) : `SceneSource`
ne gagne aucun champ `charts[]`, `tables[]` ni `kpis[]` (`09` §2.3). Leurs attributs sont validés
par des schémas zod de `@recit/core`, et le cœur ne garde que leurs noms (règle B4) ; leur rendu vit
dans `@recit/data`.

```ts
// packages/core/src/schema/data.ts (suite)
export const AggregateSchema = z.enum([
  "sum", "avg", "min", "max", "count", "first", "last", "growth",
]);
export const ValueFormatSchema = z.enum(["number", "currency", "percent", "compact", "date"]);
export const PeriodSchema = z.enum(["month", "quarter", "year"]);

/** `field=value` pairs, comma separated: `region=North,year=2026` (equality only, v1) */
const where = z.optional(z.string());

export const ValueDirectiveSchema = z.strictObject({
  data: DataNameSchema,
  field: z.optional(z.string()), // required unless agg=count
  agg: AggregateSchema,
  /** growth: the date column and the period compared (last vs previous) */
  by: z.optional(z.string()),
  period: z.optional(PeriodSchema),
  where,
  format: z._default(ValueFormatSchema, "number"),
  currency: z.optional(z.string().check(z.length(3))), // ISO 4217
  digits: z.optional(z.int().check(z.gte(0), z.lte(6))),
});

export const ChartBlockSchema = z.strictObject({
  data: DataNameSchema,
  type: z.enum(["line", "bar", "area", "dot"]), // pie: v2 (08 §1)
  x: z.string(),
  y: z.string(), // one or more columns: "revenue,cost"
  color: z.optional(z.string()), // a column that splits the series
  stack: z._default(z.boolean(), false),
  where,
  title: z.optional(z.string()),
  /** Alternative text; generated from the data when absent */
  alt: z.optional(z.string()),
  format: z.optional(ValueFormatSchema), // y axis
});

export const TableBlockSchema = z.strictObject({
  data: DataNameSchema,
  columns: z.optional(z.string()), // "name,revenue,growth"; all by default
  sort: z.optional(z.string()), // "-revenue" descending
  limit: z.optional(z.int().check(z.gte(1))),
  where,
  caption: z.optional(z.string()),
});

/** :::kpi — a list of `:value` with their label; no attribute of its own in v1 */
export const KpiBlockSchema = z.strictObject({ cols: z.optional(z.int().check(z.gte(1), z.lte(6))) });
```

Les noms exacts des API zod/mini (`z._default`, `z.length`) sont à vérifier à l'implémentation,
comme dans `15` §3.2. Les types sont dérivés (`z.infer`) et exportés par `@recit/core/schema` ;
aucun package ne les redéfinit.

| Directive             | Rendu web et PDF                                                                                                                    | Erreurs (avec la ligne)                                                                                          |
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `:value{…}`           | un `<data value="…">` avec le texte formaté par `Intl.NumberFormat` ou `Intl.DateTimeFormat` de la locale                           | source ou champ inconnus (« did you mean »), `agg` sur une colonne non numérique, `growth` sans `by` ni `period` |
| `::chart{…}`          | `<figure>` avec un SVG rendu au build (d3 sans DOM), `role="img"`, `<title>`, `<desc>` généré (min, max, tendance), légende en HTML | colonne `x` ou `y` inconnue, `y` non numérique, plus de 8 séries (illisible : erreur, pas d'avertissement)       |
| `::table{…}`          | `<table>` avec `<caption>`, `<thead>` répété à chaque page en PDF, nombres alignés et formatés                                      | colonne inconnue dans `columns` ou `sort`                                                                        |
| `:::kpi`              | grille de chiffres clés (`<dl>`), une entrée par élément de liste                                                                   | élément sans `:value`                                                                                            |
| directives existantes | `:::callout`, `:::card`, `:::col`, Mermaid, KaTeX, code, inchangées                                                                 | celles d'aujourd'hui                                                                                             |

**Le crochet de directives (R0).** `compileStory` n'a pas de point d'extension :
`createSlidePlugins` fixe la liste des plugins remark et rehype. R0 ajoute une option typée :

```ts
// packages/core/src/compile/compile-story.ts
export type DirectiveHandler = (
  node: DirectiveNode, // mdast, with its position
  context: { scene: SceneLocation; locale: string; report: (diagnostic: Diagnostic) => void },
) => HastElement | undefined;

export type CompileStoryOptions = {
  // …existing options
  /** Handlers of registered directive names, supplied by the foundation that owns them (B4) */
  directives?: Partial<Record<DirectiveName, DirectiveHandler>>;
};
```

- `DirectiveName` est l'énumération du registre du cœur (`DirectiveNameSchema`, déjà annoncée par
  `15` §3.2 pour le manifeste d'exports) ; R0 y inscrit `value`, `chart`, `table` et `kpi`.
- Le nœud produit est inséré **après** `rehypeSanitize` : il est généré par Récit, jamais copié du
  source, et chaque texte venu des données passe par un nœud texte hast (échappé). L'allowlist de
  sanitisation n'a donc pas à accepter le SVG de l'auteur.
- Une directive enregistrée sans gestionnaire (un deck compilé sans `@recit/data`) donne une erreur
  claire (`::chart needs @recit/data; recit build loads it for you`), jamais un `div` vide.

### 3.4 Le jumeau JSON et les réglages typés

Le report n'a **aucun champ de scène propre** (`09` §2.3) : la scène JSON a `id`, `attrs`, `body`,
`notes`. Les réglages sont typés à leur phase (L6) :

```ts
// packages/core/src/schema/source.ts
export const ReportSettingsSourceSchema = z.strictObject({
  /** A named theme (`report`, `warm-editorial`) or theme settings */
  theme: z.optional(z.union([ThemeModeSchema, z.string(), ThemeConfigSchema])),
  /** Side table of contents on the web page */
  toc: z._default(z.boolean(), true),
  /** Numbered section headings (1, 1.1…) on the web and in the PDF */
  numbering: z._default(z.boolean(), false),
  /** Content rules of the report (§4.3): RulesConfigSchema plus `hardcodedNumbers` */
  rules: z.optional(ReportRulesSchema),
  /** PDF options: ReportPdfOptionsSchema of plan 15, typed in PDF1.6 */
  pdf: z.optional(ReportPdfOptionsSchema),
});

export const ReportSceneSourceSchema = z.strictObject(sceneFields);

export const ReportSourceSchema = z.strictObject({
  ...storyFields, // data: DataSourcesSchema
  format: z.literal("report"),
  settings: z.optional(ReportSettingsSourceSchema),
  scenes: z.array(ReportSceneSourceSchema),
});
```

`pdf` n'entre dans le schéma qu'avec PDF1.6 (« aucun champ avant sa phase », `15` §3.2) ; avant, R0
livre le schéma sans lui. Le schéma est **strict** : une clé inconnue du frontmatter d'un report est
une erreur avec sa ligne, au lieu d'être ignorée (le deck garde `looseObject` pour ses clés
historiques). `ReportConfigSchema` (la section `report` de `recit.config.ts`) est le même objet,
avec `z.prefault` pour que ses défauts s'appliquent quand la section est absente (règle du
`AGENTS.md`).

Jumeau de l'exemple du §3.1 (`quarterly.json`) :

```json
{
  "$schema": "https://recit.dev/schema/v1/report.json",
  "kind": "story",
  "version": 1,
  "format": "report",
  "meta": { "title": "Lorem Ipsum Quarterly", "lang": "en", "date": "2026-10-01" },
  "data": {
    "sales": "./data/sales.csv",
    "regions": {
      "src": "./data/regions.json",
      "columns": { "name": "string", "revenue": "number", "growth": "number" }
    }
  },
  "settings": { "toc": true, "pdf": { "page": { "size": "A4" }, "toc": true } },
  "scenes": [
    { "id": "summary", "body": "## Summary\n\nRevenue reached :value{data=sales …}…" },
    { "id": "regions", "body": "## Regions\n\n::table{data=regions …}\n\n:::callout{type=info}…" }
  ]
}
```

- Le jumeau référence les **mêmes fichiers de données** : ils ne sont jamais copiés dans le JSON.
  Des lignes inline (`rows`) sont permises dans les deux formes (YAML du frontmatter, JSON).
- Le multilingue suit `09` : `quarterly.fr.md`, ou des `body` en `Localized`. Les libellés (`title`,
  `caption`, `alt` des directives) sont dans le `body`, donc traduits avec lui. Les données restent
  les mêmes ; le formatage suit la locale (`1 234,5 €` en `fr`).
- `schema/parity.ts` vérifie que `data` et `settings` sont identiques d'une langue à l'autre, sauf
  `pdf.output`.

### 3.5 Le modèle compilé

Le report compile vers la `Story` commune, sans second modèle (règle 5 de `context.md`) : chaque
scène a son `html` où les directives sont déjà rendues (texte formaté, SVG, tableaux). Les données
**ne voyagent pas** vers le client : `ClientStory` ne contient que le HTML, pas les lignes. Les
seules informations propres au format sont, sous une clé dédiée (`02-architecture.md` §2),
l'empreinte des sources (`report.data: { name, file, rows, sha256 }[]`), pour `exports.json`, la
surveillance de `recit dev` et le cache du build.

## 4. Le rendu

### 4.1 Web

- **`@recit/report`** (formats, publié) expose :
  - `.` : `Report` (racine), `ReportToc` (sommaire latéral, section courante suivie au scroll avec
    `IntersectionObserver`, repli en tiroir sur mobile), `ReportSection`, `ReportHeader` (titre,
    auteur, date localisée) ;
  - `./runtime` : `ReportApp` et `mount`, pour la page autonome de `recit dev` et `recit build`,
    comme `@recit/deck/runtime` ;
  - `./print` : la vue imprimable (§4.2) ;
  - `./bridge` : le pont vers le deck (§5) ;
  - `./styles.css` : la feuille compilée de ses modules (Lightning CSS, table typée
    `X.module.css.ts`), comme `@recit/deck`.
- **`@recit/data`** (fondation, publié) expose `Value`, `DataTable`, `Chart` (composants React qui
  reçoivent des données déjà évaluées, pour une page MDX ou une story de Storybook), `./node`
  (chargement des fichiers, Node seulement) et `./directives` (les gestionnaires du §3.3). Le rendu
  du SVG est une fonction pure, `renderChart(spec, rows, options) → hast`, sans DOM, appelée au
  build.
- **Style** : plain CSS modules (pas de Mantine dans un package publié, pas de PostCSS), loi du plan
  19 ([19 · Style](/19-style)). Les couleurs des séries viennent des jetons du thème par des classes
  du module (`classes.series1` à `series8`, `light-dark()`), jamais par un attribut `fill` en dur :
  le même SVG suit le mode sombre. Un thème nommé `report` rejoint `@recit/theme` (typographie de
  lecture, interlignage, tableaux), comme le prévoit R3 de `08`.
- **Intégration au framework de sites** : `@recit/site-stories` route une story `format: report`
  vers `Report` (comme elle route `motion` vers `MotionViewer`), et `StoryEmbed` accepte
  `format="report"` (rendu dans la page, sans plein écran). `Report` n'est pas un module lourd au
  sens du `AGENTS.md` (aucun Shiki ni Mermaid à l'entrée : le HTML est déjà rendu) ; il reste chargé
  dans le code découpé par route du plugin.
- **Accessibilité** : `<main>`, un `<nav aria-label>` pour le sommaire, un
  `<section aria-labelledby>` par scène, `<figure>` et `<figcaption>` pour les graphiques, et, sous
  chaque graphique, un `<details>` « Data » qui montre le `DataTable` des points tracés (le tableau
  accessible, comme pour `::map` dans `13`).
- **Rien de calculé dans le navigateur** : `Intl` tourne au build, en Node. Le HTML prérendu et
  l'hydratation affichent donc le même texte, et les captures ne dépendent pas de l'ICU du
  navigateur.

### 4.2 PDF, par le plan 15

Le report est, avec le deck, le premier client de la chaîne de `15` (PDF1). Il n'en fournit que deux
choses :

- **sa vue imprimable** (`@recit/report/print`, rendue sur `?print=1`) : page de titre (titre,
  description, auteur, date), table des matières **en liens, sans numéros**, sections dans l'ordre,
  `break-inside: avoid` sur les figures et les KPI, `<thead>` répété
  (`display: table-header-group`), sommaire latéral et contrôles masqués, puis `whenPrintable()` de
  `@recit/core/print` quand les polices sont chargées ;
- **son extension de schéma** (`ReportPdfOptionsSchema`, déjà esquissée dans `15` §3.2 : `engine`
  limité à `chromium` en P6, `tables.repeatHeader`).

En-tête courant (titre) et pied (numéro de page) passent par les boîtes de marge de Chromium,
générées par `pageCss(options)` de `@recit/paged`. Les signets viennent des titres de section
(`outline: true`, qui force `tagged`). `engine: pagedjs` et la table numérotée arrivent en P8
(PDF3.2), en option : aucun travail du report n'est prévu pour cela en P6.

### 4.3 Règles de contenu

- **Aucun chiffre en dur dans un `::chart`** : il n'a pas d'attribut de valeur, seulement des noms
  de colonnes.
- **Lint « nombre écrit à la main »** : un nombre de trois chiffres ou plus, ou suivi de `%`, `€`,
  `$`, dans une section qui contient une directive de données, donne un **avertissement** (`08` §9),
  désactivable par `rules.hardcodedNumbers: false` (`ReportRulesSchema`, qui étend
  `RulesConfigSchema` par `z.extend`). Les années (`2026`) et les numéros d'`id` sont exclus.
- **Densité** : les règles de densité du deck ne s'appliquent pas à une page continue. Le report
  garde seulement l'avertissement de section trop longue sans titre intermédiaire (au-delà de 600
  mots). Les règles du deck s'appliquent au résultat du pont (§5).

## 5. Le pont report → deck

`recit convert quarterly.md --to deck` produit une story `format: deck` en Markdown (ou son jumeau
JSON avec `-o deck.json`). La correspondance est fermée (`08` §0) :

| Report                                   | Deck                                                                                   |
| ---------------------------------------- | -------------------------------------------------------------------------------------- |
| `meta`, `data`                           | identiques                                                                             |
| une section (scène)                      | une slide, même `id`                                                                   |
| `::chart`, `::table`, `:::kpi`, `:value` | gardés tels quels (le deck les rend par les mêmes gestionnaires de `@recit/data`)      |
| `::table` de plus de 8 lignes            | `limit=8` ajouté, avec un avertissement                                                |
| prose au-delà des règles de densité      | **erreur** avec la ligne, jamais de résumé inventé : l'auteur coupe la section en deux |
| `toc`, `numbering`, `pdf`                | abandonnés (avertissement) ; `pdf` du deck reprend ses défauts                         |

La fonction est pure, `reportToDeck(source: ReportSource): { source: DeckSource; diagnostics }`,
dans `@recit/report/bridge`. Elle travaille sur la source, pas sur la `Story` compilée, pour que la
sortie soit une source éditable. Le deck gagne au passage les directives de données (un deck peut
contenir un `::chart` dès P6) : `@recit/deck` ne dépend pas de `@recit/data`, c'est la CLI et
`@recit/site-stories` qui passent les gestionnaires à `compileStory`.

## 6. CLI et MCP

### 6.1 La CLI

Aujourd'hui `dev`, `build` et `export` supposent un deck, sauf `dev`, qui aiguille vers motion par
`readMotionKind()`. R4 généralise l'aiguillage :

- `readStoryFormat(file)` (Markdown : `format` du frontmatter ; JSON : `format` du jumeau) remplace
  `readMotionKind()` comme premier test ; chaque format a son module, `packages/cli/src/report/`,
  comme `packages/cli/src/motion/` ;
- la CLI charge les sources par `@recit/data/node`, puis appelle `compileStory` avec
  `directives: dataDirectives(tables, locale)`.

| Commande                                    | Effet pour un report                                                                                | Lot    |
| ------------------------------------------- | --------------------------------------------------------------------------------------------------- | ------ |
| `recit init --format report`                | `report.md`, `data/sample.csv` (Lorem), `recit.config.ts` avec `--config`                           | R4     |
| `recit dev report.md`                       | page autonome, rechargement à chaque changement du `.md`, du `.json` **ou d'un fichier de données** | R4     |
| `recit build report.md`                     | site statique (`index.html`, assets) ; échoue sur une erreur de données                             | R4     |
| `recit export --to pdf report.md`           | PDF par `@recit/paged`, écrit dans `exports.json` ; `--all-locales` donne un PDF par locale         | PDF1.8 |
| `recit export --html report.md`             | HTML autonome (comme le deck), graphiques inclus                                                    | R4     |
| `recit validate report.md`                  | schéma, puis chargement des données et évaluation de chaque directive, sans build                   | R4     |
| `recit convert report.md --to json \| deck` | jumeau JSON ; pont vers le deck (§5)                                                                | R4, R5 |

Le flag `--to` de `convert` mélange aujourd'hui deux sens (`json`, `md` : la représentation ; `film`
: le modèle de rendu de motion) et son aide parle encore de `deck` pour le film (§13). Le pont
ajoute un troisième sens, un format cible : question 5.

### 6.2 Le MCP

`@recit/mcp` ne décrit aujourd'hui que `motion` et `deck` (`FORMATS` de
`packages/mcp/src/server.ts`).

- `describe_format` accepte `report` : syntaxe Markdown, directives et leurs schémas, JSON Schema du
  jumeau. Guide en ressource `recit://guide/report`, rédigé une fois et servi aussi par
  `recit skills export` (règle B9).
- Nouvel outil `describe_data` : pour une story et un nom de source, rend les colonnes, leurs types
  inférés, le nombre de lignes et trois lignes d'exemple. C'est ce qui permet à un agent d'écrire un
  `:value` juste du premier coup.
- La validation passe par l'outil de validation existant étendu à tous les formats, s'il existe à ce
  moment-là ; sinon par `recit validate`, que le guide cite.

## 7. Le site report.recit.dev

Un site de format comme motion.recit.dev (`sites/motion`) : du Markdown et un `recit.config.ts`,
aucun composant propre (règle « sites are Markdown and config »).

- `sites/report`, projet nx `report`, tag `scope:site`, port de dev **3004** (réservé par
  [07 · Domaines](/07-domaines) §4), preview e2e **4304** (`e2e/ports.ts`), projet Vercel
  `recit-report` avec son `vercel.json` (`nx-ignore report`), teinte **sarcelle**.
- `SITES.report` dans `packages/tools/site-kit/src/sites.ts`, fiche `report` de `formats.ts` passée
  à `available` avec `site: "report"`.
- Pages (`sites/report/pages`, en anglais) :
  - `index.mdx` : `FormatHero` avec `examples/report-quarterly` rendu dans la page, sa source
    dépliable, et, à côté, l'aperçu de son PDF (`ExportPanel`, qui lit `exports.json`) ;
  - `gallery.mdx` : `report-quarterly`, `report-study` ;
  - `from-data.mdx` : la démo « les chiffres viennent des données » (`:value`, `::chart`, `::table`
    avec leur source, et un `SourceToggle` Markdown ⇄ JSON) ;
  - `pdf.mdx` : le même rapport en PDF, ce que la chaîne garantit (balisage, signets) ;
  - `report-to-deck.mdx` : le pont, avec la slide obtenue en `StoryEmbed format="deck"`.
- `publish.entries` lit les exemples dans `examples/` (pas de copie). Le site déclare ces exemples
  en `devDependencies` (règle ST-20 du `AGENTS.md`), sinon un build affecté peut sauter le site.
- La doc du format reste sur docs.recit.dev (`Formats › Report`, `Components › Chart`, `Table`,
  `Value`, `Kpi`) : le site de format n'a pas de documentation (`07`).
- Les PDF sont produits en CI, jamais sur Vercel ; leur arrivée sur le site dépend de la question 8
  de `15` (déploiement prébuilt). Sans fichier, `ExportPanel` n'affiche pas de lien.

## 8. Les exemples

Contenu en Lorem Ipsum, aucun nom réel, données générées. Dossiers `examples/report-*` (règle B7),
tag `scope:example`, scripts `dev`, `build`, `export` comme `examples/motion-hello`.

| Exemple                     | Contenu                                                                                                                                               | Ce qu'il exerce                                                                                             |
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `examples/report-quarterly` | `quarterly.md`, `quarterly.fr.md`, `quarterly.json` (jumeau), `data/sales.csv` (24 mois), `data/regions.json`, `data/targets.yaml`, `recit.config.ts` | les trois formats de fichier, `:value` avec `growth`, `line`, `bar`, `::table`, `:::kpi`, deux langues, PDF |
| `examples/report-study`     | `study.md`, `study.json`, `data/survey.csv` (300 lignes, colonnes catégorielles), une section MDX                                                     | `area`, `dot`, `color=`, `stack`, `where`, un long tableau paginé en PDF                                    |

Les données sont produites une fois par un script déterministe (graine fixe) et **committées** : le
build ne génère rien au hasard, les captures sont stables. Le script lui-même est un `scripts/` de
l'exemple, hors de la chaîne de build.

## 9. Les tests

Suivant la pyramide de [21 · Tests](/21-tests).

- **Unitaires (vitest)**
  - `@recit/core` : parse de chaque schéma (§3.2 à §3.4), refus d'une clé inconnue, défauts par
    `z.prefault` ; crochet de directives (gestionnaire appelé avec la position, directive sans
    gestionnaire = erreur) ; le nœud inséré après la sanitisation ne laisse passer aucun texte non
    échappé (test avec un CSV contenant `<script>`) ; JSON Schema régénéré.
  - `@recit/data` : lecture CSV, JSON, YAML, inférence des types, erreurs de cellule avec ligne ;
    chaque agrégation, `growth` sur des mois et des trimestres, `where` ; formatage `en` et `fr`
    (`Intl`, avec une ICU complète en CI) ; `renderChart` vérifié par la géométrie (nombre de
    points, domaine des échelles), pas par un snapshot de HTML (interdit par `21`).
  - `@recit/report` : `reportToDeck` (correspondance, erreur de densité avec ligne).
  - `@recit/cli` : `readStoryFormat`, aiguillage de `dev`, `build`, `validate` sur un report ;
    message d'erreur de `:value` inconnu identique à `08` §1.
- **Jumeau** : `source-conversion.spec.ts` couvre `examples/report-*` sans code nouveau
  (`compile(md) == compile(toJson(md))` et inversement). Un cas ajouté vérifie l'égalité du **HTML
  rendu** avec les données chargées, pas seulement des sources.
- **Stories avec `play`** (une par composant, axe en erreur) : `Data/Value`, `Data/DataTable` (tri
  au clavier si activé, en-têtes `scope`), `Data/Chart` (nom accessible, `<details>` qui s'ouvre et
  montre le tableau), `Report/Report` (le sommaire suit la section visible, le lien déplace le
  focus), `Report/ReportToc` (tiroir mobile : ouvre, `Escape` ferme). Titres ajoutés à
  l'arborescence de `21` §3.1, `node scripts/check-stories.js` les exige.
- **e2e (Playwright)**
  - `e2e/site-report.spec.ts` : chaque page du site, captures pleine page en clair et en sombre,
    aucune erreur console, ancres du sommaire ;
  - `e2e/report-export.spec.ts` : `recit export --to pdf` sur `examples/report-quarterly`, lu par
    `pdfjs-dist` (PDF0.4a) : nombre et taille des pages, signets égaux aux titres, PDF balisé,
    `Lang`, polices embarquées, en-tête répété d'un tableau coupé, pages rendues en PNG et comparées
    ;
  - `e2e/storybook-report.spec.ts` : la surface `Report` et `Data` du Storybook ;
  - `scripts/e2e-projects.js` reçoit les trois specs (sites `report` et `recit-storybook`).
  - Les captures `-linux` de référence ne s'écrivent que par
    `pnpm test:e2e:update:linux e2e/site-report.spec.ts e2e/report-export.spec.ts`.
- **Couverture** : un glob par package nouveau (`packages/data`, `packages/formats/report`) dans le
  `vitest.config.ts` racine, avec `autoUpdate`.

## 10. Lots

Les identifiants prolongent ceux de `08` §1 (R1 à R6) : R0 est ajouté avant, R7 à R9 après, et les
sous-tâches s'écrivent `R1.2` (règle B10). Chaque lot laisse `pnpm check` et les tests de ce qu'il
touche au vert, et ajoute son plan de version (`pnpm exec nx release plan`, `preminor` pour la
première publication de `@recit/data` et `@recit/report`, puis `prerelease`). `NOTICE.md` reçoit les
nouveaux chemins dans le même changement.

Ordre et parallélisme (vagues de [20 · Multi-agents](/20-multi-agents)) :

```
R0 ─┬─► R1 ─┬─► R3 ─┬─► R4 ─► R5 ─► R6 ─► R7 ─► R8 ─► R9
    │       │       │
PDF0 (plan 15) ─────┴─► PDF1 (plan 15, avec R3 pour la vue imprimable)
                    └─► lot ::map de 13 (GE1 à GE8), qui réutilise d3 de R1
```

### R0 — Les types du report dans `@recit/core` (S)

- [ ] R0.1 — `schema/data.ts` : `DataSourcesSchema`, schémas des quatre directives, types dérivés ;
      `DataSourceSchema` remplacé dans `storyFields` et `SidecarSchema`.
- [ ] R0.2 — `ReportSettingsSourceSchema` (sans `pdf`), `ReportSceneSourceSchema`,
      `ReportSourceSchema` strict ; `ReportConfigSchema` dans `config/validate.ts` (section
      `report`, `z.prefault`).
- [ ] R0.3 — `DirectiveNameSchema` avec `value`, `chart`, `table`, `kpi` ; option `directives` de
      `compileStory`, insertion après la sanitisation, erreur pour une directive sans gestionnaire.
- [ ] R0.4 — JSON Schemas régénérés (`report.json`, `sidecar.json`, `story.json`).

**Sortie :** un JSON de report invalide échoue avec le chemin du champ ; un report Markdown fait
l'aller-retour ; `compileStory` accepte un gestionnaire de test.

### R1 — `@recit/data` (M)

- [ ] R1.1 — Package publié (`private` retiré, `tsdown`, exports `.`, `./node`, `./directives`,
      `./styles.css`), dépendances `d3-dsv`, `d3-scale`, `d3-shape`, `yaml` (déjà dans le lockfile).
- [ ] R1.2 — Chargement et validation des sources, inférence des types, diagnostics avec fichier et
      ligne.
- [ ] R1.3 — Requêtes : agrégations, `where`, `growth` ; formatage par `Intl`.
- [ ] R1.4 — `renderChart` (hast, sans DOM) pour `line`, `bar`, `area`, `dot` ; axes, légende,
      `<desc>` généré.
- [ ] R1.5 — Gestionnaires des directives (`:value`, `::chart`, `::table`, `:::kpi`) avec « did you
      mean » ; composants `Value`, `DataTable`, `Chart` et leurs stories.

**Sortie :** un test compile `examples/report-quarterly` (fixture provisoire) en HTML sans DOM ; un
champ mal écrit donne `report.md:12 :value: field "revnue" not in sales (did you mean "revenue"?)`.

### R2 — La chaîne PDF (plan 15)

- [ ] PDF0 puis PDF1 de [15 · Export PDF](/15-export-pdf), sans changement. R3 fournit la vue
      imprimable dont PDF1.7 a besoin ; PDF1.6 ajoute `pdf` à `ReportSettingsSourceSchema`.

### R3 — `@recit/report` (M)

- [ ] R3.1 — Package publié ; `Report`, `ReportToc`, `ReportSection`, `ReportHeader` et leurs
      stories ; modules CSS compilés, mode sombre par `light-dark()`.
- [ ] R3.2 — `./runtime` (`ReportApp`, `mount`) pour la page autonome.
- [ ] R3.3 — `./print` : la vue imprimable du §4.2, branchée sur `whenPrintable()`.
- [ ] R3.4 — Thème nommé `report` dans `@recit/theme`.

**Sortie :** les stories `Report/*` passent avec axe ; la vue imprimable se rend sur `?print=1`.

### R4 — CLI (M)

- [ ] R4.1 — `readStoryFormat()`, module `packages/cli/src/report/`, aiguillage de `dev`, `build`,
      `export --html`, `validate`.
- [ ] R4.2 — Surveillance des fichiers de données dans `recit dev`.
- [ ] R4.3 — `recit init --format report`.
- [ ] R4.4 — Avec PDF1.8 : `recit export --to pdf` sur un report.

**Sortie :** `recit build` et `recit export --to pdf` fonctionnent sur `examples/report-quarterly`
(critère de P6).

### R5 — Pont report → deck (S)

- [ ] R5.1 — `reportToDeck` dans `@recit/report/bridge`, tests de correspondance et de densité.
- [ ] R5.2 — `recit convert --to deck` (forme du flag selon la question 5) ; la CLI et
      `@recit/site-stories` passent les gestionnaires de données au deck.

### R6 — Exemples (S)

- [ ] R6.1 — `examples/report-quarterly` (en, fr, jumeau), données générées et committées.
- [ ] R6.2 — `examples/report-study`.

### R7 — Intégration aux sites et au MCP (S)

- [ ] R7.1 — `@recit/site-stories` : route `format: report`, `StoryEmbed format="report"`,
      chargement des données et surveillance dans le plugin Vite (`addWatchFile`), index Pagefind
      des sections.
- [ ] R7.2 — MCP : `describe_format report`, `describe_data`, guide `recit://guide/report`.

### R8 — report.recit.dev et la doc (M)

- [ ] R8.1 — `sites/report` (§7), `SITES.report`, fiche `available`, `vercel.json`, port e2e.
- [ ] R8.2 — `sites/docs` : `Formats › Report`, pages des composants de données.

### R9 — Tests transverses et clôture (S)

- [ ] R9.1 — Les trois specs e2e, captures `-linux` de référence, `scripts/e2e-projects.js`.
- [ ] R9.2 — Seuils de couverture, `pnpm verify` et `pnpm test:e2e:linux` au vert.

**Sortie de P6** (celle de `06`, inchangée, précisée) : `recit build` et `recit export --to pdf`
fonctionnent sur `examples/report-quarterly` ; chaque chiffre est calculé et un champ inconnu donne
une erreur avec ligne ; le PDF du deck passe par `@recit/paged` sans régression ; les PDF du deck et
du report sont balisés, ont des signets, sont vérifiés en e2e et listés dans `exports.json` ;
report.recit.dev est en ligne ; `0.2.0-alpha` est publiée sur validation explicite.

## 11. Risques

| Risque                                                                                          | Parade                                                                                                                                         |
| ----------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| Le formatage `Intl` diffère selon l'ICU (Node sans ICU complète, versions de navigateur)        | formatage au build seulement ; Node avec ICU complète en CI (vérifiée par un test `fr`) ; captures prises dans le conteneur Linux de référence |
| Un graphique d3 sans DOM moins soigné qu'Observable Plot (ticks, étiquettes qui se chevauchent) | quatre types seulement en P6 ; règles de ticks testées ; plus de 8 séries refusé                                                               |
| Injection par les données (un CSV qui contient du HTML)                                         | nœuds hast texte, insertion après la sanitisation, test dédié                                                                                  |
| Données lourdes dans le bundle client                                                           | les lignes ne quittent jamais le build ; `ClientStory` ne porte que le HTML ; `size-limit` sur `@recit/report`                                 |
| `growth` ambigu (quel point de comparaison ?)                                                   | `by` et `period` obligatoires ; définition écrite dans la doc et le guide MCP ; question 3                                                     |
| Le crochet de directives devient une API de plugins générale                                    | il n'accepte que les noms du registre (`DirectiveName`), pas des plugins remark arbitraires                                                    |
| Le deck gagne des directives de données sans les tests d'un format                              | le pont (R5) porte les tests du deck avec données ; captures du deck inchangées sans données                                                   |
| P6 surchargé (PDF0, PDF1, `::map`, report, site)                                                | ordre du §10 ; `::map` ne démarre qu'après R1 ; le site (R8) peut glisser sans bloquer le format                                               |
| PDF du site absent sur Vercel                                                                   | `ExportPanel` piloté par `exports.json` ; question 8 de `15`                                                                                   |

## 12. Questions ouvertes

1. **`agg` obligatoire.** Le §3.3 rend `agg` obligatoire sur `:value`, sans défaut, pour qu'aucun
   chiffre ne soit choisi implicitement. D'accord, ou `last` par défaut ?
2. **Filtre `where`.** Égalités seulement en v1 (`region=North,year=2026`). Faut-il des comparaisons
   (`>`, `<`) et des listes dès P6, au prix d'un petit langage à spécifier et à tester ?
3. **Définition de `growth`.** Proposé : dernière période complète de `by` comparée à la précédente,
   en `period` (`month`, `quarter`, `year`). Faut-il aussi la comparaison sur un an (`yoy`) ?
4. **Nombre écrit à la main.** Avertissement (proposé, `08` §9) ou erreur en mode `--strict` ?
5. **`recit convert --to deck`.** `--to` désigne déjà une représentation (`json`, `md`) ou le modèle
   de rendu de motion (`film`). Proposé : `--to <format>` pour un pont (`deck`), la représentation
   se déduisant de l'extension de `-o` (Markdown par défaut). Autre option : un flag distinct,
   `--format deck`.
6. **Schéma strict.** Un report refuse une clé inconnue du frontmatter (proposé), là où le deck
   l'ignore. D'accord pour tous les nouveaux formats, ou alignement sur le deck ?
7. **Thème `report`.** Un thème nommé dans `@recit/theme` (prévu par `08` R3), ou seulement des
   réglages typographiques du format sur les thèmes existants ?
8. **Graphiques interactifs.** Info-bulles et survol demandent du JS client. Hors de P6 (proposé) ;
   à prévoir en option après la 1.0 ?
9. **Directives de données dans le deck dès P6.** Le pont (§5) les y fait entrer. Faut-il les
   documenter et les montrer sur presentation.recit.dev dès P6, ou les garder discrètes jusqu'à P7 ?

## 13. Incohérences relevées dans d'autres documents (à corriger par l'intégrateur)

- `packages/cli/src/index.ts` : l'aide de `convert --to` (« deck (motion deck.json) ») et le message
  d'erreur (« --to must be json, md or deck ») parlent de `deck`, alors que la cible acceptée est
  `film` (`ConvertTarget` de `packages/cli/src/commands/convert.ts`) et que le vocabulaire réserve «
  deck » aux slides.
- `15-export-pdf.md` §2.1 décrit `STORY_FORMATS` comme « un tableau typé à la main, sans schéma zod
  » : le code dérive déjà `STORY_FORMATS` de `StoryFormatSchema` (`types/index.ts`). Une partie de
  PDF0.1 est faite.
- `context.md` (table des plans, ligne de `15`) et `03-packaging.md` §6 (ligne `@recit/cli`, «
  `export pdf --site` ») écrivent la commande en sous-commande, contre la règle DO-8 qu'ils citent
  (`recit export --to pdf`).
- `08-formats.md` §0 place `@recit/paged` dans `packages/tools/paged` comme un fait ; le stub est
  encore dans `packages/formats/paged` jusqu'à PDF1.0.
