/* ============================================================
   ALT TAB STUDIO — « Le Film »
   Une page pinnée : le scroll joue un film, il ne fait pas défiler la page.
   ============================================================ */

:root {
  --display: 'Bricolage Grotesque', Georgia, serif;
  --body: 'Hanken Grotesk', -apple-system, system-ui, sans-serif;
  --mono: 'Martian Mono', ui-monospace, 'SF Mono', monospace;

  /* --------------------------------------------------------
     Tokens du film — ÉCHANTILLONNÉS le 2026-08-13 dans les vraies frames (?palette=1),
     comme pear.no prélève son or et son cerulean dans ses tableaux. Les points de
     prélèvement sont dans samplePalette (src/main.js) et ont été recalés à la main sur
     la composition du rush réel : le point « light » d'origine tombait dans le ciel et
     rendait un bleu — un token qui colore du texte doit venir des CRÊTES éclairées.
     À rejouer après tout changement de rush : ?palette=1, coller ici.
     -------------------------------------------------------- */
  --film-sky:   #1d3650;  /* bleu de la brume lointaine */
  --film-light: #e0ceb0;  /* blanc de plomb des crêtes */
  --film-water: #5f6d74;  /* verdigris du lac */
  --film-earth: #a89273;  /* ocre chaud des toits */

  --ink: #1c1a17;
  --ease: cubic-bezier(0.22, 0.61, 0.36, 1);

  /* L'interlignage du corps de texte, et le corps du wordmark.
     --masthead-h A ÉTÉ RETIRÉ, et ce qu'il servait à garantir est maintenant gratuit. Il
     valait `--brand-size × --body-lh`, c'est-à-dire la hauteur de la marque, et il était
     imposé au bouton d'écriture pour que les deux boîtes se centrent sur la même
     horizontale. Depuis que les deux sont centrés dans le BANDEAU — `top` à la moitié du
     filet haut, `translateY(-50%)` — ils partagent leur horizontale par construction, quelles
     que soient leurs hauteurs respectives : le bouton a d'ailleurs grandi de 25,28 à 33 px
     en devenant un aplat, sans que la marque bouge d'un pixel. */
  --body-lh: 1.55;
  --brand-size: 1.02rem;

  /* Retrait des montants verticaux du cadre de filets, en fraction de la largeur.
     La valeur vit ICI et non plus en dur dans src/divider.js, parce que la gouttière
     ci-dessous s'en déduit : tant que les deux étaient déclarés séparément, ils
     dérivaient l'un de l'autre et le filet finissait par tomber dans le texte. */
  --rule-inset: 0.055;

  /* Les deux filets horizontaux, en pourcentage de la hauteur d'écran. Ils vivent ici pour
     la même raison que le retrait ci-dessus : src/divider.js les trace, src/glass.js
     découpe exactement leur complémentaire, et css/glass.css pose son liseré dessus. Trois
     lecteurs pour une seule géométrie — écrite trois fois, elle dériverait, et le symptôme
     serait un flou qui s'arrête à deux pixels du filet au lieu de dessous.
     Le pourcentage est la forme qui convient aux trois : le CSS le consomme tel quel,
     divider.js le divise par 100 (voir cssFraction). */
  --rule-top: 13%;
  --rule-bottom: 87%;

  /* La gouttière = le montant + 32 px, et rien d'autre. Le 32 est relevé sur la
     référence : montant à x=76, premier jambage du texte à x=108.
     L'ancienne valeur (clamp(1.5rem, 5vw, 5rem)) croisait le montant dès 430 px de
     large et passait dessous ensuite — le filet traversait la colonne de texte de
     −3,8 px à 768 jusqu'à −25,6 px à 1920. En multipliant --rule-inset par 100vw, la
     gouttière suit le montant exactement, à n'importe quelle largeur : mesuré à 32,0 px
     de dégagement de 320 à 2560 px, sans une seule exception.
     Volontairement SANS plafond. Un plafond, même haut, finit par être rattrapé par le
     montant qui, lui, n'en a pas : avec un maximum à 11rem la dérive recommençait à
     2618 px de large et redevenait négative sur un écran 4K. */
  --gut: calc(var(--rule-inset) * 100vw + 2rem);

  /* Polarité de la marque. Le fond derrière elle est peint, donc il change : le film
     passe au clair sous le coin haut-gauche et le reste sur 83 % de la course. Sur ce
     fond-là (mesuré : rgb(157,174,181)) le blanc du site tombe à 1,68:1, et même du
     blanc pur plafonnerait à 2,29:1 — WCAG en demande 4,5. Aucune ombre ne rattrape ça
     (la meilleure des huit essayées plafonne à 3,03:1 avec un halo noir sale), et un
     voile sombre en haut de scène abîmerait la peinture, qui est le sujet du site.
     On bascule donc l'ENCRE, comme la référence (.ov.on-light { --mark: var(--ink) }).
     src/divider.js pose .on-light sur <html> quand le ciel échantillonné sous la zone
     de marque est clair. Le halo bascule avec : une ombre sombre sous une lettre sombre
     ne sert à rien, il faut un halo clair. */
  --mark: var(--film-light);
  --mark-halo-near: rgba(20, 18, 15, 0.9);
  --mark-halo-far: rgba(20, 18, 15, 0.55);
}

html.on-light {
  --mark: var(--ink);
  /* Le halo clair est prélevé sur --film-light, pas sur du blanc pur : c'est déjà le
     blanc de plomb du film, et un blanc franc ferait une auréole de néon sur l'huile. */
  --mark-halo-near: rgba(226, 220, 204, 0.9);
  --mark-halo-far: rgba(226, 220, 204, 0.6);
}

*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

html {
  background: var(--ink);
  -webkit-font-smoothing: antialiased;
  /* Pas de scroll-behavior:smooth : il entrerait en concurrence avec l'easing de
     poursuite du moteur (src/scrub-engine.js) et produirait un double amortissement. */
}

body {
  font-family: var(--body);
  color: var(--film-light);
  line-height: var(--body-lh);
  overflow-x: hidden;
}

a { color: inherit; text-decoration: none; }
::selection { background: var(--film-earth); color: #fff; }

/* ============================================================
   SCÈNE — tout est fixe, rien ne bouge avec le scroll
   ============================================================ */

.stage {
  position: fixed;
  inset: 0;
  z-index: 1;
  overflow: hidden;
  opacity: 0;
  transition: opacity 900ms var(--ease);
}
.stage.is-ready { opacity: 1; }

.film, .divider {
  position: absolute;
  inset: 0;
  display: block;
}
.film { z-index: 1; }

/* Le sable : juste au-dessus du film, sous le divider et sous le texte. Il remplace le
   plan qu'on quitte, donc il occupe exactement sa place — et rien de ce qui est posé PAR
   DESSUS le film (le cadre de filets, le titre du chapitre) ne doit passer dessous, sinon
   la jointure aurait l'air de souffler l'interface avec le paysage. */
.sand {
  position: absolute;
  inset: 0;
  display: block;
  z-index: 1;
  pointer-events: none;
  /* LES DEUX LIGNES QUI SUIVENT NE SONT PAS REDONDANTES AVEC `inset: 0`, et les retirer
     fait diverger le canvas jusqu'à seize millions de pixels de côté en une seconde. Un
     canvas est un élément REMPLACÉ : avec `left/right: 0` et une largeur `auto`, il ne
     s'étire pas à son bloc conteneur, il prend sa taille INTRINSÈQUE — c'est-à-dire son
     attribut `width`. Or src/sand.js règle justement cet attribut sur `clientWidth × dpr`
     à chaque construction : chaque image doublait la taille de la précédente. Le film n'a
     pas le problème parce qu'il mesure le `.stage` et non lui-même. */
  width: 100%;
  height: 100%;
}
/* `hidden` doit gagner contre le `display: block` ci-dessus, sans quoi le canvas resterait
   composité tout le film pour ne rien montrer. */
.sand[hidden] { display: none; }

/* La perforation (src/trou.js, trame pear) : même régime exact que le sable — au-dessus
   du film par l'ordre du document, sous le divider et le texte, et les mêmes width/height
   explicites pour la même raison (élément remplacé, taille intrinsèque). */
.trou {
  position: absolute;
  inset: 0;
  display: block;
  z-index: 1;
  pointer-events: none;
  width: 100%;
  height: 100%;
}
.trou[hidden] { display: none; }

/* Le monde d'arrivée (src/monde.js, trame pear) : le décor DOM qui vit dans le trou, une
   fois l'épure seule à l'écran. Même plan que le trou, posé après lui dans le document —
   sous le divider et le texte : les paliers se lisent PAR-DESSUS la Joconde. */
.monde {
  position: absolute;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  overflow: hidden;
  opacity: 0;
  transition: opacity 0.6s ease;
}
.monde.is-vivant { opacity: 1; }
.monde[hidden] { display: none; }

/* Le fond : la Joconde entière, poussée au sfumato — un climat, pas une image. Débordante
   de 40 px de chaque côté pour que la parallaxe ne découvre jamais un bord. L'épure du
   codex reste lisible à travers : c'est le backend qui porte le décor, pas l'inverse. */
.monde-fond {
  position: absolute;
  inset: -40px;
  will-change: transform;
  /* --px/--py : la parallaxe écrite par src/monde.js. En variables et pas en transform
     inline, pour que le recentrage mobile du personnage puisse se composer avec. */
  transform: translate3d(var(--px, 0px), var(--py, 0px), 0);
}
.monde-fond img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: 50% 20%;
  /* Le fond n'est plus un climat (l'ex-Joconde floutée, blur 22px / brightness 0.5) mais
     un DÉCOR façon Shopify : le lac d'Annecy peint se regarde. Juste assez assombri pour
     que le texte du palier et le personnage détouré restent devant. */
  filter: saturate(0.92) brightness(0.82);
}
/* Le grade colorimétrique : le tableau nu (annecy-fond-nu) sort de la génération avec ses
   turquoises francs, hors de la gamme du film. Ce voile sanguine→encre en soft-light le
   ramène dans la trame de l'app — les mêmes pigments que le codex du trou (la sanguine
   c68052, l'encre 0a0603) — sans peindre par-dessus : soft-light module, ne couvre pas. */
.monde-grade {
  position: absolute;
  inset: 0;
  pointer-events: none;
  mix-blend-mode: soft-light;
  background: linear-gradient(160deg, rgba(198, 128, 82, 0.5) 0%, rgba(120, 78, 48, 0.4) 45%, rgba(10, 6, 3, 0.55) 100%);
}

/* La spirale d'or en VERRE — la matière du liseré du cadre (css/glass.css), appliquée à
   une courbe : une arête nette, un congé qui l'adoucit, une lueur qui se perd. Les trois
   traits sont le même chemin (<use>), prélevés sur --film-light et jamais sur du blanc
   pur — sur une huile, une lumière blanche se lit comme un néon. Les largeurs sont en
   pixels du TABLEAU (viewBox 1344 × 752) : src/monde.js dimensionne le SVG sur la boîte
   cover de l'image, l'échelle est uniforme (~1,1 à 1440), les traits suivent. */
.monde-verre {
  position: absolute;
  pointer-events: none;
  /* Le verre appartient au plan de l'ÉCRAN, pas au tableau : comme le liseré du cadre,
     il ne suit pas la peinture. Le SVG vit dans .monde-fond (pour hériter de la boîte
     cover que src/monde.js calcule), mais il ANNULE la parallaxe du parent en lisant les
     mêmes variables à l'envers. C'est ce qui rend la superposition avec la spirale
     sanguine de l'épure (src/trou.js) exacte en permanence : le moteur du trou dort hors
     scroll, son canvas ne peut pas suivre la souris — donc c'est le verre qui reste fixe,
     et les deux courbes ne bougent ni l'une ni l'autre. */
  will-change: transform;
  transform: translate3d(calc(-1 * var(--px, 0px)), calc(-1 * var(--py, 0px)), 0);
}
.monde-verre .verre-arete { stroke: rgba(226, 220, 204, 0.5); stroke-width: 1.6; }
.monde-verre .verre-conge { stroke: rgba(226, 220, 204, 0.18); stroke-width: 5; }
.monde-verre .verre-lueur { stroke: rgba(226, 220, 204, 0.09); stroke-width: 18; }

/* Voile de lisibilité : le texte des paliers (beige parchemin) vit en haut à gauche, sur
   les platanes ensoleillés du décor. Un dégradé sombre sur le flanc gauche le fait tenir
   — même geste que Shopify derrière son sommaire, invisible passé la moitié de l'écran. */
