/**
 * style.css — Zero Heroes.
 *
 * Every colour, size, shadow and easing here comes from tokens.css, which is the
 * implementation of docs/design/tokens.md. If you find yourself typing a hex
 * value into this file, the token is missing — add it there instead.
 *
 * Phase 1 covers solo mode only. The stall attract loop, tag-in, BLACKOUT and
 * the leaderboards are Phase 2+.
 */

/* ── fonts ──────────────────────────────────────────────────────────
   Vendored path FIRST, CDN second. Browsers walk the src list, so the container
   serves locally (no outbound requests, works with the mall wifi down) and
   ./serve.sh — which has no vendor/ — still renders in-brand off the CDN. */

@font-face {
  font-family: "Montserrat"; font-style: normal; font-weight: 500; font-display: swap;
  src: url("./vendor/fonts/montserrat-latin-500-normal.woff2") format("woff2"),
       url("https://cdn.jsdelivr.net/npm/@fontsource/montserrat@5.2.5/files/montserrat-latin-500-normal.woff2") format("woff2");
}
@font-face {
  font-family: "Montserrat"; font-style: normal; font-weight: 600; font-display: swap;
  src: url("./vendor/fonts/montserrat-latin-600-normal.woff2") format("woff2"),
       url("https://cdn.jsdelivr.net/npm/@fontsource/montserrat@5.2.5/files/montserrat-latin-600-normal.woff2") format("woff2");
}
@font-face {
  font-family: "Montserrat"; font-style: normal; font-weight: 700; font-display: swap;
  src: url("./vendor/fonts/montserrat-latin-700-normal.woff2") format("woff2"),
       url("https://cdn.jsdelivr.net/npm/@fontsource/montserrat@5.2.5/files/montserrat-latin-700-normal.woff2") format("woff2");
}
@font-face {
  font-family: "Montserrat"; font-style: normal; font-weight: 800; font-display: swap;
  src: url("./vendor/fonts/montserrat-latin-800-normal.woff2") format("woff2"),
       url("https://cdn.jsdelivr.net/npm/@fontsource/montserrat@5.2.5/files/montserrat-latin-800-normal.woff2") format("woff2");
}
@font-face {
  font-family: "Montserrat"; font-style: normal; font-weight: 900; font-display: swap;
  src: url("./vendor/fonts/montserrat-latin-900-normal.woff2") format("woff2"),
       url("https://cdn.jsdelivr.net/npm/@fontsource/montserrat@5.2.5/files/montserrat-latin-900-normal.woff2") format("woff2");
}
@font-face {
  font-family: "Press Start 2P"; font-style: normal; font-weight: 400; font-display: swap;
  src: url("./vendor/fonts/press-start-2p-latin-400-normal.woff2") format("woff2"),
       url("https://cdn.jsdelivr.net/npm/@fontsource/press-start-2p@5.3.0/files/press-start-2p-latin-400-normal.woff2") format("woff2");
}

/* ── base ───────────────────────────────────────────────────────────── */

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

/* Several elements below are display:flex/grid, which beats the UA's
   [hidden]{display:none}. app.js drives .hidden on #result, #countdown,
   #stageFlash and #pbGhost, so without this the result card and the countdown
   render on top of the camera from the moment the page loads. */
[hidden] { display: none !important; }

html, body { height: 100%; }

body {
  margin: 0;
  background: var(--surface-ground);
  color: var(--text-body);
  font: var(--fw-medium) var(--fs-body-sm) / var(--lh-body) var(--font-core);
  display: flex;
  flex-direction: column;
  min-height: 100dvh;
  /* Pull-to-refresh mid-run would kill the run. */
  overscroll-behavior-y: contain;
  -webkit-tap-highlight-color: transparent;
}

/* Nothing in the HUD should be selectable — a kid mashing the screen shouldn't
   end up with a blue text selection over the battery. */
body, button, select { user-select: none; }

img { max-width: 100%; }

.sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip-path: inset(50%); white-space: nowrap;
}

:focus-visible {
  outline: none;
  box-shadow: var(--ring-focus);
  border-radius: var(--radius-sm);
}

/* ── backdrop ───────────────────────────────────────────────────────
   The dot-grid is the brand's only pattern, and the only infinite loop. Never
   faster than 38s (nauseating on a big screen), never stationary (reads as a
   bug). Masked so it fades before it reaches the corner rails. */

.bg {
  position: fixed;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  overflow: hidden;
  background: var(--wash-ground), var(--surface-ground);
}

.bg i {
  position: absolute;
  /* Oversized so the drift never exposes an edge. */
  inset: calc(-2 * var(--dotgrid-size));
  background-image: var(--dotgrid);
  background-size: var(--dotgrid-size) var(--dotgrid-size);
  mask-image: var(--dotgrid-mask);
  -webkit-mask-image: var(--dotgrid-mask);
  animation: drift var(--dur-drift) linear infinite;
}

@keyframes drift {
  to { transform: translate(var(--dotgrid-size), var(--dotgrid-size)); }
}

/* The ground itself warms when Stage Zero is reached: the charge wash replaces
   the brand wash for as long as the result is up. Cheap, and it is the only
   time the page outside the camera frame reacts to the game. */
body[data-phase="done"] .bg { background: var(--wash-charge), var(--wash-ground), var(--surface-ground); }

/* ── micro-label — the brand's connective tissue ─────────────────────
   Every panel opens with one. That's the rhythm. */

.micro {
  font: var(--fw-bold) var(--fs-micro) / 1 var(--font-core);
  letter-spacing: var(--ls-micro);
  text-transform: var(--tt-micro);
  color: var(--text-faint);
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
}

.micro b { color: var(--text-muted); font-weight: var(--fw-bold); }

.micro-charge { color: var(--text-charge); }

.micro-charge::before {
  content: "";
  width: 6px; height: 6px;
  border-radius: var(--radius-pill);
  background: var(--status-charge);
  box-shadow: var(--glow-text-charge);
}

/* A micro-sized line that is a SENTENCE — "No sign-up to play. Ever.", the
   board's status note, the PB ghost line. Same size and colour, but never
   uppercased or tracked: a sentence in caps is shouting, and tokens.md §9 is
   explicit that prose is sentence case. */
.micro-prose { text-transform: none; letter-spacing: 0; font-weight: var(--fw-medium); }

/* ── topbar ─────────────────────────────────────────────────────────── */

.topbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-4);
  padding: var(--space-3) clamp(var(--space-4), 3vw, var(--space-8));
  border-bottom: var(--border-w) solid var(--border-hair);
  background: var(--surface-glass);
  backdrop-filter: var(--blur-panel);
  position: sticky;
  top: 0;
  z-index: 20;
}

.brand { display: flex; align-items: center; gap: var(--space-4); text-decoration: none; }
/* The lockup already contains the shield, so only one of these shows at a time:
   the full lockup where there is room, the shield alone on a phone. */
.brand-shield { width: 44px; height: 44px; flex: none; display: none; }
.brand-lockup { height: 34px; width: auto; }

.topbar-end { display: flex; align-items: center; gap: var(--space-5); }

/* On desktop the shell must not exist as far as layout is concerned. */
.drawer-shell { display: contents; }

.controls { display: flex; align-items: center; gap: var(--space-3); }

/* The Stage Zero mark. 28px in the top bar (was 18 — invisible from the
   queue), larger on the set-pieces, and never below 0.9 opacity: it is the
   client's logo, not a footnote (design review 2026-09-02). */
.endorse { height: 28px; width: auto; opacity: 0.9; }
.endorse-lg { height: var(--control-sm); }
.brand-row { display: flex; align-items: center; gap: var(--space-3); }
.result .brand-row { margin-top: var(--space-2); }
.signup .brand-row { margin-bottom: var(--space-1); }

/* The parent's line. Prose, muted, one sentence wide — it is read over a
   kid's shoulder by the person who pays the electricity bill. */
.parent-line {
  margin: 0;
  max-width: var(--maxw-prose);
  font: var(--fw-medium) var(--fs-label) / var(--lh-body) var(--font-core);
  color: var(--text-muted);
  text-wrap: pretty;
}
.parent-line:empty { display: none; }
/* In the attract row the line reads off the mark, so it is left-aligned; and
   nothing in that stack may shrink — a flex column squeezes the CTA first. */
.attract .brand-row { gap: var(--space-4); text-align: left; }
.attract > * { flex: none; }
/* Same rule for the prize line as for the parent's: empty means absent. */
.prize:empty { display: none; }

