/* =========================================================================
   Break room boards — v4, dark minimal.

   Two rules this file holds to:

   1. CRISPNESS. The board is authored at 1920x1080 and fitted to the screen
      with `zoom`, not `transform: scale()`. Transform rasterises the layer
      at the authored size and then stretches the bitmap — on a 4K panel
      that is a 1080p image blown up, which is exactly the softness you see.
      `zoom` changes the computed layout instead, so every glyph, border and
      arc is re-rendered at the panel's real pixel size. Vector all the way
      down. Fonts are the system UI faces for the same reason: natively
      hinted, no webfont round trip, no swap flash.

   2. STILLNESS. Only the ticker, the LIVE dot and the launch clock move.
      Everything else holds. Data moves; the interface does not.
   ========================================================================= */

:root{
  --bg:#000000;
  --card:#000000;
  --strip:#000000;
  /* Borders are set for the panel this runs on, not for a designer's monitor.
     The N5300 is a 1080p IPS with a low native contrast ratio, so its black
     is not black — there is a backlight floor, and a lit break room reflects
     more on top. Measured against that floor, the old 7% hairline came out at
     1.39:1 against the background: a smudge rather than an edge. 16% puts it
     at 2.4:1, which holds up across the room. On a dark panel this would look
     heavy; on this one it looks like a drawn line. */
  --line:rgba(255,255,255,.16);
  --line-soft:rgba(255,255,255,.13);

  --brand:#ff453a;        /* the park name */
  --good:#30d158;
  --bad:#ff453a;
  --gold:#e6c65c;         /* first place */

  --ink:#ffffff;
  --ink-80:rgba(255,255,255,.8);
  --ink-60:rgba(255,255,255,.6);
  --ink-55:rgba(255,255,255,.55);
  --ink-50:rgba(255,255,255,.5);
  --ink-45:rgba(255,255,255,.45);
  --ink-35:rgba(255,255,255,.35);
  --ink-30:rgba(255,255,255,.3);
}

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

html,body{
  width:100%; height:100%; overflow:hidden;
  background:var(--bg);
  color:var(--ink);
  /* Segoe UI Variable Display is Windows 11's face for large type and is
     the sharpest thing available on the mini PC. The rest is fallback. */
  font-family:"Segoe UI Variable Display","Segoe UI",-apple-system,
              "Helvetica Neue","Liberation Sans",Arial,sans-serif;
  /* Deliberately NOT antialiased: on Windows that thins glyphs and throws
     away subpixel rendering, which is the opposite of what a TV wants. */
  font-synthesis-weight:none;
  display:grid; place-items:center;
}

/* Digits must not reflow as they change. */
.num{ font-variant-numeric:tabular-nums; font-feature-settings:"tnum" 1 }

/* ---------------------------------------------------------- keyframes -- */

@keyframes blink   { 0%,100%{opacity:1} 50%{opacity:.25} }
@keyframes barGrow { from{transform:scaleX(0)} to{transform:scaleX(1)} }
@keyframes ticker  { from{transform:translate3d(0,0,0)} to{transform:translate3d(-50%,0,0)} }
@keyframes softPulse { 0%,100%{opacity:1} 50%{opacity:.5} }

body.no-anim *,
body.no-anim *::before,
body.no-anim *::after{ animation:none !important }

@media (prefers-reduced-motion:reduce){
  *{ animation-duration:.001ms !important; animation-iteration-count:1 !important }
}

/* -------------------------------------------------------------- stage -- */
/* zoom is set from board.js; the grid centring on <body> accounts for the
   zoomed box, so nothing has to be positioned by hand. */

#stage{ width:1920px; height:1080px; position:relative }

.board{ position:absolute; inset:0; background:var(--bg); overflow:hidden }

/* burn-in drift: discrete steps from board.js, not a running animation */
.drift{ position:absolute; inset:0; transition:transform 1.2s ease-in-out }

/* ------------------------------------------------------------- header -- */

.hdr{
  position:relative; display:flex; align-items:baseline;
  justify-content:space-between; padding:44px 80px 0;
}
.park{ font-size:20px; font-weight:600; color:var(--brand); letter-spacing:.14em; text-transform:uppercase }
.title{ font-size:56px; font-weight:600; color:var(--ink); letter-spacing:-.01em; margin-top:4px }

/* --------------------------------------------------------- wordmarks -- */
/* Both titles are text. The faces ship with the board (public/fonts, SIL
   Open Font License — free to embed and self-host), so they render as vector
   text at the panel's native size, cost nothing to paint, and do not depend
   on what Windows happens to have installed. Barlow for the track title,
   Titillium Web for the CSR title; both are technical/motorsport faces rather
   than the default poster black. */
