/* Display — big shared screen. "Toy Box" look (the six roles: theme.css's header).
   Tokens + reusable bits live in /shared/theme.css; this file is layout +
   display-only composition. Class/ID names are contracted with display/main.js,
   Stage.js and the render modules beside them — don't rename without updating
   those. */

html, body {
  margin: 0; height: 100%;
  background: var(--paper);
  color: var(--ink);
  overflow: hidden;
}

/* THE SAFE ZONE, in the units this file lays out in. A television may crop the
   edges of what it is handed and cannot be asked how much, so every edge-anchored
   thing on this page measures from here rather than from the viewport — see the
   token's own note in theme.css for where the figure comes from.

   `env(safe-area-inset-*)` is NOT this and never was: it reports a notch or a
   home indicator, it is zero in every TV browser and inside the AirConsole frame,
   and it stays in the sum only because a phone-shaped display window is still a
   thing this page can end up in. Overscan is the term it cannot see.

   Per-cell HUD chrome does NOT come through here: those rects arrive already
   inset from ttp_display_cell_rects, because only C++ knows which cell edges are
   screen edges (a stacked pair's divider is not one). */
html {
  --safe-x: calc(var(--safe-frac-x) * 100vw + env(safe-area-inset-left));
  --safe-y: calc(var(--safe-frac-y) * 100vh + env(safe-area-inset-top));
  /* The same margin measured from the BOTTOM, which is a separate value only
     because of the env() term: a notch is on one edge and a home indicator on
     the other, so a bottom-anchored thing that reached for `--safe-y` would
     clear the wrong one. Both env() terms are zero on every television; this is
     for the phone-shaped window the page can still end up in. */
  --safe-y-b: calc(var(--safe-frac-y) * 100vh + env(safe-area-inset-bottom));
}

/* The corner button's SIZE, declared once: the row is FIXED over every screen,
   so chrome that must not sit under it (the lobby's right rail) has to derive
   its own clearance from this — see .race-rail.

   There is no offset token beside it because THE ROW SITS ON THE SAFE EDGE and
   `#corner-btns` reads `--safe-x/y` directly. Every edge-anchored thing on this
   page lines up on that one rectangle, so the buttons, the lobby's rails and the
   sound hint share a margin instead of each carrying its own guess at one. It
   also fixes the order they used to sit in: the row carried a 1.1rem inset of
   its own while the lobby carried a bigger authored gutter, which put the
   buttons OUTSIDE the content they float over. */
html {
  --corner-btn: 3.4rem;
}

/* ---- Lobby ---- */
/* Sticker chrome FRAMING the board (paper stage OR live 3D preview behind —
   the layout is identical in both states): two rails that run the full height
   of the board, and the seat dock hanging from the bottom of the middle.
   Everything hangs off ONE bottom line. The hand-slapped tilts stay playful
   because the grid beneath is straight.

   THE DOCK IS BESIDE THE RAILS, NOT BELOW THEM, and that is what keeps the
   cars on screen. It used to be a footer row under a `1fr auto` grid, which
   made the board's height the sum of the tallest rail AND the dock — and the
   race rail does not shrink: ~280px of it (the corner-button clearance and the
   cup shelf's five text rows) is authored in rem, so it costs the same on a
   720p board as on a 1080p one. The sum therefore only fitted at 1080, and on
   every shorter board — 16:9 ones included — the dock, last in the column, was
   what went over the edge. The middle column is the free space the rails leave,
   the dock is narrower than it at every size the wide layout is used at, and
   in its own column it cannot push anything anywhere.

   Both rails are capped by VIEWPORT HEIGHT as well as width (the `min()` in
   the columns): a rail's cards are sized by their column, so on a squat board
   an unclamped column makes a card taller than the band it has to fit in. */
#lobby {
  position: relative;
  z-index: 1;                 /* float the lobby over the #scene backdrop */
  height: 100%;
  display: grid;
  grid-template-columns:
    min(clamp(240px, 26vw, 440px), 38vh)
    minmax(0, 1fr)
    min(clamp(220px, 24vw, 460px), 32vh);
  /* ONE ROW, full height. The middle column carries only the dock, hung from
     its bottom edge; the rest of it is left open for #scene, which is behind
     the whole grid rather than an item in it.

     `minmax(0, 1fr)` and not `1fr`: a bare 1fr floors at the dock's min-content,
     so a wide roster would push the middle column past the space the rails left
     and take the whole board with it. The dock overflows its own column instead,
     centred, into the gutters it shares with nothing. */
  grid-template-areas: "ticket dock race";
  /* The row is CLAMPED to the board, not floored at its tallest item: on a
     board squat enough that even a full-height race rail does not fit, an auto
     floor would grow the row and carry the dock off the bottom with it — the
     exact failure this layout exists to remove. Clamped, the dock keeps the
     bottom line and the cup shelf is what runs past it instead, which is the
     right thing to lose: it is the long game, and the cars are the room. */
  grid-template-rows: minmax(0, 1fr);
  column-gap: clamp(16px, 2vw, 36px);
  /* THE SAFE ZONE IS THE GUTTER. The rails used to add an authored gutter on
     top of it, which framed the centre well on a set that crops nothing and
     read as two different margins beside the corner buttons. One rectangle
     now: the rails, the button row and the hint all measure from it. The
     middle stays open for the live track preview either way — what sets that
     is the column widths above, not this padding. */
  padding: var(--safe-y) var(--safe-x) var(--safe-y-b);
}

/* Left rail — the join ticket with the scan hint hanging under it. The rail
   spans the band and CENTRES the pair rather than stretching the ticket: the
   QR is what sets the card's height, and a card grown past its content is just
   a field of white with a small QR marooned in it. */
.ticket-rail {
  grid-area: ticket; align-self: stretch;
  display: flex; flex-direction: column; justify-content: center; align-items: center;
  gap: 14px;
}
/* Join ticket — the wordmark masthead over QR + URL, one sticker card.
   A <button>: the whole ticket copies the join link. */
.ticket {
  width: 100%;                        /* the QR is the lobby's MAIN element */
  display: flex; flex-direction: column; align-items: center; gap: 0.5rem;
  padding: 0.85rem 0.85rem 1rem;
  font: inherit; color: var(--ink); text-align: center;
  background: var(--surface);
  border: 0; border-radius: var(--r-lg);
  box-shadow: var(--shadow-card);
  cursor: pointer;
  transition: box-shadow 0.08s ease, transform 0.08s ease;
}
.ticket:active { transform: scale(0.99); box-shadow: var(--shadow-pop); }
.ticket:focus-visible { outline: var(--focus-ring); outline-offset: 3px; }
/* the in-card wordmark: the kit badge's white die-cut edge would vanish on the
   white card, so here it's the plain ink/red lockup — same face, same tilt */
.ticket__wordmark {
  font-size: clamp(25px, 2.5vw, 38px);   /* .l2 scales along at 1.24em */
  -webkit-text-stroke-width: 0;
  filter: none;
  margin: 0.1rem 0 0.15rem;
}
/* Reserve a square box up front (aspect-ratio, not the canvas' intrinsic size)
   so the ticket holds its final footprint before the async QR fetch resolves
   (a blank <canvas> is 300×150 — without this the card would pop taller). */
#qr { display: block; width: 100%; aspect-ratio: 1 / 1; border-radius: 6px; image-rendering: pixelated; }
/* Until the room opens that reserved box is a blank white square (the card
   surface); the QR and URL fade in when they render (Net.js stamps .is-in),
   rather than popping in over a lobby that's already up. */
#qr, #joinurl { opacity: 0; transition: opacity 0.4s ease; }
#qr.is-in, #joinurl.is-in { opacity: 1; }
/* The URL line reads "Loading…" until then — overlapped with the real URL in
   one grid cell so the swap is a crossfade, not a reflow. */