.field { display: flex; flex-direction: column; gap: var(--space-1); }

select {
  appearance: none;
  font: var(--fw-semi) var(--fs-label) / 1 var(--font-core);
  color: var(--text-body);
  background: var(--surface-inset);
  border: var(--border-w) solid var(--border-hair);
  border-radius: var(--radius-pill);
  padding: 0 var(--space-4);
  height: var(--control-sm);
  box-shadow: var(--inset-hair);
  cursor: pointer;
}

select:disabled { opacity: 0.38; filter: saturate(0.2); cursor: not-allowed; }

/* ── buttons — always fully pill, never a colour change on press ────── */

.btn {
  font: var(--fw-heavy) var(--fs-label) / 1 var(--font-core);
  /* Sentence case, no tracking (tokens.md §2, 2026-09-02): +.1em was written
     for uppercase, and on "Play free" it reads as a typo. */
  letter-spacing: 0;
  height: var(--control-md);
  padding: 0 var(--space-5);
  border-radius: var(--radius-pill);
  border: var(--border-w) solid var(--border-hair);
  background: var(--surface-glass);
  color: var(--text-body);
  cursor: pointer;
  transition: filter var(--dur-fast) var(--ease-out),
              transform var(--dur-instant) var(--ease-out);
}

/* Physical, not chromatic. Nothing changes colour on hover or press. */
.btn:hover { filter: brightness(1.12); }
.btn:active { transform: scale(0.96); }
.btn:disabled { opacity: 0.38; filter: saturate(0.2); cursor: not-allowed; }

.btn-charge {
  background: var(--status-charge);
  color: var(--text-on-accent);
  border-color: transparent;
  box-shadow: var(--glow-charge);
}

.btn-ghost { background: transparent; border-color: transparent; color: var(--text-muted); }

.btn-lg { height: var(--control-lg); padding: 0 var(--space-8); font-size: var(--fs-body-sm); }

/* ── burger + drawer (mobile only) ───────────────────────────────────── */

.burger { display: none; }
.scrim { display: none; }

/* ── layout ─────────────────────────────────────────────────────────
   Stall/desktop is a two-column read: battery left (the tallest thing on
   screen), camera centre. */

.stage {
  flex: 1;
  min-height: 0;
  display: grid;
  /* Two columns, not three: battery left (the tallest thing on screen), camera
     centre. The right-hand telemetry column is gone — what survived of it is a
     HUD row inside the camera frame (design review 2026-09-02). */
  grid-template-columns: minmax(150px, 220px) minmax(0, 1fr);
  gap: clamp(var(--space-4), 2vw, var(--space-8));
  padding: clamp(var(--space-4), 2vw, var(--space-8));
  align-items: stretch;
}

.panel {
  background: var(--surface-glass);
  border: var(--border-w) solid var(--border-hair);
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-panel);
  padding: var(--space-3) var(--space-4);
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
}

/* ── battery ────────────────────────────────────────────────────────
   app.js sets ONLY --fill. Here that drives HEIGHT. On mobile the same
   property drives WIDTH. The scoring code never learns which. */

.battery-col {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-3);
  min-height: 0;
}

.battery {
  flex: 1;
  min-height: 0;
  width: 100%;
  max-width: 132px;
  display: flex;
  flex-direction: column-reverse; /* cap sits on top */
  align-items: center;
}

.battery-cap {
  width: 38%;
  height: 10px;
  border-radius: var(--radius-cell) var(--radius-cell) 0 0;
  background: var(--surface-raised);
  border: var(--border-w) solid var(--border-hair);
  border-bottom: 0;
}

.battery-well {
  position: relative;
  flex: 1;
  min-height: 0;
  width: 100%;
  border-radius: var(--radius-md);
  background: var(--surface-inset);
  border: var(--border-w) solid var(--border-hair);
  box-shadow: var(--inset-well);
  overflow: hidden;
}

.battery-fill {
  position: absolute;
  left: 0; right: 0; bottom: 0;
  height: var(--fill, 0%);
  background: linear-gradient(to top, var(--fill-bot, #8f1f1f), var(--fill-top, #f26a6a));
  box-shadow: 0 0 26px var(--fill-glow, transparent);
  transition: height var(--dur-fast) linear,
              background var(--dur-base) var(--ease-out);
}

/* The cells are an overlay of gaps, not 20 elements — so the fill underneath
   stays one continuous bar and the orientation abstraction holds. */
.battery-cells {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: repeating-linear-gradient(
    to bottom,
    transparent 0 calc(5% - 3px),
    var(--surface-sunken) calc(5% - 3px) 5%
  );
  opacity: 0.85;
}

.battery.full .battery-well {
  box-shadow: var(--inset-well), var(--glow-charge);
  border-color: var(--border-charge);
}

.stage-text {
  margin: 0;
  text-align: center;
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
}
.stage-text small {
  font: var(--fw-bold) var(--fs-micro) / 1 var(--font-core);
  letter-spacing: var(--ls-micro);
  text-transform: uppercase;
  color: var(--text-faint);
}
/* Numerics get the arcade face. Never a sentence in Press Start 2P. */
.stage-text b {
  font: var(--fw-regular) var(--fs-num-lg) / 1 var(--font-arcade);
  letter-spacing: var(--ls-arcade);
}
.stage-text.zero b {
  color: var(--stage-0);
  text-shadow: var(--glow-text-charge);
  /* Four letters, not one numeral — at --fs-num-lg it overruns the battery
     column it is captioning. */
  font-size: var(--fs-num);
}

/* Transparent to layout on desktop, where stage and "x% to go" stack under the
   vertical battery exactly as before. Mobile turns it into a row. */
.battery-readout { display: contents; }

.to-go {
  margin: 0;
  text-align: center;
  font: var(--fw-semi) var(--fs-label) / var(--lh-snug) var(--font-core);
  color: var(--text-muted);
}
.to-go.done { color: var(--text-charge); }

/* ── viewport ───────────────────────────────────────────────────────── */

.viewport {
  position: relative;
  border-radius: var(--radius-lg);
  overflow: hidden;
  background: var(--surface-sunken);
  border: var(--border-w) solid var(--border-hair);
  box-shadow: var(--inset-well);
  min-height: 0;
}

/* Video and canvas share one intrinsic size and both cover, so they crop
   identically and the skeleton stays registered to the body. */
.viewport video,
.viewport canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* Mirrored: a player raising their right hand should see it go up on the
     right of the screen, like a mirror. */
  transform: scaleX(-1);
}

.viewport::after {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  border-radius: inherit;
  box-shadow: inset 0 0 0 0 transparent;
  transition: box-shadow var(--dur-slow) var(--ease-out);
}

/* Stage climb — the hit has to register with the volume down, which at a mall
   stall it always will be. */
.viewport.hit::after {
  animation: hit var(--dur-slow) var(--ease-out);
}
@keyframes hit {
  from { box-shadow: inset 0 0 0 4px var(--status-charge), var(--glow-charge); }
  to   { box-shadow: inset 0 0 0 0 transparent; }
}

.protect { position: absolute; left: 0; right: 0; pointer-events: none; }
.protect-top { top: 0; height: 34%; background: var(--protect-top); }
.protect-bottom { bottom: 0; height: 42%; background: var(--protect-bottom); }

/* Restraint, not a filter. Darkens: white lines lift blacks and wash out the
   skeleton over a live feed (tokens.md §7.5). */
.scanlines {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: var(--scanlines);
}

/* ── HUD over the camera ─────────────────────────────────────────────── */

.hud-top {
  position: absolute;
  top: var(--hud-safe);
  left: var(--hud-safe);
  right: var(--hud-safe);
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: var(--space-4);
}

.hud-bottom {
  position: absolute;
  left: var(--hud-safe);
  bottom: var(--hud-safe);
}

.timer, .combo {
  margin: 0;
  font: var(--fw-regular) var(--fs-num) / 1 var(--font-arcade);
  letter-spacing: var(--ls-arcade);
  color: var(--text-muted);
  transition: color var(--dur-base) var(--ease-out);
}
.timer small { font-size: 0.5em; margin-left: 0.25em; }
.timer.live { color: var(--text-hero); text-shadow: var(--glow-text-white); }
.timer.stopped { color: var(--text-charge); text-shadow: var(--glow-text-charge); }

.combo.hot { color: var(--text-charge); text-shadow: var(--glow-text-charge); }

/* ── set-pieces over the camera ──────────────────────────────────────── */

.countdown {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  font: var(--fw-regular) clamp(56px, 11vw, var(--fs-num-mega)) / 1 var(--font-arcade);
  color: var(--text-hero);
  text-shadow: 0 0 60px var(--border-brand);
  animation: pop var(--dur-base) var(--ease-pop);
  pointer-events: none;
}
.countdown.go { color: var(--text-charge); text-shadow: var(--glow-text-charge); }

@keyframes pop {
  from { transform: scale(0.7); opacity: 0; }
  to   { transform: scale(1); opacity: 1; }
}

.stage-flash {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--space-2);
  pointer-events: none;
  animation: pop var(--dur-base) var(--ease-pop);
}
.stage-flash small {
  font: var(--fw-bold) var(--fs-micro) / 1 var(--font-core);
  letter-spacing: var(--ls-micro);
  text-transform: uppercase;
  color: var(--text-muted);
}
.stage-flash b {
  font: var(--fw-black) clamp(var(--fs-title), 6vw, var(--fs-title-lg)) / 1 var(--font-core);
  letter-spacing: var(--ls-display);
}

