# 27 — Calendar : le format `calendar` et calendar.recit.dev

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

Ce plan détaille la phase P10 de [06 · Feuille de route](/06-roadmap) : le format `calendar`, la
seconde moitié de la fondation `@recit/time` (heures, fuseaux, récurrences, ICS en lecture), le type
`Place` (règle B3, porté par [12 · Glossaire et annuaire](/12-glossaire-annuaire)), le pont roadmap
→ calendar et le site calendar.recit.dev. Il part de [08 · Formats](/08-formats) §5 et le précise
sans le contredire ; les écarts que le code impose sont posés en questions ouvertes (§12). Il suit
le plan 26 (roadmap, P9), dont il reprend la table de visionneuses, l'horloge de build, l'écrivain
ICS, l'outil MCP `validate_story` et les variantes `ics` du manifeste `exports.json`.

Il dépend de [02 · Architecture](/02-architecture), de [03 · Packaging](/03-packaging) §6 et §8, de
[07 · Domaines](/07-domaines), de [09 · Langues et JSON](/09-langues-et-json), de
[12 · Glossaire et annuaire](/12-glossaire-annuaire) (`Place`, JSON-LD partagé),
[13 · Cartes](/13-cartes) (qui importe `Place` sans le redéfinir), de
[15 · Export PDF](/15-export-pdf) et de [21 · Tests](/21-tests). Le lot avancé de P10 du plan
[10 · Formulaires](/10-formulaires) (FO0, FO1) se déroule dans la même phase et n'est pas repris
ici. Le pont calendar → stories est le plan 28 (P11).

Chaque choix porte une **recommandation**. Rien n'est implémenté avant l'accord de l'utilisateur.

## 1. Pourquoi

- **Un agenda est une liste d'événements qui se lit de quatre façons** (mois, semaine, agenda,
  année) et s'exporte vers les agendas de tout le monde (ICS). Meetups, cours, sorties, planning
  éditorial : aujourd'hui, on les tient dans un tableur, un Google Agenda et une page web, à la main
  et en trois exemplaires. Récit en fait une source unique, en Markdown pour la prose, en JSON pour
  le multilingue et l'outillage.
- **C'est le cas d'usage type du JSON multilingue** (08 §5) : un `meetups.json` où chaque titre est
  un `Localized`, sans Markdown du tout, est la meilleure démonstration du jumeau typé.
- **Les fuseaux et les changements d'heure** sont le piège de tout calendrier. Les traiter une fois,
  dans une fondation testée (`@recit/time`), protège tous les formats qui auront un jour des heures.
- **Les formats se relient** : les jalons de la roadmap remontent dans le calendrier (critère de
  sortie de P10), un événement passé renvoie vers son deck ou son film, et les prochains événements
  deviennent une story (P11).

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

| Brique                 | État réel au 02/11/2026, ou à la fin de P9 (plan 26)                                                                           | Ce que P10 en fait                                                                                                             |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
| `CalendarSourceSchema` | `genericSource("calendar")` dans `packages/core/src/schema/source.ts` : `looseObject` sans champ typé                          | `strictObject` typé (§3.3)                                                                                                     |
| Pivot Markdown         | `compileStorySource` passe un JSON par `sourceToMarkdown` avant de le compiler                                                 | le codec du calendrier dans `SCENE_CODECS` (plan 26 RM0) ; les objets (`Place` inline) passent par la forme JSON du séparateur |
| Séparateur             | `parser/split-slides.ts` : `key=value` avec guillemets, ou `--- {…}` en JSON ; une valeur ne tient que sur **une** ligne       | les attributs d'un événement restent sur la ligne du séparateur, ou passent en forme JSON                                      |
| Compagnon              | fusion par `id` de scène (`mergeSidecar`), listes à clé ajoutées par le plan 26                                                | un événement est une scène : rien de plus à faire                                                                              |
| `@recit/time`          | après P9 : dates civiles, trimestres, `today()`/`now()` sur `SOURCE_DATE_EPOCH`, écrivain ICS de journées entières             | heures et fuseaux, récurrences, `VTIMEZONE`, lecture ICS (§3.5, §4.4)                                                          |
| `Temporal`             | absent de Node 22.22, la version du dépôt (vérifié : `typeof Temporal` vaut `undefined`)                                       | polyfill **côté build seulement** ; le client n'utilise que `Intl` (§3.5)                                                      |
| Semaine locale         | `new Intl.Locale("fr-FR").weekInfo` répond dans Node 22 (`firstDay: 1`) ; pas dans tous les navigateurs                        | calculé au build, jamais dans le client                                                                                        |
| `Place`, JSON-LD       | spécifiés par le plan 12 (D6, D11) : un seul `Place` dans `@recit/core/schema`, un module JSON-LD partagé `@recit/core/jsonld` | **livrés ici**, premier consommateur (B3)                                                                                      |
| `@recit/roadmap`       | après P9 : `storyToRoadmap`, jalons datés                                                                                      | lu par le pont (§3.7), dépendance format → format permise (03 §8)                                                              |
| Sites                  | table `FORMAT_VIEWERS`, modèle calculé au build, fichiers `.ics` écrits à côté des pages, `ExportPanel` (plan 26 §6.1)         | une ligne de plus, plus des **routes par événement** (§6.1)                                                                    |
| PDF                    | chaîne de [15 · Export PDF](/15-export-pdf), liste fermée : `calendar` imprime « mois ou agenda »                              | `CalendarPdfOptionsSchema`                                                                                                     |
| Squelette              | `packages/formats/calendar` : `export {}`, privé, `scope:format`                                                               | devient publié                                                                                                                 |

## 3. Le modèle

### 3.1 Une scène = un événement

Accepté dans 08 §0 : une scène est **un événement**. Un événement récurrent reste **une** scène, qui
produit plusieurs **occurrences** au rendu. La mention « (ou un jour) » de 08 §0 n'a pas de syntaxe
dans 08 §5 et n'est pas reprise : un jour est une case de la vue mois, pas une scène.

Conséquence pour le JSON : les événements sont les `scenes` du jumeau, comme pour tous les formats.
08 §5 et 09 §2.3 parlent d'un tableau `events[]` ; un tel tableau doublerait `scenes`, casserait la
fusion du compagnon par `id` de scène et le test d'aller-retour. Recommandation : pas d'`events[]`,
les événements sont les scènes (question 1).

