/* ═══════════════════════════════════════════════════════════════════════════
   INDEX — refonte 2026. Intégration de la planche Claude Design du 07/09.

   Grammaire héritée du hub in-game : angles droits absolus, le BLANC est la
   couleur de l'interaction, l'OR ne signale que le curseur clavier/manette et
   ponctue une donnée clé. Trois fontes, trois rôles, jamais interchangés.
   ═══════════════════════════════════════════════════════════════════════════ */

:root {
  --bg:        #0a0a0a;
  --bg-raise:  #151515;
  --text:      #f0eff0;
  --muted:     #b5b5b5;
  --dim:       #6a6a6a;
  --off:       #4a4a4a;
  --gold:      #e5c469;
  --success:   #22c55e;
  --signal:    #f44336;
  --surface:   rgba(255,255,255,.055);
  --line:      rgba(255,255,255,.1);
  --line-soft: rgba(255,255,255,.06);

  --display: 'Bebas Neue', Impact, sans-serif;
  --body:    'Satoshi', system-ui, -apple-system, sans-serif;
  --mono:    'JetBrains Mono', ui-monospace, monospace;

  --ease: cubic-bezier(0.16, 1, 0.3, 1);

  /* Gouttière latérale unique : toutes les sections s'y alignent. Une valeur
     dérivée plutôt que répétée — sinon un bloc finit toujours par décrocher. */
  --gutter: clamp(20px, 6.6vw, 96px);
  --maxw: 1680px;

  /* Hauteur de l'en-tête collant. Jeton et non valeur en dur : le calage au
     défilement, la marge de calage et la hauteur plein écran des sections de
     jeu en dépendent toutes — désaccordées, une section serait rognée de
     quelques pixels en bas, ou son titre passerait sous l'en-tête. */
  --hdr: 64px;

  /* Hauteur du dock audio. Il est en `position: sticky; bottom: 0`, donc il
     occupe le bas de la fenêtre en permanence : toute section plein écran doit
     lui réserver sa place, sinon son dernier contenu passe DERRIÈRE. */
  --dock: 48px;

  /* Hauteur utile d'un écran : la fenêtre moins l'en-tête et le dock. Une
     seule définition — trois blocs la lisaient, et désaccordés ils produisent
     des sections qui débordent de quelques pixels, ce qui suffit à casser la
     régularité de la téléportation. */
  --screen: calc(100svh - var(--hdr) - var(--dock));
}

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

/* ⚠️ `scroll-padding-top` vit ICI, sans media query, et il est SEUL à réserver
   l'en-tête. Mesuré : il était dans le bloc `min-width: 900px` en même temps
   qu'un `scroll-margin-top` sur `.snap`, et les deux S'ADDITIONNENT — toute
   ancre native atterrissait 128 px trop bas sur desktop, laissant voir le bas
   de la section précédente. Sous 900 px, à l'inverse, aucun des deux ne
   s'appliquait : la cible passait DERRIÈRE l'en-tête collant.

   Le rail masquait le défaut, parce que son JS calcule `offsetTop - 64` et
   n'utilise pas la navigation par ancre. Les liens du deck, eux, sont de
   vraies ancres : c'est ce qui l'a fait sortir. */
html { scroll-behavior: smooth; scroll-padding-top: var(--hdr); }
@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } }

body {
  margin: 0;
  background: var(--bg);
  color: var(--text);
  font-family: var(--body);
  /* Le HUD non défilant a disparu : plus de height:100% ni d'overflow:hidden.
     C'est tout l'objet de la refonte. */
  overflow-x: hidden;
}

