/* Hallmark · route: custom (bespoke) · structure: pitch
 * idea: "le robot au centre, les mains cadrent le bouton"
 * paper: oklch(50% 0.148 150) · accent: oklch(62% 0.21 32)
 * display: Bricolage Grotesque · body: Instrument Sans
 * axes: mid / geometric-sans / chromatic-green
 * pre-emit critique: P5 H5 E4 S5 R4 V5
 */

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

html, body { overflow-x: clip; background: var(--color-pitch); }
html { -webkit-font-smoothing: antialiased; }

body {
  font-family: var(--font-body);
  font-size: var(--text-base);
  line-height: 1.55;
  color: var(--color-ink);
  min-height: 100dvh;
}

img { display: block; max-width: 100%; height: auto; }
a { color: inherit; text-decoration: none; }
::selection { background: var(--color-accent); color: var(--color-accent-ink); }

:focus-visible {
  outline: 3px solid var(--color-focus);
  outline-offset: 3px;
  transition: none;                 /* jamais animé : doit paraître à l'instant */
}

.skip {
  position: fixed; top: var(--space-md); left: var(--space-md); z-index: 90;
  padding: var(--space-xs) var(--space-md);
  background: var(--color-chalk); color: var(--color-chalk-ink);
  font-weight: 600; border-radius: var(--radius-sm);
  transform: translateY(-250%);
}
.skip:focus-visible { transform: translateY(0); }

/* ═══════════════════════════════════════════════════════ le terrain ═══
   Bandes tondues, ligne médiane, rond central : un vrai motif de pelouse.
   C'est ce qui ancre la page dans le football plutôt que dans « landing sombre ». */

/* ══════════════════════════════════════════════ écran de chargement ═══ */

/* z-index 200 : au-dessus de tout, y compris la barre du haut (40) et le rail
   (35). Le fond est OPAQUE — un voile translucide laisserait voir le stade
   s'assembler derrière, ce qui est exactement ce qu'on masque. */
.boot {
  position: fixed; inset: 0; z-index: 200;
  display: grid; place-items: center;
  background: var(--color-pitch-dark);
  transition: opacity 520ms var(--ease-out), visibility 520ms;
}
/* Retiré du flux ET de l'ordre de tabulation une fois masqué : laissé visible
   pour les lecteurs d'écran, il annoncerait un chargement déjà terminé. */
.boot.is-done { opacity: 0; visibility: hidden; pointer-events: none; }

.boot__inner {
  display: grid; justify-items: center; gap: var(--space-lg);
  padding: var(--gutter);
}

/* Le ballon rebondit ET tourne : deux animations distinctes sur deux éléments
   imbriqués serait plus souple, mais un seul suffit ici car les deux partagent
   la même durée. L'écrasement au contact (scale sur Y) est ce qui distingue un
   rebond crédible d'une simple translation verticale. */
.boot__ball {
  width: 72px; height: 72px;
  animation: boot-bounce 900ms cubic-bezier(0.4, 0, 0.6, 1) infinite,
             boot-spin 1800ms linear infinite;
  transform-origin: 50% 50%;
}
@keyframes boot-bounce {
  0%, 100% { transform: translateY(-26px); }
  50%      { transform: translateY(0); }
}
@keyframes boot-spin {
  to { rotate: 360deg; }
}

.boot__label {
  font-family: var(--font-display);
  font-size: var(--text-2xl); font-weight: 700;
  color: var(--color-ink);
  letter-spacing: -0.02em;
}

/* Ligne de touche : trait blanc plein, comme les tracés du terrain — pas une
   barre de progression d'interface grise. */
.boot__track {
  width: min(62vw, 18rem); height: var(--rule);
  background: var(--color-line-soft);
  overflow: hidden;
}
.boot__fill {
  display: block; height: 100%; width: 0%;
  background: var(--color-ink);
  transition: width 220ms var(--ease-out);
}
.boot__pct {
  font-family: var(--font-body); font-size: var(--text-sm);
  font-variant-numeric: tabular-nums;   /* sinon la largeur saute à chaque chiffre */
  color: var(--color-muted);
}

/* Une animation qui rebondit en boucle est exactement ce que ce réglage
   désigne. On garde l'écran et la progression, on retire le mouvement. */
@media (prefers-reduced-motion: reduce) {
  .boot__ball { animation: none; }
}

/* ══════════════════════════════════════════ invitation mobile ═══ */

/* z-index 210 : au-dessus de l'écran de chargement (200), qui se lève tout de
   suite en mode léger — l'invitation doit rester le dernier plan. */
.invite {
  position: fixed; inset: 0; z-index: 210;
  display: grid; place-items: end center;
  padding: var(--gutter);
  padding-bottom: calc(var(--gutter) + env(safe-area-inset-bottom) + 3.5rem);
  background: linear-gradient(to top,
    oklch(14% 0.03 252 / 0.86) 0%, oklch(14% 0.03 252 / 0.35) 60%, transparent 100%);
  animation: invite-in 420ms var(--ease-out) both;
}
.invite[hidden] { display: none; }
@keyframes invite-in { from { opacity: 0; } }

.invite__card {
  width: min(100%, 26rem);
  display: grid; justify-items: center; gap: var(--space-sm);
  text-align: center;
  padding: var(--space-xl);
  background: var(--color-chalk);
  color: var(--color-chalk-ink);
  border-radius: 6px;
  box-shadow: 0 28px 70px oklch(14% 0.03 252 / 0.6);
  animation: invite-up 480ms var(--ease-out) both;
}
@keyframes invite-up { from { transform: translateY(22px); opacity: 0; } }

.invite__icon { width: 34px; height: 34px; color: var(--color-accent); }
.invite__title {
  font-family: var(--font-display); font-weight: 700;
  font-size: var(--text-2xl); line-height: 1.05;
  letter-spacing: -0.02em;
}
.invite__body {
  font-size: var(--text-base); color: var(--color-chalk-ink2);
  max-width: 30ch;
}
/* Les deux boutons empilés : côte à côte sur 26 rem, « Lancer la 3D ici » se
   couperait sur deux lignes. */
.invite__actions {
  display: grid; gap: var(--space-xs);
  width: 100%; margin-top: var(--space-xs);
}
.invite__actions .btn { width: 100%; }
.invite__note {
  font-size: var(--text-xs); color: var(--color-chalk-ink2);
  opacity: 0.75;
}

/* ══════════════════════════════════════════════ paysage forcé ═══
 *
 * Quand `screen.orientation.lock('landscape')` échoue — iOS Safari, où l'API
 * n'existe pas, ou téléphone dont l'utilisateur a verrouillé la rotation en
 * portrait — aucune API ne peut faire pivoter le matériel. On fait alors pivoter
 * le CONTENU : le visiteur tourne son téléphone, l'image reste droite.
 *
 * Les dimensions sont INVERSÉES (100svh de large, 100svw de haut) avant la
 * rotation de 90°, sinon le canvas garderait un cadre portrait tourné et
 * laisserait deux bandes vides. Le renderer lit la taille du canvas et non celle
 * de la fenêtre (voir resize() dans scene.js), donc il suit sans réglage.
 *
 * Seule la scène pivote — pas la page entière : le texte et les boutons
 * resteraient illisibles à la verticale, et le défilement partirait de travers.
 * En paysage forcé, la 3D occupe tout l'écran et le contenu HTML est masqué. */
/* ══════════════════════════════════════════════ paysage imposé ═══
 *
 * « Lancer la 3D » présente le site en paysage quelle que soit l'orientation
 * physique de l'appareil et son verrouillage de rotation. Quand
 * `screen.orientation.lock()` aboutit, l'OS pivote et il n'y a rien à faire :
 * cette classe n'est alors même pas posée. Quand il échoue — iOS Safari, ou
 * rotation verrouillée dans les réglages — aucune API ne peut faire tourner le
 * matériel, donc on pivote le document.
 *
 * `html` porte la rotation, PAS `body` : un `transform` sur body en fait un
 * conteneur de référence pour ses enfants `position: fixed`, qui cessent alors
 * de se caler sur le viewport. La scène 3D, le rail et l'écran de chargement en
 * dépendent tous.
 *
 * Les dimensions sont inversées AVANT la rotation, sinon le contenu garde un
 * cadre portrait tourné et laisse deux bandes vides sur les côtés.
 *
 * `transform-origin` en haut à gauche puis translation : c'est la combinaison qui
 * ramène le coin pivoté exactement sur celui de l'écran. Un
 * `translate(-50%, -50%)` centrerait aussi, mais décale le repère des enfants
 * fixes d'une demi-dimension. */