### 3.2 La syntaxe Markdown

```md
---
title: Lorem Meetups
format: calendar
lang: en
timezone: Europe/Paris
view: month # month | week | agenda | year
firstDay: monday
---

--- id=kickoff start=2026-11-05T18:30 end=2026-11-05T20:30 location="Lorem Hall" tags=meetup

## Kickoff meetup

Lorem ipsum dolor sit amet, consectetur adipiscing elit.

--- id=office-hours start=2026-11-03T10:00 duration=1h rrule="FREQ=WEEKLY;BYDAY=TU;COUNT=8" url=https://example.com/live

## Office hours

--- id=course start=2026-10-06T18:30 duration=1h30m rrule="FREQ=WEEKLY;COUNT=6" exdate=2026-10-20T18:30 location=@lorem-hall

## Lorem course

--- id=launch date=2027-03-31 tags=release

## 1.0 launch

--- id=retreat date=2027-05-10 end=2027-05-12 film=./films/retreat.story.md

## Ipsum retreat
```

| Attribut                   | Rôle                                                                                                                                  |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `start`                    | heure murale locale `AAAA-MM-JJTHH:MM`, dans `timezone` de l'événement, sinon celle du calendrier                                     |
| `end` ou `duration`        | l'un ou l'autre, jamais les deux. `duration` : `1h`, `90m`, `1h30m` ou ISO 8601 (`PT1H30M`)                                           |
| `date`                     | un événement sur la journée entière ; avec `end` (date), sur plusieurs jours, **`end` compris** (§3.5)                                |
| `allDay`                   | accepté (08, 09) ; implicite avec `date`. `allDay=true` avec `start` est une erreur                                                   |
| `timezone`                 | fuseau IANA (`America/New_York`) ; interdit sur un événement `date`                                                                   |
| `rrule`, `exdate`          | récurrence RFC 5545 sans le préfixe `RRULE:` ; exceptions, liste séparée par des virgules, dans la même forme que `start` (ou `date`) |
| `location`                 | chaîne libre (affichage seul), `@id` d'une entrée d'annuaire, ou `Place` inline en JSON (plan 12 D6)                                  |
| `url`, `tags`              | lien (visio, billetterie), étiquettes filtrables                                                                                      |
| `status`                   | `confirmed` (défaut), `tentative`, `cancelled` : un événement annulé reste affiché barré et sort dans l'ICS avec `STATUS:CANCELLED`   |
| `deck`, `film`             | story liée : « voir le deck », « voir le film ». 08 §5 écrit `video=` ; le vocabulaire d'`AGENTS.md` dit **film**                     |
| `color`                    | une clarté de la teinte du site (`soft`, `base`, `strong`), jamais une couleur libre (08 §5 : teinte unique)                          |
| titre et corps de la scène | le premier titre est le titre de l'événement, le reste sa description en Markdown complet                                             |

Les attributs tiennent sur la ligne du séparateur : l'exemple de 08 §5 coupe celle d'`office-hours`
en deux lignes, ce que l'analyseur ne lit pas. Une ligne trop longue s'écrit en forme JSON
(`--- {"id": "office-hours", …}`), déjà acceptée par `parseInlineConfigWithDiagnostics`. Les sources
de stories ne passent jamais par oxfmt, qui ne les recoupera donc pas.

### 3.3 Le schéma (dans `@recit/core/schema`)

Primitives dans `packages/core/src/schema/time.ts` (créé par le plan 26), `Place` dans
`packages/core/src/schema/place.ts` (plan 12, livré ici), le reste dans `source.ts`.

```ts
import * as z from "zod/mini";

// time.ts — added by P10 next to PlainDateSchema and QuarterSchema (P9)
/** Wall-clock time without offset: "2026-11-05T18:30" (seconds optional) */
export const LocalDateTimeSchema = z
  .string()
  .check(z.regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?$/), z.refine(isValidLocalDateTime));
/** IANA time zone known to Intl: "Europe/Paris" */
export const TimeZoneSchema = z.string().check(z.refine(isKnownTimeZone, { message: "unknown time zone" }));
/** "1h", "90m", "1h30m" or ISO 8601 "PT1H30M" */
export const DurationSchema = z.string().check(z.refine(isDuration));
/** RFC 5545 RRULE value, without "RRULE:"; grammar checked here, expansion in @recit/time */
export const RRuleSchema = z.string().check(z.refine(isRRuleGrammar));

// place.ts — owned by plan 12, shipped by P10
export const PlaceSchema = z.strictObject({
  name: z.optional(LocalizedSchema),
  address: z.optional(z.string()), // display only
  street: z.optional(z.string()),
  city: z.optional(z.string()),
  postcode: z.optional(z.string()),
  region: z.optional(z.string()),
  country: z.optional(z.string().check(z.regex(/^[A-Z]{2}$/))),
  geo: z.optional(z.tuple([z.number(), z.number()])),
});
/** Free text, "@id" of a directory entry, or an inline Place */
export const LocationSchema = z.union([z.string(), PlaceSchema]);

// source.ts
export const CalendarSceneSourceSchema = z.strictObject({
  ...sceneFields, // id, attrs, body (description), notes
  title: z.optional(LocalizedSchema),
  start: z.optional(LocalDateTimeSchema),
  end: z.optional(z.union([LocalDateTimeSchema, PlainDateSchema])),
  duration: z.optional(DurationSchema),
  date: z.optional(PlainDateSchema),
  allDay: z.optional(z.boolean()),
  timezone: z.optional(TimeZoneSchema),
  rrule: z.optional(RRuleSchema),
  exdate: z.optional(z.array(z.union([LocalDateTimeSchema, PlainDateSchema]))),
  location: z.optional(z.union([LocalizedSchema, PlaceSchema])),
  url: z.optional(z.url()),
  tags: z.optional(z.array(z.string())),
  status: z.optional(z.enum(["confirmed", "tentative", "cancelled"])),
  deck: z.optional(z.string()),
  film: z.optional(z.string()),
  color: z.optional(z.enum(["soft", "base", "strong"])),
});

export const CalendarSettingsSourceSchema = z.strictObject({
  timezone: z.optional(TimeZoneSchema),
  view: z.optional(z.enum(["month", "week", "agenda", "year"])),
  firstDay: z.optional(z.enum(["monday", "sunday", "saturday"])),
  /** Occurrences computed around the build date */
  window: z.optional(z.strictObject({ past: z.optional(DurationSchema), future: z.optional(DurationSchema) })),
  maxOccurrences: z.optional(z.int().check(z.positive())),
  noOverlap: z.optional(z.boolean()),
  /** Roadmap stories whose dated milestones become events (§3.7) */
  include: z.optional(z.array(z.string())),
  // pdf: CalendarPdfOptionsSchema, added by CA3 (15 §3.2)
});

export const CalendarSourceSchema = z.strictObject({
  ...storyFields,
  format: z.literal("calendar"),
  settings: z.optional(CalendarSettingsSourceSchema),
  scenes: z.array(CalendarSceneSourceSchema),
});
```