.ticket__urlbox {
  display: grid; max-width: 100%;
  /* the URL scale lives HERE (both lines inherit it) so the em reserve below
     tracks it. Smaller than the code under it: the host is the boring half of
     the URL, and a preview host (up to ~76 chars) needs to wrap in few lines. */
  font-size: clamp(13px, 1.15vw, 17px);
  /* reserve the final host-line + code-line footprint up front: the card must
     not grow (and recentre) when the room opens — only its content fades */
  min-height: 3.4em;
  align-items: center;
}
.ticket__urlbox > * { grid-area: 1 / 1; }
.ticket__wait { color: var(--ink-2); transition: opacity 0.4s ease; }
.ticket__urlbox:has(#joinurl.is-in) .ticket__wait { opacity: 0; }
/* The URL must stay readable in full — it's the type-it-in fallback for anyone
   who can't scan. So it WRAPS rather than truncating: an ellipsis eats the tail,
   and the tail is the room code (preview hosts like
   tinytrack-<branch>.paperballoon.games are long enough to lose it entirely).
   `anywhere` because a host has no spaces to break at. */
.ticket__url {
  max-width: 100%;
  font-family: var(--font-display); font-weight: 600;
  /* font-size inherits from .ticket__urlbox, where the em height reserve needs it */
  line-height: 1.25;
  color: var(--ink);
  overflow-wrap: anywhere;
}
/* the room code — its own line under the host, in the accent colour, and the
   biggest text on the ticket: it's the only part that changes per party and the
   only part worth reading from the sofa. Always broken out (not just when the
   host is long) so it lands in the same place on every screen. */
.ticket__cd {
  display: block;
  font-size: 1.45em;
  color: var(--blue); font-weight: 700; letter-spacing: 0.08em;
}

/* Race rail — tonight's race over the long game. It spans the band; the pick
   hangs from the top, the shelf from the bottom. */
.race-rail {
  grid-area: race; align-self: stretch;
  display: flex; flex-direction: column;
  gap: clamp(10px, 1.4vh, 18px);
  /* clear #corner-btns, which is FIXED over every screen — without this the
     pick card would sit under the mute/fullscreen row. The row and this rail
     both start at the safe edge, so the clearance is the button plus air. */
  padding-top: calc(var(--corner-btn) + 0.5rem);
}

/* Cup slot — empty pre-pick, the race card post-pick (renderCupSlot fills it).
   AUTO MARGINS BOTH SIDES: the pick floats in the middle of the rail's free
   space and the shelf keeps the bottom line. Top-aligned, the card clung under
   the corner buttons with the whole surplus dropped in one hole between it and
   the shelf; split in two, the same surplus reads as air.

   The margin below is also what pins the SHELF to the rail's bottom, and that
   is the half that must not become `justify-content`: the slot is empty
   (zero-height) until the host picks, and a `space-between` rail would park the
   lone shelf at the TOP and then drop it when the pick arrived. This way the
   shelf never moves. */
.cup-slot {
  margin: auto 0;
  display: flex; justify-content: center;
}
/* the pick sits on its own white sticker card (mirroring the join ticket's
   surface, leaning the other way) so it doesn't dangle in the air */
.cup-slot__pick {
  width: 100%;
  display: flex; flex-direction: column; align-items: center; gap: 0.65rem;
  padding: 0.9rem 0.8rem 1rem;
  background: var(--surface);
  border-radius: var(--r-lg);
  box-shadow: var(--shadow-card);
}
/* The cup's name: a LABEL in the cup's own colour (--c, lobbySeats.js; the
   neutral grey for a run that belongs to no cup), because nothing here is
   pressed. The tint is the colour, the words stay ink: the label recipe's
   darkened text turns muddy on a pale cup colour (Beach's sand). */
.cup-sticker {
  font-family: var(--font-display); font-weight: 700;
  font-size: clamp(1.2rem, 1.8vw, 1.7rem); line-height: 1.05;
  color: var(--ink); text-align: center;
  background: color-mix(in srgb, var(--c, var(--ink-2)) 34%, #fff); border-radius: var(--r-pill);
  padding: 0.35em 0.85em 0.4em;
}
/* the picked circuits as mini schematics — a 2×2 grid for a cup's four races
   (numbered), one full-width map for an exact pick. The random family spoils
   nothing: a counted card is raceCount grey "?" boxes, endless a single grey
   ∞ box, and the World Tour one cup-tinted "?" per cup on a 3-wide grid (the
   ladder itself). The --five/--many variants are BOX-COUNT layouts, not mode
   hooks — a bigger cup roster degrades to the denser grid by itself. */
.cup-maps { width: 100%; display: grid; grid-template-columns: repeat(2, 1fr); gap: 7px; }
.cup-maps--one { grid-template-columns: 1fr; }
.cup-maps--one .track-map { max-width: 72%; margin: 0 auto; }
.cup-maps--one .cup-maps__tile--q { width: 72%; margin: 0 auto; }
.cup-maps--five { grid-template-columns: repeat(3, 1fr); }
/* the long random card: 8 "?" boxes shrink to a 4-wide strip of pips */
.cup-maps--many { grid-template-columns: repeat(4, 1fr); }
.cup-maps__tile { position: relative; }
.cup-maps__tile .track-map { border-radius: 14px; }
/* an undrawn race: same footprint as a mini under a big "?". The wash is set
   inline — its cup's colour, or the picker's neutral grey when even the cup
   is unknown (lobbySeats.js, single-sourced from trackPicker's palette). */
.cup-maps__tile--q {
  aspect-ratio: 1 / 1;
  display: grid; place-items: center;
  border-radius: 14px;
  container-type: inline-size;
  font-family: var(--font-display); font-weight: 700;
  /* fallback size for engines without container queries; the span rule below
     is the real one. line-height 1 keeps ∞ (whose glyph box rides high)
     truly centred */
  font-size: clamp(1.5rem, 5vh, 3.2rem); line-height: 1; color: var(--ink);
}
/* the glyph tracks its own BOX, not the card: a third of the tile whether
   that tile is the endless card's lone big square or one of the long card's
   eight small ones */
.cup-maps__glyph { font-size: 34cqw; }
/* the tour's teaser for a locked cup: the "?" box anatomy on sunken paper,
   a padlock where the glyph would be — a rung on the ladder, not a race */
.cup-maps__tile--locked {
  aspect-ratio: 1 / 1;
  display: grid; place-items: center;
  border-radius: 14px;
  background: var(--surface-2);
}
.cup-maps__tile--locked .lock-glyph { width: 34%; height: auto; }
/* the running-order badge: at 1920 wide a 20px digit in a 30px ink disc,
   hung 8px off the tile's corner */
.cup-maps__n {
  position: absolute; top: -0.4em; left: -0.4em;
  width: 1.5em; height: 1.5em; text-align: center;
  font-family: var(--font-display); font-weight: 600;
  font-size: clamp(14px, 1.05vw, 20px); line-height: 1.5em;
  color: #fff; background: var(--ink); border-radius: 50%;
}
.cup-races {
  font-family: var(--font-display); font-weight: 600; font-size: 0.78rem;
  letter-spacing: 0.1em; text-transform: uppercase;
  color: var(--ink-2);
  background: var(--surface-2); border-radius: var(--r-pill);
  padding: 0.3em 0.95em;
}

/* Scan hint — a quiet white sticker of its own under the ticket, as on both TV
   shells. In flow, so it can never land on the seat dock: the dock has its own
   column. Hidden until there's a code to scan (the QR's .is-in), then it fades
   in; its box is held from the start, so the ticket never re-centres. It
   wraps at the ticket's width where the TV shells overhang on one line: a
   browser window can be far squatter than a TV, and one line would overflow
   the rail there. */
.tagline {
  max-width: 100%;
  text-align: center;
  font-family: var(--font-display); font-weight: 600;
  font-size: clamp(14px, 1.15vw, 22px); line-height: 1.15;
  color: #fff; background: var(--glass);
  -webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px);
  border-radius: var(--r);
  padding: 0.6em 1.1em;
  opacity: 0; transition: opacity 0.4s ease;
}
.ticket-rail:has(#qr.is-in) .tagline { opacity: 1; }

/* "Cups" — the couch's star shelf, at the bottom of the right rail under the
   "Up next" card. ONE sticker card of
   short-name rows (renderCupShelf strips the " Cup" suffix) rather than a
   stack of separate pills: five pills with five outlines, five drops and a gap
   between each read as five things scattered down the corner, and the shelf is
   one thing. The locked cup trails its unlock count in stars, and the label
   tab carries the couch's total. */
/* Ships .hidden (index.html) because the shelf is a CARD now, and an unfilled
   one paints a blank white slab for the whole first paint. renderCupShelf owns
   the class from there. */
.cup-shelf {
  position: relative;
  background: var(--surface);
  border-radius: var(--r-lg);
  box-shadow: var(--shadow-card);
  padding: 0.55rem 0.9rem 0.45rem;
}
/* the label rides the card's top-left corner like a sticker tab — a full
   label row is height the rail can't spare above the 720p seat band */
.cup-shelf__label { position: absolute; top: -0.75rem; left: 0.7rem; z-index: 1; font-size: 0.8rem; }
/* rows are separated by a hairline, not by air: inside one card the divider is
   what makes them a list */
.cup-shelf__row {
  display: flex; align-items: center; gap: 0.45rem; min-width: 0;
  border-bottom: 2px solid var(--hairline);
  padding: 0.34rem 0.15rem;
  font-family: var(--font-display); font-weight: 700;
  font-size: clamp(14px, 1.5vw, 21px); color: var(--ink);
}
.cup-shelf__row:last-child { border-bottom: 0; }
.cup-shelf__row .dot { width: 0.65rem; height: 0.65rem; }
.cup-shelf__name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cup-shelf__row .starrow { margin-left: auto; font-size: 0.78em; flex: none; }
.cup-shelf__row .lock-glyph { width: 0.85em; height: 0.85em; flex: none; }
/* locked: the row recedes in place. It can't take the sunken fill any more —
   inside one card that would read as a hole punched in the paper. */
.cup-shelf__row--locked { color: var(--ink-2); }
.cup-shelf__row--locked .starcount { margin-left: auto; font-size: 0.85em; }
.cup-shelf__row--locked .starrow { margin-left: 0; font-size: 1em; }

/* Seat dock — a horizontal row of sticker seat cards across the bottom of the
   board, and the floor the two rails stop at. Room is capped at 4 players, so
   the dock never wraps. */
.lobby-foot {
  grid-area: dock;
  align-self: end;            /* the bottom line the rails also hang off */
  min-width: 0;
  display: flex; flex-direction: column; align-items: center; gap: 0.8rem;
}
#players { display: flex; justify-content: center; gap: clamp(0.6rem, 1.1vw, 1.1rem); }
/* Seat card — a sticker: the kart render on top, the player's name (in their
   livery colour) beneath. Ready check / host star pinned to the top-right
   corner. Neighbours lean opposite ways, like stickers slapped down one after
   another. Every card is the same height whatever it holds — see .seat__name. */
.seat {
  position: relative;
  width: clamp(100px, 9.5vw, 134px);
  display: flex; flex-direction: column; align-items: center; gap: 0.1rem;
  padding: 0.45rem 0.5rem 0.55rem;
  background: var(--surface);
  border-radius: var(--r);
  box-shadow: var(--shadow-card);
}
/* The name box ALWAYS reserves both lines (--name-lines), so the card's height is
   the same from the empty "Open" state through to a two-line name — the dock
   can't lift when someone called "Alexandra Smith" joins. Shorter names (and
   "Open") centre in the reserved box rather than hugging the render above. */
.seat__name {
  --name-lh: 1.15;
  --name-lines: 2;
  max-width: 100%;
  min-height: calc(var(--name-lh) * var(--name-lines) * 1em);
  display: flex; justify-content: center; align-items: center;
  font-family: var(--font-display); font-weight: 700;
  font-size: clamp(1rem, 1.3vw, 1.3rem); line-height: var(--name-lh); color: color-mix(in srgb, var(--c, var(--ink)) 80%, var(--ink));
}
/* Names break at the SPACE — "Alexandra Smith" stacks instead of clipping to
   "Alexandra …". `anywhere` only kicks in for a name with no space to break at
   (16 chars max, per the controller's input): it breaks mid-word rather than
   hard-clipping, since the clamp ellipsises across lines, not within one. */
.seat__label {
  display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: var(--name-lines);
  overflow: hidden;
  overflow-wrap: anywhere;
  text-align: center;
}
.seat--off { opacity: 0.5; }
/* readiness check — a green circle checkmark on the card's top-right corner;
   present (invisible) on every taken seat and revealed once the player's
   "I'm ready" lands, so the card layout never shifts. */
.seat__ready {
  visibility: hidden;
  position: absolute; top: -0.6rem; right: -0.55rem;
  width: 1.6rem; aspect-ratio: 1;
  display: grid; place-items: center;
  color: #fff; background: var(--green);
  border-radius: 50%; box-shadow: 0 0 0 3px #fff;
}
.seat__ready svg { width: 70%; height: 70%; }
.seat--ready .seat__ready { visibility: visible; }
/* host marker — a white star on an ink disc, same corner as the ready check
   (mutually exclusive: the host never readies). */
.seat__host {
  position: absolute; top: -0.6rem; right: -0.55rem;
  width: 1.6rem; aspect-ratio: 1;
  display: grid; place-items: center;
  background: var(--ink); border-radius: 50%; box-shadow: 0 0 0 3px #fff;
}
.seat__host svg {
  width: 62%; height: 62%;
  fill: #fff; stroke: none;
}
/* open seat — a glass placeholder of the SAME footprint: the empty
   .seat__open square stands in for the kart render, "Open" sits where the
   name goes */
.seat--open {
  background: rgba(24, 28, 44, 0.28);
  -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
  box-shadow: none;
}
.seat--open .seat__name { color: rgba(255, 255, 255, 0.85); }
/* matches .carthumb's box (theme.css) so an open seat is the same size as a taken one */
.seat--open .seat__open { width: 100%; aspect-ratio: 5 / 4; }

/* Build/version badge — preview builds only (the server leaves it empty in
   production, and :empty hides it). Pinned to the welcome board's bottom-right
   corner, deliberately quiet. Not uppercased so the commit hash stays
   lowercase hex. */
.version-badge {
  position: absolute; right: clamp(0.7rem, 1.4vw, 1.4rem); bottom: clamp(0.7rem, 1.4vh, 1.3rem);
  z-index: 2;
  font-weight: 700; font-size: clamp(0.7rem, 0.95vw, 0.88rem);
  letter-spacing: 0.02em; font-variant-numeric: tabular-nums;
  color: var(--ink-3);
  background: color-mix(in srgb, var(--surface) 78%, transparent);
  border: 2px solid var(--hairline); border-radius: var(--r-pill);
  padding: 0.25rem 0.7rem;
  pointer-events: none; user-select: none;
}
.version-badge:empty { display: none; }

/* ---- Boot cover ---- */
/* The splash. Above every board AND the corner chrome (the mute/fullscreen
   buttons are 12, the toast 13) — a board with a mute button floating over it
   reads as a half-drawn screen rather than a cover. Below the pause overlay (20)
   and the modals, because it covers the game rather than interrupting it.
   DELIBERATELY STATIC: no spinner, no pulse, nothing that animates. A
   full-screen animated overlay over a live 3D surface is the exact shape that
   cost an Apple TV 60 -> 7 fps through the GO beat, and this one is up while
   the renderer is at its busiest. Paper rather than transparent so it does not
   depend on whatever is behind it having drawn. */
#cover {
  position: fixed; inset: 0; z-index: 15;
  display: flex; align-items: center; justify-content: center;
  background: var(--paper);
  /* INERT, like the tvOS board's allowsHitTesting(false). It is a picture, not a
     modal: a full-screen element that eats clicks while it is up would make the
     boot window swallow input on whatever it happens to be covering. */
  pointer-events: none;
}
.cover__wordmark {
  font-size: clamp(56px, 9vw, 130px);
  text-align: center;
}

/* ---- Welcome ---- */
/* Title board at boot: the wordmark at poster scale + NEW GAME, centred over
   the paper diorama. Same stacking slot as #lobby (they swap via .hidden). */
#welcome {
  position: relative; z-index: 1;      /* over the diorama / #scene backdrop */
  height: 100%;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: clamp(1.1rem, 3.6vh, 2.2rem);
  padding-bottom: 7vh;                 /* optical centre: sit above the grass band */
}
.welcome__wordmark {
  font-size: clamp(56px, 9vw, 130px);
  text-align: center;
}
.welcome__tag {
  margin: 0;
  font-weight: 800; color: var(--ink-2);
  font-size: clamp(16px, 1.7vw, 24px);
}
.welcome__btn {
  font-size: clamp(22px, 2.6vw, 34px);
  padding: 0.72em 1.6em;
  letter-spacing: 0.03em;
  border-radius: 24px;
}
/* TRAILER: the white choice under NEW GAME, a size down. */
.welcome__btn--trailer {
  font-size: clamp(15px, 1.6vw, 22px);
  padding: 0.6em 1.3em;
  border-radius: var(--r);
}