html.force-landscape { overflow: hidden; }   /* plus rien ne scrolle au niveau page */

/* Les DEUX wrappers subissent la même rotation. Ils se superposent exactement :
   #fixes porte les couches immobiles, #rot le contenu qui défile.
   Cette séparation est indispensable — un élément `position: fixed` dont un
   ancêtre porte un `transform` défile avec le contenu de cet ancêtre au lieu de
   rester fixe à l'écran. La scène 3D restait donc collée au premier écran du
   contenu et laissait un fond uni sous les sections suivantes. */
html.force-landscape #fixes,
html.force-landscape #rot {
  position: fixed;
  inset: 0;
  /* Dimensions inversées : la largeur du cadre pivoté vaut la hauteur de
     l'écran, et sa hauteur vaut la largeur. Sans cela le contenu garderait un
     cadre portrait tourné, avec deux bandes vides sur les côtés. */
  width: 100svh;
  height: 100svw;
  transform-origin: top left;
  /* La rotation amène le coin haut-gauche hors écran ; la translation de la
     hauteur du cadre (donc 100svw) le ramène exactement sur le coin de l'écran. */
  transform: rotate(90deg) translateY(-100svw);
}
/* #fixes ne défile pas et ne doit rien intercepter : ses enfants réactivent les
   événements pointeur individuellement (boutons de langue, rail). */
html.force-landscape #fixes {
  overflow: hidden;
  pointer-events: none;
  z-index: 1;
}
html.force-landscape #fixes > * { pointer-events: auto; }
/* Le contenu passe au-dessus des couches de décor. On perd ici l'entrelacement
   titre/personnage du desktop — impossible à conserver, `transform` créant un
   contexte d'empilement par wrapper. Sans conséquence en paysage : le texte
   occupe la colonne de gauche et le personnage la droite, ils ne se recouvrent
   plus. */
html.force-landscape #rot { z-index: 2; }

/* Les trois calques plein écran, hors wrappers, pivotent pour eux-mêmes et
   conservent leurs z-index globaux (200, 210, 220). */
html.force-landscape .boot,
html.force-landscape .invite,
html.force-landscape .rotate {
  width: 100svh;
  height: 100svw;
  transform-origin: top left;
  transform: rotate(90deg) translateY(-100svw);
}

html.force-landscape #rot {
  overflow-x: hidden;
  overflow-y: auto;          /* le défilement vit ici, et ici seulement */
  -webkit-overflow-scrolling: touch;
}
/* `min-height: 100svh` sur body compte la hauteur RÉELLE du viewport, soit la
   grande dimension sous rotation : il forçait 844 px de haut dans un cadre de
   390 px et empêchait la mise en page de se refermer. */
html.force-landscape body { min-height: 0; }

/* Les boutons quittent l'accrochage aux mains et reviennent dans le flux.
   `--gesture-x/y` sont calculées en projetant les poignets dans le repère du
   VIEWPORT, alors que le conteneur est pivoté de 90° : les deux repères ne
   coïncident plus, et le bloc partait sur le côté réduit à deux pastilles.
   Transformer les coordonnées serait possible, mais l'accrochage n'a de sens que
   dans le hero cadré large — ici le bloc gagne à être simplement lisible. */
/* Les boutons RESTENT accrochés aux mains du personnage, comme sur ordinateur.
   Ils avaient été sortis du flux parce qu'ils partaient sur le côté : la
   projection des poignets se faisait dans les dimensions du VIEWPORT (390 × 844)
   au lieu de celles du canvas (829 × 390). Corrigé dans scene.js — le canvas et
   le contenu subissant la même rotation, leurs repères coïncident.
   Ici on ne fait donc que RÉDUIRE, pour qu'ils tiennent entre les mains : le
   personnage mesure ~240 px de haut à l'écran, des boutons de 44 px empilés le
   dépassaient. */
html.paysage .gesture-target {
  gap: calc(0.8 * var(--hu));
  /* La taille reste celle déjà réglée au tour précédent : on ne touche qu'à la
     POSITION ici. */
}
html.paysage .gesture-target .btn {
  min-height: calc(8 * var(--hu));
  font-size: calc(var(--text-xs) * 0.88);
  padding-inline: var(--space-sm);
  gap: var(--space-2xs);
}
html.paysage .gesture-target .btn__logo { width: 13px; height: 13px; }

/* Libellés : les mêmes partout, y compris en paysage mobile.
   J'avais introduit une version courte (« Steam » / « Discord ») parce que
   « Bientôt sur Steam » impose 151 px de largeur minimale alors que les mains ne
   sont écartées que de 91 px. Mais changer le texte selon l'écran affaiblit
   l'appel à l'action, et ce n'était pas demandé : le bloc dépasse donc un peu les
   mains, ce qui est le compromis assumé. Les libellés courts restent dans le HTML
   au cas où, simplement jamais affichés. */
.btn__court { display: none; }
/* La note passe à la trappe : trois lignes sous les boutons ne tiennent pas dans
   l'espace laissé par les mains. */
html.paysage .gesture-target__note { display: none; }
/* Les couches fixes se recalent sur le cadre pivoté. Un élément `fixed` dont un
   ancêtre porte un `transform` se positionne par rapport à cet ancêtre : `inset:
   0` couvre donc exactement le document tourné, sans calcul supplémentaire. */
html.force-landscape .stage,
html.force-landscape .stage-bg {
  position: fixed;
  inset: 0;
  width: 100%;
  height: 100%;
  transform: none;
}

/* Historique — la première tentative de rotation forcée a été retirée puis
   reprise sur d'autres bases. Ses quatre défauts, pour ne pas les refaire :
 *
 * Elle pivotait le document pour imposer le paysage quand
 * `screen.orientation.lock()` échoue. Elle a produit quatre défauts successifs :
 *   1. `inset: auto` déclaré après top/left écrasait le centrage — scène en haut
 *      à gauche, au quart de sa taille ;
 *   2. la fonction de mise à jour écoutait `resize` et en émettait un —
 *      récursion infinie, onglet gelé ;
 *   3. le contenu masqué supprimait le défilement, donc la progression restait à
 *      zéro et l'animation ne démarrait jamais ;
 *   4. `body` pivoté gardait un cadre portrait : mise en page comprimée, mesuré
 *      829 × 844 au lieu de 844 × 390.
 *
 * Les trois premiers sont corrigés ci-dessus. Le quatrième l'est par la classe
 * `html.paysage`, qui applique la mise en page paysage indépendamment de ce que
 * mesure le viewport — une media query ne peut pas le faire, puisque sous
 * rotation le viewport reste portrait. */

/* ── Demande de rotation ──
   Plein écran opaque : elle n'apparaît qu'après le passage en plein écran, donc
   il n'y a plus de page à voir derrière. */
.rotate {
  position: fixed; inset: 0; z-index: 220;
  display: grid; place-items: center; align-content: center;
  gap: var(--space-lg);
  background: var(--color-pitch-dark); color: var(--color-ink);
  font-family: var(--font-display); font-size: var(--text-2xl);
  text-align: center; padding: var(--gutter);
}
.rotate[hidden] { display: none; }
.rotate__icon {
  width: 56px; height: 56px;
  animation: rotate-hint 1600ms var(--ease-in-out) infinite;
}
@keyframes rotate-hint {
  0%, 45%   { transform: rotate(0deg); }
  70%, 100% { transform: rotate(-90deg); }
}
@media (prefers-reduced-motion: reduce) {
  .rotate__icon { animation: none; }
  .invite, .invite__card { animation: none; }
}