- `location` accepte un `Localized` (une chaîne, ou une chaîne par langue : « Lorem Hall » et «
  Salle Lorem ») ; une chaîne qui commence par `@` est une référence d'annuaire (DI5.4, résolue plus
  tard par `@recit/directory`) ; un objet est un `Place`. Une seule résolution, dans le cœur (plan
  12 D6). Les noms exacts de zod/mini sont à vérifier à l'implémentation.
- Les défauts (`view: month`, `window: { past: P3M, future: P12M }`, `maxOccurrences: 500`,
  `firstDay` de la langue) sont posés par la section `calendar:` de `recit.config`
  (`CalendarConfigSchema`, `.prefault({})`), jamais par la source.
- Contrôles du schéma (`z.refine` au niveau de la scène) : exactement un de `start` ou `date` ;
  `end` et `duration` exclusifs ; `end` de même nature que le début (date avec `date`, heure avec
  `start`) ; `timezone` interdit sur un `date` ; `exdate` de même nature que le début.
- `timezone` du calendrier est **obligatoire** dès qu'un événement a une heure sans fuseau propre.
  Il n'y a pas d'heure « flottante » (sans fuseau) : elle change de sens d'un lecteur à l'autre.

### 3.4 Le jumeau et le compagnon

Le jumeau d'un calendrier Markdown est un JSON dont chaque scène porte ses champs typés ; un
calendrier peut aussi être **seulement** du JSON (`meetups.json`, `kind: "story"`, sans `.md`), ce
qui est la forme naturelle d'un agenda multilingue :

```json
{
  "$schema": "https://recit.dev/schema/v1/calendar.json",
  "kind": "story",
  "version": 1,
  "format": "calendar",
  "meta": { "title": { "en": "Lorem Meetups", "fr": "Rencontres Lorem" }, "lang": "en" },
  "settings": { "timezone": "Europe/Paris", "view": "month" },
  "scenes": [
    {
      "id": "kickoff",
      "title": { "en": "Kickoff meetup", "fr": "Rencontre de lancement" },
      "start": "2026-11-05T18:30",
      "end": "2026-11-05T20:30",
      "location": { "name": { "en": "Lorem Hall", "fr": "Salle Lorem" }, "city": "Ipsum", "country": "FR" },
      "tags": ["meetup"],
      "body": { "en": "Lorem ipsum dolor sit amet.", "fr": "Lorem ipsum dolor sit amet, en français." }
    }
  ]
}
```

- Codec dans `SCENE_CODECS` (plan 26 RM0) : `markdownToSource` lit le premier titre en `title`, les
  attributs typés du séparateur en champs, le reste en `attrs` ; `sourceToMarkdown` émet `## titre`,
  la description, et les attributs en `key=value` — ou en forme JSON du séparateur quand un champ
  est un objet (`Place` inline) ou contient un caractère qui ne se cite pas.
- Le pivot résout les `Localized` dans une langue (`compileStorySource`), si bien que le calendrier
  compile une fois par langue, comme le deck.
- Le compagnon complète un événement par son `id` (statut, `url` ajoutée par un outil, `geo` d'un
  lieu) ; `recit convert meetups.ics --to json` est la seule voie pour importer un agenda existant
  (§5), jamais une fusion silencieuse.

### 3.5 Dates, heures et fuseaux

Le cœur du plan. Principes, testés un par un (§9) :

1. **On stocke l'heure murale et son fuseau, jamais un instant UTC.** `start=2026-11-05T18:30` dans
   `Europe/Paris` est « 18 h 30 à Paris », quoi qu'il arrive aux règles de fuseau. L'instant est
   calculé au build par `Temporal.ZonedDateTime`. C'est la règle de 08 §9 (« les heures sont
   toujours stockées avec leur fuseau, et l'affichage est converti »).
2. **Une récurrence garde l'heure murale.** Un cours hebdomadaire à 18 h 30 reste à 18 h 30 à Paris
   après le passage à l'heure d'hiver (25/10/2026) ; c'est l'instant UTC qui se décale. Exemple de
   `course` : occurrences du 06/10 au 10/11/2026, le 13/10 à 16 h 30 UTC, le 27/10 à 17 h 30 UTC.
3. **Heure inexistante ou ambiguë.** `2026-03-29T02:30` n'existe pas à Paris ; `2026-10-25T02:30`
   existe deux fois. Résolution `disambiguation: "compatible"` de Temporal (comme RFC 5545 : décalée
   d'une heure vers l'avant pour l'inexistante, la première pour l'ambiguë), avec un avertissement
   (`calendar-nonexistent-time`, `calendar-ambiguous-time`) qui porte la ligne.
4. **`duration` est un temps écoulé, `end` une heure murale.** `duration=2h` d'un événement à 1 h 30
   la nuit du changement d'heure dure deux heures réelles ; `end=…T03:30` finit à 3 h 30 au mur. Les
   deux sont légitimes et documentés.
5. **Journée entière = dates civiles, sans fuseau.** `date=2027-03-31` est le 31 mars partout. Dans
   l'ICS, `DTSTART;VALUE=DATE` et un `DTEND` **exclusif** (lendemain) : l'auteur écrit `end` compris
   (« du 10 au 12 mai »), le convertisseur ajoute le jour (question 5).
