Formats

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 : 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 §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), 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, 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, 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émentOùCe que le report en fait
StoryFormatSchema (inclut report)packages/core/src/types/index.tsrien à ajouter : le format est déjà déclaré, et frontmatter.ts l'accepte
ReportSourceSchema = genericSource(...)packages/core/src/schema/source.tsremplacé 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.jsonpackages/core/schema/v1/report.jsonrégénéré par pnpm -F @recit/core schema:json après R0
markdownToSource, sourceToMarkdownpackages/core/src/schema/convert.tsaller-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 exemplespackages/core/src/__tests__/source-conversion.spec.tscouvre examples/report-* dès qu'ils existent, avec ou sans .json sur disque
remarkRecitDirectives, sanitisationpackages/core/src/parser/unified-pipeline.ts, compile/compile-story.tspoint 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.tsxmodè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/srcdirectives existantes, utilisables telles quelles dans une section
Runtime autonome du deckpackages/formats/deck/src/runtime (DeckApp, mount), packages/cli/src/rendermodèle de @recit/report/runtime pour recit dev et recit build hors site
Aiguillage par format dans la CLIpackages/cli/src/motion/source.ts (readMotionKind), commands/dev.tsgénéralisé en readStoryFormat() (§6.1)
Export PDF du deckpackages/cli/src/commands/export-pdf.tsmigre dans @recit/paged en PDF1 ; le report s'y branche par sa vue imprimable
@recit/data, @recit/report, @recit/pagedpackages/data, packages/formats/report, packages/formats/pagedstubs privés (export {}, 0.0.0) avec leur tag scope:* déjà posé
Fiche du format sur les sitespackages/tools/site-kit/src/formats.ts (status: "planned", phase: "P6")passe à available avec site: "report" en R8
d3-dsv, d3-scale, d3-shape 3lockfile (transitifs de Mermaid)deviennent des dépendances directes de @recit/data, sans nouvelle bibliothèque
yaml 2lockfilelecture 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).

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

TypeScriptTypeScript
// 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.

TypeScriptTypeScript
// 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.

DirectiveRendu web et PDFErreurs (avec la ligne)
:value{…}un <data value="…"> avec le texte formaté par Intl.NumberFormat ou Intl.DateTimeFormat de la localesource 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 HTMLcolonne 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éscolonne inconnue dans columns ou sort
:::kpigrille 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éescelles 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 :

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

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

JSONJSON
{ "$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). 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) :

ReportDeck
meta, dataidentiques
une section (scène)une slide, même id
::chart, ::table, :::kpi, :valuegardés tels quels (le deck les rend par les mêmes gestionnaires de @recit/data)
::table de plus de 8 ligneslimit=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, pdfabandonné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).
CommandeEffet pour un reportLot
recit init --format reportreport.md, data/sample.csv (Lorem), recit.config.ts avec --configR4
recit dev report.mdpage autonome, rechargement à chaque changement du .md, du .json ou d'un fichier de donnéesR4
recit build report.mdsite statique (index.html, assets) ; échoue sur une erreur de donnéesR4
recit export --to pdf report.mdPDF par @recit/paged, écrit dans exports.json ; --all-locales donne un PDF par localePDF1.8
recit export --html report.mdHTML autonome (comme le deck), graphiques inclusR4
recit validate report.mdschéma, puis chargement des données et évaluation de chaque directive, sans buildR4
recit convert report.md --to json | deckjumeau 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 §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.

ExempleContenuCe qu'il exerce
examples/report-quarterlyquarterly.md, quarterly.fr.md, quarterly.json (jumeau), data/sales.csv (24 mois), data/regions.json, data/targets.yaml, recit.config.tsles trois formats de fichier, :value avec growth, line, bar, ::table, :::kpi, deux langues, PDF
examples/report-studystudy.md, study.json, data/survey.csv (300 lignes, colonnes catégorielles), une section MDXarea, 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.

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

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

RisqueParade
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 clientles 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éraleil 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 formatle 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 VercelExportPanel 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.