/* Le CIEL. Le terrain lui-même est en 3D dans le canvas ; ce calque ne peint
   que ce qui est au-dessus de l'horizon, et sa teinte basse doit correspondre
   exactement à la couleur de brume de la scène (HORIZON dans scene.js) — sinon
   une arête nette apparaît à la jonction et toute la profondeur s'effondre.

   Le dégradé monte en clarté ET DESCEND en chroma vers le haut : 0.028 en
   haut contre 0.07 à l'horizon. Un ciel aussi saturé que le sol se lit comme
   un fond de couleur, pas comme de l'air. */
.pitch {
  position: fixed; inset: 0; z-index: 0;
  /* La teinte d'horizon doit être atteinte AVANT la hauteur où le sol 3D
     commence, pas à cette hauteur exactement. L'horizon géométrique a été
     mesuré à 49,2 % du viewport (fov vertical 38°, caméra quasi horizontale à
     1,1 m — cette fraction ne dépend donc pas de la taille de la fenêtre).
     Le stop était à 63 %, puis à 50 % : dans les deux cas le ciel valait encore
     #61a67a au point de jonction contre #48895f pour le sol, soit un écart de
     81 sur la somme RVB — une arête franche en travers de l'image.
     Posé à 46 %, le ciel a déjà atteint la teinte de brume quand le sol
     apparaît, et la jonction disparaît. */
  /* Bascule du vert prairie au gris-bleu de stade couvert : ce calque servait de
     ciel au-dessus d'une pelouse infinie ; il n'y a plus de ciel, mais un toit et
     des gradins bleus. Un dégradé vert restait visible sur les bords du canvas et
     jurait avec tout le reste. La rampe de clarté est conservée (0,88 → 0,30) —
     c'est elle qui donne la profondeur — mais la teinte suit désormais le décor.
     Teinte basse = HORIZON dans scene.js (0x3a4a63). */
  background: linear-gradient(to bottom,
    oklch(94% 0.018 240) 0%,
    oklch(84% 0.028 245) 20%,
    oklch(70% 0.040 250) 34%,
    #5b7093 46%,
    #5b7093 100%);
}

/* ══════════════════════════════════════════════════ le personnage 3D ═══ */

/* Empilement du hero, de l'arrière vers l'avant :
     0  .pitch          le ciel, en CSS
     1  .stage-bg       le stade
     2  .hero__title    « Poly Strike »
     3  .stage          le personnage, canvas transparent
     5  boutons, textes  toujours cliquables et lisibles
   Le personnage doit passer DEVANT le titre et DERRIÈRE les boutons : c'est ce
   double impératif qui impose de séparer décor et personnage en deux canvas. */
.stage-bg {
  position: fixed; inset: 0; z-index: 1;
  pointer-events: none;
}
.stage-bg canvas { display: block; width: 100%; height: 100%; }

.stage {
  position: fixed; inset: 0; z-index: 3;
  transition: opacity var(--dur-slow) var(--ease-in-out);
  pointer-events: none;
}
.stage.is-dimmed { opacity: 0.1; }
.stage canvas { display: block; width: 100%; height: 100%; }

/* Vignettage — creuse les bords et ramène l'œil au centre. Fait en CSS plutôt
   qu'en post-traitement WebGL : même résultat, aucun coût GPU.

   Deux couches et non une : un vignettage large et froid pour la profondeur,
   plus un halo chaud très diffus derrière le personnage. Une seule couche
   sombre assombrit sans rien éclairer, et l'image reste terne. */
.stage::after {
  content: ""; position: absolute; inset: 0; pointer-events: none;
  background:
    radial-gradient(58% 46% at 50% 40%,
      oklch(96% 0.05 85 / 0.10) 0%, transparent 100%),
    radial-gradient(118% 90% at 50% 40%,
      transparent 0%, transparent 44%, oklch(16% 0.045 205 / 0.52) 100%);
}

/* Voile haut. Le ciel éclairci (L 0,69 → 0,80) a rendu le sur-titre et la
   signature du studio illisibles : ils sont en --color-muted, une encre CLAIRE,
   donc le contraste tombait à environ 1,1:1 — très en dessous du 4,5:1 exigé.
   Assombrir le ciel reprendrait l'amplitude tonale qu'on vient de gagner, et
   foncer ces textes les casserait partout ailleurs où le fond est sombre.
   Un voile local ne coûte ni l'un ni l'autre. Il est posé sur .pitch, donc
   SOUS le canvas : il assombrit le ciel sans toucher au personnage. */
.pitch::after {
  content: ""; position: absolute; inset: 0 0 auto 0; height: 34%;
  background: linear-gradient(to bottom,
    oklch(20% 0.030 252 / 0.46) 0%,
    oklch(20% 0.030 252 / 0.18) 55%,
    transparent 100%);
}

/* ══════════════════════════════════ sans 3D : mobile, WebGL absent, ═══
   ══════════════════════════════════ ou mouvement réduit             ═══

   La classe `no-3d` est posée sur <html> par main.js quand aucune scène n'est
   construite. Les .glb ne sont alors même pas téléchargés — c'est décidé en JS,
   avant toute requête ; ce bloc ne fait que la mise en page correspondante. */

/* Le canvas de décor n'a rien à rendre : on le retire du rendu plutôt que de le
   laisser transparent, pour ne pas garder une couche de compositing inutile. */
html.no-3d .stage-bg { display: none; }

/* Le fond devient l'image, cadrée en `cover` et non `contain` : sur un écran de
   téléphone en portrait, `contain` laisserait deux bandes vides au-dessus et en
   dessous. On perd les bords de l'image, ce qui est le bon compromis pour un
   fond. `center 38%` garde les personnages dans le cadre plutôt que de centrer
   sur la pelouse. */
html.no-3d .stage { position: fixed; inset: 0; z-index: 1; }
html.no-3d .stage__fallback {
  display: block; width: 100%; height: 100%;
  /* 68 % et non 38 % : à 38 % le cadrage tombait sur le plafond du stade et ses
     projecteurs, les robots étant dans la moitié basse de duel.png. Vérifié en
     viewport 390 × 844. */
  object-fit: cover; object-position: center 68%;
}

/* Sans mains à suivre, --gesture-* n'est jamais écrit et les valeurs de repli
   s'appliquent. On repositionne donc le bloc dans le flux normal du hero, sous
   le titre : le laisser en `fixed` au milieu de l'écran le ferait chevaucher le
   texte sur les petites hauteurs. */
html.no-3d .gesture-target {
  position: static;
  transform: none;
  width: min(88vw, 22rem);
  margin: var(--space-lg) auto 0;
}
html.no-3d .gesture-target .btn { min-height: 52px; }

/* Voile plus soutenu sur l'image : le texte du hero passe par-dessus une photo
   et non plus un dégradé maîtrisé, donc le contraste doit être garanti. */
html.no-3d .pitch::after { height: 100%; }

.stage__fallback { display: none; }
.stage.is-fallback canvas { display: none; }
.stage.is-fallback .stage__fallback {
  display: block; width: 100%; height: 100%;
  object-fit: contain; object-position: 50% 42%;
}

/* ══════════════════════════════════════════════════════════ topbar ═══ */

.topbar {
  position: fixed; inset: 0 0 auto 0; z-index: 40;
  display: flex; align-items: center; justify-content: space-between;
  gap: var(--space-md);
  padding: var(--space-lg) var(--gutter);
  pointer-events: none;
}
.topbar > * { pointer-events: auto; }

.wordmark { display: grid; }
.wordmark__name {
  font-family: var(--font-display); font-weight: 800;
  font-size: 1.05rem; letter-spacing: -0.02em;
}
.wordmark__studio { font-size: var(--text-xs); color: var(--color-muted); }

.lang { display: flex; align-items: center; gap: var(--space-2xs); }
.lang__btn {
  appearance: none; border: 0; cursor: pointer;
  background: transparent; color: var(--color-ink-2);
  font: inherit; font-size: var(--text-sm); font-weight: 500;
  padding: var(--space-2xs) var(--space-sm);
  border-radius: var(--radius-pill);
  transition: background var(--dur-fast) var(--ease-out), color var(--dur-fast) var(--ease-out);
}
.lang__btn:hover { background: var(--color-line-soft); }
.lang__btn.is-active { background: var(--color-ink); color: var(--color-pitch); }

/* ════════════════════════════════════════════════════════ le rail ═══
   Plus de tirets : un simple repère plein, et le libellé en bas-de-casse. */

.rail {
  position: fixed; z-index: 35;
  left: var(--gutter); top: 50%; transform: translateY(-50%);
  display: grid; gap: var(--space-sm);
  width: 132px;
}
.rail__beats { list-style: none; display: grid; gap: var(--space-2xs); }
.rail__beat {
  display: grid; grid-template-columns: 8px minmax(0, 1fr);
  align-items: center; gap: var(--space-xs);
  font-size: var(--text-sm); color: var(--color-muted);
  transition: color var(--dur-mid) var(--ease-out);
}
.rail__beat::before {
  content: ""; width: 8px; height: 8px; border-radius: 50%;
  background: var(--color-line-soft);
  transition: background var(--dur-mid) var(--ease-out), transform var(--dur-mid) var(--ease-out);
}
.rail__beat.is-past { color: var(--color-ink-2); }
.rail__beat.is-past::before { background: var(--color-line); }
.rail__beat.is-current { color: var(--color-ink); font-weight: 600; }
.rail__beat.is-current::before { background: var(--color-accent); transform: scale(1.35); }

.rail__track { height: 3px; background: var(--color-line-soft); border-radius: var(--radius-pill); }
.rail__fill { display: block; height: 100%; width: 0%; background: var(--color-accent); border-radius: inherit; }

/* ═══════════════════════════════════════════════════════ les folds ═══ */

main { position: relative; }

.fold {
  /* z-index 5 : au-dessus du canvas du personnage, passé à 3. Sous ce seuil, le
     rendu 3D recouvrirait boutons et textes — donc aussi les zones cliquables. */
  position: relative; z-index: 5;
  min-height: 100svh;
  display: grid; align-content: center;
  padding: var(--space-4xl) var(--gutter) var(--space-4xl)
           calc(var(--gutter) + 132px + var(--space-2xl));
}
.fold--end { justify-items: end; }

/* ── hero ────────────────────────────────────────────────────────────────
   Le personnage est CENTRÉ. Le titre passe DERRIÈRE lui (z-index 1, sous le
   canvas à 2) — c'est ce chevauchement qui donne la profondeur d'une affiche.
   Le bouton se pose dans l'axe, à la hauteur où les mains se rejoignent. */

.fold--hero {
  z-index: auto;                     /* pas de contexte d'empilement ici, sinon
                                        le titre ne peut pas passer sous le canvas */
  position: relative;
  justify-items: center; text-align: center;
  align-content: space-between;
  padding: 0 var(--gutter) var(--space-2xl);
  grid-template-rows: auto auto 1fr;
}

.kicker {
  /* z-index 5 : au-dessus du canvas du personnage, passé à 3. Sous ce seuil, le
     rendu 3D recouvrirait boutons et textes — donc aussi les zones cliquables. */
  position: relative; z-index: 5;
  margin-top: clamp(5.5rem, 12svh, 7.5rem);
  font-size: var(--text-sm); font-weight: 600;
  color: var(--color-ink-2);
}
.kicker--ink { color: var(--color-accent); }

.hero__title {
  /* DEVANT le canvas (z-index 3, comme le reste du hero).
     Le titre était à z-index 1, sous le canvas : le personnage passait devant
     lui, ce qui donnait la profondeur d'une affiche. Cet effet ne tenait que
     parce que le canvas était transparent hors du personnage. Depuis l'arrivée
     du stade, le canvas est opaque sur toute sa surface et le titre disparaît
     entièrement — c'est la deuxième fois que ce réglage se retourne.
     Résolu autrement : le décor a son propre canvas (.stage-bg, z-index 1) et
     le titre se glisse ENTRE lui et le canvas du personnage (z-index 3). Le
     personnage repasse donc devant le texte, sans que le titre disparaisse. */
  position: relative; z-index: 2;
  font-family: var(--font-display);
  font-weight: 800; font-style: normal;
  font-size: var(--text-display);
  line-height: 0.86;
  letter-spacing: -0.05em;
  color: var(--color-ink);
  margin-top: var(--space-md);
  max-width: 14ch;
  text-wrap: balance;

  /* Ombre et contour lumineux, sans toucher au gabarit.
     `text-shadow` est le seul outil qui garantit ça : il peint hors du flux et
     n'entre dans aucun calcul de mise en page, donc la taille et la position du
     titre restent au pixel près. `-webkit-text-stroke` aurait paru équivalent,
     mais il dessine sur l'axe du contour et épaissit visuellement les glyphes —
     ce n'est pas un déplacement, c'est une graisse en plus, et sur un titre à
     `letter-spacing: -0.05em` les lettres se seraient rapprochées à l'œil.

     Unités en `em` et non en px : le titre passe par `clamp()` et change de
     corps entre le mobile et le grand écran. En pixels, un halo réglé pour
     110 px de haut serait un brouillard à 40 px.

     Ordre : la première ombre est peinte AU-DESSUS des suivantes. On va donc du
     plus serré au plus large, l'ombre portée en dernier — sinon le halo blanc
     la recouvrirait et elle ne servirait à rien. */
  text-shadow:
    0 0 0.035em rgba(255, 255, 255, 0.55),   /* liseré, colle aux lettres */
    0 0 0.13em rgba(255, 255, 255, 0.20),    /* halo, décolle du fond */
    0 0.022em 0.055em rgba(9, 15, 25, 0.30); /* ombre portée, très légère */
}

/* Décroché du flux de texte : sa position ne doit dépendre que de la hauteur
   où les deux index se rejoignent, pas de la longueur du titre au-dessus.
   Sinon un titre plus long sur une autre langue décale la cible du geste. */
/* Le bouton s'accroche aux MAINS du personnage, pas à un pourcentage de la
   page : --gesture-x / --gesture-y sont écrites par la scène 3D à partir de la
   projection des poignets. Les valeurs de repli servent avant le chargement du
   modèle et si WebGL est absent. */
.gesture-target {
  position: fixed; z-index: 5;   /* au-dessus du canvas du personnage (3) */
  left: 0; top: 0;
  transform:
    translate3d(var(--gesture-x, 50vw), var(--gesture-y, 52svh), 0)
    translate(-50%, var(--gesture-drop, 7.5rem));
  display: grid; gap: var(--space-xs); justify-items: center;
  will-change: transform;
  transition: opacity var(--dur-mid) var(--ease-out);

  /* Largeur = écart réel des mains à l'écran, mesuré par la scène 3D.
     Auparavant les boutons prenaient leur largeur de leur contenu : ils
     tombaient juste à une seule taille de fenêtre, et jamais pendant le geste,
     où l'écartement des bras change à chaque frame.
     Les bornes sont indispensables : sur certaines poses les poignets se
     rapprochent presque, et une largeur suivant l'écart sans limite basse
     écraserait « Bientôt sur Steam » sur trois lignes. La borne haute évite
     l'inverse, un bouton étiré plus large que sa propre étiquette. */
  /* Borne basse à 13rem (208 px) et non 15rem : l'écart des mains est mesuré à
     233 px dans la pose de pointage, et une borne à 240 px l'emportait sur la
     mesure — le dimensionnement dynamique n'avait alors aucun effet.

     AMPLEUR : le bloc dépasse volontairement un peu l'écart des mains. À
     exactement 1.0 les boutons rentraient au millimètre entre les doigts, ce qui
     était juste mais étroit — le geste désigne le bouton, il n'a pas besoin de
     l'enfermer. Le facteur reste modeste pour que la relation aux mains se lise
     encore. */
  --gesture-ampleur: 1.14;
  width: clamp(13rem, calc(var(--gesture-w, 17rem) * var(--gesture-ampleur)), 28rem);
}
/* Les deux boutons remplissent la largeur mesurée — sans quoi ils resteraient
   dimensionnés par leur texte et le calage sur les mains ne se verrait pas.
   box-sizing explicite : mesuré, le bouton sortait à 250 px dans un bloc de
   240 px, son padding s'ajoutant à la largeur au lieu d'être compris dedans —
   il dépassait donc des mains par les deux bords. */
.gesture-target .btn {
  width: 100%;
  box-sizing: border-box;
  justify-content: center;
  /* Padding réduit de 2rem à 1rem : `.btn` porte `white-space: nowrap`, donc sa
     largeur min-content vaut le texte plus le padding — mesuré à 250 px, au-delà
     des 233 px d'écart des mains. `width: 100%` ne peut pas descendre sous
     min-content, et le bouton dépassait donc des mains quoi qu'on demande.
     Rogner le padding fait tomber ce plancher à ~218 px et rend la mesure
     effective. */
  padding-inline: var(--space-md);
  /* Un peu plus haut et plus lisible que le `.btn` générique (50 px / 1rem) :
     ce sont les deux seuls appels à l'action du hero, ils portent toute la page.
     La hauteur monte avec la largeur, sinon des boutons élargis paraissent
     aplatis. */
  min-height: 58px;
  font-size: 1.05rem;
}
/* fixed = repère écran, cohérent avec la projection 3D — mais il faut donc le
   retirer explicitement dès qu'on a quitté le hero */
body.hero-out .gesture-target { opacity: 0; pointer-events: none; }
.gesture-target__note {
  font-size: var(--text-sm); color: var(--color-ink-2);
  margin-top: var(--space-2xs);
  text-shadow: 0 1px 6px oklch(19% 0.035 252 / 0.7);
}

.hero__lede {
  /* z-index 5 : au-dessus du canvas du personnage, passé à 3. Sous ce seuil, le
     rendu 3D recouvrirait boutons et textes — donc aussi les zones cliquables. */
  position: relative; z-index: 5;
  align-self: end;
  font-size: var(--text-lg);
  color: var(--color-ink-2);
  max-width: 46ch;
  text-wrap: pretty;
  /* posé sur la pelouse : sans support il devient illisible dès que l'herbe
     s'éclaircit sous lui */
  padding: var(--space-sm) var(--space-lg);
  border-radius: var(--radius-pill);
  background: oklch(19% 0.035 252 / 0.55);
  backdrop-filter: blur(6px);
}

/* ── boutons ── */

.btn {
  display: inline-flex; align-items: center; justify-content: center;
  gap: var(--space-sm);
  min-height: 50px; padding: 0 var(--space-xl);
  border: var(--rule) solid transparent;
  border-radius: var(--radius-pill);
  font-family: var(--font-body); font-size: var(--text-base); font-weight: 600;
  white-space: nowrap; cursor: pointer;

  /* ── reflet ──
     Un voile clair en haut, un assombrissement en bas : c'est la lecture d'une
     surface bombée éclairée par le dessus, et la façon la plus économique de
     sortir ces pastilles de l'aplat. Il est posé UNE fois ici et vaut pour
     toutes les variantes, chacune gardant sa propre couleur.

     `background-image` et non `background` — et c'est ce qui oblige les variantes
     plus bas à passer en `background-color`. Le raccourci `background` remet
     `background-image` à `none` : écrit `background: var(--brand-steam)`, chaque
     variante effaçait ce dégradé sans qu'aucune erreur ne le signale.

     Le dégradé s'arrête à 52 % : porté jusqu'en bas, il donne le bouton verni
     des interfaces de 2008. Ici il ne fait qu'un reflet sur la moitié haute. */
  background-image: linear-gradient(to bottom,
    rgba(255, 255, 255, 0.14) 0%,
    rgba(255, 255, 255, 0.04) 52%,
    rgba(0, 0, 0, 0.07) 100%);

  /* Deux ombres portées de rayons très différents : une courte et dense qui
     pose l'arête, une longue et diffuse qui décolle le bouton du fond. Une seule
     ombre ne peut pas faire les deux, et ces boutons se détachent sur le
     personnage 3D, dont le contraste change à chaque position de scroll.
     Le liseré interne haut simule l'arête éclairée. */
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.20),
    0 1px 2px rgba(6, 12, 20, 0.20),
    0 8px 18px -8px rgba(6, 12, 20, 0.38);

  transition: transform var(--dur-fast) var(--ease-out),
              background-color var(--dur-fast) var(--ease-out),
              border-color var(--dur-fast) var(--ease-out),
              box-shadow var(--dur-fast) var(--ease-out),
              color var(--dur-fast) var(--ease-out);
}