6. **Affichage dans le fuseau du calendrier, par défaut.** La page prérendue affiche les heures dans
   `timezone` du calendrier, écrite à côté (« 18:30 CET »), pour que le HTML soit le même pour tout
   le monde et que l'hydratation ne diverge pas. `TimezoneSwitcher` propose ensuite le fuseau du
   lecteur (`Intl.DateTimeFormat().resolvedOptions().timeZone`) et le retient en `localStorage`
   (préférence de confort, protégée par `try`). Le changement se fait **côté client par `Intl`**,
   sur les instants déjà calculés : un lecteur de New York voit le cours du 27/10 à 13 h 30, et
   celui du 03/11 à 12 h 30, sans `Temporal` dans le navigateur (question 6).
7. **Fenêtre de calcul.** Une récurrence sans fin (`rrule` sans `COUNT` ni `UNTIL`) est développée
   entre `today() - window.past` et `today() + window.future`, au plus `maxOccurrences`
   (avertissement `calendar-occurrences-capped`). L'ICS, lui, garde la règle `RRULE` entière : un
   client d'agenda la développe lui-même.
8. **Une horloge.** `today()` et `now()` de `@recit/time` (plan 26 §3.5), sur `SOURCE_DATE_EPOCH`.
   Les vues ouvrent sur le mois de l'horloge de build ; `UpcomingEvents` filtre côté client avec
   l'heure réelle, **après** l'hydratation, pour qu'un site construit lundi ne montre pas mardi un
   événement de lundi comme « à venir ».

Outils, dans `@recit/time` :

| Sous-export | Contenu                                                                                                                                           | Où il tourne                |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------- |
| `.`         | dates civiles, trimestres, semaines ISO, durées, `today()`, formatage `Intl` (P9)                                                                 | partout, client compris     |
| `./zoned`   | heure murale → instant, désambiguïsation, développement des récurrences, fenêtres ; charge le polyfill `Temporal` si `globalThis.Temporal` manque | Node (build, CLI) seulement |
| `./ics`     | écriture (P9 : journées entières ; P10 : heures, `TZID`, `VTIMEZONE`, `RRULE`, `EXDATE`, `STATUS`) et lecture                                     | Node ; pur                  |

Dépendances : un polyfill `Temporal` et `rrule`, déjà prévus par 03 §6 (« rrule, polyfill Temporal
»). Le développement des récurrences de `rrule` est réputé fragile aux changements d'heure quand on
lui passe un fuseau ; d'où le spike CA0 (§10), qui tranche entre `rrule` et un développement maison
sur `Temporal`, limité au sous-ensemble utile (`FREQ`, `INTERVAL`, `COUNT`, `UNTIL`, `BYDAY`,
`BYMONTHDAY`, `BYMONTH`, `BYSETPOS`, `WKST`), avec les exemples de RFC 5545 §3.8.5.3 en tests
(question 2).

### 3.6 Les règles de validation

| Code                          | Gravité       | Cas                                                                          |
| ----------------------------- | ------------- | ---------------------------------------------------------------------------- |
| `calendar-date-order`         | erreur        | `end` avant `start` (ou avant `date`)                                        |
| `calendar-start`              | erreur        | ni `start` ni `date`, ou les deux ; `end` et `duration` ensemble             |
| `calendar-unknown-timezone`   | erreur        | fuseau inconnu d'`Intl`                                                      |
| `calendar-missing-timezone`   | erreur        | événement à l'heure sans fuseau, dans un calendrier sans `timezone`          |
| `calendar-invalid-rrule`      | erreur        | grammaire fausse, `UNTIL` et `COUNT` ensemble, `BYDAY` invalide              |
| `calendar-exdate-mismatch`    | avertissement | `exdate` qui ne tombe sur aucune occurrence                                  |
| `calendar-nonexistent-time`   | avertissement | heure murale qui n'existe pas (passage à l'heure d'été)                      |
| `calendar-ambiguous-time`     | avertissement | heure murale qui existe deux fois (passage à l'heure d'hiver)                |
| `calendar-overlap`            | erreur        | deux occurrences se chevauchent, seulement si `noOverlap: true`              |
| `calendar-occurrences-capped` | avertissement | développement arrêté à `maxOccurrences`                                      |
| `calendar-unknown-place`      | erreur        | `location=@id` sans entrée d'annuaire (quand `@recit/directory` est présent) |

Toutes portent fichier et ligne, ou chemin JSON (`meetups.json /scenes/3/rrule`).

### 3.7 Le pont roadmap → calendar

`include: [../roadmap/product.md]` dans le frontmatter (08 §5). Le calendrier compile chaque roadmap
listée avec `storyToRoadmap` (`@recit/roadmap`) et en tire un événement `date` par **jalon daté**
(`id` préfixé : `product.v1`), avec les tags `milestone` et le slug de la roadmap. Les éléments
datés n'entrent que si la roadmap le demande (`ics.items: true`, plan 26 §4.4), en événements sur
plusieurs jours. Correspondance fermée (règle des ponts de 08 §0) : un jalon sans date ne passe pas,
sans erreur ; un `id` en double est une erreur.

Le chemin d'`include` est relatif au fichier et ne sort pas du projet ; sur un site, la roadmap doit
être publiée par le même site ou par un exemple déclaré en `devDependencies` (constat ST-20). Les
jalons inclus suivent la langue de la page (titres `Localized` de la roadmap).

### 3.8 Les langues

Localisables : `title`, `body`, le `name` d'un `Place`, une `location` libre. Communs : `id`,
heures, fuseaux, récurrences, `url`, `tags`, `status` (09 §2.3). `recit i18n check` compare en plus
les attributs temporels entre `meetups.md` et `meetups.fr.md` : une heure différente est une erreur
(attribut structurel). Les noms de jours et de mois, le premier jour de la semaine et le format des
heures (24 h ou 12 h) viennent d'`Intl`, dans la langue de la page.

## 4. Le rendu

### 4.1 Le modèle compilé

