/* Commun aux deux pages (spec §5) */

:root {
  --bg: #10131a;
  --ink: #eef2f8;
  --muted: #7d8798;
  --ball: #ffca3a;
  --accent: #4cc9f0;
}

* {
  box-sizing: border-box;
}

/* `hidden` doit l'emporter sur TOUTE règle d'affichage.
 *
 * L'attribut n'est qu'un `display: none` posé par la feuille de style du
 * navigateur : la moindre règle d'auteur qui donne un `display` à l'élément —
 * un `.pad { display: flex }` — le rend inopérant, et l'élément reste visible
 * alors que le code croit l'avoir caché. La panne est silencieuse et ne
 * ressemble pas à un problème de style : sur la page d'administration, le
 * formulaire de connexion restait affiché après une connexion réussie, et la
 * console qui s'affichait derrière était poussée hors de l'écran.
 *
 * Cette règle a remplacé les onze corrections au cas par cas (`.diag[hidden]`,
 * `.gate[hidden]`…) qui traitaient le symptôme là où il avait été remarqué.
 * Rien à faire pour un élément nouveau : `hidden` marche, c'est tout. */
[hidden] {
  display: none !important;
}

html,
body {
  margin: 0;
  height: 100%;
  overflow: hidden;
  background: var(--bg);
  color: var(--ink);
  font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
  -webkit-user-select: none;
  user-select: none;
  -webkit-tap-highlight-color: transparent;
}

canvas {
  display: block;
  width: 100%;
  height: 100%;
  touch-action: none;
}

/* Trois colonnes et non un `space-between` : le pseudo est au MILIEU DE L'ÉCRAN,
   pas au milieu de ce qui reste entre l'invite et le bouton Quitter. Les deux
   colonnes latérales font la même fraction, donc la colonne centrale reste sur
   l'axe du plateau quelle que soit la longueur de l'invite. */
.hud {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: baseline;
  gap: 1rem;
  padding: 1rem 1.25rem;
  pointer-events: none;
}

/* Le bouton Quitter ferme la colonne de droite, collé au bord. */
.hud > .quit {
  justify-self: end;
}

.hint {
  font-size: clamp(0.75rem, 1.6vw, 1rem);
  color: var(--muted);
}

/* Nom de l'équipe sur la manette : dans sa couleur, celle de la bille, en bas de
   l'écran. C'est aussi le bouton qui rouvre le choix d'équipe (en deux taps). */
.team {
  /* La barre est transparente aux pointeurs pour laisser passer les gestes vers
     le plateau : sans cette ligne, le bouton n'est tout simplement pas tapable. */
  pointer-events: auto;
  appearance: none;
  background: none;
  border: 0;
  color: inherit;
  font: inherit;
  /* Taille de départ, et plafond : `fitOneLine()` (controller.js) la RÉDUIT au
     besoin pour tenir sur une ligne. Une taille figée aurait à choisir entre
     « Bleu » minuscule et « Les Tortues Ninja » coupé. */
  font-size: clamp(1.2rem, 9vw, 2.2rem);
  font-weight: 700;
  letter-spacing: 0.02em;
  /* Seul sur sa ligne, il peut prendre presque toute la largeur. L'ellipse ne
     sert qu'aux cas que le rétrécissement ne sauve pas (nom très long sur écran
     très étroit) : jamais deux lignes. */
  max-width: 86vw;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  /* Le texte fait ~14 px de haut : sans remplissage, la cible serait deux fois
     trop petite pour un pouce. Les marges négatives le rendent au HUD, dont
     l'alignement ne doit pas bouger. */
  padding: 0.6rem 0.7rem;
  margin: -0.6rem -0.7rem;
  cursor: pointer;
  /* Discret mais présent : dit « ça se tape » sans crier plus fort que le nom
     de l'équipe, qui reste l'information principale. */
  text-decoration: underline dotted;
  text-underline-offset: 0.35em;
  text-decoration-thickness: 1px;
}

/* Équipes réparties par le maître du jeu : il n'y a rien à ouvrir, et l'invite
   à taper serait un mensonge. */
.team:disabled {
  cursor: default;
  text-decoration: none;
}

.team:not(:disabled):active {
  opacity: 0.6;
}

/* Le pseudo, au centre du bandeau du haut. Ce conteneur n'existe que pour porter
   l'incitation en surimposition, qui se cale sur lui. */
.hud-me {
  position: relative; /* repère de l'incitation, posée dessous */
  display: flex;
  justify-content: center;
  min-width: 0; /* sinon la colonne centrale s'élargit au lieu de couper */
}

/* L'équipe, en bas de l'écran et centrée. La barre traverse toute la largeur
   pour que le centrage se fasse sur l'ÉCRAN et non sur le texte, et laisse
   passer les pointeurs : seul le bouton les prend, sinon un bord entier du
   plateau cesserait de répondre à l'inclinaison. */
.hud-team {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  display: flex;
  justify-content: center;
  /* `safe-area` : sur iPhone, la barre d'accueil mange le bas de l'écran et
     avalerait la moitié du nom d'équipe. */
  padding: 0.9rem 1.25rem calc(0.9rem + env(safe-area-inset-bottom));
  pointer-events: none;
}

/* L'invite du premier tap, posée juste au-dessus du nom d'équipe. Même facture
   que l'incitation au pseudo — c'est la même chose dite à l'autre bout de
   l'écran — mais flèche vers le BAS : c'est le nom en dessous qu'elle désigne. */
.teamteach {
  position: absolute;
  bottom: 100%;
  left: 50%;
  translate: -50% 0;
  margin: 0 0 0.4rem;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.15rem;
  width: max-content;
  max-width: 80vw;
  padding: 0.5rem 0.7rem;
  border: 1px solid color-mix(in srgb, var(--ball) 35%, transparent);
  border-radius: 0.7rem;
  background: color-mix(in srgb, var(--ball) 12%, var(--bg));
  color: var(--ink);
  font-size: clamp(0.72rem, 3vw, 0.95rem);
  line-height: 1.25;
  text-align: center;
  /* Elle monte au lieu de descendre : elle vient du doigt qui vient de taper,
     posé en dessous. */
  animation: teach-in 0.3s ease-out;
}

@keyframes teach-in {
  from {
    opacity: 0;
    transform: translateY(0.5rem);
  }
  to {
    opacity: 1;
    transform: none;
  }
}

.teamteach-arrow {
  color: var(--ball);
  font-size: 0.7em;
  line-height: 1;
}

@media (prefers-reduced-motion: reduce) {
  .teamteach {
    animation: none;
  }
}

/* ─── Incitation à choisir son pseudo ──────────────────────────────────────── */
/* Elle glisse sous le pseudo pendant l'accueil et l'entraînement, la flèche
   pointant le bouton juste au-dessus. Hors du flux : dans le bandeau, elle
   déplacerait le pseudo à chaque apparition, et le bandeau danserait à chaque
   changement de scène. */
.nicknudge {
  position: absolute;
  top: 100%;
  /* Centrée sous le pseudo, qui l'est lui-même sur l'écran : la bulle reste donc
     dans le cadre des deux côtés. `translate` et non `transform` : l'animation
     d'entrée occupe déjà `transform`, et les deux propriétés se composent au
     lieu de s'écraser. */
  left: 50%;
  translate: -50% 0;
  margin-top: 0.5rem;
  /* Flèche AU-DESSUS du texte et centrée, pas à côté : la bulle est centrée sous
     le pseudo, une flèche posée dans son coin gauche ne désignerait plus rien. */
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.15rem;
  width: max-content;
  max-width: 62vw;
  text-align: center;
  padding: 0.5rem 0.7rem;
  border: 1px solid color-mix(in srgb, var(--ball) 35%, transparent);
  border-radius: 0.7rem;
  background: color-mix(in srgb, var(--ball) 12%, var(--bg));
  color: var(--ink);
  font-size: clamp(0.72rem, 3vw, 0.95rem);
  line-height: 1.25;
  /* Elle ne prend AUCUN geste : le bandeau laisse passer les pointeurs vers le
     plateau, et une bulle qui avalerait un tap couperait l'inclinaison sans
     que personne comprenne pourquoi. */
  pointer-events: none;
  animation: nudge-in 0.45s ease-out;
}

.nicknudge-arrow {
  color: var(--ball);
  font-size: 0.7em;
  line-height: 1;
}

/* Elle arrive en glissant, sans clignoter : quelque chose qui apparaît d'un coup
   dans le coin de l'œil pendant qu'on regarde le grand écran se lit comme une
   alerte, alors que ce n'est qu'une proposition. */
@keyframes nudge-in {
  from {
    opacity: 0;
    transform: translateY(-0.5rem);
  }
  to {
    opacity: 1;
    transform: none;
  }
}

@media (prefers-reduced-motion: reduce) {
  .nicknudge {
    animation: none;
  }
}