/* ── state overlay ───────────────────────────────────────────────────── */

.state {
  position: absolute;
  inset: 0;
  /* Above the HUD meters (z 4): an overlay that dims the frame must also cover
     the bars, or "Back into frame" shows four meters holding stale values. */
  z-index: 5;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--space-3);
  text-align: center;
  padding: var(--space-6);
  background: var(--surface-scrim);
  backdrop-filter: var(--blur-scrim);
  transition: opacity var(--dur-base) var(--ease-out);
}
.state.hidden { opacity: 0; pointer-events: none; }

.state-icon { font-size: var(--fs-title); line-height: 1; }
.state-title {
  margin: 0;
  font: var(--fw-black) clamp(var(--fs-title-sm), 4vw, var(--fs-title)) / var(--lh-tight) var(--font-core);
  letter-spacing: var(--ls-display);
  color: var(--text-hero);
}
.state-sub {
  margin: 0;
  max-width: var(--maxw-prose);
  font: var(--fw-medium) var(--fs-body-sm) / var(--lh-body) var(--font-core);
  color: var(--text-muted);
}
.state.warn .state-title { color: var(--text-hero); }

.spinner {
  width: 34px; height: 34px;
  border-radius: var(--radius-pill);
  border: 3px solid var(--border-hair);
  border-top-color: var(--status-charge);
  animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }

/* ── result — Stage Zero ─────────────────────────────────────────────
   The one place --glow-charge-hard is used. Never both a glow and a heavy
   shadow unless it is THE hero element; this is it. */

.result {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--space-4);
  text-align: center;
  padding: var(--space-6);
  background: var(--wash-charge), var(--surface-scrim);
  backdrop-filter: var(--blur-scrim);
  animation: pop var(--dur-set-piece) var(--ease-pop);
}

.result-time {
  margin: 0;
  font: var(--fw-regular) clamp(var(--fs-num-lg), 7vw, var(--fs-num-hero)) / 1 var(--font-arcade);
  color: var(--text-charge);
  text-shadow: var(--glow-text-charge);
}

/* ── Stage Zero — the lights come back on ────────────────────────────
   The mirror of lights-out: the room is dark, the tube catches twice, and
   then it is lit. Same set-piece duration, --ease-out rather than
   --ease-dead because power coming back is a relief, not a shock. The two
   flash stops are the same values lights-out uses — reused, not new. */
.result.win { animation: lights-on var(--dur-set-piece) var(--ease-out); }

@keyframes lights-on {
  0%   { background: var(--surface-sunken); }
  18%  { background: var(--surface-sunken); }
  24%  { background: rgba(255, 255, 255, 0.55); }
  30%  { background: var(--surface-sunken); }
  40%  { background: #ffffff; }
  48%  { background: var(--wash-charge), var(--surface-scrim); }
  100% { background: var(--wash-charge), var(--surface-scrim); }
}

/* The time is the score, and on a win it is the biggest thing on screen. */
.result.win .result-time {
  /* Floor stays at --fs-num-lg: a 64px floor clips "21.7s" below ~365px wide.
     10vw does the growing, and the TV block pins it at mega. */
  font-size: clamp(var(--fs-num-lg), 10vw, var(--fs-num-mega));
  text-shadow: var(--glow-text-charge), var(--glow-text-white);
}

.result-delta {
  margin: 0;
  font: var(--fw-heavy) var(--fs-label) / 1 var(--font-core);
  letter-spacing: 0;
}
.result-delta.record { color: var(--text-charge); }
.result-delta.slower { color: var(--text-muted); }

.result-meta {
  margin: 0;
  font: var(--fw-medium) var(--fs-label) / var(--lh-body) var(--font-core);
  color: var(--text-muted);
}

/* ── full-viewport set-pieces ─────────────────────────────────────────
   Attract, setup, tag-in, BLACKOUT and the result card all occupy the camera
   viewport. Only one is ever visible — app.js drives that through showOverlay().
   Content is centred here; per tokens.md §6 only set-pieces get to centre. */

.attract, .setup, .tagin, .blackout {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--space-4);
  text-align: center;
  padding: var(--space-6);
  z-index: 5;
}

/* ── attract ──────────────────────────────────────────────────────
   The stall's shop window. A stall that reacts to passers-by stops them. */

/* NO full scrim and NO blur here, unlike the other set-pieces.
   Attract's whole job is to show the demo ghost playing on the canvas behind it,
   and a scrim plus blur hid the one thing the screen exists to display. Text
   gets a bottom-up protection gradient instead and sits low, which is the rule
   for type over video anyway (tokens.md §4) — so the ghost has the upper two
   thirds of the frame to itself. */
.attract {
  background: var(--protect-bottom);
  /* flex-start plus a flexible SPACER (::before, below), NOT justify-content:
     flex-end. Both bottom-anchor the stack when it is short, but once a
     stall's five-row board makes it taller than the frame, flex-end overflows
     the START edge — which is unreachable by scrolling — and the lockup was
     clipped off the top of the TV (measured 83 of 98px gone). This way the
     overflow is at the end and scrolls.

     A spacer rather than margin-top:auto on the first child, because on the
     phone the first child (the lockup) is display:none and the auto margin
     went with it — the whole stack sat on top of the shield and the demo HUD
     (seen on a real Android, 2026-09-03). */
  justify-content: flex-start;
  overflow-y: auto;
  /* No visible bar: a mall TV cannot scroll and the bar read as a bug. A
     mouse wheel still works on a laptop. */
  scrollbar-width: none;
  padding-bottom: var(--space-10);
  gap: var(--space-4);
}
.attract::-webkit-scrollbar { display: none; }
/* The spacer: takes every spare pixel when the stack is short, collapses to
   nothing when it is tall. Not matched by `.attract > *`, so it keeps flex. */
.attract::before { content: ""; flex: 1 1 0; min-height: 0; }


.attract-lockup {
  width: min(45%, 380px);
  /* The only thing on screen that breathes. It stops the moment a body is
     detected — the screen has something better to do then. */
  animation: breathe 6s var(--ease-in-out) infinite;
}
.attract.live .attract-lockup { animation: none; }

@keyframes breathe {
  0%, 100% { transform: scale(1); opacity: 0.9; }
  50%      { transform: scale(1.03); opacity: 1; }
}

.attract-title {
  margin: 0;
  font: var(--fw-black) clamp(var(--fs-title), 5vw, var(--fs-title-lg)) / var(--lh-tight) var(--font-core);
  letter-spacing: var(--ls-display);
  color: var(--text-hero);
  transition: color var(--dur-base) var(--ease-out);
}
/* Somebody is standing there. Say so. */
.attract.live .attract-title { color: var(--text-charge); text-shadow: var(--glow-text-charge); }

.attract-sub {
  margin: 0;
  max-width: var(--maxw-prose);
  font: var(--fw-medium) var(--fs-body) / var(--lh-body) var(--font-core);
  color: var(--text-muted);
  text-wrap: pretty;
}

/* ── team setup ───────────────────────────────────────────────────── */

.setup {
  background: var(--surface-scrim);
  backdrop-filter: var(--blur-scrim);
  gap: var(--space-4);
  overflow-y: auto;
}

.setup-title {
  margin: 0;
  font: var(--fw-black) clamp(var(--fs-title-sm), 4.5vw, var(--fs-title)) / var(--lh-tight) var(--font-core);
  letter-spacing: var(--ls-display);
  color: var(--text-hero);
}

/* One question, five answers, thumb-sized. --control-lg is the mall-stall and
   kid-thumb minimum for anything you have to hit. */