/* ── survol ──
   Le bouton MONTE d'un pixel et son ombre s'allonge. C'est la seule paire qui se
   lit comme un déplacement vers le visiteur ; changer la couleur seule, comme
   avant, informe sans donner de retour physique.
   Un pixel, pas trois : au-delà, le bloc accroché aux mains du personnage se
   décale visiblement par rapport à elles.
   Le reflet s'intensifie aussi — c'est ce qui reste crédible sous la lumière. */
.btn:hover {
  transform: translateY(-1px);
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.32),
    0 2px 4px rgba(6, 12, 20, 0.22),
    0 14px 26px -10px rgba(6, 12, 20, 0.46);
}
/* À l'appui, le bouton passe SOUS sa position de repos et son ombre s'écrase :
   sans l'écrasement, il descend en gardant une ombre longue et paraît flotter. */
.btn:active {
  transform: translateY(1px);
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.10),
    0 1px 1px rgba(6, 12, 20, 0.24);
}
/* Le même retour au clavier qu'à la souris. `outline` est posé HORS du flux, il
   ne décale donc rien — un `border` supplémentaire aurait bougé le bouton. */
.btn:focus-visible {
  outline: 3px solid var(--brand-steam-glow);
  outline-offset: 3px;
}
/* Le DÉPLACEMENT tombe, l'ombre reste. C'est le mouvement que ce réglage
   désigne, pas le retour visuel : supprimer aussi l'ombre priverait ces
   visiteurs de tout signal de survol, ce qui serait une régression
   d'accessibilité déguisée en respect d'une préférence. */
