/* ============================================================
   Northwind RACE BOARD — Sports Broadcast
   Authored at literal 1920x1080 and scaled to the viewport, so the design is
   identical on a 55" TV, a laptop, and a screenshot. Never size anything to
   vw/vh — that would stretch the layout instead of scaling it.

   Geometry budget (must total 1080):
     header 96 + heat-dots 8 + 8 lanes x 110 + bus 96 = 1080

   The bus strip is what cost the racer lanes 12px each (122 -> 110). Names are
   unchanged at 25px; only the breathing room shrank. Do not add a tenth row
   without re-deriving this — an earlier draft at 62px put names at ~13px on the
   real panel, which is unreadable across a room.

   Self-contained: no CDN, no remote fonts, no external images. the CRM serves this
   under its own CSP and anything external gets blocked.
   ============================================================ */

/* Self-hosted brand fonts (OFL), copied from brand-kit/ds-bundle/fonts.
   latin subset only — the roster is ASCII. Note the brand kit ships a single
   400-weight file per family, so 600/700 are browser-synthesized bold; that is
   a brand-kit limitation, not a bug here. */
@font-face {
  font-family: 'Oswald'; font-style: normal; font-weight: 400 700;
  font-display: swap; src: url(assets/Oswald-400-latin.woff2) format('woff2');
}
@font-face {
  font-family: 'Roboto Mono'; font-style: normal; font-weight: 400 700;
  font-display: swap; src: url(assets/RobotoMono-400-latin.woff2) format('woff2');
}

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

:root {
  --hd-h: 96px;
  --dots-h: 8px;
  --lane-h: 110px;
  --bus-h: 96px;
  --gutter-w: 420px;
  --finish-w: 88px;

  --bg-0: #06070a;
  --bg-1: #0b0d11;
  --bg-2: #14181f;
  --ink: #e8edf4;
  --ink-dim: #8f98a6;
  --rule: #1e242e;
  --accent: #e10600;   /* broadcast red */
  --pace: #ffb400;     /* pace car / on-pace marker */

  --ease-move: cubic-bezier(.2, .7, .2, 1);
}

html, body {
  height: 100%;
  background: #000;
  color: var(--ink);
  font-family: Oswald, "Arial Narrow", system-ui, sans-serif;
  -webkit-font-smoothing: antialiased;
  overflow: hidden;              /* a TV never scrolls */
  cursor: none;                  /* no pointer on a wall display */
}
/* The board carries no on-screen controls: theme switching and Jordan's
   dumpster toggle live in the /control page (a second tab). The old
   cursor-reveal switcher and its mousemove listener are gone, so the wall
   stays `cursor: none` and inert. */

/* Dumpster fire — flame flicker. Each tongue flickers on its own timing so the
   fire looks alive. transform-origin is inline in viewBox units (the flame base)
   like the wheels; no transform-box. `.anim` lets reduced-motion still it. */
.dump-flame-a { animation: flicker .55s ease-in-out infinite; }
.dump-flame-b { animation: flicker .43s ease-in-out infinite .12s; }
.dump-flame-c { animation: flicker .67s ease-in-out infinite .21s; }
@keyframes flicker {
  0%, 100% { transform: scaleY(1) scaleX(1); }
  50%      { transform: scaleY(1.22) scaleX(.88); }
}

/* ---------- board shell ---------- */
.board {
  position: absolute; top: 50%; left: 50%;
  width: 1920px; height: 1080px;
  transform: translate(-50%, -50%) scale(var(--k, 1));
  transform-origin: center center;
  overflow: hidden;
  background:
    linear-gradient(180deg, rgba(255,255,255,.026), transparent 40%),
    radial-gradient(90% 140% at 12% 0%, var(--bg-2) 0%, var(--bg-1) 55%, var(--bg-0) 100%);
}

/* ---------- header ---------- */
.hd {
  display: flex; align-items: center;
  height: var(--hd-h); padding: 0 34px;
  border-bottom: 1px solid var(--rule);
  background: rgba(255,255,255,.014);
  position: relative; z-index: 20;
}
.hd > * + * { margin-left: 34px; }   /* flex `gap` is Chromium 84+ */
.hd-title { display: flex; align-items: baseline; }
.hd-title > * + * { margin-left: 16px; }
.hd-mark {
  font-size: 27px; font-weight: 700; letter-spacing: .2em;
  color: #fff; background: var(--accent);
  padding: 5px 14px; border-radius: 2px;
}
.hd-heat {
  font-size: 30px; font-weight: 600; letter-spacing: .12em; text-transform: uppercase;
  transition: opacity .28s;
}
.hd-rule { width: 1px; height: 40px; background: var(--rule); }
.hd-stat { display: flex; align-items: baseline; }
.hd-stat > * + * { margin-left: 12px; }
.hd-stat .lbl {
  font-size: 16px; letter-spacing: .15em; text-transform: uppercase; color: var(--ink-dim);
}
.hd-stat .val {
  font-family: "Roboto Mono", ui-monospace, monospace;
  font-size: 28px; font-weight: 700; color: #fff;
}
.hd-spacer { flex: 1; }
.hd-time { display: flex; flex-direction: column; align-items: flex-end; line-height: 1.04; }
.hd-clock {
  font-family: "Roboto Mono", ui-monospace, monospace;
  font-size: 24px; font-weight: 500; color: var(--ink-dim);
  font-variant-numeric: tabular-nums;
}
/* "Deals through …" — the feed's cutoff. Dim by default; amber past staleHours,
   which pairs with the prior-day weekday the JS prepends when it's not today. */