```ts
// packages/formats/calendar/src/model.ts
export const OccurrenceSchema = z.strictObject({
  event: z.string(), // scene id
  /** Instants, ISO 8601 with offset: "2026-10-27T18:30:00+01:00" */
  start: z.string(),
  end: z.string(),
  allDay: z.boolean(),
  /** For an all-day occurrence: civil dates, end inclusive */
  dates: z.optional(z.strictObject({ from: PlainDateSchema, to: PlainDateSchema })),
  cancelled: z.boolean(),
});

export const CalendarSchema = z.strictObject({
  title: z.string(),
  lang: LocaleTagSchema,
  timezone: TimeZoneSchema,
  view: z.enum(["month", "week", "agenda", "year"]),
  firstDay: z.int().check(z.gte(1), z.lte(7)),
  events: z.array(CalendarEventSchema), // id, title, html, location (resolved), url, tags, links
  occurrences: z.array(OccurrenceSchema), // sorted, inside the window
  window: z.strictObject({ from: PlainDateSchema, to: PlainDateSchema }),
  today: PlainDateSchema,
});
export type Calendar = z.infer<typeof CalendarSchema>;

export function storyToCalendar(story: Story, options?: { file?: string }): CalendarResult;
```

Le modèle est calculé au build, une fois par langue ; le client reçoit des instants avec leur
décalage, qu'`Intl.DateTimeFormat` affiche dans n'importe quel fuseau. Ni `Temporal` ni `rrule`
n'entrent dans un bundle client (règle « Bundle Size » d'`AGENTS.md`, `size-limit`).

### 4.2 Les quatre vues

| Vue      | Ce qu'elle montre                                                                                                                 | Structure accessible                                                    |
| -------- | --------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| `month`  | grille du mois, une case par jour, les événements en liens ; « +3 » quand la case déborde ; navigation mois précédent et suivant  | `<table>` avec `<caption>` (mois, année), en-têtes de jours `<th>`      |
| `week`   | sept colonnes, les heures en lignes, les événements placés par leur instant dans le fuseau affiché ; journées entières en bandeau | liste par jour sous la grille visuelle, lue par un lecteur d'écran      |
| `agenda` | liste des occurrences à venir, groupées par jour                                                                                  | `<ol>` de jours, chaque événement un `<article>` avec `<time datetime>` |
| `year`   | douze mini-mois, les jours occupés marqués ; un clic ouvre le mois                                                                | douze `<table>` titrées ; marque doublée d'un nombre                    |

La vue et la période se lisent dans l'URL (`?view=week&date=2026-11-02`), les filtres aussi
(`?tag=meetup`). Navigation au clavier : flèches entre les jours dans la grille, `Entrée` sur un
événement, `Escape` ferme le détail. Une semaine ou un mois hors de la fenêtre de build affiche «
hors de la période publiée », jamais une grille vide trompeuse.

Composants (08 §5) : `Calendar` (racine), `MonthView`, `WeekView`, `AgendaList`, `YearView`,
`EventCard`, `EventDetail` (`<dialog>` natif, et page propre §6.1, avec « ajouter à mon agenda »),
`MiniCalendar`, `UpcomingEvents`, `Countdown`, `TimezoneSwitcher` (`<select>` natif),
`CalendarFilters` (cases à cocher), `SubscribeButton`. Dans
`packages/formats/calendar/src/components/`, un module CSS et une story chacun.

Style : la loi du plan 19, comme le plan 26 §4.3 — React et modules CSS compilés par Lightning CSS,
une feuille `@recit/calendar/styles.css`, pas de Mantine dans un package publié, `light-dark()` pour
le mode sombre. La teinte est celle du site calendar (rouge, [07 · Domaines](/07-domaines) §4) ;
`color` ne choisit qu'une clarté. Un événement annulé se lit par le mot « Annulé » et le barré, pas
par la couleur.

### 4.3 La page d'un événement et le JSON-LD

Chaque événement a sa page (`/calendar/<slug>/<id>`), prérendue : titre, occurrences à venir, lieu,
description, liens vers son deck ou son film, `SubscribeButton` pour cet événement seul
(`/calendar/<slug>/<id>.ics`). Elle porte un JSON-LD `schema.org/Event` produit par le module
partagé `@recit/core/jsonld` (plan 12 D11) : `startDate` et `endDate` avec décalage, `location` en
`Place` avec `PostalAddress` quand l'adresse est structurée (jamais depuis `address` libre),
`VirtualLocation` quand seule `url` est donnée, `eventStatus` (`EventCancelled`),
`eventAttendanceMode`. Un événement récurrent émet ses occurrences de la fenêtre, en `eventSchedule`
si les moteurs le lisent, sinon une entrée par occurrence (à vérifier dans CA2).

### 4.4 Les exports

| Sortie         | Commande                             | Contenu                                                                                                      |
| -------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------ |
| Web            | `recit build meetups.json`           | les quatre vues, une page par événement                                                                      |
| ICS            | `recit export meetups.json --to ics` | `VCALENDAR` complet : un `VEVENT` par événement, `RRULE` et `EXDATE` gardées, `VTIMEZONE` par fuseau utilisé |
| Flux abonnable | au build d'un site                   | `/calendar/<slug>.ics` (un par langue : `/fr/calendar/<slug>.ics`), servi en `text/calendar; charset=utf-8`  |
| PDF            | `recit export meetups.json --to pdf` | le mois (une page par mois de la plage) ou l'agenda, A4 portrait                                             |

- **ICS fiable** : `UID` stable (`<id>@<slug>.<hôte>`, jamais régénéré) pour qu'un client abonné
  mette à jour au lieu de dupliquer ; `SEQUENCE` et `LAST-MODIFIED` dérivés d'un hachage du contenu
  de l'événement et de l'horloge de build ; `DTSTAMP` de l'horloge de build ; `TZID` IANA **et**
  `VTIMEZONE` calculé avec `Temporal` (transitions du fuseau sur la période des événements), parce
  qu'Outlook l'exige ; `X-WR-CALNAME`, `X-WR-TIMEZONE` et `REFRESH-INTERVAL;VALUE=DURATION:PT12H`
  pour les abonnés. Lignes pliées à 75 octets, CRLF.
- **Abonnement** : `SubscribeButton` propose `webcal://…`, Google Agenda (ajout par URL), Outlook
  (`addfromweb`) et le téléchargement du fichier ; aucun script tiers, aucun suivi.
- **PDF** :
  `CalendarPdfOptionsSchema = z.extend(PdfOptionsSchema, { view: month | agenda, from, months })` en
  CA3, section `calendar.pdf` et `pdf:` du frontmatter (15 §3.2), vue `?print=1` et
  `whenPrintable()`.
