Skip to main content

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

Les tables indexées par slug

Le même slug 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)

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

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