.charger-count { display: flex; gap: var(--space-2); flex-wrap: wrap; justify-content: center; }
.charger-count button {
  width: var(--control-lg);
  height: var(--control-lg);
  border-radius: var(--radius-pill);
  border: var(--border-w) solid var(--border-hair);
  background: var(--surface-glass);
  color: var(--text-body);
  font: var(--fw-regular) var(--fs-num) / 1 var(--font-arcade);
  cursor: pointer;
  transition: filter var(--dur-fast) var(--ease-out), transform var(--dur-instant) var(--ease-out);
}
.charger-count button:hover { filter: brightness(1.12); }
.charger-count button:active { transform: scale(0.96); }
/* Selected is a green fill with the charge glow — the system's "selected". */
.charger-count button.on {
  background: var(--status-charge);
  color: var(--text-on-accent);
  border-color: transparent;
  box-shadow: var(--glow-charge);
}

.field-wide { width: min(100%, var(--maxw-form)); gap: var(--space-2); }
.field-wide input {
  width: 100%;
  height: var(--control-lg);
  padding: 0 var(--space-4);
  border-radius: var(--radius-pill);
  border: var(--border-w) solid var(--border-hair);
  background: var(--surface-inset);
  box-shadow: var(--inset-well);
  color: var(--text-hero);
  font: var(--fw-semi) var(--fs-body-sm) / 1 var(--font-core);
  text-align: center;
}
.field-wide input::placeholder { color: var(--text-faint); font-weight: var(--fw-medium); }

.field-hint {
  font: var(--fw-medium) var(--fs-micro) / var(--lh-body) var(--font-core);
  color: var(--text-faint);
}
/* Amber, not red: a name we won't take is a nudge, not an error. Red belongs to
   the stage ladder and nothing else (tokens.md §1.4b). */
.field-hint.bad { color: var(--status-warn); }

.setup-legs {
  margin: 0;
  font: var(--fw-medium) var(--fs-label) / var(--lh-body) var(--font-core);
  color: var(--text-muted);
}

/* ── tag-in ───────────────────────────────────────────────────────
   The clock and the drain are paused behind this. The countdown is the only
   pressure — no double punishment for a handoff. */

.tagin {
  background: var(--surface-scrim);
  backdrop-filter: var(--blur-scrim);
  gap: var(--space-3);
}

.tagin-title {
  margin: 0;
  font: var(--fw-black) clamp(var(--fs-title-sm), 5vw, var(--fs-title-lg)) / var(--lh-tight) var(--font-core);
  letter-spacing: var(--ls-display);
  color: var(--text-hero);
}

.tagin-count {
  margin: 0;
  font: var(--fw-regular) clamp(var(--fs-num-hero), 14vw, var(--fs-num-mega)) / 1 var(--font-arcade);
  color: var(--text-charge);
  text-shadow: var(--glow-text-charge);
  animation: pop var(--dur-base) var(--ease-pop);
}

.tagin-sub {
  margin: 0;
  font: var(--fw-medium) var(--fs-body-sm) / var(--lh-body) var(--font-core);
  color: var(--text-muted);
}

/* ── BLACKOUT ─────────────────────────────────────────────────────
   The fail state IS the load-shedding metaphor, and it is a designed moment,
   not an error screen. Absence of light, never danger — so no red anywhere
   here, and the lights do not fade, they GO (--ease-dead is a hard ease-in). */

.blackout {
  background: var(--surface-sunken);
  gap: var(--space-5);
  animation: lights-out var(--dur-set-piece) var(--ease-dead);
}

@keyframes lights-out {
  /* A dying fluorescent tube, then nothing. */
  0%   { background: var(--surface-scrim); }
  8%   { background: #ffffff; }
  12%  { background: var(--surface-sunken); }
  20%  { background: #ffffff; }
  26%  { background: var(--surface-sunken); }
  34%  { background: rgba(255, 255, 255, 0.55); }
  100% { background: var(--surface-sunken); }
}

.blackout-title {
  margin: 0;
  font: var(--fw-black) clamp(var(--fs-title), 8vw, var(--fs-display)) / var(--lh-tight) var(--font-core);
  letter-spacing: var(--ls-display);
  text-transform: uppercase;
  color: var(--text-hero);
  opacity: 0.92;
}

.blackout-line {
  margin: 0;
  max-width: var(--maxw-prose);
  font: var(--fw-medium) var(--fs-body) / var(--lh-body) var(--font-core);
  color: var(--text-muted);
}

/* The ONE place the light-polarity scanlines are used, at full strength. Dark
   lines on a near-black dying screen would be invisible (tokens.md §7.5). */
.scanlines-dead {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: var(--scanlines-dead);
  opacity: 0.7;
}

/* ── loader ───────────────────────────────────────────────────────
   The wait is branded, not hidden: the loader IS a battery charging. Same
   --fill custom property the real battery uses, so the two read as one object
   (PRD §9.4). */

.loader {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--space-4);
  text-align: center;
  padding: var(--space-6);
  background: var(--surface-scrim);
  backdrop-filter: var(--blur-scrim);
  z-index: 6;
}

.loader-battery {
  position: relative;
  width: min(80%, 520px);
  height: 46px;
  border-radius: var(--radius-md);
  background: var(--surface-inset);
  border: var(--border-w) solid var(--border-hair);
  box-shadow: var(--inset-well);
  overflow: hidden;
}
.loader-fill {
  position: absolute;
  inset: 0 auto 0 0;
  width: var(--fill, 0%);
  background: linear-gradient(to right, var(--green-600), var(--green-400));
  box-shadow: var(--glow-charge);
  transition: width var(--dur-base) var(--ease-out);
}
.loader-cells {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: repeating-linear-gradient(
    to right, transparent 0 calc(5% - 3px), var(--surface-sunken) calc(5% - 3px) 5%);
  opacity: 0.85;
}
.loader-pct {
  margin: 0;
  font: var(--fw-regular) var(--fs-num) / 1 var(--font-arcade);
  color: var(--text-charge);
  text-shadow: var(--glow-text-charge);
}
.loader-note {
  margin: 0;
  max-width: var(--maxw-prose);
  font: var(--fw-medium) var(--fs-label) / var(--lh-body) var(--font-core);
  color: var(--text-muted);
}

/* ── challenge landing ────────────────────────────────────────────
   The ghost is already replaying behind this. Same protection-gradient
   treatment as attract, for the same reason: do not hide the thing the screen
   exists to show. */

.challenge {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: flex-end;
  gap: var(--space-4);
  text-align: center;
  padding: var(--space-6);
  padding-bottom: var(--space-10);
  background: var(--protect-bottom);
  z-index: 6;
}
.challenge-title {
  margin: 0;
  font: var(--fw-black) clamp(var(--fs-title-sm), 5vw, var(--fs-title-lg)) / var(--lh-tight) var(--font-core);
  letter-spacing: var(--ls-display);
  color: var(--text-hero);
}
.challenge-title b {
  font: var(--fw-regular) clamp(var(--fs-num), 4.5vw, var(--fs-num-lg)) / 1 var(--font-arcade);
  color: var(--text-charge);
  text-shadow: var(--glow-text-charge);
}
.challenge-sub {
  margin: 0;
  font: var(--fw-medium) var(--fs-body-sm) / var(--lh-body) var(--font-core);
  color: var(--text-muted);
}
.micro-alarm { color: var(--text-alarm); }

/* ── result: the rank tease ───────────────────────────────────────
   Shown BEFORE the form, never after (PRD §6). */

.result-rank {
  margin: 0;
  max-width: var(--maxw-prose);
  font: var(--fw-semi) var(--fs-body-sm) / var(--lh-body) var(--font-core);
  color: var(--text-body);
}
.result-rank b {
  font: var(--fw-regular) var(--fs-num) / 1 var(--font-arcade);
  color: var(--text-charge);
  text-shadow: var(--glow-text-charge);
}
.result-actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-3);
  justify-content: center;
  align-items: center;
}

/* ── sign-up ──────────────────────────────────────────────────────── */

.signup, .claimed, .enrol {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: flex-start;
  gap: var(--space-3);
  text-align: center;
  padding: var(--space-5) var(--space-4);
  background: var(--surface-scrim);
  backdrop-filter: var(--blur-scrim);
  overflow-y: auto;
  z-index: 7;
}

.signup-title, .claimed-title, .enrol-title {
  margin: 0;
  font: var(--fw-black) clamp(var(--fs-title-sm), 4vw, var(--fs-title)) / var(--lh-tight) var(--font-core);
  letter-spacing: var(--ls-display);
  color: var(--text-hero);
}

