> ## Documentation Index
> Fetch the complete documentation index at: https://docs.wrappy.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Pipeline de rendu d'une scène

> Comment une expérience passe du payload en base au pixel à l'écran : les trois étages de rendu, les tables indexées par slug, et où modifier quoi.

## En bref

Ouvrir un lien Wrappy déclenche une chaîne de **trois étages** :

```
apps/open  /$token                 ← route : fetch du payload + capabilities
    │
    ▼
Experience.tsx        (core/runtime)  ← QUOI rendre (scène 3D ou repli plat), le chrome
    │                                    HTML, et le montage : contexte, Suspense, loader
    ▼
<Scene />             (core/experiences/<slug>/scene.tsx)  ← la scène elle-même
    │
    ▼
SceneCanvas.tsx       (core/components)  ← le canvas WebGPU/WebGL
```

<Note>
  Il y avait un quatrième étage, `SceneStage`, entre `Experience` et la scène. Il n'était monté que
  par `Experience`, n'avait aucun consommateur externe, et faisait traverser `config`, `entries` et
  `mode` deux fois. Il a été fusionné dans `Experience`.
</Note>

***

## Où modifier quoi

| Ce que tu veux changer                                                            | Fichier                                                                                                                                                                                  |
| --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Le catalogue (titre, description, vignette, OG, gratuité)                         | `scene-contracts/src/registry.ts`                                                                                                                                                        |
| Ce que l'émetteur peut régler (champs du wizard)                                  | `core/src/experiences/<slug>/contract/`                                                                                                                                                  |
| Le message de repli sans WebGL                                                    | `registry.ts` → `fallback.message` (ou un composant : `chrome.Fallback` de la définition)                                                                                                |
| Le composant lazy, le repli riche, l'overlay HTML et le mot du loader d'une scène | `core/src/experiences/<slug>/definition.ts` → `factoryScene({ slug, component, chrome })`                                                                                                |
| Ce qu'une scène demande à son hôte (déposer, téléverser…)                         | `core/src/runtime/actions.ts` → `SceneActionsMap`, fourni par `HOST_ACTIONS` (`apps/open/src/lib/strategy-host-actions.ts`) et `PREVIEW_RUNTIME` (`core/src/runtime/preview-runtime.ts`) |
| Le loader (barre de progression, titre, mot)                                      | `core/src/runtime/Experience.tsx` + `components/LoadingOverlay.tsx`                                                                                                                      |
| Le renderer, le DPR, l'antialiasing                                               | `core/src/components/SceneCanvas.tsx`                                                                                                                                                    |
| Le squelette d'une scène-livre (canvas VSM, sol, lumières, nav mobile)            | `core/src/engines/book/template-book-stage.tsx` → `TemplateBookStage`                                                                                                                    |
| Ce qui varie avec la NATURE côté API (email de livraison, ouverture, compteur)    | `apps/api/src/modules/wrappy/wrappy.strategy.ts` → `NATURE_STRATEGY`                                                                                                                     |
| Le titre et la description de partage (OG) par nature                             | `scene-contracts/src/nature.ts` → `NATURE_SHARE_COPY`                                                                                                                                    |
| Une réaction à un événement (journal `gift_events`, email d'ouverture)            | `apps/api/src/modules/wrappy/wrappy.observers.ts` → `registerWrappyObservers()`                                                                                                          |
| La géométrie / l'animation / les matériaux                                        | `core/src/experiences/<slug>/scene.tsx` et `core/src/engines/*`                                                                                                                          |
| Les URLs d'assets CDN                                                             | `scene-contracts/src/assets.ts` (cf. [Assets de scène](/engineering/scene-assets))                                                                                                       |
| Le rendu d'un champ dans le wizard                                                | `apps/studio/src/components/wizard/contract-fields.tsx`                                                                                                                                  |
| L'aperçu vivant du wizard (moteur en process)                                     | `apps/studio/src/components/studio-stage.tsx`                                                                                                                                            |
| Une police, une encre, un papier d'écriture                                       | `core/src/engines/writing/styles.ts`                                                                                                                                                     |
| Le rendu du texte manuscrit (livre d'or ET cartes)                                | `core/src/engines/writing/paint.ts`                                                                                                                                                      |
| Un contrôle de studio sur mesure (`control: "custom"`)                            | `apps/studio/src/components/wizard/contract-fields.tsx` → `CUSTOM_CONTROLS`                                                                                                              |

***

## Le catalogue

| Slug         | Nature      | Catalogue | Contrat  | Chrome                      |
| ------------ | ----------- | --------- | -------- | --------------------------- |
| `codex`      | `gift`      | démo      | ✅        | —                           |
| `guestbook`  | `guestbook` | vendable  | ✅        | Fallback + Overlay + loader |
| `popup-book` | `gift`      | vendable  | ✅        | —                           |
| `3d-book`    | `gift`      | démo      | ✅ (vide) | —                           |
| `letter`     | `gift`      | démo      | ✅        | —                           |

`SCENE_CONTRACTS` est un `Record` complet : une scène du registre sans contrat ne compile pas. Un
slug **retiré** du registre (`web-form` vit encore en base) reçoit un contrat explicitement vide
(`RETIRED_CONTRACT`) — jamais celui d'une autre scène.

<Warning>
  **Aucune scène de nature `invitation` n'existe.** La scène `web-form` a été retirée. Le module API
  `invitation`, le dashboard studio et la colonne `invitation_responses` restent en place — des
  lignes existent en base — mais plus rien ne produit d'invitation. Voir [Écarts connus](#écarts-connus).
</Warning>

***

## Les tables indexées par slug

Le même `slug` sert de clé dans cinq endroits :

| # | Table             | Fichier                           | Rôle                                                                      | Exhaustive ?                                     |
| - | ----------------- | --------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------ |
| 1 | `sceneRegistry`   | `scene-contracts/src/registry.ts` | Métadonnées (SSOT du catalogue)                                           | — (c'est la source)                              |
| 2 | `SCENE_CONTRACTS` | `core/src/contracts.ts`           | Ce qui est éditable au studio                                             | `Record<SceneSlug, …>` → oui                     |
| 3 | `exports`         | `core/package.json`               | Subpath public des modules lus hors de core                               | non vérifiée                                     |
| 4 | `SCENES`          | `core/src/scenes.ts`              | La définition de chaque scène (`definition.ts` : composant lazy + chrome) | `{ [S in SceneSlug]: SceneDefinition<S> }` → oui |
| 5 | `SceneActionsMap` | `core/src/runtime/actions.ts`     | Ce que la scène demande à l'hôte, typé par slug                           | indexée par `SceneSlug` → oui                    |

Les tables 2, 4 et 5 sont exhaustives : ajouter un slug au registre sans les mettre à jour **casse à la
compilation**. La table 5 en entraîne deux autres, chez les hôtes : `HOST_ACTIONS` (`apps/open`) et
`PREVIEW_RUNTIME` (aperçu studio et galerie), déclarées `satisfies { [S in SceneSlug]: … => SceneActions<S> }` —
une action ajoutée à une scène sans ligne chez un hôte ne compile pas non plus. Seule la table 3 n'est
pas vérifiée.

### Deux axes de variation, deux familles de tables

Le **slug** décide de ce qui se dessine et de ce que la scène demande à l'hôte (tables ci-dessus).
La **nature** (`gift` / `invitation` / `guestbook`) décide du cycle de vie côté serveur :
`NATURE_STRATEGY` (`apps/api/src/modules/wrappy/wrappy.strategy.ts`) porte la livraison par email,
si la première lecture marque l'ouverture, et le compteur affiché dans la liste ; `NATURE_SHARE_COPY`
(`scene-contracts/src/nature.ts`, pur) porte le titre et la description de partage ; `DETAIL_STRATEGY`
(`apps/studio/src/routes/_protected/wrappys/$token.tsx`) porte la page de détail. Toutes
`satisfies Record<ExperienceType, …>` : une quatrième nature = une ligne, et le compilateur liste ce
qu'elle doit fournir. Aucun `if (type === "…")` ne vit en dehors de ces tables.

Les effets de bord (ligne dans `gift_events`, email « vient d'ouvrir votre cadeau ») ne sont plus dans
les services : ce sont des abonnés de `wrappyEventBus` (`apps/api/src/lib/observer-wrappy-events.ts`),
enregistrés par `registerWrappyObservers()` dans `app.ts`. Les services émettent `opened`, `sent`,
`paid`, `refunded`, `contributed` ; `paid`/`refunded` sont émis dans la transaction Stripe et attendus,
`opened`/`contributed` sont lancés sans attente hors du chemin de lecture.

### Ce qui a été fusionné, et à quel prix

`SCENE_COMPONENTS` (composant) et `EXPERIENCE_CHROME` (repli, overlay, loader) vivaient dans deux
fichiers ; elles sont réunies dans une **définition par scène** (`experiences/<slug>/definition.ts`,
via `factoryScene`) que `SCENES` ne fait que collectionner. Le prix : le chrome du livre d'or
(`GuestbookDrawer` → `SignerForm` → pad de signature) est importé statiquement par `definition.ts`,
donc par tout ce qui importe `scenes.ts` — la galerie de `apps/open` comprise. Elle est un outil de
dev jamais servi en production (`routes/index.tsx` redirige hors `DEV`) : coût accepté.

**1 + 2** (`sceneRegistry` + `SCENE_CONTRACTS`) se fusionneraient en déclarant la `meta` dans le
fichier de contrat de chaque scène. C'est la bonne forme — l'identité d'une scène et sa surface
éditable dans un seul fichier — mais elle demande de casser un cycle d'imports : `registry.ts` →
contrat → `scene-contracts/contract` (pour le type `SceneContract`) → `registry.ts` (pour `SceneSlug`). Il faut
sortir les types de contrat dans un module sans dépendance. Chantier réel, pas un déplacement.

**3** (`exports` du package.json) est la seule table sans vérification à la compilation : une entrée
manquante ne casse que le build Vite des apps, pas `tsc` ni les tests.

***

## Le voyage d'un payload

### 1. La route (`apps/open/src/routes/$token.tsx`)

```tsx theme={null}
<Experience
  payload={data.payload}              // ExperiencePayload, servi par l'API
  mode="open"
  capabilities={detectCapabilities()} // mesuré UNE fois, ici
  entries={data.entries}              // livre d'or : hors payload
  actions={actions}                   // hostActions(payload.sceneId, { token, queryClient })
/>
```

La route est le **seul endroit qui parle réseau**. Les scènes ne reçoivent que des données et des
**actions**, fabriquées par slug dans `HOST_ACTIONS` (`apps/open/src/lib/strategy-host-actions.ts`) :
la table est exhaustive, un hôte qui oublie une action que la scène déclare ne compile pas. C'est ce
qui les rend montables ailleurs (aperçu studio, future app Shopify).

`detectCapabilities()` sonde WebGL2 de façon synchrone et mémoïse le résultat. WebGL2 est le
plancher réel : `WebGPURenderer` bascule seul dessus quand l'adaptateur manque.

### 2. `Experience` — décision, chrome et montage

Route par `payload.sceneId` (le slug), **jamais** par `payload.type` :

```tsx theme={null}
const definition = sceneDefinition(payload.sceneId);   // table 4 : composant + chrome
const fallback = sceneMeta(payload.sceneId)?.fallback;  // table 1
```

Trois issues possibles :

* `capabilities.degraded` **ou** slug inconnu → le `Fallback` du chrome, sinon le message du registre
* sinon → le contexte de scène, l'overlay de chargement et `<Suspense>` autour de la scène
* dans les deux cas → l'`Overlay` du chrome par-dessus (le drawer « Laisser un mot » du livre d'or)

**Le loader** tient en un booléen :

```
SceneCanvas (Suspense résolu) → reportCanvasReady()  → ready = true, overlay masqué
useProgress() de drei         → reportProgress(n)    → barre de progression
```

Chaque scène ne monte **qu'un seul** `SceneCanvas`. Le registre de canvas qui vivait ici (deux
`Set`, cinq callbacks de contexte) coordonnait une scène — WebForm — qui en superposait deux ;
elle n'existe plus. `reportCanvasReady` est idempotent, donc le double-montage de `StrictMode` ne
peut que le repasser à `true`.

### 3. La scène (`core/src/experiences/<slug>/scene.tsx`)

Signature : `export default function Scene({ payload, actions, entries, mode }: SceneProps)`.
`actions` est requis (`{}` pour une scène qui ne demande rien à l'hôte) ; seul le chrome du livre
d'or lit les siennes, typées `ExperienceViewProps<"guestbook">`. Le `payload`
est celui que `Experience` a reçu, transmis tel quel — la scène lit ses réglages propres via le
lecteur tolérant de son contrat :

```ts theme={null}
const props = readGuestbookProps(payload);  // ne lève jamais : chaque champ a son .catch
```

Aucune scène ne déclare son propre type de props : `codex` et `3d-book` avaient chacune une
`interface Props { config?: … }` privée, troisième copie de la même forme. Elles utilisent
`SceneProps`.

### 4. `SceneCanvas`

Wrapper autour du `<Canvas>` de react-three-fiber. Fabrique un `WebGPURenderer` (qui retombe seul
sur WebGL2), force la taille après `init()` (sinon le depth buffer WebGPU reste à 300×150), cape le
DPR à 2, et signale sa disponibilité à `Experience`.

L'overlay de perf (`<Perf />`) est monté sous `{DEV && …}` — comme les panneaux Leva des scènes de
démo. Le garde `DEV` vit dans `core/src/lib/dev.ts` : Vite remplace `import.meta.env.DEV`
statiquement, donc ces outils et leurs dépendances disparaissent du bundle du destinataire.

***

## Les types de config

Deux types décrivent la donnée, plus un par frontière :

| Type                      | Fichier                    | Qui le reçoit                     | Contenu                                                                                   |
| ------------------------- | -------------------------- | --------------------------------- | ----------------------------------------------------------------------------------------- |
| `ExperienceConfig`        | `scene-contracts/types.ts` | — (base)                          | Tous les champs de noyau + `props?: unknown`                                              |
| `ExperiencePayload`       | `scene-contracts/types.ts` | `Experience`, les scènes          | `ExperienceConfig & { type: ExperienceType }`                                             |
| `StoredExperiencePayload` | `scene-contracts/types.ts` | l'API                             | Idem, mais `attachments` porte des clés R2 + `ogImageKey`                                 |
| `SceneProps<S>`           | `core/runtime/types.ts`    | la scène                          | `{ payload, actions: SceneActions<S>, entries?, mode, editing? }`                         |
| `ExperienceViewProps<S>`  | `core/runtime/types.ts`    | `Experience`, fallbacks, overlays | `extends SceneProps<S>` + `capabilities`                                                  |
| `SceneActions<S>`         | `core/runtime/actions.ts`  | scène, chrome, hôtes              | `SceneActionsMap[S]` — livre d'or : `sign`, `uploadPhoto`, `editSign` ; les autres : `{}` |

`ExperienceViewProps` **étend** `SceneProps` : `payload`, `entries` et `mode` ne sont déclarés
qu'une fois. Le repli sans WebGL et l'overlay HTML d'une expérience lisent ainsi exactement les
mêmes données que sa scène 3D.

La prop de la scène s'appelle `payload`, pas `config` : c'est le même objet que celui reçu par
`Experience`, et deux noms pour un objet font croire à deux objets. Elle est **requise** —
`Experience` en a toujours un, et l'optionnalité semait des `?.` dans chaque scène.

<Note>
  `type` est une **nature**, pas un discriminant de forme : les trois valeurs (`gift`, `invitation`,
  `guestbook`) portent exactement les mêmes champs. C'est le `sceneId` qui décide de ce qui s'ouvre,
  et le contrat de cette scène qui décide de ce qui se règle.
</Note>

### Ce que l'union coûtait

`ExperiencePayload` était une union à trois branches. `InvitationPayload` recopiait à la main \~90 %
de la base pour y déclarer `eventTitle` et `eventDate` **obligatoires** — une exigence que le type
affirmait sans que rien ne la fasse respecter à l'exécution. Elle se payait en :

* **six casts `as ExperiencePayload`** répartis dans l'API, le studio, `open` et core ;
* **des valeurs inventées** : `previewPayload` fabriquait un titre `"Anniversaire"` et une date à
  +30 jours pour satisfaire le compilateur ;
* **une garde dupliquée** dans `wrappy.repository.ts`, qui rejouait une règle déjà appliquée par
  `applyCore` au `zValidator` de la route — la seule frontière de confiance du chemin.

Ce qui est obligatoire l'est désormais par **contrat de scène** (`core.<champ>.required`), lu par
`applyCore`. Une seule déclaration, appliquée à un seul endroit.

Le seul chemin qui exigeait vraiment ces deux champs est l'email d'invitation
(`deliverInvitation`, `wrappy.strategy.ts`) : il les résout en repli neutre plutôt que de refuser l'envoi. Un titre absent
devient « Vous êtes invité·e » ; une date absente fait disparaître la ligne de date, parce que
`formatDay` rend `null` sur une date impossible.

***

## Le circuit d'aperçu studio

Le studio **monte le moteur en process** : `apps/studio/src/components/scene-preview.tsx` est une
coquille rendue côté serveur (la route `/wrappys/new` reste SSR pour le formulaire) qui charge en
lazy, au client seulement, `studio-stage.tsx` — le seul fichier du studio qui importe `@wrappy/core`
côté 3D. Même package, même commit que l'app du destinataire : l'aperçu ne peut pas diverger.

```
apps/studio
  ScenePreview (SSR-safe)           StudioStage (chunk lazy, client)
      │                                  │
      ├─ payload = builderPreviewPayload(form) ►  writeAssetPaths(props, objectUrls)
      ├─ assets  = sceneAssets(form) ────►  useObjectUrls : un object URL par File, stable
      ├─ « Rejouer » → key ──────────────►  <Experience mode="studio" {...previewRuntime(sceneId)} />
      └─ « Voir en vrai » → open /preview/<slug>#cfg=<base64>
```

* les fichiers en cours d'édition deviennent des **object URLs same-origin**, posés sur les props à
  l'emplacement que le contrat leur donne (`writeAssetPaths`) — le studio ne connaît pas la forme
  des props d'une scène
* `previewRuntime(sceneId)` (`@wrappy/core/experience`) lit `PREVIEW_RUNTIME`, une table par slug :
  les fixtures d'un livre d'or et des actions qui ne font rien ; la galerie de `/preview/$slug`
  l'utilise aussi
* le replay remonte `<Experience>` entier par sa `key` : une scène garde son avancement dans des
  refs internes
* une scène qui plante est contenue par `StageBoundary` : le formulaire survit, le brouillon est
  autosauvé

`/preview/$slug` dans `open` ne sert plus qu'à la démo de la landing, à la galerie
(`#mode=preview`) et à « Voir en vrai ». Le fragment `#cfg=` porte l'état initial et rien d'autre —
jamais envoyé au serveur, donc un brouillon ne laisse aucune trace, et l'aperçu reste partageable
par lien.

## Ajouter une scène

<Steps>
  <Step title="Registre">
    Une entrée dans `sceneRegistry` (`scene-contracts/src/registry.ts`) : slug, titre, description,
    `type`, `catalog`, `assetVersion`, et éventuellement `fallback` / `thumbnail` / `ogImage`.

    Le `namespace` d'un `ogImage` est un **dossier CDN**, pas une scène : sans lui, l'URL se résout
    sous le slug de la scène elle-même.
  </Step>

  <Step title="Contrat">
    `core/src/experiences/<slug>/contract/` : schéma zod des props, `assetPaths`, descripteurs
    d'interface (`ui.steps`), champs de noyau utilisés (`core`). À côté de la scène, dans le même
    dossier — avec ses modules de données s'il en a (`templates.ts`, `cards.ts`, `styles.ts`).

    Ce fichier est lu par le SERVEUR : ni React, ni three, ni composant. La règle Biome
    `style/noRestrictedImports` (premier override de `biome.json`) le fait respecter — le package
    `@wrappy/scene-contracts` le garantissait avant par simple absence de dépendance.
  </Step>

  <Step title="Table des contrats">
    Une entrée dans `SCENE_CONTRACTS` (`core/src/contracts.ts`). Le `Record` complet fait échouer la
    compilation si elle manque. `assetPaths` se dérive de l'UI (`assetPathsOf(ui)`) : un test garantit
    que chaque contrat s'en sert, parce qu'un upload absent d'`assetPaths` est une clé que l'API ne
    vérifie plus.
  </Step>

  <Step title="Export du package">
    Seulement si un module de la scène est lu hors de core (l'API, `@wrappy/db`, une app) : un chemin
    **exact** dans `exports` de `core/package.json`, pas de wildcard.

    Même chose pour un module lu depuis un AUTRE dossier de core : `#/` ne survit pas à la compilation
    d'une app consommatrice — ses `paths` tsconfig masquent ceux de core. Passer par
    `@wrappy/core/<subpath>`, comme `pageCanvas.ts` le fait pour `@wrappy/core/guestbook`.
  </Step>

  <Step title="Composant">
    `core/src/experiences/<slug>/scene.tsx` avec `export default function Scene(props: SceneProps)`.
  </Step>

  <Step title="Définition">
    `core/src/experiences/<slug>/definition.ts` :
    `factoryScene({ slug, component: lazy(() => import("./scene")), chrome? })`. Le chrome (repli
    riche, overlay HTML, mot du loader) vit ici, à côté de la scène, pas dans le runtime.
  </Step>

  <Step title="Table des scènes">
    Une entrée dans `SCENES` (`core/src/scenes.ts`). Le `satisfies` fait échouer la compilation si elle
    manque.
  </Step>

  <Step title="Actions">
    Une clé dans `SceneActionsMap` (`core/src/runtime/actions.ts`) — `Record<never, never>` si la scène
    ne demande rien à l'hôte. Si elle demande quelque chose, le compilateur réclame une ligne dans
    `HOST_ACTIONS` (`apps/open`) et dans `PREVIEW_RUNTIME` (core) : c'est le but.
  </Step>
</Steps>

Le studio, lui, ne demande **aucune ligne** : `contract-fields.tsx` rend les descripteurs du
contrat, et `sceneSteps()` déduit le rail d'étapes.

***

## Écarts connus

| Écart                                                        | Où                               | Impact                                                                                     |
| ------------------------------------------------------------ | -------------------------------- | ------------------------------------------------------------------------------------------ |
| La nature `invitation` n'a plus de scène                     | `registry.ts`                    | Module API, dashboard studio et colonne DB vivants, mais plus rien ne les alimente         |
| Plus aucun contrat ne déclare `core.event`                   | tous les `contract/index.ts`     | La garde de cohérence des dates (`use-wrappy-form.ts`, `isStepComplete`) est inatteignable |
| Le chrome du livre d'or entre dans le chunk de la galerie    | `guestbook/definition.ts`        | Galerie de dev seulement, jamais servie en production                                      |
| `codex/contract/props.ts` déclare `paperColor`/`ribbonColor` | `codex/contract/ui.ts`           | La scène ne les lit pas encore                                                             |
| Cinq tables indexées par slug                                | 2 packages, 4 fichiers           | Ajouter une scène demande 6 éditions (voir ci-dessus), dont 3 réclamées par le compilateur |
| `surveys.ts` n'a plus d'émetteur                             | `scene-contracts/src/surveys.ts` | Seul le studio écrit des réponses (saisie manuelle) ; aucune scène ne propose de RSVP      |