@font-face{ font-family:"Barlow"; font-weight:800; font-style:italic; font-display:swap;
  src:url(fonts/barlow-latin-800-italic.woff2) format("woff2") }
@font-face{ font-family:"Barlow"; font-weight:300; font-style:italic; font-display:swap;
  src:url(fonts/barlow-latin-300-italic.woff2) format("woff2") }
@font-face{ font-family:"Titillium Web"; font-weight:700; font-display:swap;
  src:url(fonts/titillium-web-latin-700-normal.woff2) format("woff2") }
@font-face{ font-family:"Titillium Web"; font-weight:200; font-display:swap;
  src:url(fonts/titillium-web-latin-200-normal.woff2) format("woff2") }

.wm{ line-height:1; white-space:nowrap; margin-top:2px }

/* ---- Track Crew: Barlow ExtraBold italic cut from a flag, CREW light ---- */
/* A true italic, so no skew. The weight contrast — heavy flag, hairline-thin
   CREW — is what makes it read as designed rather than shouted. */
.wm-track{
  font-family:"Barlow", "Segoe UI", Arial, sans-serif;
  font-weight:800; font-style:italic;
  font-size:140px; letter-spacing:-.02em;
  margin-left:-.05em;          /* the T's overhang, back onto the eyebrow's edge */
}
/* TRACK is cut out of a checkered flag: big squares you can count, a 1px
   hairline holding the silhouette, and two layers over the checker — a
   diagonal light across the fabric and two soft fold shadows — so it reads
   as a flag with a light on it rather than a tiled pattern. paint-order puts
   the hairline under the fill so the counters stay open. */