/* The trailer player (index.html #trailer): the film on the overlays' frost
   (grouped with #pause-overlay below), so the title board it
   opened from stays in view around it; a click on the glass closes it. Over
   the corner chrome (12), the toast (13) and the boot cover (15); under the
   overlays that interrupt (pause 20, link 25, device chooser 30). */
#trailer { z-index: 18; --trailer-pad: clamp(1rem, 6vh, 4rem); padding: var(--trailer-pad) 0; }
/* Opened from the phone landing, it has to play over the landing itself. */
html:not(.device-choice-dismissed) #trailer { z-index: 31; }
#trailer video {
  /* Always 16:9: as wide as 80vw (which leaves the close button's corner clear
     of the film) or the height between the paddings allows. No pixel cap: on a
     large display the film would shrink beside a board that scales with it. */
  width: min(80vw, calc((100vh - 2 * var(--trailer-pad)) * 16 / 9));
  aspect-ratio: 16 / 9;
  display: block;
  /* COVER, not the default contain: the border makes the picture box a hair off
     16:9, and contain letterboxes that hair as a dark line inside the outline. */
  object-fit: cover;
  border-radius: var(--r-lg);
  box-shadow: var(--shadow-card);
}
/* A portrait screen (the phone landing) centres the film far below the close
   button's corner, so the 80vw clearance only costs width: go nearly edge to edge. */