.monde-fond::after {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(100deg, rgba(18, 14, 9, 0.6) 0%, rgba(18, 14, 9, 0.3) 28%, transparent 55%);
}

/* Le personnage : ancré au bas de l'écran comme les détourés de Shopify — on ne voit
   jamais où la découpe s'arrête, elle sort du cadre. À droite, parce que les paliers de la
   trame pear ancrent leur texte en haut à gauche.

   HORS du bloc .monde depuis le 2026-08-15 : la Joconde est AU PREMIER PLAN, devant les
   filets (z-index 2) et le texte des paliers (3) — un enfant ne sort pas du contexte
   d'empilement de son parent, elle vit donc en soeur du monde, à hauteur de la marque (4).
   Elle porte pour elle-même le fondu et le `hidden` que le monde portait pour elle. */
.monde-perso {
  position: absolute;
  z-index: 4;
  right: 4vw;
  bottom: -4vh;
  height: min(78vh, 900px);
  aspect-ratio: 1280 / 1908;
  margin: 0;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.6s ease;
  will-change: transform;
  transform: translate3d(var(--px, 0px), var(--py, 0px), 0);
  filter: drop-shadow(0 30px 60px rgba(0, 0, 0, 0.55));
}
.monde-perso.is-vivant { opacity: 1; }
.monde-perso[hidden] { display: none; }
.monde-perso-img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}
.monde-accessoires {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}

/* Le personnage en SÉQUENCE (rush extrait, voir PERSO_FRAMES dans src/monde.js) : le
   conteneur passe au 16:9 du rush — la Joconde y est centrée, et sa chute a besoin de
   l'air du cadre entier — et le canvas remplace l'image et ses accessoires SVG, qui sont
   désormais DANS les frames. */
.monde-perso.is-film {
  aspect-ratio: 16 / 9;
  right: -6vw;
  height: min(72vh, 820px);
}
.monde-perso.is-film .monde-perso-img,
.monde-perso.is-film .monde-accessoires { display: none; }
.monde-perso-film {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}

/* La bulle respire : elle gonfle depuis les lèvres, doucement — un chewing-gum, pas un
   ballon. C'est la seule animation autonome du site avec la poussière dorée, et elle vit
   au même endroit qu'elle : hors du film, dans les coulisses. */
.acc-bulle {
  transform-origin: 598px 632px;
  animation: bulle-souffle 4.2s ease-in-out infinite;
}
@keyframes bulle-souffle {
  0%, 100% { transform: scale(0.92); }
  55% { transform: scale(1.06); }
}
@media (prefers-reduced-motion: reduce) {
  .acc-bulle { animation: none; }
}

/* Sur petit écran le personnage se recentre et se réduit : le texte des paliers passe en
   pleine largeur, la Joconde devient un fond de scène plus qu'une voisine. */
@media (max-width: 760px) {
  .monde-perso {
    right: 50%;
    transform: translate3d(calc(50% + var(--px, 0px)), var(--py, 0px), 0);
    height: 58vh;
  }
}
/* Le divider est au-dessus du film mais SOUS le texte : c'est ce qui permet à sa teinte
   échantillonnée de se fondre dans le ciel sans être masquée par le type. */
.divider { z-index: 2; pointer-events: none; }

/* Le verre : la marge hors du cadre. Même plan que le film, posé après lui dans le
   document. Voir le commentaire d'index.html — le flou est peint dans le canvas, ici il
   n'y a que ce qui ne coûte rien parce que ça ne change pas d'une frame à l'autre. */
.glass {
  position: absolute;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  /* La seule mesure du cadre qui n'existe qu'ici : le retrait des montants EN PIXELS.
     --rule-inset, sa source, est une fraction sans unité — c'est ce que demande le canvas,
     qui multiplie par sa propre largeur. Le CSS, lui, a besoin d'une longueur pour ses
     découpes. Les deux horizontales, elles, sont déclarées sur :root et lues aussi bien
     par src/divider.js que par src/glass.js. */
  --rule-side: calc(var(--rule-inset) * 100vw);
}

/* Le scroll fantôme : donne au navigateur de quoi scroller. Sa hauteur est calculée en
   JS (totalFrames × pxPerFrame). Aucun contenu dedans. */
.spacer { position: relative; z-index: 0; width: 100%; }

/* ============================================================
   TYPE — dans le DOM, par-dessus le canvas
   ============================================================ */

.type-layer { position: absolute; inset: 0; z-index: 3; pointer-events: none; }

/* Polarité du TEXTE DU FILM. Même principe que --mark pour la marque, mais décidé UNITÉ
   PAR UNITÉ : src/divider.js sonde l'empreinte de chaque unité visible — un titre, un
   corps, un terme, une définition, un bouton, l'indice de scroll — et lui pose `on-light`
   quand le film est clair dessous.

   Deux granularités ont été essayées avant, et les deux étaient trop grosses. Une classe
   globale sur <html> : la marque vit en haut à gauche, les titres n'importe où, et le
   chapitre III (lac sombre, 6,23:1 en crème) virait à l'encre à tort. Puis une classe par
   BLOC : sur le palier des projets, large de 1218 px, le fond vaut 204,6 sous le titre et
   96 sous la légende, la moyenne du bloc valait 118,7, et le titre restait crème sur un
   ciel à 204 — 1,3:1 mesuré, illisible.

   Le halo n'est pas un ornement, c'est l'autre moitié du remède, et c'est la SEULE qui
   traite le fond qui change EN COURS DE LIGNE : « calme et solide. » passe du ciel pâle au
   versant sombre à l'intérieur d'une même phrase, et aucune encre uniforme ne peut tenir
   les deux. Le halo, lui, déplace le fond LOCAL du côté opposé au glyphe, donc il suit.

   D'où quatre couches et non plus deux, et chacune a sa portée. 2 px et 6 px collent au
   jambage et font le contraste au contact. 10 px couvre la zone où la mesure va chercher
   le fond — l'anneau de 4 à 10 px autour du glyphe, « le voisinage immédiat » — et c'est
   celle qui manquait : sans elle, entre deux lettres de mono espacées de 0,3em, l'anneau
   ne voyait que du film. 22 px diffus fait la séparation d'avec le plan sans dessiner de
   contour. L'ancienne couche unique de 3 px à 0,72 laissait le corps du chapitre III à
   2,3:1 sur son pire passage et « DESCENDRE » à 1,5:1 ; les quatre couches les portent à
   4,7 et 3,9–6,4. */
.chapter-type {
  position: absolute;
  max-width: min(38ch, 42vw);
  /* L'opacité est pilotée frame par frame par src/main.js. Une transition courte lisse
     les micro-variations sans jamais traîner derrière le scroll. */
  transition: opacity 180ms linear;
}

/* Les tokens vivent à la racine : l'indice de scroll est hors de la couche de type, et
   deux jeux de valeurs pour la même encre finiraient par diverger. */
:root {
  --type-mark: var(--film-light);
  --type-halo-near: rgba(20, 18, 15, 0.94);
  --type-halo-mid: rgba(20, 18, 15, 0.95);
  --type-halo-far: rgba(20, 18, 15, 0.55);
}

/* Les unités portent l'encre, donc elles portent la déclaration : `color` s'hérite par sa
   VALEUR CALCULÉE, pas par sa référence. Déclaré sur le bloc, il aurait figé la couleur
   décidée pour le bloc et l'unité aurait beau porter sa classe, elle n'aurait rien changé.
   Le repli des `var()` sert le mode statique, où ces mêmes classes se retrouvent hors de
   la couche de type et sans sonde pour les alimenter. */
.chapter-eyebrow,
.chapter-title,
.chapter-body,
.palier-list dt,
.palier-list dd,
.cryptex-term,
.cryptex-text,
.chapter-cta,
.scroll-hint,
/* Le bouton d'écriture est une unité comme les autres — il vit dans le coin haut-droit,
   là où le film passe au ciel clair sur la plus grande partie de la course. Sans sonde il
   resterait crème dessus, c'est-à-dire illisible : le défaut même qu'on vient de corriger
   sur cinq autres éléments. C'est src/main.js qui l'inscrit dans la liste sondée. */
.contact-open {
  color: var(--type-mark, var(--film-light));
  text-shadow:
    0 0 2px var(--type-halo-near, transparent),
    0 0 6px var(--type-halo-near, transparent),
    0 0 10px var(--type-halo-mid, transparent),
    0 1px 22px var(--type-halo-far, transparent);
  /* 400 ms comme la marque : une bascule instantanée se lirait comme un défaut de rendu,
     alors qu'étalée elle passe pour un changement de lumière du plan. */
  transition: color 400ms var(--ease), text-shadow 400ms var(--ease);
}

/* Les deux dernières lignes sont hors de .type-layer — l'indice de scroll et le bouton
   d'écriture vivent dans la scène, pas dans la couche de texte — d'où leur sélecteur
   propre. */
/* Dans la perforation (trame pear, src/trou.js), la sonde de lumière est aveugle : elle
   échantillonne le canvas du film, qui porte toujours le lac clair SOUS le noir de
   l'épure. `dans-le-trou` est publié sur <html> par src/main.js quand le trou couvre
   l'écran, et il force le retour au crème sur halo sombre — la polarité que le noir
   demande — en gagnant par spécificité sur les bascules `.on-light` ci-dessous. */
html.dans-le-trou .type-layer .on-light,
html.dans-le-trou .scroll-hint.on-light,
html.dans-le-trou .contact-open.on-light {
  --type-mark: var(--film-light);
  --type-halo-near: rgba(20, 18, 15, 0.94);
  --type-halo-mid: rgba(20, 18, 15, 0.95);
  --type-halo-far: rgba(20, 18, 15, 0.55);
}

.type-layer .on-light,
.scroll-hint.on-light,
.contact-open.on-light {
  --type-mark: var(--ink);
  /* Halo prélevé sur --film-light et non sur du blanc pur, pour la même raison que celui
     de la marque : un blanc franc ferait une auréole de néon sur l'huile. */
  --type-halo-near: rgba(226, 220, 204, 0.94);
  --type-halo-mid: rgba(226, 220, 204, 0.95);
  --type-halo-far: rgba(226, 220, 204, 0.62);
}

/* Ancrages : chaque chapitre se pose dans la réserve laissée vide au prompt
   (voir docs/shots.md). Le regard circule d'un coin à l'autre au fil du film. */
.anchor-center-left { top: 50vh;  left: var(--gut); transform: translateY(-50%); }
.anchor-top-left    { top: 20vh;  left: var(--gut); }
.anchor-left        { top: 34vh;  left: var(--gut); }
.anchor-top-right   { top: 20vh;  right: var(--gut); text-align: right; }
.anchor-bottom-left { bottom: 22vh; left: var(--gut); }
.anchor-center-right { top: 50vh; right: var(--gut); transform: translateY(-50%); }
/* Le générique, et lui seul : centré, parce que son fond est le noir de src/nuit.js —
   il n'y a plus de réserve d'image à respecter, la nuit a tout rendu disponible. */
.anchor-center {
  top: 50vh;
  left: 50%;
  transform: translate(-50%, -50%);
  text-align: center;
}

/* LE BLOC « WE SHIP » EN COLONNE FLEX, ALIGNÉ À LA VERTICALE (Lucas, 2026-08-16).
   Le drapeau est posé par buildType() (src/main.js) sur le seul chapitre qui porte un
   rouleau. Deux classes dans le sélecteur, et c'est voulu : ça passe devant
   `.anchor-top-left` (une classe) sans toucher à son `left` — l'ancrage horizontal reste
   celui du chapitre, seule la verticale change de régime. `top: 0` + `height: 100vh` +
   `justify-content: center` plutôt que le couple 50vh/translateY du générique : le bloc
   contient un rouleau dont la fenêtre vit en overflow, et un transform sur l'ancêtre
   créerait un contexte qui capturerait ses position absolute le jour où il en aura. */
.chapter-type.has-ship {
  display: flex;
  flex-direction: column;
  justify-content: center;
  top: 0;
  height: 100vh;
}

/* Aucune opacité sur les éléments secondaires de cette page, et c'est délibéré.
   Un `opacity: 0.72` mélange le glyphe à son fond : le contraste composité est multiplié
   d'autant, et une paire réglée à 4,5:1 retombe à 2,6:1 — la hiérarchie se payait donc
   directement sur le budget de lisibilité, sur le plus petit texte de la page, celui qui en
   a le plus besoin. Le rang se dit ici par le corps, la casse, la graisse et l'interlettrage,
   qui ne coûtent rien au contraste. */
.chapter-eyebrow {
  font-family: var(--mono);
  font-size: 0.72rem;
  letter-spacing: 0.34em;
  margin-bottom: 1.1rem;
}