::selection { background: var(--text); color: #151515; }

/* ── Ce qui se sélectionne, et ce qui ne se sélectionne pas ────────────────
   Un seul critère, appliqué partout : on coupe la sélection là où un
   appui-glissé est un GESTE, jamais là où c'est de la lecture.

   Le cas qui impose la règle est le bouton JOUER : son geste est un maintien
   de 1,2 s, et sans `user-select: none` ce maintien surligne le libellé en
   bleu — l'utilisateur croit avoir raté son clic. Le même raisonnement vaut
   pour tout ce qui se clique en rafale : rail, sélecteur de langue, dock,
   rangées-liens. Le double-clic y sert de raccourci ou de répétition, pas à
   sélectionner un mot.

   ⚠️ La réciproque compte autant. TOUT le texte à lire reste sélectionnable —
   prose, titres, descriptions de modes, journal de la console, disclaimer
   légal — et surtout `.cmd__v`, la commande `connect play.index4g.com` :
   le bouton COPIER est une commodité, pas le seul chemin. La couper serait
   retirer à l'utilisateur le geste qu'il connaît déjà.

   `::selection` ci-dessus n'est pas décoratif : la sélection est un état
   dessiné dans cette maquette. Raison de plus pour ne l'éteindre que là où
   elle est un accident. */
.rail, .langs, .ftr__langs, .hdr__logo, .hdr__dc,
.audiobar, .scroll-cue, .keyhint,
.deck, .route, .partner, .cmd__b,
.gauge, .viz {
  user-select: none; -webkit-user-select: none;
}
/* Exception dans le dock : le titre de piste est une DONNÉE, pas un bouton.
   C'est la seule chose de cette barre qu'on puisse vouloir copier. */
.audiobar__track { user-select: text; -webkit-user-select: text; }

img, video { max-width: 100%; display: block; }
a { color: inherit; text-decoration: none; }

/* Angles droits partout — règle n°1 du hub, sans exception. */
* { border-radius: 0 !important; }

/* ── Primitives typographiques ────────────────────────────────────────────── */
.d  { font-family: var(--display); letter-spacing: .02em; line-height: .9; }
.m  { font-family: var(--mono); text-transform: uppercase; }
.m-label { font-family: var(--mono); font-size: 11px; letter-spacing: .22em; text-transform: uppercase; }
.m-mini  { font-family: var(--mono); font-size: 10px; letter-spacing: .2em; text-transform: uppercase; color: var(--dim); }

p { font-size: clamp(16px, 1.15vw, 19px); line-height: 1.65; color: var(--muted); margin: 0 0 20px; }
p.lede { color: var(--text); }

.rule { height: 1px; background: var(--line); border: 0; margin: 0; }
.vrule { width: 1px; align-self: stretch; background: var(--line); }

/* ── En-tête collant ──────────────────────────────────────────────────────── */
.hdr {
  position: sticky; top: 0; z-index: 40;
  display: flex; align-items: center; justify-content: space-between; gap: 24px;
  height: var(--hdr); padding: 0 var(--gutter);
  background: rgba(21,21,21,.9);
  backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
  border-bottom: 1px solid var(--line);
}
.hdr__left { display: flex; align-items: center; gap: 20px; min-width: 0; }
.hdr__logo { font-family: var(--display); font-size: 28px; letter-spacing: .14em; line-height: 1; }
.hdr__sep { width: 1px; height: 20px; background: var(--line); flex: none; }
.hdr__right { display: flex; align-items: center; gap: 18px; flex: none; }

/* ── Discord dans l'en-tête ────────────────────────────────────────────────
   Le seul objet cliquable permanent de la maquette, et il est encadré : sans
   bordure il se serait posé à côté du sélecteur de langue comme une septième
   entrée du même registre — même fonte, même corps, même gris — et se serait
   lu comme une langue de plus. Le cadre le sort de la rangée sans crier.

   ⚠️ La hauteur est en dur (32 px) et non dérivée de --hdr : elle doit rester
   franchement inférieure à la barre pour que le cadre respire, et --hdr change
   au téléphone. Un `calc()` sur --hdr collerait le trait au bord.

   Le survol INVERSE (fond blanc, texte noir) : c'est l'idiome de `.cmd__b`,
   déjà le geste « copier l'adresse » de la section Rejoindre. Le blanc est la
   couleur de l'interaction dans cette grammaire, l'or ne l'est jamais — d'où
   le focus clavier traité à part, en or, comme `.deck__card` et le rail. */
.hdr__dc {
  display: inline-flex; align-items: center; gap: 9px;
  height: 32px; padding: 0 14px;
  border: 1px solid var(--line);
  font-family: var(--mono); font-size: 11px; letter-spacing: .18em;
  color: var(--muted); white-space: nowrap;
  transition: color .3s var(--ease), background .3s var(--ease), border-color .3s var(--ease);
}
.hdr__dc svg { width: 16px; height: 16px; flex: none; }
.hdr__dc:hover { color: #151515; background: var(--text); border-color: var(--text); }
.hdr__dc:focus-visible { outline: 1px solid var(--gold); outline-offset: -2px; color: var(--text); border-color: var(--gold); }

/* Jauge de capacité — 12 segments de 4 places. Pas de pastille colorée :
   l'état se prouve par une jauge qui existe, pas par un point vert. */
.gauge { display: flex; align-items: center; gap: 2px; flex: none; }
/* ⚠️ La PISTE doit se voir (2026-09-08). Elle était à 12 % de blanc : à zéro
   joueur on ne lisait pas « 48 places, toutes libres », on lisait un objet
   éteint — « compteur vide », puis « serveur désert » en test. Un compteur
   segmenté se lit piste + remplissage ; sans piste visible, il ne reste que
   l'absence de remplissage, c'est-à-dire rien.
   La piste passe donc à 28 %, et le remplissage prend l'accent au lieu du gris :
   à vide la barre EXISTE et n'est pas remplie, ce qui est exactement l'état. */
.gauge i { width: 3px; height: 11px; background: rgba(255,255,255,.28); display: block; transition: background .4s var(--ease); }
.gauge i.on { background: var(--gold); }
.hdr .status { display: flex; align-items: center; gap: 10px; font-family: var(--mono); font-size: 11px; letter-spacing: .16em; }
.status__num  { color: var(--gold); }
/* Emplacement vide = pas d'emplacement. Sans ça, `gap: 10px` réserverait
   quand même sa gouttière et le badge garderait un trou à gauche du mot. */
.status__num:empty { display: none; }
/* Registre « ça joue » : le mot quitte le gris et rejoint le chiffre. À vide
   le badge reste entièrement en gris — il est là, il ne réclame rien. */
.is-live .status__rest { color: var(--text); }

/* ⚠️ NE PAS masquer la jauge quand personne ne joue — essayé le 2026-09-08,
   retiré le jour même. Le badge ne montrait plus que le mot ONLINE, seul, et
   ça se lisait « serveur désert ». Masquer le badge ENTIER avait été proposé
   avant et écarté pour une raison voisine : un élément d'en-tête qui s'efface
   se lit comme une panne, et le visiteur ne distingue plus « en ligne mais
   calme » de « hors service ».
   Le vrai défaut n'était pas la présence de la jauge mais sa PISTE invisible,
   corrigée plus haut. Une barre de douze segments bien dessinés dont aucun
   n'est allumé dit « 48 places libres » ; la même barre à 12 % de blanc ne
   disait rien du tout. */
.status__rest { color: var(--muted); white-space: nowrap; }
/* ⚠️ CHARGEMENT ≠ SERVEUR VIDE. Les deux états peignaient exactement la même
   jauge — douze segments éteints — et se confondaient donc à l'œil : on lisait
   « ça charge » là où le serveur répondait « ouvert, personne en jeu ».
   Signalé par le propriétaire, capture à l'appui.

   C'est le CHARGEMENT qui prend une apparence propre, pas l'attente. L'inverse
   aurait été tentant — allumer la jauge quand le serveur est vide — mais une
   jauge pleine à zéro joueur ment, et la seule lecture possible d'une jauge
   pleine est « complet ». Une jauge vide sur un serveur vide est exacte ; il
   fallait seulement qu'elle cesse d'être ambiguë. Le libellé fait le reste :
   « CONNEXION… » pendant, « SERVEUR OUVERT » après. */
@keyframes gauge-scan {
  0%, 100% { background: rgba(255,255,255,.12); }
  50%      { background: rgba(255,255,255,.38); }
}
.is-loading .gauge i {
  animation: gauge-scan 1.1s var(--ease) infinite;
  animation-delay: calc(var(--i) * 70ms);
}
@media (prefers-reduced-motion: reduce) { .is-loading .gauge i { animation: none; } }

/* Hors ligne : la jauge se hachure au lieu de disparaître. */
.is-offline .gauge i { background: rgba(244,67,54,.25); }
.is-offline .status__num, .is-offline .status__rest { color: var(--signal); }

.langs { display: flex; align-items: center; gap: 12px; font-family: var(--mono); font-size: 11px; letter-spacing: .14em; }
.langs a { color: var(--dim); transition: color .25s var(--ease); }
.langs a:hover, .langs a:focus-visible { color: var(--text); }
.langs a[aria-current="true"] { color: var(--text); }

/* ── Hero ─────────────────────────────────────────────────────────────────── */
/* ⚠️ La section porte ELLE-MÊME la réserve du dock : hauteur `--screen + --dock`
   et un padding bas de `--dock`. C'est ce qui rend la bande de preuve intouchable
   quel que soit l'écran.

   `min-height: var(--screen)` seul ne suffisait pas, et la raison est
   structurelle : `--screen` retire déjà la hauteur du dock, mais SEULEMENT si le
   contenu tient dedans. Dès qu'il déborde — fenêtre courte, ligne de texte en
   plus, traduction plus longue — la section s'allonge, son dernier bloc arrive
   au bas de la FENÊTRE, et le dock, qui est fixe, le recouvre. Or ce dernier
   bloc est justement la bande de preuve.

   Avec le padding, le débordement pousse la réserve avec lui : la bande s'arrête
   toujours pile au-dessus du dock. Aucun test de hauteur, aucune media query. */
.hero {
  position: relative; overflow: hidden;
  min-height: calc(var(--screen) + var(--dock));
  padding: 0 0 var(--dock);
  display: flex; flex-direction: column;
}
.hero__body {
  position: relative; flex: 1 1 auto; min-height: 0;
  display: flex; flex-direction: column; justify-content: center;
  padding: clamp(36px, 6vh, 80px) var(--gutter) clamp(44px, 7vh, 76px);
}
.hero__grain, .hero__bg, .hero__veil-x, .hero__veil-y { position: absolute; inset: 0; pointer-events: none; }
.hero__grain { background-image: repeating-linear-gradient(118deg, rgba(255,255,255,.045) 0 1px, transparent 1px 9px); }
.hero__bg { object-fit: cover; width: 100%; height: 100%; }
/* Deux voiles croisés : l'horizontal réserve la colonne de texte, le vertical
   raccroche le hero au header et à la bande suivante. */
.hero__veil-x { background: linear-gradient(90deg, rgba(21,21,21,.96) 0%, rgba(21,21,21,.88) 34%, rgba(21,21,21,.52) 68%, rgba(21,21,21,.32) 100%); }
.hero__veil-y { background: linear-gradient(180deg, rgba(21,21,21,.52) 0%, transparent 26%, rgba(21,21,21,.6) 82%, rgba(10,10,10,.95) 100%); }
.hero__in { position: relative; max-width: var(--maxw); width: 100%; }

.kicker { display: flex; align-items: center; gap: 16px; margin-bottom: clamp(12px, 2.4vh, 26px); }
.kicker i { width: 34px; height: 1px; background: var(--gold); flex: none; }
.kicker span { font-family: var(--mono); font-size: clamp(9px, .78vw, 11px); letter-spacing: .28em; color: var(--gold); }

/* ⚠️ Les tailles du hero sont bornées EN HAUTEUR autant qu'en largeur. Sans le
   terme `vh`, `17.2vw` donnait 248 px de logotype sur un 1600×813 — le hero
   pesait 765 px pour 701 px disponibles, il débordait donc du premier écran et
   la bande de preuve passait sous le dock. Un écran large mais court est le cas
   courant (portable 1600×900 fenêtré), pas le cas limite : la largeur seule ne
   peut pas piloter une hauteur.
   Même méthode que `.sec--screen`, qui borne déjà ses titres de la même façon. */
.hero__word { font-family: var(--display); font-size: clamp(88px, min(17.2vw, 23vh), 248px); line-height: .84; letter-spacing: .02em; margin: 0 0 8px; }
.hero__claim { font-family: var(--display); font-size: clamp(30px, min(3.7vw, 6.4vh), 52px); line-height: 1.05; letter-spacing: .03em; max-width: 760px;
  /* Reprend la marge basse de l'ancienne `.hero__lede`, retirée : sans elle
     l'accroche tombait directement sur la rangée d'action. */
  margin: 0 0 clamp(22px, 4.4vh, 44px); }

.hero__cta { display: flex; align-items: center; gap: clamp(18px, 2.4vw, 32px); flex-wrap: wrap; }
/* Descendu en trois fois, à la demande : #d4d4d4 (12,3:1) → --muted (8,9:1) →
   #8a8a8a. C'est délibérément une valeur littérale et non un jeton : elle est
   choisie pour ATTERRIR JUSTE AU-DESSUS du seuil, pas pour une teinte.

   #8a8a8a donne 5,4:1 sur le voile du hero — le plus discret qu'on puisse
   faire en gardant l'indice conforme AA (4,5:1 pour du petit texte). Le gris
   qui vaut exactement 4,5:1 ici est #7d7d7d : il n'y a plus rien à gagner
   au-delà, seulement de la lisibilité à perdre.

   ⚠️ `--dim` (#6a6a6a) a été mesuré puis écarté DEUX FOIS : 3,4:1. C'est un
   indice FONCTIONNEL — il dit comment se servir du bouton principal de la
   page. L'effacer ne le rend pas discret, il le rend inutile. Ne pas y
   revenir sans décision explicite du propriétaire. */
.hero__hint { font-family: var(--mono); font-size: 10px; letter-spacing: .16em; color: #8a8a8a; line-height: 1.9; }
.live { display: flex; align-items: baseline; gap: 9px; }
.live__n { font-family: var(--display); font-size: clamp(32px, 3.2vw, 44px); line-height: 1; color: var(--gold); }
.live__l { font-family: var(--mono); font-size: 10px; letter-spacing: .22em; color: var(--muted); }

/* Bouton JOUER à maintien. Le remplissage passe SOUS le texte et le texte est
   en mix-blend-mode:difference : un seul libellé reste lisible sur les deux
   fonds, au lieu de deux copies superposées à faire basculer. */
.hold {
  /* ⚠️ C'est CETTE hauteur qui fixe celle de `.hero__cta` : la rangée est en
     `align-items: center`, elle prend donc la taille de son plus grand enfant,
     et le bouton l'est de loin (le libellé mono fait 42 px, le compteur 44).
     Toucher au padding de la rangée ne change rien ; c'est ici que ça se règle.

     92 → 60 px (en deux passes, 72 puis 60). Le libellé descend AVEC
     (52 → 34 px) : gardé à 52 dans une boîte plus basse, il ne restait plus
     que quelques pixels de respiration et le mot touchait les bords — un
     bouton plein, pas un bouton fin.

     60 px est le plancher raisonnable TANT QUE le libellé auxiliaire tient sur
     deux lignes : « HOLD / 1.2 S » fait 34 px (2 × 10 px × 1.7), il ne reste
     donc que 13 px de marge haut et bas. Descendre plus bas suppose de passer
     ce libellé sur une seule ligne — donc de retirer le <br> de la clé
     `rt-hold-meta` dans les six locales, pas seulement de changer un nombre. */
  position: relative; width: min(420px, 100%); height: 60px;
  border: 1px solid var(--text); background: transparent; color: var(--text);
  display: flex; align-items: center; justify-content: space-between; gap: 16px;
  padding: 0 26px; cursor: pointer; overflow: hidden; user-select: none;
  -webkit-tap-highlight-color: transparent;
  transition: background .35s var(--ease);
  font: inherit;
}
.hold__fill { position: absolute; left: 0; top: 0; bottom: 0; width: 0; background: var(--text); transition: width .06s linear; }
.hold__label { position: relative; mix-blend-mode: difference; font-family: var(--display); font-size: clamp(26px, 2.4vw, 34px); line-height: 1; letter-spacing: .08em; color: var(--text); white-space: nowrap; }
.hold__meta  { position: relative; mix-blend-mode: difference; font-family: var(--mono); font-size: 10px; letter-spacing: .2em; color: var(--text); text-align: right; line-height: 1.7; }
.hold:hover, .hold:focus-visible { background: rgba(255,255,255,.06); outline: none; }
.hold.is-granted { background: var(--text); }
.hold.is-granted .hold__label, .hold.is-granted .hold__meta { mix-blend-mode: normal; color: #151515; }
.hold.is-granted .hold__fill { display: none; }

.fallback { margin-top: 18px; display: flex; flex-wrap: wrap; gap: 10px 18px; font-family: var(--mono); font-size: 10px; letter-spacing: .16em; color: var(--muted); }
.fallback a { color: var(--text); border-bottom: 1px solid var(--line); }
.fallback a:hover { border-color: var(--text); }
.fallback[hidden] { display: none; }

/* ⚠️ Le repère est CENTRÉ dans le vide sous le contenu, pas collé au bas.
   Mesuré à 1600×900 avec l'ancien `bottom: 2vh` : 71 px le séparaient du bloc
   d'action et 16 px seulement de la bande de preuve. Il ne se lisait donc ni
   comme la fin du contenu ni comme un repère de bas d'écran — il tombait dans
   la bande, à laquelle il n'appartient pas.

   Il reste sur la gouttière — le défaut n'a jamais été horizontal. Il la tient
   désormais SANS `left` : rendu au flux, il hérite du padding de `.hero__body`,
   donc du même axe que le contenu, par construction plutôt que par recopie.

   ⚠️ Ne PAS revenir à un `bottom` en `vh`. Essayé et mesuré : le vide sous le
   contenu ne varie pas proportionnellement à la hauteur de fenêtre — le
   contenu se contracte lui aussi via ses propres `clamp`. Un décalage réglé
   pour équilibrer à 1600×900 (43/44 px) donnait −7 px à 1600×700 : le repère
   CHEVAUCHAIT la rangée d'action.

   Dans le flux, la collision est impossible : l'écart au contenu EST la marge
   haute, et `justify-content: center` de `.hero__body` centre le groupe entier
   — contenu + repère. Ce qui reste de vide se répartit alors au-dessus et en
   dessous, au lieu de s'accumuler entre les deux. */
.scroll-cue { align-self: flex-start; margin-top: clamp(22px, 4.2vh, 48px); display: flex; align-items: center; gap: 14px; }
.scroll-cue__rail { position: relative; width: 1px; height: 38px; background: rgba(255,255,255,.16); overflow: hidden; }
.scroll-cue__dot { position: absolute; top: 0; left: 0; width: 1px; height: 13px; background: var(--text); animation: scrolldot 2.2s var(--ease) infinite; }
.scroll-cue span { font-family: var(--mono); font-size: 10px; letter-spacing: .24em; color: var(--muted); }
@keyframes scrolldot { 0% { transform: translateY(0); opacity: 0 } 30% { opacity: 1 } 100% { transform: translateY(22px); opacity: 0 } }

/* ── Barre audio persistante ──────────────────────────────────────────────── */
/* ⚠️ `position: sticky; bottom: 0` NE FAIT PAS un dock permanent, et c'est le
   piège de cette barre — mesuré, pas supposé. Un sticky-bas épingle un élément
   tant qu'on n'est PAS ENCORE arrivé à lui, puis le relâche : il redevient un
   bloc du flux dès que sa position naturelle repasse au-dessus du bas de la
   fenêtre. Concrètement, la barre tenait le bas du premier écran, puis en
   section 02 elle se retrouvait à `top: 16px` — c'est-à-dire DERRIÈRE
   l'en-tête collant (z-index 40 > 30). Elle n'était pas discrète, elle avait
   disparu, et ce pour tout le reste de la page.

   `fixed` était la bonne réponse dès le départ ; la seule objection était qu'il
   superpose aussi le pied de page. C'est réglé à la source : `.ftr` réserve
   `--dock` dans son padding bas, exactement comme les sections plein écran le
   réservent dans `--screen`. Le jeton existait déjà, il n'était simplement pas
   honoré partout.

   Bénéfice de bord : en quittant le flux, la barre rend les 48 px qu'elle y
   occupait. Ils étaient comptés DEUX FOIS — une fois dans `--screen`, une fois
   en flux entre le hero et la section 02 — d'où un décalage de 48 px à chaque
   jointure de téléportation. */
.audiobar {
  position: fixed; left: 0; right: 0; bottom: 0; z-index: 30;
  display: flex; align-items: center; gap: 24px;
  height: 48px; padding: 0 var(--gutter);
  background: rgba(21,21,21,.93);
  backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
  border-top: 1px solid var(--line); border-bottom: 1px solid var(--line);
  font-family: var(--mono); font-size: 10px; letter-spacing: .2em;
}
.audiobar__state { color: var(--gold); }
.audiobar.is-live .audiobar__state { color: var(--text); }
.viz { display: flex; align-items: flex-end; gap: 3px; height: 14px; }
.viz i { width: 2px; background: var(--text); display: block; }
.audiobar__track { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.audiobar__spacer { flex: 1; }
.audiobar button { background: none; border: 0; padding: 0; font: inherit; letter-spacing: inherit; color: var(--muted); cursor: pointer; text-transform: uppercase; }
.audiobar button:hover { color: var(--text); }

/* ── Bandeau de preuve ────────────────────────────────────────────────────── */
.proof {
  flex: none;
  /* `position: relative` OBLIGATOIRE depuis que la bande vit dans le hero.
     Les voiles du hero sont en `position: absolute` : un élément positionné se
     peint après les blocs en flux normal, quel que soit l'ordre du DOM. Sans
     ce relative, la bande était rendue — et intégralement recouverte par les
     dégradés. Elle n'était pas absente, elle était dessous. */
  position: relative;
  display: grid; grid-template-columns: repeat(5, 1fr);
  border-top: 1px solid var(--line); border-bottom: 1px solid var(--line);
  background: rgba(255,255,255,.035);
}
.proof > div {
  /* ⚠️ La hauteur de la bande se règle ICI et sur `.proof__peak-n`, nulle part
     ailleurs : le libellé mono ne fait que 15 px, c'est le chiffre du pic —
     seul élément en fonte display de la rangée — qui commandait les 74 px.
     Baisser la boîte sans baisser le chiffre l'aurait simplement écrasé contre
     les filets. 74 → 54 px, chiffre 34 → 28 px. */
  display: flex; align-items: center; min-height: 54px; padding: 12px clamp(14px, 1.9vw, 28px);
  border-right: 1px solid var(--line);
  font-family: var(--mono); font-size: clamp(9px, .78vw, 11px); letter-spacing: .18em; color: var(--text);
}
.proof > div:last-child { border-right: 0; justify-content: space-between; gap: 16px; }
.proof__peak-l { color: var(--muted); font-size: 10px; letter-spacing: .2em; white-space: nowrap; }
.proof__peak-n { font-family: var(--display); font-size: 28px; line-height: 1; color: var(--gold); }

/* ── Ossature de section ──────────────────────────────────────────────────── */
.sec { padding: clamp(72px, 9.5vw, 150px) var(--gutter) clamp(64px, 8vw, 130px); }
.sec__head { display: flex; align-items: center; gap: 18px; padding-bottom: 14px; margin-bottom: 34px; border-bottom: 1px solid rgba(255,255,255,.14); }
.sec__num { font-family: var(--display); font-size: 30px; line-height: 1; letter-spacing: .06em; }
.sec__name { font-family: var(--mono); font-size: clamp(9px, .8vw, 11px); letter-spacing: .28em; }
.sec__fill { flex: 1; min-width: 20px; height: 1px; background: var(--line); }
.sec__title { font-family: var(--display); font-size: clamp(44px, 7.2vw, 104px); line-height: .94; letter-spacing: .01em; margin: 0 0 48px; }
.sec__title .gold { color: var(--gold); }

/* §01 — ce qu'est INDEX */
/* Le dégradé reste le SOCLE : la photo se pose dessus, elle ne le remplace pas.
   C'est lui qui tient la section quand l'image n'est pas encore décodée, et lui
   qui donne sa couleur aux zones où le masque l'éteint. */
.what { position: relative; isolation: isolate;
  background: radial-gradient(120% 90% at 12% 0%, #232323 0%, #1a1a1a 46%, #141414 100%); }

/* Fond photo (assets/backgrounds/night-city-1600.webp, tiré de night_city.webp).
   isolation: isolate sur la section fait que z-index:-1 reste AU-DESSUS du
   dégradé de .what et SOUS son contenu, sans traverser le reste de la page.
   position: absolute est indispensable et pas seulement pour le calage :
   .sec--screen est un conteneur FLEX, et un pseudo-élément en flux y devient
   un ÉLÉMENT FLEX qui pousserait la grille. Absolu, il en sort. */
.what::before {
  content: ""; position: absolute; inset: 0; z-index: -1; pointer-events: none;
  background: url('/assets/backgrounds/night-city-1600.webp') center 45% / cover no-repeat;
  opacity: .2;
  /* Les sections voisines sont des aplats sombres et le défilement est en
     scroll-snap : une photo qui s'arrête net en haut et en bas dessine deux
     coutures franches à chaque calage. Le masque les éteint. */
  -webkit-mask-image: linear-gradient(180deg, transparent 0, #000 14%, #000 78%, transparent 100%);
  mask-image: linear-gradient(180deg, transparent 0, #000 14%, #000 78%, transparent 100%);
}

/* Voile de lisibilité, au-dessus de la photo et sous le texte. La colonne de
   droite est de la prose en gris sur les fenêtres allumées d'une tour : sans
   voile, ce sont des points très clairs qui passent derrière les lignes. Le
   dégradé est plus dense à droite, là où tombe le texte — à gauche le cadre
   photo est opaque et couvre déjà tout. */
.what::after {
  content: ""; position: absolute; inset: 0; z-index: -1; pointer-events: none;
  background: linear-gradient(90deg, rgba(15,15,15,.35) 0%, rgba(15,15,15,.5) 46%, rgba(15,15,15,.82) 100%);
  -webkit-mask-image: linear-gradient(180deg, transparent 0, #000 14%, #000 78%, transparent 100%);
  mask-image: linear-gradient(180deg, transparent 0, #000 14%, #000 78%, transparent 100%);
}
.what__grid { display: grid; grid-template-columns: minmax(0,1fr) 480px; gap: clamp(40px, 6vw, 96px); align-items: start; max-width: var(--maxw); margin: 0 auto; }
.figure { position: relative; min-width: 0; min-height: 460px; border: 1px solid var(--line);
  background-image: repeating-linear-gradient(122deg, rgba(255,255,255,.05) 0 1px, transparent 1px 9px), linear-gradient(180deg, #1d1d1d, #151515); }
.figure > img, .figure > video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
/* Voile de pied de cadre. Sans lui, la légende en mono gris se pose sur ce que
   le visuel a de plus clair — ici un ciel de jour — et devient illisible.
   Le voile est porté par le CADRE et non par la légende : un fond derrière le
   seul texte se lirait comme une étiquette collée sur la photo. */
.figure::after { content: ""; position: absolute; inset: auto 0 0 0; height: 42%; z-index: 1; pointer-events: none;
  background: linear-gradient(180deg, transparent 0%, rgba(10,10,10,.55) 46%, rgba(10,10,10,.92) 100%); }
.figure:not(:has(.figure__cap))::after { display: none; }
.figure__cap { position: absolute; bottom: 18px; left: 20px; right: 20px; display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
  font-family: var(--mono); font-size: 10px; letter-spacing: .2em; color: var(--text); z-index: 2; }
.figure__cap i { width: 16px; height: 1px; background: rgba(255,255,255,.2); }
.what__aside { padding-top: 74px; }
.motto { padding-top: 26px; border-top: 1px solid var(--line); }
.motto__l { font-family: var(--mono); font-size: 11px; letter-spacing: .26em; color: var(--gold); margin-bottom: 12px; }
.motto__t { font-family: var(--display); font-size: clamp(40px, 4.4vw, 64px); line-height: .94; letter-spacing: .02em; }
.tagrow { display: flex; gap: 14px; flex-wrap: wrap; margin-top: 16px; font-family: var(--mono); font-size: 10px; letter-spacing: .2em; color: var(--dim); }

/* ── Mur de partenaires ───────────────────────────────────────────────────── */
.partners { display: flex; align-items: center; gap: clamp(16px, 2.4vw, 34px); flex-wrap: wrap;
  padding: 26px var(--gutter); border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); background: rgba(255,255,255,.02); }
.partners__l { font-family: var(--mono); font-size: 10px; letter-spacing: .24em; color: var(--dim); }
.partners__l[hidden] { display: none; }
.partner { display: flex; align-items: center; gap: 10px; font-family: var(--mono); font-size: 11px; letter-spacing: .12em;
  color: var(--muted); filter: grayscale(1); opacity: .75; transition: filter .3s var(--ease), opacity .3s var(--ease), color .3s var(--ease); }
.partner:hover { filter: none; opacity: 1; color: var(--text); }
.partner img, .partner .mono-badge { width: 24px; height: 24px; flex: none; object-fit: cover; }
.partner .mono-badge { display: grid; place-items: center; background: var(--surface); border: 1px solid var(--line); font-size: 11px; color: var(--muted); }
.partner + .partner { border-left: 1px solid var(--line); padding-left: clamp(16px, 2.4vw, 34px); }

/* ── §02 Les quatre jeux ──────────────────────────────────────────────────── */
/* ── Le deck des quatre jeux ───────────────────────────────────────────────
   Remplace la liste de liens qui tenait cette section (2026-09-08). Elle ne
   montrait AUCUN des quatre visuels — alors que c'est le seul écran du site qui
   puisse les montrer ensemble — et ses quatre liens faisaient doublon avec les
   crans secondaires du rail, qui pointent déjà les mêmes ancres. Mesuré avant
   de la remplacer : le contenu occupait 41 % de la section sur un 1920×1080.

   Le motif est celui du hub in-game, et de l'accueil actuel en production : un
   deck de quatre cartes.

   La grille prend LA HAUTEUR QUI RESTE (`flex: 1 1 auto`) au lieu d'imposer un
   ratio — même patron que `.figure--fill`. Un ratio fixe déborderait la section
   sur écran court et laisserait du vide sur écran haut ; ici la carte s'ajuste
   et le `object-fit: cover` du visuel suit sans se déformer.

   L'enveloppe `.games__col` doit être une colonne flex pour que ce `flex: 1`
   ait un sens. C'est exactement ce qui manquait à l'ancienne règle
   `.sec--screen .games__index { margin-top: auto }` : elle calculait 0 px, une
   marge auto ne faisant rien dans un conteneur `display: block`. L'ancrage
   voulu n'a donc jamais existé. */
.games__col { max-width: var(--maxw); margin: 0 auto; width: 100%; display: flex; flex-direction: column; }
.sec--screen .games__col { flex: 1 1 auto; min-height: 0; }

.deck {
  display: grid; grid-template-columns: repeat(4, minmax(0,1fr));
  gap: 1px; background: var(--line); border: 1px solid var(--line);
  flex: 1 1 auto; min-height: 200px;
}
.deck__card {
  position: relative; overflow: hidden; isolation: isolate;
  display: flex; flex-direction: column; justify-content: space-between;
  padding: clamp(14px, 1.4vw, 22px); background: var(--bg-raise);
}
/* z-index EXPLICITES : `::after` est le dernier enfant, donc à z-index auto le
   voile de lisibilité se peindrait par-dessus le titre, pas dessous. */
.deck__art { position: absolute; inset: 0; z-index: 0; width: 100%; height: 100%; object-fit: cover;
  filter: grayscale(1) brightness(.42); transition: filter .55s var(--ease); }
.deck__card::after { content: ""; position: absolute; inset: 0; z-index: 1; pointer-events: none;
  background: linear-gradient(180deg, rgba(10,10,10,.3) 0%, transparent 40%, rgba(10,10,10,.88) 100%); }
.deck__n, .deck__foot { position: relative; z-index: 2; }

/* ⚠️ Le survol ne pose AUCUN `transform`, et ce n'est pas un oubli. Un zoom sur
   un visuel dans un calque découpé est précisément ce qui a coûté SEPT
   correctifs ratés au deck de production : un filet parasite sur la dernière
   colonne de pixels, visible en compositing GPU seulement — donc invisible à
   toute capture headless. La désaturation qui se lève suffit, et c'est déjà le
   traitement du mur de partenaires. Ne pas réintroduire de `transform` ici. */
.deck__card:hover .deck__art,
.deck__card:focus-visible .deck__art { filter: grayscale(0) brightness(.66); }
.deck__card:focus-visible { outline: 1px solid var(--gold); outline-offset: -2px; }

.deck__n { font-family: var(--mono); font-size: 10px; letter-spacing: .2em; color: var(--gold); }
.deck__foot { display: flex; flex-direction: column; gap: 7px; }
.deck__t { font-family: var(--display); font-size: clamp(22px, 2.1vw, 32px); line-height: 1; }
.deck__c { font-family: var(--mono); font-size: 9px; letter-spacing: .18em; color: var(--dim);
  transition: color .3s var(--ease); }
.deck__card:hover .deck__c, .deck__card:focus-visible .deck__c { color: var(--muted); }

/* Une section par jeu, plein cadre, visuel et texte en alternance. */
/* Plein écran, une section par jeu. La hauteur est la fenêtre MOINS l'en-tête :
   la téléportation aligne le haut de section juste sous l'en-tête collant, donc
   une section à 100svh dépasserait de 64 px et se ferait couper le bas.
   `svh` et non `vh` : sur mobile, `vh` se mesure sur la fenêtre barre d'adresse
   RÉTRACTÉE, ce qui fait dépasser la section tant que la barre est déployée.
   `min-height` et non `height` : sur un écran très bas, le contenu doit pouvoir
   pousser la section plutôt que d'être tronqué. */
.game { position: relative; min-height: var(--screen); display: grid; align-items: end;
  border-bottom: 1px solid var(--line); overflow: hidden; }
.game__bg { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; z-index: 0; }
.game__veil { position: absolute; inset: 0; z-index: 1; pointer-events: none;
  background: linear-gradient(180deg, rgba(10,10,10,.72) 0%, rgba(10,10,10,.28) 34%, rgba(10,10,10,.86) 78%, rgba(10,10,10,.97) 100%); }
.game__veil--l { background: linear-gradient(180deg, rgba(10,10,10,.72) 0%, rgba(10,10,10,.3) 30%, rgba(10,10,10,.9) 80%, rgba(10,10,10,.97) 100%),
                              linear-gradient(90deg, rgba(10,10,10,.9) 0%, rgba(10,10,10,.35) 46%, transparent 74%); }
.game__veil--r { background: linear-gradient(180deg, rgba(10,10,10,.72) 0%, rgba(10,10,10,.3) 30%, rgba(10,10,10,.9) 80%, rgba(10,10,10,.97) 100%),
                              linear-gradient(270deg, rgba(10,10,10,.9) 0%, rgba(10,10,10,.35) 46%, transparent 74%); }
.game__in { position: relative; z-index: 2; padding: clamp(48px, 7vw, 96px) var(--gutter); width: min(760px, 100%); }
.game--right .game__in { margin-left: auto; }
.game__mod { font-family: var(--mono); font-size: 10px; letter-spacing: .26em; color: var(--gold); margin-bottom: 16px; }
.game__name { font-family: var(--display); font-size: clamp(46px, 6.4vw, 96px); line-height: .88; letter-spacing: .01em; margin: 0 0 18px; }
.game__lede { font-size: clamp(16px, 1.2vw, 19px); line-height: 1.6; color: var(--text); margin: 0 0 30px; max-width: 56ch; }

/* Modes : du TEXTE, jamais des boîtes. La sélection se fait par contraste —
   survoler la liste l'estompe entière, seul le mode visé reste plein. */
.modes { display: flex; flex-direction: column; gap: 0; border-top: 1px solid var(--line); }
.mode { display: grid; grid-template-columns: minmax(96px, 148px) 1fr; gap: clamp(14px, 2vw, 28px);
  padding: 15px 0; border-bottom: 1px solid var(--line); transition: opacity .28s var(--ease); }
.mode__n { font-family: var(--mono); font-size: 12px; letter-spacing: .2em; color: var(--text); transition: color .28s var(--ease); }
.mode__d { font-size: 14px; line-height: 1.55; color: var(--muted); margin: 0; }
.mode--soon .mode__n, .mode--soon .mode__d { color: var(--off); }
.mode--soon .mode__n { letter-spacing: .1em; }
.modes:hover .mode { opacity: .34; }
.modes:hover .mode:hover { opacity: 1; }
@media (hover: none) { .modes:hover .mode { opacity: 1; } }

/* ── BACKDOOR ─────────────────────────────────────────────────────────────── */
.backdoor { position: relative; overflow: hidden; background: #070707; border-bottom: 1px solid var(--line); padding: clamp(64px, 8vw, 120px) var(--gutter); }
.backdoor__noise { position: absolute; inset: 0; pointer-events: none; opacity: .12; overflow: hidden;
  font-family: var(--mono); font-size: 11px; letter-spacing: .3em; color: var(--signal); line-height: 2.1; word-break: break-all; user-select: none; }
.backdoor__in { position: relative; z-index: 2; max-width: var(--maxw); margin: 0 auto; }
.backdoor__tag { display: inline-flex; align-items: center; gap: 12px; font-family: var(--mono); font-size: 10px; letter-spacing: .26em; color: var(--signal); margin-bottom: 18px; }
.backdoor__t { font-family: var(--display); font-size: clamp(48px, 7vw, 104px); line-height: .92; color: var(--signal); margin: 0 0 18px; }
.backdoor__slots { display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 1px; background: var(--line); margin-top: 44px; }
.slot { background: #0a0a0a; padding: 24px; }
.slot__h { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; margin-bottom: 16px;
  font-family: var(--mono); font-size: 10px; letter-spacing: .2em; color: var(--dim); }
.slot__h b { color: var(--signal); font-weight: 400; }
.slot__n { font-family: var(--display); font-size: 30px; line-height: 1; margin-bottom: 10px; color: var(--muted); }
.slot__d { font-family: var(--mono); font-size: 9px; letter-spacing: .16em; color: var(--off); line-height: 1.9; }

/* ── §03 Le hub ───────────────────────────────────────────────────────────── */
.hub__grid { display: grid; grid-template-columns: minmax(0,1fr) 460px; gap: clamp(40px, 6vw, 96px); align-items: start; max-width: var(--maxw); margin: 0 auto; }
.ops { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; background: var(--line); margin-top: 56px; }
.op { position: relative; background: linear-gradient(180deg, #191919, #0e0e0e); overflow: hidden; }
.op img { width: 100%; height: 100%; object-fit: cover; object-position: top center; aspect-ratio: 300/540; filter: grayscale(.55); transition: filter .4s var(--ease), transform .5s var(--ease); }
.op:hover img { filter: none; transform: scale(1.03); }
.op__n { position: absolute; left: 14px; bottom: 12px; font-family: var(--mono); font-size: 10px; letter-spacing: .22em; color: var(--text); z-index: 2; }
.op::after { content: ""; position: absolute; inset: 0; background: linear-gradient(180deg, transparent 52%, rgba(10,10,10,.9) 100%); z-index: 1; }

/* ── §04 Progression ──────────────────────────────────────────────────────── */
.ladder { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 1px; background: var(--line); border: 1px solid var(--line); }
.rung { background: #0d0d0d; padding: 22px 24px; display: flex; align-items: baseline; gap: 16px; transition: background .3s var(--ease); }
.rung:hover { background: #151515; }
.rung__l { font-family: var(--mono); font-size: 11px; letter-spacing: .18em; color: var(--gold); flex: none; }
.rung__n { font-family: var(--display); font-size: 30px; line-height: 1; }
.rung__c { font-family: var(--mono); font-size: 9px; letter-spacing: .18em; color: var(--dim); margin-left: auto; }
.rung--apex { background: #121212; }
.rung--apex .rung__n { color: var(--gold); }

/* ── §05 Rejoindre ────────────────────────────────────────────────────────── */
.join { position: relative; isolation: isolate;
  background: radial-gradient(110% 80% at 50% 100%, #1f1f1f 0%, #131313 52%, #0a0a0a 100%); }

/* Fond photo, en écho à §01 — on ouvre et on ferme sur la ville. C'est le seul
   autre endroit où l'image est sur le message : la section dit « viens jouer »,
   la ville pleine derrière n'est pas décorative.

   Le CADRAGE diffère volontairement de §01 (`center 45%`, les hauts de tours) :
   ici `center 82%` prend la rue et ses lumières chaudes. Trois recadrages du
   même cliché ne trompent personne, deux bien distincts se lisent comme une
   intention. Le dégradé radial de .join monte déjà du bas, la photo monte avec.

   position: absolute obligatoire : .sec--screen est un conteneur FLEX, un
   pseudo-élément en flux y deviendrait un élément flex et pousserait le bloc. */
.join::before {
  content: ""; position: absolute; inset: 0; z-index: -1; pointer-events: none;
  /* Zoom + calage à droite : §01 cache la photo derrière un cadre opaque et
     n'en laisse voir que des fragments, ce qui la fait lire comme une ambiance.
     Ici rien n'est opaque — étalée à plat sur tout l'écran, elle ne se lisait
     plus que comme une photo terne. On la resserre sur la tour de droite, dans
     la moitié d'écran que le contenu de JOIN laisse vide. */
  background: url('/assets/backgrounds/night-city-1600.webp') 76% 58% / 132% no-repeat;
  opacity: .34;
  /* Un flou léger fait passer la ville du plan du DÉCOR à celui du fond : sans
     lui les façades restent détaillées et rivalisent avec la barre de commande.
     Il éteint aussi le panneau LED, seul détail figuratif du cliché. */
  filter: blur(2px);
  -webkit-mask-image: linear-gradient(180deg, transparent 0, #000 14%, #000 78%, transparent 100%);
  mask-image: linear-gradient(180deg, transparent 0, #000 14%, #000 78%, transparent 100%);
}

/* Voile de lisibilité. Le contenu de JOIN est à GAUCHE — titre, barre de
   commande, bouton, routes — là où celui de §01 est à droite : le dégradé est
   donc inversé par rapport à .what::after. La barre `connect play.index4g.com`
   est une boîte bordée sur fond transparent, la photo passerait au travers. */
.join::after {
  content: ""; position: absolute; inset: 0; z-index: -1; pointer-events: none;
  background: linear-gradient(90deg, rgba(12,12,12,.97) 0%, rgba(12,12,12,.93) 38%, rgba(12,12,12,.62) 66%, rgba(12,12,12,.24) 100%);
  -webkit-mask-image: linear-gradient(180deg, transparent 0, #000 14%, #000 78%, transparent 100%);
  mask-image: linear-gradient(180deg, transparent 0, #000 14%, #000 78%, transparent 100%);
}
.join__in { max-width: var(--maxw); margin: 0 auto; }
.cmd { display: flex; align-items: stretch; border: 1px solid var(--text); margin: 0 0 40px; max-width: 860px; }
.cmd__v { flex: 1; display: flex; align-items: center; padding: 22px clamp(16px, 2vw, 28px); font-family: var(--mono);
  font-size: clamp(13px, 1.5vw, 22px); letter-spacing: .06em; color: var(--text); overflow-x: auto; white-space: nowrap; }
.cmd__b { flex: none; border: 0; border-left: 1px solid var(--text); background: transparent; color: var(--text);
  font-family: var(--mono); font-size: 10px; letter-spacing: .2em; padding: 0 clamp(16px, 2vw, 30px); cursor: pointer; transition: background .3s var(--ease), color .3s var(--ease); }
.cmd__b:hover { background: var(--text); color: #151515; }
.cmd__b.ok { background: var(--text); color: #151515; }
.join__routes { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 1px; background: var(--line); border: 1px solid var(--line); margin-top: 48px; }
.route { background: #0c0c0c; padding: 26px; display: flex; align-items: center; justify-content: space-between; gap: 16px;
  font-family: var(--mono); font-size: 11px; letter-spacing: .18em; color: var(--muted); transition: background .3s var(--ease), color .3s var(--ease); }
.route:hover { background: #161616; color: var(--text); }
.route span:last-child { color: var(--gold); }
/* Mesuré à 70ch (669px) : la note se coupait en DEUX lignes dans les six locales,
   la seconde réduite à trois mots (« sans / liste d'attente. », « no waiting list. »)
   — la phrase cassait en son milieu pour un orphelin. Le défaut n'était pas propre
   au français : allemand, espagnol et portugais faisaient pareil.

   La mesure passe donc à 90ch. C'est large pour du corps de texte, mais ce n'en est
   pas : c'est une note technique d'une ligne, posée sous la rangée de liens et alignée
   sur elle. À 90ch les six locales (109 à 123 caractères) tiennent d'un trait.

   text-wrap: balance reste le garde-fou en dessous de ~1400px, où le conteneur se
   resserre et où deux lignes redeviennent inévitables : il les équilibre au lieu de
   laisser un reliquat. Firefox ne l'implémente pas encore et retombe sur le rendu
   normal — dégradation sans conséquence.

   ⚠️ Ne pas revenir à 70ch en croyant resserrer la colonne de lecture : c'est
   exactement le réglage qui produisait l'orphelin. */
.join__note { font-size: 14px; color: var(--dim); margin-top: 28px; max-width: 90ch; text-wrap: balance; }

/* ── Pied de page ─────────────────────────────────────────────────────────── */
/* Le padding bas réserve la hauteur du dock : il est en `position: fixed`, donc
   sans cette réserve la dernière ligne du pied de page passerait dessous. */
.ftr { border-top: 1px solid var(--line); padding: clamp(48px, 6vw, 80px) var(--gutter) calc(40px + var(--dock)); }
.ftr__grid { display: grid; grid-template-columns: 2fr 1fr 1fr; gap: clamp(32px, 5vw, 72px); max-width: var(--maxw); margin: 0 auto 48px; }
.ftr__logo { font-family: var(--display); font-size: 52px; letter-spacing: .1em; line-height: 1; margin-bottom: 22px; }
.ftr__tel { display: flex; flex-direction: column; gap: 8px; font-family: var(--mono); font-size: 10px; letter-spacing: .18em; color: var(--dim); }
.ftr__tel button { background: none; border: 0; padding: 0; font: inherit; letter-spacing: inherit; color: var(--dim); cursor: pointer; text-align: left; }
.ftr__tel button:hover { color: var(--text); }
.ftr__h { font-family: var(--mono); font-size: 10px; letter-spacing: .24em; color: var(--dim); padding-bottom: 12px; border-bottom: 1px solid var(--line); margin-bottom: 18px; }
.ftr__list { display: flex; flex-direction: column; gap: 11px; font-family: var(--mono); font-size: 11px; letter-spacing: .16em; color: var(--muted); }
.ftr__list a:hover { color: var(--text); }
.ftr__langs { display: flex; flex-wrap: wrap; gap: 10px 16px; font-family: var(--mono); font-size: 11px; letter-spacing: .16em; color: var(--dim); }
.ftr__langs a:hover { color: var(--text); }
/* La locale courante se marque comme dans l'en-tête : le sélecteur du pied de
   page est le même objet, il doit dire la même chose. */
.ftr__langs a[aria-current="true"] { color: var(--text); }
.legal { max-width: var(--maxw); margin: 0 auto; padding-top: 26px; border-top: 1px solid var(--line);
  font-size: 12px; line-height: 1.75; color: var(--off); }

/* ── Console F8 ───────────────────────────────────────────────────────────── */
.console { position: fixed; left: 0; right: 0; top: 0; z-index: 90; height: 38vh; min-height: 260px;
  transform: translateY(-100%); transition: transform .45s var(--ease);
  background: rgba(10,10,10,.98); border-bottom: 1px solid rgba(255,255,255,.14);
  display: flex; flex-direction: column; }
.console.open { transform: translateY(0); }
.console__bar { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px;
  border-bottom: 1px solid var(--line); font-family: var(--mono); font-size: 10px; letter-spacing: .2em; color: var(--muted); }
.console__log { flex: 1; overflow-y: auto; padding: 18px 16px; font-family: var(--mono); font-size: 12px; line-height: 2; letter-spacing: .04em; }
.console__log .cmd  { color: var(--dim); }
.console__log .inf  { color: var(--muted); }
.console__log .sys  { color: var(--gold); }
.console__log .ok   { color: var(--success); }
.console__log .err  { color: var(--signal); }
.console__form { display: flex; align-items: center; gap: 8px; padding: 12px 16px; border-top: 1px solid var(--line); }
.console__form span { font-family: var(--mono); font-size: 12px; color: var(--text); }
.console__form input { flex: 1; background: none; border: 0; outline: none; color: var(--text);
  font-family: var(--mono); font-size: 12px; letter-spacing: .04em; }

/* ── Apparition au défilement ─────────────────────────────────────────────── */
.rv { opacity: 0; transform: translateY(18px); transition: opacity .7s var(--ease), transform .7s var(--ease); }
.rv.in { opacity: 1; transform: none; }

/* ── Surlignage des mots-clés : <mark>, sans classe ────────────────────────
   Pas d'attribut : la valeur i18n est une chaîne à guillemets DOUBLES, un
   class="hl" y casserait le fichier — et la page n'a aucun autre <mark>.
   Même forme que <code> et <br>, déjà présents dans ces chaînes.

   ── Surlignage des mots-clés ──────────────────────────────────────────────
   Reprise de `::selection` (bloc blanc, texte #151515). Ce n'est pas un effet
   de plus : la sélection est un état DESSINÉ de cette maquette — le commentaire
   de la règle le dit — donc l'emphase parle une langue que la page tient déjà.

   Deux écarts DÉLIBÉRÉS avec une vraie sélection. Sans eux, un bloc blanc qui
   apparaît seul sur un mot fait croire au visiteur que son navigateur a
   sélectionné quelque chose, et il clique à côté pour « désélectionner » :
   — le trait BALAYE de gauche à droite, comme un marqueur ;
   — il RESTE, une sélection ne reste jamais.

   Branché sur `.rv.in`, qui existe déjà : aucun IntersectionObserver de plus,
   le trait part quand la ligne se pose — même horloge que le décodage des
   titres et le comptage des paliers. */
mark {
  background-color: transparent;   /* <mark> arrive en jaune par la feuille UA */
  background-image: linear-gradient(var(--text), var(--text));
  background-repeat: no-repeat;
  background-size: 0% 100%;
  color: inherit;                  /* <mark> force du noir, sinon */
  /* ⚠️ Le bloc doit remplir la LIGNE, pas épouser les lettres. Première version
     à .04em : ligne de 30,4 px, bloc blanc de 24,5 px — un trait trop court en
     haut et en bas, qui se lisait comme un découpage raté. `::selection`, lui,
     couvre toute la hauteur de ligne, et c'est ce qu'on imite.
     Le padding VERTICAL d'un élément en ligne ne pousse pas les lignes : il
     peint en dehors sans toucher à la mise en page, donc aucune marge négative
     à compenser de ce côté. --hl-pad se règle par contexte, chaque bloc de
     texte ayant son propre interlignage. */
  padding: var(--hl-pad, .2em) .3em;
  margin: 0 -.3em;                 /* le trait déborde du mot sans décaler le texte */
  -webkit-box-decoration-break: clone;
  box-decoration-break: clone;     /* une phrase qui passe à la ligne garde ses angles */
  transition: background-size 560ms var(--ease) 420ms,
              color 160ms linear 700ms;  /* la couleur bascule APRÈS le passage du trait */
}
.rv.in mark { background-size: 100% 100%; color: #151515; }

/* La note de §05 est en 14 px sur un interlignage plus serré : à .2em le bloc
   tombait 0,5 px sous la ligne. C'est à ça que sert --hl-pad. */
.join__note mark { --hl-pad: .22em; }

/* L'emphase est du CONTENU, pas de la décoration : sans animation elle doit
   être PRÉSENTE d'emblée, jamais absente. `.in` étant posé immédiatement quand
   le mouvement est réduit, il suffit de couper la transition. */
@media (prefers-reduced-motion: reduce) {
  mark { transition: none; }
}

/* ═══════════════════════════════════════════════════════════════════════════
   RESPONSIVE — le mobile n'est pas une dégradation.
   Un visiteur arrivé d'un lien Discord ou TikTok ne PEUT pas jouer depuis son
   téléphone : l'action principale y devient DISCORD, dite franchement.
   ═══════════════════════════════════════════════════════════════════════════ */

@media (max-width: 1200px) {
  .what__grid, .hub__grid { grid-template-columns: 1fr; }
  .what__aside { padding-top: 0; }
  .ladder { grid-template-columns: repeat(2, minmax(0,1fr)); }
  .backdoor__slots { grid-template-columns: repeat(2, minmax(0,1fr)); }
  .ftr__grid { grid-template-columns: 1fr 1fr; }
}

@media (max-width: 900px) {
  .proof { grid-template-columns: repeat(2, 1fr); }
  .proof > div:nth-child(2n) { border-right: 0; }
  .proof > div { border-bottom: 1px solid var(--line); }
  /* Cinq cellules dans une grille à deux colonnes laissent la dernière seule,
     avec une case vide à sa droite. Le pic du jour prend la ligne entière —
     c'est la seule donnée chiffrée de la bande, elle supporte la mise en avant. */
  .proof > div:last-child { grid-column: 1 / -1; border-right: 0; }
  .deck { grid-template-columns: repeat(2, minmax(0,1fr)); }
  .join__routes { grid-template-columns: 1fr; }
  .game__in { width: 100%; }
  .game--right .game__in { margin-left: 0; }
  /* Les voiles latéraux n'ont plus de sens en colonne unique : le texte occupe
     toute la largeur, c'est le voile vertical seul qui doit le porter. */
  .game__veil--l, .game__veil--r {
    background: linear-gradient(180deg, rgba(10,10,10,.7) 0%, rgba(10,10,10,.42) 26%, rgba(10,10,10,.93) 74%, rgba(10,10,10,.99) 100%);
  }
}

@media (max-width: 640px) {
  :root { --hdr: 56px; }
  .hdr { gap: 12px; }
  /* Pas de plein écran forcé au téléphone : le titre, la phrase et jusqu'à
     quatre modes ne tiennent pas dans un écran de 390×844 sans rogner. */
  .game { min-height: 0; }
  .hdr__logo { font-size: 22px; }
  .hdr__right { gap: 12px; }
  /* Le mot d'état RESTE au téléphone (2026-09-08). Il était masqué du temps
     où le badge écrivait « SLOTS OPEN » / « PLACES LIBRES », qui ne tenait pas
     à côté du logo et du sélecteur de langue. Il dit maintenant ONLINE, six
     caractères et le même mot dans les six locales — et sans lui il ne
     resterait que la jauge, sans rien pour la nommer. */
  .langs { gap: 9px; font-size: 10px; }

  .hero { min-height: auto; padding-top: 64px; padding-bottom: 88px; }
  .hero__veil-x { background: linear-gradient(180deg, rgba(21,21,21,.86) 0%, rgba(21,21,21,.62) 40%, rgba(10,10,10,.92) 100%); }
  .hero__cta { flex-direction: column; align-items: flex-start; gap: 18px; }
  .hero__cta > .vrule { display: none; }
  .hero__cta > div:first-child { width: 100%; }
  .hero__hint { order: 3; }
  .kicker span { letter-spacing: .2em; }
  .hold { width: 100%; height: 56px; }
  .scroll-cue { display: none; }

  .ops { grid-template-columns: repeat(2, 1fr); }
  .ladder, .backdoor__slots, .ftr__grid { grid-template-columns: 1fr; }
  .deck { grid-template-columns: 1fr; }
  .mode { grid-template-columns: 1fr; gap: 4px; }
  .cmd { flex-direction: column; }
  .cmd__b { border-left: 0; border-top: 1px solid var(--text); padding: 16px; }
  .audiobar { gap: 12px; font-size: 9px; }
  .audiobar__track { display: none; }
  .figure { min-height: 280px; }
}

/* ── L'en-tête à l'étroit : ce qui sort, et pourquoi ───────────────────────
   L'en-tête porte QUATRE choses — le logo, l'état du serveur, le Discord et
   les six langues — et elles ne tiennent pas côte à côte sur un téléphone.
   Mesuré dans le navigateur : la rangée fait 417 px de contenu, la gouttière
   en prend 13,2 % de plus (2 × 6,6vw), donc une seule rangée réclame **480 px
   de fenêtre au minimum**. Un iPhone fait 390, un Galaxy 360.

   ⚠️ Le débordement PRÉEXISTE au Discord — vérifié en rendant la version
   d'avant : à 390 px « ONLINE » passait déjà SOUS « EN ». Rien ne le
   signalait, l'en-tête n'ayant ni `overflow` ni `flex-wrap` : les éléments se
   chevauchent en silence au lieu de pousser une barre de défilement.

   ⚠️ DEUX RANGÉES ONT ÉTÉ ESSAYÉES ET REFUSÉES (arbitrage du propriétaire,
   capture à l'appui). Ne pas les réintroduire : elles tenaient tout, mais un
   en-tête collant sur deux lignes se lit comme un menu, pas comme un HUD.

   Il faut donc qu'une des quatre choses sorte, et c'est le BADGE D'ÉTAT :

   — Il est le seul dont l'information est DÉJÀ ailleurs dans le premier
     écran. Le hero, immédiatement dessous, affiche `.live` — le compteur de
     joueurs en jeu. Sortir le badge du bandeau ne retire rien au visiteur du
     téléphone, ça évite de lui dire deux fois la même chose à 300 px d'écart.
   — Le sélecteur de langue, lui, n'a d'équivalent qu'en PIED DE PAGE, à toute
     la page de distance. Sur un site prérendu par locale, un visiteur qui
     arrive dans la mauvaise langue doit pouvoir en changer tout de suite.
   — Serrer pour tout garder ne marche pas : au maximum du serrage encore
     lisible (langues à 9 px, segments de jauge à 2 px) il reste 296 px de
     contenu — ça passe à 360 px, ça casse à 320, et surtout ça donne quatre
     blocs tassés là où le reste de la maquette respire.

   ⚠️ Ce n'est PAS la contradiction de la note plus haut (« le mot d'état RESTE
   au téléphone »). Celle-ci arbitrait entre masquer le MOT et garder la jauge
   seule, sans rien pour la nommer. Ici le badge part ENTIER, à une largeur
   fixe : rien ne s'efface sous les yeux du visiteur, il n'y a aucun état
   ambigu à distinguer. Le jour où le badge revient au téléphone, c'est le
   sélecteur de langue qui devra sortir — pas le mot.

   Palier haut (521 → 700 px) : la rangée tient encore, seul le mot DISCORD
   tombe. Le glyphe est universellement reconnu — c'est le seul endroit du site
   où une icône se suffit — et le lien garde son nom accessible par
   `aria-label`. Le cadre devient carré : un bouton d'icône, pas un bouton de
   texte dont on aurait retiré le texte. Le seuil de 700 px est mesuré lui
   aussi : avec le libellé et les valeurs de bureau (logo 28 px, gouttières de
   20 et 24 px, langues à 11 px) la rangée fait 563 px et réclame 649 px de
   fenêtre — elle se chevauchait donc entre 641 et 648 px, juste au-dessus de
   la coupure des styles mobiles. */
@media (max-width: 700px) {
  .hdr__dc { width: 32px; padding: 0; justify-content: center; }
  .hdr__dc-t { display: none; }
}

/* Vérifié à ces valeurs : 258 px de contenu, soit de la marge jusqu'à 320 px
   de fenêtre. Le filet part avec le badge — il séparait le logo de l'état, il
   n'aurait plus rien séparé. */
@media (max-width: 640px) {
  .hdr .status { display: none; }
  .hdr__left .hdr__sep { display: none; }
}

/* Le survol n'existe pas au doigt : sans ce garde, la liste des modes reste
   estompée après un tap et le bloc paraît désactivé. */
@media (hover: none) {
  .partner { filter: none; opacity: 1; }
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { animation: none !important; transition: none !important; }
  .rv { opacity: 1; transform: none; }
}

/* ═══════════════════════════════════════════════════════════════════════════
   CALAGE AU DÉFILEMENT ET RAIL DE SECTIONS
   ═══════════════════════════════════════════════════════════════════════════ */

/* ── Pourquoi `proximity` et non `mandatory` ─────────────────────────────────
   `mandatory` donne le calage le plus franc — et piège l'utilisateur dès qu'une
   section dépasse la hauteur de la fenêtre. Ici §01, LE HUB et REJOINDRE font
   plus de 900 px sur un écran de portable : en `mandatory`, le navigateur
   ramène au point de calage le plus proche dès qu'on relâche, donc on ne peut
   plus s'arrêter AU MILIEU de ces sections pour les lire. Le bas de la section
   devient inatteignable au trackpad.

   `proximity` cale quand on s'approche d'un début de section et laisse défiler
   librement partout ailleurs. On garde la sensation « une section se pose »
   sans jamais reprendre la main au visiteur.

   Ce qui est volontairement ABSENT : aucun détournement JS de la molette. Un
   `wheel` intercepté casse le trackpad, le défilement inertiel, la molette
   libre et les lecteurs d'écran, et c'est ce qui fait qu'un site « à sections »
   passe pour cassé. Le calage est fait par le NAVIGATEUR ; le JS ne sert qu'au
   rail et au clavier. */
@media (min-width: 900px) {
  html {
    scroll-snap-type: y proximity;
    /* La réserve d'en-tête n'est plus ici : `scroll-padding-top` est global
       (cf. la règle `html` en tête de fichier). La dupliquer avec un
       `scroll-margin-top` sur `.snap` doublait le décalage. */
  }
  .snap { scroll-snap-align: start; }
}

/* Sous 900 px, aucun calage. Le défilement inertiel tactile et le calage se
   disputent la main — la page part, revient, et paraît lutter contre le doigt.
   Le rail disparaît aussi : il n'y a pas la largeur, et le pouce n'y va pas. */
@media (max-width: 899px) {
  html { scroll-snap-type: none; }
}

@media (prefers-reduced-motion: reduce) {
  html { scroll-snap-type: none; }
}

/* ── Rail ─────────────────────────────────────────────────────────────────── */
.rail {
  position: fixed; top: 50%; right: 22px; transform: translateY(-50%);
  z-index: 35; display: flex; flex-direction: column; gap: 12px;
  align-items: flex-end;
  /* Le rail flotte au-dessus de visuels dont on ne maîtrise pas la valeur :
     le ciel du hero et l'arène de SUMO sont presque blancs. Une ombre portée
     lui donne son contraste SANS lui poser de panneau derrière — un cartouche
     opaque le transformerait en widget rapporté, alors qu'il doit rester une
     série de filets posés sur la page. */
  filter: drop-shadow(0 1px 2px rgba(0,0,0,.9));
}
@media (max-width: 1099px) { .rail { display: none; } }

.rail a {
  display: flex; align-items: center; gap: 10px;
  padding: 3px 0; position: relative;
  font-family: var(--mono); font-size: 10px; letter-spacing: .18em; color: var(--muted);
  transition: color .3s var(--ease);
}
/* Le cran : un FILET, pas une pastille. Le rond serait le seul angle rond du
   site — la grammaire du hub interdit les rayons, y compris ici. */
.rail a i {
  display: block; width: 18px; height: 1px; background: rgba(255,255,255,.45);
  transition: width .35s var(--ease), background .3s var(--ease);
}
.rail a b { font-weight: 400; min-width: 14px; text-align: right; }
.rail__sub i { width: 10px; }
.rail__sub { padding-right: 18px; }

/* Le libellé n'apparaît qu'au survol : onze libellés affichés en permanence
   feraient une seconde colonne de texte le long de la page. */
.rail a::before {
  content: attr(data-label);
  position: absolute; right: calc(100% + 12px); top: 50%; transform: translateY(-50%) translateX(6px);
  white-space: nowrap; font-size: 9px; letter-spacing: .2em; color: var(--text);
  background: rgba(10,10,10,.9); padding: 5px 9px; border: 1px solid var(--line);
  opacity: 0; pointer-events: none;
  transition: opacity .28s var(--ease), transform .28s var(--ease);
}
.rail a:hover::before, .rail a:focus-visible::before { opacity: 1; transform: translateY(-50%) translateX(0); }

/* Blanc = interaction et état actif. L'or reste réservé au curseur
   clavier/manette : le focus visible, et lui seul, passe à l'or. */
.rail a:hover, .rail a.is-active { color: var(--text); }
.rail a:hover i { width: 30px; background: var(--text); }
.rail a.is-active i { width: 34px; background: var(--text); }
.rail__sub.is-active i { width: 22px; }
.rail a:focus-visible { outline: none; color: var(--gold); }
.rail a:focus-visible i { background: var(--gold); width: 34px; }
.rail__bd.is-active i, .rail__bd:hover i { background: var(--signal); }
.rail__bd.is-active, .rail__bd:hover { color: var(--signal); }

/* ── Aide clavier ─────────────────────────────────────────────────────────── */
.keyhint {
  position: fixed; right: 22px; bottom: 68px; z-index: 35;
  font-family: var(--mono); font-size: 9px; letter-spacing: .16em; color: var(--off);
  text-align: right; line-height: 1.9; pointer-events: none;
  opacity: 0; transition: opacity .4s var(--ease);
}
.keyhint.show { opacity: 1; }
@media (max-width: 1099px) { .keyhint { display: none; } }

/* Quand la téléportation JS est active, le calage CSS ferait double emploi et
   se contrarierait pendant l'animation. Le scroll-snap reste donc le REPLI :
   il s'applique sans JS, en `prefers-reduced-motion`, et sur les appareils où
   la molette n'est pas détournée (tactile, petits écrans). */
html.js-nav { scroll-snap-type: none !important; }

/* ═══════════════════════════════════════════════════════════════════════════
   SECTIONS PLEIN ÉCRAN

   Une section = un écran = un cran de molette. C'est ce qui rend la
   téléportation juste : tant qu'une section dépasse la fenêtre, la molette doit
   d'abord la parcourir avant de sauter (cf. garde n°2 dans site.js), et le
   geste perd sa régularité.

   La hauteur est la fenêtre MOINS l'en-tête : le calage aligne le haut de
   section sous l'en-tête collant, donc 100svh dépasserait par le bas.
   ═══════════════════════════════════════════════════════════════════════════ */
.sec--screen {
  /* Même réserve que le hero, et pour la même raison : le dock est fixe, une
     section qui déborde verrait sa dernière ligne passer dessous. */
  min-height: calc(var(--screen) + var(--dock));
  display: flex; flex-direction: column; justify-content: center;
  padding-top: clamp(36px, 5vh, 72px);
  padding-bottom: calc(clamp(36px, 5vh, 72px) + var(--dock));
}
/* Le titre de section est calibré pour une page qui défile. Sur trois lignes
   — « UN LAUNCHER, / PAS UN ÉCRAN / DE CHARGEMENT. » — il mange à lui seul
   300 px, et il ne reste plus de place pour la démo qu'il annonce. */
.sec--screen .sec__title { font-size: clamp(34px, 5vw, 72px); margin-bottom: clamp(18px, 2.6vh, 36px); }
.sec--screen .sec__head { margin-bottom: clamp(18px, 2.4vh, 30px); }
.sec--screen p { margin-bottom: 14px; }

/* En passant `.sec` en conteneur FLEX, les enveloppes internes centrées par
   `margin: 0 auto` sont devenues des éléments flex : leurs marges auto
   l'emportent alors sur l'étirement et la boîte se rétracte à son contenu.
   Toutes les sections s'étaient discrètement resserrées de 96 px à ~250 px de
   marge. On leur rend leur largeur explicitement. */
.sec--screen > * { width: 100%; min-width: 0; }

.sec--screen .hub__grid,
.sec--screen .what__grid { flex: 1 1 auto; min-height: 0; align-items: stretch; }
.sec--screen .hub__grid > div,
.sec--screen .what__grid > div { display: flex; flex-direction: column; min-height: 0; }
/* Le décalage qui faisait respirer la colonne de droite n'a plus lieu d'être
   quand les deux colonnes doivent tenir dans la même hauteur. */
.sec--screen .what__aside { padding-top: 0; justify-content: center; }
/* Colonne de droite du hub : sans centrage, la prose et les portraits collaient
   en haut et laissaient un tiers d'écran vide sous eux. */
.sec--screen .hub__grid > div:last-child { justify-content: center; }
.sec--screen .backdoor__slots { margin-top: clamp(18px, 3vh, 44px); }
.sec--screen .ladder { flex: none; }

/* §04 — l'échelle flottait au milieu de ~315 px de vide (haut ET bas) : la
   section ne se lisait pas comme sombre mais comme VACANTE. Même remède que
   .hub__grid et .what__grid juste au-dessus — l'enveloppe prend la hauteur
   disponible et l'échelle grandit avec, ses neuf paliers respirant au lieu de
   se tasser. L'id l'emporte sur `.sec--screen .ladder { flex: none }`, qui
   reste juste pour les autres sections.
   Inerte sous 900 px : .sec--screen y repasse en `display: block`. */
/* L'enveloppe ne s'étire plus non plus : étirée, elle gardait la hauteur mais
   ses enfants non, et tout le vide se ramassait EN BAS au lieu d'entourer le
   bloc. On laisse `justify-content: center` de .sec--screen faire son travail.
   Il ne reste donc, de toute cette passe, qu'UNE chose : des rangées plus hautes.

   ⚠️ NE PAS remettre `flex: 1` sur .ladder — essayé le 2026-09-08, écarté.
   L'échelle prenait bien toute la hauteur restante, mais ses trois rangées se
   partageaient ~530 px : des cases de 175 px pour une ligne de texte de 30 px.
   Le vide n'avait pas disparu, il était passé DANS les cases, et le résultat
   se lisait comme bâclé — arbitrage du propriétaire.

   Une case a une hauteur JUSTE, pas une hauteur disponible : ~11vh, bornée,
   soit une rangée qui respire (contre 74 px avant) sans jamais ballonner. Le
   reste de l'écran redevient de la marge autour du bloc, ce qui est sa place. */
#progression .ladder { flex: 0 0 auto; grid-auto-rows: clamp(82px, 11vh, 112px); }

@media (min-width: 900px) {
  /* Un palier étiré garde ses trois éléments alignés sur la même LIGNE DE BASE
     (c'est ce qui pose LVL 02 et FRIENDS sur le même appui) mais la ligne se
     collait en haut de la case. flex-wrap active align-content, qui la recentre
     verticalement sans toucher à l'alignement interne. */
  #progression .rung { flex-wrap: wrap; align-content: center; }
}
.sec--screen .join__routes { margin-top: clamp(18px, 3vh, 48px); }

/* La figure prend la place qui reste au lieu d'imposer son ratio. La vidéo est
   déjà en object-fit: cover, elle se recadre sans se déformer. */
.figure--fill { flex: 1 1 auto; min-height: 180px; }

/* §03 — la capture du hub ne se recadre PAS (2026-09-08).
   `.figure > img { object-fit: cover }` est le bon réglage pour une photo de
   jeu : le cadre impose son format et la photo se laisse rogner. Une capture
   d'INTERFACE, non — le cadre faisait 837x372 (2,25:1) pour une image 16:9,
   soit ~21 % mangés en haut et en bas, et l'image sortait coupée sous le titre.
   Le cadre prend donc le format de l'image, et `contain` garantit qu'aucun
   pixel ne se perd même si la hauteur disponible force la boîte à rétrécir. */
/* ⚠️ PAS d'`aspect-ratio` ici — essayé le 2026-09-08, retiré le jour même.
   Un ratio fixe fait dépendre la HAUTEUR de la figure de la LARGEUR de
   l'écran : 471 px de haut en 1600 de large, mais 632 px dès 1920. La section
   passait alors à 1037 px dans une fenêtre de 993 — elle débordait de 44 px sur
   la résolution la plus courante, et en scroll-snap obligatoire une section qui
   ne tient pas dans un écran impose un arrêt intermédiaire. Deux coups de
   molette pour changer de section, signalé en test.
   Le garde `@media (max-height: 900px)` que j'avais posé ne couvrait que les
   écrans COURTS : le problème venait des écrans LARGES.

   La figure prend donc la hauteur DISPONIBLE, comme les autres, et c'est
   `object-fit: contain` — et lui seul — qui empêche le rognage. */
#hub .figure--fill { flex: 1 1 auto; min-height: 0; }
#hub .figure > img { object-fit: contain; }

.sec--screen .tagrow { flex: none; }

/* Portraits raccourcis : à 300/900 la bande d'opérateurs à elle seule
   dépasserait la moitié de l'écran. On garde les visages, on coupe les bottes. */
.sec--screen .ops { margin-top: clamp(16px, 2.4vh, 36px); flex: none; }
.sec--screen .op img { aspect-ratio: 300/380; }

/* Sous 900 px il n'y a ni calage ni téléportation : le contenu reprend la
   main, et forcer un écran tronquerait la démo et les portraits. */
@media (max-width: 899px) {
  .sec--screen { min-height: 0; display: block; }
  .hero { min-height: 0; }
  /* Sans contrainte plein écran, le deck ne peut plus « prendre ce qui reste » :
     il lui faut un ratio, sinon `min-height: 200px` lui donnerait 200 px pour
     DEUX rangées de cartes. */
  .deck { min-height: 0; }
  .deck__card { aspect-ratio: 16/10; }
  /* Le dock reste fixe ici aussi : la réserve du bas doit donc rester, même
     sans contrainte de hauteur. Seule la hauteur imposée saute. */
  /* ⚠️ Ne JAMAIS combiner `aspect-ratio` et `min-height` ici. Les deux
     ensemble donnent à la boîte une largeur minimale intrinsèque
     (260 × 1280/700 = 475 px) qui remonte comme min-content dans la piste de
     grille `1fr` et pousse toute la section hors de l'écran — 125 px de
     débordement à 390 px, texte coupé en plein mot. Le ratio seul suffit :
     la largeur commande, la hauteur suit. */
  .figure--fill { min-height: 0; aspect-ratio: 1280/700; }
  .sec--screen .what__aside { justify-content: flex-start; }
}

/* ═══════════════════════════════════════════════════════════════════════════
   MOUVEMENT — passe du 2026-09-08

   Sept ajouts, étiquetés §A à §G. Chacun est autonome : en retirer un est
   supprimer son bloc, rien d'autre n'en dépend. Les contreparties JS (§C, §D,
   §G) sont marquées de la même lettre dans js/site.js.

   Deux règles tenues partout ici :

   1. `animation-fill-mode: backwards`, JAMAIS `forwards` ni `both`, sur tout
      ce qui joue une seule fois. Avec `backwards`, l'état de départ s'applique
      pendant le délai puis l'élément REVIENT à son style naturel — aucune
      valeur figée ne subsiste, donc aucun contexte d'empilement résiduel.
      Ça n'est pas un détail de puriste : `.hold__label` est en
      `mix-blend-mode: difference`, et une animation d'opacité qui reste « en
      vigueur » sur un ancêtre isole le groupe et change ce contre quoi le
      libellé se mélange. Le bouton JOUER est la seule chose de cette page
      qu'on n'a pas le droit de casser.

   2. Rien qui boucle n'anime autre chose que `transform` ou `opacity`. Les
      `filter` de survol (deck, partenaires, portraits) restent : un one-shot
      au survol n'est pas une boucle.

   Le garde `prefers-reduced-motion` global (plus haut, `animation: none
   !important`) suffit à tout ce qui est animé par keyframes. Les états posés
   en base — et eux seuls — sont rendus explicitement à la fin de ce bloc.
   ═══════════════════════════════════════════════════════════════════════════ */

/* ── §A · Le viz du dock bat quand le son joue ──────────────────────────────
   Les cinq barres portaient des hauteurs en dur et ne bougeaient jamais, même
   sous `.is-live`. C'était le seul élément de la page qui SE PRÉSENTE comme
   une animation sans en être une.

   Le battement est en `scaleY`, pas en `height` : la silhouette dessinée dans
   le HTML (8/14/5/11/7) est conservée, chaque barre pulse autour de SA valeur.
   Une animation de hauteur relaierait en plus la mise en page à chaque frame.

   Cinq durées PREMIÈRES ENTRE ELLES et des délais négatifs : sans ça les cinq
   barres repartent ensemble à chaque tour et le motif se lit comme une vague
   qui se répète — un logo qui clignote, pas un signal. Désaccordées, elles ne
   retombent jamais en phase. */
@keyframes viz-bar { 0%, 100% { transform: scaleY(.34); } 50% { transform: scaleY(1); } }
.viz i { transform-origin: bottom center; }
.audiobar.is-live .viz i { animation: viz-bar 860ms var(--ease) infinite; will-change: transform; }
.audiobar.is-live .viz i:nth-child(1) { animation-duration: 760ms;  animation-delay: -310ms; }
.audiobar.is-live .viz i:nth-child(2) { animation-duration: 1120ms; animation-delay: -80ms;  }
.audiobar.is-live .viz i:nth-child(3) { animation-duration: 640ms;  animation-delay: -450ms; }
.audiobar.is-live .viz i:nth-child(4) { animation-duration: 980ms;  animation-delay: -190ms; }
.audiobar.is-live .viz i:nth-child(5) { animation-duration: 830ms;  animation-delay: -560ms; }

/* ── §B · Entrée du hero ────────────────────────────────────────────────────
   Le premier écran arrivait d'un bloc : `.rv` ne commence qu'à §01. Ici tout
   est en CSS pur — aucun JS, donc rien à attendre, l'animation part au parse.

   Séquence, en millisecondes de DÉLAI : filet or 0 · sur-titre 60 · logotype
   120 · accroche 200 · rangée d'action 280 · bande de preuve 340→500 · repère
   de défilement 460. L'ensemble est posé en ~800 ms, mais aucun élément ne met
   plus de 480 ms à arriver : c'est l'écart qui fait la séquence, pas la
   lenteur.

   ⚠️ AUCUN `scale` ni `blur` sur `.hero__word`. Du Bebas à 248 px rastérisé
   petit puis agrandi arrive flou et se fige net — c'est exactement le défaut
   qui a fait retirer le FLIP du deck en production. Le logotype monte et
   apparaît, il ne grandit pas.

   Le filet du sur-titre se TRACE (`scaleX` depuis la gauche) plutôt que de
   s'afficher : c'est le seul trait or de l'écran, il ouvre la page comme une
   ligne de HUD qui se pose. */
@keyframes hero-rise { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } }
@keyframes hero-slide { from { opacity: 0; transform: translateX(-7px); } to { opacity: 1; transform: none; } }
@keyframes hero-draw  { from { transform: scaleX(0); } to { transform: scaleX(1); } }

.kicker i { transform-origin: left center; animation: hero-draw 420ms var(--ease) 0ms backwards; }
.kicker span { animation: hero-slide 400ms var(--ease) 60ms backwards; }
.hero__word { animation: hero-rise 480ms var(--ease) 120ms backwards; }
.hero__claim { animation: hero-rise 460ms var(--ease) 200ms backwards; }
.hero__cta { animation: hero-rise 440ms var(--ease) 280ms backwards; }
.scroll-cue { animation: hero-rise 420ms var(--ease) 460ms backwards; }

/* La bande de preuve fait partie du premier écran : elle se remplit cellule
   par cellule, de gauche à droite. C'est la seule rangée du hero qui ÉNUMÈRE —
   quatre promesses et un chiffre — et une énumération se lit en séquence. */
.proof > div { animation: hero-rise 380ms var(--ease) backwards; }
.proof > div:nth-child(1) { animation-delay: 340ms; }
.proof > div:nth-child(2) { animation-delay: 380ms; }
.proof > div:nth-child(3) { animation-delay: 420ms; }
.proof > div:nth-child(4) { animation-delay: 460ms; }
.proof > div:nth-child(5) { animation-delay: 500ms; }

/* ── §E · Les tuiles restent, leur CONTENU se pose ──────────────────────────
   `.deck` (4 cartes), `.ladder` (9 paliers) et `.ops` (4 portraits) sont
   chacun UN SEUL `.rv` : le bloc entier apparaissait d'un coup. Une échelle de
   progression qui se pose d'un bloc se lit comme un tableau ; posée palier par
   palier, elle se lit comme une montée.

   ⚠️ Ce qui est décalé est le CONTENU des tuiles, jamais les tuiles. Ces trois
   grilles dessinent leurs séparateurs avec un fond `--line` et des `gap: 1px` :
   une tuile qu'on translate ou qu'on rend transparente découvre ce fond clair,
   et la grille se met à clignoter en bandes. Les boîtes gardent donc leur fond
   opaque du premier au dernier frame ; seul ce qu'elles contiennent arrive.

   Corollaire : `.deck__art` est en `position: absolute; inset: 0` et couvre sa
   carte. Elle prend l'opacité SEULE — la translater exposerait 8 px de
   `--bg-raise` sous elle pendant tout le trajet.

   Écart de 45 ms, ramené à 30 ms pour l'échelle : neuf paliers à 45 ms font
   360 ms d'attente pour le dernier, et au-delà de ~250 ms on ne lit plus une
   séquence mais un diaporama. C'est le verdict « powerpoint » déjà rendu sur
   le deck de production. */
/* ⚠️ `.deck__art` est EXCLUE du `> *` alors qu'elle est bien enfant direct de
   la carte. Sans le `:not()`, elle héritait du `translateY(9px)` de cette
   règle — la règle suivante ne redéfinit que l'opacité, pas la transformation,
   et l'image glissait donc quand même en découvrant 9 px de `--bg-raise` sous
   elle. Le défaut est exactement celui que ce bloc existe pour éviter, une
   ligne plus bas que le commentaire qui l'interdit.

   Les deux règles d'image REPRENNENT `filter` et `transform` dans leur liste
   de transitions : une déclaration `transition` REMPLACE la précédente, elle
   ne s'y ajoute pas. Les omettre coupait la dessaturation au survol du deck et
   le `scale(1.03)` des portraits — les deux passaient net, d'un coup. */
.rv .deck__card > *:not(.deck__art), .rv .rung > *, .rv .op__n {
  opacity: 0; transform: translateY(9px);
  transition: opacity .5s var(--ease), transform .5s var(--ease);
}
.rv .deck__art { opacity: 0; transition: opacity .55s var(--ease), filter .55s var(--ease); }
.rv .op img    { opacity: 0; transition: opacity .55s var(--ease), filter .4s var(--ease), transform .5s var(--ease); }
.rv.in .deck__card > *, .rv.in .rung > *, .rv.in .op__n { opacity: 1; transform: none; }
.rv.in .deck__art, .rv.in .op img { opacity: 1; }

.rv.in .deck__card:nth-child(1) > *, .rv.in .deck__card:nth-child(1) .deck__art { transition-delay: 0ms; }
.rv.in .deck__card:nth-child(2) > *, .rv.in .deck__card:nth-child(2) .deck__art { transition-delay: 45ms; }
.rv.in .deck__card:nth-child(3) > *, .rv.in .deck__card:nth-child(3) .deck__art { transition-delay: 90ms; }
.rv.in .deck__card:nth-child(4) > *, .rv.in .deck__card:nth-child(4) .deck__art { transition-delay: 135ms; }

.rv.in .rung:nth-child(1) > * { transition-delay: 0ms; }
.rv.in .rung:nth-child(2) > * { transition-delay: 30ms; }
.rv.in .rung:nth-child(3) > * { transition-delay: 60ms; }
.rv.in .rung:nth-child(4) > * { transition-delay: 90ms; }
.rv.in .rung:nth-child(5) > * { transition-delay: 120ms; }
.rv.in .rung:nth-child(6) > * { transition-delay: 150ms; }
.rv.in .rung:nth-child(7) > * { transition-delay: 180ms; }
.rv.in .rung:nth-child(8) > * { transition-delay: 210ms; }
.rv.in .rung:nth-child(9) > * { transition-delay: 240ms; }

.rv.in .op:nth-child(1) img, .rv.in .op:nth-child(1) .op__n { transition-delay: 0ms; }
.rv.in .op:nth-child(2) img, .rv.in .op:nth-child(2) .op__n { transition-delay: 45ms; }
.rv.in .op:nth-child(3) img, .rv.in .op:nth-child(3) .op__n { transition-delay: 90ms; }
.rv.in .op:nth-child(4) img, .rv.in .op:nth-child(4) .op__n { transition-delay: 135ms; }

/* Le survol doit reprendre la main INSTANTANÉMENT une fois la tuile posée :
   sans ça, la quatrième carte survolée attend ses 135 ms d'apparition avant de
   se dessaturer, et le retard se lit comme une page qui rame. Le délai de mise
   en place ne doit survivre qu'à la mise en place. */
.rv.in .op:hover img,
.rv.in .deck__card:hover .deck__art,
.rv.in .deck__card:focus-visible .deck__art,
.rv.in .deck__card:hover > *,
.rv.in .deck__card:focus-visible > * { transition-delay: 0ms; }

/* ── §D · La section SUMO joue son clip ─────────────────────────────────────
   ⚠️ LE CLIP EST COUPÉ depuis le 2026-09-08 (`CLIPS_ENABLED = false`, §N de
   site.js) : plus aucune <video> n'est injectée, ces règles ne s'appliquent
   donc à rien aujourd'hui. Elles sont CONSERVÉES telles quelles — c'est du
   style, pas du contenu, même traitement que `.game-card .soon-badge` et
   `.btn-tebex.error` en production. Le jour où de vraies captures existent,
   relever le drapeau suffit ; il n'y a rien à réécrire ici.

   `assets/clips/sumo.webm` (433 Ko, plan continu de 6,5 s) existait et n'était
   utilisé nulle part. SUMO est le mode que le MOUVEMENT explique — pousser à
   l'eau — et c'est le seul des quatre dont l'image fixe ne dit pas le but.

   La vidéo est posée SOUS les voiles et par-dessus l'image, qui reste le
   poster : tant que le clip n'a pas de frame, on voit exactement la page
   d'aujourd'hui. Injection et pilotage dans js/site.js (§D).

   ⚠️ Aucun `transform` ici, et ce n'est pas un oubli. Le filet parasite qui a
   coûté sept correctifs au deck de production apparaissait sur une `<video>`
   enfant d'un calque TRANSFORMÉ et découpé, au bord d'une tuile voisine. Ici
   le clip est un fond plein cadre sans transformation et sans voisin — mais le
   défaut n'existe qu'en compositing GPU et AUCUNE capture headless ne le
   montre. À vérifier dans un vrai navigateur avant de conclure. */
.game__clip {
  position: absolute; inset: 0; z-index: 0;
  width: 100%; height: 100%; object-fit: cover;
  opacity: 0; transition: opacity .6s var(--ease);
  pointer-events: none;
}
.game__clip.is-playing { opacity: 1; }

/* ── §F · BACKDOOR déchire ──────────────────────────────────────────────────
   La section a le rouge, le champ de bruit et le vocabulaire de l'anomalie —
   et rien qui bouge. C'est le seul endroit du site où une animation DÉRANGEANTE
   est dans le ton.

   Recette de la production (`hx-glitch` sur le X du logotype) : une copie
   découpée au `clip-path`, décalée, visible ~100 ms dans un cycle de 26 s, en
   `steps()` pour que ça claque au lieu de fondre. Rare au point que la plupart
   des visiteurs ne la verront jamais — c'est tout l'intérêt.

   Le texte de la copie est écrit EN DUR : BACKDOOR est un nom propre, identique
   dans les six locales (cf. le commentaire de la section dans index.html). Un
   `attr()` imposerait un attribut de plus dans le HTML pour la même chaîne.

   Les deux animations partagent EXACTEMENT la même durée de cycle : c'est ce
   qui les garde en phase indéfiniment. Deux durées voisines dériveraient l'une
   par rapport à l'autre et la section se mettrait à grésiller à contretemps. */
.backdoor__t { position: relative; }
.backdoor__t::after {
  content: "BACKDOOR";
  position: absolute; left: 0; top: 0; width: 100%;
  color: #ff2a3d;
  clip-path: inset(38% 0 44% 0);
  transform: translateX(-4px);
  opacity: 0;
  animation: bd-tear 26s steps(1, end) infinite;
  pointer-events: none;
}
@keyframes bd-tear {
  0%, 21.0% { opacity: 0; transform: translateX(-4px); clip-path: inset(38% 0 44% 0); }
  21.4%     { opacity: .9; transform: translateX(-9px); clip-path: inset(38% 0 44% 0); }
  21.8%     { opacity: .9; transform: translateX(6px);  clip-path: inset(62% 0 22% 0); }
  22.2%     { opacity: 0; }
  73.0%     { opacity: 0; transform: translateX(-4px); }
  73.4%     { opacity: .9; transform: translateX(7px);  clip-path: inset(14% 0 68% 0); }
  73.7%     { opacity: 0; }
  100%      { opacity: 0; }
}
/* Le champ de bruit sursaute dans le MÊME cycle : la section blinke d'un bloc
   plutôt que de scintiller par morceaux. */
.backdoor__noise { animation: bd-flick 26s steps(1, end) infinite; }
@keyframes bd-flick {
  0%, 21.0% { opacity: .12; }
  21.4%     { opacity: .26; }
  22.2%     { opacity: .12; }
  73.0%     { opacity: .12; }
  73.4%     { opacity: .22; }
  73.7%     { opacity: .12; }
  100%      { opacity: .12; }
}

/* ── §G · Jauge de progression ──────────────────────────────────────────────
   La téléportation à la molette retire au visiteur le repère de position que
   le défilement lui donnait : on saute d'un écran à l'autre sans jamais sentir
   où l'on en est. Un filet d'un pixel sous l'en-tête le rend.

   `scaleX` depuis la gauche et rien d'autre : c'est la seule propriété qu'on
   puisse mettre à jour à chaque frame de défilement sans relayer la mise en
   page. Aucune transition — le filet SUIT le défilement, il ne le poursuit pas.

   Blanc, jamais or : l'or ne signale que le curseur clavier/manette.
   z-index 39, soit juste sous l'en-tête (40) : le filet se pose sur sa bordure
   basse, il ne flotte pas au-dessus du contenu. */
.prog {
  position: fixed; left: 0; right: 0; top: var(--hdr); z-index: 39;
  height: 1px; pointer-events: none;
}
.prog i {
  display: block; height: 100%; width: 100%;
  background: rgba(255,255,255,.55);
  transform: scaleX(0); transform-origin: left center;
}

/* ── Mouvement réduit ───────────────────────────────────────────────────────
   Le garde global neutralise déjà toute animation et toute transition. Il
   reste à rendre visible ce qui est CACHÉ EN BASE — les états de départ de §E,
   qui ne sont pas portés par des keyframes et survivraient donc au garde.
   §B, §A et §F n'ont rien à faire ici : leur état de départ ne vit que dans
   les keyframes, une animation neutralisée les laisse à leur valeur naturelle.
   §G reste : une jauge de position n'est pas une animation. */
@media (prefers-reduced-motion: reduce) {
  /* ⚠️ Le `:not(.deck__art)` est RECOPIÉ ici, il n'est pas décoratif. Sans lui
     le sélecteur de neutralisation pesait deux classes contre les trois de la
     règle qui cache (`:not()` prend la spécificité de son argument) : il
     perdait, et le titre des cartes du deck restait invisible pour qui refuse
     le mouvement. Le défaut était masqué à l'exécution — `wireReveal` pose
     `.in` sur tout dès que `REDUCED` est vrai — donc il ne serait apparu qu'un
     jour où le JS ne part pas. Une neutralisation doit tenir SEULE. */
  .rv .deck__card > *:not(.deck__art), .rv .rung > *, .rv .op__n,
  .rv .deck__art, .rv .op img { opacity: 1; transform: none; }
}

/* ═══════════════════════════════════════════════════════════════════════════
   MOUVEMENT — seconde passe du 2026-09-08 (§H à §M)

   Celle-ci ne cherche pas à décorer davantage : elle cherche à faire faire à
   la page ce que la page RACONTE. §H en est le cas pur — « 100% CONTROLLER
   FRIENDLY » cesse d'être une affirmation dans une bande de preuve.
   ═══════════════════════════════════════════════════════════════════════════ */

/* ── §H · Le curseur manette ────────────────────────────────────────────────
   L'OR ne signale QU'UNE chose dans cette grammaire : le curseur
   clavier/manette. La règle existait, le jeton était réservé, et il n'y avait
   simplement jamais eu de manette pour l'allumer. On ne crée donc pas un style
   nouveau, on branche enfin celui qui attendait.

   Le sélecteur porte sur `html.pad` : sans manette branchée, PAS UNE règle de
   ce bloc ne s'applique, et le focus garde son apparence habituelle. */
html.pad :focus {
  outline: 1px solid var(--gold);
  outline-offset: 3px;
}
/* La carte du deck garde son liseré INTÉRIEUR : à l'extérieur il se poserait
   sur le filet de 1px de la grille et la cellule voisine paraîtrait décalée. */
html.pad .deck__card:focus { outline-offset: -2px; }
html.pad .hold:focus { outline-offset: 4px; }

/* Le bandeau d'aide de la manette. À GAUCHE, alors que l'aide clavier est à
   droite : les deux peuvent être visibles en même temps (on branche une
   manette sur une page qu'on parcourait au clavier), et superposées elles
   seraient illisibles. Filet or au bord — c'est un message du curseur. */
.padhint {
  position: fixed; left: var(--gutter); bottom: 68px; z-index: 36;
  font-family: var(--mono); font-size: 9px; letter-spacing: .16em;
  color: var(--muted); line-height: 1.9; pointer-events: none;
  border-left: 1px solid var(--gold); padding-left: 12px;
  opacity: 0; transform: translateX(-6px);
  transition: opacity .4s var(--ease), transform .4s var(--ease);
}
.padhint b { color: var(--gold); font-weight: 400; letter-spacing: .22em; }
.padhint.show { opacity: 1; transform: none; }

/* ── §K · Le hero VERROUILLE SON SIGNAL ─────────────────────────────────────
   Le fond du hero apparaissait comme une photo. Il apparaît maintenant comme
   un FLUX qui se cale : deux décrochages horizontaux et un sursaut de
   luminance, terminés en 340 ms — avant même que le logotype ait fini
   d'arriver. C'est le vocabulaire de BACKDOOR et de la console, appliqué une
   seule fois, à l'endroit où la page se présente.

   ⚠️ `steps(1, end)` et non une interpolation : un décrochage se lit parce
   qu'il CLAQUE. Une transition douce entre deux positions ne donne pas un
   glitch, elle donne une image qui glisse — l'effet le plus cheap qui soit.

   ⚠️ Le `scale(1.015)` est PERMANENT et il est la condition du reste. Sans
   lui, un `translateX(-9px)` sur une image en `object-fit: cover` découvre le
   bord du cadre : une bande vide de 9 px sur le côté du hero, à chaque
   chargement. Avec, l'image déborde de ~12 px de chaque côté et tout le
   décrochage se joue à l'intérieur de la découpe. C'est aussi ce qui éloigne
   son bord de la frontière de clip — le contraire du montage qui a produit le
   filet parasite du deck en production. */
.hero__bg { transform: scale(1.015); animation: signal-lock 340ms steps(1, end) backwards; }
@keyframes signal-lock {
  0%   { transform: scale(1.015) translateX(-9px); filter: brightness(1.45) contrast(.82); opacity: .5; }
  18%  { transform: scale(1.015) translateX(7px);  filter: none; opacity: 1; }
  34%  { transform: scale(1.015) translateX(-4px); filter: brightness(1.22); }
  52%  { transform: scale(1.015) translateX(3px);  filter: none; }
  100% { transform: scale(1.015); filter: none; }
}

/* ── §L · Transitions de vue entre locales ──────────────────────────────────
   Changer de langue est une navigation vers une autre URL prérendue. Elle se
   voyait : écran blanc, puis toute la page qui se rejoue.

   Les trois éléments nommés ci-dessous sont MORPHÉS d'un document à l'autre au
   lieu d'être détruits puis recréés — la coque reste en place pendant que le
   contenu se fond. C'est ce qui sépare une application d'un site : ce qui n'a
   pas changé ne doit pas clignoter.

   ⚠️ Un `view-transition-name` doit être UNIQUE dans le document. Les trois
   choisis existent en un seul exemplaire ; ne jamais en poser un sur une
   classe qui se répète (une carte du deck, un palier), la transition entière
   est alors abandonnée en silence.

   Le pendant JS (§L dans site.js) coupe l'entrée du hero à l'arrivée : sans
   lui le contenu se fond ET se rejoue, deux mouvements pour un geste. */
@view-transition { navigation: auto; }
.hdr      { view-transition-name: hdr; }
.audiobar { view-transition-name: dock; }
.rail     { view-transition-name: rail; }
::view-transition-old(root), ::view-transition-new(root) { animation-duration: 280ms; }

html.no-entry .kicker i, html.no-entry .kicker span, html.no-entry .hero__word,
html.no-entry .hero__claim, html.no-entry .hero__cta, html.no-entry .scroll-cue,
html.no-entry .proof > div, html.no-entry .hero__bg { animation: none; }

/* ── Mouvement réduit ───────────────────────────────────────────────────────
   Le garde global neutralise `animation` et `transition` sur `*` — mais les
   pseudo-éléments d'une transition de vue ne sont PAS des `*` : ils vivent
   dans un arbre à part et échapperaient à la règle. D'où leur neutralisation
   explicite ici. §H et §K n'ont rien à ajouter : le premier ne pose que des
   couleurs de focus, le second est une animation, donc déjà couvert. */
@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) { animation: none !important; }
}

/* ═══════════════════════════════════════════════════════════════════════════
   §N · LES SECTIONS DE MODULE — format

   Ce qui n'allait pas, et ce n'était pas décoratif : les quatre blocs étaient
   ancrés EN BAS (`align-items: end`) alors qu'ils n'ont ni le même nombre de
   modes (4 · 3 · 3 · 2) ni des titres de même hauteur (MTA et SIMEON tiennent
   sur deux lignes, SUMO et PURSUIT sur une). Le bas restait donc fixe et le
   HAUT du bloc sautait d'une section à l'autre — de 130 px entre MTA et
   SIMEON. Quatre sections qui doivent se lire comme une SÉRIE paraissaient
   dépareillées, et c'est le défaut de format le plus visible de la page.

   Deux réponses ont été essayées, dans cet ordre :

   1. Aligner les TÊTES (`align-items: start` + un décalage de tête commun de
      9vh) et laisser les bas différer. L'alignement était parfait — « MODULE
      01 », « MODULE 02 »… au même pixel — mais il calait toute la série sur la
      seule section qui remplit l'écran, MTA CLASSICS. Les trois autres, plus
      courtes de 150 à 340 px, se retrouvaient plaquées EN HAUT de leur écran
      avec tout le vide en dessous. Retour du propriétaire, 2026-09-08 : « c'est
      tout en haut de la page, on peut l'avoir centré verticalement ? »

   2. CENTRER chaque bloc dans son écran (`align-items: center`), ce qui est
      l'état actuel.

   ⚠️ Ce que le centrage COÛTE, et il faut le savoir avant d'y toucher : les
   têtes ne sont plus alignées d'une section à l'autre, puisque centrer un bloc
   court le descend plus qu'un bloc long. Mesuré : `MODULE 01` tombe 89 px plus
   haut que `MODULE 03`, à 1600×813 comme à 1920×993 — contre 130 px pour
   l'ancrage EN BAS d'origine. L'écart est donc plus faible, et surtout les
   quatre sections sont scroll-snappées : on en voit UNE à la fois, pleine page,
   ce qui rend la dérive bien moins lisible que vue en enfilade.

   Il n'y a pas de troisième voie : des têtes alignées, c'est mathématiquement
   un décalage FIXE depuis le haut, donc la variante 1. Choisir, c'est choisir
   entre « la série est réglée » et « chaque écran est équilibré ».
   ═══════════════════════════════════════════════════════════════════════════ */

.game { align-items: center; }
.game__in {
  /* 760 → 880 px : la ligne de mode a maintenant trois colonnes (index, nom,
     description) et la description tombait à ~28 caractères par ligne. */
  width: min(880px, 100%);
  /* Depuis le centrage, le padding vertical n'est plus un décalage de tête :
     c'est la marge MINIMALE au-dessus et en dessous du bloc. Il doit donc être
     SYMÉTRIQUE — asymétrique, il repousserait le contenu hors du vrai centre,
     et le centrage ne se verrait qu'à moitié.

     Il ne pèse en pratique que sur MTA CLASSICS, la seule section dont le
     contenu approche la hauteur d'écran (titre sur deux lignes et QUATRE modes,
     quand SIMEON en a deux et une ligne). C'est donc elle, et jamais SUMO, qui
     commande toute reprise de ce nombre : à 1600×813 elle mesure 532 px de
     contenu pour 701 px utiles, et les 33 px de marge de part et d'autre (4vh,
     le plafond de 48 px ne mord qu'au-delà de 1200 px de haut) portent le bloc
     à 598 px — 103 px de reste, rien ne déborde sur le dock. L'ancien réglage
     asymétrique (104 px en haut, 96 en bas) la faisait au contraire dépasser
     de 2 px à cette taille, et bien plus sur un écran plus bas. */
  padding-block: clamp(24px, 4vh, 48px);
}

/* ── L'en-tête de module reprend le motif de `.sec__head` ───────────────────
   La page a déjà une façon d'ouvrir une section : un libellé mono, un filet,
   un repère à droite. Les sections de jeu ne le suivaient pas — elles
   empilaient un libellé et un titre sans rien entre les deux. Elles le suivent
   maintenant, et le compteur `01 / 04` dit à quel endroit de la série on est.

   Le compteur est écrit en CSS et non dans le HTML : ce sont des chiffres, ils
   sont identiques dans les six locales, et les poser ici évite quatre
   attributs de plus dans le master (donc quatre occasions de désaccord au
   prérendu). Même patron que `content: attr(data-label)` du rail. */
.game__mod {
  position: relative;
  display: flex; align-items: baseline; justify-content: space-between; gap: 20px;
  padding-bottom: 11px; margin-bottom: 15px;
}
.game__mod::after { content: "— / 04"; color: var(--dim); letter-spacing: .2em; }
#mta     .game__mod::after { content: "01 / 04"; }
#sumo    .game__mod::after { content: "02 / 04"; }
#pursuit .game__mod::after { content: "03 / 04"; }
#simeon  .game__mod::after { content: "04 / 04"; }

/* Le filet de l'en-tête SE TRACE à l'arrivée de la section. C'est un `::before`
   absolu et non un `border-bottom` : un bord ne se transforme pas, il ne peut
   donc pas se dessiner. */
.game__mod::before {
  content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 1px;
  background: var(--line);
  transform: scaleX(0); transform-origin: left center;
  transition: transform .8s var(--ease);
}
.rv.in .game__mod::before { transform: none; }

.game__name { margin-bottom: 12px; }
.game__lede { margin-bottom: 22px; }

/* ── La liste de modes devient un INDEX ─────────────────────────────────────
   Chaque ligne prend son rang. C'est ce qui manquait pour que le bloc se lise
   comme un sommaire de module plutôt que comme un paragraphe découpé : on voit
   d'un coup d'œil combien de modes a le jeu, et lesquels restent à venir.

   `counter-increment` plutôt qu'un numéro dans le HTML : la numérotation est
   une conséquence de l'ordre, pas une donnée. Une ligne ajoutée ou déplacée
   renumérote toute seule, et le master n'a rien à corriger. */
.modes { counter-reset: mode; }
.mode {
  grid-template-columns: 34px minmax(90px, 148px) 1fr;
  align-items: baseline;
  padding: 13px 0 13px 0;
  /* Le filet de sélection vit sur la ligne elle-même, transparent au repos :
     réserver sa place ici évite que la ligne ne se DÉCALE au survol. */
  border-left: 1px solid transparent;
  transition: opacity .28s var(--ease), border-color .28s var(--ease),
              padding-left .28s var(--ease);
}
.mode::before {
  content: counter(mode, decimal-leading-zero);
  counter-increment: mode;
  font-family: var(--mono); font-size: 10px; letter-spacing: .18em;
  color: var(--off); transition: color .28s var(--ease);
}
/* Survol : la ligne visée avance et prend son filet. La sélection reste du
   CONTRASTE (les voisines s'estompent, règle déjà en vigueur) ; le filet est
   celui du rail, pas une surface — aucune boîte n'apparaît. */
.modes:hover .mode:hover { border-left-color: var(--text); padding-left: 14px; }
.modes:hover .mode:hover::before { color: var(--text); }
@media (hover: none) { .modes:hover .mode:hover { border-left-color: transparent; padding-left: 0; } }

/* ── Les lignes se posent l'une après l'autre ───────────────────────────────
   Même grammaire que §E : ce qui arrive en série arrive en séquence. Le
   décalage est celui de l'échelle (30 ms), pas celui du deck (45 ms) — une
   liste dense se lit plus vite qu'une grille de vignettes. */
.rv .mode { opacity: 0; transform: translateY(8px); }
.rv.in .mode { opacity: 1; transform: none;
  transition: opacity .5s var(--ease), transform .5s var(--ease),
              border-color .28s var(--ease), padding-left .28s var(--ease); }
.rv.in .mode:nth-child(1) { transition-delay: 120ms; }
.rv.in .mode:nth-child(2) { transition-delay: 150ms; }
.rv.in .mode:nth-child(3) { transition-delay: 180ms; }
.rv.in .mode:nth-child(4) { transition-delay: 210ms; }
/* Le survol reprend la main sans attendre le délai d'apparition. */
.modes:hover .mode { transition-delay: 0ms; }

@media (max-width: 900px) {
  /* Le rang passe au-dessus avec le nom, la description prend la ligne
     entière : à 390 px, trois colonnes laissent 150 px à la description. */
  .mode { grid-template-columns: 30px 1fr; gap: 2px 10px; }
  .mode__d { grid-column: 1 / -1; }
  /* Symétrique pour la même raison qu'au desktop — en colonne unique le contenu
     dépasse de toute façon la hauteur d'écran, le centrage y est sans effet et
     ces deux valeurs redeviennent de simples marges. */
  .game__in { padding-block: clamp(24px, 4vh, 48px); }
}

@media (prefers-reduced-motion: reduce) {
  .rv .mode { opacity: 1; transform: none; }
  .game__mod::before { transform: none; }
}

/* ═══════════════════════════════════════════════════════════════════════════
   §O · Le ruban des communautés
   ═══════════════════════════════════════════════════════════════════════════ */

/* La bande ne passe plus à la ligne : le libellé tient sa place à gauche, le
   ruban prend tout le reste. `min-width: 0` est INDISPENSABLE — sans lui un
   enfant flex refuse de devenir plus étroit que son contenu, la fenêtre du
   ruban s'élargit à la taille de la piste et le débordement sort de la page
   au lieu d'être découpé. */
.partners { flex-wrap: nowrap; }
.partners__l { flex: none; }
.partners__vp {
  flex: 1 1 auto; min-width: 0; overflow: hidden;
  /* Les entrées s'effacent aux deux bords au lieu d'être tranchées net sur la
     gouttière. C'est ce qui fait la différence entre un ruban et un bloc qui
     déborde : sans le fondu, une communauté est coupée en deux au bord de
     l'écran en permanence. */
  -webkit-mask-image: linear-gradient(90deg, transparent 0, #000 56px, #000 calc(100% - 56px), transparent 100%);
          mask-image: linear-gradient(90deg, transparent 0, #000 56px, #000 calc(100% - 56px), transparent 100%);
}
.partners__track { display: flex; align-items: center; width: max-content; }
.partners__track.is-running { animation: partners-roll var(--dur, 60s) linear infinite; }
@keyframes partners-roll { to { transform: translateX(-50%); } }

/* ⚠️ TOUTES les entrées du ruban portent le filet, y compris la première.
   `.partner + .partner` en laisse la première sans — or à la couture, la
   première entrée de la copie suivante EN A un, puisqu'elle suit une entrée.
   Les deux moitiés ne seraient donc pas identiques et un filet apparaîtrait
   puis disparaîtrait une fois par tour, exactement à l'endroit que la boucle
   existe pour rendre invisible. */
.partners__track .partner {
  border-left: 1px solid var(--line);
  padding-left: clamp(16px, 2.4vw, 34px);
  padding-right: clamp(16px, 2.4vw, 34px);
  flex: none;
}

/* Le ruban s'arrête sous le curseur : ce sont des LIENS, et on ne demande pas
   à quelqu'un de cliquer sur une cible en mouvement. */
.partners__vp:hover .partners__track,
.partners__vp:focus-within .partners__track { animation-play-state: paused; }

@media (max-width: 640px) {
  /* Le libellé repasse au-dessus, centré : en ligne il mangeait 40 % d'un
     écran de 390 px et il ne restait la place que pour un logo et demi. */
  .partners { flex-wrap: wrap; justify-content: center; }
  .partners__l { width: 100%; text-align: center; }
  .partners__vp { flex: 1 1 100%; }
}

@media (prefers-reduced-motion: reduce) {
  /* Aucun ruban n'est construit dans ce cas (cf. site.js §O) : la bande
     redevient une rangée qui passe à la ligne. On lui rend donc son `wrap`. */
  .partners { flex-wrap: wrap; }
}