.signup-form, .enrol-form {
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
  width: min(100%, var(--maxw-form));
  align-items: stretch;
}

/* Selected is a green fill with the charge glow — the system's "selected". */
.consent {
  display: flex;
  gap: var(--space-3);
  align-items: flex-start;
  text-align: left;
  font: var(--fw-medium) var(--fs-label) / var(--lh-body) var(--font-core);
  color: var(--text-body);
  cursor: pointer;
}
.consent input {
  appearance: none;
  flex: none;
  width: 26px;
  height: 26px;
  margin-top: 2px;
  border-radius: var(--radius-sm);
  border: var(--border-w-loud) solid var(--border-soft);
  background: var(--surface-inset);
  cursor: pointer;
}
.consent input:checked {
  background: var(--status-charge);
  border-color: transparent;
  box-shadow: var(--glow-charge);
}
.consent input:checked::after {
  content: "✓";
  display: grid;
  place-items: center;
  height: 100%;
  color: var(--text-on-accent);
  font-weight: var(--fw-black);
}

.signup-error {
  margin: 0;
  font: var(--fw-semi) var(--fs-label) / var(--lh-body) var(--font-core);
  color: var(--status-warn);
}

/* Placeholders must LOOK unset. Never style this into invisibility. */
.popia {
  margin: 0;
  text-align: left;
  font: var(--fw-regular) var(--fs-micro) / 1.7 var(--font-core);
  color: var(--text-faint);
}

/* ── claimed: the home board ─────────────────────────────────────── */