.chapter-title {
  font-family: var(--display);
  font-weight: 400;
  font-size: clamp(2.4rem, 6.2vw, 5rem);
  line-height: 0.98;
  letter-spacing: -0.02em;
  margin-bottom: 1.1rem;
}

.chapter-body {
  font-size: clamp(1rem, 1.35vw, 1.2rem);
  max-width: 34ch;
}
.anchor-top-right .chapter-body { margin-left: auto; }

/* ---- Le rouleau « We ship » (chapitre II, voir paintShip() dans src/main.js) ---------
   Une fenêtre d'UNE ligne sous le titre, dans laquelle une colonne de mots glisse au
   scroll. La fenêtre rogne (overflow), le masque adoucit : sans lui les mots voisins
   entreraient et sortiraient au cordeau, coupés net à mi-jambage — un store, pas un
   défilement. Les 18 % de fondu suffisent à ce qu'un mot naisse et meure en dégradé sans
   qu'on voie jamais deux mots pleins à la fois.

   Le corps est celui d'un SOUS-TITRE, pas du titre : le rouleau complète « We ship », il
   ne rivalise pas avec lui. Même famille (--display) pour que les deux se lisent comme une
   seule phrase, graisse plus marquée pour que le mot en mouvement garde son assise.

   `will-change: transform` sur la piste seulement : c'est elle qui bouge à chaque frame de
   scroll, la promouvoir évite de repeindre la fenêtre — le titre, lui, ne bouge que
   pendant son arrivée et se fige, il ne mérite pas un calque permanent. */
