/* ============================================================
   Ice breaker AFUP — charte « Exposition Universelle »
   Or sur brun sombre, Volta (bleu, froid) contre Edison (orange, chaud).
   Mobile-first : le jeu ne se joue que sur téléphone, dans le noir, debout.
   ============================================================ */

/* L'attribut `hidden` doit primer sur tout.
   Sans cette règle, `.stage { display: flex }` l'emporte sur le `[hidden] { display: none }`
   de la feuille de style du navigateur (une classe est plus spécifique qu'un attribut) : les
   quatre écrans — lobby, décompte, jeu, résultat — s'affichaient donc TOUS en même temps,
   alors que le contrôleur les masquait correctement. C'est le seul !important du fichier,
   et il est là pour rendre `hidden` réellement autoritaire. */
[hidden] { display: none !important; }

:root {
    /* ── Charte « Exposition Universelle » ──────────────────────────────────────
       Reprise du prototype `Prototype équipes et course`. Univers 1900 : brun chaud
       très sombre, or, Bodoni pour les titres, IBM Plex pour l'interface.

       ⚠️ CE BLOC EST LA SEULE TRACE VERSIONNÉE DE LA CHARTE. La maquette de référence
       reste hors dépôt par arbitrage explicite (#36) : elle est binaire, personne ne la
       relit en diff, et elle vit sur un poste. Ces commentaires ne documentent donc pas
       une source ailleurs — ils SONT la source. Les tenir à ce niveau fait partie du
       livrable. Toute valeur ajoutée ici sans son intention sera perdue.

       L'arbitrage précédent — « aucun vocabulaire de manche, aucune indication Épreuve 1
       sur 3 » — est LEVÉ. Il tenait tant que la série n'existait pas et que le prototype
       ne montrait qu'une manche ; les quatre sont livrées, et la maquette étendue porte
       le bandeau de manche sur le téléphone comme sur le mur. */

    /* Fonds et encres. Le brun est chaud et très sombre : projeté comme tenu en main,
       il laisse l'or et les couleurs d'équipe être les seules choses lumineuses. */
    --bg: #14100C;
    --bg-soft: #1B160F;
    --border: #2E2519;
    --ink: #F3EDE0;
    --muted: #A2937A;
    --muted-dim: #8A7C63;
    --muted-faint: #6E6350;

    /* L'or : couleur de l'institution, jamais d'une équipe. Il porte les titres, les
       cadres et l'accent de l'interface animateur — donc tout ce qui n'appartient à
       aucun camp. */
    --gold: #C9A227;
    --gold-light: #E8CE7A;
    --gold-pale: #F6E6B4;
    --accent: #C9A227;

    /* ⚠️ Couleurs d'équipe INVERSÉES par rapport à la charte précédente : Volta est
       désormais FROID (bleu) et Edison CHAUD (orange). L'ancien commentaire annonçait
       exactement ce cas — « ce choix est le nôtre, et il sautera si les visuels fournis
       pour #10 en décident autrement ». Le prototype est ce visuel, et il tranche.

       Ce qui reste vrai et doit le rester : un fond chaud contre un fond froid. Dans une
       salle sombre, à bout de bras, l'appartenance se lit à la température avant de se
       lire au nom. C'est cette propriété qui compte, pas l'attribution. */
    --volta: #3FA9FF;
    --volta-dark: #1F6DB0;
    --volta-glow: #7FE8FF;
    --volta-bg: #070B16;
    --edison: #FF9E3D;
    --edison-dark: #C1611A;
    --edison-glow: #FFC773;
    --edison-bg: #1B0E02;

    /* ── Surface parchemin ──────────────────────────────────────────────────────
       La charte n'a pas UNE surface, elle en a DEUX. Le fond reste sombre, mais tout
       panneau qui porte du contenu est un parchemin crème coiffé d'un bandeau bleu nuit.
       C'est la signature de l'évènement, présente sur les seize captures comme dans la
       maquette étendue, et c'est ce que l'application n'avait pas.

       Conséquence à ne jamais perdre de vue : sur le parchemin, `--ink` devient illisible
       et `--muted` disparaît. Un couple fond/encre global ne suffit plus. D'où ces jetons,
       qui forment une surface COMPLÈTE et autonome.

       ⚠️ INVARIANT VÉRIFIABLE SANS RENDU : un descendant de `.panel` ne référence JAMAIS
       `--ink` ni `--muted`. Une règle de peau qui retombe sur un jeton de la surface
       sombre sous un panneau est un défaut détectable à la lecture seule. */
    --paper: #EFE3C4;
    /* La peinture du parchemin, en un seul endroit. Le dégradé va du crème le plus clair EN HAUT
       vers `--paper` en bas : un papier éclairé par le haut, cohérent avec la lueur d'équipe de
       l'écran, qui vient elle aussi d'en haut.
       Un JETON et non une classe `.panel` : les quatre surfaces qui le portent sont un bouton,
       une carte, une liste et un panneau de course — structurellement différentes, avec chacune
       une géométrie déjà réglée. Une classe commune se battrait avec elles ; un jeton ne peint
       que ce qu'on lui demande de peindre. */
    --paper-surface: linear-gradient(180deg, #F7EFDA, #EFE3C4);
    --paper-ink: #16233D;
    --paper-ink-soft: #2A2115;
    /* 1,4:1 sur le parchemin. DÉCORATIF UNIQUEMENT — jamais une légende, jamais un
       libellé secondaire. Le crayonné d'une foule au repos, pas du texte. */
    --paper-muted: #C9BFA0;
    /* L'or assombri pour le papier. 2,7:1 : sous le seuil de 3:1 même pour du non-textuel,
       donc réservé aux filets et aux bandeaux. JAMAIS de l'or en texte sur crème. */
    --paper-gold: #A9871E;
    /* L'or LISIBLE, 4,75:1 sur le parchemin — le seul autorisé en texte.
       ⚠️ AJOUTÉ APRÈS UNE REVUE UX QUI A TROUVÉ LA RÈGLE CI-DESSUS ÉCRITE ET VIOLÉE QUATRE FOIS,
       ce qui est la façon dont une règle sans jeton pour l'appliquer se fait contourner : il n'y
       avait pas d'or lisible à mettre à la place, donc l'or décoratif servait de texte.
       Le pire cas était le décompte projeté, 16vh, le plus gros élément du mur au seul moment où la
       salle le lit à voix haute — donc le moins lisible de l'écran à cet instant précis. Les trois
       autres : la ligne « dernière manche » de la console, le verdict d'égalité, et le « · ton
       équipe » de la mini-course, qui dit au joueur lequel des deux couloirs est le sien.
       Assez sombre pour le texte, assez chaud pour se lire encore comme de l'or. */
    --paper-gold-ink: #7A5F10;

    /* Encres d'équipe pour le parchemin. Les teintes vives sont INUTILISABLES sur crème —
       Volta y plafonne à 2,0:1, Edison à peine mieux. Ces deux valeurs viennent de la
       maquette, qui les emploie exactement pour ça. La température se lit toujours, mais
       c'est celle-ci qui porte le texte et les jauges posées sur le papier. */
    --volta-ink: #1F6DB0;
    --edison-ink: #C1611A;

    /* ── Bandeaux et fonds de grille ────────────────────────────────────────────
       Le bandeau coiffe chaque panneau et porte son libellé en mono capitales. Le dégradé
       `--band-top` → `--band-bottom` habille les barres de marque, en haut du téléphone
       comme de l'écran projeté.

       Les deux fonds de grille sont le « papier millimétré » de la charte : quatre trames
       croisées, or fine et bleue plus large. La maille change avec la distance de lecture,
       jamais l'esprit — voir les trois valeurs à l'usage. */
    --band: #16233D;
    --band-top: #182B4A;
    --band-bottom: #0C1729;
    /* ⚠️ Les surfaces à trame sont BLEU NUIT, pas brunes. C'est un écart de fond avec la
       charte d'origine, et la maquette étendue comme les seize captures de l'évènement sont
       unanimes : le monde de l'Exposition est nocturne et froid, pas sépia.

       Ce que le brun `--bg` gardait de juste reste vrai — il laisse l'or et les équipes être
       les seules choses lumineuses — mais il faisait virer la trame or au sépia, et le papier
       millimétré se lisait comme un vieux parchemin plutôt que comme un plan d'exposition.
       `--bg` et `--bg-soft` restent en place pour tout le reste : ils ne sont pas retirés,
       ils cessent d'être le fond des trois grandes surfaces. */
    --grid-bg: #10131F;
    --grid-console: #0B0F1C;

    /* Les deux encres de la grille. Ce ne sont PAS `--gold` ni `--volta-dark`, et l'écart
       est délibéré dans la maquette : l'or de la trame est plus rouge que l'or de
       l'institution (146 de vert contre 162), et le bleu est un gris-bleu de papier, pas
       une couleur d'équipe. Une trame peinte avec les couleurs de la charte se mettrait à
       signifier quelque chose ; celle-ci doit rester du papier.
       La console emploie des versions bien plus pâles : on y lit des chiffres, pas une
       affiche, et la trame ne doit pas concurrencer le contenu. */
    --grid-line-gold: rgba(201, 146, 39, 0.22);
    --grid-line-blue: rgba(94, 124, 176, 0.3);
    --grid-line-gold-faint: rgba(232, 206, 122, 0.05);
    --grid-line-blue-faint: rgba(63, 90, 140, 0.08);

    /* ── États ──────────────────────────────────────────────────────────────────
       Vert, rouge et ambre restent EUX-MÊMES et ne passent pas à l'or : ce sont des états,
       lus depuis la régie pendant que le jeu tourne, et c'est le seul endroit où la couleur
       porte une décision. Ce raisonnement tenait déjà ; ce qui manquait, c'étaient les
       valeurs de la charte. `#e5484d` et `#2fbf71` étaient des rouges et verts de design
       system générique, faute de référence. La maquette étendue les fournit enfin.

       L'ambre, lui, n'existe pas dans la maquette : elle n'a aucun état intermédiaire et
       emploie l'orange d'équipe pour la cadence irrégulière. On le dérive donc de
       `--edison-glow` plutôt que d'inventer une troisième valeur hors charte.

       ⚠️ `--state-danger` tombe à 4,26:1 sur le parchemin, juste sous AA pour du texte
       normal. Sur papier : texte large, filet, ou fond de pastille avec `--paper-ink`
       par-dessus. Jamais du texte courant rouge sur crème. */
    --state-danger: #C0392B;
    --state-danger-dark: #7A2A22;
    --state-danger-stripe: #A5301F;
    --state-danger-ink: #FFF3ED;
    --state-ok: #4CAF6D;
    --state-ok-ink: #A8D9B4;
    /* Le vert sombre du parchemin : la coche d'une carte validée, où `--state-ok` serait
       trop clair pour se détacher du crème. */
    --state-ok-paper: #2F6B3E;
    --state-warn: var(--edison-glow);

    /* Typographie. Trois rôles, trois familles, et des replis système à chaque fois :
       le jeu doit rester lisible avec zéro police chargée — voir `fonts.css`. */
    --font-display: 'Bodoni Moda', Didot, 'Times New Roman', serif;
    --font-ui: 'IBM Plex Sans', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
    --font-mono: 'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, monospace;

    /* ── Rythme ─────────────────────────────────────────────────────────────────
       La maquette ne suit AUCUNE progression modulaire — ni ratio, ni échelle
       harmonique. Chercher à en extraire une serait inventer un système qu'elle n'a
       pas. Ce qu'elle a, en revanche, c'est une grille de 2 px dont sept valeurs
       reviennent, et 8/10/12/14 couvrent à elles seules les deux tiers des écarts. */
    --sp-1: 2px;
    --sp-2: 6px;
    --sp-3: 8px;
    --sp-4: 10px;
    --sp-5: 12px;
    --sp-6: 14px;
    --sp-7: 18px;

    /* Trois paliers de rayon, et cette fois le système est net dans la maquette :
       un contrôle est presque carré, une tuile est douce, un panneau est arrondi.
       L'application était globalement PLUS RONDE que la maquette — les boutons
       animateur à 12 px contre 5 px lui donnaient un air d'application web là où la
       maquette veut un vocabulaire d'instrument. */
    --radius-control: 4px;
    --radius-tile: 8px;
    --radius-panel: 14px;
    /* Conservé : plusieurs règles historiques s'y adossent, et c'est le rayon des
       grandes surfaces pleine largeur. Les trois paliers ci-dessus le complètent
       plutôt qu'ils ne le remplacent. */
    --radius: 16px;

    /* Trois paliers d'interlettrage, à la distance de lecture. L'application employait
       déjà 0.18em par défaut et 0.26/0.3em sur la scène : elle était alignée sans que
       ce soit nommé. */
    --track-tight: 0.06em;
    --track-label: 0.18em;
    --track-wide: 0.3em;

    /* Taille du QR code projeté, déployé. C'est la SEULE valeur à toucher si l'épreuve en
       salle montre qu'on ne scanne pas depuis le fond : le code est un SVG, l'agrandir ne
       coûte rien. La forme repliée en dérive par un facteur, pour qu'un seul réglage
       gouverne les deux. */
    --scene-qr-size: 46vh;
    --scene-qr-collapsed-ratio: 0.38;
    font-family: var(--font-ui);
}

/* Étiquette capitale espacée : le motif typographique récurrent du prototype. Employé pour
   tout ce qui NOMME sans être lu comme une phrase — « ÉQUIPE », « TEMPS RESTANT », les
   en-têtes de tuiles. En mono, parce que c'est ce qui lui donne son air d'affiche. */
/* ⚠️ `--muted` ET NON `--muted-faint` : relevé en revue d'accessibilité à environ 3,3:1 pour du
   13 px en capitales espacées, sous le seuil de 4,5:1. Les capitales et l'interlettrage large sont
   précisément ce qui rend un texte plus dur à lire, pas plus facile, donc le surtitre ne peut pas
   emprunter la couleur d'un élément décoratif. */
.eyebrow {
    font-family: var(--font-mono);
    font-size: 0.7rem;
    letter-spacing: 0.18em;
    text-transform: uppercase;
    color: var(--muted);
    margin: 0;
}

* { box-sizing: border-box; }

html, body {
    margin: 0;
    padding: 0;
    height: 100%;
    background: var(--bg);
    color: var(--ink);
}

body {
    -webkit-user-select: none;
    user-select: none;
    -webkit-tap-highlight-color: transparent;
}

/* --- Écran plein --- */
.screen {
    /* Trame « papier millimétré » : la signature visuelle de la charte, présente sur les
       seize captures de l'évènement comme sur chaque écran de la maquette. Quatre trames
       croisées — une fine maille d'or, une maille bleue deux fois plus large.

       La maille est indexée sur la DISTANCE DE LECTURE, pas sur la surface : 39/78 px sur
       un téléphone tenu à trente centimètres, 48/96 px sur un mur lu à vingt mètres. Une
       trame trop fine projetée moire à la vidéo ; une trame trop large tenue en main ne
       se lit plus comme du papier mais comme un quadrillage.

       Déclarée en variable LOCALE, sur cet élément et non dans `:root` : les variantes
       d'équipe ci-dessous réécrivent tout le raccourci `background`, et il faut pouvoir y
       remettre la trame sans la recopier trois fois. Une variable posée dans `:root`
       n'aurait PAS fonctionné — ses `var()` internes y sont résolus une fois pour toutes,
       et les pas de la scène ne s'y substitueraient jamais. */
    --surface-grid:
        repeating-linear-gradient(90deg, var(--grid-line-gold) 0 1px, transparent 1px 39px),
        repeating-linear-gradient(0deg, var(--grid-line-gold) 0 1px, transparent 1px 39px),
        repeating-linear-gradient(90deg, var(--grid-line-blue) 0 1px, transparent 1px 78px),
        repeating-linear-gradient(0deg, var(--grid-line-blue) 0 1px, transparent 1px 78px);
    min-height: 100dvh;
    display: flex;
    flex-direction: column;
    padding: env(safe-area-inset-top) 20px calc(20px + env(safe-area-inset-bottom));
    background:
        var(--surface-grid),
        radial-gradient(120% 80% at 50% 0%, #1A2136, var(--grid-bg));
    transition: background 0.6s ease;
}
/* Le fond d'équipe est une LUEUR sur un fond presque noir, et non un aplat de couleur.
   Mesuré en capture : la teinte pleine d'équipe occupait tout l'écran, et l'or comme le brun
   de la charte disparaissaient — le joueur voyait « une application bleue », pas une affiche
   avec une équipe. La lueur est donc posée en faible opacité, haute et diffuse.

   Elle ne doit pas éclairer le bas de l'écran : c'est là que se trouve le pouce, et c'est le
   contraste du bouton qui décide si le geste part ou non dans une salle sombre. */
/* ── Les trois encres d'équipe, posées sur l'ÉCRAN ─────────────────────────────
   Elles étaient d'abord sur `.tap-button`, ce qui était trop bas : les cartes de vérification
   du matériel en ont besoin et ne descendent pas du bouton. Sur l'écran, tout ce qui est posé
   sur du parchemin peut les lire, quelle que soit sa place dans l'arbre.

   Trois valeurs et non une, parce que le renversement de surface leur donne trois emplois qui
   ne se substituent pas :
     --team-ink    l'encre, pour ce qui est POSÉ sur le papier (texte, jauges, liserés)
     --team-solid  la teinte pleine, pour une jauge qui doit trancher sur l'encre
     --team-glow   la lueur, pour ce qui DÉBORDE sur le fond sombre — et rien d'autre
   Employer `--team-glow` sur du crème est le défaut à ne pas commettre : il y plafonne à 1,4:1. */
/* L'écran de placement (#45), le temps d'un aller-retour avant que l'équipe soit connue.

   ⚠️ IL DOIT AVOIR UNE TEINTE, et c'est la raison d'être de ce bloc : `--team-ink`, `--team-solid`
   et `--team-glow` ne sont définis que par `.team-volta` et `.team-edison`, or cet écran n'a
   précisément pas d'équipe. Sans lui, le titre et le halo héritent d'une variable vide — texte
   invisible sur fond sombre.

   L'or plutôt qu'une des deux couleurs d'équipe, et ce n'est pas une commodité : montrer du bleu
   ou de l'orange pendant qu'on tire l'équipe laisserait croire au joueur qu'elle est déjà décidée,
   puis la démentirait une seconde plus tard une fois sur deux. L'or est la teinte que le projet
   réserve à ce qui n'appartient à aucun camp. */
.screen--placing {
    --team-ink: var(--gold-light);
    --team-solid: var(--gold);
    --team-glow: rgba(212, 168, 83, 0.24);
    background:
        var(--surface-grid),
        radial-gradient(120% 70% at 50% -15%, rgba(212, 168, 83, 0.22), transparent 70%),
        linear-gradient(180deg, #0B0A06, #05040A);
}
.screen--placing .reveal__glow { background: radial-gradient(circle, var(--gold-light), transparent 68%); }

.screen.team-volta {
    --team-ink: var(--volta-ink);
    --team-solid: var(--volta);
    --team-glow: rgba(63, 169, 255, 0.28);
    background:
        var(--surface-grid),
        radial-gradient(120% 70% at 50% -15%, rgba(63, 169, 255, 0.3), transparent 70%),
        linear-gradient(180deg, var(--volta-bg), #04070E);
}
.screen.team-edison {
    --team-ink: var(--edison-ink);
    --team-solid: var(--edison);
    --team-glow: rgba(255, 158, 61, 0.26);
    background:
        var(--surface-grid),
        radial-gradient(120% 70% at 50% -15%, rgba(255, 158, 61, 0.28), transparent 70%),
        linear-gradient(180deg, var(--edison-bg), #0D0700);
}

/* --- Barre du haut --- */
.topbar {
    display: flex;
    align-items: center;
    gap: var(--sp-4);
    padding: var(--sp-4) 4px var(--sp-2);
    /* Filet d'or sous l'en-tête : le seul cadre de l'écran joueur. Il tient l'affiche
       ensemble sans coûter de hauteur, denrée rare sur un téléphone tenu debout.

       ⚠️ Il passe à l'OR PLEIN et à 2 px. À `--border`, le filet était un brun sombre invisible
       sur le fond brun sombre : le cadre annoncé par ce commentaire n'existait pas réellement.
       C'est la charte de l'évènement qui le montre, et elle a raison — ce trait est ce qui
       sépare l'en-tête de l'affiche. */
    border-bottom: 2px solid var(--gold);
}

/* L'emblème, en médaillon cerclé d'or. Le cercle est le motif de la charte : on le retrouve sur
   l'en-tête des seize captures et sur celui de la maquette. */
.brandmark {
    display: flex;
    flex: 0 0 auto;
    align-items: center;
    justify-content: center;
    width: 26px;
    height: 26px;
    border: 1px solid var(--gold-light);
    border-radius: 50%;
    color: var(--gold-light);
}

/* Le lockup sur deux lignes : le nom en serif, l'évènement en mono capitales espacées. C'est le
   verrou d'identité de la charte, et il tient en 26 px de haut.
   `min-width: 0` sur un enfant de flex, sans quoi l'ellipse de la ligne du nom ne se déclenche
   jamais : la valeur par défaut `auto` empêche le rétrécissement sous la largeur du contenu. */
.brand {
    display: flex;
    flex: 1;
    min-width: 0;
    flex-direction: column;
    line-height: 1.15;
}
.brand__name {
    font-family: var(--font-display);
    font-weight: 900;
    font-size: 0.82rem;
    color: var(--gold-light);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}
.brand__event {
    font-family: var(--font-mono);
    font-size: 0.52rem;
    letter-spacing: var(--track-wide);
    text-transform: uppercase;
    color: var(--muted-dim);
}

/* Le slot sponsor. Absent du DOM tant qu'aucun sponsor n'est configuré — voir le partial : sur
   la ligne la plus contrainte du téléphone, un conteneur vide coûterait quand même sa place. */
.sponsor {
    display: flex;
    flex: 0 0 auto;
    align-items: center;
    gap: var(--sp-2);
}
.sponsor__label {
    font-family: var(--font-mono);
    font-size: 0.48rem;
    letter-spacing: var(--track-label);
    text-transform: uppercase;
    color: var(--muted-faint);
}
.sponsor__logo { display: block; height: 14px; width: auto; }
.sponsor__name {
    font-family: var(--font-ui);
    font-size: 0.72rem;
    color: var(--ink);
}
.badge {
    font-family: var(--font-mono);
    font-size: 0.68rem;
    letter-spacing: 0.14em;
    text-transform: uppercase;
    padding: 5px 11px;
    border-radius: 999px;
    /* Contour et non aplat : le badge nomme l'équipe, il ne rivalise pas avec le bouton.
       C'est le bouton qui doit être la chose la plus lumineuse de l'écran. */
    border: 1px solid var(--border);
    background: rgba(0, 0, 0, 0.25);
    color: var(--muted);
}
/* Couleur du texte suivant le fond, comme pour le bouton : sur l'orange d'Edison, du blanc
   se lit mal, et dans une salle plongée dans le noir c'est rédhibitoire. */
.badge--volta  { border-color: var(--volta); color: var(--volta-glow); }
.badge--edison { border-color: var(--edison); color: var(--edison-glow); }

/* --- Étape centrée --- */
.stage {
    flex: 1;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    text-align: center;
    gap: 18px;
}
.stage--inline { flex: 0 0 auto; gap: 10px; margin: 10px 0; }
/* `flex-start` et non `space-between` : le bouton s'ancre en bas par `margin-top: auto`, et une
   marge automatique n'absorbe l'espace libre que s'il en reste — sous `space-between` il n'en
   reste aucun, donc l'ancrage ne prenait pas effet. Constaté en mesure. */
/* `position: relative` pour ancrer le bouton, et un rembourrage bas qui réserve exactement sa
   hauteur : le contenu au-dessus ne peut donc jamais passer dessous.

   ⚠️ « EXACTEMENT SA HAUTEUR » ÉTAIT FAUX, et le `max()` est le correctif. La réserve était en `vh`
   pur quand le bouton, lui, est en `height: 34vh` MAIS PLANCHERÉ à `min-height: 240px` : sous
   706 px de hauteur de viewport (240 / 0,34), le plancher gagne et le bouton devient plus haut que
   ce que la colonne lui réserve. Tout téléphone compact est en dessous, et tout téléphone dont le
   navigateur affiche ses barres aussi.

   Mesuré sur la page servie plutôt que déduit : sur Galaxy S5 en manche de rame, avec le bandeau
   capteur affiché, le compteur d'appuis chevauchait le bouton de 10 px et `overflow: hidden` le
   coupait en deux — le seul chiffre de l'écran, celui que #3 demande explicitement pour que le
   joueur « voie que sa participation compte », illisible dans la manche où il en a le plus besoin.
   Sur iPhone 14 en manche de tap, 2 px : le défaut était déjà là, sous le seuil du visible.

   ⚠️ LE CORRECTIF PORTE SUR LA RÉSERVE ET JAMAIS SUR LE BOUTON. Les six déclarations de géométrie
   de `.tap-button` sont mesurées au pixel par E2E-006, qui est la seule chose que l'écran d'essai
   apporte — voir son bloc, qui dit le prix. La réserve, elle, n'est mesurée par rien : c'est le bon
   endroit pour absorber l'écart. Les deux valeurs restent donc écrites deux fois, et c'est assumé :
   les factoriser dans une variable reviendrait à donner à la garde une prise sur la réserve. */
.stage--play {
    position: relative;
    justify-content: flex-start;
    padding: 12px 0 calc(max(34vh, 240px) + 12px);
    gap: 12px;
    overflow: hidden;
}

.muted { color: var(--muted); margin: 0; font-size: 1rem; }
/* Bodoni pour les grands titres — nom d'équipe, verdict. C'est ce qui donne l'époque, et cela
   ne concerne QUE les mots isolés : une phrase en Bodoni sur un téléphone dans le noir se lit
   mal, et l'interface reste donc en Plex Sans. */
.title {
    font-family: var(--font-display);
    font-size: clamp(2.4rem, 12vw, 4rem);
    margin: 0;
    font-weight: 900;
    letter-spacing: 0.01em;
}

/* --- Révélation d'équipe : le décor de l'écran d'attente ---

   Repris du prototype. Sur cet écran et lui seul : ailleurs, la chose la plus lumineuse doit
   être le bouton — voir le commentaire du gabarit. */
/* Le retrait en haut dégage l'étiquette du filet d'or de l'en-tête, qu'elle touchait. */
/* L'écran d'attente peut DÉFILER, et c'est la seule surface du jeu qui en a le droit : le joueur
   y a du temps, il n'y a aucune cible à viser, et le contrôle du matériel ajoute quatre cartes.
   Sur l'écran de jeu, un défilement serait une catastrophe — le bouton doit être là où le pouce
   l'attend, toujours. */
.stage--reveal {
    position: relative;
    overflow-y: auto;
    overflow-x: hidden;
    justify-content: flex-start;
    padding-top: 12px;
    gap: 12px;
}

/* Le décor est en `position: absolute` dans une pile SOUS le contenu, et `pointer-events: none`
   parce que rien ici ne se touche. Sans la pile explicite, les rayons passeraient devant le nom
   de l'équipe. */
.reveal {
    position: absolute;
    inset: 0;
    z-index: 0;
    overflow: hidden;
    pointer-events: none;
}
.stage--reveal > :not(.reveal) { position: relative; z-index: 1; }

/* Rayons d'exposition universelle : un éventail qui tourne très lentement — 72 s pour un tour,
   soit imperceptible image par image et pourtant vivant sur la durée d'une attente. Masqué en
   disque pour qu'il s'éteigne sur les bords au lieu de se couper net. */
.reveal__rays {
    position: absolute;
    left: 50%;
    top: 42%;
    /* Dimensionné en `vmin` et non en `vmax` : en `vmax`, sur un écran de téléphone, l'éventail
       est plus large que haut de plusieurs fois, le masque circulaire tombe entièrement hors de
       l'écran et ne dégrade donc plus rien. Constaté en capture — les rayons occupaient toute la
       page jusqu'aux bords et écrasaient le nom de l'équipe. */
    width: 150vmin;
    height: 150vmin;
    margin: -75vmin 0 0 -75vmin;
    /* Très basse, et c'est le point : ce décor doit se sentir, pas se voir. À 0,28 il devenait le
       sujet de l'écran. */
    opacity: 0.1;
    background: repeating-conic-gradient(from 0deg, rgba(232, 206, 122, 0.55) 0deg 1.6deg, transparent 1.6deg 9deg);
    mask-image: radial-gradient(circle, #000 0%, transparent 52%);
    -webkit-mask-image: radial-gradient(circle, #000 0%, transparent 52%);
    animation: spin 72s linear infinite;
}
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }

/* Halo d'équipe qui respire, derrière le nom. Six secondes : le rythme d'une respiration
   calme, et non celui d'une alerte. */
.reveal__glow {
    position: absolute;
    left: 50%;
    top: 42%;
    width: 78vmin;
    height: 78vmin;
    margin: -39vmin 0 0 -39vmin;
    border-radius: 50%;
    animation: breathe 6s ease-in-out infinite;
}
.team-volta  .reveal__glow { background: radial-gradient(circle, var(--volta-glow), transparent 68%); }
.team-edison .reveal__glow { background: radial-gradient(circle, var(--edison-glow), transparent 68%); }
@keyframes breathe {
    0%, 100% { transform: scale(1); opacity: 0.2; }
    50%      { transform: scale(1.12); opacity: 0.42; }
}

/* Les deux éléphants et le filament, avec les effectifs de chaque camp. */
.lamp {
    display: flex;
    align-items: flex-end;
    justify-content: center;
    gap: 3vw;
    width: 100%;
}
.lamp__side { display: flex; align-items: flex-end; gap: 2vw; }
.lamp__beast { width: 20vw; max-width: 110px; }
.lamp__beast .elephant { width: 100%; height: auto; overflow: visible; display: block; }
/* L'éléphant de droite regarde vers l'intérieur : les deux se font face, ils ne défilent pas. */
.lamp__side--edison .lamp__beast { transform: scaleX(-1); }
/* Effectif : en mono, parce qu'il change pendant l'attente et qu'une largeur qui varie le fait
   trembler. Dans la couleur claire de l'équipe, seule couleur d'équipe de cet écran. */
.lamp__count {
    font-family: var(--font-mono);
    font-size: 1.1rem;
    font-variant-numeric: tabular-nums;
    padding-bottom: 1vh;
}
.lamp__side--volta  .lamp__count { color: var(--volta-glow); }
.lamp__side--edison .lamp__count { color: var(--edison-glow); }
/* Le balancement au repos, décalé entre les deux : synchronisés, ils se liraient comme un seul
   objet symétrique et non comme deux bêtes. */
.lamp__beast .elephant--idle { animation: bob 3.4s ease-in-out infinite; }
.lamp__side--edison .elephant--idle { animation-delay: 1s; }
@keyframes bob {
    0%, 100% { transform: translateY(0); }
    50%      { transform: translateY(-6%); }
}

/* Le filament de l'ampoule, entre les deux : le motif d'Edison, en or, qui pulse. C'est la seule
   chose de l'écran d'attente qui appartienne à l'institution et non à une équipe. */
.lamp__filament {
    width: 3px;
    height: 26px;
    border-radius: 2px;
    background: linear-gradient(var(--gold-light), var(--gold));
    animation: filament 2.4s ease-in-out infinite;
}
@keyframes filament { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }

/* --- Vérifie ton matériel : les quatre cartes de contrôle --- */
/* ── Les deux cartes de la salle d'attente ────────────────────────────────────
   Bandeau bleu à libellé mono, corps transparent : ce ne sont PAS des panneaux de parchemin.
   Les cartes qu'elles contiennent le sont déjà, et une carte crème dans une carte crème n'aurait
   plus de hiérarchie. Ici le cadre ne fait que RASSEMBLER. */
.lobby-card,
.checkup {
    width: 100%;
    max-width: 460px;
    border: 1px solid var(--border);
    border-radius: var(--radius-tile);
    overflow: hidden;
}
.lobby-card__head,
.checkup__head {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: var(--sp-4);
    margin: 0;
    padding: var(--sp-3) var(--sp-6);
    background: var(--band);
    font-family: var(--font-mono);
    font-weight: 400;
    font-size: 0.68rem;
    letter-spacing: var(--track-label);
    text-transform: uppercase;
    color: var(--gold-light);
}
.lobby-card__aside { color: var(--muted-dim); white-space: nowrap; }
.checkup { display: flex; flex-direction: column; gap: 6px; }
/* Les cartes gardent leur gouttière, mais à l'intérieur du cadre. */
.checkup .card-check { margin: 0 var(--sp-3); }
.checkup .card-check:last-child { margin-bottom: var(--sp-3); }
/* Dans le bandeau, le titre suit la typographie du bandeau : mono capitales espacées, comme tout
   ce qui NOMME. Le Bodoni 900 qu'il portait était juste quand il était posé à plat sur l'écran ;
   dans un bandeau de 0,68 rem il serait deux fois trop lourd. */
.checkup__title { font: inherit; }
.checkup__ready { color: var(--muted-dim); white-space: nowrap; }
/* En or quand les quatre sont validés : c'est la seule récompense de cet écran, et elle n'ouvre
   aucune porte — le contrôle ne bloque rien. */
.checkup__ready[data-ready="all"] { color: var(--gold-light); }

/* Une carte est un BOUTON : chacune se touche, et c'est le geste lui-même qui la valide. Rien
   n'est validé en regardant. */
.card-check {
    position: relative;
    display: grid;
    grid-template-columns: 1fr auto;
    grid-template-areas: 'label mark' 'hint mark';
    align-items: center;
    gap: 0 12px;
    width: 100%;
    padding: var(--sp-4) var(--sp-6);
    border-radius: var(--radius-tile);
    border: 1px solid rgba(22, 35, 61, 0.25);
    /* Parchemin, comme les panneaux de geste. Ces quatre cartes sont la première chose qu'un
       joueur touche : les laisser sombres au milieu d'un écran dont tout le reste est passé au
       papier en aurait fait le seul endroit resté à l'ancienne charte. */
    background: var(--paper-surface);
    color: var(--paper-ink);
    font-family: var(--font-ui);
    text-align: left;
    cursor: pointer;
    touch-action: manipulation;
    -webkit-tap-highlight-color: transparent;
    overflow: hidden;
}
/* ── L'icône d'état, une par geste ────────────────────────────────────────────
   Elle dit graphiquement ce que la carte attend, là où seul le libellé le disait : on voit sa
   validation sans lire, ce qui est toute la différence sur un écran qu'on parcourt pendant que
   la salle se remplit.

   Dessinée en CSS et non en SVG, contrairement aux mascottes, et pour la même raison que la
   foule : chaque état change une géométrie — l'anneau grossit, le trait monte, la jauge se
   remplit — et cela se fait en CSS sans une ligne de JavaScript. Un SVG demanderait d'animer
   des attributs. */
.card-check {
    grid-template-columns: auto 1fr auto;
    grid-template-areas: 'icon label mark' 'icon hint mark';
}
.card-check__icon {
    grid-area: icon;
    position: relative;
    display: flex;
    align-items: center;
    justify-content: center;
    width: 2.2rem;
    height: 2.2rem;
    border: 1px solid rgba(22, 35, 61, 0.25);
    border-radius: var(--radius-control);
    overflow: hidden;
}

/* Taper : une pastille dans un anneau. L'anneau grossit à l'appui — le même retour que le
   bouton de jeu, en petit, pour que le geste s'apprenne au bon endroit. */
.card-check[data-check='tap'] .card-check__icon::before {
    content: '';
    width: 0.5rem;
    height: 0.5rem;
    border-radius: 50%;
    background: var(--paper-ink);
}
.card-check[data-check='tap'] .card-check__icon::after {
    content: '';
    position: absolute;
    width: 1.2rem;
    height: 1.2rem;
    border: 1px solid var(--team-ink, var(--paper-gold));
    border-radius: 50%;
    opacity: 0.4;
    transition: transform 0.14s ease-out, opacity 0.2s ease;
}
.card-check[data-check='tap'][data-holding] .card-check__icon::after { transform: scale(1.35); opacity: 1; }

/* Ramer : un trait qui MONTE, dans l'axe du geste. La hauteur suit la jauge de la carte. */
.card-check[data-check='row'] .card-check__icon::after {
    content: '';
    position: absolute;
    bottom: 0.25rem;
    width: 2px;
    height: 1.4rem;
    background: linear-gradient(0deg, var(--team-ink, var(--paper-ink)), transparent);
    border-radius: 1px;
}

/* Le micro : une capsule, la forme que tout le monde reconnaît. */
.card-check[data-check='mic'] .card-check__icon::before {
    content: '';
    width: 0.55rem;
    height: 1rem;
    border-radius: 999px;
    background: var(--paper-ink);
}
.card-check[data-check='mic'] .card-check__icon::after {
    content: '';
    position: absolute;
    bottom: 0.35rem;
    width: 1rem;
    height: 1px;
    background: var(--paper-ink);
}

/* Souffler : une jauge qui se remplit par le bas — le vu-mètre de la manche, en miniature. */
.card-check[data-check='blow'] .card-check__icon::after {
    content: '';
    position: absolute;
    bottom: 0;
    left: 0;
    right: 0;
    height: 35%;
    background: var(--edison-ink);
    opacity: 0.55;
}
.card-check[data-check='blow'][data-holding] .card-check__icon::after { height: 80%; opacity: 1; }

/* Validée : l'icône prend le vert de parchemin, comme la marque. Trois signaux d'accord —
   le cadre, la marque et l'icône — sur un écran parcouru du coin de l'œil. */
.card-check[data-state='done'] .card-check__icon { border-color: var(--state-ok-paper); }
.card-check[data-state='done'] .card-check__icon::before,
.card-check[data-state='done'] .card-check__icon::after { background: var(--state-ok-paper); }

.card-check__label { grid-area: label; font-size: 1rem; font-weight: 600; }
.card-check__hint { grid-area: hint; font-size: 0.8rem; color: rgba(22, 35, 61, 0.62); }
.card-check__mark {
    grid-area: mark;
    font-family: var(--font-mono);
    font-size: 1.2rem;
    color: rgba(22, 35, 61, 0.45);
    min-width: 1.2em;
    text-align: center;
}

/* La jauge de la carte est un filet en bas, et non une barre à part : elle dit l'avancement sans
   ajouter de hauteur à une liste de quatre éléments sur un téléphone. */
.card-check__gauge {
    position: absolute;
    left: 0;
    right: 0;
    bottom: 0;
    height: 2px;
    background: rgba(22, 35, 61, 0.12);
}
.card-check__gauge span { display: block; height: 100%; width: 0; transition: width 120ms linear; }
.team-volta  .card-check__gauge span { background: var(--volta-ink); }
.team-edison .card-check__gauge span { background: var(--edison-ink); }

/* Validé : bordure d'or et marque. L'or et non la couleur d'équipe — un contrôle du matériel
   n'appartient à aucun camp. */
/* Validé : la marque passe au VERT DE PARCHEMIN et le fond se teinte. L'or de l'ancienne version
   ne tient que 2,7:1 sur du crème — la carte validée aurait été plus discrète que les autres.
   `--state-ok-paper` existe pour ce cas précis, et la maquette l'emploie au même endroit. */
.card-check[data-state='done'] {
    border-color: var(--state-ok-paper);
    background: linear-gradient(180deg, #F1EFD9, #E7E7C9);
}
.card-check[data-state='done'] .card-check__mark { color: var(--state-ok-paper); }

/* Bloqué : la carte s'efface et cesse d'inviter. C'est l'état du souffle sans micro et du micro
   refusé — proposer d'essayer serait promettre l'impossible. */
.card-check[data-state='blocked'] { opacity: 0.45; }

/* Carte MAINTENUE, et c'est un correctif : le souffle se mesure tant que le doigt est sur la carte,
   or rien ne le montrait. `-webkit-tap-highlight-color: transparent` retire jusqu'au retour du
   navigateur, donc le joueur maintenait une carte parfaitement inerte à l'œil et ne pouvait pas
   savoir si son geste était pris en compte. Constaté sur téléphone réel.

   En surbrillance et non un simple assombrissement : elle doit se distinguer d'un coup d'œil au
   milieu de trois autres cartes, sur un écran de téléphone tenu à bout de bras dans une salle
   sombre. */
/* ⚠️ La surbrillance est INVERSÉE avec la surface, et le correctif qu'elle porte est le même :
   sur du crème, éclaircir ne se voit pas. La carte maintenue s'ASSOMBRIT et son liseré passe à
   l'encre d'équipe — la même bascule vers le soustractif que le panneau de tap. */
.card-check[data-holding] {
    border-color: var(--team-ink, var(--paper-ink));
    background: linear-gradient(180deg, #E8DCBB, #DED2AF);
}
.card-check[data-holding] .card-check__label { color: var(--paper-ink); }

/* ⚠️ `touch-action: none` sur la seule carte qui se MAINTIENT, et c'est l'autre moitié du
   correctif : avec `manipulation`, hérité de `.card-check`, le navigateur reste libre d'interpréter
   un maintien qui bouge de deux pixels comme un défilement. Il émet alors `pointercancel`, la
   mesure s'arrête, et le joueur souffle dans un micro qui n'écoute plus — sans rien pour le lui
   dire, puisque son doigt n'a pas quitté la carte. */
.card-check[data-check='blow'] { touch-action: none; }

/* Le repère de souffle : collé à la tranche du bas, puisque c'est là que se trouve le micro sur la
   quasi-totalité du parc. Il ne s'affiche que pendant le maintien — voir le gabarit pour ce qui est
   détectable et ce qui ne l'est pas.

   `position: fixed` et non dans le flux de la carte : ce qu'il désigne est le BORD DU TÉLÉPHONE, pas
   un endroit de la liste. Une flèche posée sur la carte pointerait vers la carte suivante. */
.blow-target {
    position: fixed;
    left: 0;
    right: 0;
    bottom: 0;
    z-index: 80;
    margin: 0;
    padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
    text-align: center;
    font-family: var(--font-mono);
    font-size: 0.8rem;
    color: var(--gold-pale);
    background: linear-gradient(to top, rgba(232, 206, 122, 0.22), rgba(0, 0, 0, 0));
    /* Aucun geste ne doit lui arriver : il apparaît PENDANT un maintien, et intercepter un
       `pointerup` ici laisserait la mesure tourner après le relâchement, micro ouvert. */
    pointer-events: none;
}
/* ⚠️ Règle de MÊME spécificité que `.stage--reveal > :not(.reveal)`, et elle est indispensable :
   cette règle-là impose `position: relative; z-index: 1` à tout enfant direct de la salle d'attente,
   et elle pèse (0,2,0) contre (0,1,0) pour `.blow-target`. Le `position: fixed` ci-dessus n'était
   donc jamais appliqué — mesuré, `getComputedStyle().position` rendait `relative`. Le repère
   retombait dans le flux, en bas du contenu de la liste, au lieu d'être collé au bord du téléphone
   — or c'est ce bord qu'il désigne, puisque c'est là qu'est le micro. Un repère qui pointe le
   mauvais endroit est pire qu'aucun repère.

   Même spécificité et non `!important` : celle-ci l'emporte par l'ordre, ce qui reste lisible et
   surchargeable. Gardée par `screens.spec.ts`, sur le style CALCULÉ. */
.stage--reveal > .blow-target { position: fixed; z-index: 80; }

.blow-target__arrow { display: block; font-size: 1.1rem; line-height: 1; }

/* Les appuis du joueur. DISCRETS : le bouton doit rester la chose la plus lumineuse de l'écran, et
   un chiffre demande d'être lu — donc de quitter le bouton des yeux. Mono et tabulaire, sinon il
   tremble en montant à dix appuis par seconde. */
.own-taps {
    margin: 10px 0 0;
    text-align: center;
    font-size: 0.8rem;
    color: var(--muted);
}
.own-taps strong {
    font-family: var(--font-mono);
    font-size: 1rem;
    color: var(--ink);
    font-variant-numeric: tabular-nums;
}

/*
 * ═══ A6 · LE MÉTRONOME, la peau de rame du panneau joueur ═══
 *
 * ⚠️ Le libellé est DÉPLACÉ, pas superposé. `.tap-button__body` est centré à 50 % du panneau, et la
 * peau de rame met une illustration au milieu de ce même panneau — laissé tel quel, le pendule
 * passait en travers de « RAME ! ». La géométrie du panneau, elle, ne bouge pas : hauteur fixe,
 * ancré en bas, ce que les gardes e2e mesurent au pixel.
 */
[data-gesture='row'] .tap-button__body { top: 62%; }

/* Les vocabulaires cohabitent dans la page ; un seul se montre. `display` et non `visibility` :
   un libellé caché qui prendrait sa place laisserait un blanc dans la colonne.

   ⚠️ Écrit en `:not()` et non en paires, et le changement s'est imposé au troisième geste : la
   forme « chaque geste × chaque autre geste » demandait deux sélecteurs pour deux gestes, six pour
   trois, douze pour quatre — la Ola arrive. Cette forme en demande un par geste.

   Ce qu'elle évite surtout : l'autre factorisation, « tout masquer puis remontrer le bon », aurait
   dû REPOSER un `display` sur l'élément montré, donc deviner s'il est `inline` ou `block`. Ici
   l'élément visible n'est jamais touché, et garde le `display` de sa propre classe. */
[data-gesture='tap'] [data-gesture-only]:not([data-gesture-only='tap']),
[data-gesture='row'] [data-gesture-only]:not([data-gesture-only='row']),
[data-gesture='blow'] [data-gesture-only]:not([data-gesture-only='blow']),
[data-gesture='ola'] [data-gesture-only]:not([data-gesture-only='ola']) { display: none; }

/* La barre de cadence cède la place au pendule : une barre qui se remplit dit « combien », et une
   manche de rame parle de rythme. Elle collait par ailleurs à l'éclaboussure, en bas du panneau. */
[data-gesture='row'] .tap-button__cadence,
[data-gesture='row'] .tap-button__cadence-label { display: none; }

/* ⚠️ Sur la ola, le titre était écrit DEUX FOIS, mot pour mot, à douze pixels l'un de l'autre : le
   bandeau de manche, et le surtitre de la carte de ola. C'est le SURTITRE qui part.

   L'inverse a été essayé et c'était le mauvais sens : le bandeau est le porteur du nom de manche sur
   les quatre manches, il est au même endroit à chaque fois, et c'est lui que les gardes lisent. Le
   masquer sur la ola aurait fait de cette manche la seule où le nom vient d'ailleurs — une exception
   à retenir, pour économiser un doublon. Un seul porteur, toujours le même, vaut mieux. */
[data-gesture='ola'] .ola-round__eyebrow { display: none; }

.tap-button__metronome,
.tap-button__splash { display: none; }

/* Capteur indisponible : PAS de métronome mort. Un pendule immobile à côté d'un message
   d'indisponibilité dit deux choses contradictoires, et le joueur essaie quand même. Le verdict
   textuel part avec — il n'a plus rien à commenter. Sélecteur plus spécifique que celui qui les
   affiche, sinon la règle est écrasée. */
/* ⚠️ LES DEUX ÉTATS, et l'oubli du second serait exactement le défaut que cette règle existe pour
   empêcher. `denied` a été séparé d'`unavailable` (#36) pour que le panneau puisse proposer de
   demander la permission ; sans ce doublon, le métronome mort réapparaîtrait dans ce cas précis —
   un appareil de mesure immobile à côté d'un panneau qui dit « autorise le mouvement ». */
[data-sensor='unavailable'][data-gesture='row'] .tap-button__metronome,
[data-sensor='unavailable'][data-gesture='row'] .tap-button__splash,
[data-sensor='unavailable'][data-gesture='row'] .cadence-verdict,
[data-sensor='denied'][data-gesture='row'] .tap-button__metronome,
[data-sensor='denied'][data-gesture='row'] .tap-button__splash,
[data-sensor='denied'][data-gesture='row'] .cadence-verdict { display: none; }

[data-gesture='row'] .tap-button__metronome {
    position: absolute;
    top: 1%;
    left: 0;
    right: 0;
    display: block;
    /* ⚠️ LA BOÎTE FAIT EXACTEMENT LA LONGUEUR DE LA COQUE, et c'est le libellé qui décide de sa
       taille, pas l'inverse. La valeur précédente — 5,5 rem — était la longueur du BRAS du
       pendule, dont tout dépendait alors.

       Mesuré sur le panneau réel de 254 px : la coque va de 3 à 103 px, `RAME !` commence à 114.
       Le premier essai, à 8 rem, la faisait passer EN TRAVERS du libellé — exactement le défaut
       que l'en-tête de ce bloc documente depuis le pendule. Agrandir la barque impose donc de
       remesurer, jamais de pousser le libellé : c'est lui qui porte l'ordre. */
    height: 6.25rem;
    pointer-events: none;
}

/* La barque, vue de dessus.

   ⚠️ ROTATION EN CSS D'UN DESSIN LARGE, et non un second SVG en portrait : `_boat.html.twig` est
   partagé avec le mur, donc le téléphone et la projection ne peuvent pas diverger. Un dessin
   dédié au panneau serait la même dette que les 418 lignes que #36 vient de retirer du banc.

   `-90deg` et non `90deg` : la proue doit pointer vers le HAUT du panneau, c'est-à-dire vers le
   sens de la course affichée juste au-dessus. Inversée, la barque recule. */
.tap-button__boat {
    position: absolute;
    top: 50%;
    left: 50%;
    display: block;
    /* 240 × 100 dans le viewBox : le rapport est tenu ici, sinon la rotation déforme la coque.

       ⚠️ 9 REM ET NON 7, ET C'EST UNE MESURE. `_boat.html.twig` est une vue de PROFIL — une coque
       en croissant, pointue aux deux bouts, qui n'occupe que la moitié basse de son viewBox. Une
       vue de profil pivotée ne DEVIENT PAS une vue de dessus : à 7 rem, elle se lisait comme un
       éclat vertical de trois millimètres de large, pas comme une barque. Ce qui la sauve est
       qu'un croissant pointu aux deux bouts est aussi la silhouette d'un canoë vu d'en haut — à
       condition de lui donner assez de corps : 8 rem de long, contre 7 au premier essai. La coque doit rester plus LARGE que la pagaie n'est
       épaisse, sinon la pagaie la mange. */
    width: 6.25rem;
    height: 1.41rem;
    margin: -0.705rem 0 0 -3.125rem;
    transform: rotate(-90deg);
}
.tap-button__boat .boat { display: block; width: 100%; height: 100%; overflow: visible; }

/*
 * La pagaie : deux positions, jamais un balayage. Elle change de bord à chaque coup, et la DURÉE
 * de la transition suit le dernier intervalle mesuré — un rameur régulier obtient un battement
 * régulier, un hésitant obtient un boitement. C'est ce qui en fait un métronome sur lequel se
 * caler et non une jauge à remplir. Cette propriété est la seule qui compte ici, et elle a
 * survécu intacte à trois dessins successifs.
 *
 * ⚠️ TROISIÈME FORME DE CET ÉLÉMENT, et l'historique mérite d'être lu avant d'y toucher.
 *
 *   1. Une translation le long d'un rail horizontal. Juste, mais muette : rien ne disait qu'on
 *      ramait.
 *   2. Un pendule pivotant, forme du prototype, reprise sur arbitrage (#36). Il avait fallu le
 *      rendre tenable — le disque du prototype tournait sur lui-même, soit 9,4 px de course pour
 *      38 px de diamètre — en faisant pivoter un BRAS de 5,5 rem, ce qui donnait 54 px.
 *   3. Celle-ci : la barque du mur, vue de dessus, traversée par une pagaie. Le pendule mesurait
 *      juste mais parlait d'autre chose que l'écran — la manche s'appelle « la Barque ».
 *
 * ⚠️ ELLE PIVOTE ET TRAVERSE, et il faut les deux. Une palette symétrique qui ne fait que pivoter
 * de ±18° ne se voit presque pas : les deux pales bougent de la même façon, donc l'œil ne lit
 * aucun bord privilégié. Le déport latéral est ce qui donne « la pale plonge à gauche, puis à
 * droite ». Il vient de `--swing-side`, écrit par le contrôleur, parce que la CSS ne sait pas
 * tirer des pixels d'un angle.
 *
 * Course de la pale : 2 × (3,25 rem × sin 18° + 0,7 rem) ≈ 2 × (16,1 + 11,2) ≈ 55 px, soit
 * l'équivalent du pendule qu'elle remplace, sur une largeur de panneau identique.
 */
.tap-button__paddle {
    position: absolute;
    top: 50%;
    left: 50%;
    width: 6.5rem;
    height: 0.3rem;
    margin: -0.15rem 0 0 -3.25rem;
    border-radius: 0.15rem;
    background: var(--paper-gold);
    transform-origin: 50% 50%;
    transform: translateX(calc(var(--swing-side, 0) * 0.7rem)) rotate(var(--swing-angle, 0deg));
    transition: transform var(--swing-ms, 400ms) ease-in-out;
}
/* Les deux pales, en pseudo-éléments pour que le manche reste une pure géométrie : un seul
   élément à faire bouger, et rien à garder d'accord. */
.tap-button__paddle::before,
.tap-button__paddle::after {
    content: '';
    position: absolute;
    top: 50%;
    width: 1.1rem;
    height: 0.85rem;
    margin-top: -0.425rem;
    border-radius: 50% / 60%;
    background: var(--paper-gold);
    box-shadow: 0 1px 3px rgba(22, 35, 61, 0.35);
}
.tap-button__paddle::before { left: -0.55rem; }
.tap-button__paddle::after  { right: -0.55rem; }

/* L'éclaboussure est un ÉCLAT et non une accumulation : elle saute au coup et retombe dans la
   fraction de seconde. Une barre qui resterait haute serait un second score, à côté de celui que
   la mini-course affiche déjà. Sa durée est bornée par l'intervalle mesuré — voir `cadence.js`. */
[data-gesture='row'] .tap-button__splash {
    position: absolute;
    right: 25%;
    bottom: 18px;
    left: 25%;
    display: block;
    height: 0.5rem;
    overflow: hidden;
    background: rgba(255, 255, 255, 0.08);
    border-radius: 0.25rem;
    pointer-events: none;
}
.tap-button__splash span {
    display: block;
    width: 0;
    height: 100%;
    background: linear-gradient(90deg, #1F6DB0, #7FE8FF);
    transition: width var(--splash-ms, 180ms) linear;
}

/* Le verdict, en mots. Italique display comme la phrase de tête : c'est un commentaire sur le jeu
   et non un chiffre — et la différence typographique est ce qui l'empêche d'être lu comme un. */
.cadence-verdict {
    margin: 10px 0 0;
    min-height: 1.2em;
    text-align: center;
    font-family: var(--font-display);
    font-style: italic;
    font-size: 0.95rem;
    color: var(--gold-light);
}
[data-gesture='tap'] .cadence-verdict { display: none; }
/* Le verdict de cadence n'a rien à commenter en manche de souffle : il compte des intervalles
   entre deux gestes discrets, et un souffle n'en a pas. */
[data-gesture='blow'] .cadence-verdict { display: none; }

/*
 * ═══ A7 · LE VU-MÈTRE, la peau de souffle du panneau joueur ═══
 *
 * ⚠️ Le libellé est DÉPLACÉ, pas superposé, et c'est le même défaut qu'en A6 — corrigé une fois sur
 * la surface de démonstration et facile à réintroduire. `.tap-button__body` est centré à 50 % du
 * panneau, et cette peau met une montgolfière au milieu de ce même panneau : laissée telle quelle,
 * la montgolfière passait en travers de « SOUFFLE ! ». Chaque élément présent et correct, écran
 * illisible. Ça ne se voit que sur une capture à 390 × 844.
 *
 * La géométrie du panneau ne bouge pas — hauteur fixe, ancré en bas — ce que les gardes mesurent
 * au pixel. Seul le placement du contenu à l'intérieur change.
 */
/* ⚠️ LE PANNEAU EST PLUS HAUT EN MANCHE DE SOUFFLE, et c'est la seule peau qui touche à sa
   géométrie. Il faut donc dire pourquoi c'est permis ici et nulle part ailleurs.
   
   La hauteur fixe et l'ancrage en bas existent pour deux raisons, et aucune ne tient sur cette
   manche. La première est la garde E2E-006 : elle compare le bouton d'essai au bouton de jeu, et
   elle le fait sur une manche de TAP — `AdminPage::reset()` force le tap — donc elle ne mesure
   jamais cette peau. La seconde est la portée du pouce, et en manche de souffle le bouton
   n'agit pas : il porte `aria-disabled="true"`, personne ne tape dessus, et rien ne doit tomber
   sous le pouce.
   
   Ce qui reste, c'est le contenu : cette peau met QUATRE choses dans le panneau — le libellé,
   l'indication, le vu-mètre et la montgolfière — contre deux pour le tap, plus l'altitude en pied.
   À 34vh elles tenaient toutes, serrées, avec de la place perdue au-dessus. L'ancrage en bas ne
   change pas ; c'est le haut du panneau qui remonte. */
[data-gesture='blow'] .tap-button {
    /* ⚠️ 50vh, et le chiffre est BORNÉ PAR LE CAS DÉGRADÉ, pas par le cas nominal. Mesuré sur le
       profil petit écran (360 × 640) : à 58vh le panneau montait derrière le compteur d'unités. Le
       cas qui contraint est celui où le micro est refusé — le bandeau du capteur pousse alors le
       compteur 75 px plus bas — et c'est justement celui qu'on ne regarde pas en réglant la mise en
       page. À 50vh il reste une quarantaine de pixels sous le compteur sur ce profil, et une bonne
       marge sur un écran de 844.

       Et `min-height` n'est PAS redéclaré, délibérément : le plancher de 240 px de la règle de base
       continue de s'appliquer, donc sur un écran très court `50vh` retombe sous lui et le panneau
       garde exactement sa taille d'aujourd'hui. L'agrandissement se désarme tout seul là où il n'y a
       pas la place, au lieu de dépendre d'une hauteur d'écran qu'on aurait devinée. */
    height: 50vh;
}

[data-gesture='blow'] .tap-button__body { top: 14%; }

/* La barre de cadence cède la place au vu-mètre, même raison que le pendule : une barre qui se
   remplit dit « combien », et cette manche parle d'un niveau tenu. */
[data-gesture='blow'] .tap-button__cadence,
[data-gesture='blow'] .tap-button__cadence-label { display: none; }

.blow-round { display: none; }

[data-gesture='blow'] .blow-round {
    position: absolute;
    inset: 0;
    display: block;
    pointer-events: none;
}

/* ⚠️ Capteur indisponible : PAS de vu-mètre mort. Même règle qu'en A6 et même raison, en plus
   littérale ici — un APPAREIL DE MESURE immobile à côté d'un message d'indisponibilité dit deux
   choses contradictoires, et le joueur souffle quand même. Sélecteur plus spécifique que celui qui
   l'affiche, sinon la règle est écrasée en silence.

   ⚠️ LE VU-MÈTRE SEUL, et pas tout le panneau. Le premier jet retirait `.blow-round` en entier, ce
   qui emportait la montgolfière et l'ALTITUDE — or l'altitude n'est pas une mesure de ce téléphone,
   c'est le score de l'équipe : elle continue de monter grâce aux autres, et la retirer dirait à
   quelqu'un dont le micro est refusé que la manche ne le concerne plus. Il perd sa contribution, pas
   son écran, exactement comme en manche de rame. Une montgolfière immobile n'est pas un appareil de
   mesure ; le vu-mètre, si. */
[data-sensor='unavailable'][data-gesture='blow'] .blow-round__meter,
[data-sensor='denied'][data-gesture='blow'] .blow-round__meter { display: none; }

/* Le vu-mètre, VERTICAL et collé à gauche : le geste est vers le bas du téléphone, donc une jauge
   verticale se lit dans le même axe que l'effort. À gauche pour ne pas croiser la montgolfière, qui
   occupe le centre. */
[data-gesture='blow'] .blow-round__meter {
    position: absolute;
    bottom: 14%;
    left: 8%;
    display: block;
    width: 1.5rem;
    height: 56%;
    overflow: hidden;
    background: rgba(22, 35, 61, 0.12);
    border-radius: 0.75rem;
}

/* ⚠️ La hauteur suit `--blow-level`, écrit par le contrôleur depuis le niveau MESURÉ. Jamais depuis
   le seuil : c'est le défaut qui a rendu toute une surface muette au souffle, dans TOUTES les
   circonstances — une mécanique rendait son propre seuil sous le nom de « niveau », et rien ne
   bougeait. La transition est courte, juste de quoi lisser la frame. */
.blow-round__meter-fill {
    position: absolute;
    right: 0;
    bottom: 0;
    left: 0;
    height: var(--blow-level, 0%);
    /* ⚠️ ENCRE D'ÉQUIPE, et non plus un bleu en dur. Le `#1F6DB0 → #7FE8FF` d'origine était la
       teinte de Volta écrite en clair : un joueur d'Edison avait donc un vu-mètre bleu au milieu
       d'un écran chaud. Le repli reste le bleu, qui est la valeur historique.
       Le dégradé part de l'encre et non de la teinte vive : la jauge est posée SUR le parchemin,
       où `--volta` plafonne à 2,0:1. */
    background: linear-gradient(0deg, var(--team-ink, var(--volta-ink)), var(--team-solid, var(--volta)));
    transition: height 90ms linear;
}

/* Le repère du seuil, posé par `--blow-threshold` — LÀ OÙ `BlowHold` décide réellement. Le
   prototype le mettait à 62 % quand sa logique basculait à 55, et un repère qui ne veut pas dire le
   seuil enseigne le mauvais souffle. C'est le geste dont le seuil est le plus incertain (#19), donc
   celui où l'écart coûte le plus cher. */
.blow-round__meter-mark {
    position: absolute;
    right: 0;
    bottom: var(--blow-threshold, 50%);
    left: 0;
    height: 1px;
    background: rgba(22, 35, 61, 0.55);
}

/* La montgolfière : elle MONTE avec le souffle et redescend aussitôt. La montée est un RETOUR, pas
   une progression — la progression est l'altitude en dessous, qui ne redescend jamais. */
/* ⚠️ Dimensionnée par la HAUTEUR et centrée en flex, jamais par des marges latérales en pourcentage.
   C'est la correction d'un défaut vu en capture, et le même que celui des couloirs à l'envers : une
   largeur en pourcentage donne au dessin une hauteur proportionnelle à la LARGEUR DE L'ÉCRAN, donc la
   montgolfière traversait « SOUFFLE ! » sur un téléphone large et rétrécissait sur un étroit. Le
   panneau, lui, a une hauteur fixe — c'est elle qui doit commander. */
[data-gesture='blow'] .blow-round__balloon {
    position: absolute;
    right: 0;
    bottom: 10%;
    left: 0;
    display: flex;
    justify-content: center;
    height: 54%;
    transform: translateY(var(--blow-lift, 0));
    transition: transform 0.3s ease-out;
}
.blow-round__balloon .balloon { display: block; width: auto; height: 100%; overflow: visible; }
.blow-round__balloon .balloon__flame { opacity: var(--blow-lit, 0); }
.blow-round__balloon .balloon__halo { opacity: calc(var(--blow-lit, 0) * 0.6); }

/* La lecture CUMULATIVE, en mètres : c'est le score dans une autre unité, lu depuis le même score
   diffusé dont les couloirs sont dérivés, jamais recompté ici. Chiffres tabulaires — un nombre qui
   décalerait ses propres chiffres en montant se lirait comme du scintillement. */
[data-gesture='blow'] .blow-round__altitude {
    position: absolute;
    right: 0;
    bottom: 6%;
    left: 0;
    display: block;
    font-family: var(--font-mono, monospace);
    font-variant-numeric: tabular-nums;
    font-size: 0.95rem;
    color: var(--paper-ink);
    text-align: center;
}

/*
 * ═══ A7 · MARQUER DOIT ÊTRE VISIBLE ═══
 *
 * ⚠️ L'état qui MANQUAIT, et son absence se lisait comme un micro cassé. Le vu-mètre montait et la
 * montgolfière se soulevait à n'importe quel niveau, donc rien à l'écran ne distinguait « tu souffles
 * sous la ligne » de « tu marques ». Un effort sans retour est indiscernable d'un appareil qui ne
 * fonctionne pas — c'est la conclusion qu'un téléphone réel a tirée.
 *
 * Trois signaux simultanés, et la redondance est le sujet : la salle est sombre, le téléphone au bout
 * du bras, la personne occupée à souffler. Il faut qu'un des trois arrive.
 */

/* 1. Le vu-mètre passe au DORÉ — la couleur que le jeu réserve à ce qui est validé, donc rien à
      apprendre. */
/* ⚠️ Sur le parchemin, « doré » ne peut plus être `--gold-pale` : à 1,9:1 sur du crème, la jauge
   validée serait MOINS visible que la jauge ordinaire, ce qui inverse exactement le signal. C'est
   `--paper-gold`, l'or assombri, qui joue ce rôle ici — et la lueur portée disparaît, sans effet
   sur du clair. Le signal reste un changement de couleur franc, du bleu-nuit vers l'or. */
[data-blowing] .blow-round__meter-fill {
    background: linear-gradient(0deg, var(--paper-gold), var(--gold));
}

/* 2. Le repère lui-même s'allume : ce que le souffle vient de franchir doit être ce qui réagit. */
[data-blowing] .blow-round__meter-mark {
    height: 2px;
    background: var(--paper-gold);
}

/* 3. La montgolfière RESPIRE, et l'altitude s'éclaire. La pulsation est LENTE — un clignotement
      rapproché sur un écran tenu près des yeux dans une salle sombre est un stroboscope, et c'est
      la raison pour laquelle le halo du bouton de tap est contenu lui aussi. */
[data-blowing] .blow-round__balloon {
    animation: blowRise 1.6s ease-in-out infinite;
    filter: drop-shadow(0 0 18px rgb(255 158 61 / 55%));
}

[data-blowing] .blow-round__altitude {
    color: var(--gold-light);
    text-shadow: 0 0 12px rgb(232 206 122 / 60%);
}

@keyframes blowRise {
    0%, 100% { transform: translateY(var(--blow-lift, 0)) scale(1); }
    50%      { transform: translateY(calc(var(--blow-lift, 0) - 4px)) scale(1.03); }
}

/*
 * ═══ A7 · LA COMPARAISON INTER-ÉQUIPES SE RETIRE ═══
 *
 * Arbitrage nº 1 de la story, et il a un coût assumé : en manche 3 le joueur ne saura plus s'il
 * mène. C'est la seule des trois manches où l'effort n'a pas de repère collectif sur le téléphone.
 * L'écran projeté, lui, garde ses deux couloirs.
 *
 * ⚠️ Tenu par la CSS et JAMAIS par un retrait en JavaScript. Les cibles Stimulus — `tugVolta`,
 * `miniRunner*`, `leadLabel` — continuent d'être peintes, donc le snapshot reste idempotent et une
 * reconnexion ne peut pas rendre un écran à moitié monté. Corollaire, déjà écrit plus haut pour la
 * peau des couloirs : le contrôleur ne décide pas de l'habillage.
 *
 * ⚠️ PORTÉE À `.stage--play`, et ce n'est pas un excès de prudence. `.minirace` et `.lead-label`
 * existent AUSSI dans l'étape de répétition du lobby (`.minirace--rehearsal`, le titre « Essaie le
 * geste du jeu »), et `data-gesture` porte déjà le geste de la manche à venir pendant que le lobby
 * est affiché. Sans cette portée, arriver dans le lobby avant la manche 3 vidait l'écran de
 * répétition — l'écran que tout le monde voit en premier.
 */
[data-gesture='blow'] .stage--play .minirace,
[data-gesture='blow'] .stage--play .lead-label { display: none; }

/* L'indisponibilité du capteur, et sa raison. Discrète mais lisible : le joueur perd sa
   contribution, pas son écran — la mini-course, le chrono et le score restent vivants au-dessus. */
.sensor-note {
    margin: 0 auto 10px;
    max-width: 460px;
    text-align: center;
    font-size: 0.82rem;
    color: var(--muted);
}
/*
 * ⚠️ PORTÉE À `.sensor-note` ET NON À `.btn--sensor` SEUL, et sans cette portée le bouton est
 * INVISIBLE. Défaut trouvé en revue UX, en relevant les styles calculés sur la page servie et non
 * en relisant le fichier.
 *
 * `.btn`, huit cents lignes plus bas, est la base des boutons de la CONSOLE ANIMATEUR, qui est du
 * parchemin : elle déclare `color: var(--paper-ink)` et `border: 1px solid transparent`. Même
 * spécificité que `.btn--sensor`, déclarée plus tard, donc elle gagne — et ce bouton-ci est le seul
 * `.btn` du jeu posé sur du NOIR. Résultat mesuré : bleu nuit sur `rgba(0,0,0,.35)`, sans bordure,
 * soit environ 1,2:1. Dans une salle éclairée par un projecteur, il n'existe pas.
 *
 * Ce que ça coûtait : tout le chemin de secours des manches 2 et 3 — quota de deux refus par geste,
 * non-réentrance, distinction entre refusé et indisponible — aboutit à ce bouton. C'est le seul
 * geste qui remet un joueur dans l'épreuve, et personne ne pouvait le voir.
 *
 * Deux sorties possibles, et c'est la portée qui a été retenue plutôt que de déplacer la règle après
 * `.btn` : elle garde cette déclaration à côté de `.sensor-note`, dont elle est le bouton, au lieu de
 * l'exiler au milieu des styles de la console pour une raison d'ordre que rien ne rappellerait.
 * Ne pas la « simplifier » en retirant `.sensor-note` — la collision reviendrait, silencieuse.
 */
.sensor-note .btn--sensor {
    display: inline-block;
    margin-top: 8px;
    padding: 8px 16px;
    background: rgba(0, 0, 0, 0.35);
    border: 1px solid var(--gold);
    border-radius: 8px;
    color: var(--gold-light);
    font-family: var(--font-ui);
    font-size: 0.85rem;
    /* 44 px de haut, plancher de cible tactile. Même revue : c'était la plus petite cible de
       l'écran (environ 34 px), tendue à quelqu'un debout, dans le noir, qui vient d'échouer. */
    min-height: 44px;
}

/* --- Décompte --- */
/* ⚠️ LE CHIFFRE A ÉTÉ RÉDUIT quand cet écran a cessé d'être vide. Il pouvait prendre 14 rem et 40vw
   tant qu'il était seul ; depuis qu'il est encadré par le nom de l'épreuve et par la consigne du
   geste, cette taille poussait la consigne hors du pli sur un petit écran — et c'est la consigne qui
   est l'information neuve, pas le chiffre, qui pulse déjà pour se faire voir. */
.countdown {
    font-family: var(--font-display);
    font-size: clamp(4.5rem, 26vw, 9rem);
    font-weight: 900;
    line-height: 1;
    font-variant-numeric: tabular-nums;
    animation: pop 1s ease infinite;
}
.countdown--sm { font-size: clamp(2.5rem, 12vw, 4rem); animation: none; }

/* Ce qui encadre le chiffre : l'épreuve qui s'ouvre, et le geste qu'elle demande.
   ⚠️ PAS la classe `.eyebrow` pour le nom de l'épreuve, bien qu'il en ait la forme : `.eyebrow` est
   posée en `--muted-faint`, ce qui convient à un surtitre redondant avec le grand texte qu'il
   surmonte. Ici le nom de l'épreuve n'est redondant avec rien — c'est la seule fois qu'il est dit
   avant la manche — donc il prend l'encre pleine.
   Le geste, lui, est la consigne : plus grand que le nom, et c'est voulu. Le joueur qui n'a que deux
   secondes d'attention doit repartir avec le verbe, pas avec le numéro. */
.countdown-round {
    margin: 0;
    font-family: var(--font-mono);
    font-size: 0.82rem;
    letter-spacing: 0.16em;
    text-transform: uppercase;
    color: var(--ink);
    text-align: center;
}
.countdown-hint {
    margin: 0;
    max-width: 22ch;
    font-family: var(--font-display);
    font-size: clamp(1.15rem, 5.5vw, 1.5rem);
    font-weight: 700;
    line-height: 1.2;
    text-align: center;
    text-wrap: balance;
    color: var(--ink);
}
@keyframes pop { 0%, 100% { transform: scale(1); opacity: 1; } 50% { transform: scale(1.12); opacity: 0.7; } }

/* La ligne qui renvoie au mur pendant le crash. Discrète : le terminal reste le sujet, et cette
   phrase n'a qu'à exister — voir `join.html.twig`, qui porte le raisonnement. */
.crash-look {
    margin: 1.2rem 0 0;
    font-family: var(--font-mono);
    font-size: 0.82rem;
    letter-spacing: 0.16em;
    text-transform: uppercase;
    color: var(--muted);
    text-align: center;
}

.dots { display: flex; gap: 10px; }
.dots span {
    width: 12px; height: 12px; border-radius: 50%;
    background: var(--ink); opacity: 0.4;
    animation: blink 1.2s ease infinite;
}
.dots span:nth-child(2) { animation-delay: 0.2s; }
.dots span:nth-child(3) { animation-delay: 0.4s; }
@keyframes blink { 0%, 100% { opacity: 0.25; } 50% { opacity: 1; } }

/* --- Chrono de jeu --- */
/* Bandeau de manche : le nom de l'épreuve, dans un cadre d'or, au-dessus du chrono.
   Mono capitales espacées, comme tout ce qui NOMME sans se lire comme une phrase.

   Il est posé sur le fond sombre et non sur du parchemin : c'est une étiquette d'affiche, pas
   un panneau de contenu, et un troisième bloc crème au-dessus des deux autres écraserait la
   hiérarchie que le parchemin vient d'établir.

   `min-height` et non une hauteur libre : les quatre libellés n'ont pas la même longueur — « la
   Montgolfière » passe à la ligne là où « la Ola » tient — et sans plancher le bandeau changerait
   de hauteur entre deux manches, ce qui déplacerait tout ce qui est en dessous. Le bouton de tap
   est ancré en bas et n'en dépend pas, mais la course, elle, remonterait. */
.round-band {
    width: 100%;
    max-width: 460px;
    margin: 0 0 var(--sp-4);
    padding: var(--sp-3) var(--sp-6);
    min-height: 2.4rem;
    display: flex;
    align-items: center;
    justify-content: center;
    text-align: center;
    border: 1px solid var(--gold);
    border-radius: var(--radius-control);
    background: rgba(22, 35, 61, 0.45);
}
.round-band__name {
    font-family: var(--font-mono);
    font-size: 0.72rem;
    letter-spacing: var(--track-label);
    text-transform: uppercase;
    color: var(--gold-light);
}

.timerline { width: 100%; max-width: 460px; }
.timer {
    font-family: var(--font-mono);
    font-size: 1.6rem;
    text-align: center;
    font-variant-numeric: tabular-nums;
    color: var(--muted);
}
/* Mono pour le chiffre qui défile, et ce n'est pas décoratif : en proportionnelle, la largeur
   change à chaque dixième et le chiffre tremble. `tabular-nums` seul ne suffit pas sur toutes
   les polices système du parc. */
.timer strong { font-size: 2rem; color: var(--ink); font-weight: 400; }
.timerbar {
    height: 8px; border-radius: 999px;
    background: rgba(0, 0, 0, 0.35);
    box-shadow: inset 0 0 0 1px var(--border);
    overflow: hidden; margin-bottom: 8px;
}
/* La barre de temps est en OR, pas en couleur d'équipe : le temps appartient à
   l'institution, il est le même pour les deux camps. La couleur d'équipe est réservée à ce
   que le joueur produit — son bouton, sa jauge, son score. */
.timerbar span {
    display: block; height: 100%; width: 100%;
    background: linear-gradient(90deg, var(--gold), var(--gold-light));
    transition: width 0.15s linear;
}

/* ⚠️ Sur l'écran de jeu et l'écran d'essai, la lueur d'équipe monte du BAS et non du haut, à
   l'inverse de l'écran d'attente. Ce n'est pas une incohérence : le bouton y est lui-même
   lumineux, donc la lumière doit venir de là où il est — sinon on obtient un bouton clair posé
   sur un bas d'écran noir, ce qui éteint la moitié de l'affiche. Constaté en capture, et c'est
   la raison pour laquelle notre première version paraissait plus terne que le prototype.

   La règle générale reste vraie ailleurs : ce qui ne doit pas être éclairé, c'est le fond
   DERRIÈRE une cible sombre. Ici la cible est claire. */
.stage--play {
    position: relative;
    isolation: isolate;
}
.stage--play::before {
    content: '';
    position: absolute;
    inset: -1vh -20px;
    z-index: -1;
    pointer-events: none;
}
.team-volta  .stage--play::before { background: radial-gradient(130% 60% at 50% 100%, rgba(127, 232, 255, 0.22) 0%, rgba(10, 15, 28, 0) 55%); }
.team-edison .stage--play::before { background: radial-gradient(130% 60% at 50% 100%, rgba(255, 199, 115, 0.22) 0%, rgba(18, 10, 6, 0) 55%); }

/* --- La course, sur le téléphone (écran A5 du prototype) --- */

/* Panneau encadré : c'est le cadre qui fait que la course est un OBJET sur l'écran, et non un
   décor de fond. Sans lui les éléphants flottent dans la page. */
.minirace {
    position: relative;
    width: 100%;
    max-width: 460px;
    /* Hauteurs resserrées après mesure : avec 27vh de course et 34vh de bouton, le tableau de
       scores sortait de l'écran sur un profil de 844 px. La course reste lisible à 21vh. */
    height: 26vh;
    min-height: 168px;
    border-radius: var(--radius-panel);
    border: 1px solid var(--paper-gold);
    /* Parchemin, comme le panneau de geste : la maquette montre la course sur du papier, avec
       les mascottes posées dessus comme des vignettes gravées. Le panneau ne suit plus la
       température de l'équipe — c'était la bonne réponse tant que le fond était sombre et que
       le panneau devait s'y fondre ; sur du crème, l'appartenance se lit aux couloirs, aux
       étiquettes et aux mascottes, qui la portent déjà trois fois. */
    background: var(--paper-surface);
    overflow: hidden;
}

/* ⚠️ CET ÉLÉMENT NE PORTE PLUS QUE L'EAU DE LA MANCHE 2 (#36). Il portait aussi le plancher de la
   manche 1, et c'est ce double rôle qui cachait le défaut : une bande unique en bas ne passe que
   sous le couloir DU BAS. Le plancher est donc parti aux couloirs, juste en dessous ; l'eau reste
   ici, parce qu'elle doit couvrir les deux couloirs d'un seul tenant et non par morceaux.
   Sans peau de rame, cet élément ne peint rien — c'est voulu. */
.minirace__ground {
    position: absolute;
    left: 0;
    right: 0;
    bottom: 0;
    /* Bande basse et non moitié du panneau : à 40 % elle avalait le couloir d'Edison, dont
       l'éléphant semblait marcher DANS le sol. */
    height: 22%;
    opacity: 0.7;
}

/* ⚠️ LE PLANCHER APPARTIENT AU COULOIR — même correction que sur le mur, même défaut. La bande
   unique ne passait que sous Edison : Volta marchait sur un simple filet, Edison sur une
   palissade qui défile. Deux coureurs de la même course sur deux sols différents.

   Les encres sont celles du parchemin : ce sont les MONTANTS qui sont encrés et le papier qui
   fait le vide entre eux, une palissade gravée plutôt qu'un sol peint. Les deux trames, leurs
   pas et la vitesse de dérive sont ceux qui avaient été réglés à l'œil.

   `z-index: -1` : le pseudo-élément peint après les enfants, donc sans lui il passerait
   par-dessus l'éléphant. */
[data-gesture='tap'] .minirace__lane::after {
    content: '';
    position: absolute;
    left: 0;
    right: 0;
    bottom: 0;
    height: 18%;
    z-index: -1;
    opacity: 0.7;
    pointer-events: none;
    background-image:
        repeating-linear-gradient(90deg, rgba(22, 35, 61, 0.14) 0 3px, transparent 3px 54px),
        repeating-linear-gradient(90deg, transparent 0 40px, rgba(22, 35, 61, 0.1) 40px 48px, transparent 48px 96px);
    background-size: 54px 100%, 96px 100%;
    animation: driftSlow 8s linear infinite;
}

/* Barre de rapport de force : UNE barre partagée, pas deux. L'écart se lit à la position de la
   frontière, sans comparer deux longueurs — ce qui compte pour un œil qui ne quitte pas le
   bouton. La part de Volta est écrite par le contrôleur, Edison prend le reste. */
.minirace__tug {
    position: absolute;
    left: 14px;
    right: 14px;
    top: 12px;
    height: 12px;
    border-radius: 6px;
    overflow: hidden;
    display: flex;
    border: 1px solid rgba(22, 35, 61, 0.18);
}
/* Les lueurs portées sont retirées et les dégradés partent de l'encre : sur du crème une
   ombre de 16 px ne rayonne pas, elle bave. La frontière entre les deux parts reste le seul
   point à lire, et le contraste des deux encres la donne franchement. */
.minirace__tug-volta {
    width: 50%;
    background: linear-gradient(90deg, var(--volta-ink), var(--volta));
    transition: width 1s linear;
}
.minirace__tug-edison {
    flex: 1;
    background: linear-gradient(90deg, var(--edison), var(--edison-ink));
}

/* ⚠️ `z-index: 0` ET NON `auto`, et ce n'est pas décoratif : il fait du couloir un CONTEXTE
   D'EMPILEMENT. Sans lui, le sol en `::after { z-index: -1 }` passe derrière le fond parchemin du
   panneau et devient invisible — constaté. Sur le mur le problème ne se pose pas, `.race__track`
   étant déjà un contexte par son propre `z-index: 1` ; ici rien ne jouait ce rôle. */
.minirace__lane { position: absolute; left: 8px; right: 26px; height: 34%; z-index: 0; }
.minirace__lane--volta  { top: 26px; }
.minirace__lane--edison { bottom: 6px; }

.minirace__lane-name {
    position: absolute;
    left: 4px;
    top: -2px;
    font-family: var(--font-mono);
    font-size: 0.56rem;
    letter-spacing: 0.2em;
    text-transform: uppercase;
}
/* Encres d'équipe : les teintes vives — `#7FE8FF`, `#FFC773` — étaient calibrées pour un fond
   noir et deviennent illisibles sur le papier, autour de 1,5:1. */
.minirace__lane--volta  .minirace__lane-name { color: var(--volta-ink); }
.minirace__lane--edison .minirace__lane-name { color: var(--edison-ink); }
/* « ton équipe » : la seule chose qui distingue le joueur de son voisin sur cet écran. En or
   assombri, parce que c'est une information et non une couleur d'équipe — et parce que l'or
   clair de l'institution ne tient pas 3:1 sur du crème. */
.minirace__lane-name em { font-style: normal; color: var(--paper-gold-ink); }

/* Même mécanique que sur l'écran projeté : `left` est écrit par le contrôleur, la transition
   dure la cadence de diffusion du snapshot. */
.minirace__runner {
    position: absolute;
    bottom: 0;
    left: 0;
    width: 30%;
    margin-left: -15%;
    transition: left 1s linear;
}
.minirace__runner .elephant { width: 100%; height: auto; overflow: visible; display: block; }

/* Qui mène, en une phrase. Bodoni italique : c'est du commentaire, pas une donnée — et la
   distinction typographique évite qu'on la lise comme un chiffre. */
.lead-label {
    margin: 0;
    font-family: var(--font-display);
    font-style: italic;
    font-size: 1rem;
    color: var(--gold-light);
}
.lead-label[data-lead="volta"]  { color: var(--volta-glow); }
.lead-label[data-lead="edison"] { color: var(--edison-glow); }

/* --- Le bouton d'appui : un PANNEAU, plus un disque ---

   Forme du prototype, et le gain est mesurable au pouce : le panneau occupe toute la largeur de
   l'écran au lieu d'un cercle de 74vw à viser. Debout, dans le noir, à bout de bras, c'est la
   surface tapable qui décide si l'appui part.

   ⚠️ L'écran d'essai partage cette classe et doit la partager : c'est la place et la forme du
   bouton que l'essai achète (C3), et E2E-006 mesure les deux. Modifier ce bloc sans regarder
   l'essai casse la seule chose que l'onboarding apporte. */
.tap-button {
    position: relative;
    /* ⚠️ Hauteur FIXE et ancrage en bas, et non `flex: 1`. C'est la correction d'un défaut que la
       garde a trouvé deux fois : tant que la boîte du bouton était le RESTE de la colonne, elle
       dépendait de la hauteur de ses frères — donc le retrait des compteurs de l'écran de jeu a
       suffi à décaler le bouton d'essai de 142 px sur iPhone et de 57 px sur petit écran, alors
       qu'il tombait juste sur Pixel 7.
       
       Avec une hauteur fixe et `margin-top: auto`, les deux surfaces obtiennent la MÊME boîte par
       construction, quoi qu'on mette au-dessus. Le couplage disparaît au lieu d'être documenté, et
       E2E-006 peut affirmer l'égalité exacte plutôt qu'une tolérance.
       
       Bénéfice connexe, qui n'est pas un hasard : ancré en bas, le bouton est là où se trouve le
       pouce d'une main qui tient un téléphone. */
    /* Position ABSOLUE et non plus une marge automatique. La marge n'absorbait l'espace que s'il
       en restait : en surcharge elle valait zéro et la position du bouton redevenait la somme des
       hauteurs de ses frères — qui diffèrent entre les deux surfaces, quatre enfants côté jeu
       contre six côté essai. Mesuré : environ 32 px de marge sur Galaxy S5, donc une ligne de plus
       dans un libellé suffisait à faire diverger les deux boutons. La garde étant passée à
       l'égalité au pixel, elle était plus stricte que la propriété n'était robuste.
       
       Ancré au bas de la surface, le bouton ne dépend plus de RIEN de ce qui l'entoure. C'est
       structurel au sens propre : il n'y a plus d'arithmétique à laquelle se fier. */
    /* ⚠️ Centré par `margin: 0 auto` entre `left: 0` et `right: 0`, JAMAIS par
       `transform: translateX(-50%)`.
       
       Le transform est la propriété que le retour d'appui utilise : `:active` applique un
       `scale`, ce qui ÉCRASE la translation de centrage. Le bouton perdait donc son centrage à
       chaque appui — un saut d'une demi-largeur vers la droite, puis un retour au relâchement,
       en continu pendant toute la manche. Sur la seule surface dont le but est que le bouton ne
       bouge pas, et invisible sur une capture puisque cela ne dure que le temps de l'appui.
       
       Toute règle qui touche `transform` sur ce bouton doit rester libre de le faire. */
    position: absolute;
    left: 0;
    right: 0;
    bottom: 0;
    margin: 0 auto;
    /* ⚠️ GÉOMÉTRIE INTOUCHÉE PAR LA REFONTE DE CHARTE. Les six déclarations qui suivent —
       position, ancrage, centrage, largeur, hauteur — sont ce que garde E2E-006, et les
       commentaires ci-dessus disent le prix qu'elles ont coûté. Le passage au parchemin ne
       change QUE la peinture. Si une retouche de style doit modifier une de ces valeurs,
       c'est que la retouche est mauvaise. */
    width: 100%;
    max-width: 460px;
    height: 34vh;
    min-height: 240px;
    border-radius: var(--radius-panel);
    border: 1px solid var(--paper-gold);

    /* ⚠️ RENVERSEMENT DE SURFACE. Le panneau était sombre et s'éteignait dans la lueur
       d'équipe ; il est désormais un PARCHEMIN, comme dans la maquette étendue et sur les
       seize captures de l'évènement, où le verbe du geste est écrit en grand serif bleu nuit
       sur du crème.

       Ce que défendait l'ancien commentaire — « un panneau qui s'éteint vers le noir est un
       trou dans l'affiche » — reste vrai, et le parchemin le sert mieux encore : ce n'est
       plus une lampe, c'est la seule chose éclairée de l'écran. L'appartenance d'équipe ne
       passe plus par le fond mais par le liseré et l'anneau, en encre d'équipe.

       Le dégradé va du crème le plus clair EN HAUT vers `--paper` en bas : c'est un papier
       éclairé par le haut, cohérent avec la lueur d'équipe de l'écran qui vient elle aussi
       d'en haut. */
    background: var(--paper-surface);
    overflow: hidden;
    cursor: pointer;
    padding: 0;
    touch-action: manipulation;
    -webkit-tap-highlight-color: transparent;
    font-family: var(--font-ui);
    color: var(--paper-ink);
}
/* Bouton inerte pendant la resynchronisation : l'écouteur est retiré côté contrôleur, et
   l'apparence doit le dire — sinon le joueur tape sur un bouton qui a l'air vivant. */
.tap-button:disabled { opacity: 0.4; }

/* L'appartenance sur le parchemin : un liseré d'encre d'équipe de 2 px, plus un halo de la
   teinte VIVE porté à l'extérieur du panneau, sur le fond sombre. C'est la répartition que
   permet le renversement de surface — la teinte vive est illisible SUR le crème (Volta y
   plafonne à 2,0:1) mais elle est parfaite AUTOUR, où elle retrouve son fond noir. */
.team-volta .tap-button,
.team-edison .tap-button {
    border-color: var(--team-ink);
    box-shadow: 0 0 0 2px var(--team-ink), 0 0 34px var(--team-glow);
}

/* ⚠️ L'ANNEAU EST RETIRÉ (#36), et c'est une correction d'usage plutôt que de style : il
   dessinait un CERCLE au milieu d'un panneau dont la surface tapable est le rectangle entier.
   Un contour au milieu d'une cible se lit comme la cible, donc il désignait la mauvaise. Sur le
   fond sombre d'origine il passait pour un halo de plus ; sur le parchemin, tracé net à l'encre
   d'équipe, il devient franchement une consigne — et une consigne fausse.

   Ce qu'il portait à l'appui est repris par les deux autres signaux, qui eux ne mentent pas sur
   la zone : le lavis couvre TOUT le panneau, et l'enfoncement le concerne en entier.

   Le halo, lui, reste — c'est un dégradé sans contour, donc il éclaire sans délimiter. */

/* ⚠️ LE RETOUR D'APPUI EST SOUSTRACTIF, et c'est la conséquence la moins évidente du
   renversement de surface. Sur fond sombre, l'appui s'annonçait en AJOUTANT de la lumière :
   un halo d'équipe qui s'allume. Sur du crème, ajouter de la lumière ne produit rien — le
   papier est déjà à 0,77 de luminance, il n'y a pas de marge au-dessus.

   L'appui ASSOMBRIT donc le papier au lieu de l'éclairer. Deux signaux concordants, tous deux
   vers le bas : le lavis d'encre d'équipe, et le panneau qui s'enfonce.

   Contenu dans le bouton, et jamais plein écran : à plus de deux appuis par seconde, un effet
   qui déborde produit un stroboscope dans une salle sombre. */
.tap-button__halo {
    position: absolute;
    left: 50%;
    top: 50%;
    width: 76vw;
    height: 76vw;
    margin: -38vw 0 0 -38vw;
    border-radius: 50%;
    opacity: 0;
    transition: opacity 0.2s ease;
    pointer-events: none;
}
/* Un lavis, pas une lueur : l'encre d'équipe posée en faible opacité SATURE le papier et en
   abaisse la luminance. C'est ce qui se voit sur du crème, là où un dégradé lumineux se perdrait
   dans le fond. Sans l'anneau, il est le seul signal de couleur — d'où l'opacité relevée. */
.team-volta .tap-button__halo { background: radial-gradient(circle, rgba(31, 109, 176, 0.34), transparent 70%); }
.team-edison .tap-button__halo { background: radial-gradient(circle, rgba(193, 97, 26, 0.32), transparent 70%); }
.tap-button.is-pressed .tap-button__halo { opacity: 1; }

.tap-button__body {
    position: absolute;
    left: 0;
    right: 0;
    top: 50%;
    transform: translateY(-50%);
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 8px;
    pointer-events: none;
}
.tap-button__label {
    font-family: var(--font-display);
    font-weight: 900;
    font-size: clamp(2.4rem, 12vw, 3.4rem);
    line-height: 1;
    letter-spacing: 0.02em;
    color: var(--paper-ink);
}
/* La lueur portée par le libellé est RETIRÉE : une ombre lumineuse derrière du texte sombre
   sur du crème ne fait que le voiler. Sur le papier, c'est la graisse du Didone qui porte le
   mot — 900, et un corps qui va jusqu'à 3,4 rem. Le rôle de l'ancienne lueur, dire l'équipe,
   est repris par le liseré et l'anneau. */
.tap-button__rule { width: 52px; height: 1px; background: var(--paper-gold); }
.tap-button__hint { font-size: 0.82rem; color: rgba(22, 35, 61, 0.62); }

/* ── L'indice de dépannage, quand la permission manque ────────────────────────
   Masqué par défaut, montré sur le seul état `denied` — c'est-à-dire exactement quand un appui
   déclenchera la demande. Sur les états définitifs (`unavailable`, pas de capteur, contexte non
   sécurisé) il reste caché : promettre une autorisation qu'aucun appui n'obtiendra serait pire que
   se taire, et c'est le bandeau au-dessus qui dit alors où aller dans les réglages.

   ⚠️ Il S'AJOUTE au verbe du geste, qui reste visible : le joueur perd sa contribution, pas son
   écran — voir le gabarit. Seul l'indice ORDINAIRE cède la place. */
.tap-button__hint--grant,
.tap-button__hint--settings { display: none; }
[data-sensor='denied'] .tap-button__hint--grant { display: block; }
[data-sensor='denied'] .tap-button__hint:not(.tap-button__hint--grant) { display: none; }

/* La consigne de secours prend la place de l'indice ordinaire quand la demande n'aboutira plus.
   Sur le PANNEAU et pas seulement dans le bandeau : c'est la surface que le joueur vient de
   toucher, donc celle qu'il regarde. */
[data-sensor='unavailable'] .tap-button__hint--settings { display: block; }
[data-sensor='unavailable'] .tap-button__hint:not(.tap-button__hint--settings) { display: none; }

/* La bonne phrase selon la plateforme, choisie par la CSS depuis `data-platform` — le contrôleur
   nomme, il n'habille pas, comme pour `data-gesture` et `data-sensor`.
   Les trois sont dans le DOM en permanence : une page servie avant que le contrôleur ne se
   connecte doit pouvoir en montrer une, et c'est le repli neutre qui joue ce rôle. */
.hint-line { display: none; }
.hint-line--other { display: inline; }
[data-platform='ios'] .hint-line--ios,
[data-platform='android'] .hint-line--android { display: inline; }
[data-platform='ios'] .hint-line--other,
[data-platform='android'] .hint-line--other { display: none; }
.tap-button__hint--settings strong { font-weight: 700; color: var(--paper-ink); }

/* Cadence : la vitesse d'appui, jamais un score — voir le contrôleur. Elle est au pied du
   bouton, donc dans le champ de vision du pouce et non en haut de l'écran. */
.tap-button__cadence {
    position: absolute;
    left: 34px;
    right: 34px;
    bottom: 22px;
    height: 8px;
    border-radius: 4px;
    background: rgba(22, 35, 61, 0.12);
    overflow: hidden;
    pointer-events: none;
}
.tap-button__cadence span { display: block; height: 100%; width: 0; transition: width 0.18s linear; }
/* Encre d'équipe, et l'ombre portée disparaît : sur du crème une lueur de 18 px ne se voit
   pas, elle salit le contour de la jauge. Le contraste suffit. */
.team-volta  .tap-button__cadence span { background: linear-gradient(90deg, var(--volta-ink), var(--volta-dark)); }
.team-edison .tap-button__cadence span { background: linear-gradient(90deg, var(--edison-ink), var(--edison)); }
.tap-button__cadence-label {
    position: absolute;
    left: 0;
    right: 0;
    bottom: 10px;
    text-align: center;
    font-family: var(--font-mono);
    font-size: 0.55rem;
    letter-spacing: 0.22em;
    text-transform: uppercase;
    /* PAS `--paper-muted` : à 1,4:1 sur le crème il est décoratif, et ceci est une étiquette
       qui se lit. L'encre atténuée tient 4,8:1. */
    color: rgba(22, 35, 61, 0.55);
    pointer-events: none;
}

.tap-button:active { transform: scale(0.995); }

.myscore { font-size: 1rem; color: var(--muted); margin: 0; }
.myscore strong { font-family: var(--font-mono); font-size: 1.3rem; color: var(--ink); font-variant-numeric: tabular-nums; }

/* --- Essai du geste ---

   L'écran d'essai est une COPIE de l'écran de jeu : même bouton, même place, même surface
   tapable, même squelette de six fentes. C'est la seule chose que la story achète réellement,
   donc la mise en page est PARTAGÉE avec `.stage--play` et jamais redéfinie ici. Ce bloc n'ajoute
   que ce que le jeu n'a pas : la jauge, les états de verdict et le lien de contournement. */

/* Fente 1, seconde ligne : le vide qui occupe la place du chiffre de chrono. Exprimé en rem
   comme le chiffre qu'il remplace — `.timer strong` fait 2rem — donc il suit la taille de police
   racine sur les trois profils au lieu d'épingler des pixels. */
.timer--void { min-height: 2.4rem; }

/* Fente 2 : le panneau prend la place de la course et contient la jauge, centrée. Même boîte que
   `.minirace`, dont il partage la classe pour que la géométrie ne puisse pas diverger. */
/* Le panneau d'essai est PLUS COURT que celui de la course, et c'est un arbitrage mesuré : à
   hauteur égale, le contenu au-dessus du bouton dépassait la place disponible sur iPhone 14 et sur
   Galaxy S5, et le bouton s'en trouvait décalé de 30 px — or c'est sa place qui est le tout de
   cette surface. Une jauge n'a pas besoin de la hauteur d'une course : il n'y a rien à y faire
   courir. La ressemblance des deux écrans se paie sur le panneau, jamais sur le bouton. */
.minirace--rehearsal {
    height: auto;
    min-height: 96px;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 0 18px;
}

/* Le pied de l'essai. Plus AUCUNE hauteur réservée : depuis que le bouton a une hauteur fixe et
   s'ancre en bas, sa boîte ne dépend plus de ce qui l'entoure, et réserver de la place ne sert
   donc plus qu'à faire déborder les petits écrans. */
.rehearsal__foot {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: flex-start;
    gap: 4px;
}

/* La jauge se remplit depuis zéro, sur le précédent du compteur de la page de diagnostic.
   ⚠️ Celui-ci code en dur une couleur d'équipe : ici il faut la paire, sur le modèle du bouton
   de jeu — le joueur doit se remplir de SA couleur. Pas de variable d'indirection d'équipe : le
   dépôt n'en a aucune, et l'introduire dépasse ce périmètre. */
.gauge { width: 100%; display: flex; }
.gauge--pips { gap: 4px; }
.gauge__pip {
    flex: 1;
    height: 14px;
    border-radius: 999px;
    /* Éteinte, la pastille doit RESTER VISIBLE en salle sombre à luminosité d'écran basse :
       c'est la jauge à vide qui dit l'objectif avant le premier appui. D'où un fond ET une
       bordure, et non une simple opacité — MAN-003 juge ce contraste sur téléphone réel. */
    background: rgba(0, 0, 0, 0.35);
    box-shadow: inset 0 0 0 1px rgba(232, 206, 122, 0.35);
    transition: background 80ms linear;
}
/* Au-delà de vingt, barre continue segmentée : trente pastilles distinctes ne tiennent pas sur
   une ligne. Décision de gabarit, prise au rendu — voir `join.html.twig`. */
.gauge--bar { gap: 1px; border-radius: 999px; overflow: hidden; }
.gauge--bar .gauge__pip { border-radius: 0; box-shadow: none; height: 16px; }
.team-volta .gauge__pip.is-lit { background: var(--volta); }
.team-edison .gauge__pip.is-lit { background: var(--edison); }

/* Après un échec, la jauge est FIGÉE au niveau atteint : aucune animation de retour à zéro. Elle
   ne se vide qu'au démarrage de la tentative suivante — voir le contrôleur. */

/* Verdict de réussite, dans la fente du commentaire de course. Le bouton disparaît avec la
   validation, jamais grisé — un bouton mort dans un jeu d'appui se lit comme une panne. La
   géométrie bouge alors, et c'est sans conséquence : le bouton n'est plus là, il n'y a plus de
   place à apprendre. */
.rehearsal__verdict { color: var(--gold-pale); font-style: normal; font-weight: 700; }

/* Lien de contournement : un lien, pas un bouton, pour ne pas concurrencer ce qu'on veut voir
   essayer. Zone tactile suffisante quand même — il est lu par quelqu'un qui vient d'échouer trois
   fois, debout, dans le noir. */
.linklike {
    background: none;
    border: none;
    padding: 10px 8px;
    color: var(--muted);
    font-size: 0.95rem;
    text-decoration: underline;
    cursor: pointer;
    font-family: inherit;
}

/* --- Tableau de scores --- */
.scoreboard {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 14px;
    width: 100%;
    max-width: 460px;
    margin: 10px auto 0;
}
.team {
    flex: 1;
    display: flex;
    flex-direction: column;
    align-items: center;
    padding: 14px 8px;
    border-radius: var(--radius);
    background: rgba(0, 0, 0, 0.3);
    border: 1px solid var(--border);
}
.team--volta   { box-shadow: inset 0 -4px 0 var(--volta); }
.team--edison { box-shadow: inset 0 -4px 0 var(--edison); }
.team__name {
    font-family: var(--font-mono);
    font-size: 0.7rem;
    color: var(--muted-dim);
    text-transform: uppercase;
    letter-spacing: 0.18em;
}
/* Bodoni pour les scores : c'est le chiffre que la salle regarde, et l'empattement lui donne
   le poids d'un tableau d'affichage. `tabular-nums` reste indispensable — un score qui change
   dix fois par seconde ne doit pas changer de largeur. */
.team__score {
    font-family: var(--font-display);
    font-size: clamp(2rem, 11vw, 3.4rem);
    font-weight: 900;
    font-variant-numeric: tabular-nums;
}
.scoreboard__vs { font-family: var(--font-display); font-weight: 400; color: var(--muted-faint); }

/* --- Gagnant --- */
.winner {
    font-family: var(--font-display);
    font-size: clamp(2rem, 10vw, 3.4rem);
    font-weight: 900;
    margin: 0;
}
/* Les teintes claires des équipes, pas les teintes pleines : le nom du vainqueur est lu, donc
   il doit contraster sur le fond sombre plutôt qu'être exact au badge. */
.winner[data-winner="volta"]  { color: var(--volta-glow); }
.winner[data-winner="edison"] { color: var(--edison-glow); }
.winner[data-winner="tie"]    { color: var(--gold-light); }

/* ⚠️ Les mêmes éléments, sur la CONSOLE, sont posés sur du parchemin : les teintes claires y
   deviennent illisibles. Portée à `.admin` et non réécrite globalement — l'écran joueur et le
   mur gardent leur fond sombre, où les règles ci-dessus sont justes. C'est la même distinction
   que pour la ola : ce n'est pas la surface qui décide, c'est le fond de l'élément. */
.admin .winner[data-winner="volta"]  { color: var(--volta-ink); }
.admin .winner[data-winner="edison"] { color: var(--edison-ink); }
.admin .winner[data-winner="tie"]    { color: var(--paper-gold-ink); }
.admin .team {
    background: rgba(22, 35, 61, 0.06);
    border-color: rgba(22, 35, 61, 0.2);
}
.admin .team__name { color: rgba(22, 35, 61, 0.6); }
.admin .team__score { color: var(--paper-ink); }
.admin .scoreboard__vs { color: rgba(22, 35, 61, 0.45); }
.admin .timer { color: rgba(22, 35, 61, 0.7); }
.admin .timer strong { color: var(--paper-ink); }
.admin .countdown { color: var(--paper-ink); }

/* ============================================================
   Admin
   ============================================================ */
/* La console est la seule surface où l'on LIT des chiffres plutôt qu'on ne regarde une
   affiche. Sa trame emploie donc les encres pâles, et une maille bleue serrée à 16 px qui
   donne un grain de papier technique sans jamais concurrencer le contenu. Six fois moins
   contrastée que celle du téléphone, à dessein. */
.admin {
    background:
        repeating-linear-gradient(90deg, var(--grid-line-gold-faint) 0 1px, transparent 1px 64px),
        repeating-linear-gradient(0deg, var(--grid-line-gold-faint) 0 1px, transparent 1px 64px),
        repeating-linear-gradient(90deg, var(--grid-line-blue-faint) 0 1px, transparent 1px 16px),
        repeating-linear-gradient(0deg, var(--grid-line-blue-faint) 0 1px, transparent 1px 16px),
        radial-gradient(120% 80% at 50% 0%, #131A2B, var(--grid-console));
}
/* ── La console en grille de tuiles ───────────────────────────────────────────
   `1.1fr 1fr 1fr` : la colonne des commandes est la plus large, parce qu'elle porte les cinq
   boutons et qu'ils ne doivent jamais passer à la ligne au mauvais endroit.

   `align-items: start` : sans lui, les tuiles d'une même rangée s'étirent à la hauteur de la
   plus haute, et une tuile de deux lignes se retrouve à moitié vide. Ici chaque tuile fait la
   hauteur de son contenu.

   Le repli en une colonne sous 1000 px n'est pas du responsive de principe : c'est la seule
   media query de tout ce fichier avec celle du mouvement réduit, et elle existe parce qu'on ne
   choisit pas la machine de l'animateur le matin même. Une grille de trois colonnes sur un
   13 pouces serait pire que la colonne qu'elle remplace. */
.admin-grid {
    display: grid;
    grid-template-columns: 1.1fr 1fr 1fr;
    align-items: start;
    gap: var(--sp-7);
    width: 100%;
    max-width: 1280px;
    margin: var(--sp-7) auto 0;
}
@media (max-width: 1000px) {
    .admin-grid { grid-template-columns: 1fr; }
}

/* Le composant Live et le tableau de bord portent chacun DEUX tuiles : ils sont donc des
   sous-grilles transparentes, et non des boîtes. `display: contents` fait remonter leurs tuiles
   dans la grille parente — sans quoi il faudrait imbriquer des grilles et les hauteurs ne
   s'aligneraient plus d'une colonne à l'autre.

   ⚠️ `display: contents` retire la boîte de l'élément, PAS ses attributs : `data-controller`,
   `data-poll` et les cibles Stimulus continuent de fonctionner à l'identique. C'est bien un
   changement de mise en page et rien d'autre. */
.control,
.admin-board {
    display: contents;
}

/* La tuile : bandeau bleu à libellé mono, corps parchemin, bord or. Le même vocabulaire que les
   panneaux du joueur, dans le registre plus sobre de la console — rayon de tuile et non de
   panneau, pas de dégradé, pas de lueur. */
.tile {
    display: flex;
    flex-direction: column;
    border: 1px solid var(--paper-gold);
    border-radius: var(--radius-tile);
    overflow: hidden;
    text-align: left;
}
.tile__head {
    margin: 0;
    padding: var(--sp-4) var(--sp-6);
    background: var(--band);
    font-family: var(--font-mono);
    font-size: 0.7rem;
    font-weight: 400;
    letter-spacing: var(--track-label);
    text-transform: uppercase;
    color: var(--gold-light);
}
.tile__body {
    padding: var(--sp-6);
    background: var(--paper);
    color: var(--paper-ink);
}

/* L'état du lien. Posé en grille sur la première rangée, pleine largeur : c'est la seule
   information de cette page qui INVALIDE toutes les autres — un panneau figé et un panneau à jour
   se ressemblent exactement — donc elle se voit sans être cherchée.

   Les libellés sont en CSS et non dans le gabarit, parce que l'état l'est aussi : le contrôleur
   nomme `pending`, `live` ou `lost`, et rien d'autre. Un libellé écrit en JavaScript serait un
   second endroit où dire la même chose. */
.link-state {
    grid-column: 1 / -1;
    /* ⚠️ `order: -1` parce que la position dans le DOM n'est PAS négociable : cet élément est une
       cible Stimulus du contrôleur `game`, qui vit sur `.admin-board`, donc il doit être dans son
       sous-arbre — c'est-à-dire en troisième position de la grille, derrière les deux tuiles du
       composant Live. La grille le remet en tête sans toucher au DOM.
       `display: contents` sur `.admin-board` fait de cet élément un item de la grille parente,
       ce qui est précisément ce qui rend `order` opérant ici. */
    order: -1;
    display: flex;
    align-items: center;
    gap: var(--sp-3);
    margin: 0;
    font-family: var(--font-mono);
    font-size: 0.72rem;
    letter-spacing: var(--track-label);
    text-transform: uppercase;
}
.link-state__dot {
    width: 0.6rem;
    height: 0.6rem;
    border-radius: 50%;
    background: var(--muted-faint);
}
.link-state__label::after { content: 'Liaison — en attente'; }
.link-state[data-link='live']    { color: var(--state-ok-ink); }
.link-state[data-link='live'] .link-state__dot {
    background: var(--state-ok);
    box-shadow: 0 0 8px var(--state-ok);
}
.link-state[data-link='live'] .link-state__label::after { content: 'Backend connecté'; }
.link-state[data-link='lost']    { color: var(--state-danger-ink); }
.link-state[data-link='lost'] .link-state__dot {
    background: var(--state-danger);
    box-shadow: 0 0 8px var(--state-danger);
}
.link-state[data-link='lost'] .link-state__label::after { content: 'Liaison perdue — resynchronisation'; }

/* Les deux appels de la ola (#45), en tête de la pile de commandes.

   ⚠️ DANS LA PILE ET NON DANS UNE TUILE À EUX, et le premier jet faisait l'inverse — une bande
   pleine largeur en haut du panneau. Retour d'essai : « je les voyais au-dessus de Arrêter la
   manche, pas dans un nouveau container ». C'est juste, et la raison est celle que la pile porte
   déjà : elle contient les gestes de jeu, et pendant la manche 4 appeler une équipe EST le geste de
   jeu. À côté, il devenait un réglage.

   Une rangée plutôt qu'une colonne, seule exception à `.control__buttons` : les deux appels sont un
   CHOIX entre deux, pas deux actions successives. Côte à côte, l'œil lit une alternative ; empilés,
   il lirait une séquence. */
.control__ola { display: flex; gap: var(--sp-4); }

/* Les deux boutons se partagent la largeur : ils sont EXACTEMENT symétriques, parce que le choix
   l'est. Une cible plus grosse que l'autre serait une préférence que le jeu n'a pas. */
.btn--ola { flex: 1; }

/* La couleur d'équipe sur le bouton, comme partout ailleurs dans le jeu : c'est elle qui se lit
   avant le texte, sur un panneau qu'on balaie du regard entre deux levers. */
.btn--ola-volta  { background: linear-gradient(180deg, var(--volta), var(--volta-dark)); border-color: var(--volta-ink); color: #08111F; }
.btn--ola-edison { background: linear-gradient(180deg, var(--edison), var(--edison-dark)); border-color: var(--edison-ink); color: #1B0F08; }

/* ⚠️ L'ÉQUIPE APPELÉE EST DÉSACTIVÉE, ET ELLE NE DOIT PAS SE LIRE COMME ÉTEINTE. C'est l'inverse
   de tous les autres `:disabled` du panneau : ailleurs, désactivé veut dire « pas maintenant » et
   l'opacité le dit bien ; ici il veut dire « c'est déjà cette équipe qui est debout », c'est-à-dire
   l'information principale de l'écran. L'atténuer rendrait l'état illisible au moment précis où on
   le consulte.

   ⚠️ PAR LA COULEUR ET NON PAR UN CONTOUR, et le premier jet posait un `inset 0 0 0 3px` d'encre —
   retour d'essai : « cette horrible bordure ». Il avait tort sur le fond autant que sur la forme :
   un liseré est un ORNEMENT ajouté à côté du bouton, alors que l'état appartient au bouton
   lui-même. La même teinte poussée d'un cran vers le sombre le dit sans rien ajouter, et se lit de
   plus loin — c'est du contraste, pas un détail de deux pixels.

   L'ombre portée est INVERSÉE : le bouton appelé s'enfonce là où les autres se posent, ce qui est
   le vocabulaire habituel d'un état pressé. Combiné à la teinte, l'état se lit même sans comparer
   les deux boutons — ce qui est le cas quand on ne regarde le panneau qu'une demi-seconde. */
.btn--ola:disabled { cursor: default; box-shadow: inset 0 2px 5px rgba(0, 0, 0, 0.35); }
.btn--ola-volta:disabled {
    background: linear-gradient(180deg, var(--volta-dark), #14446E);
    border-color: #14446E;
    color: #EAF4FF;
}
.btn--ola-edison:disabled {
    background: linear-gradient(180deg, var(--edison-dark), #7E3D0F);
    border-color: #7E3D0F;
    color: #FFF3E6;
}

/* La tuile des commandes occupe la première colonne des deux rangées : c'est là que va la main,
   et elle ne doit pas changer de place quand une autre tuile grandit. */
.tile--phase { grid-row: span 2; }
@media (max-width: 1000px) {
    .tile--phase { grid-row: auto; }
}

/* L'aperçu de l'écran de salle. Le rapport est celui du vidéoprojecteur, et le contenu est mis
   à l'échelle par `transform` : une iframe de 1920 px réduite par `zoom` ne se comporte pas de
   la même façon d'un moteur à l'autre, là où `scale` est identique partout. */
.preview {
    position: relative;
    width: 100%;
    aspect-ratio: 16 / 9;
    overflow: hidden;
    border: 1px solid rgba(22, 35, 61, 0.3);
    border-radius: var(--radius-control);
    background: var(--grid-bg);
}
.preview__frame {
    position: absolute;
    top: 0;
    left: 0;
    width: 1920px;
    height: 1080px;
    border: 0;
    transform-origin: top left;
    /* Le facteur est celui d'une tuile d'environ un tiers de 1280 px, marges comprises. Il n'a
       pas à être exact : ce qu'on lit dans cette vignette, c'est « la salle voit-elle la bonne
       chose », pas un chiffre. */
    transform: scale(0.19);
    pointer-events: none;
}

.admin-board {
    text-align: center;
}
.phaseline { color: rgba(22, 35, 61, 0.7); margin: 6px 0 14px; }
.phaseline strong { color: var(--paper-ink); }

.control__phase { color: rgba(22, 35, 61, 0.7); margin-bottom: 12px; }
.control__phase strong { color: var(--paper-ink); }
/* Diagnostic de l'onboarding : lu d'un même coup d'œil que la phase, juste au-dessus des
   boutons — c'est là que se trouvent les yeux au moment où l'oubli de « Nouvelle partie »
   est encore rattrapable. */
.control__onboarding { color: rgba(22, 35, 61, 0.7); margin-bottom: 12px; font-size: 0.95rem; }
.control__onboarding strong { color: var(--paper-ink); font-family: var(--font-mono); }
/* ⚠️ EN COLONNE, et non plus en `flex-wrap` centré. Les cinq boutons empilés dans une colonne
   de tuile sont TOUJOURS au même endroit ; en `wrap`, leur position dépendait de la largeur de
   la fenêtre — donc de la machine de l'animateur, qu'on ne choisit pas. Sous pression, un bouton
   qui a changé de place est un bouton qu'on cherche. */
.control__buttons { display: flex; flex-direction: column; gap: var(--sp-4); margin-top: var(--sp-5); }
.btn {
    border: 1px solid transparent;
    border-radius: var(--radius-control);
    padding: 14px 22px;
    font-family: var(--font-ui);
    font-size: 1.05rem;
    font-weight: 600;
    letter-spacing: 0.02em;
    cursor: pointer;
    color: var(--paper-ink);
}

/* ⚠️ LA HIÉRARCHIE DES COMMANDES EST REBÂTIE, et l'ancienne était contrainte par le fond sombre.
   Elle disait : « l'or est réservé à l'action qui lance la manche, elle doit être la seule à
   briller, les autres restent sourdes ». Sur du parchemin, « sourd » veut dire crème sur crème —
   trois des cinq boutons étaient devenus invisibles.

   La maquette donne le vocabulaire, et il est meilleur parce qu'il ne repose plus sur la seule
   luminosité : un APLAT pour ce qui agit, un CONTOUR pour ce qui règle, et une couleur par
   intention. L'intention de l'ancienne règle est conservée intégralement — la commande la plus
   fréquente et la commande destructrice ne se ressemblent pas, et aucune ne se trouve à la place
   de l'autre.

   Bleu pour enchaîner, or pour démarrer, rouge pour arrêter : trois teintes, trois gestes, et
   c'est la couleur qui les distingue avant la lecture. */

/* Enchaîner la manche suivante : l'action la plus fréquente de la soirée — trois fois par
   plénière contre une — donc la plus grosse cible et la plus franche. Bleu, comme le GO de la
   maquette : elle AVANCE, elle ne relance pas. */
.btn--next {
    background: linear-gradient(180deg, var(--volta), var(--volta-dark));
    border-color: var(--volta-ink);
    color: #08111F;
}
.btn--next:disabled { opacity: 0.35; cursor: not-allowed; }

/* « Démarrer la partie » REMET LES SCORES À ZÉRO. L'or reste le sien : c'est le geste
   irréversible, et il garde la couleur que tout le projet réserve à ce qui compte. */
.btn--start { background: var(--gold); color: #1B160F; border-color: var(--paper-gold); }
.btn--start:disabled { opacity: 0.35; cursor: not-allowed; }

/* « Arrêter la manche » : rouge, la valeur canonique de la charte. Cherchée dans l'urgence, elle
   ne doit jamais être trouvée à la place de « Démarrer » — et deux aplats de couleurs opposées
   ne se confondent pas, là où deux contours se ressemblaient. */
.btn--stop {
    background: var(--state-danger);
    border-color: var(--state-danger-dark);
    color: var(--state-danger-ink);
}
.btn--stop:disabled { opacity: 0.35; cursor: not-allowed; }

/* La commande de récupération reste en CONTOUR : c'est une sortie de secours, pas un geste de jeu,
   et elle ne doit pas attirer l'œil pendant qu'on cherche à enchaîner. « Revenir à l'attente »
   partageait cette règle ; le bouton a été retiré du panneau (#45), sa règle avec lui. */
.btn--new   { background: transparent; border-color: var(--paper-gold); color: var(--paper-ink); }

/* Les appuis : le seul chiffre du jeu, et il est là pour être dit à l'oral. Mono et tabulaire —
   il monte de plusieurs centaines par seconde, et une largeur qui varie le rend illisible. */
.control__taps { margin-top: 14px; color: rgba(22, 35, 61, 0.7); }
.control__taps strong {
    color: var(--paper-ink);
    font-family: var(--font-mono);
    font-size: 1.15rem;
    margin: 0 2px;
    font-variant-numeric: tabular-nums;
}

/* La manche et son geste : affichés, jamais choisis. Le sélecteur a été retiré — l'ordre des
   manches est fixe, donc le geste se déduit du numéro, et l'animateur n'a rien à cliquer. */
.control__gesture { margin-top: 14px; color: rgba(22, 35, 61, 0.7); }
.control__gesture strong { color: var(--paper-ink); margin: 0 2px; }

/* Le repli d'urgence, VOLONTAIREMENT terne. Ce n'est pas un choix entre égaux : c'est une sortie de
   secours, et elle ne doit pas attirer l'œil pendant qu'on cherche « Démarrer ». Quand elle est
   active, en revanche, elle doit se voir — d'où l'avertissement doré. */
/* Le faux crash (#12), et il a DEUX apparences pour ses deux emplacements — voir le gabarit.

   ⚠️ Écrit après coup, et le défaut valait d'être consigné : ce bloc n'avait AUCUNE règle. Le bouton
   se rendait donc collé au précédent, dans la couleur de tout le reste, tout en bas d'un panneau
   dense — et sur le panneau réel il n'a pas été vu. Un moment mis en scène qui dépend d'un bouton
   qu'on cherche n'est pas mis en scène.

   ⚠️ REPEINT POUR LA CONSOLE EN PARCHEMIN (#36). Les trois boutons arrivaient en `rgba(0,0,0,.35)`
   avec du texte `--muted` ou `--gold-light` : justes sur le fond sombre de la console d'alors,
   illisibles sur le crème qui l'a remplacé — l'or clair y tient 1,9:1. La HIÉRARCHIE d'origine est
   conservée intégralement, et c'est elle qui compte : un rouge plein pour le geste de fin, un
   contour discret tant qu'il reste une manche, un contour neutre pour la sortie. */
.control__crash { margin-top: 14px; }

/* En TÊTE, quand la partie est finie : c'est la seule chose qu'il reste à faire, donc elle se lit
   comme telle. Le rouge le plus profond du panneau — plus sombre que `--state-danger`, qui est
   celui de « Arrêter la manche » : les deux ne doivent pas se confondre, l'un ferme une manche et
   l'autre ferme la soirée. */
.btn--crash {
    background: linear-gradient(180deg, #C7263A, #8E1526);
    border-color: #8E1526;
    color: #FFF1F2;
}

/* Au CALME, dans le bloc du bas, tant qu'il reste une manche : même bouton, même action, mais il ne
   doit pas attirer le pouce d'un animateur qui cherche comment enchaîner. La teinte reste, elle est
   seulement rendue à sa juste discrétion — contour plutôt qu'aplat, comme les deux autres
   commandes de secours du panneau. */
.control__crash .btn--crash {
    background: transparent;
    border: 1px solid rgba(142, 21, 38, 0.55);
    color: #8E1526;
}

/* ⚠️ CE N'EST PLUS UNE SORTIE, C'EST UN CONSTAT (#45). La règle qui tenait cette place habillait
   « Annuler le crash » : « volontairement neutre et jamais alarmante, c'est le geste de secours, il
   doit se presser sans hésiter ». Le bouton a été retiré ; ce qui reste dit que la soirée est finie
   et ne se presse pas.

   Éteint pour de bon, donc, et c'est le seul `:disabled` de ce panneau qui DOIT se lire comme
   éteint : ailleurs un bouton gris dit « pas maintenant », ici il dit « plus jamais ». */
.btn--crash-done {
    background: transparent;
    border: 1px dashed rgba(22, 35, 61, 0.3);
    color: rgba(22, 35, 61, 0.55);
    cursor: default;
}

/* « dernière manche — la partie est terminée » : la ligne qui remplace « suivante : … ». Même
   discrétion que le reste du diagnostic, elle informe et ne réclame rien. */
[data-testid='admin-game-over'] { color: var(--paper-gold-ink); }

.control__fallback { margin-top: 14px; color: rgba(22, 35, 61, 0.55); font-size: 0.85rem; }
.control__fallback strong { color: var(--state-danger); }

/* Gesture rate stepping: a setting, not a game manoeuvre, so it stays visually quieter than the
   three buttons above — it must not be reachable by mistake while looking for "Start". */
.control__rate { margin-top: 14px; color: rgba(22, 35, 61, 0.7); }
.control__rate strong { color: var(--paper-ink); font-family: var(--font-mono); font-size: 1.15rem; margin: 0 2px; }
.btn--step {
    background: transparent;
    border: 1px solid rgba(22, 35, 61, 0.35);
    color: var(--paper-ink);
    padding: 6px 14px;
    font-size: 1.15rem;
    margin: 0 6px;
}

.players {
    display: flex;
    gap: 18px;
    justify-content: center;
    color: rgba(22, 35, 61, 0.7);
    margin-top: 14px;
}
.players strong { color: var(--paper-ink); font-family: var(--font-mono); font-size: 1.2rem; font-variant-numeric: tabular-nums; }

/* Bandeau de resynchronisation : le joueur n'est plus reconnu (nouvelle partie),
   sa page va se recharger. Volontairement au-dessus de tout : c'est la seule chose
   qui compte tant qu'il n'est pas reconnecté. */
.resync {
    position: fixed;
    inset: 0;
    z-index: 100;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 8px;
    padding: 24px;
    text-align: center;
    /* Doit suivre `--bg` : ce bandeau couvre tout l'écran, et un fond d'une autre teinte se
       lirait comme une autre application. */
    background: rgba(20, 16, 12, 0.97);
    color: var(--ink);
}
.resync strong { font-family: var(--font-display); font-size: 1.6rem; color: var(--gold-pale); }
.resync span { color: var(--muted); }

/* Bandeau réseau. En HAUT et non au centre, et sans couvrir quoi que ce soit d'utile : le joueur
   doit continuer à taper pendant qu'il s'affiche. C'est l'inverse de `.resync`, qui prend l'écran
   parce qu'il n'y a plus rien à y faire.

   `z-index` sous celui de `.resync` : si les deux se déclenchent, c'est la resynchronisation qui
   gagne — elle finit par un rechargement, donc elle a le dernier mot. */
.offline {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    z-index: 90;
    padding: 8px 16px;
    text-align: center;
    font-family: var(--font-mono);
    font-size: 0.8rem;
    letter-spacing: 0.04em;
    color: #1B160F;
    background: var(--gold-light);
}

/* Supervision (#18). Lisible d'un coup d'œil depuis la régie, pendant que le jeu
   tourne : pas d'onglets, pas de navigation, tout sur un écran. */
.monitoring__grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
    gap: 12px;
    padding: 12px;
}
.tile {
    display: flex;
    flex-direction: column;
    gap: 4px;
    padding: 16px;
    border-radius: 14px;
    background: var(--bg-soft);
    border: 1px solid var(--border);
    box-shadow: inset 0 -4px 0 rgba(232, 206, 122, 0.12);
}
.tile--wide { grid-column: 1 / -1; }
/* Vert, rouge et ambre restent EUX-MÊMES et ne passent pas à l'or : ce sont des états, lus
   depuis la régie pendant que le jeu tourne. Les rendre à la charte les rendrait indistincts,
   et c'est le seul endroit de l'application où la couleur porte une décision.
   Le raisonnement est inchangé ; seules les VALEURS le sont. Voir les jetons `--state-*`. */
.tile--ok { box-shadow: inset 0 -4px 0 var(--state-ok); }
.tile--ko { box-shadow: inset 0 -4px 0 var(--state-danger); }
.tile--warn { box-shadow: inset 0 -4px 0 var(--state-warn); }
.tile__label {
    font-family: var(--font-mono);
    color: var(--muted-dim);
    font-size: 0.72rem;
    text-transform: uppercase;
    letter-spacing: 0.18em;
}
.tile__value { font-family: var(--font-mono); font-size: 2.2rem; font-weight: 400; line-height: 1.1; font-variant-numeric: tabular-nums; }
.tile__hint { color: var(--muted); font-size: 0.85rem; }
.tile__link { color: var(--muted); font-size: 0.85rem; margin-top: 4px; }
.tile .unknown { color: var(--state-warn); font-size: 1.6rem; }
.monitoring__note { color: var(--muted); font-size: 0.85rem; padding: 0 12px 16px; margin: 0; }

/* Alertes. Le rouge occupe toute la largeur, au-dessus des tuiles : depuis l'autre bout
   de la régie, la seule question est « est-ce que c'est rouge ». */
.alerts {
    margin: 0 12px;
    padding: 12px 16px;
    border-radius: 14px;
    background: rgba(192, 57, 43, 0.16);
    box-shadow: inset 0 0 0 1px var(--state-danger);
}
.alerts__item { margin: 0 0 4px; }
.alerts__item:last-child { margin-bottom: 0; }
.alerts__item strong { color: var(--state-danger-ink); }
.alarm-toggle {
    margin-left: auto;
    padding: 6px 12px;
    border: 0;
    border-radius: 999px;
    background: var(--bg-soft);
    color: var(--ink);
    font: inherit;
    font-size: 0.85rem;
    cursor: pointer;
}
.alarm-toggle:disabled { color: var(--muted); cursor: default; }

/* Diagnostic capteurs (#19). Pensé pour être lu sur un téléphone, dans un couloir,
   puis renvoyé en capture d'écran. */
.diagnostic { overflow-y: auto; padding-bottom: 24px; }
.diagnostic__intro { padding: 0 16px; }
.card {
    margin: 12px 16px;
    padding: 16px;
    border-radius: 14px;
    background: var(--bg-soft);
}
.card__title { margin: 0 0 10px; font-family: var(--font-mono); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.18em; color: var(--muted-dim); }
.checks { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.checks li { font-size: 0.95rem; }
.result { margin: 10px 0 0; font-weight: 700; }
.tiny { font-size: 0.75rem; color: var(--muted); word-break: break-all; margin: 8px 0 0; }
.meter { height: 10px; border-radius: 5px; background: #ffffff1a; overflow: hidden; margin-top: 10px; }
/* Or et non couleur d'équipe : la page de diagnostic n'appartient à aucun camp. C'est aussi
   ce qui la distingue de la jauge d'essai, qui se remplit, elle, de la couleur du joueur. */
.meter span { display: block; height: 100%; width: 0; background: var(--gold); transition: width 80ms linear; }
.checks em { color: var(--muted); font-style: normal; font-size: 0.85rem; }

/* ============================================================
   Écran de scène (/scene) — projeté en plénière, jamais tenu en main.
   Lu de loin, dans le noir, par plusieurs centaines de personnes.
   ============================================================ */

.scene {
    /* Repli AVANT `dvh`, et ce n'est pas de la superstition : `100dvh` est ignoré par un
       navigateur antérieur à Chrome 108 / Safari 15.4, et `html, body { height: 100% }` ne
       propage rien jusqu'ici — la scène retomberait alors sur la hauteur de son contenu, sur
       la seule machine dont on ne choisit pas le navigateur, le matin même. */
    min-height: 100vh;
    min-height: 100dvh;
    display: flex;
    flex-direction: column;
    /* Maille doublée par rapport au téléphone — 48/96 au lieu de 39/78. Le mur est lu à
       vingt mètres : à 39 px la trame se referme en aplat, et à la vidéo elle moire.
       Le trait bleu passe aussi à 2 px, sans quoi il disparaît à la projection. */
    background:
        repeating-linear-gradient(90deg, var(--grid-line-gold) 0 1px, transparent 1px 48px),
        repeating-linear-gradient(0deg, var(--grid-line-gold) 0 1px, transparent 1px 48px),
        repeating-linear-gradient(90deg, var(--grid-line-blue) 0 2px, transparent 2px 96px),
        repeating-linear-gradient(0deg, var(--grid-line-blue) 0 2px, transparent 2px 96px),
        radial-gradient(120% 80% at 50% 0%, #182B4A, var(--grid-bg));
}

/* La barre de marque de l'écran projeté. Le dégradé et le filet d'or de 3 px viennent de la
   maquette, et ils font la même chose que sur le téléphone en plus grand : donner un bord haut
   stable, contre lequel le reste vient se poser.

   Dimensionnée en `vh` comme tout cet écran : la seule chose qu'on sache du vidéoprojecteur du
   jour J est son rapport d'image. `flex: 0 0 auto` pour qu'elle ne se laisse jamais comprimer
   par la course — c'est l'élément qui ne doit pas bouger. */
.scene-brand {
    flex: 0 0 auto;
    display: flex;
    align-items: center;
    gap: 1.6vh;
    padding: 1.2vh 2.6vh;
    background: linear-gradient(180deg, var(--band-top), var(--band-bottom));
    border-bottom: 3px solid var(--gold);
}
.scene-brand__mark {
    display: flex;
    flex: 0 0 auto;
    align-items: center;
    justify-content: center;
    width: 4.4vh;
    height: 4.4vh;
    border: 0.2vh solid var(--gold-light);
    border-radius: 50%;
    color: var(--gold-light);
}
.scene-brand__mark svg { width: 60%; height: 60%; }
.scene-brand__text { display: flex; flex: 1; flex-direction: column; line-height: 1.15; }
.scene-brand__name {
    font-family: var(--font-display);
    font-weight: 900;
    font-size: 2.4vh;
    color: var(--gold-light);
}
.scene-brand__event {
    font-family: var(--font-mono);
    font-size: 1.2vh;
    letter-spacing: var(--track-wide);
    text-transform: uppercase;
    color: var(--muted-dim);
}
.sponsor--scene { gap: 1vh; }
.sponsor--scene .sponsor__label { font-size: 1.1vh; }
.sponsor--scene .sponsor__logo { height: 3vh; }
.sponsor--scene .sponsor__name { font-size: 2vh; }

.scene-form { flex: 1; display: flex; }

/* --- Forme déployée : le code occupe l'écran --- */
.scene-form--expanded {
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 2vh;
}

/* Titre d'affiche, en Bodoni : c'est la seule chose de cet écran qui n'a pas de fonction, et
   elle est donc la seule à pouvoir être décorative. Bornée en `vh` comme tout le reste de la
   scène — un `rem` ne suit pas la dimension de projection. */
.scene-kicker {
    margin: 0;
    font-family: var(--font-display);
    font-size: 3.6vh;
    font-style: italic;
    color: var(--gold);
    letter-spacing: 0.02em;
}

/* L'invitation est une ÉTIQUETTE, pas un titre : capitales espacées en mono, comme sur
   l'affiche. C'est le QR code qui est le sujet de cet écran, jamais la phrase. */
.scene-invite {
    margin: 0;
    font-family: var(--font-mono);
    font-size: 3.4vh;
    letter-spacing: 0.18em;
    color: var(--gold-light);
    text-transform: uppercase;
}

/* Pavé BLANC OPAQUE sous les modules noirs. Le dégradé de fond passerait sinon derrière le
   code : un appareil photo qui cherche du contraste franc n'y arrive plus, et l'échec est
   silencieux — le code paraît normal à l'œil. */
.scene-qr {
    background: #fff;
    border-radius: 8px;
    line-height: 0;
}

.scene-qr svg {
    display: block;
    width: var(--scene-qr-size);
    height: var(--scene-qr-size);
}

/* L'adresse en Bodoni : c'est le titre de l'affiche, lu à trente mètres. Bornée à 20
   caractères par arbitrage PO — au-delà, la lecture depuis le fond n'est plus garantie, et
   l'empattement ne change rien à cette borne. */
.scene-address {
    margin: 0;
    font-family: var(--font-display);
    font-size: 7vh;
    font-weight: 900;
    letter-spacing: 0.01em;
    color: var(--gold-pale);
}

/* Le décompte de présents en mono : il change en continu, et une largeur qui varie le fait
   trembler sur un mur de six mètres. */
.scene-present {
    margin: 0;
    font-family: var(--font-mono);
    font-size: 3vh;
    letter-spacing: 0.06em;
    color: var(--muted);
    font-variant-numeric: tabular-nums;
}

/* --- Forme repliée : le trio se range dans un coin, la scène prend la place --- */
/* ⚠️ DEUX RANGÉES, ET C'EST LA STRUCTURE QUI DÉCIDE DE TOUT SUR CET ÉCRAN (#36).

   Ce qu'il y avait avant : `.scene-corner` était un FRÈRE de la scène dans une rangée
   HORIZONTALE. Il ne mesure qu'un tiers de la hauteur, mais il réservait sa colonne sur toute la
   hauteur — la scène commençait donc 146 px trop à droite, la foule de la ola laissait une
   colonne de vide devant elle, les couloirs étaient rétrécis d'autant, et surtout TOUT CE QUI EST
   CENTRÉ DANS LA SCÈNE l'était de travers. Le nom du vainqueur, qui est la seule chose que mille
   personnes regardent au moment du gong, tombait 65 px à droite du centre de l'écran.

   ⚠️ UNE PREMIÈRE CORRECTION A ÉTÉ ÉCARTÉE, et il vaut la peine de dire pourquoi : sortir le coin
   du flux en absolu, puis compenser par une marge sur le bandeau. Elle réglait la largeur et
   laissait l'incohérence en place — bandeau indenté, piste pas indentée — au prix de deux
   rustines qui devaient rester d'accord entre elles.

   La forme juste est celle-ci. La section est une COLONNE de deux rangées :

       .scene-stage
         ├── .scene-topline   ← rangée : le coin QR, puis le bandeau de tête
         └── .race__track…    ← contenu, pleine largeur

   Plus de position absolue, plus de marge de compensation, et le centre de la scène EST le centre
   de l'écran. Le coin reste dans le flux, donc toujours à l'écran et au premier plan — ce que
   `scene-scannability.spec.ts` garde sous la peau de la ola : « un code occulté là ne se rattrape
   pas », la ola étant la dernière manche. */
.scene-form--collapsed {
    flex-direction: column;
    gap: 2vh;
    padding: 3vh;
}

.scene-corner { display: flex; flex-direction: column; align-items: center; gap: 1vh; }

/* La rangée d'en-tête. `align-items: flex-start` pour que le bandeau reste collé en haut quand le
   coin est plus grand que lui, et `flex: 1` sur le bandeau pour qu'il prenne tout le reste. */
.scene-topline {
    display: flex;
    align-items: flex-start;
    gap: 3vh;
    flex: 0 0 auto;
}
.scene-topline .scene-corner { flex: 0 0 auto; }
.scene-topline .race__head { flex: 1; min-width: 0; }

/* La rangée de contenu : tout ce qui n'est pas l'en-tête, et le référentiel de ce qui s'y centre.
   `position: relative` la rend référence des positionnements absolus qu'elle contient — le nom du
   vainqueur, les décors, l'altimètre — et ces éléments cessent ainsi de compter l'en-tête dans
   leur calcul. `min-height: 0` parce qu'elle est un enfant de flex qui contient elle-même du
   flex : sans lui, un contenu plus haut que la place disponible refuse de se comprimer. */
.scene-content {
    position: relative;
    flex: 1;
    display: flex;
    flex-direction: column;
    min-height: 0;
}

.scene-form--collapsed .scene-qr svg {
    width: calc(var(--scene-qr-size) * var(--scene-qr-collapsed-ratio));
    height: calc(var(--scene-qr-size) * var(--scene-qr-collapsed-ratio));
}

.scene-form--collapsed .scene-address { font-size: 3.4vh; }
.scene-form--collapsed .scene-present { font-size: 2.2vh; }

/* Réservé à la manche elle-même — hors périmètre de cette story. */
/* --- La course, projetée ---

   Tout est en `vh` et jamais en `px`, comme le reste de la scène : cet écran est lu à trente
   mètres, et la seule dimension qui compte est sa hauteur de projection. Le prototype est
   dessiné en 1920×1080 fixes ; le transposer en pixels le casserait sur toute autre
   résolution que celle-là, et on ne connaît pas encore le projecteur de la salle. */
.scene-stage {
    flex: 1;
    /* ⚠️ `align-self: stretch` est indispensable : le parent `.scene-form--collapsed` est une
       rangée avec `align-items: flex-start`, donc sans cela la scène s'aligne sur sa hauteur de
       contenu et toute la course s'écrase dans une bande en haut de l'écran. Constaté en
       capture — le reste de la projection restait noir. */
    align-self: stretch;
    position: relative;
    display: flex;
    flex-direction: column;
    justify-content: space-between;
    gap: 2vh;
    min-height: 0;
    overflow: hidden;
}

.race__head {
    /* Au-dessus du sol qui défile. Sans pile explicite, le sol est le dernier élément du DOM et
       passe donc PAR-DESSUS les éléphants et le chrono — constaté en capture. */
    position: relative;
    z-index: 1;
}
/* ⚠️ LE BANDEAU DE TÊTE EST UN PANNEAU PARCHEMIN, et c'est ce qui transforme un empilement
   d'éléments flottants en TABLEAU D'AFFICHAGE. Avant, le titre, le chrono et les deux scores
   étaient posés à même le fond : trois objets sans lien, que l'œil devait rassembler lui-même à
   vingt mètres. Encadrés, ils se lisent comme une seule chose.

   C'est la surface où le parchemin est le moins risqué de tout le chantier, et c'est pourquoi
   elle méritait d'être faite tôt : jamais tenue en main, jamais dans le noir absolu, et son
   contraste encre/fond de 12,3:1 est très supérieur à celui de l'or sur brun qu'il remplace.

   Opacité 0,95 et non 1 : la trame du fond doit rester perceptible dessous, sinon le bandeau
   flotte comme une découpe collée sur l'affiche plutôt que comme un cartouche imprimé dessus. */
.race__head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 4vh;
    padding: 1.6vh 3vh;
    /* Plancher de hauteur : « LA MONTGOLFIÈRE » tient sur deux lignes là où « LA OLA » tient sur
       une. Sans lui, le bandeau se contracterait d'un tiers entre deux manches et toute la piste
       remonterait sous lui, à la vue de la salle. */
    min-height: 16vh;
    border: 0.2vh solid var(--paper-gold);
    border-radius: 1.2vh;
    background: rgba(239, 227, 196, 0.95);
    color: var(--paper-ink);
}

/* Le chrono est le plus gros chiffre de l'écran, en mono : il change dix fois par seconde et
   une largeur qui varie le fait trembler sur un mur de six mètres. */
.race__clock { display: flex; flex-direction: column; align-items: center; }
.race__clock strong {
    font-family: var(--font-mono);
    font-size: 10vh;
    /* 600 et non 400 : c'est le plus gros chiffre du mur, et la graisse est désormais chargée —
       en 400 sur du parchemin il paraissait maigre là où il doit dominer. */
    font-weight: 600;
    line-height: 1;
    color: var(--paper-ink);
    font-variant-numeric: tabular-nums;
}
.race__clock-label {
    font-family: var(--font-mono);
    font-size: 1.6vh;
    letter-spacing: var(--track-wide);
    text-transform: uppercase;
    color: rgba(22, 35, 61, 0.6);
}
/* Le décompte d'avant-départ est LE moment où l'animateur fait compter la salle à voix haute :
   il est donc plus gros et plus vif que le chrono de manche, qui doit rester lisible sans
   attirer l'œil pendant que la course se joue. Zéro atteint, le chiffre s'éteint — c'est le
   vainqueur qui prend l'attention, pas un chrono à zéro. */
/* Le décompte d'avant-départ prend l'or LISIBLE, et l'assombrissement a dû être fait deux fois.
   Le premier passage avait écarté `--gold-pale` (1,9:1) pour `--paper-gold`, sans voir que celui-ci
   ne tient que 2,7:1 — donc le chiffre que la salle compte à voix haute restait le moins lisible de
   l'écran, à 16vh, sur un vidéoprojecteur qui écrase encore le contraste. `--paper-gold-ink` monte à
   4,75:1 et se lit toujours comme de l'or. Le raisonnement était bon, la valeur d'arrivée non. */
.race__clock strong[data-clock="countdown"] { font-size: 16vh; color: var(--paper-gold-ink); }
.race__clock strong[data-clock="over"]      { color: rgba(22, 35, 61, 0.45); }

/* Le vainqueur, posé PAR-DESSUS la piste plutôt qu'à la place : la course reste visible,
   gelée sur son dernier état. `z-index` pour la même raison que `.race__head` — sans pile
   explicite, le sol qui défile est le dernier élément du DOM et passerait devant. */
/* Le verdict, en cartouche parchemin.

   Il était posé sur un aplat noir à 78 %, seule surface de l'écran à ne rien emprunter à la
   charte : le bandeau de tête, les panneaux du téléphone et les tuiles de la console sont tous
   du parchemin cerclé d'or, et le seul bloc que mille personnes regardent au moment culminant
   restait une boîte noire. Le fond OPAQUE, lui, reste indispensable pour la raison d'origine —
   le texte se pose par-dessus deux éléphants et un sol qui défile, et une ombre n'y suffit pas.
   C'est le fond qui change de registre, pas sa fonction.

   ⚠️ LA TAILLE EST DÉRIVÉE, ET C'EST LE MÊME PIÈGE QUE LA FOULE DE LA OLA. Le bloc ne dit plus
   deux mots mais une phrase — « L'équipe Volta remporte l'Exposition », trente-trois caractères
   contre douze. Un `12vh` calculé pour « Équipe Volta » débordait donc de l'écran, et un `vw` nu
   ignore les marges. Le terme dérivé borne la phrase la plus longue à la largeur réellement
   disponible ; le `vh` reste le plafond, parce que cet écran se dimensionne en hauteur.

   L'encre est celle du parchemin et non la teinte d'équipe : `--volta-glow` et `--edison-glow`
   sont calibrées pour le fond noir et tombent sous 1,5:1 ici — c'est le défaut nº5 de cette
   story, et il se reproduit à chaque bloc qu'on retourne. L'appartenance passe donc par le FILET
   sous le cartouche, qui n'a pas de texte à porter. */
.race__winner {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    z-index: 2;
    max-width: 86vw;
    margin: 0;
    padding: 3vh 6vh;
    text-align: center;
    font-size: min(8vh, calc((100vw - 24vh) / 19));
    line-height: 1.1;
    color: var(--paper-ink);
    background: var(--paper-surface);
    border: 1px solid var(--paper-gold);
    border-bottom: 0.9vh solid var(--paper-gold);
    border-radius: var(--radius-panel);
    box-shadow: 0 2vh 6vh rgb(0 0 0 / 55%);
}
/* Le filet porte l'équipe, en encre de parchemin — jamais en lueur, voir ci-dessus. L'égalité
   garde l'or, qui n'appartient à personne.

   ⚠️ L'ENCRE EST REPOSÉE ICI, ET CE N'EST PAS UNE REDONDANCE. L'élément porte `class="race__winner
   winner"`, et `.winner[data-winner='volta']` — écrit plus haut pour le fond SOMBRE du téléphone —
   pèse `0,2,0` contre `0,1,0` pour le `color` du bloc ci-dessus. La lueur d'équipe gagnerait donc
   sur le parchemin, à 1,4:1, et la règle du bloc aurait l'air juste sans jamais s'appliquer.
   C'est le piège de spécificité que ce dépôt a commis sept fois ; il ne se voit pas à la
   relecture, seulement en capture. Chaque sélecteur ci-dessous est donc à `0,2,0` lui aussi. */
.race__winner[data-winner='volta']  { color: var(--paper-ink); border-bottom-color: var(--volta-ink); }
.race__winner[data-winner='edison'] { color: var(--paper-ink); border-bottom-color: var(--edison-ink); }
.race__winner[data-winner='tie']    { color: var(--paper-ink); border-bottom-color: var(--paper-gold); }

.race__team { display: flex; flex-direction: column; gap: 0.6vh; min-width: 26vh; }
.race__team-name {
    font-family: var(--font-mono);
    font-size: 2vh;
    letter-spacing: 0.2em;
    text-transform: uppercase;
}
/* Encres d'équipe : les lueurs sont calibrées pour le fond noir et tombent sous 1,5:1 sur le
   parchemin du bandeau. Elles restent sur la PISTE, qui n'a pas changé de fond. */
.race__team--volta  .race__team-name { color: var(--volta-ink); }
.race__team--edison .race__team-name { color: var(--edison-ink); }
.race__team-score {
    font-family: var(--font-mono);
    font-size: 4.6vh;
    font-weight: 600;
    line-height: 1;
    font-variant-numeric: tabular-nums;
    color: var(--paper-ink);
}

/* Barre de score : la seule chose de cet écran qui porte la couleur d'équipe en aplat, parce
   que c'est ce que l'équipe a produit. La lueur la rend lisible de loin sans l'agrandir. */
.race__bar {
    height: 1.3vh;
    border-radius: 999px;
    background: rgba(22, 35, 61, 0.14);
    overflow: hidden;
}
.race__bar span { display: block; height: 100%; width: 0; transition: width 1s linear; }
/* Le dégradé part de l'encre et s'ouvre sur la teinte pleine, et la lueur portée disparaît :
   sur le parchemin du bandeau, une ombre de 2,4vh ne rayonne pas, elle empâte le bord de la
   barre. Le contraste de la teinte sur le crème suffit à la faire lire à vingt mètres — c'est
   la longueur qui porte l'information, pas la luminosité. */
.race__team--volta .race__bar span {
    background: linear-gradient(90deg, var(--volta-ink), var(--volta));
}
.race__team--edison .race__bar span {
    background: linear-gradient(90deg, var(--edison-ink), var(--edison));
}

/* Le retrait en haut n'est pas cosmétique : il réserve la place de l'étiquette « Arrivée »,
   qui est ancrée au sommet de la ligne. Sans lui elle passe sous la barre de score d'Edison —
   constaté en capture. */
.race__track {
    position: relative;
    z-index: 1;
    flex: 1;
    display: flex;
    flex-direction: column;
    min-height: 0;
    padding-top: 4vh;
}
.race__lane { position: relative; flex: 1; min-height: 0; }

/* ⚠️ LE SOL APPARTIENT AU COULOIR, ET NON À LA SCÈNE (#36). C'est la correction d'une asymétrie
   que personne n'avait vue et qui saute aux yeux une fois nommée : le sol était UNE bande unique
   posée en bas de la scène — `bottom: 0; height: 22vh` — donc il ne passait que sous le couloir
   du BAS. Edison marchait sur une palissade qui défile, Volta marchait dans le vide, avec pour
   seul appui un filet pointillé. Deux coureurs de la même course sur deux sols différents.

   Un sol par couloir le corrige à la racine : chaque éléphant a le sien, à ses pieds, et les deux
   défilent à la même vitesse. Le filet pointillé de Volta disparaît, devenu redondant — il tenait
   lieu de sol faute de mieux.

   `z-index: -1` : le pseudo-élément vient APRÈS les enfants dans l'ordre de peinture, donc sans
   lui il passerait par-dessus l'éléphant du couloir.

   Réservé à la manche de marche, comme l'était la bande unique : la rame a son eau sur la piste,
   le souffle son ciel, et la ola n'a pas de couloir. */
[data-gesture='tap'] .race__lane::after {
    content: '';
    position: absolute;
    left: 0;
    right: 0;
    bottom: 0;
    height: 5vh;
    z-index: -1;
    opacity: 0.75;
    pointer-events: none;
    background-image:
        repeating-linear-gradient(90deg, rgba(232, 206, 122, 0.12) 0 4px, transparent 4px 60px),
        repeating-linear-gradient(90deg, transparent 0 130px, rgba(232, 206, 122, 0.06) 130px 150px, transparent 150px 320px);
    background-size: 60px 100%, 320px 100%;
    animation: driftSlow 9s linear infinite;
}
/* Le filet d'or qui pose la ligne de sol, à hauteur des pieds. */
[data-gesture='tap'] .race__lane::before {
    content: '';
    position: absolute;
    left: 0;
    right: 0;
    bottom: 5vh;
    height: 2px;
    z-index: -1;
    background: linear-gradient(90deg, transparent, rgba(232, 206, 122, 0.55), transparent);
}

/* Le coureur. Le contrôleur publie `--lane-pos`, une POSITION DANS LE COULOIR, et c'est la peau
   qui décide de l'axe : `left` ici — une course latérale — et `bottom` sur la manche de souffle,
   parce qu'une montgolfière monte. La transition dure UNE seconde, exactement la cadence de
   diffusion du snapshot : le déplacement est continu d'un message au suivant au lieu de sauter,
   sans que rien ne soit interpolé côté client. */
.race__runner {
    position: absolute;
    bottom: 1%;
    left: var(--lane-pos, 0%);
    width: 22vh;
    margin-left: -11vh;
    transition: left 1s linear;
}
.race__runner .elephant { width: 100%; height: auto; overflow: visible; display: block; }

/* Ombre au sol : elle ancre l'éléphant sur la piste. Sans elle il flotte. */
.race__shadow {
    position: absolute;
    left: 18%;
    right: 18%;
    bottom: -0.6vh;
    height: 2.4vh;
    border-radius: 50%;
}
.race__lane--volta  .race__shadow { background: radial-gradient(ellipse, rgba(63, 169, 255, 0.5), transparent 70%); }
.race__lane--edison .race__shadow { background: radial-gradient(ellipse, rgba(255, 158, 61, 0.5), transparent 70%); }

/* ⚠️ LA LIGNE D'ARRIVÉE EST RETIRÉE (#36). Elle marquait la TÊTE de la course et non un
   objectif — le jeu n'a aucune notion de distance à parcourir — et ce sens-là ne s'est jamais
   transmis : une ligne verticale au bout d'une piste se lit comme un but à atteindre, quoi
   qu'en dise l'étiquette. La maquette étendue l'a retirée des quatre manches, et c'est le bon
   arbitrage : ce qui doit se lire, c'est l'ÉCART entre les deux mascottes, que la barre de
   rapport de force donne déjà sans ambiguïté.
   Si elle devait revenir, il faudrait d'abord répondre à « la tête de quoi ». */

/* Le sol qui défile : deux trames superposées, l'une fine et l'autre large, pour que le
   mouvement se lise sans que l'œil puisse compter les motifs. C'est ce qui donne du mouvement
   quand les deux éléphants sont au coude à coude et que rien ne bouge dans les couloirs. */
/* ⚠️ La translation doit être un multiple ENTIER de chaque `background-size`, sinon la trame
   saute visiblement à chaque bouclage — toutes les 8 s sur le téléphone, toutes les 9 s sur le
   mur. Les quatre tailles en jeu sont 54, 96, 60 et 320 px ; 4320 est leur plus petit commun
   multiple, et il est assez grand pour que la boucle ne se devine pas. */
@keyframes driftSlow { from { background-position-x: 0, 0; } to { background-position-x: -4320px, -4320px; } }

/*
 * ═══ LA PEAU DE LA MANCHE, pilotée par `[data-gesture]` ═══
 *
 * L'attribut est posé sur la racine de chaque surface, écrit par Twig depuis le snapshot inliné
 * puis tenu à jour par le contrôleur. Toute l'apparence en descend — la pose de l'éléphant, la
 * barque, le sol. C'est la grammaire déjà en place pour `data-clock`, `data-winner` et
 * `data-holding` : le contrôleur ne décide pas de l'habillage.
 *
 * Les deux surfaces sont traitées ENSEMBLE, sélecteur par sélecteur, et ce n'est pas de la
 * concision : l'écran projeté et l'écran joueur ne peuvent pas se contredire, et deux blocs
 * séparés sont deux blocs qui divergent.
 *
 * ⚠️ Une règle de peau pèse au moins 0,3,0 en spécificité. Toute réduction de mouvement qui la
 * concerne doit être réécrite à la MÊME spécificité, sinon elle est écrasée en silence — voir le
 * bloc `prefers-reduced-motion` plus bas. C'est la quatrième fois sur ce projet qu'une règle est
 * défaite par une plus spécifique.
 */

/* Démarche de l'éléphant : un balancement court et décalé entre les deux couloirs, pour qu'ils
   ne battent pas à l'unisson — deux animations synchronisées se lisent comme un seul objet.

   Sur le geste et non sur une classe `--walk` posée à l'inclusion : la pose d'un éléphant qui
   court est une fonction de la manche, et un point d'inclusion qui la figerait ferait marcher
   les éléphants pendant qu'on rame. `.elephant--idle`, les lampes du lobby, n'est pas concerné —
   il vit hors manche. */
[data-gesture='tap'] .race__runner .elephant,
[data-gesture='tap'] .minirace__runner .elephant { animation: bobFast 620ms ease-in-out infinite; }
[data-gesture='tap'] .race__lane--edison .elephant { animation-duration: 660ms; animation-delay: 120ms; }
@keyframes bobFast {
    0%, 100% { transform: translateY(0) rotate(-1deg); }
    50%      { transform: translateY(-1.5%) rotate(1deg); }
}

/* La pose de rame : l'éléphant s'assoit dans sa barque et penche sur l'aviron. Une transformation
   statique et pas une animation — c'est la barque et l'eau qui portent le mouvement, et un
   éléphant qui tanguerait EN PLUS lirait comme deux rythmes concurrents. */
/* ⚠️ L'ÉLÉPHANT EST REMONTÉ SUR LE PONT, il descendait dedans. La pose portait
   `translate(0, 4%)`, donc elle l'enfonçait de 4 % de plus dans une coque déjà peinte par-dessus
   lui : pattes et trompe passaient sous la ligne de flottaison, et l'ensemble se lisait comme un
   animal coupé en deux plutôt que comme un passager.

   La ligne de pont est à y = 46..56 sur les 100 du dessin de barque, soit la moitié de sa
   hauteur. Le décalage négatif y pose les pieds. La rotation est conservée : c'est elle qui dit
   qu'on est sur l'eau et non sur un sol. */
[data-gesture='row'] .race__runner .elephant,
[data-gesture='row'] .minirace__runner .elephant { transform: translate(0, -4%) rotate(4deg); }

/* La barque n'appartient qu'à la manche de rame : le reste du temps le couloir garde son
   éléphant, et une barque sous une marche raconterait une histoire que le jeu n'a pas.

   `display` et non `visibility` : une barque masquée qui prendrait encore sa place laisserait un
   trou là où l'éléphant doit être. */
.race__craft,
.minirace__craft { display: none; }

/* Même règle pour la montgolfière, et pour la même raison : hors manche de souffle, le couloir
   garde son éléphant. `display` et non `visibility`, sinon une montgolfière masquée garderait sa
   place au-dessus du coureur et pousserait tout le couloir vers le bas. */
.race__balloon { display: none; }

/* ⚠️ SOUS l'éléphant et non derrière lui, et c'est une erreur qui vaut d'être consignée : la
   barque prend les MÊMES `tint` et `shade` que son passager, donc superposées la coque
   disparaissait dans les pattes — rendue, mesurée à 102×47, invisible. Dégagée des pieds, les
   deux formes se lisent comme un passager dans une barque plutôt que comme un animal confus. */
[data-gesture='row'] .race__craft,
[data-gesture='row'] .minirace__craft {
    position: absolute;
    right: 4%;
    left: 4%;
    display: block;
    filter: drop-shadow(0 -2px 3px rgb(7 11 22 / 60%));
}
/* ⚠️ Le décalage vers le bas est PAR SURFACE, et la valeur de la démonstration ne se transporte
   pas : `-30%` a été réglé sur la mini-course, où le couloir est haut et le coureur petit. Sur
   l'écran projeté, le couloir d'Edison touche le bas de la scène, et la barque tombait hors de
   l'écran — constaté en capture, mât seul dépassant. Un seul chiffre pour les deux aurait donc
   livré une manche où l'équipe du bas n'a pas de barque, sur l'écran que mille personnes
   regardent. */
[data-gesture='row'] .race__craft { bottom: -10%; }
[data-gesture='row'] .minirace__craft { bottom: -18%; }
.race__craft .boat,
.minirace__craft .boat { width: 100%; height: auto; overflow: visible; display: block; }

/* L'eau, sur la manche de rame seulement. C'est elle qui dit à l'œil qu'il s'agit d'une course de
   barques et non d'une marche avec d'autres coureurs. Inerte — pas besoin d'`aria-hidden`, c'est
   un fond et non un élément.

   ⚠️ Sur l'écran projeté elle est portée par la PISTE, qui est exactement les deux couloirs.
   Peinte plus bas, l'eau laisserait la barque de Volta flotter dans le vide à mi-hauteur — c'est
   le défaut qu'avait l'ancien sol du mur, une bande unique en bas dont il ne reste rien. */
[data-gesture='row'] .race__track,
[data-gesture='row'] .minirace__ground {
    background-image:
        repeating-linear-gradient(100deg, rgb(127 232 255 / 8%) 0 14px, transparent 14px 34px),
        linear-gradient(180deg, rgb(10 24 48 / 0%), rgb(31 109 176 / 35%));
    background-size: auto, auto;
    animation: waterDrift 5s linear infinite;
}

/* ⚠️ Même correction sur le téléphone, et pour la même raison : `.minirace__ground` est une bande
   basse de 22 %, et le couloir de Volta est au-dessus. L'eau remonte donc jusque sous la barre de
   rapport de force, ce qui est exactement la hauteur des deux couloirs. */
[data-gesture='row'] .minirace__ground { top: 22px; height: auto; }

/* Le parquet disparaît sous l'eau : un plancher sous une course de barques se lit comme un décor
   qui n'a pas suivi. */

@keyframes waterDrift {
    from { background-position: 0 0, 0 0; }
    to   { background-position: 48px 0, 0 0; }
}

/* La pose de vol : l'éléphant se redresse dans sa nacelle. Statique comme la pose de rame, et pour
   la même raison — c'est le ciel qui porte le mouvement, et un éléphant qui oscillerait EN PLUS de
   la dérive du ciel lirait comme deux rythmes concurrents. Un léger relèvement seulement : il est
   assis dans un panier, pas en train de courir. */
/* ⚠️ Le décalage vertical de l'éléphant est RETIRÉ : il compensait le ballon posé derrière lui
   sur une course latérale. L'ascension le place dans la nacelle, en absolu — voir plus bas. */

/* La montgolfière porte son passager : l'enveloppe monte AU-DESSUS du coureur et la nacelle se pose
   à ses pieds. C'est l'inverse de la barque, qui se dégage vers le bas, et c'est pour la même
   raison consignée là-bas : la montgolfière prend les MÊMES `tint` et `shade` que son passager,
   donc superposées les deux formes se confondent. Ici la séparation se fait vers le haut. */
/* Un contexte d'empilement sur le coureur, pour que le `z-index: -1` de la montgolfière la place
   derrière l'éléphant SANS la faire passer derrière le fond du couloir. Portée par la peau du
   souffle uniquement : la manche de rame n'en a pas besoin, la barque se dégageant vers le bas. */
[data-gesture='blow'] .race__runner { isolation: isolate; }
/* ═══ MANCHE 3 · L'ASCENSION ═══════════════════════════════════════════════════
 *
 * ⚠️ LA SEULE MANCHE QUI NE SE COURT PAS À L'HORIZONTALE, et c'est le changement de lecture le
 * plus fort de la refonte. Les couloirs empilés racontaient une course latérale avec un ballon
 * accroché derrière l'éléphant ; la manche 3 raconte une MONTÉE. La maquette la dessine ainsi,
 * et c'est la seule des quatre dont l'app racontait autre chose que ce qu'elle est.
 *
 * Mécanique : la piste passe en RANGÉE de deux colonnes pleine hauteur, et le coureur lit
 * `--lane-pos` sur `bottom` au lieu de `left`. Le contrôleur ne change pas — il publie une
 * position dans le couloir, la peau choisit l'axe. Voir `scene_controller.js`.
 */
[data-gesture='blow'] .race__track {
    flex-direction: row;
    padding-top: 2vh;
}
[data-gesture='blow'] .race__lane {
    /* Une gouttière, là où les couloirs empilés des autres manches n'en ont pas besoin : ici les
       deux colonnes sont côte à côte, et sans elle les montgolfières se toucheraient au sommet
       de leur montée. */
    margin: 0 2vh;
    /* Le liseré qui séparait les deux couloirs empilés n'a plus d'objet : les colonnes sont
       côte à côte, la séparation est spatiale. */
    border-bottom: 0;
}
[data-gesture='blow'] .race__runner {
    /* Centré dans sa colonne, et la position lue sur la VERTICALE. `left: 50%` avec une marge
       négative, jamais `translateX` : le coureur porte déjà une transition, et une translation
       de centrage serait écrasée par toute règle qui toucherait `transform` — c'est le piège
       déjà payé sur le bouton de tap.

       ⚠️ La course est COMPRIMÉE à 62 % de la position publiée, et le facteur n'est pas un
       réglage d'apparence. `lanePct()` borne la position à 88 % (`LEAD_PCT`), ce qui convient à
       une course latérale où le coureur est plat : sur la verticale, une montgolfière de 26vh
       posée à 88 % d'un couloir de 60vh sortirait de l'écran par le haut, derrière le bandeau.
       0,62 place le sommet du meneur juste sous l'altimètre à son palier haut.
       Ne pas « corriger » en changeant `LEAD_PCT` : cette constante est partagée avec le
       téléphone et les trois autres manches, où elle est juste. */
    left: 50%;
    bottom: calc(var(--lane-pos, 0%) * 0.62);
    width: 18vh;
    height: 25vh;
    margin-left: -9vh;
    transition: bottom 1s linear;
}
/* Le ballon remplit le coureur, l'éléphant est DANS la nacelle. Avant, le ballon était posé
   DERRIÈRE l'éléphant en `z-index: -1` : sur la course latérale, cela donnait un ballon accroché
   au dos d'un éléphant qui court, ce qui se tenait. Sur l'ascension, l'éléphant pendait sous la
   nacelle. Le rapport du dessin — 200 × 280 — est repris par la boîte du coureur, 18 × 25vh. */
[data-gesture='blow'] .race__balloon {
    inset: 0;
    right: 0;
    left: 0;
    bottom: 0;
    z-index: 0;
}
[data-gesture='blow'] .race__balloon .balloon { width: 100%; height: 100%; }
/* La nacelle occupe y = 188 à 240 sur les 280 du dessin, soit 67 % à 86 % de la hauteur.
   L'éléphant y est posé, au-dessus du ballon dans la pile. */
[data-gesture='blow'] .race__runner .elephant {
    position: absolute;
    z-index: 1;
    top: 66%;
    left: 50%;
    width: 40%;
    transform: translateX(-50%);
}

/* ── L'altimètre ──────────────────────────────────────────────────────────────
   Cinq paliers, du haut vers le bas, répartis par le flux plutôt que positionnés un par un :
   `justify-content: space-between` sur une colonne pleine hauteur donne exactement la
   graduation, et un palier ajouté ou retiré se replace tout seul. */
.race__altimeter { display: none; }
[data-gesture='blow'] .race__altimeter {
    position: absolute;
    top: 2vh;
    bottom: 2vh;
    left: 0;
    right: 0;
    display: flex;
    flex-direction: column;
    justify-content: space-between;
    z-index: 0;
    pointer-events: none;
}
.race__altimeter-step {
    position: relative;
    display: block;
    height: 1px;
    background: rgba(232, 206, 122, 0.16);
}
.race__altimeter-step span {
    position: absolute;
    right: 2vh;
    bottom: 0.4vh;
    font-family: var(--font-mono);
    font-size: 1.4vh;
    letter-spacing: var(--track-label);
    color: var(--muted-faint);
}

[data-gesture='blow'] .race__balloon {
    position: absolute;
    /* ⚠️ La boîte DÉBORDE le coureur des deux côtés, et ce n'est pas un réglage esthétique : le
       dessin fait 200 × 280, donc contraint à la largeur de l'éléphant il se réduisait à une tache
       derrière lui — rendu, présent, illisible depuis la salle. La hauteur reste `auto` pour que le
       rapport du dessin la décide : la fixer en pourcentage ne grandit rien, un SVG se contentant de
       s'inscrire dans la boîte la plus contraignante. */
    right: -25%;
    bottom: -2%;
    left: -25%;
    z-index: -1;
    display: block;
    filter: drop-shadow(0 6px 10px rgb(7 11 22 / 55%));
}
.race__balloon .balloon { display: block; width: 100%; height: auto; overflow: visible; }

/* ⚠️ Le brûleur est allumé PAR LA CSS sur l'écran projeté, et l'attribut `opacity` rendu par le
   serveur reste à zéro. La scène n'a pas de mesure : elle connaît le score diffusé, pas le souffle
   de chaque équipe. Inventer une intensité depuis le score dirait « cette équipe souffle fort en ce
   moment » alors que le score est un cumul — un mensonge que personne ne pourrait détecter depuis
   la salle. Une flamme constante dit seulement « le brûleur marche », ce qui est vrai. */
[data-gesture='blow'] .race__balloon .balloon__flame { opacity: 0.85; }
[data-gesture='blow'] .race__balloon .balloon__halo { opacity: 0.5; }

/* Le ciel, sur la manche de souffle seulement, et porté par la PISTE comme l'eau — même correction
   et même piège : le sol est une bande basse de 22vh, les deux couloirs sont empilés au-dessus, donc
   un ciel peint en bas laisserait la montgolfière de Volta voler dans le noir. */
[data-gesture='blow'] .race__track {
    background-image:
        repeating-linear-gradient(80deg, rgb(255 255 255 / 5%) 0 22px, transparent 22px 90px),
        linear-gradient(180deg, rgb(63 169 255 / 22%), rgb(10 24 48 / 0%));
    background-size: auto, auto;
    animation: skyDrift 9s linear infinite;
}

/* Le parquet disparaît aussi : un plancher sous des montgolfières se lit comme un décor resté au
   sol. Même raison que sous l'eau. */

/* Plus lent que l'eau, délibérément : la dérive de nuages qui filerait à la vitesse d'un courant
   raconterait une tempête. Vers la gauche, pour que le vol se lise vers la droite — le sens de la
   course. */
@keyframes skyDrift {
    from { background-position: 0 0, 0 0; }
    to   { background-position: -120px 0, 0 0; }
}

/* ═══ La Ola — manche 4, LA MANCHE QUI NE MESURE RIEN ═══
 *
 * Toute la peau est un ORDRE, et rien d'autre. Aucune jauge, aucun vu-mètre, aucun couloir : le
 * jeu ne prend aucune mesure pendant cette manche — `RoundGesture::isScored()` est faux et
 * `TapController` refuse les lots — donc tout ce qui ressemblerait à une lecture affirmerait une
 * mesure qui n'existe pas. Cette retenue est le dessin, pas un oubli.
 *
 * Les deux surfaces sont traitées ENSEMBLE, sélecteur par sélecteur, comme le reste des peaux :
 * l'écran projeté et l'écran joueur ne peuvent pas se contredire, et deux blocs séparés sont deux
 * blocs qui divergent.
 */

/* --- Les retraits, sur le téléphone ---
 *
 * Même forme que ceux du souffle, et ⚠️ MÊME PORTÉE À `.stage--play`, pour la même raison : ces
 * classes existent aussi dans l'étape de répétition du lobby, et `data-gesture` porte déjà le
 * geste de la manche à venir pendant que le lobby est affiché. Sans la portée, arriver dans le
 * lobby avant la manche 4 viderait l'écran de répétition.
 *
 * `.own-taps` part avec le reste, et c'est le retrait qui compte le plus : c'est le SEUL chiffre
 * de l'écran joueur, et un compteur d'appuis sur une manche où aucun appui n'est compté serait un
 * mensonge — celui-là même que le refus serveur existe pour rendre impossible.
 *
 * ⚠️ Par le CSS et JAMAIS par un retrait en JavaScript : les cibles Stimulus continuent d'être
 * peintes, donc le snapshot reste idempotent et une reconnexion ne peut pas rendre un écran à
 * moitié monté. */
[data-gesture='ola'] .stage--play .minirace,
[data-gesture='ola'] .stage--play .lead-label,
[data-gesture='ola'] .stage--play .own-taps,
[data-gesture='ola'] .stage--play .tap-button__cadence,
[data-gesture='ola'] .stage--play .tap-button__cadence-label { display: none; }

/* --- L'écran joueur (A9) --- */

.ola-round { display: none; }
/* ⚠️ LE PANNEAU D'APPUI DISPARAÎT SUR LA OLA, il ne se contente pas de s'annoncer inactionnable.

   Il portait « RIEN À TAPER » et « Suis l'ordre à l'écran — tes appuis ne comptent pas », soit un
   grand cadre parchemin dont tout le contenu servait à dire qu'il ne servait à rien. C'est la
   moitié basse de l'écran — la moitié où tombe le pouce — occupée par une négation, sur la seule
   manche où le joueur doit regarder AILLEURS : le mur, et les gens autour de lui. Un cadre qu'on
   lit pour apprendre qu'il n'y a rien à y lire coûte le regard qu'il attire.

   Retiré par la peau et non par le gabarit, parce que l'écran de jeu est UN gabarit commuté par
   `data-gesture` côté client : la manche n'est pas connue au rendu. Le bouton reste donc dans le
   DOM, et `onTap()` garde son refus explicite — voir `isScenicRound()`. Ce qui disparaît est ce
   que le joueur voit, pas la garde. */
[data-gesture='ola'] .stage--play .tap-button { display: none; }
/* La ola est la seule manche dont le panneau n'est pas un bouton : rien à taper, donc rien à
   presser. Il prend malgré tout le parchemin, et pour la même raison que les autres — c'est la
   surface qui porte le contenu. Marges latérales plutôt que pleine largeur : la maquette le
   montre en carte centrée de 300 px, et une carte se distingue d'un écran. */
[data-gesture='ola'] .stage--play .ola-round {
    display: flex;
    flex: 1;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 1.2vh;
    text-align: center;
    max-width: 340px;
    margin: auto;
    padding: var(--sp-7) var(--sp-6);
    border: 1px solid var(--paper-gold);
    border-radius: var(--radius-panel);
    background: var(--paper-surface);
    color: var(--paper-ink);
}

.ola-round__eyebrow {
    margin: 0;
    font-family: var(--font-mono);
    font-size: 0.62rem;
    letter-spacing: 0.22em;
    text-transform: uppercase;
    color: rgba(22, 35, 61, 0.55);
}

/* Le CONTEXTE : quelle équipe est appelée. Elle garde la teinte de l'équipe appelée, qui est la
   partie qui se comprend sans lire.

   ⚠️ CE N'EST PLUS LA LIGNE GÉANTE, et l'inversion est un correctif de revue UX. Cette ligne nomme
   ce que fait l'équipe APPELÉE — donc, pour un joueur de l'autre équipe, elle ordonne quelque chose
   qui ne le concerne pas. À 2,6 rem contre 1,15 rem pour l'ordre qui le concerne, l'écran criait la
   mauvaise consigne. Le contexte reste utile — la vague est collective, savoir qui est debout en fait
   partie — mais il passe derrière l'ordre. Voir `.ola-round__you`. */
.ola-round__order {
    margin: 0;
    font-family: var(--font-display);
    font-size: clamp(1.05rem, 4.5vw, 1.3rem);
    font-weight: 700;
    line-height: 1.2;
    color: rgba(22, 35, 61, 0.72);
}
.ola-round__order em {
    font-style: normal;
    /* Repli en or LISIBLE : la teinte de l'équipe appelée est posée juste en dessous par
       `[data-ola-team]`, mais un instant sans attribut ne doit pas rendre le nom illisible — et
       `--paper-gold` ne tient que 2,7:1, donc il ne pouvait pas jouer ce rôle. */
    color: var(--paper-gold-ink);
}
.ola-round__order strong { font-weight: inherit; }

/* ⚠️ LA LIGNE GÉANTE DE CET ÉCRAN, et elle l'est devenue à une revue UX. L'ordre au lecteur garde
   sa taille quand il dit le contraire : « reste assis » n'est pas un message mineur, le contresens
   fait se lever à contretemps, et c'est la seule façon de gâcher une vague. C'est le même élément
   dans les deux cas, donc la propriété est structurelle et ne peut pas se perdre d'un état à l'autre.

   Elle a la taille que portait la ligne de l'équipe appelée, et pour la raison exacte qui rendait
   cette dernière trompeuse : c'est la seule phrase de l'écran qui s'adresse à la personne qui le
   tient. `text-wrap: balance` parce que les deux états n'ont pas la même longueur et qu'un mot seul
   sur la seconde ligne casse la lecture d'un ordre. */
.ola-round__you {
    margin: 0;
    font-family: var(--font-display);
    font-size: clamp(1.9rem, 9vw, 2.6rem);
    font-weight: 900;
    line-height: 1.05;
    color: var(--paper-ink);
    text-wrap: balance;
}

.ola-round .crowd { font-size: 1rem; margin-top: 0.6rem; }

/* --- Les décors narratifs des manches 2 et 3 ---------------------------------

   Masqués par défaut et allumés par la peau du geste. Le motif est celui de la barque et de la
   montgolfière : dans le DOM en permanence, la CSS décide. */
.race__lamps,
.race__night { display: none; }

/* ── Manche 2 : les réverbères ────────────────────────────────────────────────
   Six lampadaires le long du quai. C'est le seul décor narratif de la manche de rame, et il
   coûte une boucle de six éléments : un mât, et un halo posé en `::after`.

   `z-index: 0` et non une valeur positive : ils doivent passer DERRIÈRE les couloirs, qui sont
   à `z-index: 1`. Un réverbère devant la barque serait un poteau planté dans l'eau. */
[data-gesture='row'] .race__lamps {
    position: absolute;
    left: 0;
    right: 0;
    bottom: 38%;
    display: block;
    height: 12vh;
    z-index: 0;
    pointer-events: none;
}
.race__lamp {
    position: absolute;
    bottom: 0;
    /* Répartis sur la largeur par le pas d'index. 14% de marge de chaque côté pour qu'aucun mât
       ne touche le bord — un lampadaire coupé par le cadre se lit comme un défaut d'affichage. */
    left: calc(14% + (var(--i) - 1) * 14.4%);
    width: 0.4vh;
    height: 100%;
    background: var(--border);
}
.race__lamp::after {
    content: '';
    position: absolute;
    top: -1.4vh;
    left: 50%;
    width: 2.8vh;
    height: 2.8vh;
    margin-left: -1.4vh;
    border-radius: 50%;
    background: radial-gradient(circle, var(--gold-light), transparent 65%);
    /* Décalage d'animation par l'index : six halos qui pulseraient à l'unisson se liraient comme
       un clignotement de l'écran entier, pas comme des lampes. */
    animation: filament 4s ease-in-out infinite;
    animation-delay: calc(var(--i) * 0.4s);
}

/* ── Manche 3 : la nuit et les étoiles ────────────────────────────────────────
   Un voile de nuit sur la moitié haute, et vingt-quatre étoiles qui scintillent. La manche
   d'ascension est la seule qui regarde vers le HAUT de l'écran : c'est là qu'il fallait mettre
   quelque chose à voir. */
[data-gesture='blow'] .race__night {
    position: absolute;
    inset: 0 0 30% 0;
    display: block;
    z-index: 0;
    pointer-events: none;
    background: linear-gradient(180deg, rgba(10, 20, 40, 0.55), transparent);
}
.race__star {
    position: absolute;
    left: var(--x);
    top: var(--y);
    width: 0.4vh;
    height: 0.4vh;
    border-radius: 50%;
    background: var(--gold-pale);
    animation: filament 3s ease-in-out infinite;
    animation-delay: calc(var(--i) * 0.13s);
}

/* --- L'écran projeté (B6) --- */

.race__ola,
.race__ola-label { display: none; }

/* Le titre de la manche, sur les QUATRE manches depuis #36 — voir l'invariant levé dans
   `scene/index.html.twig`. Il n'est plus masqué par défaut : c'est `data-gesture-only` qui ne
   laisse passer que la variante du geste en cours, huit éléments dans le DOM et deux visibles.

   Il reste la consigne et non une décoration, et c'est encore plus vrai pour la ola : les trois
   autres manches se comprennent en regardant les éléphants, celle-ci demande à mille personnes
   de se lever. Pour les trois premières, le titre dit à la salle OÙ ELLE EN EST dans la soirée —
   ce qu'aucun élément de cet écran ne disait. */
.race__round-title {
    display: flex;
    flex-direction: column;
    gap: 0.6vh;
    margin: 0;
    /* Bornée en largeur pour que « LA MONTGOLFIÈRE » — deux fois plus long que « LA OLA » — ne
       pousse pas le chrono hors de l'axe de l'écran. Il passe alors à la ligne, ce qui est
       accepté et non subi : `.race__head` a un plancher de hauteur pour que le bandeau ne
       change pas de taille entre deux manches, et c'était le vrai risque. */
    max-width: 40vh;
}
.race__round-title span {
    font-family: var(--font-mono);
    font-size: 1.8vh;
    letter-spacing: var(--track-wide);
    text-transform: uppercase;
    color: rgba(22, 35, 61, 0.6);
}
.race__round-title strong {
    font-family: var(--font-display);
    /* ⚠️ En `vh` et JAMAIS en `vw`. Le premier jet employait un `clamp` avec un terme en `vw`,
       et le mot débordait du bandeau sur un écran large : cet écran se dimensionne entièrement
       en hauteur, parce que la seule chose qu'on sache du vidéoprojecteur du jour J est son
       rapport d'image. Mélanger les deux axes fait dépendre la mise en page d'une largeur qu'on
       ne connaît pas. */
    font-size: 4.8vh;
    font-weight: 900;
    line-height: 0.95;
    color: var(--paper-ink);
}

/* Le décompte de bascule PREND LA PLACE du temps restant, et les scores disparaissent : la manche
   ne compte rien, le cumul est décidé à la fin de la manche 3, et le remontrer ici laisserait
   croire qu'il bouge encore. */
[data-gesture='ola'] .race__clock strong[data-clock],
[data-gesture='ola'] .race__clock-label,
[data-gesture='ola'] .race__team,
[data-gesture='ola'] .race__track { display: none; }

/* ⚠️ HIÉRARCHIE REÉQUILIBRÉE APRÈS UNE REVUE UX, et le rapport était à l'envers du besoin de la
   salle. La ola est la SEULE manche où la salle doit lire le mur pour jouer — les trois autres se
   comprennent en regardant les éléphants. Ce qu'elle y trouvait, par ordre de taille : le décompte de
   bascule à 10vh, et l'ordre collectif — le nom de l'équipe appelée — à 1,6vh, soit environ 17 px sur
   une projection 1080p. Debout, à trente mètres, la question n'est pas « combien de secondes » mais
   « est-ce mon tour », et c'est le libellé qui y répond.

   ⚠️ LE LIBELLÉ N'EST PAS MONTÉ AUSSI HAUT QUE LE DÉCOMPTE ÉTAIT GROS, et c'est mesuré et non timide.
   En monospace avec 0,26 em d'interlettrage, « EDISON — LÈVE-TOI » fait dix-sept caractères à environ
   0,86 em d'avance chacun : à 7vh il réclamerait plus de cent vh de large et déborderait du bandeau.
   L'interlettrage descend donc avec l'agrandissement, et `max-width` plus `text-wrap: balance`
   transforment un dépassement résiduel en retour à la ligne plutôt qu'en débordement — la même
   protection, et pour la même raison, que `.race__round-title` juste au-dessus.

   ⚠️ À VALIDER SUR LE VIDÉOPROJECTEUR DE LA SALLE, pas sur un écran de bureau et surtout pas dans
   l'aperçu en vignette du panneau, qui est à 19 % et ne permet aucun jugement de lisibilité. */
/* ⚠️ LE DÉCOMPTE A ÉTÉ RETIRÉ (#45) ET LE LIBELLÉ MONTE D'AUTANT, sans aller jusqu'à sa taille :
   3,2vh → 4,2vh. La contrainte de largeur expliquée juste au-dessus n'a pas bougé — dix-sept
   caractères en monospace interlettré — et c'est elle, pas la timidité, qui fixe le plafond. Le
   bandeau ne porte plus qu'une chose, donc elle occupe la place des deux sans avoir à doubler.

   ⚠️ À REVOIR SUR LE VIDÉOPROJECTEUR, comme le reste de ce bloc. */
[data-gesture='ola'] .race__ola-label {
    display: block;
    font-family: var(--font-mono);
    font-size: 4.2vh;
    font-weight: 600;
    letter-spacing: 0.12em;
    line-height: 1.15;
    text-transform: uppercase;
    max-width: 44vh;
    text-wrap: balance;
    /* L'encre pleine et non 60 % : c'est devenu la consigne, et une consigne à 60 % d'opacité sur
       un vidéoprojecteur perd le contraste deux fois. La teinte d'équipe est posée juste en dessous
       par `[data-ola-team]`. */
    color: var(--paper-ink);
}

/* La foule tient la piste, éléphants au repos de part et d'autre. Rien n'avance : personne ne
   mène, il n'y a rien à mener. */
/* ⚠️ SUR LA OLA, LE COIN QR CESSE DE RÉSERVER SA COLONNE — mais il RESTE, et cette distinction
   est tout l'objet de la règle.

   Le problème : `.scene-corner` est un FRÈRE de la scène dans une rangée flex. Il ne mesure qu'un
   quart de la hauteur, mais il réserve sa colonne sur TOUTE la hauteur. La foule, qui est tout en
   bas et qui EST le sujet de cette manche, laissait donc une colonne vide à sa gauche, sous un
   coin fini depuis longtemps — mesuré : coin de y=52 à 190, foule à partir de y=375, et 146 px de
   vide devant elle.

   ⚠️ LE PREMIER JET LE MASQUAIT, ET C'ÉTAIT FAUX. Le raisonnement paraissait solide — la ola est
   la dernière manche, donc « quelqu'un arrive pour la suivante » ne tient plus, et la maquette ne
   montre aucun QR sur B6. Mais `scene-scannability.spec.ts` porte une garde DÉDIÉE à cette peau,
   et son commentaire dit l'inverse : la ola est la seule manche dont le dessin occupe toute la
   largeur, donc la seule qui puisse occulter le code, et c'est la dernière — « un code occulté là
   ne se rattrape pas ». Le dépôt a tranché, et il a raison : quelqu'un qui entre dans la salle
   pendant la ola doit pouvoir scanner pour la partie suivante.

   La sortie est donc de le RETIRER DU FLUX sans le retirer de l'écran. En absolu il ne réserve
   plus rien, la scène prend toute la largeur, et le code reste au premier plan — ce que la garde
   vérifie par `qrIsOnTop()` et non par `toBeVisible()`, précisément parce qu'un code recouvert
   reste « visible ». Le bandeau de tête est décalé d'autant pour ne pas passer dessous. */
[data-gesture='ola'] .race__ola {
    display: flex;
    flex: 1;
    align-items: flex-end;
    min-height: 0;
    /* Marges resserrées depuis le retrait des mascottes : la foule est le sujet, elle prend la
       largeur. 3vh de chaque côté, juste de quoi ne pas coller au bord. */
    padding: 0 3vh 4vh;
}

/* ⚠️ LA FOULE TRAVERSE L'ÉCRAN, et ce n'est pas obtenu en la laissant grandir « à 100 % de son
   conteneur » : elle le faisait déjà, et le conteneur ne faisait pas la largeur de l'écran.

   Deux choses le décident. D'abord le retrait des mascottes, qui rend 60vh au conteneur. Ensuite
   la TAILLE des personnes, qui est ce qui fixe la largeur du rang : une personne fait 0,66 em de
   large et les gouttières 0,35 em, donc 24 personnes occupent 23,9 em. Pour couvrir les ~172vh
   restants, il faut donc 7vh et non 6 — à 6vh le rang mesurait 143vh et laissait trente
   centimètres de vide de chaque côté sur un écran de six mètres.

   `space-between` par-dessus, comme filet : il n'a presque rien à distribuer en 16/9, et il
   rattrape un vidéoprojecteur plus large que prévu — la seule chose qu'on ne saura pas avant le
   matin même. */
/* ⚠️ `min()` DE DEUX AXES, et c'est l'une des deux seules exceptions de cet écran à la règle
   « tout en vh ». Elle se justifie parce que la contrainte est ici franchement HORIZONTALE : un
   rang de vingt-quatre personnes doit tenir dans la largeur, et sa largeur vaut 23,9 em quoi
   qu'il arrive — 0,66 em par personne, 0,35 em de gouttière.

   Le terme calculé est donc la borne qui empêche le débordement, et il est EXACT plutôt
   qu'approché : la largeur disponible vaut `100vw` moins les marges — 3vh de padding de section
   et 3vh de padding de la ola, de chaque côté, soit 12vh — et le rang doit y tenir en 23,9 em.
   Diviser par 24 laisse un cheveu de jeu.

   Le terme en `vh` est le plafond de TAILLE : au-delà de 7vh les personnes deviennent des géants
   sur un écran très large.

   Deux jets ont raté avant celui-ci, et pour la même raison — une valeur devinée plutôt que
   dérivée. `7vh` seul était calculé pour un 16/9 et débordait dès 1,58. `4vw` ignorait les
   marges et débordait encore de 16 px. Le calcul, lui, ne dépend d'aucune proportion. */
.race__ola .race__crowd { flex: 1; font-size: min(7vh, calc((100vw - 12vh) / 24)); }
.race__ola .crowd {
    width: 100%;
    justify-content: space-between;
}
.race__ola-caption {
    position: absolute;
    left: 0;
    right: 0;
    bottom: 1vh;
    margin: 0;
    font-family: var(--font-mono);
    font-size: 1.8vh;
    letter-spacing: 0.3em;
    text-transform: uppercase;
    text-align: center;
    color: var(--muted-faint);
}

/* --- La foule, et la vague qui la traverse ---
 *
 * ⚠️ LA VAGUE EST EN CSS PURE, portée par un `animation-delay` échelonné à partir de `--i`. Le
 * prototype recalcule une position de crête depuis `Date.now()` et force un rendu toutes les
 * 80 ms : c'est un contournement du fait d'avoir mis la vague en JavaScript, pas un choix à
 * reprendre. Ici la vague ne coûte aucun travail au fil de l'eau, ni sur le mur ni sur mille
 * téléphones d'entrée de gamme.
 *
 * Les dimensions sont en `em` : le conteneur pose une taille et la foule s'échelonne. C'est ce qui
 * permet aux deux surfaces de partager ce bloc — 1 rem sur le téléphone, 6 vh sur le mur.
 *
 * La couleur passe par `currentColor` : l'animation ne teinte que `color` sur la personne, et la
 * tête comme le corps la suivent. Sans cela il faudrait animer deux `background` en parallèle,
 * donc deux fois la même vague à garder d'accord. */
.crowd {
    display: flex;
    align-items: flex-end;
    justify-content: center;
    gap: 0.35em;
    height: 2.6em;
}
.crowd__person {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: flex-end;
    color: var(--crowd-rest, #3A3226);
    animation: ola-wave 2.4s ease-in-out infinite;
    /* ⚠️ Le décalage combine DEUX termes, et le second est le rebat de la vague (#36).
       `var(--i) * 0.12s` échelonne les personnes : c'est ce qui fait une vague plutôt qu'un
       sursaut collectif. `var(--wave-offset)` est écrit par les deux contrôleurs depuis le temps
       de manche, une fois par snapshot : c'est ce qui met la vague du téléphone en phase avec
       celle du mur. Sans lui, la phase démarrait au rendu de l'élément, donc deux téléphones
       arrivés à trente secondes d'écart avaient deux vagues différentes.
       Négatif, parce qu'`animation-delay` négatif démarre une animation déjà commencée.
       Voir `waveOffsetMs()` dans `ola_turn.js`, qui porte le raisonnement complet. */
    animation-delay: calc(var(--i) * 0.12s - var(--wave-offset, 0ms));
}
.crowd__head {
    width: 0.5em;
    height: 0.5em;
    margin-bottom: 0.08em;
    border-radius: 50%;
    background: currentColor;
}
.crowd__body {
    width: 0.66em;
    height: 1.1em;
    border-radius: 0.3em 0.3em 0.08em 0.08em;
    background: currentColor;
}

/* ⚠️ La teinte de la crête suit l'équipe APPELÉE et non celle du lecteur : le mur n'appartient à
   aucune équipe, et un téléphone qui teindrait sa foule à sa propre couleur contredirait le mur
   une fois sur deux. `data-ola-team` est écrit par les deux contrôleurs depuis `ola_turn.js`. */
[data-ola-team='volta']  { --crowd-crest: var(--volta-glow); }
[data-ola-team='edison'] { --crowd-crest: var(--edison-glow); }

/* ⚠️ CE QUI DÉCIDE ICI, C'EST LE FOND DE L'ÉLÉMENT, PAS LA SURFACE. Les teintes de lueur —
   `#7FE8FF`, `#FFC773` — sont calibrées pour un fond noir et tombent autour de 1,4:1 sur du
   crème. Le renversement de surface ne s'est pas arrêté au téléphone : le BANDEAU DE TÊTE du mur
   est lui aussi du parchemin, et le décompte de la ola y vit.

   D'où la règle, qui est plus simple qu'elle n'en a l'air : tout ce qui est POSÉ SUR DU PAPIER
   prend l'encre, où que ce soit. Les lueurs ne restent que sur ce qui a gardé le fond sombre —
   la piste, les couloirs, la foule du mur. */
/* La teinte suit le LIBELLÉ, qui est ce qui porte la consigne : la correspondance couleur-équipe
   ne s'apprend que sur le téléphone, donc la salle doit avoir la couleur ET le mot. Le chiffre qui
   la portait avec lui a été retiré (#45). */
[data-ola-team='volta']  .race__ola-label { color: var(--volta-ink); }
[data-ola-team='edison'] .race__ola-label { color: var(--edison-ink); }

[data-ola-team='volta']  .ola-round__order em { color: var(--volta-ink); }
[data-ola-team='edison'] .ola-round__order em { color: var(--edison-ink); }

/* La foule du téléphone est elle aussi sur le papier : au repos elle passe du brun sombre au
   crayonné clair, et sa crête prend l'encre plutôt que la lueur. Le mur garde les siennes.
   `--crowd-rest` est lu par le `@keyframes`, qui est PARTAGÉ par les deux surfaces — c'est ce qui
   permet de ne pas écrire deux vagues. */
.ola-round .crowd { --crowd-rest: var(--paper-muted); }
[data-ola-team='volta']  .ola-round .crowd { --crowd-crest: var(--volta-ink); }
[data-ola-team='edison'] .ola-round .crowd { --crowd-crest: var(--edison-ink); }

/* Le halo de la crête. `drop-shadow` et non `box-shadow` : le filtre suit les FORMES peintes —
   le disque de la tête et le corps arrondi — là où l'ombre de boîte dessinerait une lueur
   rectangulaire autour du conteneur, tête et corps compris, y compris le vide entre les deux.

   Posé sur `.crowd__person` et non sur ses deux enfants : c'est la même raison que pour
   `currentColor` juste au-dessus — deux animations parallèles seraient deux vagues à garder
   d'accord. Un seul filtre sur le parent couvre les deux formes.

   Le flou reste modeste (0,3 em) : il y a vingt-quatre personnes, et sur le téléphone c'est
   vingt-quatre filtres composités à chaque image, sur du matériel d'entrée de gamme. */
@keyframes ola-wave {
    0%, 72%, 100% {
        color: var(--crowd-rest, #3A3226);
        transform: translateY(0);
        filter: drop-shadow(0 0 0 transparent);
    }
    36% {
        color: var(--crowd-crest, var(--gold-light));
        transform: translateY(-45%);
        filter: drop-shadow(0 0 0.3em var(--crowd-crest, var(--gold-light)));
    }
}

/* --- Aucun chrono de manche, nulle part (#3) ---

   Le ticket est explicite : « il n'y aura pas de délais imparti par manche, ce sera l'animateur qui
   décidera quand arrêter ». Un chrono affiché serait donc un mensonge — il annoncerait une échéance
   que personne n'a le droit de faire respecter.

   ⚠️ MASQUÉ PAR LA PEAU, PAS SUPPRIMÉ DU CONTRÔLEUR, et la distinction porte le reste du dispositif :
   `game:playEndsAt` subsiste comme FILET très long (voir `compose.yaml`), parce que
   `GameStore::effectivePhase()` en dérive la fin de manche et que c'est la protection contre la
   boucle de jeu — point de défaillance unique du projet. Ce qui disparaît est l'affichage, pas la
   borne.

   Le DÉCOMPTE D'AVANT-MANCHE reste, lui, et ce n'est pas une exception : « départ dans 5 » n'est pas
   un délai imparti, c'est le top de départ, et c'est le moment où l'animateur fait compter la salle
   à voix haute.

   ⚠️ ÉCRIT EN POSITIF — seul `countdown` s'affiche — ET LA PREMIÈRE RÉDACTION NE L'ÉTAIT PAS. Elle
   masquait `play` et `over` nommément, donc tout état non énuméré traversait : `LOBBY` n'a pas
   d'entrée dans `CLOCK_KINDS`, et le HTML servi ne portait pas encore l'attribut du tout. Le mur
   annonçait donc « — / Temps restant » pendant toute la salle d'attente et à chaque rechargement
   avant le premier snapshot. Une liste de ce qu'on cache est un inventaire à tenir à jour ; une
   liste de ce qu'on montre se referme toute seule.

   Le corollaire est dans le gabarit : `data-clock=""` y est écrit en dur, parce qu'une règle en
   positif ne peut rien contre un attribut absent.

   Le libellé suit son chiffre — il est le frère adjacent, écrit par le même rendu. Sans lui le mur
   afficherait un libellé au-dessus de rien. */
.race__clock strong[data-clock]:not([data-clock='countdown']),
.race__clock strong[data-clock]:not([data-clock='countdown']) + .race__clock-label { display: none; }

/* --- « Calcul du score… » : ce que les deux surfaces disent entre la ola et le crash ---

   Demandé par le ticket #12 mot pour mot, et il REMPLACE l'annonce du vainqueur au lieu de s'y
   ajouter — voir le raisonnement complet dans `scene/index.html.twig`.

   ⚠️ À LA CHARTE DU JEU, et surtout PAS au vocabulaire du terminal qui suit. C'est ce qui produit
   l'effet : l'application parle normalement — Bodoni, or pâle, la même voix que depuis le début de
   la soirée — puis elle cède. Un écran déjà monospace aurait annoncé la panne avant qu'elle
   n'arrive, et il ne resterait plus rien à surprendre. Il réemploie donc `.winner` et `.eyebrow`,
   les classes que la salle lit déjà à chaque fin de manche : la rupture se mesure à leur
   disparition. */
.race__computing { display: none; }

/* ⚠️ POSÉ COMME `.race__winner`, au caractère près pour le positionnement, et ce n'est pas de la
   symétrie : il prend EXACTEMENT sa place — même centre, même pile, même fond opaque. Deux raisons.
   La première est qu'il le remplace, donc la salle doit lire la même chose au même endroit d'une
   manche à l'autre. La seconde a été trouvée à l'œil : en flux normal il tombait sous la foule et
   chevauchait le sol qui défile, sur un mur où « lisible » veut dire « à trente mètres ».

   Le fond opaque a la même justification que pour le vainqueur : le texte est posé par-dessus une
   foule et un sol texturé, et une simple ombre n'y suffit pas.

   ⚠️ ET LE COUPLE SE TIENT PAR LES DEUX BOUTS. Quand le verdict est passé en cartouche parchemin
   (#36), ce bloc-ci est resté une dalle noire pendant le temps d'une relecture — deux registres
   différents au même pixel, à la suite l'un de l'autre, sur le moment que mille personnes
   regardent. Le commentaire ci-dessus disait pourtant « au caractère près » : c'est exactement le
   genre de couplage qu'on casse sans le voir, parce qu'on modifie l'un des deux blocs et que rien
   ne rougit. Toucher à l'un des deux impose de relire l'autre — il n'y a pas de garde pour ça. */
[data-gesture='ola'] .race__computing {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    z-index: 2;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 1vh;
    margin: 0;
    padding: 3vh 6vh;
    color: var(--paper-ink);
    background: var(--paper-surface);
    border: 1px solid var(--paper-gold);
    border-bottom: 0.9vh solid var(--paper-gold);
    border-radius: var(--radius-panel);
    box-shadow: 0 2vh 6vh rgb(0 0 0 / 55%);
    /* Le titre et ses points tiennent sur UNE ligne : sans cela les points passent à la ligne et se
       posent seuls sous le titre, ce qui se lit comme un défaut d'affichage plutôt que comme une
       attente. Observé, corrigé. */
    white-space: nowrap;
}

/* Le surtitre suit le fond et non la surface : `--muted-dim` était calibré pour la dalle noire et
   tombe sous 2:1 sur le parchemin. Même règle que partout ailleurs dans cette refonte — ce n'est
   pas l'écran qui décide de l'encre, c'est le fond de l'élément. */
[data-gesture='ola'] .race__computing span {
    font-family: var(--font-mono);
    font-size: 2.2vh;
    letter-spacing: 0.18em;
    text-transform: uppercase;
    color: var(--paper-ink-soft);
}

/* ⚠️ `--gold-pale` ÉTAIT DEVENU CRÈME SUR CRÈME. Il était juste tant que ce bloc était une dalle
   noire ; le passage en cartouche parchemin l'a laissé à 1,2:1 — le titre le plus regardé de la
   soirée, illisible, sur un fond qui a l'air correct en capture tant qu'on ne lit pas le texte.
   Trouvé à l'œil et non par une garde : aucune ne mesure un contraste ici.

   La graisse passe à 900 avec l'encre : sur le parchemin, le 400 du Bodoni s'amincit là où il
   tenait sur du noir. */
[data-gesture='ola'] .race__computing strong {
    font-family: var(--font-display);
    font-weight: 900;
    font-size: 9vh;
    color: var(--paper-ink);
}

/* Le téléphone : le verdict ordinaire cède la place au calcul, sur la peau ola uniquement. */
.result-computing { display: none; }
[data-gesture='ola'] .stage--result .result-verdict { display: none; }
[data-gesture='ola'] .stage--result .result-computing { display: block; }

/* Les trois points qui avancent. La seule chose animée de cet écran, et elle porte une INFORMATION
   plutôt qu'une décoration : elle dit que la machine travaille, ce qui rend le silence d'après
   lisible comme une panne et non comme un écran oublié.

   ⚠️ `computing-dots` ET SURTOUT PAS `dots`, qui EXISTE DÉJÀ dans ce fichier (ligne ~855 : trois
   pastilles animées, `display: flex`). La collision a été écrite puis observée à l'écran : le `<i>`
   héritait de `display: flex`, son `::after` était blockifié, et l'ellipse tombait seule sous le
   titre, à gauche, sur un mur de six mètres. Les deux composants auraient été cassés à la fois.

   L'existant n'est pas réemployé, et c'est un arbitrage : ses pastilles font 12 px, donc invisibles
   à trente mètres, et une ellipse typographique dans le titre Bodoni se dimensionne en `vh` avec
   lui. Deux composants pour deux distances de lecture. */
.computing-dots::after {
    content: '…';
    display: inline-block;
    animation: computing-ellipsis 1.4s steps(1, end) infinite;
    /* Largeur réservée pour les trois points : sans elle le titre se recentre à chaque étape, et un
       titre qui se balance sur six mètres de mur se lit comme un défaut d'affichage. */
    width: 1.6em;
    text-align: left;
    vertical-align: bottom;
}

@keyframes computing-ellipsis {
    0%   { content: '.'; }
    33%  { content: '..'; }
    66%, 100% { content: '…'; }
}

/* --- Le faux crash (#12) : un terminal, sur les deux surfaces ---

   ⚠️ CETTE PEAU ROMPT AVEC LA CHARTE DU JEU, ET C'EST LE SUJET. Tout le reste de l'application est
   en Bodoni display et or pâle, façon affiche d'exposition universelle ; ici, monospace, bleu nuit
   et châssis de fenêtre. C'est la rupture qui produit l'effet : l'écran ne présente plus le jeu, il
   montre ses coulisses en train de céder. Une peau respectant la charte se serait lue comme un
   cinquième écran du jeu, donc comme quelque chose de prévu.

   ⚠️ UN SEUL BALISAGE, DEUX ÉCHELLES. Le partiel `scene/_crash.html.twig` est rendu tel quel sur le
   mur et sur le téléphone ; ce sont les deux blocs de contexte plus bas — `.scene-form--crash` et
   `.stage--crash` — qui les dimensionnent. Les tailles de la scène sont en `vh` comme partout
   ailleurs sur cet écran : il est lu à trente mètres, et la seule dimension qui compte est sa
   hauteur de projection.

   Les couleurs sont posées ICI et le contrôleur n'en décide aucune : il écrit `data-tone`, la CSS
   en tire la teinte. Même grammaire que `data-gesture`, `data-clock` et `data-winner`. */
.crash {
    /* Le bleu nuit du prototype. Local au composant plutôt que dans `:root` : c'est la seule
       surface du jeu qui l'emploie, et le remonter en variable globale suggérerait qu'une autre
       pourrait le partager. */
    --crash-bg: #10222F;
    --crash-chrome: #17303F;
    --crash-dim: #7C93A3;
    --crash-ink: #C7D6E0;
    --crash-ok: #35D0BA;
    --crash-error: #FF4A5B;

    width: 100%;
    display: flex;
    flex-direction: column;
    overflow: hidden;
    border-radius: var(--radius);
    background: var(--crash-bg);
    font-family: var(--font-mono);
    text-align: left;
}

.crash__chrome {
    display: flex;
    align-items: center;
    gap: 1em;
    padding: 0.9em 1.2em;
    background: var(--crash-chrome);
    color: var(--crash-dim);
}

.crash__dots { display: flex; gap: 0.5em; flex: 0 0 auto; }
.crash__dots i { width: 0.85em; height: 0.85em; border-radius: 50%; }
/* Les trois pastilles de la maquette. Écrites en littéral et non en variables d'équipe : ce sont
   les boutons d'une fenêtre, elles ne parlent pas du jeu. */
.crash__dots i:nth-child(1) { background: #FF5F57; }
.crash__dots i:nth-child(2) { background: #FEBC2E; }
.crash__dots i:nth-child(3) { background: #28C840; }

.crash__title { font-size: 0.95em; letter-spacing: 0.02em; }

/* Le log : UN PARAGRAPHE QUI COULE, et non des lignes de terminal.

   C'est ce que fait la maquette, et c'est le bon choix pour un mur : des lignes courtes justifiées
   à gauche laisseraient de grandes zones vides et rendraient la `Fatal error` moins haute, donc
   moins lisible de loin. La hiérarchie de lecture est portée par la COULEUR, pas par la mise en
   page.

   `min-height` plutôt qu'une hauteur libre : le texte se tape, donc le bloc grandirait sous les
   yeux de la salle et le châssis remonterait à chaque ligne. Réserver la place dès le départ est ce
   qui rend la fenêtre immobile. */
.crash__log {
    flex: 1;
    margin: 0;
    padding: 1.4em;
    line-height: 1.75;
    color: var(--crash-ink);
    overflow-wrap: break-word;
}

/* Les tons. Le module `crash_log.js` les NOMME, ces sept règles les habillent. */
.crash__log [data-tone='comment'],
.crash__log [data-tone='step'],
.crash__log [data-tone='trace'] { color: var(--crash-dim); }
.crash__log [data-tone='plain'] { color: var(--crash-ink); }
.crash__log [data-tone='ok'] { color: var(--crash-ok); }
/* ⚠️ Les deux SEULES chaînes dont la lecture est fonctionnelle : l'une clôt le jeu, l'autre annonce
   ce qui suit. D'où le gras, et d'où le fait qu'elles soient les plus contrastées de l'écran. */
.crash__log [data-tone='error'] { color: var(--crash-error); font-weight: 600; }
.crash__log [data-tone='hook'] { color: var(--crash-ok); font-weight: 600; }
/* ⚠️ Les tags d'équipe portent les teintes d'ÉQUIPE et non l'accent unique de la maquette. Raison
   écrite dans `crash_log.js` : dans une salle sombre, l'appartenance se lit à la température avant
   de se lire au nom, et deux tags identiquement teintés seraient le seul endroit du jeu où ce
   contrat cesse d'être vrai. */
.crash__log [data-tone='team-volta'] { color: var(--volta-glow); font-weight: 600; }
.crash__log [data-tone='team-edison'] { color: var(--edison-glow); font-weight: 600; }

/* Le caret. C'est lui qui dit que la machine était vivante il y a une seconde — la seule chose
   décorative de cette surface, et donc la seule que `prefers-reduced-motion` retire. */
.crash__caret {
    display: inline-block;
    width: 0.55em;
    height: 1.05em;
    margin-left: 0.1em;
    vertical-align: text-bottom;
    background: var(--crash-ok);
    animation: crash-caret 1s step-end infinite;
}

@keyframes crash-caret {
    0%, 50% { opacity: 1; }
    50.01%, 100% { opacity: 0; }
}

/* La note pour lecteur d'écran : hors de l'œil, jamais hors de l'arbre d'accessibilité.

   ⚠️ NI `display: none` NI `visibility: hidden`, et c'est toute la raison de ces sept lignes : les
   deux retirent l'élément de l'arbre d'accessibilité, donc la note ne serait plus annoncée du tout
   et l'écran deviendrait muet pour la personne à qui elle est destinée. Le motif ci-dessous est le
   seul qui masque à l'œil en gardant l'annonce.

   Scopée au composant plutôt que sortie en utilitaire global : ce projet n'a pas de `visually-hidden`,
   et en introduire un ici serait ajouter une convention pour un seul usage. */
.crash__note {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    padding: 0;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
}

/* --- Le crash sur le MUR : plein cadre --- */
.scene-form--crash {
    align-items: center;
    justify-content: center;
    padding: 6vh;
}

/* Le terminal occupe la projection sans la remplir bord à bord : la marge est ce qui le fait lire
   comme une FENÊTRE posée sur l'écran plutôt que comme l'écran lui-même. */
.scene-form--crash .crash { max-width: 100%; font-size: 3.1vh; }
.scene-form--crash .crash__log { min-height: 44vh; }

/* --- Le crash sur le TÉLÉPHONE : la même fenêtre, resserrée ---

   `justify-content: center` vient de `.stage`, donc rien à réaffirmer ici : la fenêtre est centrée
   verticalement comme les autres écrans du joueur. */
.stage--crash { padding: 0 4px; }
.stage--crash .crash { font-size: 0.82rem; }
.stage--crash .crash__log { min-height: 44vh; }

/* ⚠️ Le mouvement décoratif s'arrête si le système le demande, et cela couvre plus que
   l'accessibilité : c'est aussi ce qui économise la batterie et le GPU d'un téléphone
   d'entrée de gamme. Le mouvement qui PORTE une information — la barre de score, la position
   dans le couloir, le chrono — n'est PAS désactivé : le supprimer rendrait l'écran muet. */
@media (prefers-reduced-motion: reduce) {
    /* ⚠️ `.minirace__ground` autant que `.race__ground` : la première est sur le TÉLÉPHONE, donc
       sur l'appareil que ce bloc existe précisément pour protéger. L'oublier laissait une
       animation décorative tourner pendant toute la manche sur un mobile d'entrée de gamme. */
    .minirace__ground { animation: none; }
    /* Les deux sols sont désormais portés par CHAQUE couloir, et leurs sélecteurs pèsent 0,2,0 :
       une réduction à 0,1,0 serait écrasée. Voir la garde `reduced-motion.spec.ts`. */
    [data-gesture='tap'] .race__lane::after,
    [data-gesture='tap'] .minirace__lane::after { animation: none; }
    /* ⚠️ RÉÉCRITES À LA SPÉCIFICITÉ DE LA PEAU, et c'est tout l'objet de ces quatre lignes. Les
       deux règles ci-dessus pèsent 0,1,0 ; un sélecteur de peau comme
       `[data-gesture='row'] .minirace__ground` pèse 0,2,0 et GAGNE. Sans ce doublon, la dérive de
       l'eau et la démarche continueraient de tourner chez précisément les gens que ce bloc
       protège — et sur le téléphone d'entrée de gamme dont il économise la batterie. */
    [data-gesture='row'] .race__track,
    [data-gesture='row'] .minirace__ground { animation: none; }
    /* ⚠️ Le ciel aussi, et à la même spécificité de peau (0,2,0) pour la même raison : c'est la
       cinquième fois sur ce projet qu'une règle de réduction pesant 0,1,0 est défaite en silence
       par un sélecteur de peau. Le calcul, écrit une fois de plus parce qu'il continue de se
       vérifier : `.race__track { animation: none }` vaut 0,1,0 et perd contre
       `[data-gesture='blow'] .race__track` qui vaut 0,2,0. */
    [data-gesture='blow'] .race__track { animation: none; }
    [data-gesture='tap'] .race__runner .elephant,
    [data-gesture='tap'] .minirace__runner .elephant,
    [data-gesture='tap'] .race__lane--edison .elephant { animation: none; }
    .reveal__rays,
    .reveal__glow,
    .lamp__beast .elephant--idle,
    .lamp__filament { animation: none; }

    /* Les décors narratifs des manches 2 et 3, ajoutés par la refonte de charte (#36). Purement
       décoratifs : ils s'arrêtent, et ils RESTENT VISIBLES — le scintillement n'anime que
       l'opacité, donc les figer laisse les halos et les étoiles allumés. Un ciel qui disparaîtrait
       en mouvement réduit priverait la manche 3 de son seul décor.

       Ici 0,1,0 SUFFIT, et c'est dit plutôt que supposé : aucune règle de peau ne touche
       l'animation de ces deux sélecteurs — `[data-gesture='row'] .race__lamps` et
       `[data-gesture='blow'] .race__night` ne posent que `display` sur le CONTENEUR. Rien à
       surenchérir, donc rien à surenchérir. Le doublon serait du bruit, pas de la prudence. */
    .race__lamp::after,
    .race__star { animation: none; }
    .countdown { animation: none; }
    .dots span { animation: none; }

    /* ⚠️ LA FOULE RESTE UNE FOULE. Retirer l'animation sans rien mettre à la place laisserait une
       ligne plate — c'est-à-dire une salle assise, l'exact contraire de ce que l'écran raconte. Les
       personnes se figent donc à une hauteur lisible et à la teinte de l'équipe appelée : le sens
       survit, seul le mouvement tombe. Même arbitrage que la pulsation du souffle plus bas.

       ⚠️ Réécrit à la spécificité de la peau : `.crowd__person` pèse 0,1,0 et perd contre
       `[data-ola-team='volta'] …`. C'est la sixième fois sur ce projet qu'une règle de réduction
       est défaite en silence par un sélecteur de peau — le doublon n'est pas de la redondance. */
    .crowd__person { animation: none; transform: translateY(-22%); }
    [data-ola-team='volta']  .crowd__person { animation: none; color: var(--volta-glow); }
    [data-ola-team='edison'] .crowd__person { animation: none; color: var(--edison-glow); }

    /* ⚠️ Et voilà la SEPTIÈME fois, trouvée pendant la refonte de charte plutôt qu'après. Les deux
       lignes ci-dessus figent la foule à la teinte de LUEUR, qui était juste tant que les deux
       surfaces avaient un fond sombre. Le téléphone est passé au parchemin : `#7FE8FF` y tombe à
       1,4:1, donc en mouvement réduit un joueur verrait une foule blanche sur du crème — c'est-à-dire
       rien, exactement ce que le commentaire ci-dessus dit vouloir éviter.

       Les sélecteurs pèsent 0,3,0 contre 0,2,0. Le mur n'est pas touché : il garde ses lueurs. */
    [data-ola-team='volta']  .ola-round .crowd__person { animation: none; color: var(--volta-ink); }
    [data-ola-team='edison'] .ola-round .crowd__person { animation: none; color: var(--edison-ink); }

    /* ⚠️ Le pendule et l'éclaboussure sont RETIRÉS, et non passés en `transition: none`.
       Supprimer la transition ne supprime pas le mouvement : elle le rend instantané, soit un saut
       jusqu'à cinq fois par seconde — pire que ce qu'on voulait éviter. Et comme c'est la DURÉE de
       la bascule qui porte le rythme, une peau réduite scintillerait sans plus rien dire. Le
       verdict textuel porte alors seul la cadence, ce qui est exactement son rôle.

       À la spécificité de la peau (0,2,0), sinon la règle est écrasée par celle qui les affiche. */
    [data-gesture='row'] .tap-button__metronome,
    [data-gesture='row'] .tap-button__splash { display: none; }

    /* ⚠️ La pulsation tombe, LES COULEURS RESTENT. Retirer tout le bloc « marquer est visible »
       enlèverait le retour à ceux qui ont demandé moins de mouvement, et le retour est ici la
       différence entre « je souffle » et « je marque » — donc entre un jeu et un appareil qu'on
       croit cassé. À la spécificité de la règle qui l'anime (0,2,0), sinon elle est écrasée. */
    [data-blowing] .blow-round__balloon { animation: none; }
    /* La transition du vu-mètre part aussi : à 90 ms elle ne porte aucune information, elle lisse
       une frame. Le remplissage lui-même reste, c'est lui qui dit le niveau. */
    .blow-round__meter-fill,
    .blow-round__balloon { transition: none; }

    /* ⚠️ LE CARET S'ARRÊTE, LE TEXTE CONTINUE DE SE TAPER, et la distinction est exactement celle
       que l'en-tête de ce bloc énonce. Le clignotement est décoratif : il dit « la machine est
       vivante », ce que le texte qui avance dit déjà. Le DÉROULÉ, lui, est le contenu — c'est le
       moment lui-même, avec ses silences avant l'erreur fatale et avant l'amorce Sherlock. Le
       supprimer priverait la personne de la scène et pas d'une animation.
       `animation: none` et non `opacity: 1` : le caret reste visible, simplement fixe. */
    .crash__caret { animation: none; }

    /* ⚠️ LES POINTS S'ARRÊTENT SUR TROIS, JAMAIS SUR RIEN. `animation: none` rendrait la valeur
       déclarée du `content`, qui est bien `…` — mais l'écrire ici plutôt que de compter dessus est
       ce qui empêche un futur remaniement des étapes de laisser cet écran sur « Calcul du score »
       tout court, c'est-à-dire sur une phrase qui ne dit plus que la machine travaille. C'est la
       seule information que ce mouvement porte, et une peau réduite ne doit pas la perdre. */
    .computing-dots::after { animation: none; content: '…'; }
}