@media (orientation: portrait) { html:not(.device-choice-dismissed) #trailer video { width: calc(100vw - 1.25rem); } }
.trailer__close { position: absolute; top: var(--safe-y); right: var(--safe-x); }
/* The debug gear floats over every layer (debugPanel.css); not over the film. */
#trailer:not(.hidden) ~ .dbg-fab { display: none; }
/* The phone landing is the public face of the page: no dev gear over it. */
html:not(.device-choice-dismissed) .dbg-fab { display: none; }

/* Legal footer (.site-foot lives in theme.css) — pinned along the bottom of the
   title board rather than sitting in its centred column, so it never pushes the
   wordmark off the optical centre. It stops short of the version badge's
   corner, which is absolute at the same edge. Deliberately NOT in the slap-in
   stagger above: it is the one piece of chrome that should not draw an eye. */
.welcome__foot {
  position: absolute; z-index: 2;
  left: 50%; transform: translateX(-50%);
  bottom: clamp(0.7rem, 1.4vh, 1.3rem);
  max-width: min(90%, 40rem);
  text-align: center;
  /* This row lands on the GRASS band, not on paper, so it takes a darker ink
     than the kit's default — the muted greys the theme uses on paper wash out
     against green. */
  color: var(--ink-2);
}
.welcome__foot a, .landing__contact a, .landing__foot a { color: var(--ink); text-decoration-color: rgba(42, 39, 53, 0.45); }

/* ---- 3D scene: a persistent backdrop behind BOTH the lobby (orbiting preview)
   and the race (split-screen). The lobby/race sections layer on top. ---- */
/* Diorama backdrop (shown until a track is picked) — pin it to the viewport so it
   shares the #scene stacking context regardless of body height. */
#lobby-diorama { position: fixed; }
/* The 3D backdrop sits OVER the diorama (same z-0, later in the DOM). Visibility is by
   opacity, not display, so we can crossfade the track straight in over the diorama (the
   default background) — no intermediate veil. .hidden is a hard off for non-lobby paths;
   .is-dim drops it to transparent so the diorama beneath shows through (the fade base).
   FADE_MS in main.js mirrors this transition. */
#scene { position: fixed; inset: 0; z-index: 0; opacity: 1; transition: opacity 0.45s ease; }
#scene.is-dim { opacity: 0; }
#scene canvas { display: block; }
/* Track→track crossfade still: a frozen frame of the OUTGOING track, laid over the live
   canvas while the new track rebuilds underneath. Fading it out dissolves one circuit
   straight into the next — never dipping through the diorama. Inside #scene's z-0 stacking
   context (so it stays behind the lobby/race UI); FADE_MS in main.js mirrors this. */
#scene-snap { position: absolute; inset: 0; width: 100%; height: 100%; z-index: 1;
  opacity: 1; transition: opacity 0.45s ease; pointer-events: none;
  /* Promote to its own compositor layer so the fade keeps animating off the main thread —
     it has to glide THROUGH setTrack's tens-of-ms rebuild block (which freezes the orbit),
     masking that hitch. main.js commits the fade a frame BEFORE the rebuild for this reason. */
  will-change: opacity; }
#scene-snap.is-fading { opacity: 0; }
/* Into a cup's next race (backdrop.js dissolveInto): the finished race, frozen
   as the board left it, blurred and washed pale like #results' frost (a filter
   cannot lay white over a picture, so the contrast squeezes it toward grey
   and the brightness lifts that grey to the frost's), so the board can go
   without the old race snapping into focus. It then dissolves slowly into the
   next grid, which reads as the focus pulling onto the new circuit. Scaled a
   touch so the blur's soft edge stays off screen. */
#scene-snap.is-soft {
  transition-duration: 1.2s; scale: 1.06;
  filter: blur(14px) contrast(0.15) brightness(1.75);
}
/* Race section is now a transparent overlay container over #scene. */
#race { position: fixed; inset: 0; z-index: 2; }
/* Standalone track preview (track gallery "open ↗", not the grid iframe): the
   viewer drives the overview camera. #race sits above the canvas (z-2), so it
   must pass pointer events through for OrbitControls to receive drags. Toggled
   by the track TestHarness (html.cam-free); harmless here since track preview
   shows no race UI of its own. */
html.cam-free #race { pointer-events: none; }
/* Control legend for the free camera (created by the track TestHarness). Pinned
   bottom-centre, never catches pointer events, fades out after its timer. */
.cam-hint {
  position: fixed; left: 50%; bottom: 1.1rem; transform: translateX(-50%);
  z-index: 13; padding: 0.45rem 0.95rem; border-radius: 999px;
  background: rgba(20, 24, 30, 0.55); color: #fff;
  font: 600 0.85rem/1 'Nunito', system-ui, sans-serif; letter-spacing: 0.01em;
  white-space: nowrap; pointer-events: none; backdrop-filter: blur(4px);
  transition: opacity 0.7s ease;
}
.cam-hint.is-faded { opacity: 0; }
/* Diorama vignette: gently darken the frame edges so the eye locks to the centre,
   reinforcing the miniature/tilt-shift focus. Cosmetic only — never catches clicks. */
#scene::after {
  content: ""; position: absolute; inset: 0; pointer-events: none;
  background: radial-gradient(120% 120% at 50% 45%, transparent 55%, rgba(0, 0, 0, 0.28) 100%);
}

/* START LIGHTS along the top of every player's view (.cd-view, placed over
   its cell by raceOverlays.js placeViews): the whole count on a glass strip,
   the current beat lit a size up, GO green. Nothing sits in the middle of the
   picture, so no player's car is ever under the count. Sized off the VIEW's
   height (--cell-h), so the lights keep their share of the 3D picture: a
   fixed size reads right in a split and lost in a lone full-screen view.
   --light is one light's diameter and everything else is a multiple of it. */
#countdown { position: fixed; inset: 0; pointer-events: none; }
.cd-view { position: absolute; --light: calc(var(--cell-h) * 0.128); }
.cd-steps {
  /* level with the name tag and the place badge, which hang the same 11px
     under the safe edge (.cell-label margin, Stage.js _loop's placeEl) */
  position: absolute; left: 50%; top: calc(var(--safe-top) + 11px); translate: -50% 0;
  display: flex; gap: calc(var(--light) * 0.125); padding: calc(var(--light) * 0.156); border-radius: var(--r-pill);
  background: var(--glass); -webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px);
}
.cd-step {
  width: var(--light); height: var(--light); border-radius: 50%; display: grid; place-items: center;
  font-family: var(--font-display); font-weight: 700; font-size: calc(var(--light) * 0.44);
  color: rgba(255, 255, 255, 0.7); background: rgba(255, 255, 255, 0.14);
}
/* A "1" centred by its box reads right of centre: its flag reaches left, so
   its stem, which is what the eye reads, sits right of the middle. */