- `exports.json` : variante `ics` du plan 26, avec `subscribable: true` pour les flux.

## 5. CLI et MCP

| Commande                                 | Effet                                                                                                                              |
| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `recit init --format calendar [file]`    | `calendar.md` Lorem, ou `calendar.json` multilingue                                                                                |
| `recit dev`, `build`                     | comme le plan 26                                                                                                                   |
| `recit validate meetups.json`            | schéma, règles du §3.6, développement des récurrences dans la fenêtre                                                              |
| `recit convert meetups.md --to json\|md` | jumeau                                                                                                                             |
| `recit convert agenda.ics --to json\|md` | **import** d'un `.ics` existant en story `calendar` (CA1 de 08) ; les propriétés inconnues sont listées, jamais perdues en silence |
| `recit export … --to ics\|pdf\|html`     | §4.4 ; `--locale`, `--all-locales`                                                                                                 |
| `recit schema calendar`                  | JSON Schema                                                                                                                        |

L'import passe par `recit convert` (une commande par besoin, B9), comme Fountain au plan 14.

**MCP** : `describe_format` gagne `calendar` ; `validate_story` (plan 26) valide aussi un calendrier
; un outil `expand_events` rend les occurrences d'une story dans une fenêtre donnée et un fuseau
donné, pour qu'un agent vérifie une `rrule` avant de l'écrire ; guide `recit://guide/calendar`, qui
insiste sur l'heure murale et le fuseau.

## 6. Le site

### 6.1 L'intégration (`@recit/site-stories`)

- Une ligne `calendar` dans `FORMAT_VIEWERS` (plan 26 §6.1), chargée en `React.lazy`.
- **Routes par scène** : c'est le premier format qui publie une page par scène. Le plugin gagne une
  option par format, `sceneRoutes`, qui ajoute `/<routePrefix>/<slug>/<sceneId>` au prérendu, au
  sitemap, à Pagefind et aux routes de langue (`hreflang`). Le deck n'en a pas besoin et ne change
  pas.
- Les fichiers `.ics` (calendrier entier, et un par événement) sont écrits au build à côté des
  pages, et listés dans `exports.json`.
- `<StoryEmbed slug="meetups" view="agenda" />` et `<UpcomingEvents slug="meetups" count={3} />`
  dans une page MDX (recit.dev, docs) : ce dernier est le composant que www pourra montrer.

### 6.2 calendar.recit.dev

`sites/calendar`, sur `createSiteConfig({ site: "calendar" })`.

| Page                      | Contenu                                                                                                                  |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `/`                       | `FormatHero` : **le calendrier des versions de Récit** (§6.3), vue mois, `SubscribeButton`, source dépliable             |
| `/gallery`                | `calendar-meetups` (JSON multilingue) et `calendar-course` (Markdown, récurrences) dans les quatre vues, en `en` et `fr` |
| `/json`                   | démo « le même agenda en JSON » : le jumeau à côté du rendu, sélecteur de langue                                         |
| `/templates`              | meetups, cours, planning éditorial                                                                                       |
| `/calendar/<slug>[/<id>]` | stories en plein écran et pages d'événement (`publish.routePrefix: "/calendar"`, d'où `/calendar/<slug>.ics` de 08 §5)   |

Port local `3008`, port e2e `4308`, projet Vercel `recit-calendar`, entrée `calendar` dans `SITES`
([07 · Domaines](/07-domaines)). `vercel.json` pose le type `text/calendar` sur `*.ics`. Doc :
`sites/docs` › Formats › Calendar, avec une page « Fuseaux et changements d'heure ».

### 6.3 Dogfooding

Le hero de calendar.recit.dev (07 §2 : « calendrier des sorties et des événements Récit ») est fait
de **données réelles** et ne doit rien inventer : Récit n'a ni meetup ni cours. Proposition : une
story `sites/calendar/stories/releases.md` qui contient les versions publiées (`0.1.0-alpha`…,
journée entière, `film=` ou `deck=` vers leur annonce quand elle existe) et qui **inclut** les
jalons datés de la roadmap de Récit (plan 26 §7) par le pont du §3.7. C'est le critère de sortie «
les jalons de la roadmap remontent dans le calendrier », démontré sur Récit lui-même. Comme un site
ne dépend pas d'un autre site, la roadmap incluse doit être publiée par le même site : soit
calendar.recit.dev republie la story de roadmap (copie), soit elle vit à un endroit que les deux
sites peuvent déclarer (question 8).

## 7. Les exemples

Contenu en Lorem Ipsum, lieux fictifs (`Lorem Hall`, ville `Ipsum`, `example.com`), règle B7.

| Dossier                     | Contenu                                                                                                                                                                                                                                 |
| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `examples/calendar-meetups` | `meetups.json` seul (`kind: "story"`), titres et lieux `Localized` en `en` et `fr`, un `Place` inline, un événement annulé, une visio (`url` seule) ; `recit.config.ts` avec `i18n`                                                     |
| `examples/calendar-course`  | `course.md` : un cours hebdomadaire qui traverse le 25/10/2026, une `exdate`, des permanences sur `America/New_York` qui traversent le 01/11/2026, une journée entière sur trois jours, un `include` de `../roadmap-product/product.md` |

`calendar-course` déclare `recit-example-roadmap-product` en dépendance pour le pont (ST-20).
Gabarits `create-recit` : `calendar-meetups`, `calendar-course`.

## 8. Les risques