/* Le pseudo, en haut de l'écran et centré : c'est le nom du joueur. Plus pâle
   que le nom d'équipe — il ne doit appeler personne à s'en occuper avant le
   départ, et c'est l'équipe, dans sa couleur, qui sert à retrouver sa bille sur
   le grand écran. Comme `.team`, c'est le bouton qui rouvre son propre écran
   (double-tap), parce que l'endroit où on lit son pseudo est le seul où on
   pense à en changer. */
.nick {
  pointer-events: auto;
  appearance: none;
  background: none;
  border: 0;
  color: var(--muted);
  font: inherit;
  /* Comme le nom d'équipe : plafond ici, ajustement à la baisse par
     `fitOneLine()` pour tenir sur une ligne. Un peu plus petit qu'elle — le
     bandeau du haut porte aussi l'invite et le bouton Quitter, et c'est
     l'équipe, dans sa couleur, qui reste le repère sur le grand écran. */
  font-size: clamp(1rem, 7vw, 1.8rem);
  padding: 0.5rem 0.6rem;
  margin: -0.5rem -0.6rem;
  cursor: pointer;
  text-decoration: underline dotted;
  text-underline-offset: 0.35em;
  text-decoration-thickness: 1px;
  /* Le bandeau porte aussi l'invite et le bouton Quitter : le pseudo prend la
     largeur qu'il peut sans les écraser, et c'est `fitOneLine()` qui le fait
     tenir dedans. L'ellipse ne sert que de dernier recours — les pseudos tirés
     au sort (24 caractères au pire) tiennent tous, même sur un petit écran. */
  max-width: 64vw;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.nick:active {
  opacity: 0.6;
}

/* ─── Choix du pseudo sur la manette ───────────────────────────────────────── */
/* Plein écran comme le choix d'équipe : on ne tape pas au clavier en gardant un
   œil sur sa bille, et le clavier virtuel recouvre de toute façon la moitié de
   l'écran. */
.nickpick {
  position: fixed;
  inset: 0;
  z-index: 30;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 1rem;
  padding: 2rem 1.5rem;
  background: var(--bg);
}

.nickpick-title {
  margin: 0;
  font-size: clamp(1.2rem, 6vw, 1.8rem);
  font-weight: 700;
}

/* Le pseudo porté, en évidence : c'est le sujet de l'écran — celui qu'on est
   venu remplacer, et celui qu'on garde si l'on repart sans rien valider. */
.nickpick-current {
  margin: 0;
  min-height: 1.2em; /* la ligne ne saute pas entre deux tirages */
  color: var(--ball);
  font-size: clamp(1.1rem, 5vw, 1.5rem);
  font-weight: 700;
  text-align: center;
}

/* La bascule entre les deux façons d'avoir un pseudo : le hasard au-dessus,
   sa propre idée en dessous. Sans elle, le champ ressemble à une deuxième
   manière de faire la même chose que le bouton juste au-dessus. */
.nickpick-or {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  width: min(100%, 22rem);
  margin: 0.2rem 0 0;
  color: var(--muted);
  font-size: 0.85rem;
}

.nickpick-or::before,
.nickpick-or::after {
  content: "";
  flex: 1;
  height: 1px;
  background: rgba(238, 242, 248, 0.15);
}

/* `font-size: 1rem` au minimum : en dessous de 16 px, iOS zoome sur le champ à
   la mise au point et ne dézoome jamais — le reste de la manette reste alors
   hors cadre pour toute la partie. */
.nickpick-field {
  width: min(100%, 22rem);
  min-height: 3.5rem;
  padding: 0 1rem;
  border: 2px solid rgba(238, 242, 248, 0.25);
  border-radius: 0.9rem;
  background: rgba(255, 255, 255, 0.04);
  color: var(--ink);
  font-family: inherit;
  font-size: max(1rem, clamp(1.1rem, 5vw, 1.4rem));
  font-weight: 700;
  text-align: center;
}

.nickpick-field:focus {
  outline: none;
  border-color: var(--ball);
}

.nickpick-note {
  margin: 0;
  color: #ff8f8f;
  font-size: 0.95rem;
}

/* Le tirage au sort, offert AVANT la validation : c'est la sortie de celui qui
   n'aime pas le sien et n'a pas envie d'en inventer un. Discret quand même — la
   plupart des gens gardent celui qu'on leur a donné. */
.nickpick-roll {
  appearance: none;
  width: min(100%, 22rem);
  min-height: 3.2rem;
  border: 1px solid rgba(238, 242, 248, 0.25);
  border-radius: 0.9rem;
  background: none;
  color: var(--ink);
  font-family: inherit;
  font-size: 1rem;
  cursor: pointer;
}

.nickpick-roll:active {
  background: rgba(255, 255, 255, 0.08);
}

.nickpick-ok {
  appearance: none;
  width: min(100%, 22rem);
  min-height: 4rem;
  border: 2px solid var(--ball);
  border-radius: 0.9rem;
  background: color-mix(in srgb, var(--ball) 18%, transparent);
  color: var(--ball);
  font-family: inherit;
  font-size: clamp(1.1rem, 5vw, 1.4rem);
  font-weight: 700;
  cursor: pointer;
}

.nickpick-ok:active {
  background: color-mix(in srgb, var(--ball) 40%, transparent);
  color: var(--bg);
}

/* Champ vide : il n'y a rien à valider. Le bouton reste VISIBLE et éteint plutôt
   que de disparaître — sa place dit qu'il existe, et l'écran ne se réorganise
   pas sous les doigts à la première lettre tapée. */
.nickpick-ok:disabled {
  border-color: rgba(238, 242, 248, 0.15);
  background: none;
  color: var(--muted);
  cursor: default;
}

/* ─── Choix d'équipe sur la manette ────────────────────────────────────────── */
/* Opaque et plein écran : tant qu'il n'a pas choisi, le joueur ne pilote rien,
   et voir son plateau derrière lui ferait croire qu'il joue déjà. */
.teampick {
  position: fixed;
  inset: 0;
  z-index: 30;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 1.5rem;
  padding: 2rem 1.5rem;
  background: var(--bg);
}

.teampick-title {
  margin: 0;
  font-size: clamp(1.2rem, 6vw, 1.8rem);
  font-weight: 700;
}

.teampick-list {
  display: flex;
  flex-direction: column;
  gap: 1rem;
  width: min(100%, 22rem);
}

/* Hautes et pleine largeur : on choisit debout, téléphone en main, sans viser. */
.teampick-btn {
  min-height: 4.5rem;
  border: 2px solid var(--team);
  border-radius: 0.9rem;
  background: color-mix(in srgb, var(--team) 18%, transparent);
  color: var(--team);
  font-size: clamp(1.1rem, 5vw, 1.5rem);
  font-weight: 700;
  font-family: inherit;
  cursor: pointer;
}

.teampick-btn:active {
  background: color-mix(in srgb, var(--team) 40%, transparent);
  color: var(--bg);
}

/* L'équipe actuelle, quand on rouvre l'écran : pleine plutôt que cerclée. On
   doit voir d'où l'on part avant de choisir où l'on va. */
.teampick-btn.is-mine {
  background: color-mix(in srgb, var(--team) 45%, transparent);
  color: var(--bg);
}

.teampick-btn.is-mine::after {
  content: " ✓";
}

/* Sortie sans changement : volontairement pâle et sans cadre plein. Elle doit
   se trouver quand on la cherche, pas attirer l'œil de qui vient choisir. */
.teampick-cancel {
  appearance: none;
  background: none;
  border: 0;
  color: var(--muted);
  font: inherit;
  font-size: 1rem;
  padding: 0.8rem 1.6rem;
  cursor: pointer;
}

.teampick-cancel:active {
  color: var(--ink);
}

/* Fond de l'ecran partage : une lueur centrale qui donne de la profondeur sans
   concurrencer la bille. */
.screen {
  display: flex;
  background:
    radial-gradient(ellipse at 50% 45%, #1b2231 0%, var(--bg) 65%);
}

/* Le terrain prend la place restante. min-width: 0 est indispensable : sans
   lui, un element flex refuse de passer sous sa taille de contenu et la
   colonne laterale deborderait de l'ecran au lieu de retrecir le labyrinthe. */
.screen .stage {
  position: relative;
  flex: 1;
  min-width: 0;
  height: 100%;
}

/* ─── Bouton plein écran (écran partagé) ──────────────────────────────────── */

/* Discret par défaut, franc au survol : il est posé au-dessus d'un terrain qui
   se regarde depuis le fond de la salle, il n'a pas à disputer l'attention à la
   bille. Il reste cliquable en permanence (pas de fondu à l'apparition) parce
   qu'on le cherche justement quand on vient de poser la souris sur l'écran. */
.fs-btn {
  position: fixed;
  top: 0.75rem;
  right: 0.75rem;
  z-index: 40;
  padding: 0.45rem 0.75rem;
  border: 1px solid rgba(238, 242, 248, 0.25);
  border-radius: 0.5rem;
  background: rgba(16, 19, 26, 0.6);
  color: var(--ink);
  font: inherit;
  font-size: 0.85rem;
  line-height: 1;
  opacity: 0.45;
  cursor: pointer;
  transition:
    opacity 0.15s,
    background 0.15s;
}

.fs-btn:hover,
.fs-btn:focus-visible {
  opacity: 1;
  background: rgba(16, 19, 26, 0.9);
}

.fs-btn-icon + .fs-btn-label {
  margin-left: 0.4em;
}

/* Deja en plein ecran : plus que l'icone. Le libelle ne revient qu'au survol,
   pour qui cherche le bouton plutot qu'Echap. */
:root:fullscreen .fs-btn-label {
  display: none;
}

:root:fullscreen .fs-btn:hover .fs-btn-label,
:root:fullscreen .fs-btn:focus-visible .fs-btn-label {
  display: inline;
}

/* ─── Décompte / chronomètre (écran partagé) ───────────────────────────────── */

.chrono {
  position: absolute;
  left: 50%;
  top: 2.5vh;
  transform: translateX(-50%);
  font-variant-numeric: tabular-nums;
  font-weight: 600;
  letter-spacing: 0.02em;
  pointer-events: none;
  text-align: center;
  font-size: clamp(1.2rem, 3vw, 2.4rem);
}

/* Le décompte prend tout l'écran : il doit se voir depuis le fond de la salle. */
.chrono.is-countdown {
  top: 50%;
  transform: translate(-50%, -50%);
  font-size: clamp(6rem, 30vw, 22rem);
  color: var(--accent);
  opacity: 0.9;
}

.chrono.is-won {
  color: #50dc8c;
  font-size: clamp(1.6rem, 5vw, 4rem);
}

.chrono.is-free {
  display: none;
}

/* Avertissement : écran ouvert à travers le tunnel */
.warn {
  position: absolute;
  left: 50%;
  bottom: 1.5rem;
  transform: translateX(-50%);
  max-width: min(90vw, 46rem);
  padding: 0.85rem 1.2rem;
  border-radius: 0.6rem;
  background: #f5b301;
  color: #201800;
  font-size: clamp(0.8rem, 1.4vw, 1rem);
  line-height: 1.5;
  text-align: center;
  z-index: 20;
}

.warn code {
  background: rgba(0, 0, 0, 0.15);
  padding: 0.1em 0.4em;
  border-radius: 0.25em;
}

/* ─── Panneau des manettes connectées (touche P) ───────────────────────────── */

/* UNE SEULE colonne, jamais deux, et à GAUCHE DU TERRAIN plutôt que dessus.
   C'est le même parti que la colonne du QR : « le terrain occupe sa propre
   colonne, la colonne le rétrécit au lieu de le recouvrir, pour qu'aucun mur ne
   passe sous un panneau ». En surimposition, le panneau posait deux problèmes à
   la fois — des pseudos illisibles sur les murs, et des murs illisibles sous
   les pseudos. Le script la déplace hors du terrain quand elle prend cette
   forme (voir `updatePeers`).
   Sa largeur est bornée : elle ne s'élargit pas, elle rétrécit ses pastilles
   pour tenir en hauteur (voir `sizePeers`). L'écran est géant, une pastille de
   14 px s'y voit encore. */
.peers {
  flex: 0 0 auto;
  align-self: center;
  display: flex;
  flex-direction: column;
  flex-wrap: nowrap;
  gap: var(--peer-gap, 6px);
  max-height: 100%;
  padding: 0 clamp(0.5rem, 1vw, 1.25rem);
  pointer-events: none; /* réactivé sur les seules manettes silencieuses */
}

/* À l'entraînement, le panneau ne forme plus une bande : il se disperse en
   groupes posés à côté du carré de chaque équipe. Il couvre donc tout le
   terrain, et chaque groupe est placé au pixel par le script à partir de la
   grille dessinée sur le canvas — c'est la seule façon de faire coïncider du
   DOM et du canvas. */
.peers.is-training {
  position: absolute;
  inset: 0;
  display: block;
  max-height: none;
  padding: 0;
  width: 100%;
  height: 100%;
}

/* Même règle qu'en bande : une colonne, et elle ne déborde pas de la bande
   réservée à côté du carré de son équipe. */
.peer-group {
  position: absolute;
  display: flex;
  flex-direction: column;
  flex-wrap: nowrap;
  align-items: flex-start;
  gap: var(--peer-gap, 6px);
}

/* Les manettes qui n'ont pas encore choisi n'ont pas de carré où se ranger :
   elles se regroupent en bas à gauche, étiquetées. Lancer une partie en les
   laissant derrière est l'erreur la plus facile à commettre. */
.peer-group.is-pending {
  left: clamp(0.75rem, 2vw, 2rem);
  bottom: 1.5rem;
  top: auto;
  width: auto;
  height: auto;
  max-width: 40%;
  align-items: center;
}

.peer-group-label {
  color: var(--muted);
  font-size: 0.85rem;
  letter-spacing: 0.02em;
  align-self: center;
}

/* Une manette = une LIGNE : la pastille, et le pseudo à côté. Empilé, le nom
   doublait la hauteur de chaque entrée et le panneau passait à la ligne au bout
   de sept joueurs. À côté, il ne coûte que de la largeur — et la largeur est
   bornée, elle. */
.peer {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  min-width: 0;
}

/* La pastille est un élément à part : c'est ELLE qui est ronde et cerclée, pas
   la ligne qui la porte. Sa taille est calculée par le script pour que la
   colonne tienne dans la hauteur disponible, quel que soit l'effectif. */
.peer-plate {
  position: relative;
  flex: none;
  width: var(--peer-size, 44px);
  height: var(--peer-size, 44px);
  border: 1px solid rgba(238, 242, 248, 0.25);
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.03);
  transition: border-color 0.2s;
}

.peer.is-idle .peer-plate {
  border-color: rgba(238, 242, 248, 0.12);
}

/* Le pseudo n'apparaît que si l'évènement en distribue : en anonyme, le panneau
   est au pixel près celui d'avant. Sur UNE ligne et coupé : deux lignes
   feraient dépendre la hauteur d'une entrée de la longueur d'un nom, donc du
   hasard du tirage, et la colonne ne serait plus calculable. */
.peer-nick {
  display: none;
}

.peers.is-named .peer-nick {
  display: block;
  min-width: 0;
  max-width: var(--peer-nick-w, 10rem);
  color: var(--muted);
  font-size: var(--peer-font, 0.8rem);
  line-height: 1.15;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.peer-dot {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 26%;
  aspect-ratio: 1;
  border-radius: 50%;
  transform: translate(-50%, -50%);
  /* Couleur de l'équipe, posée par le script ; --ball tant qu'il n'y en a qu'une. */
  background: var(--team, var(--ball));
  transition:
    left 0.05s linear,
    top 0.05s linear;
}

.peer.is-idle .peer-dot {
  background: var(--muted);
}

/* Manette muette depuis longtemps : posée quelque part, sans doute oubliée.
   Cliquable pour la débrancher (le serveur revérifie qu'elle est bien muette).
   Le pseudo s'estompe avec elle : c'est justement lui qu'on lit à voix haute
   avant de débrancher quelqu'un. */
.peer.is-asleep {
  opacity: 0.45;
  cursor: pointer;
  pointer-events: auto;
}

.peer.is-asleep .peer-plate {
  border-style: dashed;
}

.peer.is-asleep:hover {
  opacity: 0.8;
}

.peer.is-asleep:hover .peer-plate {
  border-color: #ff6b6b;
}

/* Le numéro d'ordre n'est plus dessiné sur la pastille. Collé à un rond qui peut
   descendre à 20 px, il ne se lisait plus et chargeait le panneau — et personne
   ne désigne une manette par son numéro : on la montre du doigt à l'écran, ou on
   l'appelle par son pseudo. Il reste au survol et dans `peers.json`, là où le
   diagnostic va le chercher. */

/* ─── Classement des équipes (écran partagé) ───────────────────────────────── */
/* En haut à droite du terrain : le chronomètre occupe déjà le haut au centre,
   et le bas est là où les billes finissent le plus souvent. */
.board {
  position: absolute;
  right: clamp(0.75rem, 2vw, 2rem);
  top: clamp(0.75rem, 2vw, 2rem);
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
  pointer-events: none;
}

.board-row {
  display: grid;
  grid-template-columns: 1.4em 1fr auto;
  gap: 0.6rem;
  align-items: baseline;
  padding: 0.35rem 0.7rem;
  border-left: 3px solid var(--team);
  border-radius: 0.3rem;
  background: color-mix(in srgb, var(--team) 12%, transparent);
  font-size: clamp(0.85rem, 1.6vw, 1.3rem);
  color: var(--team);
}

.board-rank {
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  opacity: 0.65;
}

.board-name {
  font-weight: 600;
}

.board-time {
  font-variant-numeric: tabular-nums;
  opacity: 0.75;
}

/* Sortie trouvée : la ligne se remplit, lisible du fond de la salle. */
.board-row.is-done {
  background: color-mix(in srgb, var(--team) 30%, transparent);
}

.board-row.is-done .board-rank {
  opacity: 1;
}

/* ─── Colonne latérale : QR de la manette et de la console (spec §10) ─────── */

/* Colonne, jamais calque : elle occupe sa propre part de la largeur, le terrain
   se redessine dans ce qui reste. Rien ne passe donc au-dessus du labyrinthe. */
/* Fond blanc plein cadre : l'invitation est le seul objet clair de l'écran,
   elle se lit donc du fond de la salle sans qu'on ait à la chercher. */
.side {
  flex: 0 0 auto;
  display: flex;
  flex-direction: column;
  justify-content: flex-start;
  align-items: center;
  gap: clamp(0.75rem, 1.5vw, 1.25rem);
  width: clamp(150px, 17vw, 270px);
  height: 100%;
  padding: clamp(0.75rem, 1.5vw, 1.5rem);
  background: #fff;
  color: #10131a;
}

/* La colonne est déjà blanche : la carte ne porte plus de fond ni d'ombre,
   elle n'est qu'une pile centrée. */
.card {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: clamp(0.4rem, 1vw, 0.8rem);
  width: 100%;
  padding: clamp(0.6rem, 1.2vw, 1rem);
}

/* L'invitation occupe toute la hauteur disponible : un QR minuscule perdu au
   milieu d'un panneau vide ne se scanne pas du fond de la salle. */
#join {
  flex: 1 1 auto;
  min-height: 0;
}

/* Les deux moitiés : `flex: 1 1 0` les rend égales quel que soit leur contenu,
   le titre et le « ou » gardant juste ce qu'il leur faut. */
.join-qr,
.join-alt {
  flex: 1 1 0;
  min-height: 0;
  width: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
}

.join-qr {
  gap: clamp(0.25rem, 1vh, 0.7rem);
}

.join-alt {
  justify-content: space-evenly;
  gap: clamp(0.3rem, 1vh, 0.9rem);
}

/* Titre de l'invitation, en haut de la colonne : la consigne discrète, puis le
   nom de l'évènement sur sa propre ligne, qui est ce qu'on vient vérifier. */
.card-head {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.1em;
  margin: 0;
  text-align: center;
  text-wrap: balance;
  line-height: 1.2;
}

.card-head-lead {
  font-size: clamp(0.7rem, 1.1vw, 1rem);
  font-weight: 600;
  color: #6b7686;
}

.card-head-name {
  font-size: clamp(1rem, 1.9vw, 1.75rem);
  font-weight: 800;
  color: #6d28d9;
}

/* Le « ou » coupe un filet : les deux voies se lisent comme une alternative,
   pas comme une suite d'étapes. */
.card-or {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  width: 100%;
  margin: 0;
  font-size: clamp(0.7rem, 1.1vw, 0.95rem);
  font-weight: 600;
  color: #6b7686;
}

.card-or::before,
.card-or::after {
  content: "";
  flex: 1 1 auto;
  height: 1px;
  background: rgba(16, 19, 26, 0.18);
}

.card-title {
  margin: 0;
  font-size: clamp(0.65rem, 1vw, 0.85rem);
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: #4a5666;
}

/* Le SVG rendu par QRCode ne mesure qu'une quarantaine de pixels : sans
   `width`, il s'afficherait en timbre-poste. */
.card-qr {
  width: 100%;
  height: auto;
  display: block;
}

/* Dans sa moitié de hauteur, le QR remplit la boîte et `contain` le recentre
   en carré : il se cale donc sur la plus petite des deux contraintes. */
.join-qr .card-qr {
  height: 100%;
  min-height: 0;
  object-fit: contain;
}

/* La consigne du QR est un VERBE, pas une étiquette : c'est le seul geste que
   la salle a à faire, et il doit se lire avant l'adresse de repli. */
.card-scan {
  margin: 0;
  flex: 0 0 auto;
  text-align: center;
  font-size: clamp(0.8rem, 1.3vw, 1.1rem);
  font-weight: 800;
  color: #10131a;
}

/* Une consigne et sa valeur : elles se lisent comme un bloc, donc elles se
   serrent l'une contre l'autre plutôt que de suivre l'espacement de la carte. */
.card-step {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.15rem;
  width: 100%;
}

.card-label {
  margin: 0;
  text-align: center;
  font-size: clamp(0.65rem, 1.1vw, 0.95rem);
  font-weight: 600;
  color: #6b7686;
}

/* Une seule ligne : une URL coupée en deux se recopie de travers. fitOneLine()
   dans screen.js l'agrandit jusqu'à remplir la colonne, sans dépasser la taille
   ci-dessous — qui n'est donc plus une taille d'affichage mais un plafond. */
.card-url {
  margin: 0;
  max-width: 100%;
  white-space: nowrap;
  text-align: center;
  font-size: clamp(1rem, 3vw, 2.6rem);
  font-weight: 700;
  /* Bleu souligné : personne n'a besoin qu'on lui explique qu'une adresse
     habillée en lien se tape dans un navigateur. */
  color: #1257c9;
  text-decoration: underline;
  text-underline-offset: 0.12em;
  text-decoration-thickness: 0.06em;
}

/* ─── Scène d'accueil : la même invitation, en pleine page ─────────────────── */

/* Elle RECOUVRE le terrain (position fixe) au lieu de prendre sa place dans la
   rangée : le canvas garde ainsi sa taille, et entrer ou sortir de l'accueil ne
   déclenche pas un redimensionnement du terrain — donc pas de labyrinthe à
   redessiner ni de billes qui sautent au retour dans le jeu. */
.home {
  position: fixed;
  inset: 0;
  z-index: 3;
  display: flex;
  flex-direction: column;
  gap: clamp(0.75rem, 2vh, 2rem);
  padding: clamp(1rem, 2.5vw, 2.5rem);
  /* Même parti que la colonne latérale : l'invitation est claire, et c'est ce
     qui la rend lisible du fond de la salle. */
  background: #fff;
  color: #10131a;
}

/* L'invitation prend TOUTE la place restante : c'est le seul objet que la salle
   vient chercher sur cet écran, et un QR qui ne remplit pas sa moitié de page
   ne se scanne pas depuis le fond. */
.home-join {
  flex: 1 1 auto;
  min-height: 0;
  min-width: 0;
}

/* L'état de la salle en pied de page, sur une ligne : « qui est déjà là » est
   une vérification qu'on fait au passage, pas ce qu'on vient lire. */
.home-room {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: clamp(0.6rem, 2vw, 1.75rem);
  padding-top: clamp(0.5rem, 1.5vh, 1.25rem);
  border-top: 1px solid rgba(16, 19, 26, 0.12);
}

/* Le nombre et son mot sur la même ligne de base : « 12 manettes connectées »
   est une phrase, pas un compteur de tableau de bord. */
.home-count {
  margin: 0;
  display: flex;
  align-items: baseline;
  gap: 0.4em;
  line-height: 1;
}

.home-count-n {
  font-size: clamp(1.6rem, 3.5vw, 3rem);
  font-weight: 800;
  font-variant-numeric: tabular-nums;
  color: #10131a;
}

.home-count-label {
  font-size: clamp(0.8rem, 1.4vw, 1.3rem);
  font-weight: 600;
  color: #6b7686;
}

/* Les équipes se rangent à la suite du compteur, et repassent à la ligne sur un
   écran étroit plutôt que de comprimer les pastilles. */
.home-teams {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
  margin-left: auto;
}

/* Mêmes pastilles que le classement du jeu, en clair : le nom d'équipe garde sa
   couleur d'un écran à l'autre, c'est à ça qu'on la reconnaît. */
.home-team {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 0.75rem;
  padding: 0.3rem 0.7rem;
  border-left: 3px solid var(--team);
  border-radius: 0.3rem;
  background: color-mix(in srgb, var(--team) 14%, transparent);
  font-size: clamp(0.8rem, 1.4vw, 1.4rem);
  color: color-mix(in srgb, var(--team) 75%, #10131a);
}

.home-team-name {
  font-weight: 700;
}

.home-team-n {
  font-variant-numeric: tabular-nums;
  font-weight: 600;
}

/* Sans équipe : gris, et sans couleur d'emprunt — ces manettes n'appartiennent
   à personne tant que leur joueur n'a pas choisi. */
.home-team.is-pending {
  --team: #98a2b3;
  color: #6b7686;
}

/* La carte pleine page : même contenu que dans la colonne, mais l'écran est
   large et haut, donc le QR et le repli manuel se mettent côte à côte plutôt
   que l'un sous l'autre — empilés, chacun n'aurait qu'un quart de la hauteur
   pour la moitié de la largeur, et on aurait perdu la place des deux côtés.

   Les deux colonnes ne sont PAS à égalité : deux tiers pour le QR, un tiers
   pour le repli manuel. À largeur égale, la page proposait deux chemins qui se
   valaient et la salle lisait avant de scanner ; à deux tiers, le QR est le
   seul objet que l'œil trouve en entrant, et l'adresse ne se lit que par ceux
   qui en ont besoin. C'est le rapport qui porte le message, pas un libellé. */
.card.is-home {
  display: grid;
  grid-template-areas:
    "head head"
    "qr   alt";
  grid-template-columns: 2fr 1fr;
  grid-template-rows: auto minmax(0, 1fr);
  /* Annule le `align-items: center` de `.card`, qui vaut aussi pour une grille :
     centrées, les deux colonnes prenaient la hauteur de leur CONTENU au lieu de
     celle de leur ligne. Le QR n'avait alors plus de hauteur de référence, son
     `height: 100 %` retombait sur son ratio, et il débordait de la page. */
  align-items: stretch;
  gap: clamp(0.75rem, 3vh, 2.5rem) clamp(1rem, 3vw, 3rem);
  height: 100%;
  padding: 0;
}

.card.is-home .card-head {
  grid-area: head;
}

.card.is-home .join-qr {
  grid-area: qr;
  gap: clamp(0.5rem, 1.5vh, 1.25rem);
}

/* Le QR remplit ses deux tiers : `contain` le garde carré, donc il se cale sur
   la plus petite des deux contraintes — la hauteur sur un écran large, la
   largeur de la colonne sur un vidéoprojecteur en 4/3. */
.card.is-home .join-qr .card-qr {
  /* `height: 100 %` + `min-height: 0`, et surtout PAS `height: auto` : une image
     en item flex prend pour taille minimale sa hauteur intrinsèque, qui à
     `width: 100 %` vaut la largeur de la colonne. Elle refusait donc de
     rétrécir et débordait de la page en recouvrant le titre et le pied de
     page. Une hauteur en pourcentage donne au contraire une base que le flex a
     le droit de réduire jusqu'à la place laissée par la consigne. */
  flex: 1 1 auto;
  width: 100%;
  height: 100%;
  min-height: 0;
}

/* Les deux moitiés du repli manuel se regroupent au CENTRE avec un vrai
   intervalle entre elles, au lieu d'être réparties sur toute la hauteur :
   « Ou aller sur » se retrouvait à un écran de distance de son adresse, et le
   code collé sous elle. Un bloc compact se lit comme une consigne en deux
   temps. Le filet à gauche remplace le « ou » central : il marque la frontière
   sans se mettre au milieu, donc sans arbitrer entre les deux colonnes. */
.card.is-home .join-alt {
  grid-area: alt;
  justify-content: center;
  gap: clamp(1rem, 5vh, 3.5rem);
  width: 100%;
  padding-left: clamp(1rem, 3vw, 3rem);
  border-left: 1px solid rgba(16, 19, 26, 0.18);
}

/* Toutes ces tailles sont des PLAFONDS pour fitOneLine() : sur un écran étroit
   il redescendra tout seul, mais sur un vidéoprojecteur il faut qu'il ait le
   droit de monter jusque-là. Le repli manuel tient dans un tiers de page : ses
   plafonds sont donc calés sur cette colonne (`vw` divisé par trois environ),
   sans quoi fitOneLine() passerait son temps à rattraper une taille impossible.
   Le « Scanner », lui, est la seule consigne de la page et se lit du fond de la
   salle. */
.card.is-home .card-scan {
  font-size: clamp(1.5rem, 4vw, 3.5rem);
}

.card.is-home .card-head-lead {
  font-size: clamp(0.85rem, 1.6vw, 1.4rem);
}

.card.is-home .card-head-name {
  font-size: clamp(1.5rem, 4vw, 3.5rem);
}

.card.is-home .card-label {
  font-size: clamp(0.75rem, 1.2vw, 1.15rem);
}

.card.is-home .card-url {
  font-size: clamp(1rem, 2.2vw, 2.2rem);
}

/* Le code passe DEVANT l'adresse : c'est lui qu'on dicte à voix haute, et
   l'adresse ne sert qu'à savoir où le taper. */
.card.is-home .card-code {
  font-size: clamp(1.5rem, 3.2vw, 3.25rem);
}

/* ─── Scène des équipes (écran partagé) ────────────────────────────────────── */
/* Elle occupe LE TERRAIN, pas la page : comme à l'entraînement, la colonne
   « Pour rejoindre l'évènement » s'ouvre à côté d'elle et la rétrécit. En
   position fixe elle serait passée par-dessus, et l'écran des équipes aurait été
   le seul d'où la salle ne peut plus rejoindre la partie — c'est pourtant celui
   qu'on laisse affiché le plus longtemps.

   Pas de fond à elle : la lueur centrale de l'écran partagé se voit à travers,
   et les zones d'équipe s'y posent comme les carrés de l'entraînement. */
.lineup {
  position: absolute;
  inset: 0;
  z-index: 2;
  display: flex;
  flex-direction: column;
  gap: clamp(0.5rem, 1.5vh, 1.25rem);
  padding: clamp(0.75rem, 2vw, 2rem);
}

/* La grille dit d'un coup d'œil COMBIEN il y a d'équipes : une seule prend toute
   la page, deux se partagent l'écran, trois ou quatre en occupent les quartiers.
   À trois, le quatrième quartier reste vide plutôt que d'étirer une zone sur
   deux : les zones gardent alors la même taille et la même place d'une
   composition à l'autre, et personne n'a à rechercher la sienne. */
.lineup-zones {
  flex: 1 1 auto;
  min-height: 0;
  display: grid;
  gap: clamp(0.5rem, 1.5vw, 1.5rem);
  grid-template-columns: 1fr 1fr;
  grid-template-rows: 1fr 1fr;
}

.lineup-zones[data-n="1"] {
  grid-template-columns: 1fr;
  grid-template-rows: 1fr;
}

.lineup-zones[data-n="2"] {
  grid-template-rows: 1fr;
}

/* La couleur de l'équipe borde sa zone au lieu de la remplir : à quatre équipes,
   quatre aplats colorés se disputent l'œil et les pastilles ne se voient plus. */
.lu-zone {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
  min-height: 0;
  padding: clamp(0.5rem, 1.2vw, 1.25rem);
  border: 1px solid color-mix(in srgb, var(--team) 45%, transparent);
  border-radius: 0.9rem;
  background: color-mix(in srgb, var(--team) 7%, transparent);
}

.lu-zone-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 1rem;
  flex: 0 0 auto;
}

.lu-zone-name {
  color: var(--team);
  font-size: clamp(1rem, 2.2vw, 2rem);
  font-weight: 800;
}

.lu-zone-n {
  color: var(--muted);
  font-size: clamp(0.8rem, 1.4vw, 1.3rem);
  font-variant-numeric: tabular-nums;
}

/* Les manettes se rangent en lignes qui repassent à la ligne, centrées : c'est
   la seule disposition qui reste lisible de deux à trente sans que le script ait
   à décider d'un nombre de colonnes.

   Sa taille ne dépend PAS de son contenu (`flex: 1 1 auto` dans une grille en
   fractions, `overflow: hidden`) : c'est ce qui permet au script de la mesurer
   puis d'y faire tenir les tuiles, sans que la mesure suivante dépende de ce
   qu'il vient d'y poser. */
.lineup-players {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  flex-wrap: wrap;
  align-content: center;
  justify-content: center;
  gap: var(--lu-gap, 12px);
  overflow: hidden;
}

/* Une équipe que personne ne tient garde sa zone : c'est justement ce qu'on
   vient vérifier. */
.lu-empty {
  margin: 0;
  color: var(--muted);
  font-size: clamp(0.8rem, 1.4vw, 1.2rem);
}

/* Le nom SOUS la pastille, et non à côté comme dans le panneau des manettes :
   ici la place ne manque pas, et c'est le nom qu'on vient lire — celui qu'on
   décide de garder ou de changer.

   La largeur est IMPOSÉE par le script (`--lu-tile`), et c'est ce qui rend la
   mise en page calculable : elle vaut la pastille, ou le plus long nom de la
   salle quand il dépasse. Laissée au contenu, chaque tuile aurait sa largeur et
   le nombre de colonnes ne se prédirait plus — donc plus moyen de garantir que
   tout le monde tient dans la zone. */
.lu-p {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.35rem;
  width: var(--lu-tile, 90px);
}

.lu-plate {
  position: relative;
  flex: none;
  width: var(--lu-size, 90px);
  height: var(--lu-size, 90px);
  border: 2px solid color-mix(in srgb, var(--team) 55%, transparent);
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.03);
}