.ship-roll {
  font-family: var(--display);
  font-weight: 600;
  font-size: clamp(1.5rem, 3.4vw, 2.6rem);
  line-height: 1.3;
  letter-spacing: -0.01em;
  height: 1.3em;
  overflow: hidden;
  margin: -0.4rem 0 1rem;
  -webkit-mask-image: linear-gradient(transparent, #000 18%, #000 82%, transparent);
  mask-image: linear-gradient(transparent, #000 18%, #000 82%, transparent);
}
.ship-track { will-change: transform; }
/* `nowrap` n'est pas cosmétique : le pas du rouleau (paintShip) suppose des lignes d'UNE
   hauteur — « mentoring sessions » replié sur deux lignes décalait tous les mots suivants
   d'un demi-tour. */
.ship-word { display: block; white-space: nowrap; }

/* ---- Paliers ----------------------------------------------------------------
   Un palier arrête le film pour parler. Sa typographie doit donc dire « lis-moi »
   là où celle d'un chapitre dit « continue ». D'où un titre plus petit — il ne
   rivalise pas avec les trois titres de la descente, qui restent les repères — et
   une largeur plus généreuse, puisqu'on ne survole plus mais qu'on lit vraiment. */

.is-palier { max-width: min(44ch, 46vw); }

.is-palier .chapter-title {
  font-size: clamp(1.8rem, 3.4vw, 2.9rem);
  margin-bottom: 1.6rem;
}

.palier-list {
  display: grid;
  gap: 1.05rem;
  font-size: clamp(0.95rem, 1.15vw, 1.06rem);
}

.palier-list dt {
  font-family: var(--mono);
  font-size: 0.74rem;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  margin-bottom: 0.28rem;
}

.palier-list dd {
  margin: 0;
  line-height: 1.45;
}

.anchor-center-right .palier-list,
.anchor-top-right .palier-list { text-align: right; }

/* ---- Cryptex ----------------------------------------------------------------
   Le palier des projets (voir src/cryptex.js). Un cylindre couché : les rangées
   tournent autour d'un axe horizontal, les bagues sont des repères fixes posés
   par-dessus, et deux embouts de laiton ferment l'objet.

   Tout est en CSS 3D et rien n'est dessiné : la perspective vient de `perspective`
   sur le conteneur, la courbure vient de `rotateX + translateZ` sur chaque rangée.
   Ce sont les seules deux lignes qui font le volume — le reste n'est que matière. */

/* Ce palier-là ne tient pas dans une colonne : il lui faut le texte d'un côté et
   l'objet de l'autre. On le sort donc de l'ancrage en coin commun aux autres blocs
   et on lui donne toute la largeur, en deux colonnes. */
.has-cryptex {
  max-width: none;
  left: var(--gut);
  right: var(--gut);
  top: 50%;
  transform: translateY(-50%);
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr);
  align-items: center;
  gap: clamp(2rem, 6vw, 6rem);
}
/* Le titre coiffe la colonne de gauche seulement, avec la légende sous lui. */
.has-cryptex .chapter-title,
.has-cryptex .cryptex-readout { grid-column: 1; }
.has-cryptex .cryptex { grid-column: 2; grid-row: 1 / span 2; }
.has-cryptex .chapter-title { align-self: end; }
.has-cryptex .cryptex-readout { align-self: start; margin-top: 1.1rem; }

/* LE PALIER QUI PROJETTE — trois rangées au lieu de deux.
   Le mur nu du tableau est à gauche, et c'est aussi la colonne du titre : il fallait
   trancher entre les deux, ou les empiler. On les empile, et l'empilement dit exactement ce
   qui se passe — un mur qui reçoit une image, avec une légende sous l'image. Le titre monte
   en haut de l'écran, la légende descend sous la projection, et la rangée du milieu est
   laissée vide : c'est elle que le canvas mesure.
   Le bloc prend toute la hauteur (au lieu d'être centré sur 347 px, la hauteur du cylindre)
   parce qu'une projection coincée entre deux lignes de texte dans un bloc de 347 px de haut
   n'est pas une projection, c'est une vignette. */
.has-cryptex.has-projector {
  top: 0;
  transform: none;
  height: 100%;
  align-items: stretch;
  grid-template-rows: auto minmax(0, 1fr) auto;
  /* 18 vh en bas et non 11 : la légende du projet tombait sinon sur l'indice de scroll, qui
     vit en bas à gauche — c'est-à-dire au pied de la même colonne. La réserve est calculée
     sur la légende la PLUS LONGUE des quatre (trois lignes, Pronolino), et pas sur la
     moyenne : une seule collision suffit à faire lire un bug. */
  padding: 13vh 0 20vh;
  /* L'écart entre COLONNES reste large — c'est lui qui sépare le mur du cylindre. Celui
     entre RANGÉES retombe à presque rien, parce qu'ici les trois rangées ne sont pas trois
     blocs voisins mais un seul objet : une image et sa légende. Hérité tel quel (6 vw, soit
     86 px sur un écran courant), il volait 172 px à la projection et la rendait au format
     d'une bannière — 2,9:1 au lieu de 1,7:1. */
  row-gap: clamp(0.9rem, 2vh, 1.6rem);
}
.has-cryptex.has-projector .chapter-title { grid-row: 1; align-self: start; margin-bottom: 0; }
.has-cryptex.has-projector .projection-screen { grid-column: 1; grid-row: 2; }
.has-cryptex.has-projector .cryptex-readout { grid-row: 3; align-self: end; margin-top: 0; }
/* Le cylindre traverse les trois rangées et reste centré : c'est la lampe, elle n'a pas à
   suivre le rythme du texte. */
.has-cryptex.has-projector .cryptex { grid-row: 1 / span 3; align-self: center; }

/* L'écran ne se voit pas : il n'est qu'une boîte de mesure. On lui donne quand même une
   hauteur minimale, sinon une rangée `1fr` vide peut s'écraser à zéro sur un écran bas et
   le faisceau viserait une fente d'un pixel. */
.projection-screen {
  min-height: 8rem;
  pointer-events: none;
}

.cryptex {
  position: relative;
  /* Toute la largeur de la colonne : c'est ELLE qui commande désormais la géométrie du
     cylindre (voir layout() dans src/cryptex.js). Le débordement de la face avant, lui,
     est absorbé par le padding droit — il est asymétrique depuis que le point de fuite est
     à gauche, et sans cette réserve l'embout de laiton sortait de l'écran. */
  width: 100%;
  padding-right: 8%;
  --cryptex-cap-w: 1.6rem;
  /* La hauteur du bloc, pas celle du cylindre : il faut de la marge au-dessus et en
     dessous pour que les embouts et l'ombre portée ne soient pas coupés. */
  height: var(--cryptex-shell-h);
  pointer-events: none;
}

/* La bande d'ivoire : la fenêtre par laquelle on voit tourner les rangées.
   Ses quatre bornes sont celles mesurées sur le fichier image (BAND dans src/cryptex.js),
   les mêmes qui servent déjà à calculer le rayon — elles n'étaient simplement pas
   opposables au rendu. Sans ce détourage, la gravure débordait sur les embouts de laiton
   et jusque sur le lac.
   `overflow: hidden` est posé ICI et nulle part ailleurs : sur le barillet il repasserait
   la scène en 2D (voir src/cryptex.js), sur le bloc il couperait l'ombre portée et les
   embouts. Et c'est cette bande qui porte la perspective, parce que `perspective` ne vaut
   que pour les enfants directs. */
.cryptex-band {
  position: absolute;
  top: var(--cryptex-band-t, 21.7%);
  left: var(--cryptex-band-l, 21.6%);
  width: var(--cryptex-band-w, 56.8%);
  height: var(--cryptex-band-h, 56.4%);
  overflow: hidden;
  /* Une perspective courte creuse fort : les rangées du haut et du bas fuient
     nettement, ce qui fait lire un cylindre plutôt qu'une liste inclinée. */
  perspective: var(--cryptex-perspective, 900px);
  /* À GAUCHE, et c'est le réglage qui fait la ressemblance avec la référence : la colonne
     de vignettes, posée sur l'axe de fuite, reste droite pendant que les mots s'inclinent
     en éventail vers la droite. La valeur est négative parce qu'ORIGIN_X se compte sur la
     largeur du BLOC et que la bande n'en occupe que le milieu — c'est la même abscisse à
     l'écran, reprojetée. Voir ORIGIN_X dans src/cryptex.js. */
  perspective-origin: var(--cryptex-origin-x, -23.94%) 50%;
}

/* Le corps du cylindre. C'est lui qui porte tout l'effet de volume, et il tient dans
   un seul dégradé vertical : sombre en haut, très clair un peu au-dessus du milieu,
   sombre en bas. L'asymétrie compte — la bande claire est placée AU-DESSUS du centre
   parce que la lumière du film vient d'en haut, et un reflet centré donne un tube en
   plastique. */
/* Le corps du cylindre : une image générée, aux bagues volontairement nues (voir
   SHELL_IMAGE dans src/cryptex.js). Elle remplace le dégradé qui tenait ce rôle —
   un dégradé bien réglé reste un dégradé, et « ultra réaliste » demande de l'os et
   du laiton, pas une interpolation de couleurs. */
.cryptex-shell {
  position: absolute;
  top: 50%;
  left: 0;
  right: 0;
  height: var(--cryptex-shell-h);
  transform: translateY(-50%);
  background-image: var(--cryptex-shell-src);
  background-size: 100% 100%;
  background-repeat: no-repeat;
  /* Plus de masque : le PNG est détouré à la source (scripts/cutout.py), donc il n'y a
     plus de fond à cacher. Le masque radial qu'il y avait ici adoucissait les bords sans
     jamais faire disparaître le rectangle de studio au milieu des côtés.
     L'ombre portée, elle, est REFAITE ici plutôt que peinte dans l'image : ainsi elle
     suit le fond réel du plan, et elle épouse la silhouette exacte de l'objet puisque
     drop-shadow travaille sur le canal alpha. */
  filter: drop-shadow(0 18px 26px rgba(24, 16, 8, 0.5));
}

/* Le barillet occupe toute la fenêtre d'ivoire : c'est elle qui le cale désormais, à
   gauche comme à droite. Il ne garde que le 3D — un `overflow` ou un `clip-path` ici le
   repasserait en `flat`. */
.cryptex-barrel {
  position: absolute;
  top: 50%;
  left: 0;
  right: 0;
  transform: translateY(-50%);
  transform-style: preserve-3d;
}

/* Une LIGNE = un projet : sa vignette, puis son nom. C'est la mécanique relevée sur
   la référence — pas une grille de lettres, qui obligeait à épeler et que les
   rainures découpaient. */
.cryptex-row {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  gap: clamp(0.7rem, 1.4vw, 1.2rem);
  padding-left: 0.5rem;
  text-decoration: none;
  /* backface-visibility ne suffit pas : une ligne qui passe derrière resterait visible
     de face à travers le cylindre. C'est l'opacité calculée dans cryptex.js qui
     l'efface, et elle a besoin que la ligne reste rendue. */
  backface-visibility: hidden;
}

/* La vignette, en tête de ligne. Elle forme la colonne qui donne son rythme à
   l'objet : c'est elle qu'on voit s'incurver avant même de lire les mots. */
.cryptex-thumb {
  flex: 0 0 auto;
  /* Calée sur la HAUTEUR DE LIGNE et non sur le diamètre : une vignette plus haute que
     sa ligne est écrasée par le flex et devient une barre. Un poil plus haute que la
     ligne, comme sur la référence où les vignettes se chevauchent légèrement. */
  width: calc(var(--cryptex-row-h, 64px) * 1.06);
  height: calc(var(--cryptex-row-h, 64px) * 1.06);
  border-radius: 11px;
  background-color: var(--tint, #8a7c62);
  background-size: cover;
  background-position: center;
  box-shadow:
    inset 0 0 0 1px rgba(46, 34, 18, 0.35),
    0 2px 6px rgba(40, 30, 16, 0.3);
}

/* Le nom, GRAVÉ : sombre dans l'ivoire, avec un liseré clair dessous qui imite le
   bord éclairé d'un creux. C'est ce liseré, et non la couleur, qui fait lire
   « gravé » plutôt qu'« imprimé ». */
.cryptex-label {
  font-family: var(--mono);
  /* Très gros, et c'est mesuré sur la référence : la hauteur de capitale y vaut presque
     la hauteur de ligne, les mots se touchent presque. Un corps timide redonne une liste,
     pas un objet gravé. */
  font-size: calc(var(--cryptex-diameter) * 0.155);
  font-weight: 500;
  letter-spacing: -0.01em;
  white-space: nowrap;
  text-shadow: 0 1px 0 rgba(255, 250, 236, 0.6);
}

/* Une ligne sur deux dans une autre teinte — l'alternance blanc/rouge de la référence,
   transposée dans la palette du film : gravure sombre d'un côté, terre de sinople de
   l'autre. Ce n'est pas une couleur d'accent, c'est un pigment de la Renaissance. */
.cryptex-row.is-ink   .cryptex-label { color: rgba(38, 27, 14, 0.94); }
.cryptex-row.is-rouge .cryptex-label { color: rgba(140, 54, 30, 0.92); }

/* Seule la ligne de face est cliquable (pointer-events piloté dans cryptex.js), donc
   le survol ne peut atteindre qu'elle. */
.cryptex-row:hover .cryptex-label,
.cryptex-row:focus-visible .cryptex-label { filter: brightness(0.6); }
.cryptex-row:hover .cryptex-thumb,
.cryptex-row:focus-visible .cryptex-thumb {
  box-shadow:
    inset 0 0 0 1px rgba(46, 34, 18, 0.5),
    0 4px 14px rgba(40, 30, 16, 0.45);
}
.cryptex-row:focus-visible { outline: 2px solid rgba(30, 20, 8, 0.7); outline-offset: 3px; }

/* Deux jonc de laiton, aux extrémités seulement. Il y en avait un entre chaque
   bague, du temps de la grille de lettres ; avec des mots entiers, ils les
   tranchaient en deux. Ceux-ci ne coupent rien : ils ferment l'ivoire. */
.cryptex-grooves { display: none; }

/* Les embouts de laiton. Ils font deux choses : ils ferment le cylindre — sans eux
   les rangées flottent — et ils masquent le fait que les lettres des extrémités
   n'ont pas d'épaisseur. */
.cryptex-caps { display: none; }
.cryptex-cap {
  position: absolute;
  top: 50%;
  width: var(--cryptex-cap-w);
  height: calc(var(--cryptex-diameter) * 1.08);
  transform: translateY(-50%);
  border-radius: 50%;
  /* Le laiton n'est pas une couleur, c'est une alternance : sombre, clair, sombre,
     clair. Un dégradé à deux arrêts donne du plastique doré ; il en faut plusieurs,
     rapprochés et inégaux, pour que l'œil lise du métal tourné. */
  background: linear-gradient(
    to bottom,
    #3f3016 0%, #a8853f 14%, #e6cf9a 30%, #8a6c34 50%,
    #d8bb80 70%, #6d5324 88%, #362a13 100%
  );
  box-shadow: inset 0 0 16px rgba(38, 26, 10, 0.55);
}
/* Entièrement À L'EXTÉRIEUR du corps, et pas à cheval dessus : un embout centré sur
   l'arête mange la moitié de la première bague, donc la première lettre du mot. On
   perdait le P de PARAPENTE et le dernier E — un cryptex qui avale les lettres qu'il
   est censé montrer. */
.cryptex-cap.left  { left: calc(-1 * (var(--cryptex-overhang-l, 0%) + var(--cryptex-cap-w))); }
.cryptex-cap.right { right: calc(-1 * (var(--cryptex-overhang-r, 0%) + var(--cryptex-cap-w))); }

/* Le projet qui se présente à l'avant. Il vit sous le titre du palier, pas sur le
   cylindre : une légende qui tournerait avec les bagues serait illisible. */
.cryptex-readout { margin-top: 1.4rem; min-height: 5.5rem; }
.cryptex-term {
  font-family: var(--mono);
  font-size: 0.74rem;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  margin-bottom: 0.34rem;
}
.cryptex-text {
  font-size: clamp(0.95rem, 1.15vw, 1.06rem);
  line-height: 1.45;
  max-width: 34ch;
}

/* ============================================================
   LES ŒUVRES — les projets accrochés dans les cadres du mur (src/hotspots.js)

   Le remplaçant du cryptex, dans sa deuxième vie : l'image héros de chaque site,
   accrochée dans un cadre peint vide de la toile, endormie en sépia et réveillée au
   survol. Tout le CSS .cryptex-* ci-dessus est débranché — convention du verre.
   ============================================================ */

.hotspot-layer {
  position: absolute;
  inset: 0;
  /* Même plan que la couche de type : au-dessus du film et du cadre, et les repères
     vivent loin des blocs de texte — le tableau réserve son mur gauche au titre. */
  z-index: 3;
  opacity: 0;
  /* Même lissage que .chapter-type : l'opacité est écrite frame par frame. */
  transition: opacity 180ms linear;
  /* TOUJOURS none, même armé : seuls les <a> prennent le clic (voir hotspots.js),
     sinon ce div plein écran mangerait tous les clics du palier. */
  pointer-events: none;
}

/* L'ŒUVRE — l'image héros d'un vrai site, accrochée dans un cadre peint de la toile
   (deuxième vie du module, 2026-08-13 : les objets-métaphores sont partis avec le
   pantin). Position ET taille sont écrites frame par frame par hotspots.js : ici on ne
   décrit que la matière — le cadre, le sommeil, l'éveil. */
.oeuvre {
  position: absolute;
  display: block;
  pointer-events: none;
  text-decoration: none;
  /* Pas d'overflow:hidden ici : l'onde de l'éveil (.oeuvre-halo) rayonne HORS du cadre.
     L'image ne déborde pas (object-fit), et le lustre se clippe lui-même. */
  /* Le parchemin d'attente : la couleur du projet (posée par hotspots.js), et son code
     en creux — l'état de base tant que la capture du site n'a pas chargé. */
  background: var(--oeuvre-tint, #7d8b86);
  /* La feuillure du cadre : un liseré sombre et une ombre portée interne, pour que
     l'image semble POSÉE DANS le cadre peint et non collée sur la toile. Le cadre de
     bois lui-même est peint dans l'image — on ne le redessine pas, on s'y enfonce. */
  box-shadow:
    inset 0 0 0 1px rgba(20, 18, 15, 0.55),
    inset 0 2px 6px rgba(20, 18, 15, 0.45),
    inset 0 -1px 3px rgba(20, 18, 15, 0.25);
}
.hotspot-layer.is-live .oeuvre { pointer-events: auto; }

/* Le code du projet sur le parchemin d'attente — recouvert dès que l'image charge. */
.oeuvre::before {
  content: attr(data-code);
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  font-family: var(--mono);
  font-size: 0.7rem;
  letter-spacing: 0.22em;
  color: rgba(255, 250, 236, 0.7);
}

/* LE SOMMEIL : l'image dort dans la peinture — sépia, un peu sourde, au ton du mur.
   L'ÉVEIL (survol, clavier, ou halte de la caméra) lui rend ses couleurs d'écran et la
   fait glisser lentement : le tableau s'anime sous la main, c'est toute la promesse. */
.oeuvre-img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: top center;
  filter: sepia(0.55) saturate(0.6) contrast(0.92) brightness(0.96);
  /* 7 s : le temps de lire le haut de la page avant que le glissement n'emmène plus bas
     — une halte dure ~340 px de scroll, l'éveil doit survivre à sa fenêtre. */
  transition: filter 420ms ease, object-position 7s ease-in-out;
  /* Tant que l'image n'a pas chargé, un alt vide laisserait une icône cassée : on ne la
     montre qu'une fois chargée (classe posée par hotspots.js). */
  opacity: 0;
}
.oeuvre.a-image .oeuvre-img { opacity: 1; }
.oeuvre:hover .oeuvre-img,
.oeuvre:focus-visible .oeuvre-img,
.oeuvre.is-halted .oeuvre-img {
  filter: none;
  /* Le glissement : la capture est plus haute que le cadre (object-position top), et
     l'éveil la fait descendre — la page du site défile dans le tableau. */
  object-position: bottom center;
}

/* La légende ne s'imprime qu'à l'éveil : c'est le contrat du mur — rien ne se
   présente, tout se laisse trouver. À la halte, le fondu est plus lent que celui du
   survol : la légende arrive comme un cartel qu'on éclaire, pas comme un tooltip. */
.oeuvre-label {
  position: absolute;
  left: 0;
  bottom: 0;
  font-family: var(--mono);
  font-size: 0.72rem;
  font-weight: 500;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  white-space: nowrap;
  color: rgba(255, 250, 236, 0.96);
  background: rgba(20, 18, 15, 0.72);
  padding: 0.3rem 0.55rem;
  opacity: 0;
  transition: opacity 140ms linear;
}
.oeuvre:hover .oeuvre-label,
.oeuvre:focus-visible .oeuvre-label { opacity: 1; }
.oeuvre.is-halted .oeuvre-label {
  opacity: 1;
  transition-duration: 420ms;
}
.oeuvre:focus-visible { outline: 2px solid rgba(255, 250, 236, 0.8); outline-offset: 2px; }

/* ─── L'OR DE L'ÉVEIL — les deux gestes empruntés à pear.no, transposés ────────────
   pear anime ses toiles en petits films ; nous, l'or est peint en code. Deux gestes,
   qui ne coûtent RIEN tant qu'aucune œuvre n'est éveillée :
   — le LUSTRE : un reflet qui balaie l'image UNE FOIS au réveil, comme une feuille
     d'or qui accroche la lumière quand on tourne la tête ;
   — l'ONDE : des anneaux concentriques qui rayonnent du cadre tant que l'œuvre est
     éveillée — le « broadcast signal » de leur poire, devenu le rayonnement du
     tableau qu'on regarde.
   Le troisième geste doré — la poussière dans le rai de lumière — est un canvas à
   part (src/dorure.js), lui vit en continu sur tout le mur. */

.oeuvre-lustre {
  position: absolute;
  inset: 0;
  overflow: hidden;
  pointer-events: none;
}
.oeuvre-lustre::before {
  content: '';
  position: absolute;
  top: -30%;
  bottom: -30%;
  left: 0;
  width: 55%;
  transform: translateX(-130%) rotate(14deg);
  background: linear-gradient(
    100deg,
    rgba(232, 196, 120, 0) 0%,
    rgba(255, 236, 190, 0.34) 50%,
    rgba(232, 196, 120, 0) 100%
  );
}
.oeuvre:hover .oeuvre-lustre::before,
.oeuvre:focus-visible .oeuvre-lustre::before,
.oeuvre.is-halted .oeuvre-lustre::before {
  /* `both` : le reflet passe une fois puis reste parti — un éclat, pas un gyrophare. */
  animation: oeuvre-lustre 1.5s ease-out both;
}
@keyframes oeuvre-lustre {
  to { transform: translateX(400%) rotate(14deg); }
}

/* L'onde : deux anneaux en quinconce de phase, nés sur le cadre, morts à ~26 px.
   Sur les pseudo-éléments du span dédié — ::before de .oeuvre porte déjà le code du
   parchemin d'attente. */
.oeuvre-halo { position: absolute; inset: 0; pointer-events: none; }
.oeuvre-halo::before,
.oeuvre-halo::after {
  content: '';
  position: absolute;
  inset: 0;
  border: 1px solid rgba(232, 196, 120, 0.85);
  opacity: 0;
}
.oeuvre:hover .oeuvre-halo::before,
.oeuvre:focus-visible .oeuvre-halo::before,
.oeuvre.is-halted .oeuvre-halo::before {
  animation: oeuvre-onde 2.8s ease-out infinite;
}
.oeuvre:hover .oeuvre-halo::after,
.oeuvre:focus-visible .oeuvre-halo::after,
.oeuvre.is-halted .oeuvre-halo::after {
  animation: oeuvre-onde 2.8s ease-out 1.4s infinite;
}
@keyframes oeuvre-onde {
  0% { inset: 0; opacity: 0; }
  12% { opacity: 0.55; }
  100% { inset: -40px; opacity: 0; }
}

/* LE HALO TENU (réglage « standing pear » du 2026-08-13) : en plus de l'onde qui
   rayonne, une œuvre éveillée BAIGNE dans une lueur dorée continue — c'est le glow
   permanent des vignettes de pear, pas un événement mais un état. Sur le cadre <a>
   lui-même, pour rayonner à travers la moulure peinte. 40 px de portée : la marge
   mesurée devant la colonne de texte en absorbe 130. */
.oeuvre {
  transition: box-shadow 700ms var(--ease);
}
.oeuvre:hover,
.oeuvre:focus-visible,
.oeuvre.is-halted {
  box-shadow: 0 0 40px rgba(232, 196, 120, 0.4), 0 0 90px rgba(232, 196, 120, 0.18);
}

/* La poussière dorée (src/dorure.js) : sous les œuvres (z-index 3 du calque), au-dessus
   du film — elle flotte dans l'air de l'atelier, entre le mur et les cadres. */
.dorure-canvas {
  position: absolute;
  inset: 0;
  /* Un canvas est un élément REMPLACÉ : inset:0 le pose mais ne l'étire pas — sans ces
     100 %, il reste à sa taille intrinsèque de 300×150 (payé le 2026-08-13). */
  width: 100%;
  height: 100%;
  z-index: 2;
  pointer-events: none;
}

@media (prefers-reduced-motion: reduce) {
  .oeuvre-img { transition: none; }
  .hotspot-layer, .oeuvre-label { transition: none; }
  .oeuvre-lustre::before, .oeuvre-halo::before, .oeuvre-halo::after { animation: none !important; }
}

/* Gardé pour les moteurs de recherche, jamais affiché — et aria-hidden depuis que les
   repères portent eux-mêmes le nom et la phrase de chaque projet (voir main.js). */
.is-offscreen {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* Le bloc qui porte le CTA s'accroche au filet du bas au lieu de se centrer. Centré, le
   bouton finissait à 23 px du filet à 1440 (16 px à 390) : deux traits parallèles si près
   se battent, on voit une erreur d'alignement avant de voir un bouton. 40 px est le seuil
   où l'œil les lit comme deux objets distincts. Ancrer par le bas plutôt que décaler le
   centre tient la mesure à toutes les hauteurs d'écran — en 700 px de haut, le centrage
   faisait carrément passer le bouton SOUS le filet. 13vh = le complément de RULES[1]
   (0,87) dans src/divider.js : le filet du bas est à cette distance du bord. */
.chapter-type:has(.chapter-cta) {
  top: auto;
  bottom: calc(13vh + 40px);
  transform: none;
}

/* Le générique est centré ET porte un bouton : il garde l'ancrage bas ci-dessus — la
   mesure au filet vaut pour tous les CTA — mais le `transform: none` annulait aussi le
   recentrage horizontal, et le bloc partait vivre à droite du centre pendant que la
   constellation, elle, restait au milieu. On ne rétablit que l'axe qui n'a rien à voir
   avec le filet. */
.anchor-center:has(.chapter-cta) {
  transform: translateX(-50%);
}

.chapter-cta {
  display: inline-block;
  margin-top: 1.8rem;
  padding: 0.85rem 1.6rem;
  border: 1px solid currentColor;
  border-radius: 999px;
  font-family: var(--mono);
  font-size: 0.78rem;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  pointer-events: auto;
  transition: background 260ms var(--ease), color 260ms var(--ease);
}
.chapter-cta:hover, .chapter-cta:focus-visible {
  background: var(--film-light);
  color: var(--ink);
  text-shadow: none;
}
/* Le survol INVERSE la paire, il ne la fige pas : sur un bouton passé à l'encre, un fond
   crème et un texte encre reproduiraient l'état de repos, et le bouton n'aurait plus de
   survol du tout. La classe est sur le BOUTON depuis que la sonde travaille à l'unité :
   c'est lui qui a sa polarité, pas le bloc qui le contient. */
.chapter-cta.on-light:hover,
.chapter-cta.on-light:focus-visible {
  background: var(--ink);
  color: var(--film-light);
}

/* ============================================================
   MARQUE + INDICE DE SCROLL
   ============================================================ */

/* LE BANDEAU. La marque, le bouton d'écriture et le bouton de menu ne sont pas trois
   éléments posés dans les coins : ils habitent une bande, celle qui va du haut de l'écran
   au filet haut du cadre — que src/divider.js prolonge maintenant jusqu'aux deux bords
   pour la fermer (voir strokeMasthead). La bande reste VIDE entre les deux : aucun lien,
   aucune rubrique. C'est une ligne de faîte, pas une barre de navigation.

   D'où les deux mesures ci-dessous, qui sont les mêmes pour la marque et pour le bouton :
   · `top: calc(var(--rule-top) / 2)` — le milieu exact de la bande. Écrit à partir du
     filet et non d'un `4vh` choisi à la main, il suit le cadre si le filet change de
     hauteur, ce qu'aucune constante ne saurait faire ;
   · `left: calc(var(--rule-inset) * 50vw)` — le milieu de la MARGE, c'est-à-dire la
     moitié du retrait du montant. C'est la verticale d'interface du site : la marque, le
     bouton de menu et le rail de chapitres s'y alignent tous les trois, et elle est hors
     du cadre, donc elle n'entre jamais en concurrence avec la colonne de texte (--gut). */
.brand {
  position: absolute;
  z-index: 4;
  top: calc(var(--rule-top) / 2);
  left: calc(var(--rule-inset) * 50vw);
  transform: translateY(-50%);
  /* `align-items: baseline` et `gap: 1rem` sont partis avec « Annecy » : le premier
     n'existait que pour caler son petit corps de mono sur la ligne de base du wordmark, le
     second pour l'en écarter. `display: flex`, lui, RESTE, et pas par habitude — c'est la
     boîte de .brand qui sert de fenêtre de sonde au cadre (voir placeProbes() dans
     src/divider.js, qui lit `top + height / 2`). En flex elle colle au lien : 25,28 px de
     haut. Remise en bloc, le lien redevient inline et sa ligne de texte ajoute son
     demi-plomb sous lui — 29,08 px mesurés, donc une bande de sonde descendue de 1,9 px
     dans un coin où le film passe justement du ciel au relief. Le wordmark, lui, ne bouge
     ni dans un cas ni dans l'autre. */
  display: flex;
}
/* La marque porte son encre et son halo, et bascule avec le cadre : voir --mark dans
   :root. 400 ms, parce que la bascule est déclenchée par le
   scroll et doit se lire comme un changement de lumière du plan, pas comme un état
   d'interface qui commute — en dessous de ~300 ms on voit l'événement, au-delà de ~600 ms
   la marque traîne un moment illisible pendant la transition.
   L'ombre est en DEUX couches serrées (2 px et 8 px) et non plus en une de 24 px. Mesuré au
   pixel sur le rendu réel, en contraste au contact (cœur du glyphe contre son voisinage
   immédiat), contre la marque nue : +17 % sur le ciel sombre, +29 % dans la bande médiane
   où le fond ne tranche ni clair ni sombre, +12 % sur le plan pâle. L'ombre unique de 24 px
   faisait moins bien que RIEN dès que la marque passait à l'encre — 5,68 contre 7,19 sur le
   plan pâle : 24 px de flou étalés sur une masse de 9 px ne produisent pas d'ombre, ils
   produisent du brouillard, et ce brouillard sombre mange l'encre au lieu de la porter. */
.brand-mark {
  color: var(--mark);
  text-shadow: 0 1px 2px var(--mark-halo-near), 0 1px 8px var(--mark-halo-far);
  transition: color 400ms var(--ease), text-shadow 400ms var(--ease);
  font-family: var(--display);
  font-size: var(--brand-size);
  letter-spacing: 0.02em;
  pointer-events: auto;
  /* Le lien reste un conteneur flex alors qu'il n'a plus qu'un enfant, et ce n'est pas un
     reste : un SVG est un remplacé en ligne, il n'a pas de ligne de base et se pose par
     son bord bas — dans un lien en bloc il flotterait, et la boîte du lien mesurerait la
     hauteur d'une ligne de texte qui n'existe plus. En flex, la boîte colle au signe.
     `gap` et `align-items` sont partis avec le wordmark : il n'y a plus rien à espacer ni
     à centrer sur la hauteur d'un mot. */
  display: flex;
}

/* Le signe : deux carrés qui se superposent — la fenêtre de derrière en contour, celle
   de devant pleine, comme un Alt+Tab arrêté à mi-course. Le tracé arrière s'interrompt
   au contact du carré avant : c'est ce blanc de réserve, et non un recouvrement, qui
   dit « superposé » en monochrome. On a écarté la version à deux opacités (arrière à
   50 %) — à cette taille un blanc à demi-teinte sur le ciel pâle du film se confond
   avec le fond et on ne lit plus qu'un seul carré, donc plus de logo du tout. */
.brand-logo {
  /* En em, pas en px : le signe suit le corps du lien, reste juste au même endroit à
     390 px de large et n'a besoin d'aucune retouche dans le média mobile. 1.06em vaut
     17,3 px ici — le carré plein en occupe 13/24, soit 9,4 px, à comparer aux 11 px de
     hauteur de capitale de « Alt Tab » : le signe pèse un peu moins que la lettre, ce
     qui le fait lire comme une marque et non comme une troisième initiale. */
  width: 1.06em;
  height: 1.06em;
  flex: none;
  /* Le signe est en currentColor : il hérite de --mark posé sur .brand-mark et bascule
     donc avec le wordmark, sans règle propre. Seul son halo doit être redit ici, un SVG
     ne recevant pas text-shadow — mêmes deux couches, mêmes tokens, pour que le signe et
     le mot ne se détachent jamais l'un de l'autre au moment de la bascule. */
  filter: drop-shadow(0 1px 2px var(--mark-halo-near))
          drop-shadow(0 1px 6px var(--mark-halo-far));
  transition: filter 400ms var(--ease);
}

/* Apparition : la fenêtre de derrière se dessine, puis celle de devant se pose dessus —
   le geste du logo raconte l'Alt+Tab au lieu de l'illustrer. pathLength=100 sur le tracé
   permet de raisonner en pourcentage plutôt qu'en unités du viewBox.
   Les transitions ne sont déclarées que dans l'état .is-ready : l'état de départ est donc
   posé sans animation au chargement, et rien ne se joue derrière le loader opaque.

   Le délai de 1600 ms est la seule valeur qui donne son moment au logo. L'ancien, 450 ms,
   était justifié par un commentaire faux : il prétendait sortir du fondu de .stage, alors
   qu'à t=436 ms la scène est encore à 0,832 d'opacité et ne finit qu'à ~950 ms — 36 % du
   tracé se jouait sous une scène translucide. Pire, le cadre de filets se dessine de 200 à
   1550 ms : le geste du logo était recouvert à 79 % par un autre tracé dans le même écran,
   et l'œil ne peut pas enregistrer deux traits qui se dessinent en même temps. À 1600 ms,
   le cadre est posé depuis 50 ms et le logo a l'écran pour lui seul.
   Le carré plein garde exactement son rapport au tracé : il arrivait à 57 % de la durée,
   soit 97 % du chemin parcouru une fois l'easing appliqué — la fenêtre de devant se pose
   au moment où celle de derrière achève de se fermer. 1600 + 0,571 × 1400 = 2400 ms. */
.brand-logo path { stroke-dasharray: 100; stroke-dashoffset: 100; }
.brand-logo rect { opacity: 0; }
.stage.is-ready .brand-logo path {
  stroke-dashoffset: 0;
  transition: stroke-dashoffset 1.4s cubic-bezier(0.22, 1, 0.36, 1) 1600ms;
}
.stage.is-ready .brand-logo rect {
  opacity: 1;
  transition: opacity 420ms var(--ease) 2400ms;
}
@media (prefers-reduced-motion: reduce) {
  /* main.js bascule en statique avant même de monter la scène, donc ce bloc ne sert que
     si le réglage change après coup : on pose l'état final d'emblée plutôt que de
     laisser un logo figé à moitié dessiné. */
  .brand-logo path { stroke-dashoffset: 0; }
  .brand-logo rect { opacity: 1; }
  .stage.is-ready .brand-logo path,
  .stage.is-ready .brand-logo rect { transition: none; }
  /* La bascule de polarité, elle, doit continuer de FONCTIONNER : c'est de la lisibilité,
     pas de la décoration. Seul son fondu tombe, l'encre change alors d'un coup. */
  .brand-mark, .brand-logo { transition: none; }
}

/* Il était centré et 74 px SOUS le cadre : posé sur aucune verticale de la grille, et hors
   de la fenêtre que le cadre vient d'ouvrir — un mot qui flotte à côté du tableau. On le
   rentre dans le cadre et on le pose sur --gut, la seule verticale de texte du site : le
   mot-marque en haut, les chapitres au milieu, l'indice en bas, tous alignés sur le même
   fil, à 32 px du montant. Et comme la jauge d'avancement démarre à ce même montant, le
   mot coiffe désormais l'origine de la jauge : il annonce le geste que la jauge mesure.
   1,2rem au-dessus du filet, soit 19,2 px, dont 4 sont repris par la respiration. */
.scroll-hint {
  position: absolute;
  z-index: 4;
  bottom: calc(13vh + 1.2rem);
  left: var(--gut);
  font-family: var(--mono);
  /* 0,72rem et non 0,64, et 500 et non 400 : c'était le plus petit et le plus maigre texte
     de la page. À 10,2 px un jambage de mono ne pèse qu'un pixel et demi, dont
     l'anticrénelage mélange la moitié au fond avant même qu'on parle de contraste — d'où
     un contraste mesuré qui restait sous 4:1 même une fois la polarité et le halo réglés.
     Aux 11,5 px et 500 de l'œilleton de chapitre, qui est le même caractère et qui mesure
     entre 6,9 et 10,3:1, il tient enfin son plancher. Il reste secondaire par son corps,
     qui demeure le plus petit de la page, et par son interlettrage de 0,3em. */
  font-size: 0.72rem;
  font-weight: 500;
  letter-spacing: 0.3em;
  text-transform: uppercase;
  /* PLUS D'OPACITÉ, ni fixe ni animée, et c'est le commentaire d'en haut (« Aucune opacité
     sur les éléments secondaires de cette page ») qui avait raison contre le code : la
     règle valait 0,5 au repos et l'animation descendait à 0,28. Un glyphe à 0,28 est mélangé
     au film à 72 % — mesuré sur les pixels rendus, « DESCENDRE » plafonnait à 1,4:1 sur les
     six arrêts, et les deux juges en aveugle l'ont nommé faute éliminatoire chacun de son
     côté. On garde la respiration, qui est l'affordance : elle ne bouge plus que le mot,
     jamais son encre, et le déplacement ne coûte rien au contraste. Le mot reste secondaire
     par son corps de 0,64rem et son interlettrage de 0,3em. */
  animation: breathe 3.4s ease-in-out infinite;
}
@keyframes breathe {
  0%, 100% { transform: translateY(0); }
  50%      { transform: translateY(4px); }
}

/* ============================================================
   ÉCRIRE — le bouton de la ligne de faîte, et son panneau
   ============================================================ */

/* Le pendant droit de la marque, à l'autre bout du bandeau.

   Il était posé sur --gut, la verticale de texte, et sa hauteur était celle de la marque
   (--masthead-h) pour que les deux boîtes se centrent ensemble. Les deux règles sont
   tombées avec le bandeau, et pour la même raison : le bouton n'appartient plus à la
   colonne de texte, il appartient à la bande. Il sort donc de la fenêtre du cadre par la
   droite comme la marque en sort par la gauche, et sa hauteur est celle que lui donne son
   rembourrage — 33 px contre 25 pour la marque, sans que ça se voie, puisque les deux se
   centrent sur la même horizontale par `translateY(-50%)` et non par une hauteur commune. */
.contact-open {
  position: absolute;
  z-index: 4;
  /* Le milieu de la bande, comme la marque, et par la même formule — c'est ce qui garantit
     qu'ils se centrent sur la même horizontale sans constante à ré-étalonner. */
  top: calc(var(--rule-top) / 2);
  transform: translateY(-50%);
  /* 2 % du bord et non --gut : le bouton sort de la fenêtre du cadre, comme la marque en
     sort de l'autre côté. Les deux appartiennent au bandeau, pas au tableau. */
  right: 2%;
  display: inline-flex;
  align-items: center;
  gap: 1.1em;
  /* Le rembourrage est asymétrique et exprimé en em : beaucoup à gauche pour le mot, juste
     le liseré à droite, où le carré de la flèche vient buter. C'est ce qui donne au bouton
     sa silhouette — un mot, puis une touche. */
  padding: 0.5em 0.5em 0.5em 1.15em;
  /* PLEIN, et non plus un contour. Le bouton est le seul objet de la page qui ne commente
     pas le film : il en sort. Un aplat le pose devant le tableau au lieu de le poser
     dessus, et c'est exactement ce qu'on demande à une action.
     Le fond est celui du plan et non un noir absolu : voir la bascule plus bas. */
  background: var(--film-light);
  color: var(--ink);
  border: 0;
  /* 4 px et non 999 : une pilule est une étiquette, un rectangle à peine adouci est un
     bouton. Le reste de la page n'a aucun angle rond — le cadre, les filets et les
     marqueurs sont tous à angle vif —, une pilule y était le seul objet à l'être. */
  border-radius: 4px;
  font-family: var(--mono);
  /* 0,7rem : le corps du CTA de chapitre moins un poil, parce qu'il est ici en regard
     d'un wordmark de 1,02rem et qu'un bouton qui pèse autant que la marque se dispute la
     première lecture avec elle. Le poids 500 est celui de l'indice de scroll, pour la
     raison qu'il y est documenté — sous 500, un jambage de mono à ce corps ne pèse plus
     qu'un pixel et demi et l'anticrénelage en mélange la moitié au film. */
  font-size: 0.7rem;
  font-weight: 500;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  cursor: pointer;
  pointer-events: auto;
  /* LE HALO EST ANNULÉ, et c'est la conséquence directe de l'aplat. Le bouton fait partie
     du groupe des unités sondées (plus haut), qui pose à chacune une ombre portée en
     quatre couches — elle sert à décoller un glyphe posé À MÊME le film. Ici le glyphe
     n'est plus sur le film, il est sur un aplat : l'ombre ne décollait plus rien et
     bavait sur les 4 px de rayon du fond.
     La couleur, elle, reste pilotée par la sonde : voir la bascule juste en dessous. */
  text-shadow: none;
  transition: background 400ms var(--ease), color 400ms var(--ease);
}

/* LA BASCULE, et pourquoi le noir n'est pas écrit en dur.

   Le bouton est demandé « plein noir », et il l'est presque tout le temps : la sonde pose
   `.on-light` quand le film sous lui est CLAIR, et le coin haut-droit est du ciel sur cinq
   des six arrêts du film. Le noir est donc l'état ordinaire, pas l'exception.

   Ce qui ne pouvait pas être écrit en dur, c'est le sixième cas. Un aplat #1c1a17 posé sur
   le lac à contre-jour ou sur l'atelier au noir — mesuré : le palier des projets rend un
   plan quasi noir — donne un bouton qui DISPARAÎT, avec son texte. C'est la seule action
   de la page ; elle n'a pas le droit de s'effacer. Là, et seulement là, l'aplat s'inverse :
   crème sur encre. Le bouton reste plein dans les deux cas — c'est la forme qui est
   demandée, pas la valeur. */
.contact-open.on-light {
  background: var(--ink);
  color: var(--film-light);
}

/* LE SURVOL N'INVERSE PLUS RIEN, et c'est l'aplat qui l'interdit.

   Tant que le bouton était un contour, inverser était le seul geste disponible : il n'y
   avait pas de fond à modifier. Sur un bouton plein, la même inversion échange le fond et
   le texte — c'est-à-dire qu'elle donne au survol l'exacte apparence de l'état de repos de
   l'AUTRE polarité. Deux boutons identiques à l'écran, l'un survolé et l'autre non : le
   visiteur ne lit plus un survol, il lit un changement de plan.

   Ce qui bouge à la place est le carré de la flèche, et lui seul : il s'éclaire. C'est le
   geste de la référence, et il a la bonne portée — il désigne la sortie du bouton, donc la
   direction où le clic emmène, au lieu de repeindre tout l'objet. */
.contact-open:hover .contact-arrow,
.contact-open:focus-visible .contact-arrow {
  background: color-mix(in srgb, currentColor 14%, transparent);
}

/* Le carré de la flèche : un liseré dans l'encre du texte, à 30 %, et rien dedans que le
   signe. Il n'est pas décoratif — c'est lui qui dit que le bouton MÈNE quelque part, ce
   qu'un mot seul ne dit pas. `aspect-ratio` et non une largeur et une hauteur : le carré
   suit le corps du texte, donc il reste carré à toutes les tailles.
   2,1em pour 0,7rem de corps, soit 23,5 px : la hauteur du bouton moins ses deux
   rembourrages, au pixel. */
.contact-arrow {
  display: grid;
  place-items: center;
  width: 2.1em;
  aspect-ratio: 1;
  border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
  border-radius: 3px;
  font-size: 0.92em;
  /* Le signe est un caractère, pas un glyphe de la police mono : il ne porte pas
     l'interlettrage du bouton, qui le décalerait du centre de son carré. */
  letter-spacing: 0;
  transition: background 260ms var(--ease);
}

/* ---- Le panneau -------------------------------------------------------------
   Il appartient au film : même encre, même crème, même mono en petites capitales. Ce
   n'est pas une boîte de dialogue posée devant le tableau, c'est un feuillet qui se
   déplie DANS le cadre — d'où sa géométrie, qui n'est pas celle d'une modale centrée mais
   celle de la fenêtre que les filets ouvrent déjà. */

.contact {
  position: fixed;
  inset: 0;
  z-index: 25;
}
.contact[hidden] { display: none; }

/* Le voile assombrit le film sans le cacher : le visiteur doit continuer de voir où il en
   était. Il porte aussi la fermeture au clic, ce qui est l'affordance attendue et ne coûte
   aucun élément supplémentaire. */
.contact-veil {
  position: absolute;
  inset: 0;
  background: rgba(20, 18, 15, 0.66);
  opacity: 0;
  transition: opacity 260ms var(--ease);
}
.contact.is-open .contact-veil { opacity: 1; }

/* Sous le bouton, exactement : même `top`, même gouttière droite. Le panneau a l'air de
   sortir du bouton qui l'ouvre, et il reste dans la fenêtre du cadre.
   87vh est RULES[1] dans src/divider.js — le filet du bas. Le panneau s'arrête dessus au
   lieu de le traverser : deux objets qui se croisent en se recouvrant à moitié se lisent
   comme une erreur de mise en page, et c'est le même raisonnement qui a mis le CTA de
   chapitre à 40 px du filet. Ce qui dépasse défile DANS la feuille. */
.contact-sheet {
  position: absolute;
  top: 4vh;
  right: var(--gut);
  width: min(26rem, calc(100vw - 2 * var(--gut)));
  max-height: calc(87vh - 4vh);
  overflow-y: auto;
  /* Sans lui, arrivé en bout de course dans la feuille, le geste continue sur le
     document — et le film repartirait derrière le panneau. C'est la moitié tactile du
     blocage ; l'autre est dans src/contact.js. */
  overscroll-behavior: contain;
  padding: 1.5rem 1.6rem 1.4rem;
  background: rgba(20, 18, 15, 0.95);
  border: 1px solid rgba(226, 220, 204, 0.24);
  color: var(--film-light);
  /* Le panneau descend de sa propre hauteur d'une dizaine de pixels : assez pour que
     l'œil voie d'où il vient, pas assez pour qu'on attende son arrivée. */
  opacity: 0;
  transform: translateY(-10px);
  transition: opacity 260ms var(--ease), transform 260ms var(--ease);
}
.contact.is-open .contact-sheet { opacity: 1; transform: none; }

.contact-close {
  position: absolute;
  top: 0.9rem;
  right: 0.9rem;
  width: 1.75rem;
  height: 1.75rem;
  display: grid;
  place-items: center;
  padding: 0;
  color: var(--film-light);
  background: none;
  border: 0;
  cursor: pointer;
  /* Pas d'opacité au repos : la croix est le seul moyen d'annuler à la souris avec le
     voile, et c'est le plus petit objet du panneau — voir la note sur les éléments
     secondaires en haut de cette feuille. */
  transition: transform 200ms var(--ease);
}
.contact-close:hover { transform: rotate(90deg); }
.contact-close svg { width: 100%; height: 100%; }

.contact-eyebrow {
  font-family: var(--mono);
  font-size: 0.62rem;
  letter-spacing: 0.26em;
  text-transform: uppercase;
  margin-bottom: 0.7rem;
}
.contact-title {
  font-family: var(--display);
  font-weight: 400;
  font-size: clamp(1.5rem, 2.6vw, 1.9rem);
  line-height: 1.08;
  letter-spacing: -0.01em;
  margin-bottom: 0.5rem;
  /* La croix occupe le coin : sans réserve, un titre un peu long passe dessous. */
  padding-right: 2rem;
}
.contact-lede {
  font-size: 0.94rem;
  line-height: 1.45;
  margin-bottom: 1.4rem;
}

/* Le piège à robots. `display: none` ne convient pas : les remplisseurs automatiques
   l'ignorent justement parce qu'il est trop connu. Une boîte de zéro pixel hors flux se
   remplit, et ne se voit pas. */
.contact-gotcha {
  position: absolute;
  width: 1px;
  height: 1px;
  opacity: 0;
  pointer-events: none;
}

.contact-champ { display: grid; margin-bottom: 1.05rem; }
.contact-champ label {
  font-family: var(--mono);
  font-size: 0.64rem;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  margin-bottom: 0.4rem;
}

/* Une ligne à écrire, pas une boîte : un champ encadré fait formulaire administratif,
   une réglure fait manuscrit — et c'est le vocabulaire du reste du site, où tout est
   filet. La ligne s'éclaire quand on écrit dessus. */
.contact-champ input,
.contact-champ textarea {
  width: 100%;
  padding: 0.4rem 0 0.45rem;
  font-family: var(--body);
  font-size: 0.95rem;
  line-height: 1.45;
  color: var(--film-light);
  background: none;
  border: 0;
  border-bottom: 1px solid rgba(226, 220, 204, 0.32);
  border-radius: 0;
  transition: border-color 200ms var(--ease), border-width 200ms var(--ease);
}
.contact-champ textarea { resize: vertical; min-height: 4.6rem; }
/* Le focus se dit par la RÉGLURE, qui devient pleine et double d'épaisseur — pas par le
   contour global du site. Un champ de saisie reçoit toujours `:focus-visible`, même
   cliqué à la souris : le contour se serait donc affiché en permanence, et il aurait
   dessiné autour du champ le rectangle que ce panneau évite précisément de dessiner. Le
   rendre à la réglure garde un repère de focus visible — de 1 px à 32 % à 2 px pleins,
   c'est un quadruplement de la masse d'encre du trait — sans changer de vocabulaire.
   Le rembourrage bas rend le pixel que la réglure vient de prendre : sans lui, le texte
   du champ sautait d'un pixel vers le haut à chaque entrée de focus. */
.contact-champ input:focus,
.contact-champ textarea:focus {
  outline: none;
  border-bottom: 2px solid var(--film-light);
  padding-bottom: calc(0.45rem - 1px);
}

/* La terre de sinople du cryptex, et pas un rouge d'interface : c'est un pigment de la
   même palette, et l'erreur doit rester dans le tableau. */
.contact-champ input[aria-invalid='true'],
.contact-champ textarea[aria-invalid='true'] { border-bottom-color: rgba(190, 78, 44, 0.9); }

.contact-erreur {
  font-size: 0.8rem;
  line-height: 1.35;
  color: rgba(214, 132, 104, 0.95);
}
/* Vide, il ne doit pas réserver de place : sinon les trois champs sont écartés d'une
   ligne fantôme que rien n'explique. */
.contact-erreur:empty { display: none; }
.contact-erreur:not(:empty) { margin-top: 0.35rem; }

.contact-envoi {
  display: inline-block;
  margin-top: 0.4rem;
  padding: 0.7rem 1.5rem;
  color: var(--film-light);
  background: none;
  border: 1px solid currentColor;
  border-radius: 999px;
  font-family: var(--mono);
  font-size: 0.74rem;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  cursor: pointer;
  transition: background 260ms var(--ease), color 260ms var(--ease);
}
.contact-envoi:hover, .contact-envoi:focus-visible {
  background: var(--film-light);
  color: var(--ink);
}
/* Pendant l'envoi. L'opacité est admise ici, contrairement au texte posé sur le film :
   le fond est connu et opaque, donc elle ne coûte rien au contraste — et elle dit
   exactement ce qu'il faut, que le bouton ne répond plus pour l'instant. */
.contact-envoi:disabled { opacity: 0.5; cursor: default; }

.contact-statut {
  margin-top: 0.9rem;
  font-size: 0.86rem;
  line-height: 1.4;
}
.contact-statut:empty { display: none; }
.contact-statut[data-ton='erreur'] { color: rgba(214, 132, 104, 0.95); }
.contact-statut[data-ton='reussi'] { color: rgba(198, 214, 176, 0.95); }

.contact-mention {
  margin-top: 1.2rem;
  padding-top: 0.9rem;
  border-top: 1px solid rgba(226, 220, 204, 0.16);
  font-size: 0.74rem;
  line-height: 1.4;
  /* Le seul endroit de la page où l'on baisse une encre, et c'est assumé : la mention est
     posée sur un fond opaque connu (l'encre du panneau à 0,95), pas sur le film. Mesurée
     sur ce fond-là, elle tient 8,2:1 à 0,72 — le voile la range au second plan sans
     l'approcher du plancher. */
  opacity: 0.72;
}
.contact-mention a { text-decoration: underline; text-underline-offset: 2px; }

/* ============================================================
   CHARGEMENT — le seul moment où l'on fait attendre
   ============================================================ */

.loader {
  position: fixed;
  inset: 0;
  z-index: 20;
  display: grid;
  place-content: center;
  justify-items: center;
  gap: 1.4rem;
  background: var(--ink);
}
.loader[hidden] { display: none; }
.loader-label { font-family: var(--display); font-size: 1.1rem; }
.loader-track {
  width: min(240px, 50vw);
  height: 1px;
  background: rgba(226, 220, 204, 0.2);
}
.loader-bar {
  height: 100%;
  background: var(--film-light);
  transform: scaleX(0.02);
  transform-origin: left;
  transition: transform 220ms linear;
}
.loader-note {
  font-family: var(--mono);
  font-size: 0.62rem;
  letter-spacing: 0.26em;
  text-transform: uppercase;
  opacity: 0.45;
}

/* ============================================================
   COUCHE GEEK — voir docs/geek-layer.md
   Rien n'est visible avant d'être déclenché. Aucune animation spontanée.
   ============================================================ */

/* Le mode réalisateur, révélé par le Konami code. L'instrumentation qui a servi à régler
   le moteur, offerte à qui connaît la séquence. */
.director {
  position: fixed;
  z-index: 20;
  right: var(--gut);
  bottom: 12vh;
  min-width: 15rem;
  padding: 1rem 1.1rem;
  font-family: var(--mono);
  font-size: 0.68rem;
  line-height: 1.6;
  color: var(--film-light);
  /* Volontairement sobre : un panneau de debug criard casserait le tableau au moment
     précis où le visiteur vient de gagner le droit de le voir. */
  background: rgba(20, 18, 15, 0.82);
  border: 1px solid rgba(226, 220, 204, 0.22);
  backdrop-filter: blur(6px);
}
.director[hidden] { display: none; }
.director-title {
  font-size: 0.6rem;
  letter-spacing: 0.24em;
  text-transform: uppercase;
  opacity: 0.55;
  margin-bottom: 0.7rem;
}
.director-grid {
  display: grid;
  grid-template-columns: auto 1fr;
  column-gap: 1rem;
}
.director-grid dt { opacity: 0.5; }
.director-grid dd { text-align: right; font-variant-numeric: tabular-nums; }
.director-hint {
  margin-top: 0.8rem;
  font-size: 0.58rem;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  opacity: 0.35;
}

/* La marque de 16 px : l'enseigne du chapitre V, à trouver. Sans légende, hors ordre de
   tabulation, et volontairement à peine visible — un easter egg qui se voit n'en est plus
   un. Le survol la révèle juste assez pour confirmer qu'on a trouvé quelque chose. */
.egg-mark {
  position: fixed;
  z-index: 20;
  right: 0.75rem;
  bottom: 0.75rem;
  width: 16px;
  height: 16px;
  padding: 0;
  display: block;
  color: var(--film-light);
  background: none;
  border: 0;
  cursor: pointer;
  opacity: 0.1;
  transition: opacity 200ms linear;
}
.egg-mark:hover, .egg-mark:focus-visible { opacity: 0.75; }
.egg-mark.is-found { opacity: 0.5; }
.egg-mark svg { display: block; }
html:not(.is-scrub) .egg-mark, html:not(.is-scrub) .director { display: none; }

/* ============================================================
   MODE STATIQUE — reduced-motion, pas de JS, canvas indisponible
   Le contrat d'accessibilité : une page qui détourne le scroll doit toujours
   offrir la version qui ne le détourne pas.
   ============================================================ */

.static-fallback { display: none; }

/* .is-scrub est posé par main.js quand le moteur démarre pour de bon. */
html.is-scrub .static-fallback { display: none; }

html.is-static .stage,
html.is-static .loader,
html.is-static .spacer { display: none !important; }

html.is-static .static-fallback,
html:not(.is-scrub) .static-fallback { display: block; }

.static-fallback {
  position: relative;
  z-index: 30;
  max-width: 62rem;
  margin: 0 auto;
  padding: 12vh var(--gut) 16vh;
}
.static-hero h1 {
  font-family: var(--display);
  font-weight: 400;
  font-size: clamp(2.6rem, 8vw, 5rem);
  line-height: 1;
  margin-bottom: 1rem;
}
.static-hero > p { font-size: 1.15rem; opacity: 0.8; max-width: 40ch; }

.static-chapter {
  margin-top: 10vh;
  padding-top: 3rem;
  border-top: 1px solid rgba(226, 220, 204, 0.16);
}
/* Les stills du film. Absents tant qu'un chapitre n'est pas produit : le onerror
   retire l'élément, et le repli reste lisible en texte seul. */
.static-still {
  display: block;
  width: 100%;
  height: auto;
  margin: 2rem 0;
  border-radius: 2px;
}

.static-chapter h2 {
  font-family: var(--display);
  font-weight: 400;
  font-size: clamp(1.9rem, 4.4vw, 3rem);
  line-height: 1.05;
  margin-bottom: 1rem;
}
.static-chapter p { max-width: 44ch; opacity: 0.85; }
.static-chapter .chapter-eyebrow { margin-bottom: 0.9rem; }

/* En mode statique, un palier n'a plus de tableau derrière lui : il ne reste que le
   texte. On lui redonne le poids typographique qu'il perdait au profit de l'image. */
.static-palier .palier-list { max-width: 56ch; margin-top: 0.4rem; }
.static-palier h2 { font-size: clamp(1.6rem, 3.4vw, 2.4rem); }

/* Le générique du repli statique : pas d'image à éteindre, alors le fond EST la nuit.
   L'encadré sombre suffit à dire le changement d'heure — pas d'étoiles en CSS, le
   spectacle appartient au film (src/nuit.js), le repli reste un document. */
.static-generique {
  background: #0a0b0f;
  color: rgba(255, 250, 236, 0.92);
  padding: 3.2rem clamp(1.2rem, 6vw, 4rem);
  text-align: center;
}
.static-generique .palier-list { margin-inline: auto; }

/* ============================================================
   MOBILE
   ============================================================ */

@media (max-width: 760px) {
  /* Plus de place : sur un écran étroit, une colonne de 42vw est illisible. */
  .chapter-type { max-width: min(30ch, 82vw); }

  /* Tout à gauche : alterner les coins gauche/droite sur mobile fait sauter le texte
     d'un bord à l'autre à chaque chapitre, ce qui est désagréable à suivre. */
  .anchor-center-left,
  .anchor-top-left,
  .anchor-left,
  .anchor-top-right,
  .anchor-center-right,
  .anchor-bottom-left {
    left: var(--gut);
    right: auto;
    text-align: left;
  }
  /* transform annulé : sur mobile on ancre par le haut comme les autres, sinon le bloc
     remonte de la moitié de sa hauteur alors qu'il est déjà plus haut qu'en desktop. */
  .anchor-center-left { top: 25vh; bottom: auto; transform: none; }
  .anchor-top-left    { top: 22vh; bottom: auto; }
  .anchor-left        { top: 28vh; bottom: auto; }
  .anchor-top-right   { top: 22vh; bottom: auto; }
  .anchor-center-right { top: 25vh; bottom: auto; transform: none; }
  .anchor-bottom-left { top: auto; bottom: 24vh; }
  /* Le générique reste centré, même sur mobile : la règle « tout à gauche » sert à ne pas
     faire sauter le texte d'un bord à l'autre pendant la descente — le générique est le
     dernier écran, il ne fait sauter le regard vers rien. Seul le transform vertical
     s'annule, pour la même raison de hauteur que les autres. */
  .anchor-center {
    top: 22vh;
    left: 50%;
    transform: translateX(-50%);
    text-align: center;
  }
  .anchor-top-right .chapter-body { margin-left: 0; }
  .anchor-center-right .palier-list,
  .anchor-top-right .palier-list { text-align: left; }

  /* Un palier tient plus de texte qu'un chapitre : il lui faut toute la largeur, et un
     interligne un peu resserré pour que quatre entrées tiennent dans un écran de
     téléphone sans déborder sous le pli. */
  /* Le cryptex passe SOUS le texte : côte à côte, il ne resterait ni assez de largeur
     pour neuf bagues lisibles, ni assez pour une ligne de texte. Et il rétrécit — un
     cylindre à hauteur de doigt sur un écran de téléphone mangerait tout l'écran. */
  .has-cryptex {
    grid-template-columns: 1fr;
    gap: 1.6rem;
    top: 50%;
  }
  .has-cryptex .chapter-title,
  .has-cryptex .cryptex-readout,
  .has-cryptex .cryptex { grid-column: 1; grid-row: auto; }
  .has-cryptex .chapter-title { align-self: auto; }
  .cryptex { --cryptex-cap-w: 1rem; }
  .cryptex-row { font-size: 0.78rem; letter-spacing: 0; }
  .cryptex-readout { min-height: 4.6rem; }

  .is-palier { max-width: 86vw; }
  .is-palier .chapter-title { font-size: clamp(1.6rem, 7vw, 2.1rem); margin-bottom: 1.1rem; }
  .palier-list { gap: 0.85rem; font-size: 0.95rem; }

  /* LA MARQUE ET LE BOUTON NE SONT PLUS REMONTÉS ICI, et c'est le bandeau qui l'a rendu
     inutile. Ils étaient posés à `4vh` en bureau et `3vh` sur téléphone — deux constantes
     choisies à l'œil, qu'il fallait maintenir en face l'une de l'autre. Ils se centrent
     maintenant dans la bande, c'est-à-dire à la moitié du filet haut : une seule formule,
     qui suit le cadre à toutes les tailles et n'a rien à ré-étalonner. Seul le panneau
     d'écriture garde une hauteur à lui, parce qu'il se déplie et n'est pas dans la bande. */
  .contact-sheet { top: 3vh; }
  .contact-sheet { max-height: calc(87vh - 3vh); }
  /* À 390, la marque s'arrête à x = 190,9 et l'écran ne fait plus que 390 : le bouton à
     ses mesures de bureau ne laisserait que 49,1 px entre les deux. On le resserre —
     interlettrage de 0,16 à 0,12em, rembourrage de 1,15 à 0,85rem — ce qui rend 12,3 px
     à la gouttière centrale, portée à 61,4 px, sans toucher au corps du texte, qui est ce
     qui porte la lisibilité. */
  .contact-open { padding: 0.5em 0.5em 0.5em 0.9em; letter-spacing: 0.12em; }
  /* L'indice redescend SOUS le cadre sur téléphone, et lui seul. En desktop il tient dans
     le cadre parce qu'il occupe la colonne de gauche pendant que le bloc à CTA occupe la
     droite : les deux ne se croisent jamais. Sur mobile tous les ancrages sont ramenés à
     gauche (voir plus haut), le bloc à CTA descend jusqu'à 40 px du filet, et le cylindre
     du cryptex grandit encore après sa mise en page — mesuré à 390×844, il ne restait que
     9 px entre le bas du CTA et le haut du mot. On garde en revanche l'alignement sur
     --gut, qui était le vrai reproche : le mot reste sur la verticale de texte du site. */
  .scroll-hint { bottom: 3.5vh; }
}

/* ============================================================
   ACCESSIBILITÉ
   ============================================================ */

@media (prefers-reduced-motion: reduce) {
  /* Ceinture et bretelles : main.js bascule déjà en mode statique, mais si le réglage
     change après le chargement, le CSS coupe les animations résiduelles. */
  .scroll-hint { animation: none; }
  /* La bascule de polarité doit continuer de FONCTIONNER — c'est de la lisibilité, pas de
     la décoration. Seul son fondu tombe : l'encre change alors d'un coup. */
  .stage, .chapter-type, .chapter-cta, .loader-bar,
  .chapter-eyebrow, .chapter-title, .chapter-body,
  .palier-list dt, .palier-list dd,
  .cryptex-term, .cryptex-text, .scroll-hint,
  .contact-open, .contact-close,
  .contact-champ input, .contact-champ textarea, .contact-envoi { transition: none; }

  /* Le panneau s'ouvre et se ferme, il ne se déplie plus : il est là ou il n'y est pas.
     Le fondu tombe, le décalage de 10 px aussi — mais l'état ouvert reste exactement le
     même, et le module attend toujours ses 260 ms avant de reposer `hidden`, ce qui ne se
     voit pas puisqu'il n'y a plus rien à regarder pendant ce temps. */
  .contact-veil, .contact-sheet { transition: none; }
  .contact-sheet { transform: none; }
}

:focus-visible {
  outline: 2px solid var(--film-light);
  outline-offset: 3px;
  border-radius: 4px;
}

/* ============================================================
   PAGE PROJET — projet.html
   Une page calme et lisible : on y arrive depuis un cylindre qui tourne,
   le contraste doit être franc. Pas de film, pas de scrub, juste du texte.
   ============================================================ */

.projet-page { background: var(--ink); }

.projet {
  max-width: 62ch;
  margin: 0 auto;
  padding: clamp(3rem, 12vh, 8rem) var(--gut) 6rem;
}

.projet-eyebrow {
  font-family: var(--mono);
  font-size: 0.72rem;
  letter-spacing: 0.34em;
  text-transform: uppercase;
  opacity: 0.6;
  margin-bottom: 1rem;
}

.projet h1 {
  font-family: var(--display);
  font-weight: 400;
  font-size: clamp(2.2rem, 6vw, 3.8rem);
  line-height: 1.02;
  letter-spacing: -0.02em;
  margin-bottom: 1.2rem;
}

.projet-lede {
  font-size: clamp(1.05rem, 1.6vw, 1.3rem);
  line-height: 1.5;
  opacity: 0.92;
  margin-bottom: 2.6rem;
}

/* Les faits chiffrables, en tête et pas noyés dans le texte : c'est ce qu'un
   prospect vérifie avant de lire quoi que ce soit. */
.projet-facts {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
  gap: 1.4rem 2rem;
  padding: 1.6rem 0;
  border-top: 1px solid rgba(226, 220, 204, 0.16);
  border-bottom: 1px solid rgba(226, 220, 204, 0.16);
  margin-bottom: 2.6rem;
}
.projet-facts dt {
  font-family: var(--mono);
  font-size: 0.68rem;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  opacity: 0.55;
  margin-bottom: 0.35rem;
}
.projet-facts dd { margin: 0; font-size: 1rem; }

.projet-corps p { margin-bottom: 1.15rem; opacity: 0.88; max-width: 58ch; }

.projet-actions { margin: 2.4rem 0 3.4rem; }

.projet-autres-nav { border-top: 1px solid rgba(226, 220, 204, 0.16); padding-top: 1.8rem; }
.projet-autres { list-style: none; display: grid; gap: 0.6rem; }
.projet-autres a {
  font-family: var(--mono);
  font-size: 0.86rem;
  border-bottom: 1px solid rgba(226, 220, 204, 0.28);
  padding-bottom: 0.15rem;
}
.projet-autres a:hover { border-bottom-color: currentColor; }

.projet-retour {
  display: inline-block;
  margin-top: 2.6rem;
  font-family: var(--mono);
  font-size: 0.78rem;
  opacity: 0.7;
}
.projet-retour:hover { opacity: 1; }

.projet-loading { font-family: var(--mono); font-size: 0.8rem; opacity: 0.5; }

/* ─────────────────────────────────────────────────────────────────────────────────────
   LE CRYPTEX ACCOSTÉ — l'objet devient le sommaire.

   Deux états, un seul objet. AU REPOS il tourne à droite, posé sur l'établi peint : c'est
   un appât, il n'a rien à dire et il ne demande qu'à être touché. ACCOSTÉ il monte en haut
   à gauche, s'arrête sur un cran, rétrécit, et la liste des projets s'ouvre sous lui.

   Le déplacement lui-même est joué en FLIP par cryptex.js — ici on ne décrit que les deux
   positions et la façon de passer de l'une à l'autre. */

.cryptex {
  cursor: pointer;
  transition: transform 620ms cubic-bezier(0.22, 0.61, 0.24, 1);
  /* L'objet est le bouton : il doit se signaler au clavier comme n'importe quel autre. */
  outline-offset: 6px;
}
/* Le geste au survol est minuscule et c'est voulu : l'objet BOUGE DÉJÀ tout seul. Une
   grosse réaction au survol entrerait en concurrence avec sa propre rotation, et deux
   mouvements sur le même objet se lisent comme un défaut d'animation. */
.cryptex:hover { filter: brightness(1.06); }

/* ACCOSTÉ, TOUT SE RANGE DANS LA COLONNE DE GAUCHE, et c'est la seule mise en page
   possible : le mur nu du tableau est là, et lui seul. Poser la liste dans la colonne de
   droite — celle que le cylindre vient de libérer — la met sur les deux artisans, l'aile en
   construction et la fenêtre. C'est illisible, et ça abîme le seul endroit du tableau qu'on
   avait pris soin de peindre plein.

   Le palier passe donc d'une grille en deux colonnes à une seule, tenue à gauche : le
   cylindre en haut, réduit, et les quatre projets sous lui. */
.has-cryptex.is-open {
  grid-template-columns: minmax(0, 0.46fr) minmax(0, 1fr);
  align-items: start;
  top: 0;
  transform: none;
  height: 100%;
  /* 18 vh en bas, et c'est mesuré et non choisi : l'indice de scroll (« DESCENDRE » et son
     rail) vit au pied de la même colonne, et la liste lui passait dessus. */
  padding: 7vh 0 23vh;
  align-content: start;
  row-gap: clamp(0.9rem, 2vh, 1.6rem);
}
.has-cryptex .cryptex.is-docked {
  grid-column: 1;
  grid-row: 1;
  align-self: start;
  justify-self: start;
  width: min(210px, 24vw);
  cursor: zoom-out;
}
/* Le titre s'efface : accosté, le sommaire EST le titre. Deux en-têtes l'un au-dessus de
   l'autre — « Ce que nous avons livré » puis quatre noms de projets — répètent la même
   chose, et le second est plus utile. */
.has-cryptex.is-open .chapter-title {
  opacity: 0;
  pointer-events: none;
  transition: opacity 240ms ease;
}
/* La liste prend la place sous le cylindre. C'est le <dl> qui vivait déjà hors écran pour
   les lecteurs d'écran : accosté, il rentre dans le cadre. */
.has-cryptex.is-open .palier-list {
  grid-column: 1;
  grid-row: 2;
  align-self: start;
  max-width: 34ch;
}
.has-cryptex.is-open .palier-list dt {
  font-size: clamp(0.95rem, 1.5vw, 1.25rem);
  letter-spacing: -0.01em;
}
.has-cryptex.is-open .palier-list dd {
  margin: 0.25rem 0 0;
  opacity: 0.72;
  font-size: clamp(0.8rem, 1vw, 0.95rem);
  line-height: 1.4;
}
.has-cryptex.is-open .palier-list dd + dt { margin-top: clamp(0.7rem, 1.6vh, 1.2rem); }
/* Accosté, la légende du projet de face n'a plus de rôle : la liste dit tout, et deux
   descriptions du même projet à l'écran en même temps se contredisent dès qu'on survole une
   autre ligne. */
.has-cryptex.is-open .cryptex-readout { display: none; }

@media (prefers-reduced-motion: reduce) {
  .cryptex { transition: none; }
}