.hd-fresh {
  font-family: "Roboto Mono", ui-monospace, monospace;
  font-size: 12px; font-weight: 500; letter-spacing: .05em; text-transform: uppercase;
  color: #6a7280; margin-top: 3px; white-space: nowrap;
}
.hd-fresh.stale { color: var(--pace); }
.badge-mock {
  font-size: 15px; font-weight: 600; letter-spacing: .12em; text-transform: uppercase;
  color: var(--pace); border: 1px solid #6a4d00; border-radius: 2px;
  padding: 6px 12px;
}

/* Last week's winner — a callout, not a plain stat, so it reads as a highlight.
   Name + % of goal only; no dollar figure. */
.hd-winner {
  display: flex; flex-direction: column; align-items: flex-end;
  padding: 6px 16px; margin-right: 20px; border-radius: 5px;
  background: linear-gradient(90deg, transparent, rgba(255,180,0,.1));
  border-right: 3px solid var(--pace);
}
.hd-winner-lbl {
  font-size: 12px; font-weight: 600; letter-spacing: .14em; text-transform: uppercase;
  color: var(--ink-dim);
}
.hd-winner-val {
  display: flex; align-items: baseline;
  font-size: 22px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase;
  white-space: nowrap; color: var(--ink);
}
.hd-winner-val > * + * { margin-left: 8px; }   /* flex `gap` is Chromium 84+ */
.hd-trophy { font-size: 19px; }
.hd-winner-pct {
  font-family: "Roboto Mono", ui-monospace, monospace;
  font-size: 17px; font-weight: 700; color: var(--pace);
}

/* ---------- pace flag ----------
   Sits at the top of the amber pace line and names it. "Pace" = where you'd have
   to be right now to finish the window exactly on goal. */
.pace-flag {
  position: absolute; top: calc(var(--hd-h) + var(--dots-h) - 2px);
  z-index: 22; transform: translateX(-50%);
  transition: left 1.4s linear;
  pointer-events: none;
  display: flex; flex-direction: column; align-items: center;
}
.pace-flag span {
  font-size: 15px; font-weight: 700; letter-spacing: .18em; text-transform: uppercase;
  color: #1a1200; background: var(--pace);
  padding: 3px 10px; border-radius: 2px;
  box-shadow: 0 0 14px rgba(255,180,0,.5);
}
.pace-flag::after {
  content: ""; width: 0; height: 0;
  border-left: 5px solid transparent; border-right: 5px solid transparent;
  border-top: 6px solid var(--pace);
}

/* ---------- heat timer ---------- */
.heat-dots { height: var(--dots-h); display: flex; padding: 0 34px; align-items: center; }
.heat-dots i + i { margin-left: 3px; }
.heat-dots i {
  height: 3px; flex: 1; background: var(--rule); border-radius: 2px; position: relative; overflow: hidden;
}
.heat-dots i.on::after {
  content: ""; position: absolute; top: 0; right: 0; bottom: 0; left: 0;
  background: var(--accent);
  transform-origin: left center;
  animation: heat-fill linear forwards;
  animation-duration: var(--heat-ms, 20s);
}
@keyframes heat-fill { from { transform: scaleX(0); } to { transform: scaleX(1); } }

/* ---------- lanes ---------- */
.lanes { position: relative; z-index: 10; }

.lane {
  display: grid;
  grid-template-columns: var(--gutter-w) 1fr;
  align-items: center;
  height: var(--lane-h);
  border-bottom: 1px solid rgba(255,255,255,.05);
  position: relative;
}
.lane:last-child { border-bottom: 0; }

/* Rank ordering: lanes are built in a STABLE dom order and slide to their rank
   via --y (FLIP-style). Nothing is reordered in the DOM, so the rebuild-only-on-
   roster-change rule holds and rows glide instead of snapping.
   --y is (targetRank - domIndex) * lane-h, set in render(). */