@media (prefers-reduced-motion: reduce) {
  .btn:hover, .btn:active { transform: none; }
}

.btn--accent { background-color: var(--color-accent); color: var(--color-accent-ink); }
.btn--accent:hover { background-color: var(--color-accent-deep); }

/* ── boutons de plateforme ──────────────────────────────────────────────
   Ces deux boutons pointent vers Steam et Discord : ils portent donc les
   couleurs et les logos de ces services, parce que c'est ce que l'œil
   reconnaît avant même de lire le libellé. Ce sont les seuls endroits de la
   page où une couleur extérieure à la palette est admise. */

.btn__logo { width: 20px; height: 20px; flex: none; fill: currentColor; }

.btn--steam {
  /* `background-color` et non `background` : le raccourci effacerait le reflet
     defini sur .btn. Meme raison sur chaque variante ci-dessous. */
  background-color: var(--brand-steam);
  color: #ffffff;
  border-color: var(--brand-steam-line);
}
.btn--steam:hover {
  background-color: var(--brand-steam-hi);
  border-color: var(--brand-steam-glow);
}
.btn--steam .btn__logo { color: var(--brand-steam-glow); }

.btn--discord {
  background-color: var(--brand-discord);
  color: #ffffff;
  border-color: var(--brand-discord);
}
.btn--discord:hover {
  background-color: var(--brand-discord-hi);
  border-color: var(--brand-discord-hi);
}

/* Fond PLEIN, jamais translucide : ces boutons se posent par-dessus le
   personnage 3D, et la pose change à chaque position de scroll. Un bouton
   semi-transparent devient illisible dès que le contraste passe dessous. */
.btn--ghost {
  background-color: var(--color-pitch-deep);
  border-color: var(--color-ink);
  color: var(--color-ink);
}
.btn--ghost:hover { background-color: var(--color-ink); color: var(--color-pitch-deep); }