/* C'est la pastille qui bouge quand le joueur incline : la seule chose de cet
   écran qui lui appartienne en propre, et donc la seule façon de se reconnaître
   avant même d'avoir lu son nom. */
.lu-dot {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 30%;
  aspect-ratio: 1;
  border-radius: 50%;
  transform: translate(-50%, -50%);
  background: var(--team, var(--ball));
  transition:
    left 0.05s linear,
    top 0.05s linear;
}

.lu-p.is-idle .lu-plate {
  border-color: rgba(238, 242, 248, 0.15);
}

.lu-p.is-idle .lu-dot {
  background: var(--muted);
}

/* Muette depuis longtemps : son joueur ne se verra pas bouger. L'estomper le dit
   sans un mot, et c'est la même lecture que dans le panneau des manettes. */
.lu-p.is-asleep {
  opacity: 0.4;
}

.lu-p.is-asleep .lu-plate {
  border-style: dashed;
}

.lu-nick {
  max-width: 100%;
  color: var(--ink);
  font-size: var(--lu-font, 1rem);
  font-weight: 600;
  line-height: 1.15;
  text-align: center;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* Les manettes sans équipe : une bande, jamais une zone. Elles n'appartiennent à
   personne tant que leur joueur n'a pas choisi, et lancer une partie en les
   laissant derrière est l'erreur la plus facile à commettre. */
.lineup-pending {
  flex: 0 0 auto;
  --team: #98a2b3;
  /* Hauteur FIXE, et non dictée par son contenu : sans elle, la bande grandirait
     avec la taille des pastilles, qui est elle-même calculée à partir de la
     bande — la mesure se mordrait la queue et la mise en page oscillerait. */
  height: clamp(88px, 16vh, 200px);
  display: flex;
  align-items: center;
  gap: clamp(0.5rem, 1.5vw, 1.5rem);
  padding-top: clamp(0.4rem, 1vh, 1rem);
  border-top: 1px solid rgba(238, 242, 248, 0.12);
}

.lineup-pending-label {
  flex: 0 0 auto;
  color: var(--muted);
  font-size: clamp(0.8rem, 1.4vw, 1.2rem);
}

.lineup-pending .lineup-players {
  justify-content: flex-start;
}

/* L'invitation à changer de pseudo, en pied de page : c'est le seul écran où le
   joueur a le temps de le faire, et la seule ligne de texte qu'il vaille la peine
   de lui adresser ici. Absente quand l'évènement ne distribue pas de pseudos. */
.lineup-hint {
  flex: 0 0 auto;
  margin: 0;
  text-align: center;
  color: var(--muted);
  font-size: clamp(0.8rem, 1.5vw, 1.4rem);
}

/* ─── Ecran d'entree de la manette (spec §9.1) ─────────────────────────────── */

.gate {
  position: fixed;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 2rem;
  background: var(--bg);
  /* Le portail n'a longtemps porte qu'un mot ("À plat") : des qu'il porte une
     phrase, l'alignement par defaut la colle a gauche sous un dessin centre. */
  text-align: center;
}

/* Une main qui incline un plateau : la metaphore s'enseigne sans un mot.
   Le telephone est montre LEGEREMENT REDRESSE, face a l'ecran — la position ou
   se met un poignet qu'on ne force pas. "A plat" est une consigne que personne
   ne tient : au bout de dix secondes la main revient d'elle-meme a cette
   inclinaison, et la calibration de tilt.js l'accepte (fenetre de 40°). */
.gate-art {
  display: grid;
  grid-template: auto auto auto / auto auto auto;
  align-items: center;
  justify-items: center;
  /* L'ecart horizontal est dicte par le debordement de la main (voir .hand) :
     en dessous, les fleches latterales passent dessous. */
  gap: 0.5rem clamp(2rem, 14vw, 4rem);
}

.grip {
  grid-area: 2 / 2;
  position: relative;
  display: flex;
  /* La perspective doit porter sur le PARENT DIRECT du telephone : posee sur
     .gate-art elle ne s'appliquerait qu'a .grip, et le telephone tournerait a
     plat, sans profondeur. */
  perspective: 700px;
  /* La main doit pouvoir deborder sans pousser la grille : c'est le telephone
     qui donne la taille du bloc. */
  isolation: isolate;
}

/* Filigrane : elle dit "on le tient comme ca" et doit disparaitre des qu'on la
   regarde vraiment. Immobile, aussi — si la main tournait avec le telephone, le
   mouvement enseigne deviendrait illisible. */
.hand {
  position: absolute;
  z-index: -1;
  /* Ces trois valeurs SONT le repere du trace : elles placent le telephone en
     x 38→138, y 18→208 du viewBox. Les changer sans redessiner decale la main
     par rapport a l'appareil qu'elle est censee tenir. */
  width: 170%;
  left: -38%;
  bottom: -22%;
  color: var(--ink);
  opacity: 0.13;
}

/* Les coefficients vw sont regles pour un telephone de 390 px de large : c'est
   la seule largeur ou cette page sera lue, et un cap en px seul la rendrait
   minuscule sur les petits ecrans. */
.phone {
  width: min(38vw, 150px);
  aspect-ratio: 1 / 1.9;
  border: 3px solid rgba(238, 242, 248, 0.5);
  border-radius: 1.6rem;
  display: flex;
  align-items: center;
  justify-content: center;
  transform-style: preserve-3d;
  animation: tilt 8s ease-in-out infinite;
}

.marble {
  width: 22%;
  aspect-ratio: 1;
  border-radius: 50%;
  background: radial-gradient(circle at 35% 35%, #fff3c4, var(--ball));
  animation: roll 8s ease-in-out infinite;
}

/* Les quatre mouvements, joues l'un apres l'autre plutot que decrits : chaque
   fleche s'allume pendant que le telephone part de ce cote. Une legende figee
   "gauche/droite/haut/bas" n'apprend pas le GESTE, qui est le seul point dur. */
.tilt-arrow {
  font-size: clamp(0.85rem, 3.4vw, 1.05rem);
  line-height: 1;
  color: var(--ink);
  opacity: 0.2;
  animation: arrow-pulse 8s ease-in-out infinite;
}

.tilt-arrow.up {
  grid-area: 1 / 2;
}

.tilt-arrow.left {
  grid-area: 2 / 1;
}

.tilt-arrow.right {
  grid-area: 2 / 3;
}

.tilt-arrow.down {
  grid-area: 3 / 2;
}

/* Un simple retard cale chaque fleche sur son quart de cycle, et l'accord tient
   ensuite indefiniment : quatre jeux d'images identiques seraient quatre
   occasions de desynchroniser le jour ou on retouche la duree. */
.tilt-arrow.left {
  animation-delay: 0s;
}

.tilt-arrow.right {
  animation-delay: 2s;
}

.tilt-arrow.up {
  animation-delay: 4s;
}

.tilt-arrow.down {
  animation-delay: 6s;
}

/* Repos = legerement redresse (rotateX 30°), jamais l'horizontale. Le rotateY
   vient APRES le rotateX : il s'applique donc dans le repere du telephone,
   c'est-a-dire son axe long — le vrai roulis, pas un lacet. */
@keyframes tilt {
  0%,
  25%,
  50%,
  75%,
  100% {
    transform: rotateX(30deg) rotateY(0deg);
  }
  6%,
  19% {
    transform: rotateX(30deg) rotateY(-26deg);
  }
  31%,
  44% {
    transform: rotateX(30deg) rotateY(26deg);
  }
  /* Debattement volontairement court (30° ± 14°) : pousse plus loin, le
     telephone paraissait presque a plat a l'image, et le dessin contredisait la
     posture enseignee trois lignes plus bas. */
  /* Rabattu vers l'avant : le bord haut s'eloigne et descend, la bille monte. */
  56%,
  69% {
    transform: rotateX(44deg) rotateY(0deg);
  }
  /* Redresse vers soi : la face se verticalise, la bille tombe vers le bas. */
  81%,
  94% {
    transform: rotateX(16deg) rotateY(0deg);
  }
}

/* Les pourcentages portent sur la BILLE, qui ne fait que 22% de la largeur du
   telephone : 75% ne la deplacait que d'un sixieme du plateau, et le dessin
   racontait "elle fremit" au lieu de "elle roule jusqu'au bord". Il faut donc
   des valeurs bien superieures a 100 — et l'ecart X/Y vient de ce que le
   telephone est presque deux fois plus haut que large. */
@keyframes roll {
  0%,
  25%,
  50%,
  75%,
  100% {
    transform: translate(0, 0);
  }
  6%,
  19% {
    transform: translate(-160%, 0);
  }
  31%,
  44% {
    transform: translate(160%, 0);
  }
  56%,
  69% {
    transform: translate(0, -300%);
  }
  81%,
  94% {
    transform: translate(0, 300%);
  }
}

@keyframes arrow-pulse {
  0%,
  25%,
  100% {
    opacity: 0.2;
    color: var(--ink);
  }
  6%,
  19% {
    opacity: 1;
    color: var(--ball);
  }
}

.gate-wait {
  margin: 0;
  font-size: clamp(1rem, 4vw, 1.5rem);
  color: var(--muted);
  letter-spacing: 0.08em;
}

.gate-wait small {
  display: block;
  margin-top: 0.6rem;
  font-size: 0.8em;
  opacity: 0.75;
  max-width: 22rem;
  line-height: 1.4;
}

/* ─── Notice de premier contact ────────────────────────────────────────────── */
/* Affichee une seule fois par appareil, dans le gate lui-meme : elle ne peut pas
   etre une page precedente, parce que le bouton qui la ferme est aussi celui qui
   porte le geste utilisateur exige par iOS pour demander la permission. */

/* Le gate porte alors un vrai contenu : il doit pouvoir defiler sur un petit
   ecran, alors qu'il est un plein cadre fige le reste du temps. */
/* RIEN NE DOIT DEFILER. Une notice qui defile est une notice dont le bas
   n'existe pas : le bouton et la case "ne plus montrer" passent sous la ligne
   de flottaison, et une partie des gens ne les trouvent jamais.
   La page ne peut donc pas etre une pile de blocs de hauteur fixe — sur un
   petit ecran elle deborderait forcement. Le texte et les commandes gardent
   leur taille, et c'est L'ILLUSTRATION qui absorbe ce qui reste : elle peut
   maigrir beaucoup sans rien perdre de ce qu'elle enseigne.
   Le defilement reste autorise en tout dernier recours — le masquer rendrait
   le bouton INATTEIGNABLE si un jour la mise en page cedait quand meme. */
.gate.is-onboard {
  /* Le surplus d'un grand ecran se partage haut et bas au lieu de s'entasser
     sous la case a cocher. `safe` protege du travers classique du centrage en
     flex : si le contenu debordait malgre tout, un centrage nu rognerait le
     HAUT, donc le titre, sans aucun moyen d'y revenir. */
  justify-content: center;
  justify-content: safe center;
  gap: 0.7rem;
  gap: clamp(0.45rem, 1.6svh, 1rem);
  padding: 1rem 1.2rem calc(0.9rem + env(safe-area-inset-bottom));
  padding-top: clamp(0.7rem, 2.2svh, 1.4rem);
  overflow-y: auto;
  text-align: center;
}

/* La seule zone elastique de la page. `min-height: 0` est indispensable : sans
   lui un element flex refuse de descendre sous la taille de son contenu, et
   c'est le reste de la page qui serait pousse dehors. */
/* L'etape 2 ne parle que du dialogue a venir : le dessin de posture y ramenerait
   l'oeil sur le message precedent. */
.gate.is-perm .gate-art,
.gate.is-perm .onboard-head {
  display: none;
}

.gate.is-onboard .gate-art {
  flex: 1 1 auto;
  min-height: 0;
  max-height: 300px;
  max-height: min(40svh, 300px);
  /* La rangee du milieu — le telephone — est la seule qui cede ; les fleches
     gardent leur place. */
  grid-template-rows: auto minmax(0, 1fr) auto;
}

/* Le telephone se dimensionne DESORMAIS PAR SA HAUTEUR, la largeur suivant par
   aspect-ratio. `.grip` doit garder exactement sa taille, sinon les
   pourcentages de placement de la main se decalent (voir .hand). */
.gate.is-onboard .grip {
  height: 100%;
}

.gate.is-onboard .phone {
  width: auto;
  height: 100%;
}

.onboard-head {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

/* La typographie de cette page se regle sur DEUX dimensions, pas une seule.
   Reglee sur la largeur seule, elle garde sa taille sur un ecran court et
   mange toute la place de l'illustration — a 568 px de haut celle-ci tombait a
   40 px, ou elle n'apprend plus rien. `min(vw, svh)` la fait donc ceder aussi
   quand c'est la HAUTEUR qui manque. La ligne sans `svh` juste avant est le
   repli pour les navigateurs qui l'ignorent. */
.onboard-title {
  margin: 0;
  font-size: clamp(1.35rem, 6.4vw, 2.1rem);
  font-size: clamp(1.2rem, min(6.4vw, 3.4svh), 2.1rem);
  font-weight: 600;
  line-height: 1.2;
}

/* Regrossis une fois l'avertissement de permission sorti vers l'etape 2 : c'est
   sa cohabitation avec le reste qui obligeait tout le monde a etre minuscule. */
.onboard-sub,
.onboard-posture {
  margin: 0;
  color: var(--muted);
  font-size: clamp(1rem, 4.4vw, 1.2rem);
  font-size: clamp(0.92rem, min(4.4vw, 2.3svh), 1.2rem);
  line-height: 1.45;
  max-width: 24rem;
}

.onboard-body {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.7rem;
  gap: clamp(0.5rem, 1.6svh, 1rem);
  width: 100%;
  max-width: 24rem;
}

/* ─── Etape 2 : l'avertissement sur la permission ──────────────────────────── */
/* Un ecran a lui seul, et non plus un encart au milieu de l'etape 1. Il ne
   porte qu'une idee, donc il peut etre ecrit GRAND — c'est tout l'interet de
   l'avoir sorti : la ou tout cohabitait, tout etait minuscule. */

.permstep {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: clamp(1rem, 3svh, 1.8rem);
  width: 100%;
  max-width: 24rem;
}

.permstep-title {
  margin: 0;
  font-size: clamp(1.1rem, min(5.2vw, 2.8svh), 1.55rem);
  font-weight: 600;
  line-height: 1.3;
}

.permstep-note {
  margin: 0;
  color: var(--muted);
  font-size: clamp(0.95rem, min(4.2vw, 2.2svh), 1.15rem);
  line-height: 1.5;
}

.permstep-note b {
  color: var(--ball);
  font-size: 1.15em;
}

/* L'illustration du dialogue est ici le sujet de l'ecran, plus une vignette :
   elle a le droit d'occuper la place. */
.permstep .fakeprompt {
  max-width: 19rem;
}

.permstep .fakeprompt-title {
  font-size: 0.95rem;
}

.permstep .fakeprompt-btns {
  font-size: 1rem;
}

/* Une IMAGE de dialogue, jamais un dialogue : inclinee, estompee et sourde aux
   pointeurs pour qu'aucun doigt n'aille l'essayer. */
.fakeprompt {
  pointer-events: none;
  width: 100%;
  max-width: 15rem;
  padding: 0.6rem 0.5rem 0.4rem;
  border: 1px solid rgba(238, 242, 248, 0.16);
  border-radius: 0.7rem;
  background: rgba(238, 242, 248, 0.05);
  transform: rotate(-2.5deg);
  opacity: 0.85;
}

.fakeprompt-title {
  margin: 0 0 0.5rem;
  font-size: 0.75rem;
  color: var(--muted);
  line-height: 1.3;
}

.fakeprompt-btns {
  display: flex;
  border-top: 1px solid rgba(238, 242, 248, 0.12);
  font-size: 0.8rem;
}

.fakeprompt-btns span {
  flex: 1;
  padding: 0.4rem 0.2rem 0.1rem;
  color: var(--muted);
}

.fakeprompt-btns .yes {
  color: var(--bg);
  font-weight: 600;
  background: var(--ball);
  border-radius: 0.4rem;
  margin: 0.25rem;
  padding: 0.35rem 0.2rem;
}

/* Seule cible tapable de la notice : tout le reste est du texte. */
.onboard-go {
  appearance: none;
  width: 100%;
  padding: 0.95rem 1.4rem;
  padding-block: clamp(0.6rem, 1.7svh, 0.95rem);
  border: 0;
  border-radius: 2rem;
  background: var(--ball);
  color: var(--bg);
  font: inherit;
  font-size: clamp(1.05rem, 4.8vw, 1.3rem);
  font-weight: 600;
  cursor: pointer;
}

.onboard-go:active {
  transform: scale(0.98);
}

.onboard-skip {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  color: var(--muted);
  font-size: 0.95rem;
  /* Cible large : on coche ca d'un pouce, debout, dans une salle. */
  padding: 0.5rem;
  cursor: pointer;
}

.onboard-skip input {
  width: 1.1rem;
  height: 1.1rem;
  accent-color: var(--ball);
}

/* ─── Permission refusée : rattrapage ──────────────────────────────────────── */

.denied {
  z-index: 20;
  gap: 1.2rem;
  padding: 2rem 1.4rem;
  text-align: center;
}

.denied-title {
  margin: 0;
  font-size: clamp(1.1rem, 5vw, 1.5rem);
  font-weight: 600;
}

.denied-text,
.denied-hint {
  margin: 0;
  color: var(--muted);
  font-size: 0.95rem;
  line-height: 1.45;
  max-width: 22rem;
}

.denied-hint {
  font-size: 0.85rem;
}

.denied-retry {
  appearance: none;
  padding: 0.9rem 2rem;
  border: 0;
  border-radius: 2rem;
  background: var(--ball);
  color: var(--bg);
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}

.denied-pad {
  appearance: none;
  margin-top: 0.6rem;
  padding: 0.7rem 1.4rem;
  border: 1px solid rgba(238, 242, 248, 0.25);
  border-radius: 2rem;
  background: none;
  color: var(--muted);
  font: inherit;
  cursor: pointer;
}

/* ─── Manette dégradée : boutons de direction ──────────────────────────────── */
/* Utilisé quand le capteur d'orientation est refusé, bloqué ou absent. Les
   quatre boutons sont posés autour du plateau dessiné dans le canvas — le
   centre reste libre pour que la bille d'intention continue de dire ce qui est
   envoyé, exactement comme en mode inclinaison. */

.dpad {
  position: fixed;
  inset: 0;
  z-index: 10;
  display: grid;
  /* Le canvas trace un plateau de rayon 40vmin : des colonnes/lignes de 30vmin
     posent les boutons juste au bord, sans masquer la bille au repos. */
  grid-template: repeat(3, 30vmin) / repeat(3, 30vmin);
  place-content: center;
  /* Le fond reste transparent aux pointeurs : seuls les boutons les prennent,
     sinon le double-tap et le bouton Quitter deviendraient inatteignables. */
  pointer-events: none;
}

.dpad-btn {
  pointer-events: auto;
  appearance: none;
  border: 2px solid rgba(238, 242, 248, 0.22);
  border-radius: 1.2rem;
  background: rgba(238, 242, 248, 0.06);
  color: var(--ink);
  font-size: 7vmin;
  line-height: 1;
  opacity: 0.7;
  cursor: pointer;
  /* Sans ça, un appui maintenu déclenche le défilement ou la loupe iOS et le
     bouton « décroche » au milieu d'une poussée. */
  touch-action: none;
}

.dpad-btn:active {
  background: color-mix(in srgb, var(--ball) 35%, transparent);
  border-color: var(--ball);
  opacity: 1;
}

.dpad-btn.up {
  grid-area: 1 / 2;
}

.dpad-btn.left {
  grid-area: 2 / 1;
}

.dpad-btn.right {
  grid-area: 2 / 3;
}

.dpad-btn.down {
  grid-area: 3 / 2;
}

/* ─── Quitter (manette) ────────────────────────────────────────────────────── */

/* Le HUD est transparent aux pointeurs : le bouton doit les reprendre, sinon le
   tap traverse jusqu'au plateau. */
.quit {
  pointer-events: auto;
  appearance: none;
  background: none;
  border: 0;
  color: var(--muted);
  font: inherit;
  line-height: 1;
  cursor: pointer;
  opacity: 0.55;

  /* Cible de 44 px MINIMUM, en dur. L'ancienne version se contentait d'un peu
     de remplissage autour d'un glyphe dimensionne en vw : sur un telephone de
     390 px, ca donnait 34 px de haut — sous le seuil ou un pouce vise juste, et
     rate a repetition. La taille de la zone tapable ne doit dependre ni de la
     largeur de l'ecran ni de celle du caractere. */
  display: grid;
  place-items: center;
  width: 2.75rem;
  height: 2.75rem;
  padding: 0;
  font-size: clamp(1.4rem, 5.5vw, 1.9rem);

  /* Le HUD aligne ses enfants sur la ligne de base ; une boite de 44 px
     centree n'en a plus de comparable, on la cale donc en haut. Les marges
     negatives absorbent l'agrandissement pour que la croix reste visuellement
     dans son coin au lieu de deriver vers le centre. */
  align-self: flex-start;
  margin: -0.6rem -0.75rem -0.6rem 0;
}

.quit:active {
  opacity: 1;
}

.bye {
  gap: 2.5rem;
}

.bye-title {
  margin: 0;
  font-size: clamp(1.4rem, 6vw, 2.4rem);
  font-weight: 600;
}

.bye-back {
  appearance: none;
  background: none;
  color: var(--muted);
  font: inherit;
  font-size: clamp(0.9rem, 3.5vw, 1.1rem);
  padding: 0.7rem 1.4rem;
  border: 1px solid rgba(238, 242, 248, 0.25);
  border-radius: 2rem;
  cursor: pointer;
}

/* Diagnostic (/play?debug=1) */
.diag {
  position: fixed;
  left: 0.75rem;
  bottom: 0.75rem;
  margin: 0;
  padding: 0.6rem 0.8rem;
  z-index: 10;
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 0.7rem;
  line-height: 1.5;
  color: var(--accent);
  background: rgba(0, 0, 0, 0.6);
  border-radius: 0.5rem;
  white-space: pre;
  pointer-events: none;
}

/* ─── Étape 5 : page publique, administration ──────────────────────────────── */

/* Ces deux pages sont des documents, pas des terrains : elles défilent, alors
   que l'écran et la manette sont bloqués en plein cadre. */
body.page {
  overflow: auto;
  user-select: text;
  -webkit-user-select: text;
}

.pad {
  min-height: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 1.6rem;
  padding: 2rem 1.2rem;
  text-align: center;
}

.pad-title {
  margin: 0;
  font-size: clamp(1.3rem, 5vw, 2rem);
  font-weight: 600;
}

/* La consigne, juste sous le titre : elle dit OÙ trouver le code. Sans elle,
   « Bienvenue » et six cases vides laissent chercher. */
.pad-lead {
  margin: -0.8rem 0 0;
  max-width: 26rem;
  color: var(--muted);
  font-size: clamp(0.95rem, 3.5vw, 1.1rem);
}

.pad-note {
  margin: 0;
  color: var(--muted);
  font-size: 0.95rem;
  max-width: 32rem;
}

/* Le rappel du QR ferme la page, en retrait : c'est une information utile la
   PROCHAINE fois, pas une consigne de plus à lire maintenant. */
.pad-hint {
  margin: 0;
  max-width: 26rem;
  color: var(--muted);
  opacity: 0.7;
  font-size: 0.85rem;
}

/* Un champ unique, dessiné en six cases. Rien ici n'a de largeur en `em` ni en
   `vw` : les cases se partagent la place restante (`flex: 1 1 0`), donc le bloc
   tient dans l'écran quelle qu'en soit la largeur. La version précédente
   dimensionnait deux champs sur la police (`4.5em` à `12vw`), c'est-à-dire à
   plus d'une demi-largeur d'écran chacun — sur un téléphone ils sortaient des
   deux côtés. */
.code {
  display: flex;
  justify-content: center;
  width: 100%;
}

/* Le `<label>` couvre tout : toucher n'importe où — une case, le tiret, la
   marge entre deux cases — donne le focus au champ. */
.code-field {
  position: relative;
  display: block;
  width: min(100%, 22rem);
  cursor: text;
}

/* Le vrai champ, invisible mais bien là : il porte la valeur, le clavier, le
   collage et l'accessibilité, pendant que les cases se contentent d'afficher.
   `font-size: 16px` n'est pas un choix de style — en dessous, iOS zoome sur la
   page à la prise de focus et ne dézoome jamais. */
.code-input {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  font-size: 16px;
  color: transparent;
  caret-color: transparent;
  outline: none;
}

.code-cells {
  display: flex;
  align-items: center;
  gap: clamp(0.25rem, 1.5vw, 0.5rem);
}

.code-cell {
  flex: 1 1 0;
  min-width: 0;
  /* Une case est un rectangle debout, pas un carré : c'est la forme qu'on
     reconnaît comme « un chiffre à mettre ici ». */
  aspect-ratio: 3 / 4;
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: clamp(1.3rem, 7vw, 2rem);
  font-weight: 600;
  color: var(--ink);
  background: rgba(238, 242, 248, 0.06);
  border: 1px solid rgba(238, 242, 248, 0.18);
  border-radius: 0.7rem;
  transition:
    border-color 0.15s,
    background 0.15s;
}

.code-cell.is-filled {
  background: rgba(238, 242, 248, 0.1);
  border-color: rgba(238, 242, 248, 0.34);
}

/* La case en cours : le champ n'a pas de curseur visible (il est transparent),
   c'est donc ce liseré qui dit où l'on en est. Le trait clignotant reprend le
   curseur là où on l'attend, à l'intérieur de la case vide. */
.code-cell.is-on {
  border-color: var(--accent);
  background: rgba(238, 242, 248, 0.12);
}

.code-cell.is-on:not(.is-filled)::after {
  content: "";
  width: 2px;
  height: 1.1em;
  background: var(--accent);
  border-radius: 1px;
  animation: caret 1.1s steps(1) infinite;
}

@keyframes caret {
  50% {
    opacity: 0;
  }
}

/* Le tiret garde sa place dans la ligne : c'est lui qui fait lire « trois-
   quatre-cinq, deux-trois-deux » plutôt que six chiffres à la suite. */
.code-sep {
  flex: 0 0 auto;
  padding: 0 clamp(0.1rem, 1vw, 0.35rem);
  font-size: clamp(1.1rem, 5vw, 1.6rem);
  color: var(--muted);
}

/* Le code sur l'écran partagé : lisible du fond de la salle, puisque c'est ce
   qu'on dicte quand le QR ne passe pas. */
.card-code {
  margin: 0; /* l'écart vient de .card-step */
  max-width: 100%;
  white-space: nowrap;
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: clamp(1.4rem, 4vw, 3.5rem); /* plafond ; fitOneLine() ajuste */
  font-weight: 700;
  letter-spacing: 0.06em;
  color: #10131a;
}