.wm-track .w-flag{
  -webkit-background-clip:text; background-clip:text; color:transparent;
  background-image:
    linear-gradient(104deg, rgba(255,255,255,0) 30%, rgba(255,255,255,.26) 45%, rgba(255,255,255,0) 58%),
    linear-gradient(98deg, rgba(0,0,0,0) 12%, rgba(0,0,0,.20) 24%, rgba(0,0,0,0) 34%,
                            rgba(0,0,0,0) 62%, rgba(0,0,0,.18) 72%, rgba(0,0,0,0) 82%),
    repeating-conic-gradient(#f6f6f8 0% 25%, #0d0d11 0% 50%);
  background-size:100% 100%, 100% 100%, 94px 94px;
  background-position:0 0, 0 0, 13px 0;
  -webkit-text-stroke:1px rgba(255,255,255,.75); paint-order:stroke fill;
}
.wm-track .w-solid{ color:#fff; font-weight:300 }

/* ---- CSR Leaderboard: heavy gold CSR, extra-light letterspaced LEADERBOARD ---- */
.wm-csr{
  position:relative; display:inline-block;
  font-family:"Titillium Web", "Segoe UI", Arial, sans-serif;
  font-weight:700; font-size:118px; letter-spacing:.005em;
}
/* Flat. A gradient on gold type is what makes it look like a cheap trophy;
   one honest colour reads as metal. Muted gold, chosen from six on the panel. */
.wm-csr .w-gold{ color:#d3b76a }
.wm-csr .w-white{ color:var(--ink); font-weight:200; letter-spacing:.06em; margin-left:.18em }
.wm-csr .goldrule{ display:none }

/* The eyebrow gives up a little room to the big title on both boards. */
.park{ font-size:15px; letter-spacing:.17em }

.hdr .rt{ text-align:right }
.live{ display:flex; align-items:center; justify-content:flex-end; gap:12px }
.live i{ width:10px; height:10px; border-radius:50%; background:var(--good); display:block }
.live.on i{ animation:blink 2.4s ease-in-out infinite }
.live span{ font-size:21px; font-weight:600; color:var(--good); letter-spacing:.08em }
.live.stale i{ background:var(--bad); animation:softPulse 1.4s ease-in-out infinite }
.live.stale span{ color:var(--bad) }
.sub{ font-size:24px; font-weight:400; color:var(--ink-60); margin-top:6px }

/* --------------------------------------------------------------- card -- */

.card{
  border:1px solid var(--line);
  border-radius:14px;
  background:var(--card);
  display:flex; flex-direction:column;
}
.card .hd{
  font-size:19px; font-weight:600; color:var(--ink-50);
  letter-spacing:.1em; text-transform:uppercase;
}

/* --------------------------------------------------------- track board -- */

.track-body{
  /* top was 172, sized for a 64px wordmark. The 128px one reaches to y=211,
     so the cards had to come down or the letters sat on the first card. */
  position:absolute; left:0; right:0; top:208px; bottom:110px;
  padding:24px 80px 28px;
  display:grid; grid-template-columns:1.15fr 1fr 1fr; gap:28px; align-items:stretch;
}
.track-body .card{ padding:26px 32px }

.ring{ position:relative; width:400px; height:400px; margin:auto }
.ring svg{ position:absolute; inset:0 }
.ring .val{ position:absolute; inset:0; display:flex; align-items:center; justify-content:center }
.ring .val .n{
  font-size:120px; font-weight:200; color:var(--ink);
  letter-spacing:-.02em; white-space:nowrap; line-height:1;
}
.ring .val .n u{
  font-size:34px; font-weight:500; color:var(--ink-55);
  letter-spacing:.1em; margin-left:10px; text-decoration:none;
}

.ringfoot{ text-align:center; padding-top:2px }
.ringfoot .sub2{ font-size:21px; font-weight:400; color:var(--ink-55); letter-spacing:.1em; text-transform:uppercase }
.ringfoot .delta{ font-size:23px; font-weight:600; margin-top:6px; color:var(--state,var(--good)) }

.bignum{ flex:1; display:flex; flex-direction:column; align-items:center; justify-content:center }
.bignum .n{ font-size:190px; font-weight:200; color:var(--ink); letter-spacing:-.02em; line-height:1 }
.bignum .n.small{ font-size:150px }
.bignum .lab{
  font-size:22px; font-weight:400; color:var(--ink-55);
  letter-spacing:.12em; margin-top:12px; text-transform:uppercase;
}

.pips{ display:flex; gap:10px; justify-content:center }
.pips i{ width:34px; height:10px; border-radius:5px; background:rgba(255,255,255,.12); display:block }
.pips i.on{ background:rgba(255,255,255,.85) }

.splits{ display:flex; gap:32px; margin-top:36px }
.splits .s{ text-align:center }
.splits .s .n{ font-size:44px; font-weight:400 }
.splits .s .k{ font-size:18px; font-weight:500; color:var(--ink-45); letter-spacing:.1em; text-transform:uppercase; margin-top:4px }
.splits .div{ width:1px; background:rgba(255,255,255,.1) }

/* the one moment worth emphasising: the clock running between heats */
.launch-live .n{ color:var(--state,var(--good)) }
.launch-live .lab{ color:var(--state,var(--good)) }

/* --------------------------------------------------------- CSR board -- */

.csr-body{
  /* top was 172, sized for a 56px heading. The 108px title plus its gold rule
     reaches y=188; 220 leaves a ~32px gap, matching the track board's rhythm. */
  position:absolute; left:0; right:0; top:220px; bottom:76px;
  padding:0 80px 24px;
  display:flex; flex-direction:column; gap:26px;
}
.band{ display:flex; flex-direction:column; gap:14px; min-height:0 }
.band.month{ flex:1 1 auto }

.bandlabel{ display:flex; align-items:center; gap:18px }
.bandlabel .t{ font-size:19px; font-weight:600; color:var(--ink); letter-spacing:.12em; text-transform:uppercase; white-space:nowrap }
.bandlabel .ln{ flex:1; height:1px; background:var(--line) }
.bandlabel .dt{ font-size:19px; font-weight:400; color:var(--ink-45); letter-spacing:.08em; white-space:nowrap }

/* Two metric columns. Driven by COLUMNS in board.js — if a column is added
   back, this count changes with it and nothing else does.

   Full width, not capped and centred. Capping was tried and abandoned: it
   pulled the cards off the left gutter that the page title and the THIS WEEK /
   THIS MONTH rules align to, and a visible misalignment is a worse regression
   than the wider name-to-value gap it fixed. Two cards at ~866px keep every
   alignment relationship the three-column version had. */
.cards{ display:grid; grid-template-columns:1fr 1fr; gap:28px }
.band.month .cards{ flex:1; min-height:0 }
.cards .card{ padding:20px 28px 22px }
.band.month .cards .card{ padding:20px 28px 24px }

/* The standings box is a stack of slots (see the CSR section of board.js).
   Rows are absolutely positioned inside it and moved with transforms, so the
   list never re-lays-out. Its height is the visible area; below that is a
   runway the same depth as the card's bottom padding, pulled into the padding
   with a negative margin so the card itself is the same size as before. The
   box clips at the bottom of the runway, and a mask shades the runway, so a
   row coming up from under the list rises out of the dark instead of
   appearing — that shading is the only thing the runway is for, and at rest
   it touches nothing, because no row ever sits in it. */
.rows{
  --runway:22px;
  position:relative;
  margin-top:16px;
  height:calc(115px + var(--runway));      /* border-box: 115 visible + runway */
  padding-bottom:var(--runway);
  margin-bottom:calc(-1 * var(--runway));
  overflow:hidden;
  -webkit-mask-image:linear-gradient(to bottom, #000 calc(100% - var(--runway)), rgba(0,0,0,0) 100%);
  mask-image:linear-gradient(to bottom, #000 calc(100% - var(--runway)), rgba(0,0,0,0) 100%);
}
.band.month .rows{ --runway:24px; flex:1; height:auto; margin-top:18px }

.row{
  position:absolute; left:0; right:0; top:0; height:34px;   /* SLOT_H in board.js */
  display:flex; align-items:center; gap:14px;
  will-change:transform;
}
.row .pos{ font-size:22px; font-weight:600; color:var(--ink-35); width:34px;
  transition:color .5s ease, font-size .5s ease, width .5s ease }
.row.lead .pos{ font-size:24px; font-weight:700; color:var(--gold) }
.row.dim .pos{ color:var(--ink-30); font-size:20px; width:30px }
.row .bd{ flex:1; min-width:0 }
.row .ln{ display:flex; justify-content:space-between; gap:14px; font-size:24px; font-weight:400; color:var(--ink-80);
  transition:color .5s ease, font-size .5s ease }
.row.lead .ln{ font-size:27px; font-weight:600; color:var(--gold) }
.row.dim .ln{ font-size:22px; color:var(--ink-55) }
.row .nm{ overflow:hidden; text-overflow:ellipsis; white-space:nowrap }

.trk{ height:5px; background:var(--line-soft); border-radius:3px; margin-top:7px; overflow:hidden }
.row.lead .trk{ height:6px }
.bar{
  height:100%; border-radius:3px; background:rgba(255,255,255,.45);
  transform-origin:left;
  transition:width .7s cubic-bezier(.2,.9,.3,1);
}
.row.lead .bar{ background:var(--ink) }
.bar.first{ animation:barGrow 1.2s cubic-bezier(.2,.9,.3,1) both }

/* the month band is a standings list, not a race — no bars */
/* Bars are off in both bands now (see buildBand in board.js). The rules
   above are kept because turning them back on is one argument, but this
   guard stays so a stray `true` can never reintroduce them here. */
.band.month .trk{ display:none }

/* --------------------------------------------------------- bottom strips -- */

.strip{
  position:absolute; left:0; right:0; bottom:0;
  background:var(--strip); border-top:1px solid var(--line);
  display:flex; align-items:center;
}
.strip.csr{ height:76px; overflow:hidden }
.strip.track{ height:110px; gap:24px; padding:0 80px }

.striphd{
  font-size:19px; font-weight:600; color:var(--ink-50);
  letter-spacing:.1em; text-transform:uppercase; white-space:nowrap;
}

/* ---- the footer: one take at a time ---- */
/* It has been two things before this and both were wrong. Four grey sentences
   on a loop is something you read once and stop seeing. Label / name / number
   entries fixed the styling and broke the point — they read the board back to
   a room that is already looking at the board.

   So: short written takes, one at a time, each held long enough to land. They
   are computed (buildTakes in board.js) from gaps, totals and what has moved
   since the board came up — the things the standings above do NOT say.

   A rotator rather than a marquee, which also kills the glitch: the old lane
   was rebuilt whenever any figure changed, and rebuilding restarted the CSS
   animation, so the whole strip jumped back to the start every few seconds.
   Here the DOM is only touched at a changeover, and the next take is chosen
   at that moment from whatever the latest data is. */
.takes{
  position:relative; flex:1; min-width:0; height:100%; overflow:hidden;
  /* The takes arrive and leave rather than appearing and vanishing at a hard
     edge. Short on the left where a line is leaving the eye anyway, longer on
     the right where the next one is coming in. */
  -webkit-mask-image:linear-gradient(90deg, transparent 0, #000 70px, #000 calc(100% - 110px), transparent 100%);
  mask-image:linear-gradient(90deg, transparent 0, #000 70px, #000 calc(100% - 110px), transparent 100%);
}
/* The lane is driven from board.js, one transform per frame, and items are
   appended and dropped as they cross the edges. There is no CSS animation
   here on purpose: a keyframed marquee has to be rebuilt to change, and every
   rebuild restarted it — which is what made the old strip jump. */
.takelane{ position:absolute; top:0; left:0; height:100%; will-change:transform }
.take{
  position:absolute; top:0; height:100%; display:flex; align-items:center;
}
/* The line is ONE child span. A flex container drops the whitespace between
   its items, so a line built from bare text nodes and <em>/<b> siblings loses
   every space next to a name or a figure — "within$15.06of Priya N.". */
.take > span{
  white-space:nowrap;
  font-size:27px; font-weight:400; color:var(--ink-55); letter-spacing:.01em;
}
.take em{ font-style:normal; font-weight:600; color:var(--ink) }     /* a person */
.take b { font-weight:600; color:var(--gold) }                        /* a figure */

/* Motion off: no lane, one take sitting still, swapped on a timer. */
.takes.still{ mask-image:none; -webkit-mask-image:none }
.takes.still .takelane{ position:static }
.takes.still .take{ position:static; padding-left:80px }

/* The footer is the original Recent Heats strip, unchanged: one tidy row per
   cell, heat on the left, its detail on the right. Only what it SAYS changed —
   it now runs last / on track / next / next instead of four heats already run.

   An earlier pass restacked each cell into three lines with an uppercase
   label. That was a worse strip: smaller type, more to read, and the labels
   repeated what position and colour already say. Reverted. */
/* Four slots, cells keyed by heat and moved between them with transforms —
   see paintTimeline in board.js. The box clips at its own edges, so a heat
   sliding off the left or arriving from the right passes through the edge of
   the strip rather than appearing on it. --slide is set from SLIDE_MS. */
.timeline{ position:relative; flex:1; height:50px; overflow:hidden }

.heat{
  position:absolute; top:0; left:0; height:50px; box-sizing:border-box;
  border:1px solid var(--line); border-radius:10px;
  padding:10px 20px; display:flex; justify-content:space-between; align-items:center;
  transition:transform var(--slide, 1.9s) cubic-bezier(.45,.05,.3,.95),
             opacity .7s ease,
             border-color 1.5s ease, background-color 1.5s ease;
  will-change:transform;
}
.heat.fresh{ transition:none !important }
.heat.offstage{ opacity:0 }
.heat .n{ font-size:24px; font-weight:600; color:var(--ink); transition:color 1.5s ease }
.heat .s{ font-size:21px; font-weight:400; color:var(--ink-60); transition:color 1.5s ease }

/* On track now — the only cell that gets colour, and it comes up rather than
   snapping on: a heat starting is a smooth green-in, a heat finishing is the
   green draining out as the cell slides left into the past.
   NOT called .live: that class already exists for the header's LIVE dot, which
   sets align-items:center, and .heat.live silently inherited it. */
.heat.ontrack{ border-color:rgba(48,209,88,.6); background:rgba(48,209,88,.08) }
.heat.ontrack .n{ color:var(--good) }
.heat.ontrack .s{ color:var(--ink) }

/* Already run, and anything beyond the next one up: present but receded. */
.heat.done .n, .heat.far .n{ color:var(--ink-60) }
.heat.done .s, .heat.far .s{ color:rgba(255,255,255,.4) }

.heat.empty .n{ color:var(--ink-45) }
.heat.empty .s{ color:rgba(255,255,255,.3) }

body.no-anim .heat{ transition:none }
@media (prefers-reduced-motion:reduce){ .heat{ transition:none } }

/* ------------------------------------------------------ launch records -- */
/* Best and Slowest roll: the new figure comes down from above as the old one
   drops out of the bottom. The box clips to its own line, so the roll happens
   inside the number's own space and nothing around it moves. */
.splits .s .n{ position:relative; height:44px }
.splits .s .n.rolling{ overflow:hidden }
.splits .s .n span{
  display:block; height:44px; line-height:44px;
  transition:transform .8s cubic-bezier(.45,.05,.3,.95), opacity .5s ease;
}
.splits .s .n span.in{ transform:translateY(-100%); opacity:0; transition:none }
.splits .s .n span.out{ position:absolute; left:0; right:0; top:0; transform:translateY(100%); opacity:0 }

/* A new best glows for four seconds once it has rolled in — up over 1.5s,
   held, then back — the same shape as the utilization goal. It only starts
   after the roll, so the clip that the roll needs is gone before the number
   grows. */
#launchBest.record{ animation:recordGlow 4s cubic-bezier(.33,0,.2,1) 1 both }
#launchBest.record span{ animation:recordNum 4s cubic-bezier(.33,0,.2,1) 1 both }
@keyframes recordGlow{
  0%    { filter:drop-shadow(0 0 0 rgba(48,209,88,0)) }
  37.5% { filter:drop-shadow(0 0 18px rgba(48,209,88,.95)) drop-shadow(0 0 6px rgba(48,209,88,.6)) }
  75%   { filter:drop-shadow(0 0 18px rgba(48,209,88,.95)) drop-shadow(0 0 6px rgba(48,209,88,.6)) }
  100%  { filter:drop-shadow(0 0 0 rgba(48,209,88,0)) }
}
@keyframes recordNum{
  0%    { transform:scale(1);    font-weight:400; color:var(--good) }
  37.5% { transform:scale(1.14); font-weight:600; color:#c8ffd6 }
  75%   { transform:scale(1.14); font-weight:600; color:#c8ffd6 }
  100%  { transform:scale(1);    font-weight:400; color:var(--good) }
}
body.no-anim #launchBest.record, body.no-anim #launchBest.record span{ animation:none }

/* ------------------------------------------------------- rising value -- */
/* The value lifts and warms while money is landing on it, and takes a small
   knock on each hit. Transform and colour only — both cheap, and neither
   moves anything else on the row. */
/* The value keeps its own colour — gold stays gold. While money is landing it
   simply plumps: heavier and larger, with a knock on each hit.
   transform-origin is the right edge because the value is right-aligned in the
   row, so it grows leftwards and its right edge never moves. */
/* Podium. Gold is already on .lead; these are the other two places, and they
   are applied to the MONTH band only (see paintBand). Chosen to read as metal
   on a low-contrast panel rather than as "grey text" and "brown text". */
:root{
  --silver:#cdd3dc;
  --bronze:#d08b4a;
}
.row.silver .pos{ font-size:23px; font-weight:700; color:var(--silver) }
.row.silver .ln { font-size:25px; font-weight:600; color:var(--silver) }
.row.bronze .pos{ font-size:23px; font-weight:700; color:var(--bronze) }
.row.bronze .ln { font-size:25px; font-weight:600; color:var(--bronze) }

.row .vl{
  display:inline-block; line-height:1;
  transform-origin:100% 50%;
  transition:font-weight .18s ease, transform .18s ease;
}
.row .vl.rising{ font-weight:700; transform:scale(1.1) }
/* A drop reads as a drop: red, and the plump goes the other way — it shrinks
   slightly instead of swelling, so the row visibly deflates. */
.row .vl.falling{ font-weight:700; color:var(--bad) }
.row .vl.hit{ animation:valueHit .34s cubic-bezier(.2,.9,.3,1) }

/* Every step keeps the 1.1 base so the knock rides on top of the plump
   instead of dropping back to normal size between hits. */
@keyframes valueHit{
  0%   { transform:scale(1.10) translateY(0) }
  35%  { transform:scale(1.22) translateY(-2px) }
  100% { transform:scale(1.10) translateY(0) }
}


/* ---------------------------------------------------------- odometer -- */
/* Each digit is a 10-high strip clipped to one line. Moving the strip is the
   whole animation: up for a gain, down for a loss, and the direction falls out
   of the arithmetic rather than needing a flag. */
/* Digit columns and the static characters must share one vertical reference,
   or the $ and the decimal point sit on a different baseline from the digits.
   Both are 1em boxes aligned at the top; tabular figures already keep every
   digit the same width, so nothing shifts sideways as the values roll. */
.od{
  display:inline-block; overflow:hidden;
  height:1em; line-height:1; vertical-align:top;
}
.odstrip{
  display:block;
  transition:transform .30s cubic-bezier(.22,.9,.24,1);
  will-change:transform;
}
/* While the counter is unwinding the digits are set every frame, so the
   transition has to go — at speed it would lag several digits behind and the
   roll would smear into a blur instead of reading as digits. */
.vl.spinning .odstrip{ transition:none }
.odstrip > span{ display:block; height:1em; line-height:1 }
.odlit{ display:inline-block; height:1em; line-height:1; vertical-align:top }

body.no-anim .odstrip{ transition:none }
@media (prefers-reduced-motion:reduce){ .odstrip{ transition:none } }

/* --------------------------------------------------------- reordering -- */
/* One slot per step, one second per slot; --step is set from STEP_MS in
   board.js so the two can never disagree. The curve is symmetrical — a row
   leaves its place as gently as it arrives at the next — which is what makes
   a run of steps read as a cadence rather than a series of lurches. */
.row{
  transition:transform var(--step, 1s) cubic-bezier(.45,.05,.3,.95),
             opacity   .6s ease;
}
/* Placed, not moved: a newcomer taking its place under the edge, the first
   fill of the board. */
.row.fresh{ transition:none !important }

/* Under the bottom edge of the box. The clip and the runway shader do most of
   the hiding; this is what makes them fade in and out along the way. */
.row.offstage{ opacity:0 }

/* While two rows cross: the one worth watching is drawn on top, the other
   steps back a touch until they have passed. */
.row.over{ z-index:3 }
.row.under{ opacity:.3; transition:transform var(--step, 1s) cubic-bezier(.45,.05,.3,.95), opacity .25s ease }
.row.under.offstage{ opacity:0 }

body.no-anim .row{ transition:none }
@media (prefers-reduced-motion:reduce){ .row{ transition:none } }

.row.empty-row[hidden]{ display:none }

/* ------------------------------------------------------- goal reached -- */
/* Six seconds, in three acts: 0-3s growing and brightening, 3-5s held at full,
   5-6s easing back to the ordinary green. The percentages below are those
   times over the 6s duration — 50%, 83%, 100%.

   The glow is a drop-shadow on the SVG rather than a box-shadow on the card,
   so the light comes off the ARC itself and not off a rectangle. Transform and
   filter only, and only while the class is on: an idle board is untouched. */
#utilCard.hit-goal .ring{
  animation:goalGrow 6s cubic-bezier(.33,0,.2,1) 1 both;
}
#utilCard.hit-goal .ring svg{
  animation:goalGlow 6s cubic-bezier(.33,0,.2,1) 1 both;
}
#utilCard.hit-goal .ring .val .n{
  animation:goalNum 6s cubic-bezier(.33,0,.2,1) 1 both;
}

@keyframes goalGrow{
  0%   { transform:scale(1) }
  50%  { transform:scale(1.075) }     /* 3s — finished growing */
  83%  { transform:scale(1.075) }     /* 5s — held */
  100% { transform:scale(1) }         /* 6s — back to normal */
}

/* Two shadows: a tight bright core and a wide soft halo, so it reads as light
   radiating outwards rather than a uniform blur. */
@keyframes goalGlow{
  0%   { filter:drop-shadow(0 0 0    rgba(48,209,88,0))
                drop-shadow(0 0 0    rgba(48,209,88,0)) }
  50%  { filter:drop-shadow(0 0 22px rgba(48,209,88,.95))
                drop-shadow(0 0 70px rgba(48,209,88,.55)) }
  83%  { filter:drop-shadow(0 0 26px rgba(48,209,88,1))
                drop-shadow(0 0 84px rgba(48,209,88,.6)) }
  100% { filter:drop-shadow(0 0 0    rgba(48,209,88,0))
                drop-shadow(0 0 0    rgba(48,209,88,0)) }
}

@keyframes goalNum{
  0%   { color:var(--ink); text-shadow:none }
  50%  { color:#b6ffc9; text-shadow:0 0 26px rgba(48,209,88,.7) }
  83%  { color:#ccffd9; text-shadow:0 0 34px rgba(48,209,88,.8) }
  100% { color:var(--ink); text-shadow:none }
}

/* ---------------------------------------------------------- confetti -- */
/* Sits over the whole board and ignores the mouse. It is display:none until
   a launch beats the target, so on an idle board there is no compositor
   layer and nothing to burn in. */
.confetti{
  position:absolute; inset:0; width:1920px; height:1080px;
  pointer-events:none; display:none; z-index:5;
}
.confetti.on{ display:block }

/* ------------------------------------------------------------ states -- */

/* ---- park shut: the track board, minus everything that needs racing ---- */
/* The header stays exactly where it is. Only the three cards and the heat
   strip are replaced, because with no heats they have nothing to say — an
   empty ring and a row of dashes reads as a broken board, not a closed park.

   Everything here is deliberately dim. This panel can be on screen for twelve
   hours overnight on an IPS panel that does not like static bright pixels;
   the board's existing drift moves it, and low luminance does the rest. */
.shut{
  /* Exactly the region the three cards occupy, so the message lands where
     the eye already goes rather than drifting down into the strip's space. */
  position:absolute; left:0; right:0; top:208px; bottom:110px;
  display:none; align-items:center; justify-content:center; text-align:center;
}
.board.is-shut .shut{ display:flex }
.board.is-shut .track-body,
.board.is-shut .strip.track{ display:none }

/* One line, and nothing else. Nobody walks up to this to read it — they see
   that there is a bit of text in the middle and they know the board has not
   started yet. So it is set in the page's own face rather than the wordmark's,
   and kept faint: it is a placeholder, not an announcement, and it can be on
   screen for hours on a panel that does not like bright static pixels. */
.shut-big{
  font-size:54px; font-weight:400;
  color:rgba(255,255,255,.26);
  letter-spacing:-.01em; line-height:1.15;
}

/* ---------------------------------------------------------- boot-up -- */
/* The moment the doors open. Two acts:
     1. the waiting line falls away — it shrinks toward the centre and goes,
        as if it were travelling off into the distance;
     2. the board assembles: cards land left to right with a light passing
        across each one, their contents rise into place a beat behind, the
        ring draws itself and the figures count up from nothing, the heat
        strip slides up and its cells light in order.
   Transform, opacity and a border colour only, so it costs nothing it does
   not need to; the figures are counted by board.js. It plays once, at the
   shut-to-open transition, and never on a page load. */

/* Act 1 */
.board.receding .shut-big{
  animation:recede var(--recede,900ms) cubic-bezier(.55,0,.85,.25) both;
}
@keyframes recede{
  0%   { transform:scale(1);   opacity:1;  filter:blur(0) }
  70%  { opacity:.55 }
  100% { transform:scale(.02); opacity:0;  filter:blur(3px) }
}

/* Act 2 — cards */
.board.boot .track-body .card{ position:relative; overflow:hidden; --d:0s }
.board.boot .track-body .card:nth-child(1){ --d:.05s }
.board.boot .track-body .card:nth-child(2){ --d:.22s }
.board.boot .track-body .card:nth-child(3){ --d:.39s }

.board.boot .track-body .card{
  animation:cardIn .7s cubic-bezier(.2,.9,.25,1.04) var(--d) both,
            cardFlash 1.1s ease-out var(--d) both;
}
@keyframes cardIn{
  0%   { opacity:0; transform:scale(.94) translateY(16px) }
  55%  { opacity:1 }
  100% { opacity:1; transform:none }
}
@keyframes cardFlash{
  0%   { border-color:rgba(255,255,255,.75) }
  40%  { border-color:rgba(255,255,255,.75) }
  100% { border-color:var(--line) }
}
/* a light across the face of each card as it lands */
.board.boot .track-body .card::after{
  content:""; position:absolute; inset:0; pointer-events:none; border-radius:inherit;
  background:linear-gradient(108deg, transparent 38%, rgba(255,255,255,.13) 50%, transparent 62%);
  transform:translateX(-130%);
  animation:sheen .85s cubic-bezier(.3,.2,.2,1) calc(var(--d) + .18s) both;
}
@keyframes sheen{ to{ transform:translateX(130%) } }

/* contents a beat behind their card */
.board.boot .track-body .card > *{
  animation:riseIn .55s cubic-bezier(.2,.8,.2,1) calc(var(--d) + .26s) both;
}
.board.boot .track-body .card > .hd{ animation-delay:calc(var(--d) + .16s) }
@keyframes riseIn{
  0%   { opacity:0; transform:translateY(10px) }
  100% { opacity:1; transform:none }
}
/* pips light in order; --pd is set per pip in board.js */
.board.boot .pips i.on{
  animation:pipOn .28s ease-out calc(.95s + var(--pd, 0ms)) both;
}
@keyframes pipOn{
  0%   { background:rgba(255,255,255,.12); transform:scaleX(.4) }
  60%  { background:#fff }
  100% { background:rgba(255,255,255,.85); transform:none }
}

/* Act 2 — the heat strip */
.board.boot .strip.track{
  animation:stripIn .6s cubic-bezier(.2,.8,.2,1) 1.15s both;
}
@keyframes stripIn{
  0%   { opacity:0; transform:translateY(22px) }
  100% { opacity:1; transform:none }
}
/* --cd is the cell's slot × 120ms, set in placeCell */
.board.boot .heat:not(.offstage){
  animation:cellIn .45s ease-out calc(1.3s + var(--cd, 0ms)) both,
            cardFlash .9s ease-out calc(1.3s + var(--cd, 0ms)) both;
}
@keyframes cellIn{ 0%{ opacity:0 } 100%{ opacity:1 } }

body.no-anim .board.boot *, body.no-anim .board.receding *{ animation:none !important }
@media (prefers-reduced-motion:reduce){
  .board.boot *, .board.receding *{ animation:none !important }
}

.stalepill{
  position:absolute; right:80px; top:126px; z-index:4;
  display:none; align-items:center; gap:10px;
  border:1px solid rgba(255,69,58,.5); background:rgba(255,69,58,.1);
  border-radius:8px; padding:6px 14px;
  font-size:18px; font-weight:600; letter-spacing:.1em; color:var(--bad);
}
.stalepill.show{ display:flex }
.stalepill i{ width:8px; height:8px; border-radius:50%; background:var(--bad); animation:softPulse 1.4s ease-in-out infinite }