/* Steam pas encore ouvert : on garde l'identité Steam (le visiteur doit
   comprendre où ça mènera) mais désaturée et non cliquable. */
.btn[aria-disabled="true"] {
  /* Assez désaturé pour signaler « pas encore », assez peu pour qu'on
     reconnaisse toujours Steam — c'est l'information utile ici. */
  filter: saturate(0.7) brightness(0.92);
  cursor: not-allowed;
}
/* Desactive : aucun retour au survol. Il faut annuler le DEPLACEMENT et
   l'OMBRE, pas seulement le filtre — sans ca le bouton Steam se soulevait au
   passage de la souris tout en affichant `cursor: not-allowed`, ce qui promet une
   action puis la refuse. */
.btn[aria-disabled="true"]:hover,
.btn[aria-disabled="true"]:active {
  filter: saturate(0.7) brightness(0.92);
  transform: none;
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.12),
    0 1px 2px rgba(6, 12, 20, 0.18);
}

/* ── panneaux — feuille de match posée sur la pelouse ── */

.panel {
  background: var(--color-chalk);
  color: var(--color-chalk-ink);
  max-width: 54ch;
  padding: var(--space-2xl);
  border-radius: var(--radius-sm);
  border-top: 6px solid var(--color-accent);
  box-shadow: 0 18px 40px oklch(19% 0.035 252 / 0.28);
}
.panel--wide { max-width: 72ch; }
.panel .kicker { margin-top: 0; }

.panel__title {
  font-family: var(--font-display); font-weight: 800; font-style: normal;
  font-size: var(--text-4xl); line-height: 1.02; letter-spacing: -0.03em;
  overflow-wrap: anywhere; min-width: 0; text-wrap: balance;
  margin: var(--space-xs) 0 var(--space-md);
}
.panel__body { color: var(--color-chalk-ink2); max-width: var(--measure); }

.specs { margin-top: var(--space-xl); }
.specs__row {
  display: grid; grid-template-columns: 13ch minmax(0, 1fr); gap: var(--space-md);
  padding: var(--space-sm) 0;
  border-top: 2px solid var(--color-chalk-2);
  font-size: var(--text-sm);
}
.specs__row dt { font-weight: 600; color: var(--color-chalk-ink); }
.specs__row dd { color: var(--color-chalk-ink2); }

.rules { list-style: none; margin-top: var(--space-lg); }
.rules__item { padding: var(--space-lg) 0; border-top: 2px solid var(--color-chalk-2); }
.rules__h { font-size: var(--text-lg); font-weight: 600; margin-bottom: var(--space-2xs); }
.rules__item p { color: var(--color-chalk-ink2); font-size: var(--text-sm); max-width: var(--measure); }

/* ── gardien ── */

.fold--plate { padding: 0; align-content: stretch; }
.plate { position: relative; display: grid; min-height: 100svh; }
.plate img { grid-area: 1 / 1; width: 100%; height: 100%; object-fit: cover; object-position: 50% 40%; }
.plate::after {
  content: ""; grid-area: 1 / 1; pointer-events: none;
  background: linear-gradient(to top,
    oklch(30% 0.10 152 / 0.94) 0%, oklch(30% 0.10 152 / 0.45) 52%, transparent 100%);
}
.plate__cap {
  grid-area: 1 / 1; z-index: 1; align-self: end;
  max-width: 62ch; color: var(--color-ink);
  padding: var(--space-3xl) var(--gutter) var(--space-3xl)
           calc(var(--gutter) + 132px + var(--space-2xl));
}
.plate__cap .panel__title { color: var(--color-ink); }
.plate__cap .panel__body { color: var(--color-ink-2); }

/* ── modes ── */

.modes {
  display: grid; gap: var(--space-2xl);
  grid-template-columns: repeat(auto-fit, minmax(min(26ch, 100%), 1fr));
  margin-top: var(--space-lg);
}
.mode { border-top: 6px solid var(--color-accent); padding-top: var(--space-md); }
.mode__name {
  font-family: var(--font-display); font-weight: 800; font-style: normal;
  font-size: var(--text-display-s); line-height: 0.95; letter-spacing: -0.035em;
  overflow-wrap: anywhere; min-width: 0;
}
.mode__size { font-weight: 600; color: var(--color-accent); margin-bottom: var(--space-xs); }
.mode__body { color: var(--color-chalk-ink2); font-size: var(--text-sm); }

/* ── clôture ── */

.fold--close { justify-items: center; text-align: center; }
.close { max-width: 52ch; }
.close__title {
  font-family: var(--font-display); font-weight: 800; font-style: normal;
  font-size: var(--text-display-s); line-height: 0.98; letter-spacing: -0.035em;
  overflow-wrap: anywhere; min-width: 0; text-wrap: balance;
  margin-bottom: var(--space-md);
}
.close__body { color: var(--color-ink-2); }
.close__actions {
  display: flex; flex-wrap: wrap; gap: var(--space-sm);
  justify-content: center; margin-top: var(--space-xl);
}

/* ── pied ── */

.foot {
  /* z-index 5 : au-dessus du canvas du personnage, passé à 3. Sous ce seuil, le
     rendu 3D recouvrirait boutons et textes — donc aussi les zones cliquables. */
  position: relative; z-index: 5;
  background: var(--color-pitch-dark);
  padding: var(--space-3xl) var(--gutter);
  display: grid; gap: var(--space-md);
}
.foot__statement {
  font-family: var(--font-display); font-weight: 700; font-style: normal;
  font-size: var(--text-2xl); line-height: 1.15; letter-spacing: -0.025em;
  max-width: 28ch; overflow-wrap: anywhere; min-width: 0;
}
.foot__meta { font-size: var(--text-sm); color: var(--color-muted); }

/* ── révélation ── */

.reveal { opacity: 0; transform: translateY(16px); }
.reveal.is-in {
  opacity: 1; transform: none;
  transition: opacity var(--dur-slow) var(--ease-out), transform var(--dur-slow) var(--ease-out);
}

/* ═══════════════════════════════════════════════════════ responsive ═══ */

@media (max-width: 1024px) {
  .rail { width: 104px; }
  .fold { padding-left: calc(var(--gutter) + 104px + var(--space-lg)); }
  .plate__cap { padding-left: calc(var(--gutter) + 104px + var(--space-lg)); }
}

/* ══════════════════════════════ mise en scène mobile (sans 3D) ═══
   La version mobile ne rejoue pas le desktop en moins bien : elle a ses propres
   moyens. Pas de scène 3D, donc pas de caméra qui tourne — mais un fond fixe que
   le contenu balaie, des visuels plein écran, et des entrées décalées. L'effet
   de profondeur vient du décalage entre un fond immobile et un contenu qui
   défile, ce qui ne coûte aucun JavaScript.
   Tout est sous `html.no-3d` : sur un mobile assez puissant où la 3D tournerait,
   ces règles ne s'appliquent pas. */