| Risque                                                    | Parade                                                                                                                                                  |
| --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Erreurs aux changements d'heure                           | heure murale et fuseau stockés ; `Temporal` pour les calculs ; tests sur les quatre transitions de 2026 (Paris 29/03 et 25/10, New York 08/03 et 01/11) |
| `rrule` faux avec un fuseau                               | spike CA0 avec go/no-go, repli sur un développement maison sur `Temporal`                                                                               |
| Base de fuseaux différente entre Node et navigateurs      | les instants sont calculés au build (ICU de Node, `full-icu`) ; le client ne fait que formater ; un test compare au fuseau `UTC`                        |
| HTML prérendu différent selon le lecteur (hydratation)    | prérendu dans le fuseau du calendrier, toujours avec `timeZone` explicite ; le fuseau du lecteur ne s'applique qu'après l'hydratation                   |
| Site statique périmé (« à venir » d'hier)                 | `UpcomingEvents` filtre côté client ; reconstruction planifiée du site (question 7)                                                                     |
| Clients ICS capricieux (Outlook, Apple, Google)           | `VTIMEZONE` toujours émis, `UID` stable, essais manuels des trois avant la sortie de P10 ; l'ICS est aussi relu en test                                 |
| Trop d'occurrences (récurrence quotidienne sans fin)      | fenêtre et `maxOccurrences`, avertissement                                                                                                              |
| Le polyfill `Temporal` alourdit le client                 | sous-export `./zoned` réservé à Node ; `size-limit` sur le bundle du calendrier                                                                         |
| Fuites de données personnelles (adresse d'un particulier) | `Place` structuré seulement pour des lieux publics ; l'avertissement « personne avec adresse » du plan 12 s'applique aux `location`                     |
| Un lieu libre pris pour une adresse dans le JSON-LD       | `address` libre jamais émis en `PostalAddress` (plan 12 D6)                                                                                             |

## 9. Les tests

| Niveau           | Quoi                                                                                                                                                                                                                                                                | Où                                                                                     |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| Unitaire, cœur   | schéma et `refine` (exclusivités, natures de dates, fuseau obligatoire), codec Markdown ⇄ JSON (forme JSON du séparateur pour un `Place`), `Place` et `location`                                                                                                    | `packages/core/src/__tests__/calendar-*.spec.ts`                                       |
| Aller-retour     | `examples/calendar-*` et `sites/calendar/stories`, y compris un calendrier **seulement JSON** (`json → md → json`)                                                                                                                                                  | `source-conversion.spec.ts`                                                            |
| Unitaire, time   | les quatre transitions de 2026 ; heures inexistantes et ambiguës ; récurrences : exemples de RFC 5545 §3.8.5.3, `EXDATE`, `UNTIL` en heure locale, `BYSETPOS` ; durées contre heures de fin ; `VTIMEZONE` ; ICS écrit puis relu, identique                          | `packages/time/src/__tests__/`                                                         |
| Unitaire, format | `storyToCalendar` : fenêtre, plafond, annulés, pont roadmap → calendar, `id` préfixés ; JSON-LD validé contre le schéma `Event` attendu                                                                                                                             | `packages/formats/calendar/src/__tests__/`                                             |
| Stories          | un `play` par composant : naviguer au clavier dans `MonthView`, ouvrir et fermer `EventDetail` (`Escape`), changer de fuseau et lire l'heure affichée (« 13:30 » à New York pour le 27/10), filtrer par tag, `SubscribeButton` qui expose ses liens ; axe en erreur | `packages/formats/calendar/src/components/*.stories.tsx`, titre `Calendar/<Composant>` |
| e2e site         | chaque page en `en` et `fr`, les quatre vues, une page d'événement, `.ics` servi en `text/calendar` et relu, `hreflang` ; captures sous `SOURCE_DATE_EPOCH` fixe **et** `timezoneId` Playwright fixé                                                                | `e2e/site-calendar.spec.ts`, projet `calendar`                                         |
| e2e Storybook    | captures `Calendar/*`                                                                                                                                                                                                                                               | `e2e/storybook-calendar.spec.ts`                                                       |
| PDF              | pages par mois, texte extrait, signets                                                                                                                                                                                                                              | test de la CLI                                                                         |

Un test e2e change aussi le fuseau du navigateur (`timezoneId: "America/New_York"`) et vérifie que
la page prérendue reste celle du fuseau du calendrier jusqu'au choix du lecteur.

## 10. Les lots

Identifiants de 08 §5 conservés (CA1 à CA5), plus CA0, sous-tâches `CAn.m` (B10). Prérequis : P9
terminé (plan 26 : `SCENE_CODECS`, `FORMAT_VIEWERS`, `@recit/time` moitié roadmap, écrivain ICS,
`validate_story`). Chaque lot laisse `pnpm check`, build, typecheck et `pnpm test:related` au vert,
avec son version plan (`preminor` au premier lot publié de la phase, `prerelease` ensuite), et les
lignes de `NOTICE.md` des dépendances ajoutées.

### CA0 — Spike récurrences et fuseaux (S), go/no-go

- [ ] CA0.1 — Polyfill `Temporal` : `temporal-polyfill` (plus léger) ou `@js-temporal/polyfill`
      (référence du TC39), mesurés (poids, conformité sur nos cas).
- [ ] CA0.2 — `rrule` contre un développement maison sur `Temporal`, sur la batterie du §9.

Sortie : une note dans ce plan, deux dépendances choisies (ou une seule), validées par
l'utilisateur.

### CA1 — `@recit/time`, moitié calendrier (M)

- [ ] CA1.1 — `LocalDateTimeSchema`, `TimeZoneSchema`, `DurationSchema`, `RRuleSchema` dans le cœur.
- [ ] CA1.2 — `./zoned` : heure murale → instant, désambiguïsation, récurrences, fenêtre.
- [ ] CA1.3 — `./ics` : heures, `TZID`, `VTIMEZONE`, `RRULE`, `EXDATE`, `STATUS` ; lecture.

Sortie : tous les tests « Unitaire, time » verts ; un ICS écrit puis relu est identique.

### CA2 — Le modèle, les vues, `Place` (L)

- [ ] CA2.1 — `PlaceSchema` et `LocationSchema` (plan 12 DI0.1), `CalendarSourceSchema` typé,
      `CalendarConfigSchema`, JSON Schema `calendar.json`.
- [ ] CA2.2 — Codec dans `SCENE_CODECS`, règles du §3.6.
- [ ] CA2.3 — `storyToCalendar`, `CalendarSchema`.
- [ ] CA2.4 — Les quatre vues et les composants, stories avec `play`.
- [ ] CA2.5 — `@recit/core/jsonld` (`Event`, `Place`), partagé avec le plan 12.
- [ ] CA2.6 — Runtime et CLI (`init`, `dev`, `build`, `validate`, `convert`, import `.ics`).

Sortie : `recit build examples/calendar-meetups/meetups.json` montre les quatre vues en `en` et `fr`
; `pnpm test:stories packages/formats/calendar` passe, axe compris.

### CA3 — Flux, PDF, pont (M)

- [ ] CA3.1 — `export --to ics`, flux abonnable par langue, ICS par événement.
- [ ] CA3.2 — `CalendarPdfOptionsSchema`, vue imprimable, `export --to pdf`.
- [ ] CA3.3 — `include` : pont roadmap → calendar.
- [ ] CA3.4 — MCP : `describe_format`, `expand_events`, guide.

Sortie : flux abonné à la main dans Google Agenda, Apple Calendrier et Outlook, heures justes de
part et d'autre du 25/10 ; les jalons de `roadmap-product` apparaissent dans `calendar-course`.

### CA4 — Les exemples (S)

- [ ] CA4.1 — `examples/calendar-meetups` et `examples/calendar-course` (§7), gabarits.

Sortie : aller-retour vert, `recit i18n check` vert.

### CA5 — calendar.recit.dev et la doc (M)

- [ ] CA5.1 — `sceneRoutes` dans `@recit/site-stories`.
- [ ] CA5.2 — `sites/calendar` (§6.2), `SITES`, ports, Vercel, type des `.ics`.
- [ ] CA5.3 — `sites/calendar/stories/releases.md` et l'inclusion de la roadmap de Récit (§6.3).
- [ ] CA5.4 — `sites/docs` › Formats › Calendar, page des fuseaux ; `formats.ts` de
      `@recit/site-kit`.
- [ ] CA5.5 — `e2e/site-calendar.spec.ts`, `e2e/storybook-calendar.spec.ts`, captures `-linux`.

Sortie (celle de P10 dans 06) : `examples/calendar-meetups` dans les quatre vues ; flux ICS
abonnable et récurrences justes aux changements d'heure ; jalons de la roadmap dans le calendrier ;
`pnpm verify` et `pnpm test:e2e:linux e2e/site-calendar.spec.ts` verts deux fois de suite ; version
`0.6.0-alpha` publiée.

**Ordre et agents.** CA0 d'abord (il conditionne CA1). CA1 et CA2.1–CA2.2 (cœur) peuvent avancer en
parallèle une fois CA0 tranché ; CA2.3 et suivants attendent CA1.2. CA3 et CA4 se partagent ; CA5
ferme. Les lots FO0 et FO1 du plan 10 se déroulent à côté, sans fichier commun.

## 11. Ce qui reste hors de P10

- Synchronisation bidirectionnelle avec Google Agenda ou CalDAV : jamais dans le cœur ; l'import
  `.ics` ponctuel suffit.
- Inscription à un événement : le bloc `:::form` du plan 10 peut vivre dans la description d'un
  événement, sans rien de propre au calendrier.
- Pont calendar → stories (« à venir » en cartes) : plan 28, P11.
- Disponibilités, prise de rendez-vous, fuseaux multiples affichés côte à côte : non prévus.

## 12. Questions ouvertes

1. **Forme du JSON** : les événements sont les `scenes` du jumeau (recommandé, §3.1) ; ou un tableau
   `events[]` à part, comme l'écrivent 08 §5 et 09 §2.3 ?
2. **Récurrences** : garder `rrule` (prévu par 03 §6) si le spike CA0 le valide, sinon un
   développement maison sur `Temporal` limité au sous-ensemble du §3.5 (recommandé en repli) ?
3. **Polyfill `Temporal`** : `temporal-polyfill` (recommandé, plus léger, Node seulement) ou
   `@js-temporal/polyfill` ?
4. **Lecture ICS** : `ical.js` (MPL-2.0), qui servirait aussi d'oracle aux tests du plan 26
   (question 9) ; ou un analyseur maison limité à ce que Récit écrit et importe ?
5. **Journée entière sur plusieurs jours** : `end` écrit **compris** par l'auteur, converti en
   `DTEND` exclusif dans l'ICS (recommandé) ; ou `end` exclusif comme RFC 5545 ?
6. **Fuseau d'affichage** : celui du calendrier par défaut, celui du lecteur sur demande
   (recommandé) ; ou celui du lecteur d'office après l'hydratation ?
7. **Fraîcheur** : accepter une reconstruction planifiée de calendar.recit.dev (une par jour, par
   une tâche Vercel), pour que la vue « à venir » et le flux suivent le temps ?
8. **Dogfooding** : le calendrier des versions de Récit inclut la roadmap de Récit (§6.3). Où vit la
   story de roadmap pour que deux sites la lisent sans dépendre l'un de l'autre : copiée dans
   `sites/calendar`, ou dans un projet de contenu commun hors de `examples/` (non Lorem) ?
9. **`video=` → `film=`** : renommer l'attribut de 08 §5 selon le vocabulaire d'`AGENTS.md`
   (recommandé) ?
10. **`color`** : limiter aux trois clartés `soft`, `base`, `strong` de la teinte du site
    (recommandé) ; ou supprimer l'attribut et ne colorer que par tag ?
11. **Quatre vues dès P10** (critère de sortie de 06) ; ou `month` et `agenda` d'abord, `week` et
    `year` en `prerelease` suivante ?

## 13. À reporter dans les autres plans (par l'intégrateur)

| Fichier                       | Ce qui change                                                                                                                                                          |
| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `06-roadmap.md`               | P10 renvoie à ce plan ; CA0 ajouté                                                                                                                                     |
| `08-formats.md` §0, §5        | « (ou un jour) » retiré ; `events[]` selon la question 1 ; séparateur d'`office-hours` sur une ligne ; `export --to ics` ; `film=` ; `status`                          |
| `09-langues-et-json.md` §2.3  | ligne `calendar` : champs de scène (`title`, `start`, `end`, `duration`, `date`, `timezone`, `rrule`, `exdate`, `location`…) au lieu d'`events[]` ; ligne `Place` (B3) |
| `12-glossaire-annuaire.md`    | `Place` livré en CA2.1, `@recit/core/jsonld` en CA2.5                                                                                                                  |
| `15-export-pdf.md` §3.2       | `CalendarPdfOptionsSchema` ; `subscribable` sur la variante `ics`                                                                                                      |
| `21-tests.md` §3.1            | titre Storybook `Calendar/<Composant>`, surface `e2e/storybook-calendar.spec.ts`                                                                                       |
| `sites/plans/recit.config.ts` | entrée de navigation pour 27                                                                                                                                           |