.lane {
  transform: translateY(var(--y, 0px));
  transition: transform .75s cubic-bezier(.35, .9, .25, 1);
  will-change: transform;
}

/* The heat wipe animates the lane's CHILDREN, not the lane itself. Both would
   otherwise animate `transform` and the keyframes would clobber --y, snapping
   every row back to its dom position mid-wipe. */
.lane > .gutter, .lane > .track {
  animation: lane-in .42s var(--ease-move) both;
  animation-delay: var(--d, 0ms);
}
@keyframes lane-in {
  from { opacity: 0; transform: translateX(90px); }
  to   { opacity: 1; transform: translateX(0); }
}
.lanes.out .lane > .gutter, .lanes.out .lane > .track {
  animation: lane-out .3s var(--ease-move) both;
  animation-delay: var(--d, 0ms);
}
@keyframes lane-out {
  from { opacity: 1; transform: translateX(0); }
  to   { opacity: 0; transform: translateX(-70px); }
}

.gutter {
  display: flex; align-items: center;
  padding: 0 20px 0 28px; height: 100%;
  border-left: 6px solid var(--color);
  /* --tint-* are precomputed rgba() from JS: color-mix() is Chromium 111+ and
     absent on every TV browser, which would drop the tint entirely. */
  background: linear-gradient(90deg, var(--tint-13), transparent 88%);
  position: relative; z-index: 6;
}
.gutter > * + * { margin-left: 16px; }   /* flex `gap` is Chromium 84+ */
.lane.leader .gutter {
  background: linear-gradient(90deg, var(--tint-30), transparent 88%);
}
.chip {
  width: 54px; height: 54px; flex: none; border-radius: 3px;
  display: grid; place-items: center;
  font-family: "Roboto Mono", ui-monospace, monospace;
  font-size: 20px; font-weight: 700;
  color: #000; background: var(--color);
}
.who { min-width: 0; flex: 1; }
.who .nm {
  display: block; line-height: 1.12;
  font-size: 25px; font-weight: 600;
  letter-spacing: .05em; text-transform: uppercase;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.who .sub {
  display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  font-family: "Roboto Mono", ui-monospace, monospace;
  font-size: 17px; font-weight: 500; color: var(--ink-dim);
}
/* a goal we invented must never look like a goal they set */
.who .sub .tbd { color: var(--pace); opacity: .85; }
.crown { font-size: 30px; flex: none; margin-left: auto; padding-left: 8px; opacity: 0; transition: opacity .3s; }
.lane.leader .crown { opacity: 1; }

/* ---------- team bus ---------- */
/* The whole team as one vehicle, riding the same track against the same pace
   line and the same finish.

   It lives OUTSIDE .lanes deliberately. The team number is identical in both
   heats, so the bus must not wipe when buyers swap to AMs — it keeps rolling
   while the heats change above it, which is exactly the point it's making. That
   also keeps it out of the rank sort in render(): it isn't a person, it has no
   rank, and it must never slide.

   The grid MUST stay identical to .lane. If the columns drift, the bus's track
   starts at a different x than every lane above it and the pace line stops being
   one straight vertical — the same bug that already bit us once. */
.bus-lane {
  display: grid;
  grid-template-columns: var(--gutter-w) 1fr;
  align-items: center;
  height: var(--bus-h);
  position: relative; z-index: 11;
  /* reads as a totals row: a rule separating the team from the individuals */
  border-top: 2px solid rgba(255,255,255,.16);
}
.bus-lane .gutter { background: linear-gradient(90deg, var(--tint-13), transparent 88%); }
.bus-lane .who .nm { font-size: 26px; letter-spacing: .07em; }
/* "ALL" is 3 chars where initials are 2 — drop a point so it doesn't crowd */
.bus-lane .chip { font-size: 18px; letter-spacing: .02em; }

/* ---------- track ---------- */
.track { position: relative; height: 100%; overflow: hidden;
  background:
    repeating-linear-gradient(90deg, rgba(255,255,255,.05) 0 1px, transparent 1px 96px),
    linear-gradient(180deg, #0e1117, #090b0f);
}
.finish {
  position: absolute; top: 0; right: 0; bottom: 0; width: var(--finish-w); z-index: 3;
  pointer-events: none;
  background-color: var(--bg-2);
  /* SVG checker, not repeating-conic-gradient (Chromium 69+ / absent on
     older TV browsers, which would render a flat block, not a finish line) */
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='36' height='36'%3E%3Crect width='18' height='18' fill='%23e9eef5'/%3E%3Crect x='18' y='18' width='18' height='18' fill='%23e9eef5'/%3E%3C/svg%3E");
  background-size: 36px 36px;
  border-left: 2px solid rgba(255,255,255,.5);
  box-shadow: -24px 0 44px -18px rgba(255,255,255,.22);
}
.pace {
  position: absolute; top: 0; bottom: 0; width: 4px; z-index: 4;
  transform: translateX(-2px);
  transition: left 1.4s linear;
  pointer-events: none;
  background: linear-gradient(180deg, transparent, var(--pace) 18%, var(--pace) 82%, transparent);
  box-shadow: 0 0 16px rgba(255,180,0,.55);
}

/* The wrapper is exactly car-width: the pill floats off it absolutely, so
   translateX always positions the CAR body and never shifts when the pill
   flips sides or the number changes digits. */
.car-wrap {
  position: absolute; top: 50%; left: 0; z-index: 5;
  transform: translate(0, -50%);
  transition: transform 2.4s var(--ease-move);
  will-change: transform;
}
.car-inner { position: relative; display: block; }
.car { display: block; }
.pill {
  position: absolute; left: 100%; top: 50%;
  transform: translateY(-50%); margin-left: 12px;
  font-family: "Roboto Mono", ui-monospace, monospace;
  font-size: 21px; font-weight: 700; white-space: nowrap;
  padding: 3px 11px; border-radius: 3px;
  background: rgba(6,8,12,.82); color: #fff;
  border: 1px solid rgba(255,255,255,.14);
}
.flip .pill { left: auto; right: 100%; margin-left: 0; margin-right: 12px; }
.zero .pill { opacity: .38; }

/* The wheel's spin now lives with the other theme motion, in the THEMES section
   below — it belongs to the `cars` costume, not to the board's chrome. */

.puff {
  position: absolute; left: -10px; top: 50%; z-index: -1;
  width: 16px; height: 16px; border-radius: 50%; opacity: 0;
  background: rgba(255,255,255,.32); filter: blur(3px);
}
.moving .puff { animation: puff .55s ease-out 3; }
@keyframes puff {
  0%   { opacity: .75; transform: translate(0, -50%) scale(.4); }
  100% { opacity: 0;   transform: translate(-46px, -50%) scale(1.7); }
}
.streak {
  position: absolute; right: 100%; top: 50%; height: 3px; width: 0; opacity: 0; margin-right: 9px;
  background: linear-gradient(90deg, transparent, var(--color));
  filter: blur(1px);
  box-shadow: 0 8px 8px -5px var(--tint-55), 0 -8px 8px -5px var(--tint-55);
}
.moving .streak { animation: streak 2.4s var(--ease-move); }
@keyframes streak {
  0%   { width: 0;    opacity: 0; }
  22%  { width: 84px; opacity: .85; }
  100% { width: 0;    opacity: 0; }
}
.moving .car { filter: blur(.8px); }
.car-wrap.done .car { animation: pop .6s cubic-bezier(.16,1.2,.3,1); }
@keyframes pop {
  0%   { transform: scale(1) rotate(0deg); }
  40%  { transform: scale(1.14) rotate(-6deg); }
  100% { transform: scale(1) rotate(0deg); }
}

/* ---------- toast / confetti / error ---------- */
/* bottom by default; .top flips it clear of a bottom-half racer so the toast
   never sits on the car it's announcing */
.toast {
  position: absolute; right: 26px; bottom: 22px; z-index: 30;
  display: flex; align-items: center; padding: 14px 22px;
  background: rgba(10,13,18,.94); border-left: 5px solid var(--color, var(--accent));
  border-radius: 0 3px 3px 0; box-shadow: 0 12px 40px rgba(0,0,0,.6);
  opacity: 0; transform: translateY(10px);
  transition: opacity .3s, transform .3s;
  pointer-events: none;
}
.toast > * + * { margin-left: 14px; }
.toast.top { bottom: auto; top: 124px; transform: translateY(-10px); }
.toast.show { opacity: 1; transform: translateY(0); }
.toast .dot { width: 12px; height: 12px; border-radius: 50%; background: var(--color); flex: none; }
.toast .tx { font-size: 23px; font-weight: 600; letter-spacing: .04em; white-space: nowrap; }
.toast .tx b { font-family: "Roboto Mono", ui-monospace, monospace; font-weight: 700; }
/* Cancellation: a muted grey bar + dot instead of the racer's colour, so a
   deal falling through never wears the celebratory accent. The womp carries the
   rest. */
.toast.sad { border-left-color: #5a6472; }
.toast.sad .dot { background: #5a6472; }
.toast.sad .tx { color: var(--ink-dim); }
.toast.sad .tx b { color: var(--ink); }

.confetti { position: absolute; top: 0; right: 0; bottom: 0; left: 0; z-index: 25; pointer-events: none; overflow: hidden; }
.conf { position: absolute; width: 10px; height: 16px; top: -20px; animation: fall linear forwards; }
@keyframes fall { to { transform: translateY(1120px) rotate(620deg); opacity: 0; } }

/* ---------- win moment ----------
   Fires when a car crosses 100%. Sound is likely blocked on a TV's built-in
   browser (no way to pass Chrome's autoplay flag), so this has to land with the
   volume off. Transient: ~4.6s, then removed.
   Everything here is transform/opacity only — safe on old TV engines. */
.win {
  position: absolute; top: 0; right: 0; bottom: 0; left: 0;
  z-index: 28; pointer-events: none;
  display: flex; align-items: center; justify-content: center;
}
/* REQUIRED. `display: flex` above is an author style and beats the UA's
   [hidden] { display: none }, so without this the overlay is permanently
   displayed, its animations run once on page load (when nobody has won) and
   never replay — toggling .hidden wouldn't change display, and CSS animations
   only restart when display does. This one line is what makes celebrate() work. */
.win[hidden] { display: none; }
.win-scrim {
  position: absolute; top: 0; right: 0; bottom: 0; left: 0;
  background: radial-gradient(60% 60% at 50% 50%, rgba(6,8,12,.72), rgba(6,8,12,.9));
  opacity: 0; animation: scrim-in 4.6s ease both;
}
@keyframes scrim-in {
  0% { opacity: 0; } 10% { opacity: 1; } 82% { opacity: 1; } 100% { opacity: 0; }
}

.win-row { position: relative; display: flex; align-items: center; }
.win-row > * + * { margin-left: 34px; }

/* ONE animation per element covering in -> hold -> out.
   Do NOT stack two animations that both touch opacity: with fill-mode `both`,
   the later one's backwards fill pins opacity to the element's base value (0)
   for the whole delay, so the entrance is cancelled and the card renders as a
   ghost. One timeline, no conflict. */
.win-flag { width: 190px; height: 238px; opacity: 0; }
.win-flag svg { width: 100%; height: 100%; display: block; }
.win-flag-l { animation: flag-l 4.6s cubic-bezier(.16,1.2,.3,1) both; }
.win-flag-r { animation: flag-r 4.6s cubic-bezier(.16,1.2,.3,1) both; }
@keyframes flag-l {
  0%   { opacity: 0; transform: translateX(-70px) rotate(-16deg); }
  11%  { opacity: 1; transform: translateX(0) rotate(-7deg); }
  88%  { opacity: 1; transform: translateX(0) rotate(-7deg); }
  100% { opacity: 0; transform: translateX(-24px) rotate(-11deg); }
}
@keyframes flag-r {
  0%   { opacity: 0; transform: translateX(70px) rotate(16deg); }
  11%  { opacity: 1; transform: translateX(0) rotate(7deg); }
  88%  { opacity: 1; transform: translateX(0) rotate(7deg); }
  100% { opacity: 0; transform: translateX(24px) rotate(11deg); }
}
/* the cloth waves: skew + squash, offset per flag so they don't move in lockstep */
.win-cloth { animation: wave .85s ease-in-out infinite; }
.win-flag-r .win-cloth { animation-delay: .18s; }
@keyframes wave {
  0%, 100% { transform: skewY(0deg)  scaleY(1);    }
  30%      { transform: skewY(-4.5deg) scaleY(.965); }
  65%      { transform: skewY(3.5deg)  scaleY(1.03); }
}

.win-card {
  position: relative; text-align: center;
  padding: 26px 44px;
  border: 3px solid var(--win-color, var(--pace));
  border-radius: 6px;
  background: rgba(8,10,14,.92);
  box-shadow: 0 24px 70px rgba(0,0,0,.75);
  opacity: 0;
  animation: win-card 4.6s cubic-bezier(.16,1.2,.3,1) both;
}
.win-name {
  display: block;
  font-size: 76px; font-weight: 700; letter-spacing: .04em;
  text-transform: uppercase; line-height: 1.04; color: #fff;
  white-space: nowrap;
}
.win-sub {
  display: block; margin-top: 6px;
  font-size: 30px; font-weight: 600; letter-spacing: .38em;
  text-transform: uppercase; color: var(--win-color, var(--pace));
}
@keyframes win-card {
  0%   { opacity: 0; transform: scale(.82) translateY(14px); }
  11%  { opacity: 1; transform: scale(1) translateY(0); }
  88%  { opacity: 1; transform: scale(1) translateY(0); }
  100% { opacity: 0; transform: scale(.96) translateY(-6px); }
}

.err {
  position: absolute; left: 34px; bottom: 22px; z-index: 30;
  font-size: 18px; color: var(--accent); letter-spacing: .06em;
}

/* Shown only if the browser refused to play the fanfare (no user gesture yet).
   On a properly-launched kiosk this never appears — see HANDOFF. */
.sound-hint {
  position: absolute; left: 34px; bottom: 52px; z-index: 30;
  font-size: 17px; color: var(--pace); letter-spacing: .06em;
  border: 1px solid #6a4d00; border-radius: 2px; padding: 5px 11px;
}

/* ============================================================
   THEMES
   Each week's costume. Scoped by the id race.js resolves and writes to #board
   (NOT <body> — .board is a child of body, so an attribute there would never
   match these selectors).

   Theme CSS lives HERE, in the real stylesheet — never injected as a <style>
   tag, which a strict `style-src 'self'` would block exactly the way it blocks
   inline <script>. Same trap, different attribute.

   The art itself is in themes.js; this is only its motion and its track.
   ============================================================ */

/* ---------------- theme: cars (built into race.js, the fallback) ----------
   NOT scoped to [data-theme="cars"] on purpose: `cars` is also what every
   failed theme resolution degrades to, and this rule is the one piece of theme
   motion that must work even if #board's attribute never got written.

   NO transform-box here. Each wheel sets transform-origin inline in viewBox user
   units (e.g. "18px 20px"), which is what the default `view-box` box resolves
   against. `fill-box` would resolve those same numbers against the wheel's own
   bounding box — the origin lands outside the wheel and it ORBITS instead of
   spinning (measured: 59px x 64px of drift). It's Chromium 64+ regardless. */
.wheel { animation: spin 1.15s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }

/* ---------------- theme: sharks ------------------------------------------- */
/* Deep water. The lane's vertical grid survives as a faint current.
   Kept dark on purpose: the % pill is white-on-near-black and the finish line
   glows white — a bright track skin destroys both. Test every new skin against
   the pill and the amber pace line, not just against the character. */
.board[data-theme="sharks"] .track {
  background:
    repeating-linear-gradient(90deg, rgba(120,190,225,.06) 0 1px, transparent 1px 96px),
    linear-gradient(180deg, #0a1a24, #040d14);
}
/* Tail sweep. transform-origin is set INLINE on the <g> in viewBox user units
   (see themes.js) — do not add transform-box here, it would make the tail orbit
   instead of swish, the same way it broke the wheels. */
.board[data-theme="sharks"] .fin-tail {
  animation: swish 1.05s ease-in-out infinite;
}
@keyframes swish {
  0%, 100% { transform: rotate(-11deg); }
  50%      { transform: rotate(11deg); }
}
.board[data-theme="sharks"] .fin-fluke {
  animation: swish 1.9s ease-in-out infinite;   /* a whale is unhurried */
}
.board[data-theme="sharks"] .spout {
  animation: spout 2.6s ease-out infinite;
  transform-origin: 57px 8px;
}
@keyframes spout {
  0%, 62%   { opacity: 0; transform: scaleY(.35); }
  72%       { opacity: .95; transform: scaleY(1.1); }
  100%      { opacity: 0; transform: scaleY(1.35); }
}
/* A shark leaves a bubble, not an exhaust puff. Same element, restyled — the
   name is car-flavoured but the idea (a rear-emitted motion particle) is not. */
.board[data-theme="sharks"] .puff {
  background: rgba(190,230,255,.5);
  box-shadow: inset 0 0 0 1px rgba(255,255,255,.35);
}

/* ---------------- theme: swimmers ----------------------------------------- */
/* A pool, read from UNDER the water.
   This is the theme that proves the skin rule. A pool wants to be bright, and
   bright is what the board cannot take — the % pill is white-on-near-black and
   .finish sells itself with a white glow (see .finish's box-shadow). A literal
   pale-blue pool erases both. So: deep water, with the lane ropes and the tiled
   floor doing the "this is a pool" work instead of the brightness. */
.board[data-theme="swimmers"] .track {
  background:
    /* lane rope: the dashed float line every pool has */
    repeating-linear-gradient(90deg, rgba(255,255,255,.16) 0 10px, transparent 10px 22px),
    /* pool tile grid */
    repeating-linear-gradient(90deg, rgba(150,220,255,.07) 0 1px, transparent 1px 96px),
    linear-gradient(180deg, #0b2740, #061626);
  background-size: 22px 3px, auto, auto;
  background-position: 0 6px, 0 0, 0 0;
  background-repeat: repeat-x, repeat, no-repeat;
}
.board[data-theme="swimmers"] .legs {
  animation: kick .42s ease-in-out infinite;   /* a flutter kick is fast */
}
@keyframes kick {
  0%, 100% { transform: rotate(-7deg); }
  50%      { transform: rotate(7deg); }
}
.board[data-theme="swimmers"] .arm-back {
  animation: stroke-arm 1.25s ease-in-out infinite;
}
@keyframes stroke-arm {
  0%, 100% { transform: rotate(0deg); }
  50%      { transform: rotate(-26deg); }
}
.board[data-theme="swimmers"] .oars {
  animation: row 1.9s ease-in-out infinite;
}
@keyframes row {
  0%, 100% { transform: rotate(-6deg); }
  50%      { transform: rotate(9deg); }
}
/* a swimmer's wake, not an exhaust puff */
.board[data-theme="swimmers"] .puff {
  background: rgba(220,245,255,.55);
}

/* ---------------- theme: runners ------------------------------------------ */
/* A running track: warm lane surface with the white lane stripes every track
   has. Kept muted, not the real orange-red of a track, so the white pill and
   the amber pace line survive (same rule as swimmers). */
.board[data-theme="runners"] .track {
  background:
    repeating-linear-gradient(90deg, rgba(255,255,255,.10) 0 2px, transparent 2px 96px),
    linear-gradient(180deg, #2a1410, #180b09);
}
/* The run cycle IS opposite arm/leg swing: front leg + back arm drive together,
   back leg + front arm recover together. Two phases, 180deg apart. transform-
   origin is set inline on each group in viewBox units (see themes.js) — no
   transform-box, or the limbs orbit instead of pivoting. */
.board[data-theme="runners"] .leg-f,
.board[data-theme="runners"] .arm-b { animation: stride .46s ease-in-out infinite; }
.board[data-theme="runners"] .leg-b,
.board[data-theme="runners"] .arm-f { animation: stride .46s ease-in-out infinite reverse; }
@keyframes stride {
  0%, 100% { transform: rotate(-24deg); }
  50%      { transform: rotate(24deg); }
}
.board[data-theme="runners"] .wave { animation: wave 1.1s ease-in-out infinite; }
@keyframes wave {
  0%, 100% { transform: rotate(-10deg); }
  50%      { transform: rotate(16deg); }
}

/* ---------------- theme: bricks ------------------------------------------- */
/* A studded baseplate. Radial dots are the studs; kept dark so the pill and the
   amber pace line survive. */
.board[data-theme="bricks"] .track {
  background:
    radial-gradient(circle at 12px 12px, rgba(150,190,235,.14) 2.6px, transparent 3px) 0 0 / 24px 24px,
    linear-gradient(180deg, #10161f, #0a0e14);
}
/* A minifig's stiff little swing: opposite arm/leg, small angle (they're rigid). */
.board[data-theme="bricks"] .leg-l,
.board[data-theme="bricks"] .arm-r { animation: brickstep .52s ease-in-out infinite; }
.board[data-theme="bricks"] .leg-r,
.board[data-theme="bricks"] .arm-l { animation: brickstep .52s ease-in-out infinite reverse; }
@keyframes brickstep {
  0%, 100% { transform: rotate(-13deg); }
  50%      { transform: rotate(13deg); }
}

/* ---------------- theme: plumbers ----------------------------------------- */
/* Underground brick: the sidescroller-dungeon look. Faint brick courses, dark
   enough for the chrome. */
.board[data-theme="plumbers"] .track {
  background:
    repeating-linear-gradient(0deg, rgba(120,90,70,.14) 0 1px, transparent 1px 15px),
    repeating-linear-gradient(90deg, rgba(120,90,70,.14) 0 1px, transparent 1px 30px),
    linear-gradient(180deg, #17110d, #0d0a07);
}
.board[data-theme="plumbers"] .leg-f,
.board[data-theme="plumbers"] .arm-b { animation: stride .5s ease-in-out infinite; }
.board[data-theme="plumbers"] .leg-b,
.board[data-theme="plumbers"] .arm-f { animation: stride .5s ease-in-out infinite reverse; }
/* pipe crew bob, staggered so they don't move as one block */
.board[data-theme="plumbers"] .pop-a { animation: pop 1.4s ease-in-out infinite; }
.board[data-theme="plumbers"] .pop-b { animation: pop 1.4s ease-in-out .7s infinite; }
@keyframes pop {
  0%, 100% { transform: translateY(3px); }
  50%      { transform: translateY(-3px); }
}

/* ---------------- theme: animals (savanna) -------------------------------- */
.board[data-theme="animals"] .track {
  background:
    repeating-linear-gradient(88deg, rgba(180,140,60,.10) 0 2px, transparent 2px 26px),
    linear-gradient(180deg, #2a2110, #17110a);
}
.board[data-theme="animals"] .legs { animation: gallop .34s ease-in-out infinite; }
.board[data-theme="animals"] .legs:nth-of-type(2) { animation-direction: reverse; }
@keyframes gallop { 0%,100% { transform: rotate(-16deg); } 50% { transform: rotate(16deg); } }

/* ---------------- theme: rockets (deep space) ----------------------------- */
.board[data-theme="rockets"] .track {
  background:
    radial-gradient(circle at 20% 30%, rgba(255,255,255,.5) .6px, transparent 1.2px) 0 0 / 70px 55px,
    radial-gradient(circle at 65% 70%, rgba(180,210,255,.5) .6px, transparent 1.2px) 0 0 / 90px 80px,
    radial-gradient(circle at 40% 85%, rgba(255,255,255,.35) .5px, transparent 1px) 0 0 / 55px 60px,
    linear-gradient(180deg, #0a0b1a, #05060d);
}
.board[data-theme="rockets"] .rk-flame { animation: flicker .18s ease-in-out infinite; }

/* ---------------- theme: ufos (alien sky) --------------------------------- */
.board[data-theme="ufos"] .track {
  background:
    radial-gradient(circle at 30% 40%, rgba(120,230,180,.5) .6px, transparent 1.2px) 0 0 / 80px 70px,
    radial-gradient(ellipse 60% 40% at 70% 20%, rgba(120,90,200,.16), transparent),
    linear-gradient(180deg, #0e1e18, #0a0a16);
}
.board[data-theme="ufos"] .ufo-lights { animation: blink .9s steps(1) infinite; }
@keyframes blink { 0%,100% { opacity: 1; } 50% { opacity: .35; } }

/* ---------------- theme: trains (railway) --------------------------------- */
/* Sleepers rush past under a steel rail; wheels reuse the global spin. */
.board[data-theme="trains"] .track {
  background:
    repeating-linear-gradient(90deg, rgba(150,120,90,.22) 0 8px, transparent 8px 34px) 0 78% / auto 10px,
    linear-gradient(180deg, rgba(120,130,140,.14), transparent 30%),
    linear-gradient(180deg, #1a1c1f, #101113);
  background-repeat: repeat-x, repeat, repeat;
}
.board[data-theme="trains"] .steam { animation: steam-rise 1.3s ease-out infinite; }
@keyframes steam-rise {
  0%   { transform: translate(0,0) scale(.7); opacity: .6; }
  100% { transform: translate(-8px,-9px) scale(1.5); opacity: 0; }
}

/* ---------------- theme: boats (ocean) ------------------------------------ */
.board[data-theme="boats"] .track {
  background:
    repeating-linear-gradient(90deg, rgba(120,190,235,.06) 0 40px, rgba(90,160,210,.10) 40px 80px),
    linear-gradient(180deg, #0a2438, #05131f);
}
.board[data-theme="boats"] .wake { animation: wake .8s ease-out infinite; }
@keyframes wake { 0% { opacity: .7; transform: translateX(0); } 100% { opacity: 0; transform: translateX(-10px); } }

/* ---------------- theme: coins (vault) ------------------------------------ */
.board[data-theme="coins"] .track {
  background:
    repeating-linear-gradient(90deg, rgba(210,170,60,.05) 0 1px, transparent 1px 96px),
    radial-gradient(ellipse 80% 60% at 50% 120%, rgba(200,160,50,.14), transparent),
    linear-gradient(180deg, #1c1608, #0d0a04);
}
/* the coin spins on its vertical axis — but only to ~40% width, never to a
   sliver, so it always still reads as a coin at TV distance. */
.board[data-theme="coins"] .coin { animation: coinspin 1.5s ease-in-out infinite; }
@keyframes coinspin { 0%,100% { transform: scaleX(1); } 50% { transform: scaleX(.42); } }

/* Every theme's moving part carries `.anim`, so this stays ONE rule instead of a
   list each new theme must remember to extend.

   `!important` is load-bearing, not sloppiness. A media query adds NO
   specificity, so plain `.anim { animation: none }` is (0,1,0) and would LOSE to
   a theme's own `.board[data-theme="x"] .anim { animation: ... }` at (0,3,0),
   regardless of source order — the kill would silently fail for exactly the
   themes it exists to cover. (Today's `.wheel` rule only works by source-order
   luck: both selectors happen to be (0,1,0).)

   Do NOT widen this to `.board * { animation: none !important }`. The win card
   and flags have base `opacity: 0` and rely on `animation ... both` to become
   visible — a blanket kill renders the whole celebration invisible for 4.6s.

   Calibration: prefers-reduced-motion is Chromium 74+ and the TV panels are
   ~53-85, so on much of the fleet this block is simply dropped and motion stays
   on — which is what a wall display wants. This is a dev/accessibility story,
   not a TV story. Never invert it to `no-preference`: on Chromium <74 that block
   would also drop, silently deleting all theme animation on the oldest panels. */
@media (prefers-reduced-motion: reduce) {
  .car-wrap, .pace { transition: none; }
  .lane { animation: none; }
  .anim, .moving .puff, .moving .streak { animation: none !important; }
}