@media (max-width: 860px) {

  /* ── Hero : l'image tient tout l'écran et ne bouge pas ──
     `background-attachment: fixed` est ignoré par la plupart des navigateurs
     mobiles ; on obtient le même effet avec une image en `position: fixed`
     (.stage l'est déjà) que le contenu recouvre en défilant. */
  html.no-3d .fold--hero {
    min-height: 100svh;
    align-content: center;
    text-align: center;
  }
  html.no-3d .hero__title {
    /* Plus grand qu'en desktop proportionnellement : sur mobile le titre est le
       seul élément qui peut porter l'impact, il n'y a pas de personnage animé
       pour le faire. */
    font-size: clamp(3.6rem, 19vw, 6rem);
    line-height: 0.82;
    text-shadow: 0 4px 34px oklch(14% 0.03 252 / 0.7);
  }
  html.no-3d .hero__lede {
    margin-inline: auto;
    max-width: 34ch;
  }

  /* Dégradé de lisibilité : le texte passe sur une photo, pas sur un fond
     maîtrisé. Sans lui le contraste dépend de ce qu'il y a derrière. */
  html.no-3d .fold--hero::before {
    content: ""; position: fixed; inset: 0; z-index: -1;
    background: linear-gradient(to bottom,
      oklch(14% 0.03 252 / 0.72) 0%,
      oklch(14% 0.03 252 / 0.30) 42%,
      oklch(14% 0.03 252 / 0.78) 100%);
  }

  /* ── Sections : chaque panneau devient une carte qui monte du bas ──
     Décalage progressif via nth-child : les éléments n'arrivent pas tous
     ensemble, ce qui donne un rythme au défilement. */
  html.no-3d .panel,
  html.no-3d .close {
    background: oklch(96.5% 0.014 100 / 0.96);
    backdrop-filter: blur(10px);
    box-shadow: 0 24px 60px oklch(14% 0.03 252 / 0.45);
    border-radius: 4px;
  }
  html.no-3d .reveal { transform: translateY(34px) scale(0.98); }
  html.no-3d .reveal.is-in { transform: none; }
  html.no-3d .panel > * { transition-delay: 60ms; }
  html.no-3d .panel > *:nth-child(2) { transition-delay: 130ms; }
  html.no-3d .panel > *:nth-child(3) { transition-delay: 200ms; }

  /* ── La planche du gardien en plein écran ──
     Sur desktop elle s'insère dans une colonne ; sur mobile elle prend tout le
     cadre et le texte se pose dessus. C'est le moment le plus « affiche » de la
     page mobile. */
  html.no-3d .fold--plate { padding-inline: 0; }
  html.no-3d .plate img {
    width: 100%; height: 62svh; object-fit: cover;
    object-position: center 30%;
  }
  html.no-3d .plate__cap {
    margin-top: calc(var(--space-2xl) * -1);
    position: relative;
  }

  /* ── Le ballon du chargement reste comme motif de ponctuation ──
     Réutilisé en filigrane derrière la clôture : un élément déjà chargé, donc
     gratuit, et qui rattache la page à son thème. */
  html.no-3d .fold--close { position: relative; overflow: hidden; }

  /* le rail quitte le flanc et devient un bandeau bas : une colonne de 104 px
     ne survit pas à un téléphone */
  .rail {
    left: 0; right: 0; top: auto; bottom: 0; transform: none; width: auto;
    padding: var(--space-sm) var(--gutter) calc(var(--space-sm) + env(safe-area-inset-bottom));
    /* Teinte alignée sur la palette bleu-gris du stade : la valeur verte
       héritée du terrain infini jurait avec tout le reste depuis la bascule. */
    background: oklch(20% 0.035 252 / 0.92);
    backdrop-filter: blur(8px);
  }
  .rail__beats { display: none; }

  .fold {
    padding-inline: var(--gutter);
    padding-block: var(--space-3xl) calc(var(--space-3xl) + 3.5rem);
    min-height: auto;
  }
  .fold--end { justify-items: stretch; }
  .fold--hero { min-height: 100svh; }
  .plate__cap { padding-inline: var(--gutter); }

  .specs__row { grid-template-columns: minmax(0, 1fr); gap: var(--space-2xs); }
  .panel { padding: var(--space-xl); }

  /* En une colonne, le personnage passe DERRIÈRE le texte : il doit reculer.
     Sauf dans le hero, où il EST le sujet — d'où la classe posée en JS, le
     canvas étant un frère du hero et non son enfant (un sélecteur descendant
     ne peut pas l'atteindre). */
  .stage { opacity: 0.4; }
  .stage.is-front { opacity: 1; }

  /* …mais PAS en paysage : les panneaux y occupent une colonne latérale, donc
     ils ne recouvrent plus le personnage et rien ne justifie de l'effacer.
     Cette règle s'appliquait pourtant, la largeur d'un téléphone tourné
     (829 px) restant sous le seuil de 860 : le robot devenait translucide dès
     qu'on quittait le hero, en pleine animation de frappe. */
  html.paysage .stage { opacity: 1; }

  /* Le titre repasse DEVANT le personnage : le chevauchement est un luxe de
     grand écran. Sur 390 px de large il n'y a pas assez de place pour que le
     mot survive derrière une silhouette, et un titre illisible ne vaut aucun
     effet de profondeur. */
  .hero__title {
    z-index: 3;
    font-size: clamp(2.8rem, 15vw, 4.4rem);
    text-shadow: 0 2px 18px oklch(19% 0.035 252 / 0.55);
  }
  .hero__lede { margin-top: var(--space-lg); }
}

/* ══════════════════════ téléphone en PAYSAGE : la hauteur commande ═══
 *
 * Un téléphone tourné fait environ 844 × 390. Toutes les tailles mobiles étant
 * exprimées en `vw`, elles se calent sur la largeur — devenue la grande
 * dimension — et explosent : le titre à 19vw donne 160 px de haut pour 390 px
 * d'écran, et une section occupe plusieurs fois la hauteur disponible.
 *
 * Ici la contrainte est la HAUTEUR. Les tailles passent donc en `vh` / `svh`, et
 * les espacements verticaux sont resserrés. `max-height` plutôt que
 * `orientation: landscape` seul : un iPad en paysage a 768 px de haut et n'a
 * aucun besoin de ce traitement. */
/* Piloté par une CLASSE, `html.paysage`, et non par une media query.
 *
 * C'est la clé de tout ce qui suit. En paysage FORCÉ le document est pivoté en
 * CSS, mais le viewport reste portrait aux yeux du navigateur : une media query
 * `orientation: landscape` ne s'y déclenche donc jamais. Le contenu était tourné
 * tout en gardant la mise en page portrait — d'où l'écrasement observé.
 * Une classe posée par le JS couvre les deux cas d'un coup : téléphone
 * physiquement tourné, ou paysage imposé depuis l'invitation.
 *
 * `--hu` (« hauteur utile ») résout le second piège : sous rotation, `vh` mesure
 * toujours la hauteur RÉELLE du viewport, donc la grande dimension. `13vh`
 * donnerait 110 px au lieu de 51. En paysage forcé la hauteur perçue est la
 * LARGEUR réelle, d'où l'inversion de l'unité. Toutes les tailles verticales
 * passent par cette variable. */
html.paysage {
  --hu: 1vh;          /* hauteur utile, paysage naturel */
  --hu-plein: 100svh;
}
html.paysage.force-landscape {
  --hu: 1vw;          /* sous rotation, la hauteur perçue est la largeur réelle */
  --hu-plein: 100svw;
}

/* ── Le hero tient dans 390 px de haut, sans chevauchement ──
   Il était en `grid-template-rows: auto auto 1fr` avec
   `align-content: space-between` et un `margin-top` de 88 px sur le sur-titre.
   Additionné aux quatre blocs, le contenu dépassait la hauteur disponible : les
   rangées se recouvraient et l'accroche venait par-dessus les boutons.
   Rangées toutes en `auto`, contenu centré, et un interligne exprimé en hauteur
   utile — la place se répartit alors au lieu de se disputer. */
/* `.fold.fold--hero` et non `.fold--hero` seul : la règle générique
   `html.paysage .fold` est déclarée plus bas avec une spécificité IDENTIQUE
   (0,2,1), donc elle l'emportait par simple ordre de déclaration. Mesuré :
   padding-top à 32 px au lieu de 62, et min-height ramené à 0 — c'est ce qui
   laissait le sur-titre remonter sous la barre de titre malgré `align-content:
   start`. Le second `.fold` porte la spécificité à (0,3,1). */