.cd-step[data-n='1'] { padding-right: 0.09em; }
.cd-step--go { width: calc(var(--light) * 1.4); border-radius: var(--r-pill); }
/* The size up is a transform, so the slap's `scale` multiplies with it. */
.cd-step.is-now { background: #fff; color: var(--ink); transform: scale(1.15); }
.cd-step--go.is-now { background: var(--brand); color: #fff; }
/* GO stays up the beat after the start (cars are already moving) and fades
   out over that ~1s window. Added/removed by onCountdownTick on the n=0 beat.
   A PLAIN FADE, no grow: see CountdownView.swift for what a grow cost the TV. */
#countdown.is-go { animation: countdown-go 1s ease-out forwards; }
@keyframes countdown-go {
  from { opacity: 1; }
  to   { opacity: 0; }
}

/* per-viewport (split-screen) label, anchored at each cell's top-left: the
   player's name as a LIVERY sticker tag, the item slot beneath it. */
.cell-label {
  position: absolute; display: none; margin: 0.7rem; pointer-events: none;
}
/* name on top, the item slot stacked + left-aligned below it (place + lap
   moved out to the top-right .cell-rank readout) */
.cell-label__row { display: flex; flex-direction: column; align-items: flex-start; gap: 0.5rem; }
/* FILLED with the player's livery, white text — the same tag the phone wears
   (controller.css .lobby-me__name), so your name reads as one object across both
   screens. Filled rather than livery text on white: across a 4-way split the
   thing you hunt for is your cell, and a solid block of your car's colour is
   found in peripheral vision where coloured GLYPHS are not. It also degrades
   better on the palest liveries, where the name washes out either way round but
   the tag still says whose cell this is. */
.cell-label__name {
  font-family: var(--font-display); font-weight: 700; font-size: clamp(1.5rem, 2vw, 2.2rem);
  line-height: 1;
  color: #fff;
  background: var(--c, var(--brand));
  border-radius: var(--r-pill);
  padding: 0.28em 0.75em 0.32em;
  box-shadow: var(--shadow-card);
  white-space: nowrap; max-width: 14em; overflow: hidden; text-overflow: ellipsis;
}

/* Another player's name over their car, in your cell (CPU cars get none).
   Placed per frame by Stage's _paintNameTags from ttp_display_name_tags: the
   transform anchors the tag's tail's tip just above the point C++ projected,
   so nothing here may set transform. A filled livery pill, one weight lighter
   than the cell's name tag, with tight padding so a pack of tags does not turn
   into a wall of chips.
   AUTHORED AT THE CLOSEST SIZE, C++'s scale 1 (ttp/name_tags.h), which is 4/3
   of the usual tag: C++ only ever shrinks it, so the type is never an upscaled
   raster. */
.name-tag {
  --b: calc(2px * 4 / 3);
  position: absolute; left: 0; top: 0; display: none;
  transform-origin: 0 0; will-change: transform, opacity;
  font-family: var(--font-display); font-weight: 600; font-size: clamp(1.2rem, 1.56vw, 1.8rem);
  line-height: 1; color: #fff; white-space: nowrap;
  background: var(--c, var(--brand));
  border-radius: 999px;
  padding: 0.14em 0.55em 0.18em;
  box-shadow: var(--shadow-card);
}
/* The tail: one livery triangle under the tag's centre, its tip flattened a
   little to soften the point. When two tags overlap, the tail says whose car is
   whose. `--b` sizes it, and the TV shells' tags (NameTags.swift, NameTags.kt)
   size theirs by the same number. */
.name-tag::after {
  content: ''; position: absolute; left: 50%; top: calc(100% - 1px);
  width: calc(0.6em + 2 * var(--b)); height: calc(0.3em + var(--b));
  transform: translateX(-50%);
  clip-path: polygon(0 0, 100% 0, 54% 92%, 46% 92%);
  background: var(--c, var(--brand));
}

/* Place + lap — top-right of the cell: a white sticker badge for the ordinal
   over an ink "LAP 2/3" pill, the pair leaning +1.5°. Positioned by _loop; the
   transform anchors its RIGHT edge to the left coord _loop sets (cell right). */
.cell-rank {
  position: absolute; display: none; transform: translateX(-100%);
  transform-origin: top right;
  text-align: right; pointer-events: none;
}
/* _loop shows the wrapper with display:block, so the badge + pill right-align
   themselves (display:table shrink-wraps while honouring margin-left:auto). */
.cell-rank__place {
  display: table; margin-left: auto;
  font-family: var(--font-display); font-weight: 700; line-height: 1;
  font-size: clamp(2rem, 2.8vw, 2.9rem);
  color: var(--ink); background: var(--surface);
  border-radius: var(--r);
  padding: 0.22em 0.5em;
  box-shadow: var(--shadow-card);
}
.cell-rank__lap {
  display: table; margin-left: auto; margin-top: 0.45rem;
  font-family: var(--font-display); font-weight: 600;
  font-size: clamp(0.9rem, 1.25vw, 1.15rem); letter-spacing: 0.12em; text-transform: uppercase;
  color: #fff; background: var(--glass); border-radius: var(--r-pill);
  -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
  padding: 0.32em 0.9em;
  white-space: nowrap;
}

/* Held-item indicator — a FIXED reserved sticker slot (no reflow when empty)
   under the player name so players read what they got at a glance (the
   controller has no item UI; place/lap live in .cell-rank). Dashed ink outline
   while empty; a solid white sticker with the hard drop once an item lands. */
.cell-label__item {
  flex: none; width: clamp(112px, 10vw, 152px); height: clamp(112px, 10vw, 152px); transform-origin: center;
  display: flex; align-items: center; justify-content: center;
  background: var(--surface);
  border-radius: 28px;
  box-shadow: var(--shadow-card);
}
.cell-label__item.is-empty {
  background: rgba(24, 28, 44, 0.18);
  border: 3px dashed rgba(255, 255, 255, 0.75);
  box-shadow: none;
}
/* every icon is an inlined /assets/items/*.svg (see shared/itemIcons.js) */
.cell-label__item svg { width: 70%; height: 70%; display: block; }
/* roulette spin: a fast scale/tilt flicker while the slot rolls */
.cell-label__item.rolling { animation: cellItemRoll 0.1s linear infinite; }
@keyframes cellItemRoll {
  0%   { filter: brightness(1.2); transform: scale(1.12) rotate(-4deg); }
  50%  { filter: brightness(0.9); transform: scale(0.97) rotate(4deg); }
  100% { filter: brightness(1.2); transform: scale(1.12) rotate(-4deg); }
}
/* landing pop: a big springy scale-in */
.cell-label__item.pop { animation: cellItemPop 0.5s cubic-bezier(0.2, 1.5, 0.35, 1); }
@keyframes cellItemPop {
  0%   { transform: scale(1.8); filter: brightness(1.6); }
  60%  { filter: brightness(1.15); }
  100% { transform: scale(1); filter: none; }
}

/* The per-cell steer indicator is NOT here any more, and neither are the ink
   rules between cells: both are cell-anchored and textless, so the RENDERER
   draws them (native/renderer/materials/voverlay.mat), from the same cell
   rectangles this file's chrome is placed on. The composition moved with them —
   270 x 34 with a 4px border, pill radius, ink on --surface with a 40% centre
   tick and a --c fill, 20 clear of the bottom — but the UNITS did not: every
   number scales with a DAMPED SHARE OF SCREEN HEIGHT now — the geometric mean
   of the cell's height and the screen's, over 1080, times the renderer's
   BAR_SCALE (1.7). A split takes height away so each bar shrinks, but by the
   square root of the share, not proportionally: 459 x 58 at one player, 325 x 41
   at every split. No CSS pixel is left for the renderer to convert,
   deliberately: a TV shell cannot supply a points-per-pixel scale, knowing
   neither the panel's size nor the couch's distance.

   NOTE THE ASYMMETRY, since it is a live design question: .cell-label and
   .cell-rank below are still sized in CSS pixels and do NOT scale with the
   split at all, so a 2x2 cell mixes one element that shrinks with the split
   against three that hold their size.

   The line, so this does not become a habit: cell-anchored AND textless goes to
   the renderer; anything carrying type or sticker chrome stays right here. */

/* per-cell FINISHED overlay — a sticker card centred in a player's split-screen
   cell the moment they cross the line, while the rest of the field is still
   racing. _loop sets left/top to the cell centre; translate centres it. Lives
   in #scene (z-0) like the other cell overlays, so the full results screen
   (#results, in #race z-2) covers it once the race ends. */
.cell-finish {
  position: absolute; display: none; transform: translate(-50%, -50%);
  flex-direction: column; align-items: center; gap: clamp(0.4rem, 0.8vw, 0.7rem); text-align: center;
  padding: clamp(0.9rem, 1.9vw, 1.7rem) clamp(1.3rem, 2.8vw, 2.6rem);
  background: var(--surface);
  border-radius: var(--r-lg);
  box-shadow: var(--shadow-card);
  pointer-events: none;
}
/* a LABEL in the player's livery */
.cell-finish__badge {
  font-family: var(--font-display); font-weight: 700;
  font-size: clamp(0.85rem, 1.4vw, 1.25rem); letter-spacing: 0.16em; text-transform: uppercase;
  color: color-mix(in srgb, var(--c, var(--brand)) 78%, #1B1830);
  background: color-mix(in srgb, var(--c, var(--brand)) 20%, #fff);
  padding: 0.2rem 0.9rem; border-radius: var(--r-pill);
}
.cell-finish__place {
  font-family: var(--font-display); font-weight: 700; line-height: 1;
  font-size: clamp(2rem, 4vw, 3.4rem); color: var(--ink);
}
.cell-finish__time {
  font-weight: 800; font-size: clamp(0.95rem, 1.4vw, 1.4rem);
  color: var(--ink-2); font-variant-numeric: tabular-nums;
}

/* results overlay — FROST over the live scene, the board ONE paper card on it.
   See-through on purpose: the scene behind is the race that just ended, and
   between cup races it is where the next track comes in. */
#results {
  position: fixed; inset: 0; display: flex; flex-direction: column;
  align-items: center; justify-content: center;
  background: var(--frost);
  -webkit-backdrop-filter: var(--frost-blur); backdrop-filter: var(--frost-blur);
}
/* THE BOARD FADES UP, because it no longer arrives at the flag. The race's end
   arms it and the frozen finish frame holds the screen for the flourish
   (race_flow.h FINISH_FLOURISH_MS) while everyone reads the place cards — so
   the paper glass has something to fade OVER, and cutting to it throws away the
   beat the hold exists to buy. The pieces keep their own slap-ins below and
   land on the glass as it arrives; the fade restarts on its own every time
   because showing the board is a display:none → shown flip. */
@media (prefers-reduced-motion: no-preference) {
  #results { animation: results-fade 320ms ease-out both; }
  @keyframes results-fade { from { opacity: 0; } to { opacity: 1; } }
}
#results h2 {
  font-family: var(--font-display); font-weight: 700;
  font-size: clamp(2.4rem, 6vw, 4.6rem); margin: 0 0 1rem; color: var(--ink);
}
/* podium boards: the title goes celebration red — a red LABEL round it
   (capped a step below the plain title so it doesn't dwarf the podium).
   SPLIT IN TWO ON PURPOSE. The sticker is bigger than a plain title, so a board
   that grew one at the end would push everything under it — and the rows are
   mid-FLIP at that moment while the button is not animated at all. So a podium
   board carries the sticker's BOX from its first frame (.is-champs, below) and
   the celebration lands as PAINT ONLY: colour, background, and a rotate/scale
   that are transforms and therefore cost no layout. Nothing under
   the header moves by so much as a pixel.
   Keep every property here layout-free — a padding or font-size added to this
   rule instead of the one above reintroduces the jump. */
#results.is-champs h2 {
  font-size: clamp(2rem, 4.2vw, 3.4rem);
  margin-bottom: 0.5em;
  border-radius: var(--r-lg);
  padding: 0.25em 0.7em;
}
#results.is-podium h2 {
  color: color-mix(in srgb, var(--red) 78%, #1B1830);
  background: color-mix(in srgb, var(--red) 20%, #fff);
  transform: rotate(-2deg);
}
@media (prefers-reduced-motion: no-preference) {
  /* `scale` is its own property, not `transform` — it composes with the rotate
     above and, like it, changes no layout. */
  #results.is-podium h2 { animation: champs-slam 0.42s cubic-bezier(0.2, 1.5, 0.35, 1) both; }
  @keyframes champs-slam {
    0%   { scale: 1.14; }
    60%  { scale: 0.98; }
    100% { scale: 1; }
  }
}
/* The board's rows. A full grid plus late joiners is eleven of them, which does
   not fit a 720p TV in one column, so .is-two-col (set by raceOverlays above
   ONE_COL_MAX rows) splits it. COLUMN-major — --result-rows is ceil(n/2), so
   ranks 1-N fill the left column top-down and the rest the right, the same
   reading order the phone's board uses. Counters follow DOM order, which is
   that order, so the numerals stay correct in both layouts. */
/* The board is ONE paper card and its rows are printed on it, ruled apart by
   a hairline: rows that each looked like a button were the old board's
   problem. Every row draws its rule ABOVE itself, and the strip along the
   card's top edge (::before, out of the grid's flow) paints over the top row's
   in both columns, which is the one rule a column-major list cannot pick out
   by selector. */
#results-list {
  position: relative;
  list-style: none; margin: 0; counter-reset: li;
  font-size: 1.5rem; display: grid; gap: 0; min-width: 24rem;
  padding: 10px 1rem; background: var(--surface);
  border-radius: var(--r-lg); box-shadow: var(--shadow-card);
}
/* Whole pixels on both sides: at a fractional offset the row's rule snaps a
   pixel lower than the strip and a sliver of it shows. Above every row: the
   standings re-sort stacks its sliding rows by rank (raceOverlays). */
#results-list::before {
  content: ''; position: absolute; z-index: 99; left: 1rem; right: 1rem;
  top: 10px; height: 2px; background: var(--surface);
}
#results-list.is-two-col {
  grid-auto-flow: column;
  grid-template-rows: repeat(var(--result-rows, 4), auto);
  column-gap: 2rem;
}
#results-list li {
  counter-increment: li;
  display: flex; align-items: center; gap: 0.7rem;
  border-top: 2px solid var(--hairline);
  padding: 0.55rem 0.7rem; font-family: var(--font-display); font-weight: 600;
}
/* The leader's row is washed in the celebration red, the way the artifact's
   board marks it, so the eye finds the top of the table first. */
