En bref
Ouvrir un lien Wrappy déclenche une chaîne de trois étages :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.Où modifier quoi
Le catalogue
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.
Les tables indexées par slug
Le mêmeslug sert de clé dans cinq endroits :
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)
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 :
capabilities.degradedou slug inconnu → leFallbackdu 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’
Overlaydu chrome par-dessus (le drawer « Laisser un mot » du livre d’or)
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 :
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 :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.
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.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 ExperiencePayloadrépartis dans l’API, le studio,openet core ; - des valeurs inventées :
previewPayloadfabriquait 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 parapplyCoreauzValidatorde la route — la seule frontière de confiance du chemin.
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.
- 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) litPREVIEW_RUNTIME, une table par slug : les fixtures d’un livre d’or et des actions qui ne font rien ; la galerie de/preview/$slugl’utilise aussi- le replay remonte
<Experience>entier par sakey: 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
1
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.2
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.3
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.4
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.5
Composant
core/src/experiences/<slug>/scene.tsx avec export default function Scene(props: SceneProps).6
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.7
Table des scènes
Une entrée dans
SCENES (core/src/scenes.ts). Le satisfies fait échouer la compilation si elle
manque.8
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.contract-fields.tsx rend les descripteurs du
contrat, et sceneSteps() déduit le rail d’étapes.