.board {
  list-style: none;
  margin: 0;
  padding: 0;
  width: min(100%, var(--maxw-form));
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
}
.board-row {
  display: grid;
  grid-template-columns: 3.5rem 1fr auto;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-2) var(--space-3);
  border-radius: var(--radius-sm);
  background: var(--surface-glass);
  border: var(--border-w) solid transparent;
  text-align: left;
}
.board-row.you {
  border-color: var(--border-charge);
  background: var(--surface-glass-strong);
}
.board-rank {
  font: var(--fw-regular) var(--fs-num-sm) / 1 var(--font-arcade);
  color: var(--text-muted);
}
.board-row.you .board-rank { color: var(--text-charge); }
.board-name {
  font: var(--fw-bold) var(--fs-label) / 1.3 var(--font-core);
  color: var(--text-body);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.board-time {
  font: var(--fw-regular) var(--fs-num-sm) / 1 var(--font-arcade);
  color: var(--text-body);
}

/* Which stall a national time came from. Light and quiet on purpose: the team
   name is what a passer-by reads, and this answers "where do I have to beat
   them?" only once they have. Montserrat, not the arcade face — it is prose,
   and prose is Montserrat always (tokens.md). */
/* Name and stall share one grid cell. min-width:0 on both is what actually lets
   the team name ellipsis instead of blowing the column out. */
.board-name-cell {
  display: flex;
  align-items: baseline;
  gap: var(--space-2);
  min-width: 0;
  overflow: hidden;
}
.board-venue {
  font: var(--fw-medium) var(--fs-micro) / 1.2 var(--font-core);
  color: var(--text-faint);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  min-width: 0;
}
.claimed-note {
  margin: 0;
  max-width: var(--maxw-prose);
  font: var(--fw-medium) var(--fs-label) / var(--lh-body) var(--font-core);
  color: var(--text-muted);
}

/* ── HUD additions ────────────────────────────────────────────────── */

/* The window is frozen during a tag-in. Show that; don't just stop the digits. */
.timer.paused { color: var(--text-faint); }
/* Last ten seconds. Orange means pressure — it is not an error. */
.timer.alarm { color: var(--text-alarm); text-shadow: var(--glow-alarm); }

.team-name {
  margin: 0;
  font: var(--fw-black) clamp(var(--fs-body-lg), 2.4vw, var(--fs-title-sm)) / 1 var(--font-core);
  letter-spacing: var(--ls-display);
  color: var(--text-hero);
}

.hud-bottom { display: flex; flex-direction: column; gap: var(--space-2); align-items: flex-start; }

/* ── output meters — a HUD row over the camera ───────────────────────
   Bottom-right of the frame, opposite the team name. Hidden except during a
   live run: the phase is published on <body> by render(). No panel chrome;
   they sit on the protection gradient like every other bit of type on video
   (tokens.md §4).

   It used to be a column of its own, and the column had to be scrollable
   because a laptop shorter than ~810px could not fit it — a scrollbar hard
   against the right edge of the screen that read as a PAGE scrollbar. Four
   thin bars inside the frame cannot overflow anything, so that whole apparatus
   is gone rather than carried forward. */
.telemetry {
  display: none;
  position: absolute;
  right: var(--hud-safe);
  bottom: var(--hud-safe);
  width: min(42%, 360px);
  z-index: 4;
}
body[data-phase="ready"] .telemetry,
body[data-phase="countdown"] .telemetry,
body[data-phase="running"] .telemetry,
body[data-phase="tagin"] .telemetry { display: block; }

.telemetry .panel-output { background: none; border: 0; box-shadow: none; padding: 0; gap: var(--space-1); }
.telemetry .panel-output > .micro { display: none; }
.regions { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: row; gap: var(--space-2); }
.slot { flex: 1; display: flex; flex-direction: column; gap: 2px; }
.regions .slot .micro { letter-spacing: var(--ls-label); }

/* ── the ladder, on the battery ──────────────────────────────────────
   Five marks inside the well, one per stage, each at the charge level where
   that stage begins. The current stage lights in its own tone; the rest sit
   faint. This replaces the side-panel list: the ladder is the metaphor, and
   as a list it was the smallest thing on screen. Placement reads --at (set
   inline in the markup) along the fill axis — bottom here, left on a phone —
   so the JS remains orientation-blind. */
.ladder { position: absolute; inset: 0; pointer-events: none; }
.ladder span {
  position: absolute;
  left: 0; right: 0;
  bottom: var(--at);
  height: 0;
  display: flex;
  align-items: flex-end;
  justify-content: flex-end;
  padding-right: var(--space-2);
  font: var(--fw-regular) var(--fs-num-sm) / 1 var(--font-arcade);
  letter-spacing: var(--ls-arcade);
  color: var(--text-faint);
  /* Nothing here inherits a glow: only the lit Stage Zero mark earns one, and
     it asks for it explicitly below (tokens.md: one glowing element per screen). */
  text-shadow: none;
  /* Lift the numeral clear of its own hairline so the two do not touch. */
  transform: translateY(calc(-1 * var(--space-1)));
  transition: color var(--dur-fast) var(--ease-out);
}
/* The hairline is the boundary; the numeral sits just above it. */
.ladder span::before {
  content: "";
  position: absolute;
  left: 0; right: 0; bottom: 0;
  height: var(--border-w);
  background: var(--border-soft);
}
/* The numeral is wrapped in a <b> so it can carry a scrim of its own. A LIT
   mark sits exactly on the fill's top edge, and the fill's brightest tone IS
   the stage tone the numeral is painted in — so without something behind it
   the current stage was the one mark you could not read (checked in the
   browser at 10/27/60/80/100% charge, design review 2026-09-02). It also
   straddles that edge, half on the dark well and half on the fill, so no flat
   colour fixes it. --surface-scrim is the token that keeps HUD text legible
   over the live camera feed; it does the same job here over the fill, and over
   the unlit well it is 72% black on near-black and shows nothing. */
.ladder span b {
  font-weight: inherit;
  padding: 0 var(--space-1);
  border-radius: var(--radius-cell);
  background: var(--surface-scrim);
}
/* Stage 4 is the floor and Stage Zero the ceiling, so neither draws a boundary
   — the well's own edge is the boundary — and Stage Zero's numeral has to be
   pushed back DOWN by its own height or it sits above the well and is clipped. */
.ladder span[data-stage="4"]::before { display: none; }
.ladder span[data-stage="0"] { transform: translateY(calc(1em + var(--space-1))); }
.ladder span[data-stage="0"]::before { display: none; }
/* Lit marks read the same stage tokens as the fill (tokens.md §1.6). */
.ladder span[data-stage="4"].on { color: var(--stage-4); }
.ladder span[data-stage="3"].on { color: var(--stage-3); }
.ladder span[data-stage="2"].on { color: var(--stage-2); }
.ladder span[data-stage="1"].on { color: var(--stage-1); }
.ladder span[data-stage="0"].on { color: var(--stage-0); text-shadow: var(--glow-text-charge); }

.r-track {
  height: 8px;
  border-radius: var(--radius-pill);
  background: var(--surface-inset);
  box-shadow: var(--inset-hair);
  overflow: hidden;
}
.r-track i {
  display: block;
  height: 100%;
  width: 0;
  border-radius: var(--radius-pill);
  transition: width var(--dur-fast) linear;
}
/* Region colours: these four and the canvas skeleton read the SAME token. */
.slot[data-region="arms"] .r-track i { background: var(--region-arms); }
.slot[data-region="head"] .r-track i { background: var(--region-head); }
.slot[data-region="torso"] .r-track i { background: var(--region-torso); }
.slot[data-region="legs"] .r-track i { background: var(--region-legs); }

.slot[data-region="arms"].live .micro { color: var(--region-arms); }
.slot[data-region="head"].live .micro { color: var(--region-head); }
.slot[data-region="torso"].live .micro { color: var(--region-torso); }
.slot[data-region="legs"].live .micro { color: var(--region-legs); }

.prize {
  margin: 0;
  font: var(--fw-medium) var(--fs-label) / var(--lh-body) var(--font-core);
  color: var(--text-muted);
}
/* Prize copy is the last thing on the attract screen; nothing follows it. */
.attract .prize { margin-top: var(--space-2); }

/* ── footer ─────────────────────────────────────────────────────────
   Privacy in plain words, not legalese. */

.footer {
  padding: var(--space-3) clamp(var(--space-4), 3vw, var(--space-8)) var(--space-5);
  border-top: var(--border-w) solid var(--border-hair);
}
.footer .micro { text-transform: none; letter-spacing: 0; font-weight: var(--fw-medium); line-height: var(--lh-body); }

/* ── mobile ─────────────────────────────────────────────────────────
   A different SHAPE, not a squeezed desktop. Camera is the hero; the battery
   lies flat above it; controls go behind a drawer. */

@media (max-width: 860px) {
  /* ── the phone is a DIFFERENT SHAPE, not a squeezed desktop ──────────
     The camera is the hero and everything else is a HUD drawn on top of it
     (tokens.md §6). Laid out as a grid — battery above, telemetry below — the
     camera ended up a letterbox in the middle of a phone, which is the one
     device where the framing actually has to work.

     So on mobile the viewport IS the stage, full bleed, and the battery, the
     meters and the top bar float over it. */

  .stage {
    display: block;
    position: relative;
    padding: 0;
    gap: 0;

    /* The bottom of a phone screen is a stack of rails, measured up from the
       thumb: battery, then the region meters, then the run labels. Expressing
       it once means moving one of them cannot silently bury another — which is
       exactly what happened when these were separate hard-coded offsets and the
       team name ended up underneath the battery. */
    --rail-0: calc(var(--gutter-phone) + env(safe-area-inset-bottom, 0px));
    --rail-1: calc(var(--rail-0) + 74px);
    --rail-2: calc(var(--rail-1) + 34px);
  }

  .viewport {
    position: absolute;
    inset: 0;
    border: 0;
    border-radius: 0;
    box-shadow: none;
  }

  /* The top bar becomes the 52px status strip the design asks for: it floats on
     the protection gradient instead of stealing a band of camera. */
  .topbar {
    position: absolute;
    top: 0; left: 0; right: 0;
    z-index: 12;
    background: none;
    backdrop-filter: none;
    border-bottom: 0;
    padding: var(--space-2) var(--space-4);
  }
  .brand-lockup { display: none; }
  .brand-shield { display: block; width: 36px; height: 36px; }

  /* The top bar now floats over the camera, so the clock and combo have to start
     BELOW it — otherwise the timer sits under the shield and the combo under the
     burger. tokens.md §6 sizes that strip at 52px; this clears it. */
  .hud-top { top: 64px; left: var(--gutter-phone); right: var(--gutter-phone); }

  /* Run labels sit on the top rail of the bottom stack, above the meters. */
  .hud-bottom { bottom: var(--rail-2); left: var(--gutter-phone); right: var(--gutter-phone); }

  /* Privacy copy belongs on the screens where somebody is deciding whether to
     start — not over a live run, where it is just a band of text eating camera. */
  .footer { display: none; }
  body[data-phase="idle"] .footer,
  body[data-phase="attract"] .footer,
  body[data-phase="setup"] .footer,
  body[data-phase="signup"] .footer { display: block; position: relative; z-index: 12; }

  /* ── the play HUD ────────────────────────────────────────────────────
     Hidden by default and shown ONLY while a run is live. Every other phase
     puts a full-screen set-piece in the viewport, and a battery floating over
     BLACKOUT or the sign-up form would be noise on top of the moment.

     Only the battery needs hiding here: since the meters became a HUD row
     inside the viewport they are display:none at every size by default and the
     same four phases turn them on, so the phone inherits the gate rather than
     restating it. */
  .battery-col { display: none; }

  body[data-phase="ready"] .battery-col,
  body[data-phase="countdown"] .battery-col,
  body[data-phase="running"] .battery-col,
  body[data-phase="tagin"] .battery-col {
    display: flex;
    position: absolute;
    left: var(--gutter-phone);
    right: var(--gutter-phone);
    bottom: var(--rail-0);
    z-index: 10;
    flex-direction: column;
    align-items: stretch;
    gap: var(--space-2);
  }

  /* Region meters sit just above the battery: four thin bars, no panel chrome.
     They answer "which limb is paying" at a glance, which is the one piece of
     telemetry a player actually uses mid-run — and since the design review it
     is the only telemetry left anywhere. Net charge and session stats were
     hidden here first and are now deleted outright; prize copy moved to the
     attract screen; the ladder rides the battery, so the phone gets it too
     rather than losing it to the stage numeral. */
  body[data-phase="ready"] .telemetry,
  body[data-phase="countdown"] .telemetry,
  body[data-phase="running"] .telemetry,
  body[data-phase="tagin"] .telemetry {
    left: var(--gutter-phone);
    right: var(--gutter-phone);
    bottom: var(--rail-1);
    width: auto;
  }
  .regions .slot .micro { font-size: 9px; letter-spacing: var(--ls-label); }
  .r-track { height: 5px; }

  /* Battery lies flat. Same --fill, now driving WIDTH. */
  .battery {
    flex: none;
    flex-direction: row;
    max-width: none;
    height: 30px;
    /* stretch, not the inherited center: upright, the battery is a column and
       `align-items: center` only centres its children horizontally, so the well
       still filled the height via `flex: 1`. Flat, the axes swap — center became
       a VERTICAL alignment and the well, a flex child with no height of its own,
       collapsed to its content, which is nothing. The result was a 2px hairline
       where the phone's battery should be: no fill, no cells, no ladder marks.
       The cap opts back out with its own align-self, because it is deliberately
       shorter than the well. */
    align-items: stretch;
  }
  .battery-cap { width: 10px; height: 46%; border-radius: 0 var(--radius-cell) var(--radius-cell) 0; border: var(--border-w) solid var(--border-hair); border-left: 0; align-self: center; }
  .battery-fill {
    top: 0;
    right: auto;
    width: var(--fill, 0%);
    height: 100%;
    background: linear-gradient(to right, var(--fill-bot, #8f1f1f), var(--fill-top, #f26a6a));
    transition: width var(--dur-fast) linear, background var(--dur-base) var(--ease-out);
  }
  .battery-cells {
    background: repeating-linear-gradient(
      to right,
      transparent 0 calc(5% - 3px),
      var(--surface-sunken) calc(5% - 3px) 5%
    );
  }
  /* Same marks, other axis. */
  .ladder span {
    left: var(--at); right: auto; top: 0; bottom: 0; height: auto; width: 0;
    align-items: center; justify-content: flex-start;
    padding: 0 0 0 var(--space-1);
    font-size: 9px;
    transform: none;
  }
  .ladder span::before { left: 0; right: auto; top: 0; bottom: 0; width: var(--border-w); height: auto; }
  /* Anchor Stage Zero's label to the right-hand end instead of translating it
     by a guessed width: "Zero" is four glyphs of Press Start 2P, far wider
     than the 1em a transform could assume, and it would hang off the cap. */
  .ladder span[data-stage="0"] {
    justify-content: flex-end;
    padding: 0 var(--space-1) 0 0;
    transform: none;
  }
  /* Stage and "x% to go" read as one line under the rail, not a stacked block. */
  .battery-col > .micro { display: none; }
  .stage-text {
    flex-direction: row;
    align-items: baseline;
    justify-content: flex-start;
    gap: var(--space-2);
  }
  .stage-text b { font-size: var(--fs-num-sm); }
  .stage-text small { font-size: 9px; }
  .to-go { font-size: var(--fs-micro); }
  .battery-readout {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: var(--space-3);
  }

  /* 44px touch targets. */
  .btn { height: var(--control-lg); }

  /* Attract and setup are FULL SCREEN steps on a phone, not panels inside the
     camera frame. Without this the "Lock it in" button sits below the fold of
     the viewport panel — measured: goButtonVisible false at 390x844. */
  /* `idle` is in here because attract IS the cold-start screen — the phase is
     still idle until a camera exists, and leaving it out meant a phone landing
     on / got the cramped panel version of the very screen meant to sell it. */
  body[data-phase="idle"] .battery-col,
  body[data-phase="attract"] .battery-col,
  body[data-phase="setup"] .battery-col,
  body[data-phase="challenge"] .battery-col,
  body[data-phase="signup"] .battery-col,
  body[data-phase="claimed"] .battery-col { display: none; }

  body[data-phase="idle"] .stage,
  body[data-phase="attract"] .stage,
  body[data-phase="setup"] .stage,
  body[data-phase="challenge"] .stage,
  body[data-phase="signup"] .stage,
  body[data-phase="claimed"] .stage { grid-template-rows: minmax(0, 1fr); }

  /* The lockup is already in the topbar on a phone; a second one here just eats
     the room the charger buttons need. */
  .attract-lockup { display: none; }

  /* Keep 1-5 on ONE row. At 390px the viewport gutter plus the setup padding
     leaves 302px, and five 56px targets with 8px gaps need 312 — so the "5"
     wrapped onto a line of its own. Trim the padding and the gap rather than the
     targets: --control-lg is the kid-thumb minimum and does not get smaller. */
  .setup { padding: var(--space-5) var(--space-4); }
  .charger-count { gap: var(--space-1); flex-wrap: nowrap; }

  .burger {
    display: grid;
    place-content: center;
    gap: 5px;
    width: var(--control-md);
    height: var(--control-md);
    border-radius: var(--radius-pill);
    border: var(--border-w) solid var(--border-hair);
    background: var(--surface-glass);
    cursor: pointer;
  }
  .burger span { display: block; width: 18px; height: 2px; background: var(--text-body); border-radius: var(--radius-pill); }

  #freeLabel { display: none; }

  /* Viewport-sized, so it can never itself overflow — and it CLIPS the drawer
     parked beyond the right edge.
     Clipping the root instead does not work: the drawer is position:fixed, so
     its containing block is the viewport, not the root box, and it escapes
     `html { overflow-x: clip }` entirely. Measured, not assumed — the page
     still scrolled 320px sideways with clip applied and computing on <html>. */
  .drawer-shell {
    display: block;
    position: fixed;
    inset: 0;
    overflow: hidden;
    pointer-events: none;   /* the shell itself must not eat taps */
    z-index: 40;
  }

  .controls {
    position: absolute;
    top: 0; right: 0; bottom: 0;
    width: min(84vw, 320px);
    pointer-events: auto;
    flex-direction: column;
    align-items: stretch;
    justify-content: flex-start;
    gap: var(--space-4);
    padding: var(--space-6) var(--space-5);
    background: var(--surface-raised);
    border-left: var(--border-w) solid var(--border-hair);
    box-shadow: var(--shadow-sheet);
    transform: translateX(100%);
    transition: transform var(--dur-base) var(--ease-out);
  }
  .menu-open .controls { transform: translateX(0); }

  .scrim {
    display: block;
    position: fixed;
    inset: 0;
    background: var(--surface-scrim);
    z-index: 30;
  }
  .endorse { align-self: flex-start; }
  /* The phone's attract already shows the shield in the top bar and has no
     vertical room to spare; the result and sign-up rows stay. */
  .attract .brand-row, .attract .parent-line { display: none; }
}

/* ── TV scale ─────────────────────────────────────────────────────────
   A mall stall is a 1920×1080 television read from three metres, and until
   now it got the laptop layout with laptop type (design review 2026-09-02).
   tokens.md §2 sets the stall minimum at 24px; this block re-picks LARGER
   EXISTING tokens per element — nothing here is a new value. It is a media
   query rather than the venue-key flag so an un-enrolled laptop on a
   projector at the pitch gets the same treatment. min-height keeps a wide
   laptop with a short screen out of it. */
@media (min-width: 1600px) and (min-height: 860px) {
  .topbar { padding: var(--space-4) var(--gutter-stall); }
  .brand-lockup { height: var(--space-12); }
  .micro { font-size: var(--fs-body-sm); }
  .btn { height: var(--control-lg); font-size: var(--fs-body); padding: 0 var(--space-6); }
  .btn-lg { height: var(--control-xl); font-size: var(--fs-title-sm); padding: 0 var(--space-12); }

  .stage {
    grid-template-columns: minmax(200px, 260px) minmax(0, 1fr);
    gap: var(--space-8);
    padding: var(--space-8) var(--gutter-stall);
  }
  .battery { max-width: 220px; }
  .battery-cap { height: 14px; }
  .ladder span { font-size: var(--fs-num); padding-right: var(--space-3); }
  .stage-text b { font-size: var(--fs-num-hero); }
  .stage-text.zero b { font-size: var(--fs-num-lg); }
  .to-go { font-size: var(--fs-title-sm); }

  .viewport { --hud-safe: var(--space-10); }
  .timer { font-size: var(--fs-num-hero); }
  .combo { font-size: var(--fs-num-lg); }
  .team-name { font-size: var(--fs-title); }
  .telemetry { width: min(40%, 520px); }
  .regions .slot .micro { font-size: var(--fs-label); }
  .r-track { height: 10px; }

  .countdown { font-size: var(--fs-num-mega); }
  .stage-flash b { font-size: var(--fs-display-lg); }
  .stage-flash small { font-size: var(--fs-label); }
  .state-title { font-size: var(--fs-title-lg); }
  .state-sub { font-size: var(--fs-body-lg); }

  /* Sized so a five-row stall board, its note and the prize line all fit a
     1080p frame under the lockup and title with nothing to scroll (measured
     ~815px of ~856px). Loosen any of these and the bottom of the board goes
     under the frame edge on the TV. */
  .attract { gap: var(--space-4); padding-bottom: var(--space-10); }
  .attract-lockup { width: min(28%, 420px); }
  .attract-title { font-size: var(--fs-display-lg); }
  .attract-sub { font-size: var(--fs-title-sm); }
  .board-row { grid-template-columns: 4.5rem 1fr auto; }
  .board-rank, .board-time { font-size: var(--fs-num); }
  .board-name { font-size: var(--fs-body-lg); }
  .board-venue { font-size: var(--fs-label); }
  .prize { font-size: var(--fs-body-lg); }

  /* These three carry a qualifier the rest of the block does not need, and the
     qualifier is the whole point. Their base rules live in the Phase 5 section
     FURTHER DOWN this file, and a later rule of equal specificity wins no
     matter which media query the earlier one sits in — so `.attract-board` and
     `.board-empty` written plainly here would simply lose, and
     `.attract-board .board-row` is a two-class selector to start with. One
     extra class settles all three. The block stays where it is, above reduced
     motion, because that is where the "TV scale" heading gets looked for;
     reordering it below Phase 5 to win on source order would hide it. Neither
     qualifier narrows the reach: `.board-empty` is only ever a child of
     `ol.board`, and `.attract-board` only ever a child of `.attract`. */
  .board .board-empty { font-size: var(--fs-body); }
  /* --maxw-prose is 56ch and ch follows the element's own font size: at the
     body's 15px that is ~480px, and with a 4.5rem rank and an arcade time in
     the row "The Ndlovus" truncated. Setting the board's size to --fs-body-lg
     makes the same token resolve ~640px. Still one token, no new value. */
  .attract .attract-board { font-size: var(--fs-body-lg); width: min(100%, var(--maxw-prose)); }
  /* .board itself is capped at --maxw-form (420px) for the phone; on the TV it
     fills the wider board container above, or the names truncate again. */
  .attract .attract-board .board { width: 100%; }
  .attract .board-row { padding: var(--space-2) var(--space-4); }

  .setup-title, .signup-title, .claimed-title, .enrol-title { font-size: var(--fs-title-lg); }
  .charger-count button { width: var(--control-xl); height: var(--control-xl); font-size: var(--fs-num-lg); }
  .field-wide input { height: var(--control-xl); font-size: var(--fs-body-lg); }
  .field-hint, .setup-legs, .consent, .signup-error { font-size: var(--fs-body-lg); }
  .claimed-note, .result-meta, .result-rank { font-size: var(--fs-title-sm); }
  .popia { font-size: var(--fs-label); }

  .tagin-title { font-size: var(--fs-display); }
  .tagin-count { font-size: var(--fs-num-mega); }
  .tagin-sub { font-size: var(--fs-body-lg); }

  .blackout-title { font-size: var(--fs-display-xl); }
  .blackout-line { font-size: var(--fs-title-sm); }

  .result-time { font-size: var(--fs-num-mega); }
  .result.win .result-time { font-size: var(--fs-num-mega); }
  .parent-line { font-size: var(--fs-title-sm); }
  .endorse { height: 36px; }
  .endorse-lg { height: var(--control-lg); }
  .result-delta { font-size: var(--fs-body-lg); }

  .loader-battery { height: var(--control-xl); }
  .loader-pct { font-size: var(--fs-num-lg); }
  .loader-note { font-size: var(--fs-body-lg); }

  .challenge-title { font-size: var(--fs-display); }
  .challenge-sub { font-size: var(--fs-body-lg); }

  .footer { padding: var(--space-4) var(--gutter-stall) var(--space-6); }
  .footer .micro { font-size: var(--fs-body-sm); }
}

/* ── reduced motion ─────────────────────────────────────────────────
   Kill the drift, collapse the set-pieces. Everything stays legible. */

@media (prefers-reduced-motion: reduce) {
  .bg i { animation: none; }
  .countdown, .stage-flash, .result, .result.win { animation: none; }
  .viewport.hit::after { animation: none; }
  * { transition-duration: 1ms !important; }
}

/* ── Phase 5: the stall's board and the promoter's panel ─────────────
   Both are operator-facing surfaces. The attract board is read from six
   metres, the promoter panel from thirty centimetres, and they are sized
   accordingly — the panel is the one place in this product where density
   beats drama. */

.board-empty {
  padding: var(--space-3);
  color: var(--text-faint);
  font: var(--fw-medium) var(--fs-label) / 1.3 var(--font-core);
  text-align: center;
}

/* A run that is on this device but has not reached the server yet. It shows,
   because the family who set it is standing right there — but it is honest
   about being provisional rather than pretending to be a board position. */
.board-row.unsent {
  border-style: dashed;
  border-color: var(--border-soft);
  background: transparent;
}
.board-row.unsent .board-rank,
.board-row.unsent .board-time { color: var(--text-muted); }
.board-tag {
  grid-column: 3;
  justify-self: end;
  font: var(--fw-bold) var(--fs-micro) / 1 var(--font-core);
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--text-alarm);
}

/* The cross-fade between rotating views. Short and opacity-only: this sits
   behind the primary CTA, and anything that moves would compete with it. */
@keyframes board-swap {
  from { opacity: 0; transform: translateY(4px); }
  to   { opacity: 1; transform: none; }
}
.attract-board {
  animation: board-swap var(--dur-base) var(--ease-out);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-2);
  margin-top: var(--space-6);
  width: min(100%, var(--maxw-form));
}
.attract-board .board-row { padding: var(--space-1) var(--space-3); }

/* ── promoter panel ──────────────────────────────────────────────────
   Above everything: it is opened while a run is on screen, and the point of
   it is to act on what that screen is showing. 60 clears the mobile drawer
   (40) and its scrim (30). */
.promoter {
  position: fixed;
  inset: 0;
  z-index: 60;
  display: grid;
  place-items: center;
  padding: var(--space-4);
  background: var(--surface-scrim);
}
.promoter-card {
  width: min(100%, 560px);
  max-height: 86vh;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
  padding: var(--space-5);
  border-radius: var(--radius-lg);
  border: var(--border-w) solid var(--border-soft);
  /* OPAQUE, unlike every other panel in the game. --surface-inset is
     translucent and the attract screen behind it — a lockup and a 46px
     headline — reads straight through the run rows. Everywhere else that
     glassiness is atmosphere; here it is an operator misreading which run
     they are about to hide. */
  background: var(--surface-sunken);
  box-shadow: var(--shadow-panel);
}
.promoter-head {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  flex-wrap: wrap;
}
.promoter-head .micro:first-child { flex: 1; }
/* A key the server does not recognise. Red, because this is the bad STATE the
   promoter has to see from across the stall — the same reasoning as Stage 4
   (docs/design/tokens.md §7.2). */
.promoter-head .bad { color: var(--red-400); }
.promoter-msg {
  margin: 0;
  padding: var(--space-2) var(--space-3);
  border-radius: var(--radius-sm);
  background: var(--surface-glass);
  color: var(--text-body);
  font: var(--fw-medium) var(--fs-label) / 1.4 var(--font-core);
}
.promoter-runs {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
}
.promoter-run {
  display: grid;
  /* The score is Press Start 2P, which is far wider per character than a
     4rem column allows — at 14px "31.0s" ran straight through the state text
     next to it. min-content sizes to the glyphs instead of to a guess, and
     minmax(0,...) on the flexible columns is what actually lets the name and
     the state truncate rather than push. */
  grid-template-columns: 3.2rem minmax(0, 1fr) min-content minmax(0, 1fr) auto;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-2) var(--space-3);
  border-radius: var(--radius-sm);
  border: var(--border-w) solid var(--border-hair);
  background: var(--surface-glass);
}
.promoter-run-name {
  font: var(--fw-bold) var(--fs-label) / 1.2 var(--font-core);
  color: var(--text-body);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.promoter-run-score {
  font: var(--fw-regular) var(--fs-num-sm) / 1 var(--font-arcade);
  color: var(--text-body);
}
.promoter-run-state {
  text-align: right;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.promoter-run-score { white-space: nowrap; }
.promoter-actions { display: flex; gap: var(--space-2); flex-wrap: wrap; }
.promoter-field { align-self: flex-start; }
.promoter-keys { color: var(--text-faint); text-transform: none; letter-spacing: 0; }
.btn-sm { padding: var(--space-1) var(--space-3); font-size: var(--fs-micro); }

@media (max-width: 720px) {
  /* A promoter on a phone-sized screen is a fallback, not the design target —
     the layout just has to stay usable rather than stay pretty. */
  .promoter-run { grid-template-columns: 1fr auto; }
  .promoter-run .micro:first-child { display: none; }
  .promoter-run-state { grid-column: 1 / -1; text-align: left; }
}

/* ── stall enrolment ─────────────────────────────────────────────────
   A setup surface, not a game screen: it is reached only by opening ?enrol,
   and it borrows the sign-up overlay's shell rather than inventing a second
   one. The key field is monospace because a 43-character base64url key is
   read back character by character when it does not work. */

.enrol-current {
  margin: 0;
  max-width: var(--maxw-form);
  color: var(--text-muted);
  text-transform: none;
  letter-spacing: 0;
}
/* A key is read back character by character when it does not work, so it gets
   the arcade face (which is monospaced) and is left-aligned rather than
   centred — a long string sliding out of a centred box is unreadable. */
#enrolKey {
  font-family: var(--font-arcade);
  font-size: var(--fs-micro);
  letter-spacing: 0.02em;
  text-align: left;
}
#enrolKey::placeholder { font-family: var(--font-core); }
.enrol-note {
  margin: 0;
  max-width: var(--maxw-form);
  text-align: left;
  color: var(--text-faint);
  text-transform: none;
  letter-spacing: 0;
  line-height: 1.7;
}

/* Auto-rotating content is what this setting exists to stop, and nobody can
   pause a mall TV. app.js also stops the rotation itself — this is the belt to
   that braces. */
@media (prefers-reduced-motion: reduce) {
  .attract-board { animation: none; }
}

/* A stall is a landscape TV; an enrolled phone is an edge case, not a target.
   Drop the venue tag rather than let it squeeze the team name into an ellipsis. */
@media (max-width: 560px) {
  .board-venue { display: none; }
}

/* The handoff, once the incoming player has earned it. The countdown keeps
   running — this only says "we saw you", which without it leaves someone waving
   at a number that gives no sign it noticed. Charge green, the system's
   "confirmed". */
.tagin.ready .tagin-count {
  color: var(--text-charge);
  text-shadow: var(--glow-text-charge);
}
.tagin.ready .tagin-title { color: var(--text-charge); }