#results-list li:first-child {
  background: color-mix(in srgb, var(--red) 12%, #fff);
  border-radius: 14px; border-top-color: transparent;
}
#results-list li:first-child + li { border-top-color: transparent; }
/* A bot's tag: small, grey, after the name. */
.res-cpu {
  font-family: var(--font-body); font-weight: 800; font-size: 0.5em; letter-spacing: 0.08em;
  color: var(--ink-3); background: var(--surface-2);
  border-radius: var(--r-pill); padding: 0.3em 0.7em;
}
/* The rank numeral reserves the MEDAL CHIP's box on every row, medalled or not
   (the chip below only paints it in). Sizing the chip larger than the plain
   numeral widened the rank column when the medals landed, so the podium's list
   grew as it re-sorted. */
#results-list li::before {
  content: counter(li); font-family: var(--font-display); font-weight: 600;
  color: var(--ink-3); min-width: 1.6em; text-align: center;
  padding: 0.05em 0.45em; border-radius: var(--r-pill);
}
/* racer names carry their livery colour (no swatch dot) */
#results-list .res-name { color: color-mix(in srgb, var(--c, var(--ink)) 80%, var(--ink)); }
/* The trailing cells. FIXED WIDTHS, and that is load-bearing: a cup board's two
   phases lay out the same three cells and phase 2 only fills the total in, so
   the re-sort animation moves rows without any of them changing size under it.
   tabular-nums keeps a counting total from twitching for the same reason. */
.res-trail { margin-left: auto; display: flex; align-items: center; gap: 0.7rem; font-family: var(--font-body); }
.res-time { color: var(--ink-2); font-weight: 800; font-variant-numeric: tabular-nums;
            min-width: 3.6em; text-align: right; }
/* Late joiners ride along under the field: no rank number, "Next race" status. */
#results-list li.is-joining::before { content: '–'; }
#results-list li.is-joining .res-time { font-style: italic; }
#results-newgame { margin-top: 1.8rem; }

/* ---- cup dressing (intermission + podium) ---- */
.results-sub {
  font-family: var(--font-display); font-weight: 700;
  font-size: clamp(1.1rem, 2vw, 1.6rem); color: var(--ink-2);
  margin: -0.4rem 0 1.3rem;
}
/* Cup boards trade the race time for points: this race's gain + the cup total.
   margin-left is the TRAIL's now (all three cells sit in it), not this cell's:
   an `auto` here would push the gain away from the time by whatever slack the
   row has, which differs per row and per phase. The width holds the WIDEST gain
   the ladder pays (POINTS_BY_RANK[0]), not the "+0" the count-up empties it to
   while filling the total — a min-width fitting only the settled state lets the
   cell, and so the whole board, shrink under the re-sort. Both TV shells lay
   this cell out at the same multiple; tests/points-ladder.test.js pins them. */
.res-gain { color: var(--brand); font-weight: 800; font-variant-numeric: tabular-nums;
            min-width: 2.7em; text-align: right; }
.res-gain.is-zero { color: var(--ink-3); }
/* Spent once the last point has landed. The gain would otherwise rest on "+0",
   which reads as "scored nothing" on a board that stays up for the rest of the
   intermission; the lap time would be the only number on a settled CUP board
   still talking about the race. Fading (not removing) keeps both columns' width,
   so the settled board is the same shape as the one that was just animating. */
.res-gain.is-spent, .res-time.is-spent { opacity: 0; transition: opacity 0.35s ease 0.15s; }
/* Filled in BOTH phases and differing only in value (the total coming in, then
   the total banked), so the count-up has a before state the eye can actually
   read and nothing has to materialise mid-transition. */
.res-pts { color: var(--ink-2); font-weight: 800; font-variant-numeric: tabular-nums;
           min-width: 4.2em; text-align: right; }
/* "Next up: Gulch — starting in 8…" under the intermission board: a LABEL */
.results-next {
  margin-top: 1.4rem; font-weight: 800; font-size: clamp(1.1rem, 2vw, 1.5rem);
  color: color-mix(in srgb, var(--ink-2) 78%, #1B1830);
  background: color-mix(in srgb, var(--ink-2) 20%, #fff);
  border-radius: var(--r-pill); padding: 0.45em 1.1em;
}
.results-next b { font-family: var(--font-display); font-weight: 700; color: var(--ink); }
/* Held, not hidden: the race phase RESERVES the footer's box so its arrival in
   the standings phase moves nothing above it. The title, the list and the button
   are not what the FLIP animates, so they would jump while the rows slid. */
.results-next.is-held, .results-sub.is-held { visibility: hidden; }

/* podium — the cup's top three, medalled in place in the standings list rather
   than lifted onto separate steps. Gold/silver/bronze is what a medal wants and
   what the theme forbids (yellow and amber are vetoed in chrome), so the ranking
   is carried by WEIGHT instead of by metal: the champion takes a filled rank
   chip in the celebration red, 2nd and 3rd take ink chips, everyone else keeps
   the plain numeral. */
/* The champion is marked by the CHIP, not by a bigger name. Scaling the name
   widened the grid column and grew the row, so the podium's list changed size
   the moment the medals landed — the one thing the two phases must never do. */
#results-list li.is-medal-1::before,
#results-list li.is-medal-2::before,
#results-list li.is-medal-3::before {
  color: #fff; background: var(--ink-2);
}
/* The champion's chip pops — on the ::before, NOT on the li, which the FLIP
   owns: a keyframed transform outranks even an inline one, so a pop on the row
   itself would freeze that row where it stands while every other row slides. */
#results-list li.is-medal-1::before {
  background: var(--red);
  animation: champ-pop 0.45s cubic-bezier(0.2, 1.6, 0.4, 1) both;
}
@keyframes champ-pop {
  from { transform: scale(0.4); }
  to   { transform: scale(1); }
}