html.paysage .fold.fold--hero {
  min-height: var(--hu-plein);
  /* trois rangées : l'accroche est passée en absolu, elle ne participe plus */
  grid-template-rows: repeat(3, auto);
  position: relative;   /* repère pour le bandeau bas */
  /* Tout le texte dans la COLONNE DE GAUCHE, comme les autres sections.
     Centré, le hero plaçait l'accroche exactement sur le personnage — lui aussi
     centré — et les deux se disputaient le même espace. Aucun réglage de taille
     ne pouvait résoudre ça : c'était un conflit de position. */
  justify-items: start;
  text-align: left;
  gap: calc(1.4 * var(--hu));
  /* `start` et non `center` : la barre de titre est `position: fixed`, donc elle
     se superpose au hero sans participer au flux, et il faut lui réserver une
     marge haute FERME. Avec `center`, le groupe se recentrait dans l'espace
     restant et ignorait cette marge — mesuré, le sur-titre remontait sous le
     wordmark avec 18 px de recouvrement. */
  align-content: start;
  /* 16 unités ≈ 62 px, contre 55 px mesurés pour la barre. */
  padding-block: calc(16 * var(--hu)) calc(4 * var(--hu));
}
html.paysage .kicker {
  font-size: calc(var(--text-xs) * 0.92);
  margin-top: 0;        /* les 88 px d'origine poussaient tout hors du cadre */
}

/* La barre de titre s'amincit : sur 390 px de haut, elle prenait un sixième du
   cadre pour deux lignes de signature. */
html.paysage .topbar { padding: var(--space-xs) var(--gutter); }
html.paysage .wordmark__name { font-size: var(--text-sm); }
html.paysage .wordmark__studio { font-size: calc(var(--text-xs) * 0.85); }
html.paysage .lang__btn { font-size: calc(var(--text-xs) * 0.9); padding: 2px 8px; }

html.paysage .hero__title {
  /* 10 unités de hauteur utile au lieu de 13 : le titre reste l'élément le plus
     fort sans écraser les trois blocs qui le suivent. */
  font-size: clamp(1.5rem, calc(10 * var(--hu)), 2.8rem);
  line-height: 0.9;
}

/* L'accroche devient un BANDEAU BAS, hors du flux.
   Dans la colonne de gauche elle occupait six lignes et se retrouvait collée au
   personnage — 180 caractères ne tiennent pas dans une demi-largeur sur 390 px de
   haut. Étalée sur toute la largeur en bas du cadre, elle tient en deux lignes,
   ne dispute plus l'espace à la colonne de texte, et son fond la garde lisible
   par-dessus la pelouse. */
html.paysage .hero__lede {
  position: absolute;
  left: var(--gutter);
  right: var(--gutter);
  bottom: calc(2 * var(--hu));
  max-width: none;      /* annule la borne de demi-largeur ci-dessous */
  width: auto;
  text-align: left;
  font-size: calc(var(--text-xs) * 0.9);
  padding: var(--space-2xs) var(--space-sm);
  border-radius: var(--radius-sm);
}

/* Les autres blocs du hero restent dans la moitié gauche : c'est ce qui libère la
   droite pour le personnage. */
html.paysage .fold--hero > *:not(.hero__lede) { max-width: 50%; }
html.paysage .gesture-target { justify-content: center; }

/* Les folds cessent d'occuper un écran entier : sur 390 px de haut, une section
   plein écran par bloc rendrait la page interminable.
   Et les panneaux deviennent une COLONNE — on reprend le principe du desktop,
   texte à gauche et scène à droite, que ce rapport de forme rend naturel. */
html.paysage .fold {
  padding-block: var(--space-xl);
  padding-inline: var(--gutter);
  min-height: auto;
  justify-items: start;
}

/* Boutons côte à côte : la largeur est là, et empilés ils mangeraient la moitié
   de la hauteur utile. */
html.paysage.no-3d .gesture-target {
  width: min(94vw, 34rem);
  grid-auto-flow: column;
  gap: var(--space-sm);
  margin-top: var(--space-md);
}
html.paysage.no-3d .gesture-target .btn {
  min-height: 44px; font-size: var(--text-sm);
}
html.paysage .gesture-target__note { display: none; }  /* le détail d'abord sacrifié */

html.paysage .rail { padding-block: var(--space-2xs); }  /* la hauteur est comptée */

/* Blocs de texte resserrés d'un cran de plus. Sur 390 px de haut, un panneau
   confortable en portrait occupe encore les deux tiers du cadre et concurrence le
   personnage — qui est le sujet. */
html.paysage .panel {
  width: min(46%, 26rem);
  padding: var(--space-sm) var(--space-md);
}
/* « Le règlement » est le seul panneau à trois colonnes de texte, donc le plus
   large et le plus haut : mesuré, il débordait du cadre. Ses trois règles
   repassent en colonnes serrées et le bloc reste sous la moitié de l'écran. */
html.paysage .panel--wide { width: min(54%, 30rem); }
html.paysage .rules { grid-template-columns: minmax(0, 1fr); gap: var(--space-2xs); }
html.paysage .rules__h { font-size: calc(var(--text-xs) * 0.98); }
html.paysage .rules p { font-size: calc(var(--text-xs) * 0.9); }
html.paysage .panel__title { font-size: var(--text-lg); line-height: 1.1; }
html.paysage .panel__body  { font-size: var(--text-xs); }
html.paysage .kicker--ink  {
  margin-bottom: 0;
  font-size: calc(var(--text-xs) * 0.92);
}

/* Spécifications et modes repassent en deux colonnes : la largeur est
   disponible, et empilés ils allongeaient le panneau au-delà de l'écran. */
html.paysage .specs__row {
  grid-template-columns: 8rem minmax(0, 1fr); gap: var(--space-xs);
}
html.paysage .specs__row dt,
html.paysage .specs__row dd { font-size: var(--text-xs); }

html.paysage .modes {
  grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-md);
}
html.paysage .mode__name { font-size: var(--text-2xl); }
html.paysage .mode__body { font-size: var(--text-xs); }

/* La clôture reste CENTRÉE : `justify-items: start` vaut pour les sections qui
   partagent l'écran avec le personnage, pas pour le dernier écran, où il n'y a
   plus rien à côté et où l'appel à l'action doit être au milieu.
   Mesuré avant correction : 20 px de marge avant, 255 px après. */
html.paysage .fold.fold--close { justify-items: center; }
html.paysage .close {
  width: min(84%, 44rem);
  margin-inline: auto;
  text-align: center;
}
html.paysage .close__title { font-size: var(--text-2xl); }
html.paysage .close__body { font-size: var(--text-xs); }
html.paysage .close__actions { justify-content: center; }

/* La planche du gardien : bornée en largeur, pas en hauteur — c'est la hauteur
   qui la faisait déborder. */
html.paysage.no-3d .fold--plate { padding-inline: var(--gutter); }
html.paysage.no-3d .plate { width: min(56%, 34rem); }
html.paysage.no-3d .plate img { height: auto; max-height: calc(58 * var(--hu)); }
html.paysage.no-3d .plate__cap { margin-top: 0; }

/* L'invitation en deux colonnes : sa carte verticale coupait sa note de bas sur
   390 px de haut. */
html.paysage .invite { place-items: center; padding-bottom: var(--gutter); }
html.paysage .invite__card {
  width: min(96vw, 40rem);
  grid-template-columns: 1fr auto;
  grid-template-areas:
    "icone  actions"
    "titre  actions"
    "corps  actions"
    "note   actions";
  align-items: center;
  text-align: left;
  padding: var(--space-lg);
  gap: var(--space-2xs) var(--space-lg);
}
html.paysage .invite__icon  { grid-area: icone; width: 24px; height: 24px; }
html.paysage .invite__title { grid-area: titre; font-size: var(--text-lg); }
html.paysage .invite__body  { grid-area: corps; font-size: var(--text-sm); max-width: 42ch; }
html.paysage .invite__note  { grid-area: note; font-size: var(--text-xs); }
html.paysage .invite__actions {
  grid-area: actions; align-self: center;
  width: 13rem; margin-top: 0;
}
html.paysage .invite__actions .btn { min-height: 44px; font-size: var(--text-sm); }

@media (max-width: 420px) {
  .close__actions .btn, .gesture-target .btn { width: 100%; }
}

/* ══════════════════════════════════════════════════ reduced motion ═══ */

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 1ms !important;
    transition-duration: 120ms !important;
    scroll-behavior: auto !important;
  }
  .reveal { opacity: 1; transform: none; }
}