/* ---- Corner buttons ---- */
/* The top-right row over the scene, on EVERY screen: mute + fullscreen
   (everywhere but the welcome board, see below) + pause (races only, .hidden
   off-race), as ONE white pill of keys (theme.css .key-pill), the same over the
   lobby, the race and the frost; we only place it here. The row keeps this corner during a race too (user call): it is
   where a viewer reaches for these controls, and moving it on the lobby -> race
   change only means they are not there when someone reaches.

   IT SHARES THE CORNER WITH A CELL'S PLACE/LAP BADGE, and simply draws over it:
   the row is layered above #race's whole stacking context, and the cell overlay
   lives inside that one, unlayered on #scene — so nothing the race paints can
   reach over the row whatever z-index it is later given. Nothing is nudged out
   of the way either: the badge is placed from its own cell and stays there.
   What resolves the overlap is the auto-hide: a few seconds without a mouse or a
   key and the row fades off the badge entirely (html.chrome-idle below). The
   fade is the ROW's transition, which leaves each button's own .icon-btn
   hover/press transitions untouched. */
#corner-btns {
  position: fixed; z-index: 12;
  top: var(--safe-y);
  right: var(--safe-x);
  padding: 5px;
  transition: opacity 0.4s ease;
}
/* an empty pill (every button hidden) must not leave a white dot behind */
#corner-btns:not(:has(> .icon-btn:not(.hidden))) { display: none; }
#corner-btns .icon-btn, .trailer__close { width: var(--corner-btn); height: var(--corner-btn); }

/* The welcome board has no fullscreen toggle: NEW GAME is the gesture that
   claims fullscreen, so a second control beside it is only a way to spend the
   gesture early. Not the mute button's route (markup .hidden + show()) because
   chrome.js already owns this button's .hidden for the no-fullscreen-API case,
   and the welcome board is also what the FIRST PAINT shows — before any screen
   switch has run to re-hide it. */
#welcome:not(.hidden) ~ #corner-btns #fullscreen-btn { display: none; }
#welcome:not(.hidden) ~ #corner-btns:not(:has(> #mute-btn:not(.hidden), > #pause-btn:not(.hidden))) { display: none; }

/* Idle race chrome — main.js sets html.chrome-idle after a few seconds without
   a pointer move / tap / key press, and ONLY while a race is running (never on
   the welcome board, the lobby, the results, or behind the pause overlay, where
   a vanished cursor would strand a mouse user mid-click). The corner row fades
   off the place/lap readout, and the mouse pointer goes with it: this is a TV
   surface, and an arrow parked over the track is nothing but litter. Any input
   brings both straight back.
   `html.chrome-idle *` (0,1,1) is deliberately more specific than the .btn /
   .icon-btn `cursor: pointer` rules (0,1,0), so hovering one of those can't
   resurrect the arrow regardless of stylesheet order. */
html.chrome-idle, html.chrome-idle * { cursor: none; }
html.chrome-idle #corner-btns { opacity: 0; pointer-events: none; }

/* The full-screen overlays are the phone's FROST with no card: the screen
   behind blurred and lightened, the content straight on it, so a pause on the
   TV and on a phone are one screen. (Dark glass was tried here and dropped:
   it was one more surface style, used nowhere else at that size.) The frozen
   race still shows through, so the screen reads as "on hold". Each overlay
   sets only its own z-index; the buttons step up to TV size. */
#pause-overlay, #link-overlay, #trailer {
  position: fixed; inset: 0;
  display: flex; align-items: center; justify-content: center;
  background: var(--frost);
  -webkit-backdrop-filter: var(--frost-blur); backdrop-filter: var(--frost-blur);
}
.pause-card .btn, .link-card .btn { font-size: clamp(1.25rem, 2.3vw, 1.9rem); }
#pause-overlay { z-index: 20; }
.pause-card {
  display: flex; flex-direction: column; align-items: center;
  gap: clamp(1.2rem, 2.5vw, 2rem); text-align: center;
}
.pause-card h2 {
  font-family: var(--font-display); font-weight: 700;
  font-size: clamp(2.6rem, 7vw, 4.6rem); line-height: 1.05; margin: 0; color: var(--ink);
}
/* The pair is always one size: a floor in em (so it scales with the label)
   that most translations fit inside, and two EQUAL columns, so a label that
   outgrows it widens both buttons rather than one. */
.pause-card__btns { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; gap: 1rem; }
.pause-card__btns .btn { min-width: 9em; }

/* ---- Connection overlay ---- the display's own link (#link-overlay). The
   pause frost at one z-step up: above the boot cover (15) and the pause
   overlay (20), under the device chooser (30). */
#link-overlay { z-index: 25; }
.link-card {
  display: flex; flex-direction: column; align-items: center;
  gap: clamp(1rem, 2vw, 1.6rem); text-align: center;
}
.link-card h2 {
  font-family: var(--font-display); font-weight: 700;
  font-size: clamp(2.6rem, 7vw, 4.6rem); line-height: 1.05; margin: 0; color: var(--ink);
}
.link-status { font-size: clamp(1.25rem, 2.5vw, 1.8rem); font-weight: 700; color: var(--ink-2); }
.link-status:empty { display: none; }
.link-card .btn { min-width: 9em; }   /* the pause pair's size */

/* ---- Device chooser / phone landing ---- the display URL opened on a
   phone-sized screen (see #device-choice in index.html). Hidden by default; the
   media query below surfaces it on cramped viewports until a choice is made.
   Size-only triggers (no pointer/hover filters): the "run the big screen here
   OR send it to one" fork applies to any small window, and continuing is one
   tap away. main.js adds html.device-choice-dismissed on "continue" AND at boot
   on big screens / dev surfaces, so a mid-session window resize can never
   resurface the chooser over a live lobby or race.
   A full-screen board, so it sits on paper; it scrolls, because a landscape
   phone is under 400px tall and the pitch is not. */
#device-choice {
  display: none; position: fixed; inset: 0; z-index: 30;
  justify-content: center;
  overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain;
  background: var(--paper);
}
.landing {
  display: flex; flex-direction: column; align-items: center; text-align: center;
  gap: 1.1rem; width: 100%; max-width: 30rem; min-height: 100%;
  padding: 2rem 1.2rem 0;
  margin: auto 0;   /* centred when it fits, top-aligned scroll when it does not */
}
/* No blue tap flash on the phone page: every control here shows its own press
   (the controller page does the same). */
#device-choice * { -webkit-tap-highlight-color: transparent; }

/* The welcome board's grass (theme.css .scene__grass and the two hills behind
   it), drawn by the block that holds the last links. It spans the screen,
   whatever the column's width, and its edge sits a fixed distance above the
   first link, however tall the page above it is. The hill colours are
   .scene__sky's in theme.css. */
.landing__ground {
  position: relative; z-index: 0;   /* a stacking context, so the -z grass and hills paint under the links */
  width: 100vw; margin-top: auto;
  display: flex; flex-direction: column; align-items: center; gap: 1.1rem;
  padding: 4.2rem 1.2rem 1.4rem;
}
/* The hills take the block's top 1.6rem and the grass starts under them, so
   nothing reaches up into the share button above. */
.landing__ground::before {
  content: ''; position: absolute; inset: 1.6rem 0 0; z-index: -1;
  background: var(--grass);
  border-radius: 50% 50% 0 0 / 2.4rem 2.4rem 0 0;
}
.landing__ground::after {
  content: ''; position: absolute; left: 0; right: 0; top: 0; height: 4rem; z-index: -2;
  background:
    radial-gradient(ellipse 48% 100% at 14% 100%, #d3edbb 98%, transparent 100%),
    radial-gradient(ellipse 52% 100% at 88% 100%, #e0f3cd 98%, transparent 100%);
}
.landing__wordmark { font-size: 2.6rem; margin: 0; }
.landing__tag { text-wrap: balance; margin: 0; color: var(--ink-2); font-weight: 700; font-size: 1.05rem; max-width: 22rem; }

/* The picture is the trailer's play button: a die-cut sticker with a hard
   shadow and a slight tilt the other way from the wordmark. */
.landing__hero {
  position: relative; display: block; width: 100%;
  padding: 0; border: 0; border-radius: var(--r-lg);
  overflow: hidden; background: var(--ink);
  box-shadow: var(--shadow-card);
  cursor: pointer;
}
.landing__hero img { display: block; width: 100%; height: auto; }
/* A narrow screen gives the picture most of its width back: it bleeds into
   the side padding. */
@media (max-width: 30rem) { .landing__hero { width: calc(100% + 1.2rem); } }
/* The disc is the picture's push-button, an ACTION: squashed and darkened
   while the picture is pressed (theme.css .btn has the press timing's
   reasoning). */
.landing__play {
  position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
  display: grid; place-items: center;
  width: 3.6rem; height: 3.6rem;
  border-radius: var(--r-pill);
  background: var(--red);
  transition: transform var(--press-up) ease, filter var(--press-up) ease;
}
.landing__hero:active .landing__play {
  transform: translate(-50%, -50%) scale(0.94); filter: brightness(0.9);
  transition: none;
}
.landing__hero:focus-visible { outline: var(--focus-ring); outline-offset: 4px; }
.landing__play svg { width: 1.9rem; height: 1.9rem; fill: #fff; }

/* The three steps: white sticker rows (.card), each led by a numbered disc in
   the next chrome colour. */
.landing__steps {
  display: flex; flex-direction: column; gap: 0.65rem;
  width: 100%; margin: 0.3rem 0 0; padding: 0; list-style: none; text-align: left;
}
.landing__step {
  display: flex; align-items: center; gap: 0.75rem;
  padding: 0.6rem 0.9rem 0.6rem 0.6rem;
  box-shadow: var(--shadow-pop);
}
.landing__step > span:last-child { display: flex; flex-direction: column; }
.landing__step strong { font-family: var(--font-display); font-weight: 700; font-size: 1.05rem; color: var(--ink); }
.landing__step strong + span { font-weight: 700; font-size: 0.85rem; color: var(--ink-2); }
.landing__num {
  flex: none; display: grid; place-items: center;
  width: 2.4rem; height: 2.4rem;
  border-radius: var(--r-pill);
  background: var(--num); color: #fff;
  font-family: var(--font-display); font-weight: 700; font-size: 1.2rem;
}
.landing__step:nth-child(1) { --num: var(--red); }
.landing__step:nth-child(2) { --num: var(--green); }
.landing__step:nth-child(3) { --num: var(--blue); }

.landing__share { display: flex; flex-direction: column; gap: 0.15rem; width: 100%; margin-top: 0.4rem; text-transform: none; }
.landing__host { font-size: 1.2rem; overflow-wrap: anywhere; }
.landing__share-sub { font-size: 0.85rem; opacity: 0.9; }
.landing__continue {
  background: none; border: 0; padding: 0.3rem;
  font: inherit; font-weight: 700; color: var(--ink);
  text-decoration: underline; text-underline-offset: 3px; text-decoration-thickness: 2px;
  cursor: pointer;
}
.landing__contact { margin: 0.4rem 0 0; color: var(--ink-3); font-weight: 700; font-size: 0.85rem; }
.landing__foot { margin-top: 0.2rem; }

@media (max-width: 700px), (max-height: 500px) {
  html:not(.device-choice-dismissed) #device-choice { display: flex; }
}

/* ---- Motion: sticker slap-ins (sticker-slap keyframes in theme.css) ----
   Lobby chrome slaps on in a quick stagger every time #lobby is (re)shown
   (display:none → shown restarts CSS animations), the cup sticker pops as the
   pick lands (.cup-slot__pick loses .hidden), and the results board's pieces
   land one after another on reveal. Countdown digits are re-triggered from JS
   (the .slap class) since their element never re-enters the DOM. All of it
   only exists under prefers-reduced-motion: no-preference.

   The scan hint has no slap: it is held back until the QR lands, which is
   long after the slap, so its arrival is the fade (see .tagline). */
@media (prefers-reduced-motion: no-preference) {
  .welcome__wordmark, .welcome__tag, .welcome__btn,
  .ticket, .cup-slot__pick, .lobby-foot {
    animation: sticker-slap 240ms cubic-bezier(0.2, 1.5, 0.35, 1) both;
  }
  .welcome__tag { animation-delay: 60ms; }
  .welcome__btn { animation-delay: 120ms; }
  .welcome__btn--trailer { animation-delay: 180ms; }
  .ticket { animation-delay: 40ms; }
  .cup-slot__pick { animation-delay: 120ms; }
  .cup-shelf { animation: sticker-slap 240ms cubic-bezier(0.2, 1.5, 0.35, 1) both; animation-delay: 140ms; }
  .lobby-foot { animation-delay: 160ms; }

  /* sticker-slap animates `scale`/`rotate`, never `transform` — which is what
     lets the results board's FLIP (raceOverlays, phase 2) drive `transform` on
     these same rows without the two fighting over one property.

     The rows' stagger is set INLINE by raceOverlays, not by :nth-child here.
     The FLIP re-orders those same nodes, so an nth-child delay changes under a
     row the moment it moves — which restarts this animation from its 0% frame,
     and 0% is opacity 0. Every row that overtook another went invisible for the
     length of its own slide. An inline delay travels with the node. */
  #results h2, .results-sub, #results-list li, .results-next, #results-newgame {
    animation: sticker-slap 240ms cubic-bezier(0.2, 1.5, 0.35, 1) both;
  }
  .results-sub { animation-delay: 60ms; }
  .results-next, #results-newgame { animation-delay: 300ms; }
}

/* ---- Narrow / portrait (e.g. someone opens the display on a phone) ----
   The rail layout is designed for a wide screen; on a narrow viewport stack
   the chrome in one scrolling column instead. (The device chooser covers most
   of these visits anyway.)

   The lobby's padding below DROPS the safe zone, deliberately: a viewport this
   shape is a phone or a small window and never a television, and 5vw of overscan
   margin on a 380px-wide column is a quarter of the column. The per-cell HUD
   keeps its inset regardless — that one is C++'s and this file cannot reach it. */
@media (max-width: 860px) {
  html, body { overflow-y: auto; }
  #lobby {
    height: auto; min-height: 100%;
    display: flex; flex-direction: column; align-items: center;
    gap: 1.4rem; padding: 1.4rem 1rem 2rem;
  }
  .ticket-rail { order: 1; width: min(84vw, 400px); }
  /* the race rail joins the column whole (pick, then shelf); nothing above it
     to clear, and no band to spread over */
  .race-rail { order: 2; width: min(84vw, 360px); padding-top: 0; }
  .lobby-foot { order: 3; }
  #players { flex-wrap: wrap; justify-content: center; }
  /* the stage clouds are placed for the wide layout — behind the stacked
     column they poke out from odd corners, so they sit this one out */
  .scene__cloud { display: none; }
  /* The corner row measures from the safe zone, which this viewport has just
     dropped: 2.5vw of a 380px window is 9px, and 9px is not a margin. Back to a
     flat inset, matching the column's own side padding above. */
  #corner-btns { top: 1rem; right: 1rem; }
}

/* ---- Toast ---- transient confirmation (e.g. "Copied"), bottom-centre.
   Hidden until .is-on is added; fades + slides in, then back out. */
.toast {
  /* Clear of the safe edge rather than on it: the toast floats OVER the seat
     dock, which now ends at that edge, so a flush pill would land on the last
     row of seats. The flat 2.5rem it used to sit at was inside the crop margin
     on a 4K board. */
  position: fixed; left: 50%; bottom: calc(var(--safe-y-b) + 1rem);
  transform: translate(-50%, 1rem);
  z-index: 10;
  font-family: var(--font-display); font-weight: 600; font-size: 1rem; color: #fff;
  background: rgba(24, 28, 44, 0.78);
  -webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px);
  padding: 0.65rem 1.4rem; border-radius: var(--r-pill);
  opacity: 0; pointer-events: none;
  transition: opacity 0.2s ease, transform 0.2s ease;
}
.toast.is-on { opacity: 1; transform: translate(-50%, 0); }

/* ---- Sound hint ---- persistent pill while the browser still has audio
   locked (no user gesture yet); see #sound-hint in index.html. */
.sound-hint {
  /* On the safe edge with the rest of the edge-anchored chrome: this pill is up
     on the welcome board and in the lobby, where a television that crops would
     eat the flat 1rem it used to sit at. */
  position: fixed; left: var(--safe-x); bottom: var(--safe-y-b); z-index: 10;
  font: inherit; font-weight: 800; font-size: 0.95rem; color: var(--ink);
  background: var(--surface);
  border: 0; padding: 0.6rem 1.2rem; border-radius: var(--r-pill);
  box-shadow: inset 0 0 0 2px var(--edge);
  cursor: pointer;
}

/* ---- Reconnect card ---- a QR for a dropped player, centred in their own
   split-screen cell exactly like the FINISHED card. It REUSES .cell-finish for
   placement + chrome (absolute, translate(-50%,-50%), frosted card, livery
   top-border); _loop sets left/top and toggles display, and hands the card its
   cell's size as --cell-w/--cell-h.

   The QR is BIG: a phone has to read it from the couch, and a fixed ~9vw square
   was a postage stamp on a television. Half the screen's height for one
   player; a split cell gives it most of its height instead, because half of a
   half-screen cell was too small to scan and the cell has nothing better to
   show while its driver is gone. Sized off the CELL, not the viewport, so the
   card stays inside its own half or quarter. The width cap only bites on a
   portrait cell (one player on a phone held upright). The type scales with the
   QR so the card reads as one sticker at every size. */
.cell-reconnect {
  --qr: min(50vh, calc(var(--cell-h, 100vh) * 0.6), calc(var(--cell-w, 100vw) * 0.6));
  gap: calc(var(--qr) * 0.04);
}
.rc-card__title {
  font-family: var(--font-display); font-weight: 700;
  font-size: clamp(0.9rem, calc(var(--qr) * 0.08), 2.6rem); letter-spacing: 0.06em;
  text-transform: uppercase; color: var(--ink);
}
.rc-card__qr {
  display: block; width: var(--qr); aspect-ratio: 1 / 1;
  border-radius: var(--r-sm); image-rendering: pixelated;
}
