/* gamenight.click — layout prototype
   Warm dark theme with a light mode. No remote fonts or assets: the server's
   CSP forbids them, and the site should never depend on one. */

*, *::before, *::after { box-sizing: border-box; }
[hidden] { display: none !important; }   /* author display rules beat the UA sheet */

:root {
  --bg:        #14100d;
  --bg-glow:   #1d1712;
  --surface:   #1c1713;
  --surface-2: #241d17;
  --line:      #352b22;
  --line-soft: #2a221b;
  --text:      #f2ebe1;
  --muted:     #a19182;
  --accent:    #e8ab3c;
  --accent-dim:#8a6620;
  --on-accent: #201603;
  --live:      #d9694a;
  /* The "connected" green. It was a bare #5fa87a inside the .conn rule, outside
     any token, and it measures 2.56:1 on the light background — under the 3:1
     bar for a non-text indicator. Light needs its own value. */
  --ok:        #5fa87a;
  --shadow:    0 1px 2px rgb(0 0 0 / .4), 0 8px 28px -12px rgb(0 0 0 / .7);
  --font: ui-sans-serif, system-ui, "Segoe UI", Inter, Roboto, Helvetica, Arial, sans-serif;
  --mono: ui-monospace, "Cascadia Mono", "SF Mono", Consolas, monospace;

  /* ═══ THE SYSTEM ══════════════════════════════════════════════════════════
     Eight spacing steps replacing 54 ad-hoc magnitudes; seven type steps
     replacing 27; three weights replacing nine; five radii replacing eleven.
     No step is within 33% of its neighbour, so two adjacent steps can never be
     mistaken for a typo — which is how .84rem, .85rem and .86rem all got in.
     Nothing below is a colour: the light block further down never restates it. */

  /* ── SPACING ────────────────────────────────────────────────────────────
     4px-linear through the UI band, where a 4px error is visible and a 2px one
     is not; ~1.5x geometric above it, where the eye compares ratios not pixels.
     25 of the old values lived between 3.2px and 19.2px; --s1..--s4 cover that
     whole band with four steps. */
  --s0: .125rem;  /*  2px — hairline: inset rings, optical nudges              */
  --s1: .25rem;   /*  4px — inside one object: dot-to-word, pill-to-pill       */
  --s2: .5rem;    /*  8px — siblings that read as one thing                    */
  --s3: .75rem;   /* 12px — control padding-inline, dense list-row padding     */
  --s4: 1rem;     /* 16px — the default gap; card padding on a phone           */
  --s5: 1.5rem;   /* 24px — card padding on desktop; head-to-its-content       */
  --s6: 2rem;     /* 32px — page gutter on desktop; title-to-content           */
  --s7: 3rem;     /* 48px — page padding-block on desktop                      */
  --s8: 4rem;     /* 64px — page tail; the largest gap on any surface          */

  /* Both endpoints of every clamp land on a scale step, so the system holds at
     the two viewports that get tested and only the transit between is off it. */
  --gutter:     clamp(1rem, 4vw, 2rem);   /* --s4 <=400px  ->  --s6 >=800px    */
  --pad-page-y: clamp(2rem, 4vw, 3rem);   /* --s6 -> --s7                      */
  --gap-block:  clamp(2rem, 5vw, 4rem);   /* --s6 -> --s8. 64px on desktop is
                                             what stops Home reading as a stack
                                             of equal bands.                   */

  /* ── TYPE ───────────────────────────────────────────────────────────────
     Eleven of the old gaps were exactly 0.32px and two were 0.16px — a 1-2%
     step, under the perceptual threshold. Every step below is >=12.5% from its
     neighbour, and the two smallest ranks are separated by TREATMENT as well
     (t1 is mono/uppercase/tracked) and by COLOUR (t2 is --muted), so the 1.14
     ratio between t2 and t3 carries three channels, not one. */
  --t1: .75rem;    /* 12px — STAMP: mono, uppercase, tracked. Section stamps,
                              state pills, ordinals, relative dates, PRIVATE.
                              Absorbs eleven variants of one role.             */
  --t2: .875rem;   /* 14px — META: hints, subtitles, captions, row names.      */
  --t3: 1rem;      /* 16px — BODY, and every control that commits. Also the
                              iOS no-zoom floor for an input.                  */
  --t4: 1.125rem;  /* 18px — CARD TITLE / row title / lede / empty-state title */
  --t5: 1.375rem;  /* 22px — SECTION HEAD (h2)                                 */
  --t6: clamp(1.75rem, 1.15rem + 2.5vw, 2.25rem);
                   /* 28 -> 36px — PAGE TITLE (h1), every surface. Hits max at
                      704px so the whole desktop range is a stable 36px; holds
                      28px to 384px so a 375px phone never gets less.          */
  --t7: clamp(2.25rem, 1.4rem + 3.4vw, 3rem);
                   /* 36 -> 48px — DISPLAY: the gate wordmark, and NOTHING
                      else. Setting a page title to this collapses the one
                      distinction the door has.                               */

  /* Line-height is a function of size, not a scale — a 48px title and a 14px
     hint cannot share one number, and this sheet had NO root value at all, so
     everything inherited the UA's `normal` (~1.2 on this stack). */
  --lh-stamp: 1;      /* t1: single-line, never wraps                          */
  --lh-tight: 1.1;    /* t6, t7                                                */
  --lh-head:  1.22;   /* t4, t5                                                */
  --lh-ui:    1.3;    /* t3 inside a control — a label must not add box height */
  --lh-meta:  1.45;   /* t2                                                    */
  --lh-body:  1.55;   /* t3 as prose. Down from 1.6 because the measure comes
                         from 137ch to ~66ch, and shorter lines need less
                         leading to find the next one.                         */

  --ls-stamp: .1em;   /* uppercase mono is unreadable without positive tracking */
  --ls-t4: -.008em;
  --ls-t5: -.016em;
  --ls-t6: -.022em;
  --ls-t7: -.03em;    /* tighter as size grows: default tracking looks loose at
                         36-48px, which is why the sheet already hand-set
                         -.018em on h1/h2 and -.02em on .brand.                */

  /* ── WEIGHT ─────────────────────────────────────────────────────────────
     Measured in-page on this exact stack, 'Three Thirteen Handgloves 12345' at
     16px: 400 -> 237.563px; 500/550/600/620/640 -> 243.016px; 650/680/700/750
     -> 254.328px; 800 -> 264.828px. No variable axis is exposed and a webfont
     is forbidden, so THREE FACES IS THE CEILING. Every 500/550/620/640/650/
     680/750 in the platform layer rendered as its neighbour: collapsing them is
     a visual no-op and eleven fewer decisions.

     Consequence worth naming: three state distinctions were carried by a weight
     change that never rendered. They need a real second channel, not a heavier
     number — see the empty seat, the winning score, and the scoreboard's "me"
     column. */
  --w-reg:  400;
  --w-med:  600;   /* absorbs 500, 550, 620, 640 — identical rendered face     */
  --w-bold: 700;   /* absorbs 650, 680, 750 — identical rendered face          */

  /* ── RADIUS ─────────────────────────────────────────────────────────────
     Radius tracks the size of the box it rounds. Each step is >=1.33x the last,
     so 14-vs-16-vs-18 (1.14 ratios — pure drift from two generations of the
     sheet layered on each other) cannot recur. */
  --r1: 4px;    /* chips set inside text: the rules counter, the brand mark    */
  --r2: 8px;    /* controls: buttons, inputs, seat cells                       */
  --r3: 12px;   /* inner panels: the code field, the seatpick track, .findform */
  --r4: 16px;   /* cards and sheets: .card, .setgroup, .invite, .match, .empty */
  --r5: 22px;   /* the gate card, and only that — the largest object on the
                   site, and the one that must not read as a page card         */
  --r-pill: 999px;
  /* Two aliases retarget ~20 existing rules onto the scale in two lines. Both
     moves are sub-2px with no layout consequence. */
  --radius:    var(--r4);     /* was 14px */
  --radius-sm: var(--r2);     /* was  9px */

  /* ── MEASURE, COLUMNS, TARGETS ──────────────────────────────────────────
     `ch` is the advance of "0", which under-counts real prose on this stack:
     .rules at max-width:64ch measured as 69 real characters, a 1.078 factor.
     62ch lands at ~67 — inside the 55-75 band, and the number the one
     correctly-set page on the site already proves the author knows. */
  --measure: 62ch;
  --measure-tight: 52ch;   /* --muted at 14px loses the line-return sooner     */
  --col-wide:   1020px;    /* .page — a grid column, sized for tiles not prose */
  --col-narrow:  660px;    /* .narrow — forms and list rows                    */
  --bar-h:  56px;          /* 44px tap target + 6px clearance top and bottom   */
  --tap:    44px;          /* WCAG 2.5.5 / iOS HIG                             */
  --tap-sm: 36px;          /* dense in-bar controls; 1.5x the 24px 2.5.8 floor */
  --e0: none;              /* flush and in-flow — a list row, the bar at rest  */

  /* The ink on a player disc, and deliberately NOT redefined for light: the six
     disc colours are absolute in both themes, so identity does not shift when
     someone flips the theme, and their ink has to be absolute too. Measured
     against the six: 8.77 / 5.16 / 6.26 / 5.26 / 5.19 / 9.81 — all clear AA. */
  --marker-ink: #201603;
  /* --line-strong is deliberately IDENTICAL to dark's --line above, so every
     --line -> --line-strong swap below changes the LIGHT theme only and cannot
     regress dark. That is what makes those swaps safe to do unconditionally. */
  /* #806e58, and NOT dark's --line. That equality was deliberate when the
     --line -> --line-strong swap landed, so the swap could not regress dark —
     but it preserved a failure rather than avoiding one: #352b22 on --surface
     #1c1713 measures 1.29:1, and this token is the entire visual definition of
     a button. 3.63 / 3.87 now. Kept WARM (a cool grey would fight the amber
     this theme is built on) rather than taking the blue a reviewer proposed. */
  --line-strong: #806e58;
  --live-ink:    #d9694a;
  --ok-ink:      #5fa87a;
  --e1: 0 1px 2px rgb(0 0 0 / .35);
  --e2: var(--shadow);
  --e3: 0 2px 4px rgb(0 0 0 / .45), 0 22px 48px -20px rgb(0 0 0 / .8);
}
html[data-theme="light"] {
  /* ── NEUTRAL CHROME, COLOUR FROM THE GAMES ───────────────────────────────
     Rewritten 2026-09-05. The previous ramp was a COOL BLUE one: ground
     #eaf1f8, accent #1a63a4. It was measured and correct, but it put a hue on
     66% of the painted area, so every game's own colour competed with a blue
     page instead of being the only colour on it.

     The brief: white, Game Night style, chrome neutral and the games carrying
     the colour. So the ground is a near-neutral grey, the chrome is graphite,
     and the ONLY saturated things on screen are the game surfaces.

     EVERY RATIO BELOW IS MEASURED, not eyeballed — same discipline as the ramp
     it replaces. Text >= 4.5:1, control edges >= 3:1 against BOTH the card and
     the ground (a border that passes on white and fails on the page behind it
     is a border that disappears half the time). */
  --bg:        #f7f7f8;   /* near-neutral. text 16.55:1, card lifts 1.07:1  */
  --bg-glow:   #ffffff;
  --surface:   #ffffff;
  --surface-2: #fafafa;
  --line-soft: #e4e4e7;   /* 1.27 on --surface — a hairline, not an edge    */
  --line:      #d4d4d8;   /* 1.48 on --surface — divider                    */
  --line-strong:#8b8b93;  /* 3.38 on --surface AND 3.16 on --bg — control   */
  --text:      #18181b;   /* 17.72 on --surface                             */
  --muted:     #52525b;   /*  7.73 on --surface                             */
  /* GRAPHITE, NOT A HUE. The accent carries focus rings, current-nav and
     primary buttons — chrome. Neutral here is what lets a magenta game card be
     the most colourful thing on the page. 10.44:1 as text, and white on it is
     10.44:1 the other way, so it works as a filled button too. */
  --accent:    #3f3f46;
  --accent-dim:#71717a;   /*  4.83 on --surface, 4.51 on --bg               */
  --on-accent: #ffffff;
  --live:      #c0392b;   /*  5.44 on --surface                             */
  --ok:        #15803d;   /*  5.02 on --surface                             */
  --live-ink:  #a5241d;   /*  6.72 — red as SMALL TEXT                      */
  --ok-ink:    #0f6b3a;   /*  6.19 — green as SMALL TEXT                    */

  /* ── THE ROYGBIV SLOTS ───────────────────────────────────────────────────
     A palette for games to be ASSIGNED FROM, rather than each one inventing a
     hex. Seven so there is somewhere for the next five games to go without
     anybody picking a colour under deadline.

     Each is the most vivid value that still clears 4.5:1 as text on a white
     card — and each also clears 4.5:1 with WHITE ON IT, so the same token can
     be a heading and a filled button without a second variant. Measured, both
     directions, in that order. */
  --hue-red:    #c0392b;  /* 5.44 */
  --hue-orange: #b45309;  /* 5.02 */
  --hue-yellow: #8a6d00;  /* 4.92 — a dark amber. Real yellow cannot pass.  */
  --hue-green:  #15803d;  /* 5.02 */
  --hue-blue:   #1e5fbf;  /* 6.10 */
  --hue-indigo: #4c3fbf;  /* 7.58 */
  --hue-violet: #7e22ce;  /* 6.98 */

  /* neutral smoke, so shadows do not tint the white */
  --shadow: 0 1px 2px rgb(24 24 27 / .06), 0 10px 30px -14px rgb(24 24 27 / .22);
  --e1: 0 1px 1px rgb(24 24 27 / .05), 0 1px 3px rgb(24 24 27 / .07);
  --e2: var(--shadow);
  --e3: 0 1px 2px rgb(24 24 27 / .06), 0 18px 40px -22px rgb(24 24 27 / .34);
}
html { color-scheme: dark; }
html[data-theme="light"] { color-scheme: light; }

/* ── IDIOMS THAT ONLY WORK ON A DARK GROUND ──────────────────────────────────
 *
 * The palette above is a token swap; these are not reachable by swapping a
 * token, because the rule itself encodes "the ground is dark". Each one is
 * inert or actively wrong in light, and they are collected here rather than
 * scattered so the next person can see the whole class at once.
 */

/* 1. A white inner rim is gloss on a near-black ground and nothing on cream.
      It matters most on the player discs: those six colours are deliberately
      theme-independent so identity does not shift, and two of them are pale
      (#e8ab3c is 2.03:1 on --surface, #c9bfae 1.82:1). The rim was their only
      edge, so on white a gold or bone player was a smear with no boundary —
      including on the profile swatches, where the unselected state is a
      transparent border and you are asked to choose between them. */
html[data-theme="light"] :is(.gate-mark, .brand-mark) {
  box-shadow: inset 0 0 0 1px rgb(15 35 65 / .22);
}
html[data-theme="light"] :is(.avatar, .sw) {
  box-shadow: inset 0 0 0 1px rgb(15 35 65 / .42);
}

/* 2. The gate mark's glow is `0 8px 24px -10px var(--accent)` — an OPAQUE
      colour used as a drop shadow. On the dark theme --accent was a bright
      #e8ab3c and it read as light spilling off the tile; in light --accent is
      #8a5f10 and it is a solid dark-brown smudge under the first graphic on
      the first page anybody sees. A shadow has to be transparent. */
html[data-theme="light"] .gate-mark {
  box-shadow: inset 0 0 0 1px rgb(15 35 65 / .22), 0 10px 26px -12px rgb(26 99 164 / .40);
}

/* 3. Scrims built as `color-mix(--bg NN%, transparent)` DARKEN in dark and
      LIGHTEN in light, which is backwards: the reveal overlay resolved to
      #f8f4ed over a #ffffff panel — the modal separated from its own veil by
      1.10:1, so it did not read as covering anything. A scrim's job is to push
      the page back, so in light it must be a dark wash, not a pale one. */
html[data-theme="light"] .tt-reveal { background: rgb(8 20 34 / .46); }
html[data-theme="light"] .jg-tap    { background: rgb(8 20 34 / .50); }

/* 4. The placeholder is the only thing showing the XXXXX-XXXXX shape of the
      code, on the first screen anybody sees, and it measured 2.15:1. */
html[data-theme="light"] #code::placeholder { color: color-mix(in oklab, var(--muted) 82%, transparent); }

/* 5. Dimming by opacity was tuned against a near-black ground. The same alpha
      over cream lands much closer to the page, so text that is meant to be
      quiet becomes text that is gone: a losing buzz-in put a player's NAME at
      1.57:1, and these are live scoreboard entries and readable brand names,
      not disabled controls, so no contrast exemption applies. Raised only in
      light, and only far enough to stay legible while still reading as dim. */
/* Raising the alpha was not enough — measured on the real page, three of the
   four still failed (a lost player's NAME at 2.14:1, a knocked-out name at
   2.84, the deck count at 3.21). Opacity is the wrong instrument: it drags the
   text toward the ground no matter which alpha you pick, and these are live
   names and readable brand strings, not inert controls, so no disabled-contrast
   exemption applies. Dim by COLOUR and SCALE instead — both states then keep
   two channels and neither loses AA. */
html[data-theme="light"] .jg-lamp[data-verdict="lost"] {
  opacity: 1; transform: scale(.9); color: var(--muted); border-color: var(--line);
}
html[data-theme="light"] .jg-track[data-result="dim"] {
  opacity: 1; color: var(--muted); background: transparent; border-color: var(--line);
}
html[data-theme="light"] .jg-chip[data-out="true"] { opacity: 1; }
/* Dim the artwork, never the number. aria-disabled rather than :disabled was
   chosen precisely so the count stays perceivable. */
html[data-theme="light"] .tt-pile[aria-disabled="true"] { opacity: 1; }
html[data-theme="light"] .tt-pile[aria-disabled="true"] .tt-pile-stack { opacity: .5; }

/* 6. The checkbox is the one control with no rule anywhere in this sheet, so it
      renders as the OS default — under color-scheme:light, a system-blue box.
      That is the only blue on a site that is otherwise entirely amber and clay,
      and it sits on the setup page every host passes through. */
/* accent-color and a 17px box: superseded by the real switch further down this
   sheet, which sizes and paints the control itself. Left as a note rather than
   deleted silently, because the ORIGINAL problem it solved — a Windows
   system-blue checkbox as the only blue on an amber site — is why it existed. */

/* 7. The waveform is the clock this game is built around, and its unplayed bars
      and notches were drawn in --line/--line-soft: the whole right-hand
      not-yet-played portion was invisible, and a bar at 10% read as a floating
      stub rather than a bar that has emptied. The darker edge tokens above fix
      most of it; the track needs to sit lower still than an ordinary border,
      because a progress track is read as a shape, not as an edge. */
/* #cabda4 measured 1.66:1 on --bg — LIGHTER than --line (2.23:1), so that first
   attempt moved the track further from visible, not closer. A progress track is
   read as a shape, and the part that is LEFT is the whole point of one. */
html[data-theme="light"] :is(.jg-choice-clock, .jg-rank-bar) { background: var(--line-strong); }

/* 8. The game surface derives its own tints from --accent, and in light three
      of them resolve to something too pale to carry what they carry. */
html[data-theme="light"] .jg {
  /* --jg-dim was color-mix(accent 34%, surface-2) = #cfbc9b, 1.65:1 on --bg,
     and it is the SOLE carrier of round progress, the held-audio tally, the
     platter hub and the rank-bar fill. --accent-dim is the token that was
     already tuned to sit one step below --accent for exactly this job. */
  --jg-dim: var(--accent-dim);
  /* --jg-ink was color-mix(accent 45%, #000) — effectively black, used as a
     20px blurred cast shadow under the buzzer. On cream that is a hard black
     lip, not a shadow. */
  --jg-ink: color-mix(in oklab, var(--accent) 70%, #000);
}
/* A cast shadow is transparent; the same alpha idiom --shadow and
   --tt-card-shadow already use. */
html[data-theme="light"] .jg-buzzer {
  box-shadow: 0 7px 0 var(--jg-ink), 0 14px 20px -10px rgb(16 36 60 / .45);
}
html[data-theme="light"] .jg-buzzer:active,
html[data-theme="light"] .jg-buzzer.is-down {
  box-shadow: 0 0 0 var(--jg-ink), inset 0 3px 8px rgb(0 0 0 / .35);
}
/* The reveal cover's wash sets the ground under .jg-taken, and that ground
   moves — --cx/--cy are hashed from the brand string in JS — so the text on it
   cannot be measured against a fixed colour unless the wash is weakened. At
   13% the ground is #eee8dd and the text is predictable. */
html[data-theme="light"] .jg-cover { --jg-glow: color-mix(in oklab, var(--accent) 13%, transparent); }
html[data-theme="light"] .jg-taken[data-house="true"] { color: var(--live-ink); }

/* 9. The platter. Its conic sheen wedge is --line on a --surface-2 plate, which
      in light is 1.96:1 — and that wedge is the ONLY thing that makes the
      rotation legible, so in light the disc looks frozen while it spins. */
html[data-theme="light"] .jg-disc {
  background:
    conic-gradient(from 0deg, transparent 0 41%, var(--line-strong) 46% 54%, transparent 59%),
    repeating-radial-gradient(circle at 50% 50%, var(--line) 0 1px, transparent 1px 5px),
    radial-gradient(circle at 50% 50%, var(--jg-dim) 0 21%, var(--surface-2) 21.5%);
}

/* 10. Cream blurred over cream is not an edge. */
html[data-theme="light"] .topbar {
  border-bottom-color: var(--line); box-shadow: 0 1px 0 rgb(16 36 60 / .06);
}

/* The "THE TABLE, DRAWN" layer lives at the END of this sheet, not here:
   several of its rules (.gate-card, .gate-mark, .card, .empty, .setgroup h3)
   restate a bare class that is also declared further down, and CSS resolves an
   equal-specificity tie by source order. Placed here it lost every one of those
   ties silently — the gate card stayed 430px/14px while the rule that said 460/18
   sat above it doing nothing. */


body {
  /* Both, in this order. On a phone `100vh` is the LARGEST viewport — the one
     with the address bar hidden — so the bottom of the page sits under browser
     chrome until you scroll, and shifts as the bar comes and goes. `dvh` tracks
     the viewport that is actually there. The vh line stays as the fallback for
     an engine that does not know dvh, which drops the second declaration. */
  margin: 0; min-height: 100vh; min-height: 100dvh;
  /* THE SHEET HAD NO ROOT LINE-HEIGHT AT ALL, so every button, label, h3 and
     pseudo-label inherited the UA's `normal` — about 1.2 on this stack — and
     nothing on the site shared a vertical rhythm. */
  font-family: var(--font); color: var(--text); line-height: var(--lh-body);
  background: radial-gradient(1100px 620px at 50% -12%, var(--bg-glow), transparent 68%), var(--bg);
  -webkit-font-smoothing: antialiased;
}
/* h2 is --w-med, not --w-bold: a 36px/700 page title above a 22px/600 section
   head has TWO channels. At 650 and 700 they shared the same rendered face, so
   the hierarchy rested on size alone. */
h1, h2 { margin: 0 0 .35em; font-weight: var(--w-bold); }
h1 { font-size: var(--t6); line-height: var(--lh-tight); letter-spacing: var(--ls-t6); }
h2 { font-size: var(--t5); line-height: var(--lh-head);  letter-spacing: var(--ls-t5);
     font-weight: var(--w-med); }
h3 { line-height: var(--lh-head); letter-spacing: var(--ls-t4); }
p  { line-height: var(--lh-body); margin: 0 0 1em; }

/* THE MEASURE. Body copy ran at 137ch on Home, 127ch in empty states and 85ch
   on the narrow pages; the one page that was set correctly (Rules, 69ch) proves
   the number was known. `ch` is the advance of "0" and under-counts real prose
   on this stack by ~1.078, so 62ch lands at ~67 real characters — inside the
   55-75 band. Headings are left uncapped: a five-word title at 137ch is fine.
   margin-inline keeps it honest inside centred boxes. */
:is(.block-head, .empty, .firstrun-head) p,
.hint, .card-tagline, .rules p, .rules-lede {
  max-width: var(--measure); margin-inline: 0;
}
.empty p, .gate-card p { margin-inline: auto; }
/* Constraint 4: the game owns everything inside its surface, measure included. */
#gameSurface { --measure: none; }
.dot { color: var(--accent); }
.muted { color: var(--muted); }
.hint { color: var(--muted); font-size: var(--t2); line-height: 1.5; margin: .45em 0 0; }
.err  { color: var(--live); font-size: var(--t2); font-weight: var(--w-med); margin: .5em 0 0; }

/* The PLATFORM's visually-hidden class. The game has its own (.tt-sr) on
   purpose — neither owns the other's. */
.sr-only {
  position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
  overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;
}
.skip { min-height: var(--tap); display: inline-flex; align-items: center;
  position: absolute; left: -9999px; top: 0; z-index: 20;
  padding: var(--s2) var(--s4); background: var(--accent); color: var(--on-accent);
  font-weight: var(--w-bold); text-decoration: none;
}
.skip:focus { left: 0; }

/* ── connection state + refusals ───────────────────────────────────────── */

/* The one thing a live site owes the player at all times: whether the server is
   still there. A stale board with no indicator is indistinguishable from a
   board nobody has moved on. */
/* A chip, not a bare dot. A colour on its own is not a status (WCAG 1.4.1) and
   the title attribute it used to rely on cannot be hovered on a phone. It stays
   silent while things are fine and puts a word up when they are not. */
.conn {
  margin: 0; display: inline-flex; align-items: center; gap: var(--s1);
  min-height: 24px; padding: var(--s0) var(--s2) var(--s0) var(--s2); border-radius: var(--r-pill);
  font-size: var(--t1); font-weight: var(--w-med); color: var(--text);
  border: 1px solid transparent;
}
.conn-dot { width: 8px; height: 8px; border-radius: 50%; flex: none;
            background: var(--muted); transition: background .2s; }
.conn[data-state="live"]       .conn-dot { background: var(--ok); }
.conn[data-state="connecting"] .conn-dot { background: var(--accent); }
.conn[data-state="lost"]       .conn-dot { background: var(--live); }
.conn[data-state="lost"] {
  border-color: color-mix(in oklab, var(--live) 40%, var(--line));
  background: color-mix(in oklab, var(--live) 10%, transparent);
}
.conn-word:empty { display: none; }
/* `.conn + .who` was deleted: #tableChip always sits between them in the DOM,
   so the adjacency has never once matched. The cluster's own gap does the job. */
.topbar-right { display: flex; align-items: center; gap: var(--s3); justify-self: end; }

/* The bar's middle track. Below 760px it collapses and Home's .pagenav carries
   the same destinations — two route maps, never both visible at once. */
.topnav { display: none; gap: var(--s1); }
@media (min-width: 760px) { .topnav { display: flex; } }
.topnav a {
  padding: var(--s1) var(--s3); border-radius: var(--r-pill);
  font-size: var(--t2); font-weight: var(--w-med); color: var(--muted);
  text-decoration: none; white-space: nowrap;
}
.topnav a:hover { color: var(--text); background: var(--surface-2); }
.topnav a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.topnav a[aria-current="page"] { color: var(--text); background: var(--surface-2); }
/* No forced-colors override for .conn-word: it is EMPTY whenever the chip is
   quiet, so forcing it visible showed nothing and added a stray flex gap. The
   chip only stays quiet while everything is fine — the word is present for both
   states worth seeing, in every colour mode. */

/* The server's own words, never a guess made here. Dismissible, and the timer
   pauses while it is being read: this is the only place a refusal is reported,
   and yanking it away mid-sentence loses the only explanation there was. */
.banner {
  display: flex; align-items: center; gap: var(--s3);
  margin: 0; padding: .7rem clamp(.9rem, 3vw, 2rem);
  background: color-mix(in oklab, var(--live) 14%, var(--surface));
  border-bottom: 1px solid color-mix(in oklab, var(--live) 40%, var(--line));
  font-size: var(--t2); font-weight: var(--w-med);
}
.banner span { flex: 1; min-width: 0; }
/* .icon-btn was named in the shared :focus-visible rule and defined nowhere —
   a prototype leftover. Now it is real. */
.icon-btn {
  display: grid; place-items: center; width: 28px; height: 28px; flex: none;
  padding: 0; border: 1px solid transparent; border-radius: var(--radius-sm);
  background: transparent; color: inherit; cursor: pointer; font: inherit;
}
.icon-btn:hover { border-color: var(--line); background: var(--surface); }

/* ── buttons + fields ──────────────────────────────────────────────────── */
.btn {
  display: inline-flex; align-items: center; justify-content: center;
  font: inherit; font-size: var(--t2); font-weight: var(--w-med); cursor: pointer;
  padding: var(--s3) var(--s4); border-radius: var(--radius-sm);
  border: 1px solid var(--line-strong); background: var(--surface); color: var(--text);
  transition: border-color .12s, background .12s, transform .06s;
}
.btn:hover { border-color: var(--accent-dim); background: var(--surface-2); }
.btn:active { transform: translateY(1px); }
.btn-primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
.btn-primary:hover { background: color-mix(in oklab, var(--accent) 88%, #fff); border-color: transparent; }
/* --on-accent on --accent measures 3.66:1 in light — under AA for anything that
   is not large text, and the lobby's Join is a .82rem instance. Darkened here
   only, rather than moving the --accent token, because the game uses that token
   too and its own contrast was reasoned about separately. 5.58:1. */
/* .btn-primary needs no light override any more — it was hardcoding what
   --accent now is. Only the hover, which lifts rather than restates. */
html[data-theme="light"] .btn-primary:hover { background: #9c6c13; border-color: #9c6c13; }
.btn-block { width: 100%; }

label { display: block; font-size: var(--t2); font-weight: var(--w-med); margin-bottom: var(--s1); }
/* number = Jingle Guesser's "Rounds", which matched no rule in this sheet and
   rendered as a raw OS widget. search = the Friends field. */
input[type="text"], input[type="number"], input[type="search"] {
  font: inherit; width: 100%; color: var(--text); background: var(--bg);
  border: 1px solid var(--line-strong); border-radius: var(--radius-sm); padding: var(--s3) var(--s3);
}
/* Enumerated, never a global :where(...). A blanket rule would newly ring the
   game's own controls — .tt-pile is a bare <button> with no .btn class — and
   the game owns its focus styling. */
input:focus-visible, select:focus-visible, .btn:focus-visible,
.who:focus-visible, .sw:focus-visible, .icon-btn:focus-visible, .card:focus-visible,
.back:focus-visible, .brand:focus-visible, .skip:focus-visible,
.invite-code:focus-visible, .empty a:focus-visible, .firstrun a:focus-visible {
  /* No border-radius here. It applied to the ELEMENT, not the ring, so every
     rounded control — the pill-shaped profile chip, the round swatches — was
     squared off for as long as it held focus. */
  outline: 2px solid var(--accent); outline-offset: 2px;
}

/* ── the gate ──────────────────────────────────────────────────────────── */
.gate { min-height: 82vh; min-height: 82dvh;
        display: grid; place-items: center; padding: var(--s6) var(--s4) var(--s8);
  position: relative;
  overflow: hidden;
}
.gate-card {
  width: min(460px, 100%); text-align: center;
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--r5); box-shadow: var(--e3);
  padding: clamp(1.6rem, 5vw, 2.6rem);
  position: relative;
  z-index: 1;
  background-image: linear-gradient(var(--surface-2), var(--surface) 92px);
}
.gate-mark {
  width: 56px; height: 56px; border-radius: 0; margin: 0 auto var(--s4);
  background: none;
  box-shadow: none;
}
.gate-card h1 { font-size: var(--t7); }
.gate-sub { color: var(--muted); font-size: var(--t3); margin-bottom: var(--s6); }
.gate-form { text-align: left; }
#code {
  font-family: var(--mono); font-size: 1.22rem; letter-spacing: .13em;
  text-align: center; text-transform: uppercase; padding: var(--s3);
  background: var(--surface-2);
  border-radius: var(--r3);
  box-shadow: inset 0 1px 2px rgb(16 36 60 / .07);
}
#code::placeholder { color: color-mix(in oklab, var(--muted) 55%, transparent); }
.gate-form .btn { margin-top: var(--s5); }
.gate-foot {
  margin: var(--s5) 0 0; padding-top: var(--s5); border-top: 1px solid var(--line-soft);
  font-size: var(--t2); color: var(--muted);
}

/* ── top bar ───────────────────────────────────────────────────────────── */
.topbar {
  /* A MASTHEAD, not a status bar. brand | nav | right-cluster, so the middle
     track absorbs the free space and nothing moves when a chip appears. */
  display: grid; grid-template-columns: auto 1fr auto; align-items: center;
  gap: var(--s4); min-height: var(--bar-h);
  padding-inline: var(--gutter);
  border-bottom: 1px solid var(--line-soft);
  position: sticky; top: 0; z-index: 10;
  background: color-mix(in oklab, var(--bg) 88%, transparent);
  backdrop-filter: blur(10px);
}
/* color and text-decoration are not decoration here: these are bare <a>s, and
   without them the wordmark rendered in the browser's default link blue with an
   underline through it. */
.brand { display: flex; align-items: center; gap: var(--s2); font-weight: var(--w-bold);
         letter-spacing: -.02em; color: var(--text); text-decoration: none; }
.brand-mark {
  width: 20px; height: 20px; border-radius: var(--r1); flex: none;
  background: linear-gradient(150deg, var(--accent), var(--live));
  box-shadow: inset 0 0 0 1px rgb(255 255 255 / .18);
}
.who {
  display: flex; align-items: center; gap: var(--s2);
  font: inherit; font-size: var(--t2); font-weight: var(--w-med); cursor: pointer;
  padding: var(--s1) var(--s3) var(--s1) var(--s1); border-radius: var(--r-pill);
  border: 1px solid transparent; background: transparent; color: var(--text);
  text-decoration: none;
}
.who:hover { border-color: var(--line); background: var(--surface); }
/* The disc carries a colour AND an initial. Four brand-new accounts are four
   identical gold discs otherwise, and the initial is the cheapest thing that
   tells them apart. --mk is the one size knob. */
.avatar {
  display: inline-grid; place-items: center;
  width: var(--mk, 26px); height: var(--mk, 26px); border-radius: 50%; flex: none;
  box-shadow: inset 0 0 0 1px rgb(255 255 255 / .16);
  font-size: calc(var(--mk, 26px) * .42); font-weight: var(--w-bold); line-height: 1;
  color: var(--marker-ink); user-select: none;
}
.avatar[data-c="1"] { background: #e8ab3c; }
.avatar[data-c="2"] { background: #d9694a; }
.avatar[data-c="3"] { background: #5fa87a; }
.avatar[data-c="4"] { background: #5d8fc4; }
.avatar[data-c="5"] { background: #a878c0; }
.avatar[data-c="6"] { background: #c9bfae; }
.avatar-sm { --mk: 22px; }
.avatar-lg { --mk: 34px; }

/* ── page + sections ───────────────────────────────────────────────────── */
.page { max-width: 1020px; margin: 0 auto; padding: clamp(1.4rem, 4vw, 2.6rem) clamp(.9rem, 3vw, 2rem) 5rem; }
.block + .block { margin-top: clamp(2.2rem, 6vw, 3.4rem); }
.block-head { margin-bottom: var(--s4); }
.block-head p { margin: 0; font-size: var(--t2); }

/* ── the catalogue card ────────────────────────────────────────────────────
   A game is presented with NO ARTWORK, because there is none and none is
   coming. So the card is typographic: the name IS the picture, set at poster
   size on its own plate over a rail carrying the two facts that decide whether
   a game is playable tonight — how many people, and how long.

   Nothing here depicts a game, and that is the point. A plate that drew cards
   would be a lie the moment game #2 is a 3D room. A plate that drew SEATS would
   be worse: filled seat marks on a catalogue card are indistinguishable from
   "2 of 6 seated", which is invented players on a card with no occupants. */
.grid {
  display: grid; gap: var(--s5);
  /* auto-FIT, not auto-fill. auto-fill keeps the empty tracks it created, so a
     two-game catalogue sat in a three-track shelf with a 308px hole on the
     right — a third of the shelf reading as a missing card rather than as a
     shelf with two boxes on it. auto-fit collapses the empty track and the two
     real cards absorb the width.
     The 420px ceiling is the other half: without it two games on a 1020px page
     become two 490px slabs, which is a different kind of wrong. Cards grow to
     420px, then the row centres and stops. */
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 244px), 420px));
  justify-content: center;
}
.card {
  container-type: inline-size;       /* the plate's type scales with the card */
  display: flex; flex-direction: column; padding: 0; cursor: pointer;
  text-align: left; font: inherit; color: var(--text);
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--r4); overflow: hidden; box-shadow: var(--e2);
  transition: border-color .14s, transform .14s, box-shadow .14s;
}
.card:hover { border-color: var(--accent-dim); transform: translateY(-2px); }
.card:active { transform: translateY(0); }
/* Never :disabled — disabling the focused element drops focus to <body>. */
.card[aria-busy="true"] { cursor: progress; transform: none; }

.card-plate {
  position: relative; flex: none;
  display: flex; flex-direction: column; justify-content: space-between; gap: var(--s3);
  aspect-ratio: 16 / 10; min-height: 138px; overflow: hidden;
  padding: var(--s3) var(--s4) var(--s3) var(--s4);
  border-bottom: 1px solid var(--line-soft);
  background-color: var(--surface-2);
  /* A corner wash, not a texture: it fades out by 58% of the height so it never
     reaches the rail, and the only thing it sits under is --text at 14:1. */
  background-image: radial-gradient(120% 90% at 0% 0%,
                    color-mix(in oklab, var(--accent) 8%, transparent), transparent 58%);
}
/* This rail used to be one identical --accent-dim edge on every card, argued
   for as "a shelf, not a junk drawer". That was right while the shell owned all
   the colour on the site. It is deliberately reversed now: the rail carries the
   GAME'S OWN declared colour, so the shelf is still a shelf and the spines are
   what tell the boxes apart. The value below is the fallback for a game that
   declared nothing — see the carrier block at the end of this sheet. */
.card-plate::before {
  content: ""; position: absolute; inset: 0 auto 0 0; width: 3px;
  background: var(--accent-dim); transition: background .14s;
}
.card:hover .card-plate::before,
.card:focus-visible .card-plate::before { background: var(--accent); }

.card-name {
  display: block;
  font-weight: var(--w-bold); line-height: 1.06; letter-spacing: -.022em;
  overflow-wrap: anywhere; text-wrap: balance;
  /* Two declarations, in this order and on purpose. The first is the fallback
     for an engine without container queries, which drops the second outright.
     --name-scale is set inline from the string's own length, because a single
     long word cannot wrap and so must count for more than the total. */
  font-size: calc(var(--name-scale, 1) * 1.45rem);
  font-size: calc(var(--name-scale, 1) * clamp(1.2rem, 8.8cqi, 2.05rem));
}
.card-meta {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: var(--s2); flex-wrap: wrap;
  padding-top: var(--s2); border-top: 1px solid var(--line);
}
/* --muted on --surface-2 measures 5.46:1 dark and 4.65:1 light. Both clear AA,
   and the light figure is close enough that the ground under this line has to
   stay flat — which is why the wash above fades out before it. */
.card-fact {
  font-family: var(--mono); font-size: var(--t1); font-weight: var(--w-med);
  letter-spacing: .06em; text-transform: uppercase; color: var(--muted);
  white-space: nowrap;
}
.card-body { display: flex; flex-direction: column; flex: 1;
             padding: var(--s3) var(--s4) var(--s3) var(--s4); }
.card-tagline {
  font-size: var(--t2); line-height: 1.45; color: var(--muted); margin: 0;
  display: -webkit-box; -webkit-box-orient: vertical;
  -webkit-line-clamp: 3; line-clamp: 3; overflow: hidden;
}
/* margin-top:auto is load-bearing, not tidiness: it is the only thing holding every footer on a row level when the taglines differ in length. */ .card-go{
  display: flex; align-items: center; gap: var(--s2);
  margin-top: auto; padding-top: var(--s3);
  font-size: var(--t2); font-weight: var(--w-med); color: var(--text);
}
/* Drawn, not a glyph and not an asset. The label stays --text: --accent as small
   text is 3.72:1 on white, the same failure the game's wild chip documents. A
   2px graphic is held to 3:1, which it clears. */
.card-go::after {
  content: ""; flex: none; width: 6px; height: 6px;
  border-top: 2px solid var(--accent); border-right: 2px solid var(--accent);
  rotate: 45deg; transition: translate .14s;
}
.card:hover .card-go::after { translate: 3px 0; }

@media (forced-colors: active) { .card-plate { border-bottom-color: CanvasText; } }
/* A 16:10 plate on a 352px phone is 220px of poster before the first word. */
@media (max-width: 560px) { .card-plate { aspect-ratio: 5 / 2; } }

/* ── lobby rows ────────────────────────────────────────────────────────────
   A lobby entry is a RECORD, not a poster: every table carries the same fields
   in the same order, and the reader is MATCHING — a code that was read out, a
   name they know — rather than browsing. So the fields are bands: game name
   under game name, code under code, action under action, and a scan is one
   vertical sweep. The Games grid above is cards because those items differ in
   identity; these differ only in data.

   Nothing here reads a game-specific field. A WebGL room scene renders the
   identical row. */
.rows { list-style: none; margin: 0; padding: 0;
        display: flex; flex-direction: column; gap: var(--s2); }
.row {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  grid-template-areas: "title action" "people action" "meta action";
  align-items: center; column-gap: var(--s4); row-gap: var(--s1);
  padding: var(--s3) var(--s4) var(--s3) var(--s4);
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--radius-sm); border-left: 3px solid var(--line);
  transition: border-color .18s, background .18s;
}
.row[data-status="waiting"] { border-left-color: var(--accent); }
.row[data-status="playing"] { border-left-color: var(--live); }
/* No :hover background. The row is not clickable — only the Join button is —
   and a full-surface change is the strongest hover signal there is. */

.row-head  { grid-area: title; display: flex; align-items: baseline;
             gap: var(--s2); flex-wrap: wrap; min-width: 0; }
.row-title { margin: 0; font-size: var(--t3); font-weight: var(--w-med); line-height: 1.2;
             letter-spacing: -.012em; }
/* State in three channels — the rail colour, the pill's dot, and the WORD — so
   it never rests on colour alone. The word is --text on a tint, never --accent. */
.row-state {
  display: inline-flex; align-items: center; gap: var(--s1); flex: none;
  padding: var(--s0) var(--s2) var(--s0) var(--s2); border-radius: var(--r-pill);
  border: 1px solid var(--line); background: var(--surface-2); color: var(--text);
  font-size: var(--t1); font-weight: var(--w-bold); letter-spacing: .08em; text-transform: uppercase;
}
.row-state-dot { width: 7px; height: 7px; border-radius: 50%; flex: none;
                 background: var(--muted); }
.row[data-status="waiting"] .row-state {
  border-color: var(--accent-dim);
  background: color-mix(in oklab, var(--accent) 12%, transparent); }
.row[data-status="waiting"] .row-state-dot { background: var(--accent); }
.row[data-status="playing"] .row-state {
  border-color: color-mix(in oklab, var(--live) 45%, var(--line));
  background: color-mix(in oklab, var(--live) 12%, transparent); }
.row[data-status="playing"] .row-state-dot { background: var(--live); }

/* WHO is seated and how many chairs are free, as one object on one line. An
   earlier version stacked a disc track above a names line; with real accounts
   most discs render the same initial, so that was a row of near-identical
   circles above a line already spelling the names out. Here the discs carry
   colour and capacity, the text carries names, neither repeats the other. */
.row-people { grid-area: people; display: flex; align-items: center;
              gap: var(--s2); min-width: 0; }
.row-faces  { display: flex; align-items: center; gap: var(--s1); flex: none; }
.row-names  { margin: 0; font-size: var(--t2); color: var(--muted); min-width: 0;
              overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* An empty chair, drawn rather than apologised for — the same idiom the game's
   empty card slot uses. Hidden on a running table: nobody can take one, so
   drawing them would misstate the roster. */
.seat-ring { width: 22px; height: 22px; border-radius: 50%; flex: none;
             border: 1px dashed var(--line-strong); }

.row-meta { grid-area: meta; margin: 0; display: flex; align-items: baseline;
            gap: var(--s2); flex-wrap: wrap;
            font-size: var(--t1); color: var(--muted);
            font-variant-numeric: tabular-nums; }
/* The code IDENTIFIES the row — two tables of the same game are otherwise the
   same row twice — and does not compete with the game name. Mono and tracked so
   six characters read as six, --muted so it stays in the fact tier, and no box:
   a bordered full-contrast chip out-weighted the title and made a column of
   mono chips the strongest rhythm on the page. */
.row-code { font-family: var(--mono); font-weight: var(--w-bold); letter-spacing: .1em;
            color: var(--muted); }
.row-sep::before { content: "\00b7"; }

.row-action { grid-area: action; display: flex; align-items: center; gap: var(--s2); }
.row-join   { min-width: 5.5rem; }
/* A word, not a disabled button: a disabled control is skipped in several
   screen-reader browse modes, which would delete running tables from the lobby
   a screen reader can perceive. */
.row-note   { font-size: var(--t1); font-weight: var(--w-med); color: var(--muted);
              white-space: nowrap; }

@media (max-width: 620px) {
  .row { grid-template-columns: minmax(0, 1fr);
         grid-template-areas: "title" "people" "meta" "action";
         row-gap: var(--s2); }
  .row-action { margin-top: var(--s0); }
  .row-action .btn { flex: 1; }
  .row-names { white-space: normal; }   /* two lines beat an ellipsis on a phone */
}
/* Forced colours flattens backgrounds, which would make a taken chair and a
   free one the same circle and erase the state pill. */
@media (forced-colors: active) {
  .row-state { border-color: CanvasText; }
  .avatar    { border: 1px solid CanvasText; }
  .seat-ring { border: 1px dashed CanvasText; }
}

.btn-sm { padding: var(--s1) var(--s3); font-size: var(--t2); }
.btn:disabled { opacity: .45; cursor: not-allowed; }
.btn:disabled:hover { border-color: var(--line); background: var(--surface); }
.btn-danger { color: var(--live); border-color: color-mix(in oklab, var(--live) 40%, var(--line)); }
.btn-danger:hover { color: var(--live); border-color: var(--live);
                    background: color-mix(in oklab, var(--live) 10%, var(--surface)); }

.empty {
  padding: clamp(1.8rem, 5vw, 2.6rem) 1.5rem; text-align: center;
  border: 1px dashed var(--line-strong); border-radius: var(--radius);
  background: var(--surface);
  box-shadow: var(--e1);
}
.empty p { margin: 0; font-size: var(--t2); }
.empty p + p { margin-top: var(--s2); }
.empty-title { font-weight: var(--w-bold);
  font-size: var(--t3);
}
/* A table and chairs nobody is in, drawn from PLATFORM shapes — seats — so it
   carries no suggestion of cards or turns and stays true when game #2 is a 3D
   room. Used ONLY where nothing is occupied, so it cannot read as occupancy. */
.mark { display: block; width: 132px; height: auto; margin: 0 auto var(--s4); opacity: .8; }
.mark-table { fill: var(--surface-2); stroke: var(--line-strong); stroke-width: 1.5; }
.mark-seat  { fill: none; stroke: var(--line-strong); stroke-width: 1.5; stroke-dasharray: 3 3; }

/* ── profile page ──────────────────────────────────────────────────────── */
.narrow { max-width: 660px; }
.back {
  display: inline-block; margin-bottom: var(--s5); text-decoration: none;
  color: var(--muted); font-size: var(--t2); font-weight: var(--w-med);
}
.back:hover { color: var(--accent); }

/* Settings groups. Adding a setting is one .setrow; adding a category is one
   .setgroup — the page is built to grow, since more settings are coming. */
.setgroup { border: 0; padding: 0; margin: var(--s6) 0 0; }
.setgroup:first-of-type { margin-top: var(--s5); }
.setgroup h3 {
  margin: 0 0 var(--s1); padding-bottom: var(--s3); border-bottom: 1px solid var(--line-soft);
  font-size: var(--t1); font-weight: var(--w-bold); letter-spacing: .14em; text-transform: uppercase;
  color: var(--muted);
  display: flex;
  align-items: center;
  gap: var(--s3);
  font-family: var(--mono);
}
.setrow {
  display: flex; align-items: flex-start; gap: var(--s5); flex-wrap: wrap;
  padding: var(--s4) 0; border-bottom: 1px solid var(--line-soft);
}
.setrow:last-child { border-bottom: 0; }
.setrow-label { flex: 1 1 240px; min-width: 0; }
.setrow-label label, .pseudo-label {
  display: block; font-size: var(--t3); font-weight: var(--w-med); margin-bottom: var(--s1);
}
.setrow-label .hint { margin: 0; }
.setrow-control { flex: 0 1 260px; min-width: 200px; }
.setrow-control code {
  /* A 36-character UUID in a 200px-min control overflows at 375px without this. */
  font-family: var(--mono); font-size: var(--t1); color: var(--muted);
  overflow-wrap: anywhere; user-select: all; max-width: 100%;
  background: var(--surface); border: 1px solid var(--line-strong);
  border-radius: var(--radius-sm); padding: var(--s2) var(--s3); display: inline-block;
}

.swatches { display: flex; gap: var(--s2); flex-wrap: wrap; }
.sw {
  width: 34px; height: 34px; border-radius: 50%; cursor: pointer; padding: 0;
  border: 2px solid transparent; outline-offset: 3px;
  box-shadow: inset 0 0 0 1px rgb(255 255 255 / .18);
}
.sw { display: grid; place-items: center; color: var(--marker-ink); }
.sw[aria-checked="true"] { border-color: var(--text); }
/* Selection carried by SHAPE as well as a border tint. */
.sw-check { width: 15px; height: 15px; fill: none; stroke: currentColor;
            stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }
.sw[aria-checked="false"] .sw-check { visibility: hidden; }
.setrow-preview .seat { pointer-events: none; }

.form-foot {
  display: flex; align-items: center; gap: var(--s4);
  margin-top: var(--s6); padding-top: var(--s5); border-top: 1px solid var(--line-soft);
}
.saved { font-size: var(--t2); color: var(--accent); font-weight: var(--w-med); }

select {
  font: inherit; width: 100%; color: var(--text); background: var(--bg); cursor: pointer;
  border: 1px solid var(--line-strong); border-radius: var(--radius-sm); padding: var(--s3) var(--s3);
}
select:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

.who[aria-current="page"] { border-color: var(--line); background: var(--surface); }

/* ── the table ─────────────────────────────────────────────────────────── */
.table-head {
  display: flex; align-items: flex-start; gap: var(--s4); flex-wrap: wrap;
  margin-bottom: var(--s5);
}
.table-head p { margin: 0; font-size: var(--t2); }
/* Wraps so the sentence explaining why Start is off sits under the button it
   explains, rather than beside it. */
.table-actions { margin-left: auto; display: flex; gap: var(--s2);
                 flex-wrap: wrap; justify-content: flex-end; }

/* Every seat the table HAS, occupied or not, so "how many more people do we
   need" is something you count rather than compute. */
.seats { list-style: none; margin: 0 0 var(--s4); padding: 0;
         display: flex; flex-wrap: wrap; gap: var(--s2); }
.seat {
  display: flex; align-items: center; gap: var(--s2);
  padding: var(--s2) var(--s3) var(--s2) var(--s2); min-height: 42px;
  background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-pill);
  font-size: var(--t2); font-weight: var(--w-med);
  box-shadow: var(--e1);
}
.seat .avatar { --mk: 30px; }
.seat-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.seat-free { background: none; border-style: dashed; color: var(--muted); font-weight: var(--w-med);  border-color: var(--line-strong);}
.seat-free[data-need="true"] { border-color: var(--accent-dim); color: var(--text);
  box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--accent) 22%, transparent);
}
.seat-free .seat-disc { width: 30px; height: 30px; border-radius: 50%; flex: none;
                        border: 1px dashed var(--line-strong); }
.seat-free[data-need="true"] .seat-disc { border-color: var(--accent-dim); }
/* The dashed outline alone is not enough — --line on --surface is about 1.3:1 in
   dark, well under the 3:1 non-text bar — which is why the WORD is on the plate
   in --muted rather than the meaning being left to the border. */
.tag {
  font-size: var(--t1); font-weight: var(--w-bold); letter-spacing: .08em; text-transform: uppercase;
  color: var(--muted); border: 1px solid var(--line); border-radius: var(--r-pill); padding: var(--s0) var(--s2);
}

/* ── boot ─────────────────────────────────────────────────────────────────
   The page starts here and render() is what takes it away. A failed /api/me
   used to leave a silently empty page with no message and no way back. */
.boot { min-height: 46vh; min-height: 46dvh;
        display: grid; place-items: center; text-align: center;
        padding: clamp(1.4rem, 4vw, 2.6rem) clamp(.9rem, 3vw, 2rem); }
/* Held back 250ms, so a local answer never paints it at all. The delay survives
   prefers-reduced-motion deliberately: the global clamp shortens DURATION, not
   delay, and `both` keeps the element invisible until the delay elapses. It is
   also why there is no spinner — a rotation clamped to .001ms and set to
   infinite is a strobe, not a still. */
.boot-msg { margin: 0; font-size: var(--t2); color: var(--muted);
            animation: gn-hold 120ms ease-out 250ms both; }
@keyframes gn-hold { from { opacity: 0; } }
.boot-error { max-width: 34rem; }
.boot-title { margin: 0 0 var(--s1); font-size: var(--t4); font-weight: var(--w-bold); }
.boot-error .muted { margin: 0 0 var(--s4); font-size: var(--t2); }

/* ── first run ────────────────────────────────────────────────────────────
   Every new account is created with an empty name and renders as "Player" to
   everyone else. One field, one Save, and nothing is blocked — a modal for this
   would be a wall in front of the thing the person came to do. */
.firstrun {
  display: flex; flex-wrap: wrap; align-items: flex-end; gap: var(--s3) var(--s5);
  margin-bottom: clamp(2.2rem, 6vw, 3.4rem);
  padding: clamp(1rem, 3vw, 1.35rem);
  background: var(--surface);
  border: 1px solid var(--line); border-left: 3px solid var(--accent);
  border-radius: var(--radius); box-shadow: var(--shadow);
}
.firstrun-head { flex: 1 1 18rem; min-width: 0; }
.firstrun-head h2 { margin: 0 0 var(--s1); font-size: var(--t4); }
.firstrun-head p  { margin: 0; font-size: var(--t2); }
.firstrun-form { display: flex; gap: var(--s2); flex: 0 1 22rem; min-width: 0; }
.firstrun-form input[type="text"] { flex: 1 1 auto; min-width: 0; }
.firstrun-form .btn { flex: none; }
.firstrun .hint { flex: 1 1 100%; margin: 0; }
.firstrun .hint a { color: var(--text); text-underline-offset: 2px; }

/* ── join by code ─────────────────────────────────────────────────────────
   The paste target for a code someone read out. Resolved against the table list
   this client already holds — no endpoint, no judgement. */
.codejoin { display: flex; align-items: flex-end; gap: var(--s2);
            margin: var(--s4) 0 0; flex-wrap: wrap; }
.codejoin .field { flex: 0 0 12rem; }
.codejoin label { margin-bottom: var(--s1); }
#joinCode { font-family: var(--mono); letter-spacing: .16em; text-transform: uppercase;
            text-align: center; padding: var(--s2) var(--s2); }
.codejoin .err { flex: 1 1 100%; margin: var(--s1) 0 0; }

/* ── the waiting room ─────────────────────────────────────────────────────
   Platform, not game, and deliberately NOT inside #gameSurface: this is the
   same screen when game #2 is a WebGL room. The only picture on it is a dashed
   circle, which is a chair nobody is in. */
.waitroom { display: grid; gap: var(--s4); margin-bottom: var(--s5);
  grid-template-columns: minmax(0, 1fr);
}
.invite {
  display: flex; flex-direction: column; align-items: flex-start; gap: var(--s2);
  padding: var(--s4) var(--s4) var(--s4);
  background: var(--surface); border: 1px solid var(--line);
  border-left: 3px solid var(--accent);
  border-radius: var(--r4); box-shadow: var(--e2);
}
.invite-label { margin: 0; font-size: var(--t1); font-weight: var(--w-bold); letter-spacing: .1em;
                text-transform: uppercase; color: var(--muted); }
/* The one thing on this screen a person reads out loud, so it is the largest
   thing on it. A BUTTON because clicking it SELECTS it: navigator.clipboard is
   undefined on the tailnet's plain-http origin, which SERVICES.md records as an
   intended way in, so the guaranteed affordance has to be the primary one. The
   extra right padding pays for the trailing letter-space, which is otherwise
   applied after the last character and pushes the text off-centre. */
.invite-code {
  font: inherit; font-family: var(--mono); font-weight: var(--w-bold); line-height: 1.05;
  font-size: clamp(1.5rem, 5vw, 2.2rem); letter-spacing: .16em;
  color: var(--text); background: var(--surface-2);
  border: 1px solid var(--line-strong); border-radius: var(--radius-sm);
  padding: var(--s1) var(--s2) var(--s1) var(--s3); cursor: pointer;
  transition: border-color .12s;
  border-style: dashed;
}
.invite-code:hover { border-color: var(--accent-dim); }
/* Not .saved, which is --accent at .85rem and carries the light-theme contrast
   problem the game's wild chip documents. */
.invite-state { margin: 0; font-size: var(--t2); font-weight: var(--w-med); color: var(--text);
                min-height: 1.2em; }
.invite .hint { margin: 0; max-width: 52ch; }

/* ── the device link, on the profile page ─────────────────────────────────
   Deliberately the same furniture as the table code above: same class, same
   click-to-select affordance, same reason (no clipboard API on plain http). A
   second visual language for "here is a code, read it out" would be a second
   thing to learn for no gain. */
.linkbox {
  display: flex; flex-direction: column; align-items: flex-start; gap: var(--s2);
  margin-top: var(--s4); padding: var(--s4) var(--s4) var(--s4);
  background: var(--surface-2); border: 1px solid var(--line);
  border-left: 3px solid var(--accent);
  border-radius: var(--radius);
}
/* Smaller than the table code, because it is nearly three times as long: the
   table's is 6 characters and this is 15 in three groups. At the shared size a
   phone in portrait wraps it mid-group, which is precisely how a code gets
   misread aloud. */
.linkbox .invite-code {
  font-size: clamp(1rem, 3.9vw, 1.45rem); letter-spacing: .12em;
  background: var(--surface);
}
/* "Already sent": outstanding, but minted where this browser cannot see it. Not
   a live control, and it must not look like one. */
.linkbox .invite-code:disabled {
  color: var(--muted); cursor: default; font-family: inherit;
  font-weight: var(--w-med); letter-spacing: normal; font-size: var(--t3);
}
.linkbox .invite-code:disabled:hover { border-color: var(--line); }
.linkbox .hint { margin: 0; max-width: 52ch; }

/* ── the lobby, in two lists ──────────────────────────────────────────────
   Open tables and running ones answer different questions, so the running ones
   sit under their own quieter heading rather than mixed into the joinable list. */
.running { margin-top: var(--s5); }
.running-head {
  margin: 0 0 var(--s2); font-size: var(--t1); text-transform: uppercase;
  letter-spacing: .08em; color: var(--muted);
}
/* What a table is SET TO — the game's own option labels, resolved by the server
   so browsing the lobby never means loading a game's code. */
.row-settings {
  margin: var(--s1) 0 0; font-size: var(--t1); color: var(--muted);
}
.row-settings:empty { display: none; }

/* ── friends ──────────────────────────────────────────────────────────────── */
.findform { display: flex; gap: var(--s2); align-items: center; }
.findform input {
  flex: 1; min-width: 0; font: inherit; padding: var(--s2) var(--s3);
  color: var(--text); background: var(--surface-2);
  border: 1px solid var(--line-strong); border-radius: var(--radius-sm);
}
#findHint { margin: var(--s1) 0 var(--s4); }
.people { list-style: none; margin: 0 0 var(--s2); padding: 0; display: grid; gap: var(--s2); }
.person {
  display: flex; align-items: center; gap: var(--s2);
  padding: var(--s2) var(--s3);
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  box-shadow: var(--e1);
}
.person-name {
  flex: 1; min-width: 0; font-weight: var(--w-bold);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.person-note { font-size: var(--t1); color: var(--muted); }

/* ── the archive ──────────────────────────────────────────────────────────── */
.matches { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--s3); }
.match {
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--r4); padding: var(--s3) var(--s4);
  box-shadow: var(--e1);
}
.match-head {
  display: flex; align-items: baseline; gap: var(--s2);
  margin-bottom: var(--s2); padding-bottom: var(--s2); border-bottom: 1px solid var(--line-soft);
}
.match-game { font-weight: var(--w-bold); }
.match-when { margin-left: auto; font-size: var(--t1); color: var(--muted);
  font-family: var(--mono);
  letter-spacing: .06em;
  text-transform: uppercase;
}
.match-seats { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--s1); }
.match-seat { display: flex; align-items: center; gap: var(--s2); font-size: var(--t2); }
/* Your own row, marked without colour alone — the weight carries it too. */
.match-seat[data-you="true"] { font-weight: var(--w-bold); }
.match-seat[data-you="true"] .match-name { color: var(--accent); }
.match-place {
  min-width: 2.4em; font-size: var(--t1); font-weight: var(--w-bold);
  color: var(--muted); font-variant-numeric: tabular-nums;
}
.match-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.match-score { font-variant-numeric: tabular-nums; font-weight: var(--w-bold); }

/* ── how to play ──────────────────────────────────────────────────────────
   The GAME supplies this content (DESIGN.md §4); the platform only sizes it for
   reading. Capped at a comfortable measure — long lines are the single easiest
   way to make rules text unreadable. */
.rules { max-width: 64ch;
  counter-reset: rulesec;
}
.rules h3 {
  margin: var(--s5) 0 var(--s2); font-size: var(--t1); text-transform: uppercase;
  letter-spacing: .08em; color: var(--muted);
}
.rules p { margin: 0 0 var(--s3); }
.rules-lede { font-size: var(--t4);
  line-height: 1.55;
  color: var(--text);
}
.rules-list { margin: 0 0 var(--s2); padding-left: var(--s5); display: grid; gap: var(--s1); }
.rules-list li { line-height: 1.55; }
.rules-note {
  margin-top: var(--s5); padding: var(--s3) var(--s3);
  background: var(--surface-2); border-left: 3px solid var(--accent);
  border-radius: var(--radius-sm); font-size: var(--t2);
}

/* ── the seat picker on the setup screen ──────────────────────────────────
   One decision, every value one press. A number spinner would hide the range
   the game actually allows behind two tiny arrows. */
.seatpick { display: flex; flex-wrap: wrap; gap: 0;
  padding: 3px;
  width: max-content;
  max-width: 100%;
  background: var(--surface-2);
  border: 1px solid var(--line-strong);
  border-radius: var(--r3);
}
.seatpick-n {
  font: inherit; font-weight: var(--w-bold); font-variant-numeric: tabular-nums;
  min-width: 2.6rem; min-height: 2.6rem; padding: var(--s1) var(--s2);
  color: var(--text); background: var(--surface);
  border: 1px solid var(--line-strong); border-radius: var(--radius-sm);
  cursor: pointer; transition: border-color .12s, background .12s;
}
.seatpick-n:hover { border-color: var(--accent-dim); }
.seatpick-n[aria-checked="true"] {
  background: var(--accent); border-color: var(--accent); color: var(--on-accent);
  box-shadow: var(--e1);
}
/* .seatpick-n needs no light override: it was hardcoding what --accent is. */

/* ── the way back to a table you are sitting at ───────────────────────────
   In the top bar because it has to be reachable from every page — that is the
   entire reason it exists (DESIGN.md §4). */
.tablechip {
  display: inline-flex; align-items: center; gap: var(--s1);
  padding: var(--s1) var(--s2);
  font-size: var(--t2); font-weight: var(--w-bold); text-decoration: none;
  color: var(--text); background: var(--surface-2);
  border: 1px solid var(--line); border-radius: var(--r-pill);
  white-space: nowrap; max-width: 42vw; overflow: hidden; text-overflow: ellipsis;
}
.tablechip:hover { border-color: var(--accent-dim); }
.tablechip-dot {
  width: .5rem; height: .5rem; border-radius: 50%;
  background: var(--ok); flex: none;
}

.gate-line { margin: 0 0 var(--s5); font-size: var(--t2); font-weight: var(--w-med); color: var(--text); }
.gate-line[data-blocked="false"] { color: var(--muted); font-weight: var(--w-med); }
.gate-line:empty { display: none; }

.start-why { flex: 1 0 100%; margin: var(--s0) 0 0; text-align: right;
             font-size: var(--t2); color: var(--muted); }
.start-why.is-nudge { animation: gn-nudge 900ms ease-out; }
@keyframes gn-nudge { 0%, 100% { color: var(--muted); } 20%, 60% { color: var(--text); } }

/* aria-disabled, NOT disabled — see the JS. Because the control stays focusable
   and stays clickable, it has to stay readable: a colour change rather than the
   opacity used for a genuinely inert button. */
.btn[aria-disabled="true"] { color: var(--muted); cursor: not-allowed; }
.btn[aria-disabled="true"]:active { transform: none; }
.btn-primary[aria-disabled="true"],
.btn-primary[aria-disabled="true"]:hover {
  background: var(--surface-2); border-color: var(--line);
  color: var(--muted); box-shadow: none;
}

/* A table that came back from disk written by a different build of its game.
   It keeps its row, its seats and its state, but it is never handed to the
   engine — a board that is quietly wrong three turns later is far worse than one
   that says so. */
.stale-note {
  margin: 0 0 var(--s5); padding: var(--s3) var(--s4);
  font-size: var(--t2); line-height: 1.5;
  background: color-mix(in oklab, var(--live) 10%, var(--surface));
  border: 1px solid color-mix(in oklab, var(--live) 40%, var(--line));
  border-left: 3px solid var(--live);
  border-radius: var(--radius-sm);
}

/* ── motion ───────────────────────────────────────────────────────────────
   Four cues, each attached to a state change worth noticing, each with a static
   carrier so nothing is lost when motion is off. */
.row[data-new="true"] { animation: gn-row-in 180ms cubic-bezier(.2,.7,.3,1); }
@keyframes gn-row-in { from { opacity: 0; translate: 0 -8px; } }
.row[data-started="true"] { animation: gn-row-start 700ms ease-out; }
@keyframes gn-row-start {
  0%, 100% { background: var(--surface); }
  30% { background: color-mix(in oklab, var(--live) 15%, var(--surface)); }
}
.seat[data-arrived="true"] { animation: gn-seat-in 200ms cubic-bezier(.2,.7,.3,1); }
@keyframes gn-seat-in { from { opacity: 0; scale: .94; } }


/* ── Three Thirteen ────────────────────────────────────────────────────────
   Owned by the game, not the platform (DESIGN.md §4). Every rule below is
   about how this one game looks; nothing here is meant to be reused by the
   next one, which may not use the DOM at all.

   Cards are drawn, never fetched: each pip is an inline <svg> filled with
   currentColor. No request, no font gamble, correct in both themes for free. */

/* THE TOKENS ARE ON BOTH ROOTS, and that is not tidiness.
   The reveal overlay is a SIBLING of .tt, not a descendant — it has to be, so a
   repaint of the table cannot destroy it mid-read. Custom properties inherit
   down the tree and nowhere else, so tokens declared on .tt alone reach none of
   the reveal: every card in it collapsed to 1.28px with no radius, no shadow,
   and hearts rendered in --text instead of red, which is the entire information
   content of a suit. Any new token goes on both selectors or it does not exist
   inside the reveal. */
.tt, .tt-reveal {
  /* One knob. Every card dimension derives from it, so the hand, the piles and
     the reveal are one object at four sizes. Tracks the viewport rather than
     stepping — a container query cannot be used here, because an element
     cannot query itself and inline-size containment would imply layout
     containment on .tt, trapping the lifted card's z-index. */
  --tt-card-w: clamp(34px, 2.9vw + 23px, 46px);
  --tt-slot-min: 24px;                            /* the minimum touch target */
  --tt-lift: 10px;

  /* Card red. Game-specific by necessity, and deliberately NOT --live: that is
     the platform's alarm colour, and the reveal needs --live to mean "this cost
     you points". If hearts were --live too, half the reveal would read as an
     alarm and the cost signal would die. 5.25:1 on --surface in dark, 5.93:1 in
     light — both clear AA. */
  --tt-red: #e8615c;

  /* A 34px card under --shadow (a panel shadow) is a smudge. */
  --tt-card-shadow: 0 1px 1px rgb(0 0 0 / .28), 0 4px 10px -6px rgb(0 0 0 / .5);
}
html[data-theme="light"] :is(.tt, .tt-reveal) {
  --tt-red: #c0261f;
  --tt-card-shadow: 0 1px 1px rgb(16 36 60 / .10), 0 4px 10px -6px rgb(16 36 60 / .25);
}

.tt {
  display: flex; flex-direction: column; gap: var(--s4);
  position: relative;
  /* A lifted card must still escape upward, so this is only ever horizontal. */
  overflow-x: clip;
}

/* Game-scoped visually hidden text. */
.tt-sr {
  position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
  overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;
}
/* Never display:none — some engines refuse to resolve <use> into a
   display:none subtree. */
.tt-sprite { position: absolute; width: 0; height: 0; overflow: hidden; }

.tt-table { display: grid; gap: var(--s4) var(--s5); align-items: start; }
@media (min-width: 820px) {
  .tt-table { grid-template-columns: minmax(0, 1fr) clamp(260px, 30%, 320px); }
}
.tt-felt, .tt-rail { display: flex; flex-direction: column; gap: var(--s4); min-width: 0; }

/* The dock holds the hand and every commit button under the thumb. Sticky
   bottom, which is why .tt is a flex column and not a grid: a sticky grid item
   in a self-sized row has nowhere to travel. */
.tt-dock {
  position: sticky; bottom: 0; z-index: 6;
  display: flex; flex-direction: column; gap: var(--s2);
  padding: .55rem 0 calc(.55rem + env(safe-area-inset-bottom, 0px));
  background: linear-gradient(to top, var(--bg) 82%, transparent);
}

/* ── status strip ─────────────────────────────────────────────────────── */
.tt-status {
  display: flex; align-items: center; gap: var(--s3); flex-wrap: wrap;
  padding-bottom: var(--s3); border-bottom: 1px solid var(--line-soft);
}
.tt-round { font-weight: var(--w-bold); font-size: var(--t3); }
.tt-round span { color: var(--muted); font-size: var(--t1); font-weight: var(--w-med); }
.tt-counts { margin-left: auto; display: flex; gap: var(--s4); font-size: var(--t1); color: var(--muted); }
.tt-counts b { font-family: var(--mono); color: var(--text); font-weight: var(--w-bold); }

/* The wild chip. Accent is a FILL here, behind --on-accent, and the words stay
   --text on --surface. This was written when light --accent was #b07a15 and
   unusable as text (3.3:1); it is #8a5f10 now and clears AA either way, but
   fill-behind-on-accent is still the right shape for a chip. */
.tt-wildchip {
  display: inline-flex; align-items: center; gap: var(--s2); flex: none;
  padding: var(--s0) var(--s1) var(--s0) var(--s0); border-radius: var(--r-pill);
  border: 1px solid var(--accent-dim); background: var(--surface);
}
/* 1.2rem = 19.2px at the default root size, and bold — deliberately over the
   18.66px-bold threshold at which WCAG treats text as "large" and the AA bar
   drops to 3:1. --on-accent on --accent measures 8.77:1 in dark but only
   3.66:1 in light, so at any smaller size the light theme would fail, and this
   is the one glyph on the surface a player must never misread. Sizing the BOX
   to 24px does not help: the threshold is on the font. */
.tt-wildchip-rank {
  display: grid; place-items: center; min-width: 28px; height: 28px; padding: 0 var(--s1);
  font-family: var(--mono); font-size: var(--t4); font-weight: var(--w-bold); line-height: 1;
  color: var(--on-accent); background: var(--accent); border-radius: var(--r-pill);
}
.tt-wildchip-word {
  font-size: var(--t1); font-weight: var(--w-bold); letter-spacing: .04em; color: var(--text);
}
.tt-dockbar { display: flex; align-items: center; gap: var(--s2); flex-wrap: wrap; min-height: 26px; }
.tt-turn { margin: 0; font-weight: var(--w-med); font-size: var(--t2); flex: 1 1 12rem; min-width: 0; }
.tt-turn[data-mine="true"] { color: var(--text); }
.tt-notice { margin: 0; font-size: var(--t2); font-weight: var(--w-med); color: var(--live); }

.tt-log {
  list-style: none; margin: 0; padding: 0;
  max-height: 4.6rem; overflow-y: auto; overscroll-behavior: contain;
  font-size: var(--t1); color: var(--muted); line-height: 1.5;
}
.tt-log li { padding: var(--s0) 0; }
.tt-log li + li { border-top: 1px solid var(--line-soft); }

/* ── seats ────────────────────────────────────────────────────────────── */
.tt-seats { display: flex; flex-direction: column; gap: var(--s1); }
.tt-seat {
  display: flex; align-items: center; gap: var(--s3);
  padding: var(--s2) var(--s3); border-radius: var(--radius-sm);
  border: 1px solid transparent; font-size: var(--t2);
}
/* Whose turn it is — the most-consulted state on the table — was one border
   over a 1.11:1 fill change. The 3px accent rail is the idiom this sheet
   already uses for "this one is yours/active". */
.tt-seat[data-active="true"] { border-color: var(--accent-dim); background: var(--surface);
                               box-shadow: inset 3px 0 0 var(--accent); }
.tt-seat[data-me="true"] .tt-seat-name { font-weight: var(--w-bold); }
.tt-seat-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tt-seat-cards { color: var(--muted); font-size: var(--t2); }
.tt-seat-total { font-family: var(--mono); font-weight: var(--w-bold); min-width: 3ch; text-align: right; }
.tt-rail-head {
  font-size: var(--t1); font-weight: var(--w-bold); letter-spacing: .08em; text-transform: uppercase;
  color: var(--muted); margin: 0 0 var(--s1);
}
.tt-seat-final { font-size: var(--t1); color: var(--muted); min-width: 1.2em; text-align: center; }
.tt-seat[data-final="taken"] .tt-seat-final { color: var(--accent); }

/* ── the card ─────────────────────────────────────────────────────────── */
.tt-card {
  position: relative; display: block; box-sizing: border-box;
  /* aspect-ratio, not a --tt-card-h token. A height token is computed where it
     is DECLARED, so overriding --tt-card-w further down the tree (the small
     cards in a meld tray, the 30px cards in a claimed group) changed the width
     and left the height at the outer value — a 26px card stretched to 47.6px.
     Deriving from the element's own width cannot drift that way. */
  width: var(--tt-card-w); aspect-ratio: 5 / 7;   /* 2.5 x 3.5in, the poker ratio */
  padding: 0; font: inherit; line-height: 1; overflow: hidden;
  background: var(--surface); color: var(--text);
  border: 1px solid var(--line-strong); border-radius: calc(var(--tt-card-w) * .12);
  box-shadow: var(--tt-card-shadow);
}
.tt-card[data-suit="2"], .tt-card[data-suit="4"] { color: var(--tt-red); }

/* The index is load-bearing: in an overlapped fan the leftmost ~24px is all you
   see, and the corner index is the part of a real card designed for exactly
   that. Inset past the 3px wild bar. */
.tt-card-corner {
  position: absolute;
  top:  calc(var(--tt-card-w) * .077);
  left: calc(var(--tt-card-w) * .15);
  display: flex; flex-direction: column; align-items: center;
  gap: calc(var(--tt-card-w) * .02);
  pointer-events: none;
}
.tt-card-rank {
  font-size: max(10px, calc(var(--tt-card-w) * .30));
  font-weight: var(--w-bold); letter-spacing: -.045em;
  font-variant-numeric: lining-nums tabular-nums;
}
.tt-pip { display: block; fill: currentColor; pointer-events: none; }
.tt-pip-sm { width: calc(var(--tt-card-w) * .24); height: calc(var(--tt-card-w) * .24); }
/* Bottom-right, not centred: in a fan this is what gets covered, and it is
   better fully hidden than sliced. */
.tt-pip-lg {
  position: absolute;
  right:  calc(var(--tt-card-w) * .08);
  bottom: calc(var(--tt-card-w) * .07);
  width:  calc(var(--tt-card-w) * .44); height: calc(var(--tt-card-w) * .44);
  opacity: .9;
}
.tt-cards[data-size="sm"] { --tt-card-w: 26px; }
.tt-cards[data-size="sm"] .tt-pip-lg { display: none; }

/* WILD is a fact about the ROUND, not the card. It changes every hand and
   misreading it loses the round, so it gets three channels, and the one that
   survives an 80%-overlapped fan lives in the leftmost 3px. Deliberately not a
   background tint: mixing accent into --surface in dark mode raises the card's
   luminance enough to drop --tt-red below 4.5:1. The third channel is the word
   "wild" in the accessible name. */
.tt-card[data-wild="true"] {
  border-color: var(--accent);
  box-shadow: inset 3px 0 0 var(--accent), var(--tt-card-shadow);
}

/* SELECTED. aria-pressed on the slot is the state; this is its picture. The
   bottom bar is what makes selection legible in a 24px sliver, and it is
   --text rather than --accent so a selected wild card is not one gold blob. */
.tt-card[data-sel="true"] { border-color: var(--text); }
.tt-card[data-sel="true"]::after {
  content: ""; position: absolute; inset: auto 0 0 0; height: 3px; background: var(--text);
}

.tt-card-back {
  --tt-back-ink: color-mix(in oklab, var(--accent) 34%, transparent);
  background-color: var(--surface-2);
  background-image:
    repeating-linear-gradient( 45deg, var(--tt-back-ink) 0 1px, transparent 1px 6px),
    repeating-linear-gradient(-45deg, var(--tt-back-ink) 0 1px, transparent 1px 6px);
  box-shadow: inset 0 0 0 3px var(--surface), inset 0 0 0 4px var(--line-soft),
              var(--tt-card-shadow);
}
/* Empty is the honest state: nothing is drawn in it. */
.tt-card-slot { background: none; border-style: dashed; box-shadow: none; }

/* Windows High Contrast drops box-shadows and forces colour, taking the wild
   bar and the selected bar with them. Restore both with properties it keeps. */
@media (forced-colors: active) {
  .tt-card { border-color: CanvasText; }
  .tt-pip  { fill: CanvasText; }
  .tt-card[data-wild="true"] { outline: 2px dashed CanvasText; outline-offset: -4px; }
  .tt-card[data-sel="true"]  { outline: 3px solid Highlight;   outline-offset: 1px; }
}

/* ── piles ────────────────────────────────────────────────────────────── */
.tt-piles { display: flex; gap: var(--s5); align-items: flex-start; }
.tt-pile {
  display: flex; flex-direction: column; align-items: center; gap: var(--s2);
  font: inherit; cursor: pointer; padding: var(--s2); border-radius: var(--radius-sm);
  background: transparent; border: 1px solid transparent; color: var(--text);
}
.tt-pile:hover:not([aria-disabled="true"]) { border-color: var(--accent-dim); background: var(--surface); }
/* aria-disabled, not disabled: a disabled control is skipped in several
   screen-reader browse modes, which would take the deck count with it. */
.tt-pile[aria-disabled="true"] { cursor: default; opacity: .62; }
.tt-pile-label { font-size: var(--t1); color: var(--muted); font-variant-numeric: tabular-nums; }

/* Depth, from the count the server actually sends. Ghost layers are siblings
   painted before the top card — never z-index:-1, which depends on the
   ancestor's background being opaque. */
.tt-pile-stack { position: relative; width: var(--tt-card-w); aspect-ratio: 5 / 7; }
.tt-pile-stack > * { position: absolute; inset-block-start: 0; inset-inline-start: 0; }
.tt-ghost {
  width: var(--tt-card-w); aspect-ratio: 5 / 7;
  border: 1px solid var(--line-strong); border-radius: calc(var(--tt-card-w) * .12);
  background: var(--surface-2);
}
.tt-ghost[data-i="1"] { translate: 2px 2px; }
.tt-ghost[data-i="2"] { translate: 4px 4px; }

/* ── the hand ─────────────────────────────────────────────────────────── */
.tt-groups { display: flex; flex-wrap: wrap; gap: var(--s2); }
.tt-groups:empty { display: none; }
.tt-group {
  display: flex; align-items: center; gap: var(--s1); padding: var(--s1) var(--s1);
  border: 1px solid var(--accent-dim); border-radius: var(--radius-sm);
  background: color-mix(in oklab, var(--accent) 14%, transparent);
}
.tt-group .tt-slot { --tt-card-w: 30px; }

/* overflow-x forces overflow-y to auto, so the lift and the focus ring are
   paid for with padding rather than allowed to spill. */
.tt-fan-rail {
  overflow-x: auto; overscroll-behavior-x: contain;
  scroll-padding-inline: var(--s2); scrollbar-width: none;
  padding-block: calc(var(--tt-lift) + 6px) 4px;
}
.tt-fan-rail::-webkit-scrollbar { display: none; }
.tt-fan { display: flex; align-items: flex-start; }

/* Fixed-height slots that shrink, each holding one absolutely-positioned face
   that stays full card width and overflows right. Slots never overlap, so
   hit-testing is unambiguous and a raised card cannot swallow its neighbour's
   strip — the failure that kills naive fans. Faces overlap freely, so it reads
   as a hand. Later slots come later in the DOM, so every card's LEFT strip is
   never covered; that is why the index and the wild bar both live there.
   The browser solves the widths at layout time: no JS reads a width, sets a
   position, or runs after paint. */
.tt-slot {
  position: relative; overflow: visible;
  flex: 0 1 calc(var(--tt-card-w) * 1.1);
  min-width: var(--tt-slot-min);
  height: calc(var(--tt-card-w) * 1.4);
  margin: 0; padding: 0; border: 0; background: none; appearance: none;
  font: inherit; color: inherit; text-align: start;
  touch-action: manipulation; -webkit-tap-highlight-color: transparent;
}
button.tt-slot { cursor: pointer; }
/* The right-hand card is always whole; only its neighbours give ground. */
.tt-fan > .tt-slot:last-child { flex: 0 0 var(--tt-card-w); }

.tt-slot > .tt-card {
  position: absolute; inset-block-start: 0; inset-inline-start: 0;
  pointer-events: none;                  /* the slot owns hit-testing */
  transition: transform .12s ease, border-color .12s;
}
.tt-slot[data-sel="true"]     { z-index: 2; }
button.tt-slot:hover          { z-index: 3; }
button.tt-slot:focus-visible  { z-index: 4; outline: none; }
button.tt-slot:hover         > .tt-card,
button.tt-slot:focus-visible > .tt-card,
.tt-slot[data-sel="true"]    > .tt-card { transform: translateY(calc(-1 * var(--tt-lift))); }
button.tt-slot:hover > .tt-card { border-color: var(--accent-dim); }
/* Outline the FACE, not the 24px slot. */
button.tt-slot:focus-visible > .tt-card { outline: 2px solid var(--accent); outline-offset: 2px; }
/* The card you drew this turn, findable after a re-sort drops it mid-fan.
   Carried by outline rather than box-shadow: the wild marker is an inset
   box-shadow on the same element, and two box-shadow declarations do not merge
   — the later one replaced the wild bar outright, so drawing a wild card hid
   the fact that it was wild. */
.tt-slot[data-fresh="true"] > .tt-card {
  outline: 2px solid var(--accent-dim); outline-offset: -1px;
}

.tt-sortbtn { font-variant-numeric: tabular-nums; }
.tt-actions { display: flex; align-items: center; gap: var(--s2); flex-wrap: wrap; }
.tt-hint { margin: 0; }

/* ── scoreboard ───────────────────────────────────────────────────────── */
.tt-board { background: var(--surface); border: 1px solid var(--line);
            border-radius: var(--radius); overflow: hidden; }
.tt-board-head { display: flex; align-items: center; gap: var(--s2); flex-wrap: wrap;
                 padding: var(--s3) var(--s3); }
.tt-board-head h3 { margin: 0; font-size: var(--t2); font-weight: var(--w-bold); }
/* Lowest total wins inverts every scoreboard instinct, so the rule is stated
   permanently and never collapsed away. */
.tt-board-rule {
  font-size: var(--t1); font-weight: var(--w-bold); letter-spacing: .07em; text-transform: uppercase;
  color: var(--text); padding: var(--s0) var(--s2); border-radius: var(--r-pill);
  border: 1px solid var(--accent-dim);
  background: color-mix(in oklab, var(--accent) 14%, transparent);
}
.tt-board-toggle { margin-left: auto; }
.tt-board-empty { margin: 0; padding: 0 var(--s3) var(--s3); font-size: var(--t2); color: var(--muted); }

.tt-rank { list-style: none; margin: 0; padding: 0 var(--s2) var(--s2);
           display: flex; flex-direction: column; gap: var(--s0); }
.tt-rank-row { display: flex; align-items: baseline; gap: var(--s2);
               padding: var(--s1) var(--s2); border-radius: var(--radius-sm);
               border: 1px solid transparent; font-size: var(--t2); }
.tt-rank-row[data-lead="true"] { border-color: var(--accent-dim);
  background: color-mix(in oklab, var(--accent) 16%, transparent); }
.tt-rank-row[data-me="true"] .tt-rank-name { font-weight: var(--w-bold); }
.tt-rank-name { flex: 1; min-width: 0; overflow: hidden;
                text-overflow: ellipsis; white-space: nowrap; }
.tt-rank-total { font-family: var(--mono); font-variant-numeric: tabular-nums;
                 font-size: var(--t3); font-weight: var(--w-bold); min-width: 3.5ch; text-align: right; }
.tt-rank-gap { font-family: var(--mono); font-variant-numeric: tabular-nums;
               font-size: var(--t1); color: var(--muted); min-width: 4.5ch; text-align: right; }
.tt-lead { color: var(--text); border-color: var(--accent-dim); }

.tt-board-scroll { overflow: auto; max-height: min(46vh, 19rem);
                   border-top: 1px solid var(--line-soft); overscroll-behavior: contain; }
.tt-grid { width: 100%; min-width: 18rem; table-layout: fixed;
           border-collapse: collapse; font-size: var(--t1); }
.tt-grid th, .tt-grid td { padding: var(--s1) var(--s1); text-align: right; }
.tt-grid th:first-child, .tt-grid td:first-child { width: 4.2rem; text-align: left; }
/* No `display` override on any table part: that is the standard way to lose
   table semantics in assistive tech, which is the whole reason a real <table>
   was chosen. */
.tt-grid thead th { position: sticky; top: 0; z-index: 1;
  background: var(--surface-2); border-bottom: 1px solid var(--line);
  font-size: var(--t1); font-weight: var(--w-bold); color: var(--muted);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tt-grid thead th[data-me="true"] { color: var(--text); font-weight: var(--w-bold); }
.tt-grid tbody th { color: var(--muted); font-weight: var(--w-med); white-space: nowrap; }
.tt-grid-rn { font-family: var(--mono); color: var(--text); }
.tt-grid-wild {
  display: inline-block; margin-left: var(--s1); min-width: 1.5em; text-align: center;
  font-family: var(--mono); font-size: var(--t1); font-weight: var(--w-bold); color: var(--text);
  padding: 0 var(--s1); border: 1px solid var(--accent-dim); border-radius: var(--r1);
  background: color-mix(in oklab, var(--accent) 12%, transparent);
}
.tt-grid tbody td { font-family: var(--mono); font-variant-numeric: tabular-nums;
                    border-bottom: 1px solid var(--line-soft); }
/* Heat bands are the game's own constants: 50 is exactly one unmelded wild, 15
   an unmelded ace, so 25+ means real cards were held. The number stays --text,
   so the tint never costs contrast. */
.tt-grid td[data-heat="0"] { background: color-mix(in oklab, var(--accent) 11%, transparent); font-weight: var(--w-bold); }
.tt-grid td[data-heat="1"] { background: color-mix(in oklab, var(--live) 8%, transparent); }
.tt-grid td[data-heat="2"] { background: color-mix(in oklab, var(--live) 18%, transparent); }
.tt-grid td[data-heat="3"] { background: color-mix(in oklab, var(--live) 30%, transparent); font-weight: var(--w-bold); }
.tt-grid td[data-heat="none"] { color: var(--muted); }
.tt-grid td[data-out="true"] { box-shadow: inset 0 -2px 0 var(--accent); }
.tt-grid tfoot th, .tt-grid tfoot td {
  position: sticky; bottom: 0; z-index: 1;
  background: var(--surface-2); border-top: 1px solid var(--line);
  font-family: var(--mono); font-variant-numeric: tabular-nums; font-weight: var(--w-bold); }
.tt-grid tfoot td[data-lead="true"] { color: var(--text); box-shadow: inset 0 -3px 0 var(--accent); }
.tt-board-legend { margin: 0; padding: var(--s2) var(--s3) var(--s3); font-size: var(--t1);
  line-height: 1.5; color: var(--muted); border-top: 1px solid var(--line-soft); }

/* ── the round-end reveal ─────────────────────────────────────────────────
   seatScored fires once per seat and is the one moment every hand is public.
   The server's melds, the server's leftovers, the server's numbers. */
.tt-reveal {
  position: fixed; inset: 0; z-index: 60;
  display: flex; align-items: center; justify-content: center;
  padding: clamp(.5rem, 3vw, 2rem);
  background: color-mix(in oklab, var(--bg) 84%, transparent);
}
@supports (backdrop-filter: blur(3px)) { .tt-reveal { backdrop-filter: blur(3px); } }
.tt-reveal-panel {
  display: flex; flex-direction: column;
  width: min(880px, 100%); max-height: min(88vh, 900px);
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--radius); box-shadow: var(--shadow);
}
.tt-reveal-head { flex: none; padding: var(--s4) var(--s4) var(--s3);
                  border-bottom: 1px solid var(--line-soft); }
.tt-reveal-head h3 { margin: 0; font-size: var(--t3); font-weight: var(--w-bold); }
.tt-reveal-sub { margin: var(--s1) 0 0; font-size: var(--t2); color: var(--muted); }
.tt-reveal-body { flex: 1 1 auto; min-height: 0; overflow-y: auto;
                  overscroll-behavior: contain; padding: var(--s4) var(--s4); }
.tt-reveal-body:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.tt-reveal-grid { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--s3);
                  grid-template-columns: repeat(auto-fill, minmax(268px, 1fr)); }

.tt-rs { padding: var(--s3) var(--s3) var(--s3); border-radius: var(--radius-sm);
         border: 1px solid var(--line-soft); background: var(--surface-2); }
.tt-rs[data-me="true"] { border-color: var(--accent-dim); box-shadow: inset 2px 0 0 var(--accent); }
.tt-rs-head { display: flex; align-items: baseline; gap: var(--s2); margin-bottom: var(--s2); }
.tt-rs-name { flex: 1; min-width: 0; font-weight: var(--w-bold); font-size: var(--t2);
              overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tt-rs-pts { font-family: var(--mono); font-weight: var(--w-bold); font-size: var(--t5); color: var(--live); }
.tt-rs-pts[data-zero="true"] { color: var(--accent); }
.tt-rs-total { font-family: var(--mono); font-size: var(--t1); color: var(--muted); }

.tt-rs-label { display: block; margin: var(--s2) 0 var(--s1);
  font-size: var(--t1); font-weight: var(--w-bold); letter-spacing: .09em; text-transform: uppercase; }
/* Cost is carried by the container, the label and the number — never by
   tinting the cards, because red already means hearts. */
.tt-rs-cost { padding: var(--s2) var(--s2); border-radius: var(--radius-sm);
  background: color-mix(in oklab, var(--live) 9%, transparent);
  border: 1px solid color-mix(in oklab, var(--live) 38%, var(--line)); }
.tt-rs-cost .tt-rs-label { color: var(--live-ink); margin-top: 0; }
/* No opacity here. Dimming the whole block also dims the cards inside it, and
   card red measured below the 4.5:1 floor once multiplied through. The melds
   recede by being smaller and quieter-labelled instead. */
.tt-rs-melds { display: flex; gap: var(--s2); flex-wrap: wrap; }
.tt-rs-melds .tt-rs-label { color: var(--muted); flex: 1 1 100%; }
.tt-cards { display: flex; gap: var(--s1); flex-wrap: wrap; align-items: flex-start; }
/* NOT `position: static`. The corner index and the large pip are absolutely
   positioned inside the card, so removing the card's containing block sends
   them to the nearest positioned ancestor — which in the reveal is the fixed
   overlay itself. The card is already in normal flow here; `relative` costs it
   nothing and is what its children are measured against. */
.tt-meld { padding: var(--s1); border: 1px solid var(--line); border-radius: var(--radius-sm);
           background: var(--surface); display: flex; gap: 2px; }
.tt-rs-none { margin: 0; font-size: var(--t2); color: var(--muted); }
.tt-rs-pen { font-family: var(--mono); font-size: var(--t1); color: var(--muted); }
.tt-rs-card { display: inline-flex; flex-direction: column; align-items: center; gap: var(--s0); }

.tt-rs-reject { margin-top: var(--s2); padding: var(--s2) var(--s2); border-radius: var(--radius-sm);
  border: 1px solid color-mix(in oklab, var(--live) 40%, var(--line));
  background: color-mix(in oklab, var(--live) 8%, transparent); }
.tt-rs-reject h4 { margin: 0 0 var(--s1); font-size: var(--t2); font-weight: var(--w-bold); color: var(--live-ink); }
.tt-rs-reject .hint { margin-top: var(--s2); }
.tt-rs-unknown { width: var(--tt-card-w); aspect-ratio: 5 / 7;
  border: 1px dashed var(--line-strong); border-radius: calc(var(--tt-card-w) * .12); }

.tt-reveal-foot { flex: none; display: flex; align-items: center; gap: var(--s3); flex-wrap: wrap;
  padding: var(--s3) var(--s4); border-top: 1px solid var(--line-soft); }
.tt-reveal-next { flex: 1 1 12rem; min-width: 0; margin: 0; font-size: var(--t2); color: var(--muted); }
.tt-reveal-foot .btn { margin-left: auto; }

/* ── motion ───────────────────────────────────────────────────────────────
   Motion tells you what changed. Nothing eases for feel, and every cue has a
   static carrier, so the surface is fully legible with motion off. */
/* Deliberately not a card-flip. rotateX(90deg) turns the glyph edge-on, which
   makes the one thing a player must not misread INVISIBLE for the length of the
   animation — and if the animation is ever delayed (a throttled background tab,
   a context that is not compositing) it stays that way. Every frame here is
   legible, which is the rule the rest of this file holds itself to. */
.tt-wildchip.is-new .tt-wildchip-rank { animation: tt-flip 320ms ease-out; }
@keyframes tt-flip {
  from { transform: scale(.72); opacity: .5; }
  60%  { transform: scale(1.08); opacity: 1; }
}
.tt-seat.is-turn { animation: tt-turn 600ms ease-out; }
@keyframes tt-turn {
  0%, 100% { background: transparent; }
  25% { background: color-mix(in oklab, var(--accent) 16%, transparent); }
}
.tt-pile .tt-card.is-drop { animation: tt-drop 190ms cubic-bezier(.2,.7,.3,1); }
@keyframes tt-drop { from { translate: 0 -14px; scale: 1.06; opacity: .4; } }
.tt-slot[data-fresh="true"] > .tt-card { animation: tt-in 170ms ease-out; }
@keyframes tt-in { from { opacity: 0; translate: 0 12px; scale: .96; } }
.tt-reveal { animation: tt-fade 180ms ease-out; }
.tt-reveal-panel { animation: tt-rise 200ms cubic-bezier(.2,.7,.3,1); }
@keyframes tt-fade { from { opacity: 0; } }
@keyframes tt-rise { from { opacity: 0; translate: 0 8px; } }

/* ── a short viewport: a phone turned sideways ─────────────────────────────
   At 812x375 the dock was 48% of the screen and the Discard and Go out buttons
   started BELOW the fold — you had to scroll before the sticky dock pinned
   itself, which defeats the one thing the dock is for.

   The fix is a column that fills the window: the topbar takes its own height,
   the page takes the rest, and the GAME'S SURFACE scrolls inside that. No magic
   topbar number — hard-coding the height of platform chrome is exactly the
   coupling this file avoids elsewhere — and nothing reaches into the game's own
   classes: #gameSurface is the platform's element, and what the game does inside
   it stays the game's business. Its sticky dock then pins to the bottom of the
   surface, which is the bottom of the screen.

   Keyed on height, not orientation: a short window on a laptop has exactly the
   same problem. */
@media (max-height: 560px) {
  /* height, not min-height, and the page stops scrolling as a whole. A flex
     column whose height is only a MINIMUM still grows with its content, so the
     children never get a remainder to fill — which is why the first attempt at
     this left the dock exactly where it was. */
  /* Both, because overflow only propagates from body to the viewport when html
     leaves it alone — and something in this tree does not. Stated on both rather
     than relying on the propagation rule. */
  html { height: 100%; overflow: hidden; }
  body { height: 100dvh; overflow: hidden; }
  /* The column is #app, NOT body. The shell is injected into a wrapper div, so
     body's only flex child is that wrapper — making body the column left every
     page unconstrained and changed nothing. */
  #app { display: flex; flex-direction: column; height: 100%; min-height: 0; }
  #gate { min-height: 0; overflow-y: auto; }
  .topbar, .banner, .boot { flex: none; }
  .page { padding-top: var(--s3); padding-bottom: 0; }

  /* Each page takes the remainder and scrolls inside itself. */
  #pageHome, #pageProfile { flex: 1; min-height: 0; overflow-y: auto; }
  #pageTable { flex: 1; min-height: 0; display: flex; flex-direction: column; }
  #gameSurface { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; }

  /* A smaller hand buys back the vertical room the dock needs. This is the one
     game-facing value here, and it is the knob the game itself exposes for it. */
  .tt { --tt-card-w: clamp(28px, 2.2vw + 20px, 36px); --tt-lift: 7px; }
  .tt-status { padding-bottom: var(--s2); }
  .tt-log { max-height: 2.2rem; }
  .tt-hint { display: none; }
}

@media (pointer: coarse) { .tt-actions .btn { min-height: 44px; } }
@media (max-width: 560px) {
  .tt-log { max-height: 2.4rem; }
  .tt-reveal { padding: 0; }
  .tt-reveal-panel { width: 100%; height: 100%; max-height: 100%; border: 0; border-radius: 0; }
  .tt-reveal-grid { grid-template-columns: 1fr; }
}
/* NO FULL-BLEED HAND RAIL. It was here, pulling the rail out to 100vw to
   reclaim the platform's page gutter, and it did not work: .tt clips
   horizontally (it has to, or the rail's own overflow escapes the page), so the
   reclaimed strip was clipped straight back off. Measured at 375px: the rail
   reported 375px wide while .tt was 346px, and elementFromPoint at the leftmost
   card returned the page, not the card — the card was painted nowhere and
   could not be tapped. Living inside .tt costs about 2px per card at fourteen
   cards and every card stays hittable. */

/* ── responsive ────────────────────────────────────────────────────────── */
@media (max-width: 560px) {
  .who-name { display: none; }
  .table-actions { margin-left: 0; width: 100%; justify-content: flex-start; }
  .start-why { text-align: left; }
  .firstrun-form { flex: 1 1 100%; }
  .codejoin .field { flex: 1 1 100%; }
  .codejoin .btn { width: 100%; }
}
/* Enumerated targets, never a bare .btn: the game renders six .btn btn-sm
   controls of its own, and growing those eats its measured mobile budget. */
@media (pointer: coarse) {
  .row-action .btn, .table-actions .btn, .invite-code,
  .codejoin .btn, .firstrun-form .btn { min-height: 44px; }
  .who { min-height: 44px; padding-inline: var(--s2); }
}
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { animation-duration: .001ms !important; transition-duration: .001ms !important; }
  /* The clamp above leaves a zero-duration animation, which can still flash on
     a freshly-created node. These are the places that show. */
  .tt-reveal, .tt-reveal-panel { animation: none; }
  .row[data-new="true"], .row[data-started="true"],
  .seat[data-arrived="true"], .start-why.is-nudge { animation: none; }
}

/* ── Jingle Guesser ────────────────────────────────────────────────────────
 *
 * A record deck: a platter that turns because audio.currentTime advances, a
 * waveform that is the clock, and one control deck that swaps faces in a fixed
 * footprint. Prefixed jg-, no shadow DOM, no assets — the prefix IS the
 * isolation and a bare element selector here restyles the shell.
 *
 * SPLIT OF LABOUR.
 *   CONTINUOUS  -> a custom property written by the ONE rAF loop, on .jg only:
 *                  --ring --pos --spin --adj --left --arm --load
 *   DISCRETE    -> a @keyframes fired by a data-attribute set on an EDGE, never
 *                  on a level. render() runs once per SERVER FRAME; fire a
 *                  keyframe on a state and the stage shakes forever.
 *
 * LIGHT THEME IS REAL (styles.css:36-51). --accent-dim inverts to a PALE
 * #e6c98d there, so it is never used as a drop shadow or a dim fill below.
 * --jg-ink and --jg-dim exist for exactly that reason.
 */

.jg {
  /* Derived tints. Plain declaration FIRST so an engine without color-mix
     degrades to a flat colour rather than to transparent. */
  --jg-ink:  #000;
  --jg-ink:  color-mix(in oklab, var(--accent) 45%, #000);
  --jg-dim:  var(--line);
  --jg-dim:  color-mix(in oklab, var(--accent) 34%, var(--surface-2));
  --jg-glow: var(--accent-dim);
  --jg-glow: color-mix(in oklab, var(--accent) 28%, transparent);
  --jg-stop: var(--live);
  --jg-stop: color-mix(in oklab, var(--live) 40%, transparent);
  --jg-ok:   var(--ok);
  --jg-ok:   color-mix(in oklab, var(--ok) 14%, var(--surface-2));

  /* Written every frame by the ticker. Declared so CSS never sees them invalid
     before the first tick lands. */
  --ring: 0; --pos: 0%; --spin: 0; --adj: 0; --left: 1; --arm: 0;

  max-width: 34rem; margin: 0 auto;
  display: grid; gap: var(--s3);
}
@media (min-width: 62rem) {
  .jg { max-width: 52rem; grid-template-columns: minmax(0,1fr) 15rem;
        grid-template-areas: "head board" "stage board" "wave board"
                             "lamps board" "deck board"; align-items: start; }
  .jg-head{grid-area:head} .jg-board{grid-area:board}
  .jg-stage{grid-area:stage} .jg-wave{grid-area:wave}
  .jg-lamps{grid-area:lamps} .jg-deck{grid-area:deck}
}

/* ── head: round progress as a pip rule, plus the tally ─────────────────── */
.jg-head {
  display: flex; align-items: center; justify-content: space-between;
  gap: var(--s3); min-height: 2.4rem;
  padding-bottom: var(--s2); border-bottom: 1px solid var(--line-soft);
}
.jg-pips { display: flex; gap: 3px; flex-wrap: wrap; }
.jg-pips i {
  width: 13px; height: 3px; border-radius: 2px; background: var(--line-strong);
  transition: background-color .2s linear, box-shadow .2s linear;
}
.jg-pips i[data-done="true"] { background: var(--jg-dim); }
.jg-pips i[data-now="true"]  { background: var(--accent); box-shadow: 0 0 8px var(--jg-glow); }

.jg-tally {
  display: inline-flex; align-items: center; gap: var(--s2);
  font: var(--w-bold) var(--t1)/1 var(--mono); letter-spacing: .12em; text-transform: uppercase;
  color: var(--muted); font-variant-numeric: tabular-nums;
}
.jg-tally i { width: .5rem; height: .5rem; border-radius: 50%;
              background: var(--line); font-style: normal;
              transition: background-color .2s linear; }
.jg-tally[data-audio="playing"] { color: var(--text); }
.jg-tally[data-audio="playing"] i { background: var(--live); animation: jg-onair 1.4s ease-in-out infinite; }
.jg-tally[data-audio="held"] i { background: var(--jg-dim); }
.jg-tally[data-audio="blocked"] { color: var(--live); }
@keyframes jg-onair {
  0%,100% { box-shadow: 0 0 0 0 var(--jg-stop); }
  50%     { box-shadow: 0 0 0 6px transparent; }
}

/* ── the stage: fixed height, every child absolute ──────────────────────── */
.jg-stage {
  position: relative; overflow: hidden; isolation: isolate;
  height: clamp(10.5rem, 34vw, 14rem);
  border-radius: var(--radius); border: 1px solid var(--line);
  background: var(--surface);
  transition: opacity .6s linear;
}
.jg-stage > * { position: absolute; inset: 0; }
.jg-stage[data-dim="true"] { opacity: .82; }
.jg-stage[data-slam="true"] { animation: jg-slam .2s linear; }
@keyframes jg-slam {
  0%{transform:translateX(0)} 25%{transform:translateX(-4px)}
  50%{transform:translateX(3px)} 75%{transform:translateX(-2px)}
  100%{transform:translateX(0)}
}

/* The platter. A radially symmetric disc spinning looks frozen — the conic
   sheen wedge is what makes the rotation legible at a glance. */
.jg-disc {
  inset: auto; left: 50%; top: 50%; width: min(38vw, 8.5rem); aspect-ratio: 1;
  margin: 0; transform: translate(-50%,-50%) rotate(calc(var(--spin) * 1deg));
  border-radius: 50%; opacity: .34; transition: opacity .3s linear;
  background:
    conic-gradient(from 0deg, transparent 0 41%, var(--line) 46% 54%, transparent 59%),
    repeating-radial-gradient(circle at 50% 50%, var(--line-soft) 0 1px, transparent 1px 5px),
    radial-gradient(circle at 50% 50%, var(--jg-dim) 0 21%, var(--surface-2) 21.5%);
}
.jg[data-audio="playing"] .jg-disc, .jg[data-audio="held"] .jg-disc { opacity: 1; }
.jg-disc::after {
  content: ""; position: absolute; left: 50%; top: 50%; width: 9px; height: 9px;
  margin: -4.5px 0 0 -4.5px; border-radius: 50%; background: var(--bg);
}

/* ONE ring, two jobs: the 3s lead-in and the 20s choice clock. --ring is 0..1
   and is written per frame from a SERVER timestamp. No transition — a smoothed
   ring is a ring that lies about when the audio begins. */
.jg-ring {
  inset: auto; left: 50%; top: 50%; width: min(46vw, 9.5rem); aspect-ratio: 1;
  margin: 0; transform: translate(-50%,-50%); border-radius: 50%; opacity: 0;
  transition: opacity .18s linear;
  background: conic-gradient(from -90deg, var(--accent) calc(var(--ring) * 360deg), transparent 0);
  -webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 5px), #000 calc(100% - 4px));
          mask: radial-gradient(farthest-side, transparent calc(100% - 5px), #000 calc(100% - 4px));
}
.jg-ring[data-on="true"] { opacity: 1; }
.jg-ring[data-urgent="true"] {
  background: conic-gradient(from -90deg, var(--live) calc(var(--ring) * 360deg), transparent 0);
  animation: jg-urgent 1s ease-in-out infinite;
}
@keyframes jg-urgent { 50% { opacity: .35; } }
.jg-ring[data-blow="true"] { animation: jg-blow .24s cubic-bezier(.4,0,1,1) forwards; }
@keyframes jg-blow { to { transform: translate(-50%,-50%) scale(1.3); opacity: 0; } }

.jg-count {
  display: grid; place-items: center; pointer-events: none;
  font: 800 clamp(3rem, 14vw, 4.4rem)/1 var(--mono);
  font-variant-numeric: tabular-nums; color: var(--accent);
  text-shadow: 0 0 24px var(--jg-glow);
}
.jg-count[data-tick] { animation: jg-tick .26s cubic-bezier(.2,.9,.3,1.25); }
@keyframes jg-tick {
  from { transform: scale(1.28); opacity: .25; filter: blur(3px); }
  to   { transform: scale(1);    opacity: 1;   filter: blur(0); }
}
.jg-count[data-blow="true"] { animation: jg-blow-n .24s cubic-bezier(.4,0,1,1) forwards; }
@keyframes jg-blow-n { to { transform: scale(1.5); opacity: 0; } }

/* THE ONLY FLASHING SURFACE. Never white, never --text. Amber, red-orange or
   green at .16-.28 over the stage, one flash per 200ms hard floor enforced in
   JS, and suppressed outright under reduced motion. */
.jg-flash { z-index: 6; opacity: 0; pointer-events: none; background: var(--accent); }
.jg-flash[data-hue="stop"] { background: var(--live); }
.jg-flash[data-hue="win"]  { background: var(--ok); }
.jg-flash[data-on="true"]      { animation: jg-flash .3s cubic-bezier(.1,.8,.3,1); }
.jg-flash[data-on="big"]       { animation: jg-flash-b .42s cubic-bezier(.2,0,0,1); }
@keyframes jg-flash   { 0%{opacity:0} 18%{opacity:.16} 100%{opacity:0} }
@keyframes jg-flash-b { 0%{opacity:0} 14%{opacity:.28} 100%{opacity:0} }

/* The claim strip. One name, full width, --on-accent on --accent: this game is
   played across a room on other people's phones and this is the only element
   legible from four feet away. clip-path, NOT scaleX — a strip scaled from .18
   squashes the name into mush for exactly the 100ms people are reading it. */
.jg-claim {
  inset: 38% 0 auto 0; z-index: 5; height: max-content;
  padding: var(--s2) var(--s3); text-align: center;
  font: 800 clamp(.9rem, 3.4vw, 1.2rem)/1.25 var(--mono);
  letter-spacing: .06em; text-transform: uppercase;
  color: var(--on-accent); background: var(--accent);
  border-block: 2px solid var(--jg-ink);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.jg-claim[data-in="true"] { animation: jg-claim .26s cubic-bezier(.16,1,.3,1) both; }
@keyframes jg-claim {
  from { clip-path: inset(0 100% 0 0); opacity: .4; letter-spacing: .32em; }
  to   { clip-path: inset(0 0 0 0);    opacity: 1;  letter-spacing: .06em; }
}
.jg-claim[data-quiet="true"] {
  color: var(--muted); background: var(--surface-2);
  border-block-color: var(--line); font-weight: var(--w-bold);
}
.jg-claim i { font-style: normal; animation: jg-dot 1.2s ease-in-out infinite; }
.jg-claim i:nth-of-type(2) { animation-delay: .16s }
.jg-claim i:nth-of-type(3) { animation-delay: .32s }
@keyframes jg-dot { 0%,100%{opacity:.25} 50%{opacity:1} }

/* ── the reveal cover ───────────────────────────────────────────────────── */
/* --surface + --text, NOT an amber field: --accent-dim is a pale cream in the
   light theme and a brand printed on it lands near 1.2:1. The hash from the
   brand string is spent on the highlight ORIGIN only, so the same jingle still
   prints the same sleeve every night. */
.jg-cover {
  z-index: 4; display: grid; align-content: center; gap: var(--s2);
  padding: 8%; text-align: left; opacity: 0;
  clip-path: circle(0% at 50% 55%);
  background:
    radial-gradient(120% 100% at var(--cx,26%) var(--cy,16%), var(--jg-glow), transparent 62%),
    var(--surface);
}
.jg-cover[data-in="true"] { opacity: 1; animation: jg-print .44s cubic-bezier(.2,.8,.2,1) both; }
@keyframes jg-print { from { clip-path: circle(0% at 50% 55%) } to { clip-path: circle(82% at 50% 55%) } }
.jg-brand {
  font: 800 clamp(1.6rem, 7vw, 2.6rem)/1.06 var(--font, inherit);
  color: var(--text); overflow-wrap: anywhere;
  opacity: 0; transform: scale(.88) translateY(6px); filter: blur(7px);
}
.jg-cover[data-in="true"] .jg-brand { animation: jg-brand .44s cubic-bezier(.2,.9,.3,1.35) .04s forwards; }
@keyframes jg-brand { to { opacity: 1; transform: none; filter: blur(0); } }
.jg-tags { display: flex; gap: var(--s1); flex-wrap: wrap; }
.jg-tag {
  font: var(--w-med) var(--t1)/1 var(--mono); letter-spacing: .08em; text-transform: uppercase;
  padding: var(--s1) var(--s2); border-radius: var(--r-pill); color: var(--muted);
  background: var(--surface-2); border: 1px solid var(--line);
  opacity: 0; transform: translateY(8px);
}
.jg-cover[data-in="true"] .jg-tag { animation: jg-rise .24s ease-out both; }
.jg-cover[data-in="true"] .jg-tag:nth-child(2) { animation-delay: .05s }
.jg-cover[data-in="true"] .jg-tag:nth-child(3) { animation-delay: .10s }
@keyframes jg-rise { to { opacity: 1; transform: none; } }

.jg-taken {
  font: var(--w-bold) var(--t1)/1.3 var(--mono); letter-spacing: .1em; text-transform: uppercase;
  color: var(--muted); opacity: 0;
}
.jg-taken b { color: var(--text); }
.jg-taken time { color: var(--accent); letter-spacing: 0; }
.jg-taken[data-house="true"] { color: var(--live); }
.jg-taken[data-in="true"] { animation: jg-rise-o .24s cubic-bezier(.16,1,.3,1) both; }
@keyframes jg-rise-o { from { opacity:0; transform: translateY(8px) } to { opacity:1; transform:none } }

.jg-halo {
  inset: auto; left: 50%; top: 50%; width: min(60vw, 14rem); aspect-ratio: 1;
  margin: 0; z-index: 3; pointer-events: none;
  border-radius: 50%; border: 2px solid var(--accent); opacity: 0;
}
.jg-halo[data-in="true"] { animation: jg-halo .62s cubic-bezier(.1,.7,.3,1) both; }
@keyframes jg-halo {
  from { transform: translate(-50%,-50%) scale(.35); opacity: .5; }
  to   { transform: translate(-50%,-50%) scale(1.9);  opacity: 0; }
}

/* ── the standings ladder (lives INSIDE the stage) ──────────────────────── */
.jg-ladder { z-index: 5; align-content: end; display: grid; gap: var(--s1); padding: var(--s3) var(--s3); }
.jg-rank {
  display: grid; grid-template-columns: 1fr auto; align-items: center;
  gap: var(--s0) var(--s2); font-size: var(--t1); color: var(--muted);
  opacity: 0; transform: translateX(-14px);
  animation: jg-rank-in .22s cubic-bezier(.16,1,.3,1) both;
  animation-delay: calc(var(--i) * 45ms);
}
@keyframes jg-rank-in { to { opacity: 1; transform: none; } }
.jg-rank[data-me="true"] { color: var(--text); }
.jg-rank[data-lead="true"] { color: var(--text); border-left: 3px solid var(--accent); padding-left: var(--s1); }
.jg-rank b { font: 800 .9rem var(--mono); font-variant-numeric: tabular-nums; color: var(--text); }
.jg-rank-bar { grid-column: 1/-1; height: 3px; border-radius: var(--r-pill); background: var(--line); overflow: hidden; }
.jg-rank-bar i {
  display: block; height: 100%; background: var(--jg-dim);
  transform-origin: left; transform: scaleX(var(--f, 0));
  transition: transform .48s cubic-bezier(.16,1,.3,1) .32s;
}
.jg-rank[data-lead="true"] .jg-rank-bar i { background: var(--accent); }

/* Autoplay fallback — a full panel, not a status line nobody reads. */
.jg-tap {
  z-index: 7; display: grid; place-items: center; gap: var(--s2);
  border: 0; cursor: pointer; color: var(--text);
  font: var(--w-bold) var(--t1)/1 var(--mono); letter-spacing: .14em; text-transform: uppercase;
  background: var(--surface);
  background: color-mix(in oklab, var(--bg) 82%, transparent);
}
.jg-tap svg { width: 4.5rem; height: 4.5rem; fill: var(--accent); }

/* ── the waveform ───────────────────────────────────────────────────────── */
.jg-wave { position: relative; height: 4.5rem; padding-block: var(--s2); }
.jg-lane { position: absolute; inset: .4rem 0; display: flex; align-items: center; gap: 2px; }
.jg-lane b {
  display: block; flex: 1 1 0; min-width: 2px; border-radius: 1px;
  height: calc(var(--h) * 1%); min-height: 2px; background: var(--line-strong);
  transform: scaleY(var(--load, 1));
  transition: transform .5s cubic-bezier(.2,.8,.2,1), background-color .18s linear;
}
/* ONE clip-path write on the container paints all 48 bars. */
.jg-lane--play { clip-path: inset(0 calc(100% - var(--pos)) 0 0); will-change: clip-path; }
.jg-lane--play b { background: var(--accent); }
.jg[data-audio="held"] .jg-lane--play b { background: var(--jg-dim); }
.jg[data-phase="revealed"] .jg-lane--play b,
.jg[data-phase="finished"] .jg-lane--play b { background: var(--line); }

.jg-cursor {
  position: absolute; top: 0; bottom: 0; left: var(--pos); width: 2px; margin-left: -1px;
  border-radius: 2px; background: var(--text); box-shadow: 0 0 14px 3px var(--jg-glow);
  transition: background-color .12s linear, box-shadow .12s linear;
}
.jg-cursor::after {
  content: ""; position: absolute; left: 50%; top: -5px; width: 9px; height: 9px;
  margin-left: -4.5px; border-radius: 50%; background: inherit;
}
.jg[data-audio="held"] .jg-cursor { background: var(--live); box-shadow: none; }
.jg-notch { position: absolute; top: -3px; bottom: -3px; width: 1px; background: var(--line-strong); }
.jg-notch[data-hot="true"] { background: var(--live); }

/* ── the lamps: the 250ms window, made visible ──────────────────────────── */
.jg-lamps {
  min-height: 2.25rem; display: flex; align-items: center; justify-content: center;
  gap: var(--s2); flex-wrap: wrap;
}
.jg-lamp {
  display: grid; justify-items: center; gap: var(--s0);
  padding: var(--s1) var(--s2); border-radius: var(--radius-sm);
  background: var(--surface-2); border: 1px solid var(--line-strong); color: var(--muted);
  font: var(--w-bold) var(--t1)/1.1 var(--mono); letter-spacing: .04em;
  max-width: 8rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  animation: jg-lamp-in .14s cubic-bezier(.2,.9,.3,1.4) both;
  transition: opacity .22s ease-out, transform .22s ease-out, border-color .18s linear;
}
@keyframes jg-lamp-in { from { transform: scale(.6); opacity: 0 } to { transform: scale(1); opacity: 1 } }
/* .34 not .18: at .18 a player's NAME measures ~1.15:1 and stops being text. */
.jg-lamp[data-verdict="lost"] { opacity: .34; transform: scale(.9); }
.jg-lamp[data-verdict="won"]  { transform: scale(1.12); border-color: var(--accent); color: var(--text); }
.jg-lamp time { font-size: var(--t1); color: var(--accent); opacity: 0; transition: opacity .2s linear .06s; }
.jg-lamp[data-verdict="won"] time { opacity: 1; }
.jg-wrong { font: var(--w-med) var(--t1) var(--mono); color: var(--muted); text-decoration: line-through; }

/* ── THE DECK: one footprint, four faces ────────────────────────────────── */
.jg-deck {
  position: relative; display: grid; place-items: center;
  min-height: 7.5rem;
  transition: min-height .26s cubic-bezier(.16,1,.3,1);
}
.jg[data-phase="choosing"] .jg-deck { min-height: 15.5rem; }
.jg-deck > * { grid-area: 1 / 1; width: 100%; }
.jg-face[hidden] { display: none !important; }
.jg-face[data-in="true"] { animation: jg-face-in .22s cubic-bezier(.2,.8,.2,1) .12s both; }
@keyframes jg-face-in { from { opacity: 0; transform: translateY(10px) } to { opacity: 1; transform: none } }

.jg-dome { position: relative; justify-self: center; width: 100%; max-width: 16rem; }
.jg-buzzer {
  display: block; width: 100%; min-height: 5.25rem; border: 0; border-radius: var(--r-pill);
  cursor: pointer; -webkit-tap-highlight-color: transparent; touch-action: manipulation;
  font: 800 1.5rem/1 var(--mono); letter-spacing: .12em;
  color: var(--on-accent); background: var(--accent);
  /* --jg-ink, never --accent-dim: accent-dim is #e6c98d in the light theme and
     the button would float on a highlight instead of sitting on a shadow. */
  box-shadow: 0 7px 0 var(--jg-ink), 0 14px 20px -10px var(--jg-ink);
  transition: transform .07s cubic-bezier(.2,.7,.3,1), box-shadow .07s linear;
}
/* .is-down is added on POINTERDOWN, before ctx.play(), and HELD until the phase
   leaves adjudicating. A 1200ms failsafe releases it if the intent is dropped. */
.jg-buzzer:active, .jg-buzzer.is-down {
  transform: translateY(7px);
  box-shadow: 0 0 0 var(--jg-ink), inset 0 3px 8px rgb(0 0 0 / .35);
}
.jg-buzzer[data-armed="true"]::after {
  content: ""; position: absolute; inset: 0; border-radius: inherit; pointer-events: none;
  border: 2px solid var(--accent); animation: jg-arm .24s cubic-bezier(.2,.9,.3,1.3);
}
@keyframes jg-arm { from { transform: scale(.94); opacity: .6 } to { transform: scale(1.2); opacity: 0 } }
/* NO opacity fade when unavailable. A 35%-opacity button reads as broken; a
   cover that states WHY reads as a rule. The client already computes the reason
   into buzzer.dataset.why — this is that string, finally rendered. */
.jg-buzzer:disabled { cursor: default; }
.jg-latch {
  position: absolute; inset: 0; border-radius: inherit; pointer-events: none;
  display: grid; place-items: center; text-align: center; padding: 0 var(--s4);
  font: var(--w-bold) var(--t1)/1.2 var(--mono); letter-spacing: .14em; color: var(--text);
  border: 1px solid var(--line); border-radius: var(--r-pill);
  background: repeating-linear-gradient(-45deg,
    var(--surface) 0 9px, var(--surface-2) 9px 18px);
  transform: translateY(calc(-135% * var(--arm)));
  transition: transform .2s cubic-bezier(.3,0,.1,1);
}
.jg-latch[data-open="true"] { transform: translateY(-135%); }

.jg-key {
  font: var(--w-bold) var(--t3) var(--mono); letter-spacing: .05em; min-height: 3.4rem;
  padding: var(--s3) var(--s4); cursor: pointer; text-align: center;
  color: var(--text); background: var(--surface-2);
  border: 1px solid var(--line-strong); border-radius: var(--radius-sm);
  box-shadow: 0 4px 0 var(--line-soft);
  transition: transform .07s cubic-bezier(.2,.7,.3,1), box-shadow .07s linear, opacity .18s linear;
}
.jg-key:active { transform: translateY(3px); box-shadow: 0 1px 0 var(--line-soft); }
.jg-buzzer:focus-visible, .jg-key:focus-visible,
.jg-track:focus-visible, .jg-search input:focus-visible {
  outline: 3px solid var(--text); outline-offset: 4px;
}

/* the answer face */
.jg-search { position: relative; display: flex; align-items: center; max-width: 22rem; margin: 0 auto; }
.jg-search svg { position: absolute; left: .7rem; width: 1.05rem; height: 1.05rem; fill: var(--muted); pointer-events: none; }
.jg-search input {
  font: inherit; width: 100%; min-height: 48px; padding: var(--s2) var(--s3) var(--s2) var(--s7);
  color: var(--text); background: var(--surface-2);
  border: 1px solid var(--line-strong); border-radius: var(--r-pill); outline: 0;
  transition: border-color .16s linear, box-shadow .16s linear;
}
.jg-search input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--jg-glow); }
.jg-search[data-wrong="true"] input { animation: jg-shake .4s cubic-bezier(.36,.07,.19,.97); border-color: var(--live); }
@keyframes jg-shake { 0%,100%{transform:none} 20%{transform:translateX(-6px)}
                      45%{transform:translateX(6px)} 70%{transform:translateX(-4px)} }

/* the four doors — a tracklist, not a quiz grid */
.jg-list { display: grid; gap: var(--s2); }
.jg-track {
  display: grid; grid-template-columns: 1.6rem minmax(0,1fr) auto; align-items: center; gap: var(--s3);
  min-height: 3.4rem; padding: var(--s3) var(--s4); text-align: left; cursor: pointer;
  font: var(--w-bold) var(--t3)/1.2 inherit; color: var(--text); overflow-wrap: anywhere;
  background: var(--surface-2); border: 1px solid var(--line-strong); border-radius: var(--radius-sm);
  opacity: 0; transform: translateY(10px);
  animation: jg-rise .24s cubic-bezier(.2,.8,.2,1) both;
  animation-delay: calc(var(--i) * 55ms);
  transition: border-color .16s linear, background-color .16s linear,
              opacity .2s linear, transform .24s cubic-bezier(.2,.8,.2,1);
}
.jg-track .jg-idx  { font: var(--w-med) var(--t1)/1 var(--mono); color: var(--muted); }
.jg-track .jg-sent { font: var(--w-bold) var(--t1)/1 var(--mono); letter-spacing: .1em; color: var(--accent); }
/* NO per-option emphasis before revealed. Exactly one is secretly correct, so
   any accent, ordering or hover distinction is a tell. */
.jg-track[data-mine="true"]  { border-color: var(--accent); background: var(--surface); }
.jg-track:disabled:not([data-mine="true"]) { opacity: .45; cursor: not-allowed; }
.jg-track[data-result="right"] {
  opacity: 1; border-color: var(--ok); box-shadow: inset 0 0 0 1px var(--ok);
  background: var(--surface-2); background: var(--jg-ok); transform: translateY(-4px);
}
.jg-track[data-result="dim"] { opacity: .4; }
.jg-track[data-mine="true"][data-result="dim"] { border-left: 3px solid var(--live); opacity: .58; }
.jg-choice-clock { height: 3px; border-radius: var(--r-pill); background: var(--line); overflow: hidden; }
.jg-choice-clock i { display: block; height: 100%; background: var(--accent);
                     transform-origin: left; transform: scaleX(var(--left)); }
.jg-choice-clock[data-urgent="true"] i { background: var(--live); }

/* the next key wears its own clock */
.jg-next { position: relative; justify-self: center; min-width: 14rem; overflow: hidden; }
.jg-next::before {
  content: ""; position: absolute; inset: 0; z-index: 0; background: var(--jg-glow);
  transform-origin: left; transform: scaleX(var(--left));
}
.jg-next[data-urgent="true"]::before { background: var(--jg-stop); }
/* THE NUMBER, READABLE FROM A SOFA.
 *
 * ASKED FOR LIVE 2026-09-13: the countdown "should be more vibrant and show the
 * numbers better". It was the same size and weight as the label beside it, in a
 * proportional face, so the digit changing was the least noticeable thing on a
 * control whose entire job is the digit changing. Bigger, bolder and tabular,
 * so it does not jiggle as it counts. */
.jg-next span {
  position: relative; z-index: 1;
  font-variant-numeric: tabular-nums; font-feature-settings: "tnum" 1;
  font-weight: var(--w-bold); font-size: var(--t3); letter-spacing: .04em;
}
/* The last third counts down in the stop colour and leans on the eye a little. */
.jg-next[data-urgent="true"] span { font-size: var(--t4); }
@media (prefers-reduced-motion: no-preference) {
  .jg-next[data-urgent="true"] span { animation: jg-next-pulse 1s steps(1) infinite; }
  @keyframes jg-next-pulse { 0%, 60% { opacity: 1 } 61%, 100% { opacity: .62 } }
}

/* ── the scoreboard ─────────────────────────────────────────────────────── */
/* ONE LINE, SCROLLED, NOT WRAPPED. Wrapping was why the board had to hide
 * everybody but the leader on a phone: eight chips became three rows and ate
 * the stage. Scrolling keeps the whole room visible at one line on any width,
 * so a four-player table shows four players. Leader first — the sort is by
 * score — so the most-wanted chip is the one you never have to scroll to. */
.jg-board {
  display: flex; gap: var(--s2); min-height: 1.9rem; align-items: center;
  flex-wrap: nowrap; overflow-x: auto; overflow-y: hidden;
  scrollbar-width: thin; -webkit-overflow-scrolling: touch;
  padding-bottom: 2px;
}
.jg-board::-webkit-scrollbar { height: 3px; }
.jg-board::-webkit-scrollbar-thumb { background: var(--line-strong); border-radius: 999px; }
.jg-board .jg-chip { flex: 0 0 auto; }
/* Your own chip is the one you look for; keep it findable in a scrolled row. */
.jg-chip[data-me] { position: sticky; left: 0; z-index: 1; }

/* An answer somebody tried and the judge refused, shown at the reveal beside
 * the brand. Struck through and muted: it is context, not a second answer. */
.jg-tag--miss { text-decoration: line-through; opacity: .7; }
.jg-chip {
  position: relative; display: inline-flex; align-items: baseline; gap: var(--s1);
  max-width: 10rem; padding: var(--s1) var(--s2); border-radius: var(--r-pill); font-size: var(--t1);
  color: var(--muted); background: var(--surface-2); border: 1px solid var(--line-strong);
  transition: border-color .9s linear, opacity .22s linear;
}
.jg-chip[data-me="true"]   { border-color: var(--accent); color: var(--text); }
.jg-chip[data-out="true"]  { opacity: .55; border-left: 2px solid var(--live); }
.jg-chip[data-bump="up"]   { border-color: var(--ok); }
.jg-chip[data-bump="down"] { border-color: var(--live); }
.jg-chip span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* odometer: two stacked digits, the old leaves as the new lands */
.jg-odo { position: relative; display: inline-block; height: 1.15em; overflow: hidden;
          vertical-align: -.15em; font: var(--w-bold) .95em var(--mono); font-variant-numeric: tabular-nums;
          color: var(--text); }
.jg-odo i { display: block; height: 1.15em; line-height: 1.15em; font-style: normal; text-align: right; }
.jg-odo[data-roll="true"] i { animation: jg-odo .38s cubic-bezier(.16,1,.3,1) both; }
@keyframes jg-odo { from { transform: translateY(0) } to { transform: translateY(-100%) } }

.jg-chit {
  position: absolute; left: 50%; top: -.3rem; pointer-events: none;
  /* --ok-ink, not --ok: on the chip's --surface-2 ground --ok is 4.28:1. The
     token's own 4.50 figure was measured against --bg, and the chit is
     absolutely positioned over the chip. */
  font: 800 .76rem/1 var(--mono); color: var(--ok-ink);
  animation: jg-chit .7s cubic-bezier(.2,.7,.2,1) both;
}
.jg-chit[data-neg="true"] { color: var(--live-ink); }
@keyframes jg-chit {
  0%   { transform: translate(-50%, 4px); opacity: 0; }
  18%  { opacity: 1; }
  70%  { opacity: 1; }
  100% { transform: translate(-50%,-22px); opacity: 0; }
}
/* FLIP reorder: JS sets --dy, then clears it on the next frame. */
.jg-chip.is-flip    { transform: translateX(var(--dy)); transition: none; }
.jg-chip.is-flip-go { transform: none; transition: transform .48s cubic-bezier(.16,1,.3,1); }

@media (max-width: 30rem) { .jg-chip[data-hide="true"] { display: none; } }

/* ── PAUSED ─────────────────────────────────────────────────────────────── */
.jg[data-phase="paused"] { filter: saturate(.5); opacity: .62; transition: filter .28s linear, opacity .28s linear; }

/* ── reduced motion ─────────────────────────────────────────────────────────
 * THE SHELL ALREADY CLAMPS US. styles.css:1431 sets animation-duration and
 * transition-duration to .001ms !important on `*`. Two consequences that decide
 * this whole block:
 *
 * (1) Any element whose BASE state is opacity:0 does not merely stop animating,
 *     it VANISHES — a zero-duration non-forwards animation snaps back to base.
 *     So every entrance below is RE-STATED at its END state. Forgetting one is
 *     an invisible-content bug, not a cosmetic one.
 * (2) You cannot "keep the pacing and drop the motion" in CSS, because the
 *     clamp wins. So the reveal's SIX BEATS ARE SEQUENCED IN JS off timestamps
 *     (see §4.3), not by animation-delay. Under this query they still land at
 *     0 / 480 / 520 / 900 / 1150 / 1450 ms — they simply appear instead of move.
 *     The pacing IS the design and it survives.
 *
 * !important is used on exactly two things: the flash and the slam. Those must
 * not merely shorten — a clamped 200ms shake is a single-frame teleport of the
 * stage, which is worse than the animation it replaced.
 */
@media (prefers-reduced-motion: reduce) {
  .jg-flash, .jg-flash[data-on] { animation: none !important; opacity: 0 !important; }
  .jg-stage[data-slam="true"]   { animation: none !important; box-shadow: inset 0 0 0 2px var(--live); }
  .jg-search[data-wrong="true"] input { animation: none !important; border-color: var(--live); }

  /* infinite loops become strobes under the clamp — kill by name */
  .jg-tally[data-audio="playing"] i, .jg-ring[data-urgent="true"],
  .jg-claim i, .jg-buzzer[data-armed="true"]::after { animation: none !important; }

  /* every entrance, re-stated at its end state */
  .jg-cover[data-in="true"] .jg-brand,
  .jg-cover[data-in="true"] .jg-tag,
  .jg-taken[data-in="true"], .jg-track, .jg-rank, .jg-lamp, .jg-face[data-in="true"] {
    opacity: 1; transform: none; filter: none;
  }
  .jg-cover[data-in="true"] { clip-path: none; }
  .jg-odo[data-roll="true"] i:first-child { display: none; }
  .jg-chit { opacity: 1; transform: translate(-50%, -8px); }   /* JS removes it at +900ms */
  .jg-halo { display: none; }

  /* the platter stops; the timecode, ring, playhead and clocks keep updating,
     because those are INFORMATION and removing them removes the game's timing */
  .jg-disc { transform: translate(-50%,-50%) !important; }
  .jg-lane b { transform: none; transition: none; }
}

/* ── the review: the room overruling the judge ─────────────────────────────
 *
 * Post-dates the design spec. It sits INSIDE the deck's footprint rules: it is
 * a deck face, so it must not push the buzzer down the page. */
.jg-review {
  display: flex; flex-direction: column; gap: var(--s2);
  padding: var(--s3); border: 1px solid var(--line); border-radius: var(--radius-sm);
  background: var(--surface-2);
}
.jg-review-title {
  margin: 0; font-size: var(--t1); font-weight: var(--w-bold); letter-spacing: .08em;
  text-transform: uppercase; color: var(--muted);
}
.jg-review-list { display: flex; flex-direction: column; gap: var(--s2); }
.jg-review-row { display: flex; align-items: center; gap: var(--s2); flex-wrap: wrap; font-size: var(--t2); }
/* The answer being voted ON, above the guesses being voted on. Separated by a
 * rule rather than a heading: it is the reference, not another row to act on,
 * and it must not read as a fourth thing you could click. */
.jg-review-answer {
  display: flex; align-items: baseline; gap: var(--s2); flex-wrap: wrap;
  font-size: var(--t2); padding-bottom: var(--s2); margin-bottom: var(--s2);
  border-bottom: 1px solid var(--line);
}
.jg-review-answer .jg-review-text { font-size: var(--t3); }
.jg-review-who { color: var(--muted); font-size: var(--t1); }
.jg-review-text { overflow-wrap: anywhere; }
.jg-review-note { margin-left: auto; font-size: var(--t1); color: var(--muted); font-family: var(--mono); }
.jg-vote {
  font: inherit; font-size: var(--t1); font-weight: var(--w-bold); padding: var(--s1) var(--s3); min-height: 34px;
  color: var(--text); background: var(--surface); cursor: pointer;
  border: 1px solid var(--line-strong); border-radius: var(--r-pill);
}
.jg-vote:hover:not(:disabled) { border-color: var(--accent); }
.jg-vote:disabled { opacity: .5; cursor: default; }
.jg-voted { background: var(--accent); color: var(--on-accent); border-color: var(--accent); opacity: 1; }

/* ── SHORT SCREENS: the buzzer must be reachable without scrolling ─────────
 *
 * MEASURED, not guessed. On a 375x812 phone with four seats the buzzer's bottom
 * edge landed at 853px — 41px below the fold. The game's own budget was fine;
 * the platform shell above it (header, table bar, code strip) takes ~305px,
 * where the design arithmetic assumed ~120px.
 *
 * A buzzer game whose buzzer needs a scroll is broken, so the stage, waveform
 * and lamp rail all give ground here. They are decoration; the button is the
 * game. Keyed on HEIGHT as well as width — a short landscape window has the
 * same problem and none of the width symptoms. */
@media (max-width: 30rem), (max-height: 880px) {
  .jg { gap: var(--s2); }
  .jg-stage { height: clamp(7.5rem, 26vw, 10rem); }
  .jg-wave { height: 3.1rem; padding-block: var(--s1); }
  .jg-lamps { min-height: 1.55rem; }
  .jg-head { padding-bottom: var(--s1); }
  .jg-deck { min-height: 6.6rem; }
  .jg-buzzer { min-height: 4.4rem; font-size: var(--t5); }
  /* The options face still needs its own room; four 3.4rem rows do not fit in a
     buzzer's footprint at any screen size. */
  .jg[data-phase="choosing"] .jg-deck { min-height: 14.5rem; }
}

/* Four options are the tallest thing this game ever shows: measured at 901px on
 * a 375x812 phone, 89px below the fold. The waveform and the lamp rail are the
 * two things that convey nothing once the clip is over and the buzzers are shut,
 * so they give way HERE and only here.
 *
 * This does not violate the fixed-footprint contract. That contract exists so
 * the BUZZER never moves between phases — and in `choosing` there is no buzzer;
 * the deck is already sanctioned to grow. Nothing a player is reaching for
 * shifts under them. */
@media (max-width: 30rem), (max-height: 880px) {
  .jg[data-phase="choosing"] .jg-wave,
  .jg[data-phase="choosing"] .jg-lamps { height: 0; min-height: 0; padding-block: 0; overflow: hidden; }
  .jg[data-phase="choosing"] .jg-stage { height: clamp(5rem, 18vw, 7rem); }
  .jg[data-phase="choosing"] .jg-track { min-height: 3rem; padding-block: var(--s2); }
}

/* ═══════════════════════════════════════════════════════════════════════════
   THE TABLE, DRAWN — the site's own geometry, promoted to its identity.

   emptyMark() already draws a rounded plate ringed by dashed seats out of
   PLATFORM shapes, and the comment above it says why: it carries no suggestion
   of cards or turns, so it stays true when game #3 is nothing like the first
   two. That drawing becomes the mark — on the door, in every empty state, and
   in the waiting room — which costs no new vocabulary and asserts nothing false
   about any particular game.

   Nothing here is fetched. Inline SVG and CSS gradients only.
   ═══════════════════════════════════════════════════════════════════════════ */

/* At .8 the drawing's stroke fell back under 3:1 — it is the mark now, not a
   watermark. */
html[data-theme="light"] .mark { opacity: 1; }
.gate::before {                    /* the edge of a table. geometry, nothing else */
  content: ""; position: absolute; z-index: 0; left: 50%; top: 50%;
  width: min(1180px, 150vw); aspect-ratio: 1 / .62; translate: -50% -50%;
  border: 1px solid var(--line); border-radius: 50%; pointer-events: none;
  background: radial-gradient(closest-side, var(--surface), transparent);
}
.gate-mark svg { display: block; width: 100%; height: 100%; }
.gm-plate { fill: var(--accent); }
.gm-dot  { fill: none; stroke: var(--accent); stroke-width: 2.4; }
.gm-dot--kept { fill: var(--live); stroke: var(--live); }
#code:focus-visible { background: var(--surface); }
.gate-foot b {
  display: block; margin-bottom: var(--s1);
  font-family: var(--mono); font-size: var(--t1); font-weight: var(--w-bold);
  letter-spacing: .16em; text-transform: uppercase; color: var(--accent);
}

/* ── the route map Home never had ────────────────────────────────────────────
   Friends and Your matches were reachable only from one grey .84rem line buried
   inside another section's block-head. On the page, not in the top bar. */
.pagenav { display: flex; flex-wrap: wrap; gap: var(--s2); margin: 0 0 var(--s5); padding: 0; }
.pagenav a {
  padding: var(--s1) var(--s3); border-radius: var(--r-pill); font-size: var(--t2); font-weight: var(--w-med);
  color: var(--text); text-decoration: none;
  background: var(--surface); border: 1px solid var(--line);
}
.pagenav a:hover { border-color: var(--accent-dim); background: var(--surface-2); }
.pagenav a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.card:hover, .card:focus-visible { transform: translateY(-3px); box-shadow: var(--e3); }
.card:active { transform: translateY(0); box-shadow: var(--e2); }
@media (hover: none) { .card:hover { transform: none; box-shadow: var(--e2); } }
.empty-slim { padding: var(--s5) var(--s4); margin-top: var(--s2); }
.empty-slim .mark { width: 104px; margin-bottom: var(--s3); }

/* ── settings become sheets ──────────────────────────────────────────────────
   :has(.setrow) is a DESCENDANT match, not a child match. The child version
   left "Game options" flat while "Players" beside it was a sheet, because the
   options group wraps its rows in <div id="setupOptions"> and app.js renders
   into that — so the rows are grandchildren. Friends' .setgroups are still
   excluded either way: they hold <ul class="people">, which contains .person
   rows and no .setrow anywhere. */
.setgroup:has(.setrow) {
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--r4); box-shadow: var(--e1); padding: var(--s4) var(--s4) var(--s1);
}
.setgroup h3::before {
  content: ""; flex: none; width: 7px; height: 7px; rotate: 45deg;
  border-radius: 2px; background: var(--accent);
}
.seatpick-n { border-color: transparent; background: transparent; border-radius: var(--r2);
              min-width: 2.5rem; min-height: 2.5rem; }
.seatpick-n:hover { background: var(--surface); border-color: var(--line); }
@media (min-width: 760px) {
  .waitroom { grid-template-columns: minmax(0, 1fr) minmax(0, .8fr); align-items: center; }
}
.waitroom-art { display: grid; place-items: center; padding: var(--s3) 0 var(--s1); text-align: center; }
.waitroom-art .mark { width: min(240px, 70%); }
.waitroom-art p { margin: var(--s1) 0 0; font-size: var(--t2); }
.invite-code:hover { border-style: solid; border-color: var(--accent); background: var(--surface); }
.linkbox .invite-code:disabled, .linkbox .invite-code:disabled:hover { border-style: solid; }
.seat-free { box-shadow: none; }
.rules h3 { counter-increment: rulesec; display: flex; align-items: center; gap: var(--s2);
            margin: var(--s6) 0 var(--s2); }
.rules h3::before {
  content: counter(rulesec, decimal-leading-zero);
  font-family: var(--mono); font-size: var(--t1); font-weight: var(--w-bold); color: var(--accent);
  border: 1px solid var(--line-strong); border-radius: var(--r1); padding: var(--s0) var(--s1);
}
.rules h3::after { content: ""; flex: 1; height: 1px; background: var(--line); }
.match-seats > li:first-child { position: relative; padding-left: var(--s2); }
.match-seats > li:first-child::before {
  content: ""; position: absolute; left: 0; top: 2px; bottom: 2px;
  width: 3px; border-radius: 2px; background: var(--accent);
}
.match-seats > li:first-child .match-score { font-weight: var(--w-bold); }

/* ── friends ─────────────────────────────────────────────────────────────── */
.findform { padding: var(--s1); background: var(--surface);
            border: 1px solid var(--line-strong); border-radius: var(--r3); box-shadow: var(--e1); }
.findform input { border: 0; background: transparent; box-shadow: none; }
.findform input:focus-visible { outline: none; }
.findform:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; }

/* ── motion ──────────────────────────────────────────────────────────────────
   The sheet's global reduced-motion clamp shortens DURATION; it does not remove
   a transform, so the tile's hover lift would still teleport. Nothing added
   above has an opacity:0 base state, so nothing needs re-stating at its end
   state. */
@media (prefers-reduced-motion: reduce) {
  .card:hover, .card:focus-visible { transform: none; box-shadow: var(--e3); }
  .card:active { transform: none; }
  .card:hover .card-go::after { translate: 0 0; }
  .jg-lamp[data-verdict="lost"] { transform: none; }
}

/* ── COLOUR THAT COMES FROM THE GAMES ────────────────────────────────────────
   The shell owns exactly one hue — --accent, reserved for CONTROLS: focus,
   primary action, selection. Every other colour on the site belongs to a game
   that declared it, and appears only where that game is named.

   --game resolves per theme from two inline custom properties the client writes,
   so a theme switch repaints without a re-render. An undeclared game falls back
   to --muted, which tracks both themes for free. */
[data-game] { --game: var(--game-light, var(--muted)); }
html[data-theme="dark"] [data-game] { --game: var(--game-dark, var(--muted)); }

/* The tile's left rail, and the lobby row's. One shape, one meaning, everywhere
   a game is named. It replaces the accent rail on .card-plate — that rail said
   "this is a card", which every card already said; this one says WHICH GAME. */
.card[data-game] .card-plate::before { background: var(--game); }
/* Deliberately NO hover override here. The base rule below this one swaps the
   rail to --accent on hover, which would repaint the game's spine the shell's
   blue at the exact moment the player is pointing at that game. Hover is already
   carried by the lift and the elevation change; it does not need the spine, and
   the spine means something hover does not. */
.card[data-game]:hover .card-plate::before,
.card[data-game]:focus-visible .card-plate::before { background: var(--game); }

/* A PIP ON THE TITLE, NOT A RAIL. The first version drew a game-coloured bar
   down the left edge — directly on top of .row's existing border-left, which is
   already the STATUS channel (:560-561: --accent while waiting, --live while
   playing). Two rails in the same three pixels is mush, and the one it obscured
   is the more urgent of the two: on a lobby row, "can I join this" outranks
   "which game is it". The pip matches how match history carries the same fact. */
.row[data-game] .row-title::before {
  content: ""; display: inline-block; width: .5rem; height: .5rem;
  margin-right: var(--s2); border-radius: 50%; background: var(--game);
}
.row[data-game-undeclared] .row-title::before {
  background: transparent; box-shadow: inset 0 0 0 1.5px var(--muted);
}

/* Match history: a pip rather than a rail, because a match card is a record and
   a rail would compete with the winner's rail already on its first row. */
.match[data-game] .match-game::before {
  content: ""; display: inline-block; width: .5rem; height: .5rem;
  margin-right: var(--s2); border-radius: 50%; vertical-align: baseline;
  background: var(--game);
}
/* Undeclared: outlined, not filled. "No colour" gets a shape of its own rather
   than reading as a rendering failure. */
.match[data-game-undeclared] .match-game::before {
  background: transparent; box-shadow: inset 0 0 0 1.5px var(--muted);
}
.card[data-game-undeclared] .card-plate::before { background: var(--muted); }

@media (forced-colors: active) {
  .row[data-game] .row-title::before,
  .match[data-game] .match-game::before { background: CanvasText; }
}

/* ── TOUCH TARGETS ───────────────────────────────────────────────────────────
   Measured at 375x812: the Home nav pills were 31px, the back link 22px, the
   six colour swatches 34px, the seat picker 40px and the auto-start checkbox a
   17px raw OS box. WCAG 2.5.5 and the iOS HIG both want 44.

   SCOPED BY PAGE, not by control. The sheet's existing coarse-pointer rule
   enumerates five selectors precisely so a bare `.btn` would not also grow the
   game's six dense btn-sm controls — but an enumeration is a list somebody has
   to remember to extend, and it had already fallen behind. Scoping to the
   platform's own pages keeps #gameSurface untouched by construction, which is
   constraint 4 stated as a selector. */
@media (pointer: coarse) {
  :is(#pageHome, #pageSetup, #pageTable, #pageRules, #pageHistory, #pageFriends, #pageProfile)
    :is(.pagenav a, .back, .sw, .seatpick-n, .btn, .icon-btn, select, input[type="text"]) {
    min-height: var(--tap);
  }
  .pagenav a, .back { display: inline-flex; align-items: center; }
  .sw { width: var(--tap); height: var(--tap); }
  .brand { display: inline-flex; align-items: center; min-height: var(--tap); }
}

/* The auto-start control was a 17px raw OS checkbox alone in a 260px column —
   the only unstyled control on the site, on a page every host passes through.
   Built on the existing input, so #autoStart stays exactly where app.js looks
   for it and no markup changes. */
/* THE BOX IS 44px TALL AND TRANSPARENT; the 26px track is drawn by ::before.
   A 46x26 switch is the standard visual size and it is also 18px under the tap
   floor — the two are reconciled by making the hit area bigger than the paint,
   which is the only way to have both. ::before is the track, ::after the knob. */
input[type="checkbox"] {
  appearance: none; -webkit-appearance: none;
  position: relative; flex: none; cursor: pointer; margin: 0;
  width: 46px; height: var(--tap); background: none; border: 0;
}
input[type="checkbox"]::before {
  content: ""; position: absolute; inset: 50% auto auto 0; translate: 0 -50%;
  width: 46px; height: 26px; border-radius: var(--r-pill);
  background: var(--surface-2); border: 1px solid var(--line-strong);
  transition: background .16s, border-color .16s;
}
input[type="checkbox"]::after {
  content: ""; position: absolute; top: 50%; left: 3px;
  width: 18px; height: 18px; border-radius: 50%;
  background: var(--surface); border: 1px solid var(--line-strong);
  translate: 0 -50%; transition: translate .16s, background .16s, border-color .16s;
}
input[type="checkbox"]:checked::before { background: var(--accent); border-color: var(--accent); }
input[type="checkbox"]:checked::after { translate: 20px -50%; background: var(--on-accent);
                                        border-color: var(--on-accent); }
input[type="checkbox"]:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px;
                                       border-radius: var(--r-pill); }
input[type="checkbox"]:disabled { opacity: .5; cursor: default; }
/* The knob's travel IS the state; forced-colors keeps the border channel. */
@media (forced-colors: active) {
  input[type="checkbox"]::before { border-color: CanvasText; }
  input[type="checkbox"]:checked::before { background: Highlight; }
}

@media (forced-colors: active) {
  .empty, .pagenav a, .findform { border-color: CanvasText; }
  .match-seats > li:first-child::before { background: CanvasText; forced-color-adjust: none; }
}

/* ── Jingle Guesser: volume, and the score delta ────────────────────────────
 * Added live during the 2026-09-05 game night — see GAME-NIGHT-2026-09-05.md.
 * Deliberately plain: the ask was "bland but visually effective for now", and a
 * badge that reads instantly beats one that is being designed while people wait. */
.jg-vol { display: inline-flex; align-items: center; gap: var(--s1); margin-left: auto; }
.jg-vol-icon { color: var(--muted); font-size: var(--t1); }
.jg-vol input[type="range"] { width: 5.5rem; accent-color: var(--accent); cursor: pointer; }

.jg-delta {
  margin-left: var(--s1); font-variant-numeric: tabular-nums; font-weight: 700;
  font-size: var(--t1); padding: 0 .35em; border-radius: 999px;
}
.jg-delta[data-sign="up"]   { color: var(--good, #0a7a4a); background: color-mix(in srgb, var(--good, #0a7a4a) 14%, transparent); }
.jg-delta[data-sign="down"] { color: var(--bad, #b3261e);  background: color-mix(in srgb, var(--bad, #b3261e) 14%, transparent); }
.jg-delta[data-in] { animation: jg-delta-pop 1.6s ease-out forwards; }
@keyframes jg-delta-pop {
  0%   { transform: translateY(.4em) scale(.8); opacity: 0; }
  18%  { transform: translateY(0) scale(1.12);  opacity: 1; }
  30%  { transform: scale(1); }
  100% { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
  .jg-delta[data-in] { animation: none; }
}

/* A seat that has answered the multiple choice and is waiting on the room.
 * Distinct from [data-out] (missed, red rail) — this one is neutral: it says
 * "answered", not "wrong", because a correct pick ends the round outright and
 * anything still visible here is undecided. Doubled by the check glyph rather
 * than colour alone. */
.jg-chip[data-picked] { opacity: .82; }
.jg-chip[data-picked]::after {
  content: "¹3"; margin-left: .35em; color: var(--muted); font-size: var(--t1);
}

/* ── section icons in the top bar ──────────────────────────────────────────
 * Replaced .topnav (and Home's duplicate .pagenav) on 2026-09-05: every
 * destination had two doors and the bar repeated a menu already on the page.
 * Icon-sized but with a real 44px hit area — the drawing is 20px, the target
 * is not. Each carries an .sr-only label; an icon with no accessible name is
 * unusable with a screen reader. */
.navicon {
  display: inline-flex; align-items: center; justify-content: center;
  width: 44px; height: 44px; border-radius: var(--r-pill);
  color: var(--muted); text-decoration: none;
  transition: color .12s ease, background-color .12s ease;
}
.navicon svg {
  width: 20px; height: 20px; fill: none;
  stroke: currentColor; stroke-width: 1.9;
  stroke-linecap: round; stroke-linejoin: round;
}
.navicon:hover { color: var(--text); background: var(--surface-2); }
.navicon:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* Where you are. Doubled by weight as well as colour — colour alone is not a
 * status (WCAG 1.4.1). */
.navicon[aria-current="page"] { color: var(--accent); background: var(--surface-2); }
.navicon[aria-current="page"] svg { stroke-width: 2.4; }
@media (prefers-reduced-motion: reduce) { .navicon { transition: none; } }

/* ── GAMES CARRY THE COLOUR ──────────────────────────────────────────────────
 * Added 2026-09-05 with the neutral ramp. The chrome went graphite so that the
 * game surfaces would be the only saturated thing on the page — but a rail and
 * a title pip is not "carrying the colour", it is trimming with it. On a white
 * ground with neutral chrome there is room for the card to actually BE the
 * game's colour, which is the Game Night read: white shelf, coloured boxes.
 *
 * A WASH, NOT A FILL. 5% of the hue over white keeps every measured text ratio
 * intact — the card's text is still --text on very-nearly-#fff — while making
 * the tile unmistakably violet or green at a glance. A solid fill would mean
 * re-measuring every ratio inside the card against seven different grounds.
 * color-mix does that arithmetic in the browser, so the wash follows whatever
 * hue a game declares without a second token per game. */
.card[data-game] {
  background:
    linear-gradient(color-mix(in srgb, var(--game) 5%, transparent),
                    color-mix(in srgb, var(--game) 5%, transparent)),
    var(--surface);
  border-color: color-mix(in srgb, var(--game) 26%, var(--line));
}
.card[data-game]:hover,
.card[data-game]:focus-visible {
  background:
    linear-gradient(color-mix(in srgb, var(--game) 9%, transparent),
                    color-mix(in srgb, var(--game) 9%, transparent)),
    var(--surface);
  border-color: color-mix(in srgb, var(--game) 44%, var(--line));
}

/* The game's NAME in the game's colour. Every hue in the palette clears 4.5:1
 * on white and the wash above is 5%, so this is the one place the colour is
 * load-bearing text and it is safe in all seven. */
.card[data-game] .card-name { color: var(--game); }

/* The tile's call to action, filled rather than outlined. Every palette hue
 * also clears 4.5:1 with WHITE ON IT — measured both directions — so one token
 * serves as heading and as button with no second variant. */
.card[data-game] .card-go {
  background: var(--game); color: #fff;
  border-radius: var(--r-pill); padding: .3em .8em;
}
.card[data-game] .card-go::after { color: #fff; }

/* The table you are sitting at, and the lobby row, wear it too — so the game
 * is identifiable from the bar without reading the name. */
.tablechip[data-game] .tablechip-dot { background: var(--game); }

@media (prefers-reduced-motion: reduce) {
  .card[data-game]:hover { background: var(--surface); }
}

/* ── friends: exact toggle + invite ────────────────────────────────────────
 * Added 2026-09-05 with the invite endpoint. Plain on purpose — this surface
 * hands somebody a credential, and a credential should look like text you copy,
 * not like decoration. */
.checkline {
  display: flex; align-items: center; gap: var(--s2);
  margin: var(--s2) 0 var(--s3); font-size: var(--t1); color: var(--muted);
  cursor: pointer;
}
.checkline input { accent-color: var(--accent); width: 1.05em; height: 1.05em; cursor: pointer; }

.invite-out {
  margin-top: var(--s2); padding: var(--s3);
  background: var(--surface-2); border: 1px solid var(--line-strong);
  border-radius: var(--r3);
}
/* Monospace and letter-spaced, because this gets read aloud and typed by hand.
 * user-select:all so one tap or click takes the whole code rather than a word
 * of it — the clipboard button can be blocked, this cannot. */
.invite-code {
  font-family: var(--mono); font-size: var(--t3); font-weight: var(--w-bold);
  letter-spacing: .06em; word-break: break-all; user-select: all;
  margin: var(--s1) 0 var(--s2); color: var(--text);
}

/* ── the back control ──────────────────────────────────────────────────────
 * Was a text link at the top of the scroll column, so on a long page it
 * scrolled away and the only way out was the browser's own Back. Now it is a
 * round, fixed-size control pinned to the page's top-left, the way every app
 * on a phone puts it — sticky, so it is still there after you have scrolled a
 * hundred matches.
 *
 * Still an <a href="/">, not a history.back(): arriving here directly (a
 * refresh, a pasted link) has no history to go back to, and a control that
 * does nothing on a fresh load is worse than one that always goes home. */
.back {
  position: sticky; top: var(--s3); z-index: 2;
  display: inline-flex; align-items: center; justify-content: center;
  width: var(--tap); height: var(--tap); margin-bottom: var(--s3);
  border-radius: var(--r-pill);
  background: var(--surface); border: 1px solid var(--line-strong);
  box-shadow: var(--e1);
  color: var(--text); text-decoration: none; font-size: 0;
}
.back::before {
  /* A LITERAL arrow, not the CSS escape "\\2190". The escape is one
     backslash away from silently becoming a control character, which is
     how this rendered a tofu box and the digits "90" for a whole day. */
  content: "←"; font-size: 1.25rem; line-height: 1;
}
.back:hover { background: var(--surface-2); }
.back:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* ── list controls (Your matches) ──────────────────────────────────────────
 * Replaces the page's heading and its one-line description. A list that grows
 * every week needs narrowing more than it needs a label. */
.listbar { display: flex; gap: var(--s2); flex-wrap: wrap; margin-bottom: var(--s3); }
.listbar select {
  font: inherit; color: var(--text);
  padding: var(--s1) var(--s2); border-radius: var(--r2);
  background: var(--surface); border: 1px solid var(--line-strong);
  min-height: var(--tap); cursor: pointer;
  /* The global `select { width: 100% }` is meant for the settings forms, where
     a control owns its row. Here two controls share one, so each takes an equal
     share and they only stack when the row genuinely cannot hold them. */
  width: auto; flex: 1 1 10rem; min-width: 0;
}
.listbar select:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* The connection DOT goes quiet when there is nothing wrong.
 * A permanent green dot is a status that never changes, which trains people to
 * stop reading it — and then it cannot warn them either. It appears while
 * connecting and while lost, which is the only time it is news.
 *
 * The DOT, not the pill: app.js deliberately says "Connected" for four seconds
 * after an outage recovers, and that word is still state "live". Hiding the
 * whole pill would have swallowed the one message that confirms a drop is
 * over — the reassurance, deleted, while keeping the alarm. */
.conn[data-state="live"] .conn-dot { display: none; }
/* ...and once BOTH its children are hidden the pill is an empty 17px box still
 * holding its own padding open in the middle of the bar. Collapse it, but only
 * while the word is genuinely empty — the moment app.js writes "Connected" the
 * :has() stops matching and the padding comes back with it. */
.conn[data-state="live"]:has(.conn-word:empty) {
  padding: 0; min-height: 0; border-width: 0;
}

/* ── a game that brought its own artwork ───────────────────────────────────
 *
 * Board Game Arena's game tiles, measured on their own list page: a landscape
 * panel at roughly 2:1 (170x79, 174x87), border-radius 4px, background cover,
 * centred, no shadow, and the game's NAME INSIDE THE ART. That is the whole
 * convention, and it is a good one for the same reason a shelf of boxes beats a
 * shelf of spines — nobody chooses a game by reading a wall of lettering.
 *
 * Ours is that shape with one addition: the banner we drew has no lettering in
 * it and the box render does, so the box sits on the banner as a product shot
 * and carries the title. The two assets are the ones drawn for that very
 * submission -- the 280x280 box and the 1386x400 banner every BGA publisher is
 * asked for.
 *
 * Scoped entirely to [data-art]. A game that declares none is untouched. */
.card[data-art] .card-plate {
  aspect-ratio: 2 / 1;
  padding: 0;
  background-image: none;          /* the artwork replaces the corner wash */
  background-color: #14361f;       /* the banner's own baize, behind a slow load */
  justify-content: flex-end;
}
.card[data-art] .card-art {
  position: absolute; inset: 0; display: block; overflow: hidden;
}
.card-banner {
  width: 100%; height: 100%; object-fit: cover; object-position: 50% 42%;
  display: block;
  /* The banner is 3.47:1 and this panel is 2:1, so it IS being cropped. 42%
     keeps the players' faces and the fanned hand, which is the half worth
     seeing; dead centre cuts foreheads. */
  transition: transform .5s cubic-bezier(.16,1,.3,1);
}
.card:hover .card-banner { transform: scale(1.04); }

/* The box, standing on the art. Bottom-left so it never covers the fanned hand
   in the banner's bottom-right, which is the one detail that reads at this size. */
.card-box {
  position: absolute; left: var(--s3); bottom: var(--s3);
  width: 38%; max-width: 116px; height: auto; display: block;
  filter: drop-shadow(0 6px 14px rgb(0 0 0 / .55));
  transition: transform .28s cubic-bezier(.16,1,.3,1);
}
.card:hover .card-box { transform: translateY(-3px) scale(1.03); }

/* The title is IN the artwork now, so the text copy goes visually — but stays
   in the DOM, because it is the button's accessible name. Same technique as
   .sr-only rather than display:none, which would take it out of the tree. */
.card[data-art] .card-name {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip-path: inset(50%); white-space: nowrap;
}
/* The coloured spine still reads over artwork, and is what keeps an art card
   and a lettering card recognisably the same shelf. */
.card[data-art] .card-plate::before { z-index: 2; width: 4px; }

/* Forced colours strips background images entirely, so an art card would be an
   empty box with an invisible title. Put the lettering back. */
@media (forced-colors: active) {
  .card[data-art] .card-art { display: none; }
  .card[data-art] .card-name {
    position: static; width: auto; height: auto; margin: 0;
    clip-path: none; white-space: normal;
  }
  .card[data-art] .card-plate { aspect-ratio: 16 / 10; padding: var(--s3) var(--s4); }
}

/* The facts row, over the artwork rather than under it.
 *
 * .card-art is position:absolute, and an absolutely-positioned element paints
 * above its non-positioned siblings — so "2–6 players / about 45 min" was still
 * in the layout at full size and simply covered by the banner. The card looked
 * like it had lost its metadata; it had not, it was behind a painting.
 *
 * Lifted above the art and given a scrim to sit on, which is also what makes it
 * legible: the banner's lower edge is a mid-green table, and mid-grey text on it
 * would fail contrast at any size. White on this scrim does not. */
.card[data-art] .card-meta {
  position: relative; z-index: 2;
  margin: 0; padding: var(--s5) var(--s3) var(--s2) var(--s3);
  border-top: 0;
  color: #fff;
  background: linear-gradient(to top, rgb(0 0 0 / .82) 0%, rgb(0 0 0 / .55) 55%, transparent 100%);
}
.card[data-art] .card-fact { color: #fff; opacity: .93; }
/* The box render is bottom-left and the facts row now runs along the bottom, so
   the box lifts clear of it rather than being cropped by the scrim. */
.card[data-art] .card-box { bottom: calc(var(--s3) + 1.9rem); }

/* The setup screen's key art. Wide and short, because the asset is 1386x400 and
   cropping a painting to a square throws away the half that tells you what the
   game is. Sits flush at the page's own radius so it reads as the page's head
   rather than an image dropped into it. */
.setup-art {
  margin: 0 0 var(--s4); border-radius: var(--r3); overflow: hidden;
  border: 1px solid var(--line); background: #14361f; line-height: 0;
}
.setup-art img {
  width: 100%; height: auto; display: block;
  aspect-ratio: 1386 / 400; object-fit: cover; object-position: 50% 45%;
}
/* On a phone the full 3.47:1 strip is only ~100px tall and the faces vanish.
   Cropped tighter so the scene still reads at the size it actually gets. */
@media (max-width: 560px) {
  .setup-art img { aspect-ratio: 16 / 9; }
}

/* ════════════════════════════════════════════════════════════════════════════
   JINGLE GUESSER — THE STAGE
   ════════════════════════════════════════════════════════════════════════════

   Reviewed honestly, the play screen was a form: black text on white, a grey
   bar, a grey rectangle labelled BUZZ, and the scores as two chips the size of
   a date field. Every element was in the right place and none of it said "game
   show", because nothing said anything at all.

   THE GAME TAKES THE SCREEN. The shell stays light — it is a website, and the
   list of games, your matches and your profile should stay a website. But a
   round in progress is a broadcast, so .jg goes dark and owns its rectangle.
   That single inversion does most of the work: the same widgets read as a stage
   the moment they stop sitting on a document.

   RESTYLING ONLY. The markup, the phases, the beats and the accessible names
   are untouched — this block adds no element and removes none, because the
   layout was never the problem.

   Colour is the game's declared violet (meta.colour #7e22ce / #f588dd), so the
   card on the front page, its spine, its key art and this stage are one thing.
   Contrast was measured against the stage ground rather than assumed: body text
   is #f3e8ff on #1c0b2e at 14.9:1, and the dim tier #c9aee4 is 8.1:1. */

.jg {
  --stage: #1c0b2e;
  --stage-2: #2c1148;
  --stage-line: #4a2570;
  --stage-ink: #f3e8ff;        /* 14.9:1 on --stage */
  --stage-dim: #c9aee4;        /*  8.1:1 on --stage */
  --stage-hot: #ff5d8f;
  --stage-lit: #f588dd;

  position: relative;
  padding: var(--s4);
  border-radius: var(--r4);
  color: var(--stage-ink);
  background:
    radial-gradient(120% 80% at 18% 0%, rgb(245 136 221 / .16), transparent 62%),
    linear-gradient(160deg, var(--stage-2) 0%, var(--stage) 58%, #140820 100%);
  border: 1px solid var(--stage-line);
  box-shadow: 0 18px 48px -22px rgb(28 11 46 / .8), inset 0 1px 0 rgb(255 255 255 / .06);
  overflow: hidden;
}
/* Scanlines, at the strength where they are texture rather than a gimmick. */
.jg::before {
  content: ""; position: absolute; inset: 0; pointer-events: none; z-index: 0;
  background: repeating-linear-gradient(to bottom,
    rgb(0 0 0 / .13) 0 1px, transparent 1px 4px);
  opacity: .5;
}
.jg > * { position: relative; z-index: 1; }

/* ── the broadcast strip ────────────────────────────────────────────────── */
.jg .jg-head {
  align-items: center; gap: var(--s3);
  padding-bottom: var(--s3); margin-bottom: var(--s3);
  border-bottom: 1px solid var(--stage-line);
}
.jg .jg-tally {
  color: var(--stage-dim); letter-spacing: .16em;
  /* The ON AIR lamp the text was already claiming to be. */
  position: relative; padding-left: 1.15em;
}
.jg .jg-tally::before {
  content: ""; position: absolute; left: 0; top: 50%; translate: 0 -50%;
  width: .58em; height: .58em; border-radius: 50%;
  background: var(--stage-hot); box-shadow: 0 0 10px var(--stage-hot);
}
.jg .jg-vol-icon { color: var(--stage-dim); }
.jg .jg-vol input { accent-color: var(--stage-lit); }
.jg .jg-pips span { background: var(--stage-line); }
.jg .jg-pips span[data-on="true"] { background: var(--stage-lit); }

/* ── the scoreboard, as a scoreboard ───────────────────────────────────────
   Two chips the size of a form field were not a scoreboard; they were a
   readout. Podium plates, one per player, with the score as the largest thing
   on them, because the score is what the room looks at between rounds. */
.jg .jg-board { gap: var(--s2); flex-wrap: wrap; }
.jg .jg-chip {
  display: inline-flex; align-items: baseline; gap: var(--s2);
  padding: var(--s2) var(--s3);
  border-radius: var(--r2);
  background: linear-gradient(180deg, rgb(255 255 255 / .09), rgb(255 255 255 / .03));
  border: 1px solid var(--stage-line);
  color: var(--stage-ink);
  box-shadow: inset 0 1px 0 rgb(255 255 255 / .07);
}
.jg .jg-chip .jg-odo { font-size: 1.35rem; font-weight: var(--w-bold); color: #fff; }
.jg .jg-chip > span { color: var(--stage-dim); font-size: var(--t2); }
/* You, and whoever is holding the buzzer, are the two states worth seeing. */
/* data-me, not data-you: that is the attribute the client actually sets.
   A wiring test rejects a rule that keys on an attribute nothing writes. */
.jg .jg-chip[data-me="true"] { border-color: var(--stage-lit); }
.jg .jg-chip[data-live="true"] {
  border-color: var(--stage-hot);
  box-shadow: 0 0 0 1px var(--stage-hot), 0 0 18px -4px var(--stage-hot);
}

/* ── centre stage ──────────────────────────────────────────────────────── */
.jg .jg-stage {
  border-radius: var(--r3);
  background:
    radial-gradient(90% 120% at 50% 0%, rgb(245 136 221 / .1), transparent 70%),
    rgb(0 0 0 / .26);
  border: 1px solid var(--stage-line);
  min-height: 168px;
}
.jg .jg-claim { color: var(--stage-dim); letter-spacing: .18em; }
.jg .jg-brand { color: #fff; text-shadow: 0 2px 18px rgb(245 136 221 / .5); }
.jg .jg-taken { color: var(--stage-lit); }
.jg .jg-taken b { color: #fff; }
.jg .jg-tag {
  background: rgb(255 255 255 / .08); color: var(--stage-dim);
  border: 1px solid var(--stage-line);
}
.jg .jg-disc { opacity: .16; }

/* ── the waveform ──────────────────────────────────────────────────────── */
.jg .jg-lane span { background: var(--stage-line); }
.jg .jg-lane--play span { background: var(--stage-lit); }
.jg .jg-notch { background: var(--stage-hot); box-shadow: 0 0 8px var(--stage-hot); }

/* ── the buzzer, as a buzzer ───────────────────────────────────────────────
   It was a dark grey rectangle with BUZZ in it, which is a submit button. A
   buzzer is round, red, and looks like it will travel when you hit it. */
.jg .jg-buzzer {
  border-radius: 999px;
  background: radial-gradient(120% 120% at 50% 22%, #ff7ba6 0%, #e01e5a 46%, #a00f3e 100%);
  border: 1px solid #ff8fb4;
  color: #fff; font-weight: var(--w-bold); letter-spacing: .2em;
  text-shadow: 0 1px 2px rgb(0 0 0 / .45);
  box-shadow:
    0 10px 0 -2px #6d0a2b,
    0 16px 30px -10px rgb(224 30 90 / .65),
    inset 0 2px 0 rgb(255 255 255 / .4);
  transition: transform .08s, box-shadow .08s, filter .14s;
}
.jg .jg-buzzer:hover:not(:disabled) { filter: brightness(1.06); }
.jg .jg-buzzer:active:not(:disabled), .jg .jg-buzzer.is-down {
  transform: translateY(7px);
  box-shadow: 0 3px 0 -2px #6d0a2b, 0 6px 14px -8px rgb(224 30 90 / .6),
              inset 0 2px 0 rgb(255 255 255 / .25);
}
.jg .jg-buzzer:disabled {
  background: linear-gradient(180deg, #3a1b52, #2a1140);
  border-color: var(--stage-line); color: var(--stage-dim);
  box-shadow: 0 6px 0 -2px #190a28, inset 0 1px 0 rgb(255 255 255 / .05);
  text-shadow: none;
}

/* ── the deck: choices, typing, next, review ──────────────────────────── */
.jg .jg-key {
  background: linear-gradient(180deg, rgb(255 255 255 / .11), rgb(255 255 255 / .04));
  border: 1px solid var(--stage-line); color: var(--stage-ink);
  box-shadow: inset 0 1px 0 rgb(255 255 255 / .08);
}
.jg .jg-key:hover:not(:disabled) { border-color: var(--stage-lit); }
.jg .jg-list button, .jg .jg-review button {
  background: linear-gradient(180deg, rgb(255 255 255 / .1), rgb(255 255 255 / .035));
  border: 1px solid var(--stage-line); color: var(--stage-ink);
}
.jg .jg-list button:hover:not(:disabled) {
  border-color: var(--stage-lit); background: rgb(245 136 221 / .16);
}
.jg .jg-search input {
  background: rgb(0 0 0 / .34); border: 1px solid var(--stage-line);
  color: #fff;
}
.jg .jg-search input::placeholder { color: var(--stage-dim); opacity: .75; }
.jg .jg-search input:focus-visible { outline: 2px solid var(--stage-lit); outline-offset: 1px; }
.jg .jg-review-title { color: var(--stage-dim); }
.jg .jg-review-answer { color: var(--stage-ink); }
.jg .jg-review-text { color: #fff; }

/* The next key's clock, on a ground that can carry it. */
.jg .jg-next::before { background: rgb(245 136 221 / .3); }
.jg .jg-next[data-urgent="true"]::before { background: rgb(255 93 143 / .48); }

/* ── the reveal, made declarative ─────────────────────────────────────────
 *
 * THE ARCHITECTURAL FIX, not another patch on the same bug.
 *
 * Three separate defects today were one shape: content whose only route to
 * being visible was an animation firing. The buzz gate, the reveal beats, and
 * then the cover itself — each time, something did not fire and the player was
 * shown nothing, silently, with the content sitting in the DOM at opacity 0.
 *
 * The brand was the last of them. `.jg-brand` is opacity:0 in its base rule and
 * was lifted ONLY by `animation: jg-brand … forwards`. Measured on a live
 * reveal with the cover fully open: `animation-name: none`, no running
 * animations, opacity 0, the text "P&G" present at full size and invisible.
 *
 * So visibility no longer depends on an animation running. The open state is
 * declared, and the animation is decoration over the top — if it never fires,
 * or is stripped by reduced-motion, or the element was already mid-transition,
 * the answer is still on the screen. An animation that fails should cost a
 * flourish, never the content.
 */
.jg-cover[data-in="true"] .jg-brand,
.jg-cover[data-in="true"] .jg-taken,
.jg-cover[data-in="true"] .jg-tag {
  opacity: 1;
  transform: none;
  filter: none;
}

/* The cover is a panel on a dark stage now, so it stops being --surface (a
   light sheet, which read as a hole punched in the broadcast) and becomes part
   of the set. Brand contrast measured on this ground: #ffffff on #24103a is
   15.9:1. */
.jg .jg-cover {
  background:
    radial-gradient(120% 100% at var(--cx, 26%) var(--cy, 16%),
      rgb(245 136 221 / .22), transparent 62%),
    linear-gradient(180deg, #2e1449 0%, #24103a 100%);
  border: 1px solid var(--stage-line);
  border-radius: var(--r3);
}
.jg .jg-cover .jg-brand { color: #fff; }

/* The claim strip must not sit on the answer.
 *
 * `.jg-claim` is `inset: 38% 0 auto 0; z-index: 5` — a full-width band across
 * the middle of the stage, ABOVE the cover at z-index 4. That is right while
 * the round is live, when the band is the only thing on the stage and the
 * middle is where it should be.
 *
 * At the reveal it is wrong, and visibly so: the cover opens underneath it and
 * the band lands straight across the brand. Photographed on a real match —
 * "ANYONE CAN HIT NEXT" printed over the answer, and at the end "GAME OVER"
 * with "…&G" of P&G showing past either side. The one thing the room is waiting
 * to read was the one thing covered up.
 *
 * So once the cover is open the strip drops to the foot of the stage and gives
 * the middle back. It keeps saying what it says — it just stops saying it on
 * top of the answer. */
.jg-stage:has(.jg-cover[data-in="true"]) .jg-claim {
  inset: auto 0 0 0;
  z-index: 5;
  background: color-mix(in oklab, var(--accent) 88%, #000);
  border-block-width: 1px 0;
}

/* Fallback for an engine without :has(). The band goes translucent rather than
   moving, so the brand is still readable through it — worse than moving it,
   much better than hiding the answer. */
@supports not selector(:has(*)) {
  .jg-cover[data-in="true"] ~ .jg-claim { opacity: .72; }
}

/* ════════════════════════════════════════════════════════════════════════════
   JEOPARDY
   ════════════════════════════════════════════════════════════════════════════

   The same decision as Jingle Guesser's stage: the shell stays light because it
   is a website, and the game goes dark because a round in progress is not a
   document. Deep blue rather than violet, because the game declares amber and
   blue is what amber wants to sit on.

   NOTHING HERE IS OPACITY-GATED. Three bugs in this project have been content
   whose only route to being visible was an animation firing. The renderer draws
   the state it is given; this sheet colours it and nothing more.

   Contrast measured against the board ground #0b1a3a: body ink #e8eefc is
   16.1:1, the dim tier #a8bde0 is 8.4:1, and the amber value text #fbbf24 on
   the cell ground #12275c is 8.9:1. */

.qb {
  --qb-ground: #0b1a3a;
  --qb-cell: #12275c;
  --qb-line: #24407e;
  --qb-ink: #e8eefc;
  --qb-dim: #a8bde0;
  --qb-gold: #fbbf24;

  position: relative;
  padding: var(--s4);
  border-radius: var(--r4);
  color: var(--qb-ink);
  background: linear-gradient(170deg, #143063 0%, var(--qb-ground) 62%, #060f24 100%);
  border: 1px solid var(--qb-line);
  box-shadow: 0 18px 48px -24px rgb(6 15 36 / .85), inset 0 1px 0 rgb(255 255 255 / .06);
}

.qb-head {
  display: flex; align-items: baseline; justify-content: space-between; gap: var(--s3);
  padding-bottom: var(--s3); margin-bottom: var(--s3);
  border-bottom: 1px solid var(--qb-line);
}
.qb-round {
  font: var(--w-bold) var(--t1)/1 var(--mono);
  letter-spacing: .18em; text-transform: uppercase; color: var(--qb-dim);
}
.qb-clock {
  font: var(--w-bold) var(--t4)/1 var(--mono);
  font-variant-numeric: tabular-nums; color: var(--qb-gold);
}

/* ── scores ──────────────────────────────────────────────────────────────── */
.qb-scores { display: flex; gap: var(--s2); flex-wrap: wrap; margin-bottom: var(--s4); }
.qb-score {
  display: flex; flex-direction: column; gap: 2px;
  padding: var(--s2) var(--s3); min-width: 6.5rem;
  border-radius: var(--r2);
  background: linear-gradient(180deg, rgb(255 255 255 / .09), rgb(255 255 255 / .03));
  border: 1px solid var(--qb-line);
}
.qb-score-value {
  font: var(--w-bold) var(--t4)/1 var(--mono);
  font-variant-numeric: tabular-nums; color: #fff;
}
.qb-score-name { font-size: var(--t1); color: var(--qb-dim); }
.qb-score[data-me="true"] { border-color: var(--qb-gold); }
/* Two states worth seeing at a glance: whose board it is, and who has the
   buzzer. Doubled by a label rather than colour alone would be better still;
   the turn line above the board already says whose board it is in words. */
.qb-score[data-control="true"] { box-shadow: inset 0 0 0 1px var(--qb-gold); }
.qb-score[data-live="true"] {
  border-color: #ff5d8f; box-shadow: 0 0 18px -4px #ff5d8f;
}
.qb-score[data-out="true"] { opacity: .55; }

.qb-turn {
  font: var(--w-med) var(--t2)/1.3 var(--font, inherit);
  color: var(--qb-dim); margin-bottom: var(--s3);
}

/* ── the board ───────────────────────────────────────────────────────────── */
.qb-board {
  display: grid; gap: var(--s2);
  grid-template-columns: repeat(6, minmax(0, 1fr));
}
/* Six columns is unreadable on a phone. Three keeps the category headings
   legible and the whole board reachable with one scroll. */
@media (max-width: 720px) { .qb-board { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
@media (max-width: 420px) { .qb-board { grid-template-columns: repeat(2, minmax(0, 1fr)); } }

.qb-col { display: grid; gap: var(--s2); align-content: start; }
.qb-cat {
  display: grid; place-items: center; min-height: 3.4rem; padding: var(--s2);
  text-align: center; text-wrap: balance;
  font: var(--w-bold) var(--t1)/1.15 var(--font, inherit);
  letter-spacing: .04em; text-transform: uppercase; color: var(--qb-ink);
  background: rgb(0 0 0 / .3); border: 1px solid var(--qb-line);
  border-radius: var(--r2);
}
.qb-cell {
  display: grid; place-items: center; min-height: 3.2rem;
  font: var(--w-bold) var(--t3)/1 var(--mono); color: var(--qb-gold);
  background: var(--qb-cell); border: 1px solid var(--qb-line);
  border-radius: var(--r2); cursor: pointer;
  transition: background .12s, transform .12s, border-color .12s;
}
.qb-cell:hover:not(:disabled) { background: #1b3a86; transform: translateY(-2px); }
.qb-cell:focus-visible { outline: 2px solid var(--qb-gold); outline-offset: 2px; }
.qb-cell:disabled { cursor: default; opacity: .6; }
/* A spent square is a hole in the board, not a greyed-out button. */
.qb-cell[data-taken="true"] { background: rgb(0 0 0 / .34); border-style: dashed; }

/* ── a clue on the stage ─────────────────────────────────────────────────── */
.qb-stage {
  display: grid; gap: var(--s4); justify-items: center; text-align: center;
  padding: var(--s5) var(--s4);
  background: radial-gradient(90% 120% at 50% 0%, rgb(251 191 36 / .09), transparent 70%),
              rgb(0 0 0 / .28);
  border: 1px solid var(--qb-line); border-radius: var(--r3);
}
.qb-stage-cat {
  font: var(--w-bold) var(--t1)/1 var(--mono);
  letter-spacing: .18em; text-transform: uppercase; color: var(--qb-gold);
}
.qb-prompt {
  margin: 0; max-width: 34ch;
  font: var(--w-bold) clamp(1.2rem, 4.4vw, 2rem)/1.25 var(--font, inherit);
  color: #fff; text-wrap: balance;
}
/* The clue after somebody has buzzed. Deliberately NOT styled like the clue —
   same slot, same height, but dimmed and lighter, so the room reads "it is gone"
   rather than "it is still there and I cannot focus". */
.qb-prompt-gone {
  color: var(--qb-dim); font-weight: var(--w-med);
  font-style: italic; letter-spacing: .01em;
}
.qb-answer { display: grid; gap: var(--s1); }
.qb-answer-label {
  font: var(--w-med) var(--t1)/1 var(--mono);
  letter-spacing: .16em; text-transform: uppercase; color: var(--qb-dim);
}
.qb-answer-text { font: var(--w-bold) clamp(1.4rem, 5vw, 2.2rem)/1.1 var(--font, inherit); color: var(--qb-gold); }
.qb-verdict { font: var(--w-med) var(--t2)/1 var(--font, inherit); color: var(--qb-ink); }
.qb-status { font: var(--w-med) var(--t2)/1 var(--font, inherit); color: var(--qb-dim); }

/* ── controls ────────────────────────────────────────────────────────────── */
.qb-buzzer {
  min-width: min(22rem, 100%); min-height: 4.4rem;
  border-radius: 999px; border: 1px solid #ff8fb4;
  font: var(--w-bold) var(--t4)/1 var(--font, inherit); letter-spacing: .2em;
  color: #fff; cursor: pointer;
  background: radial-gradient(120% 120% at 50% 22%, #ff7ba6 0%, #e01e5a 46%, #a00f3e 100%);
  box-shadow: 0 10px 0 -2px #6d0a2b, 0 16px 30px -10px rgb(224 30 90 / .6),
              inset 0 2px 0 rgb(255 255 255 / .4);
  transition: transform .08s, box-shadow .08s;
}
.qb-buzzer:active:not(:disabled) {
  transform: translateY(7px);
  box-shadow: 0 3px 0 -2px #6d0a2b, inset 0 2px 0 rgb(255 255 255 / .25);
}
.qb-buzzer:disabled {
  background: linear-gradient(180deg, #21305a, #16234a);
  border-color: var(--qb-line); color: var(--qb-dim); cursor: default;
  box-shadow: 0 6px 0 -2px #0a1430;
}
.qb-key {
  min-height: var(--tap); padding: 0 var(--s4);
  border-radius: var(--r2); cursor: pointer;
  font: var(--w-bold) var(--t2)/1 var(--font, inherit); color: var(--qb-ink);
  background: linear-gradient(180deg, rgb(255 255 255 / .12), rgb(255 255 255 / .04));
  border: 1px solid var(--qb-line);
}
.qb-key:hover { border-color: var(--qb-gold); }
.qb-answerform { display: flex; gap: var(--s2); width: min(30rem, 100%); }
.qb-input {
  flex: 1 1 auto; min-width: 0; min-height: var(--tap);
  padding: 0 var(--s3); border-radius: var(--r2);
  font: var(--w-med) var(--t2)/1 var(--font, inherit); color: #fff;
  background: rgb(0 0 0 / .36); border: 1px solid var(--qb-line);
}
.qb-input::placeholder { color: var(--qb-dim); opacity: .75; }
.qb-input:focus-visible { outline: 2px solid var(--qb-gold); outline-offset: 1px; }

/* ── the table header, once a match is actually running ───────────────────
 *
 * Photographed on a live table: a 36px game name, "In progress." under it, a
 * row of seat chips and an End-this-table button — a document header sitting on
 * top of a game that had just been given a dark broadcast stage of its own. The
 * two read as two different pages stacked.
 *
 * WHILE WAITING, NONE OF THIS APPLIES and the header keeps every pixel it has.
 * That screen IS the header: the table code, the seat count and Start are the
 * entire reason anybody is looking at it. The compaction is scoped to
 * [data-live], which app.js sets only once a match is under way.
 *
 * Nothing is removed. The exits, the seat list and the game's name are all
 * still there and still reachable — they stop being a headline and become a
 * bar, which is what chrome above a game should be. */

/* NOTHING BUT THE GAME, once a match is running.
 *
 * The compaction this replaces made the header smaller and kept all of it. That
 * was still a document header sitting on a broadcast stage: reported live while
 * playing Wheel of Fortune, the game's name, "In progress.", a row of seat chips
 * carrying the player's own name, and an End-this-table button were all stacked
 * above the puzzle. None of it is about the game being played — the game draws
 * its own scoreboard, whose turn it is and who holds the buzzer — so on a phone
 * it was spending the top of the viewport saying things the board says better.
 *
 * NOTHING IS REMOVED, IT MOVED. The exits live in the top bar now, under the
 * table icon beside Friends and Your matches, which is where the other controls
 * you press once a session already are. The seat strip's one unique fact is who
 * is actually CONNECTED, which the game cannot know; if that turns out to be
 * missed it comes back as something the game is told, not as a permanent strip.
 *
 * WHILE WAITING, NONE OF THIS APPLIES. That screen IS the header — the table
 * code, the seat count and Start are the entire reason anybody is looking at it
 * — so this is scoped to [data-live], which app.js sets only once a match is
 * under way. */
#pageTable[data-live] .table-head,
#pageTable[data-live] .seats,
#pageTable[data-live] .gate-line { display: none; }

/* ── the table menu in the top bar ───────────────────────────────────────── */

.tablemenu { position: relative; display: inline-flex; }
.tablemenu-list {
  position: absolute; inset-inline-end: 0; top: calc(100% + var(--s2));
  z-index: 40; min-width: 12rem;
  display: grid; gap: 2px; padding: var(--s2);
  background: var(--raise, #161b22); border: 1px solid var(--line);
  border-radius: var(--r2); box-shadow: 0 12px 32px rgb(0 0 0 / .45);
}
.tablemenu-head {
  margin: 0 0 var(--s1); padding: 0 var(--s2);
  font: var(--w-bold) var(--t1)/1.4 var(--mono);
  letter-spacing: .1em; text-transform: uppercase; color: var(--muted);
}
.tablemenu-item {
  appearance: none; text-align: start; cursor: pointer;
  padding: var(--s2) var(--s3); border: 0; border-radius: var(--r1);
  background: transparent; color: inherit;
  font: var(--w-med) var(--t2)/1.2 var(--font, inherit);
}
.tablemenu-item:hover, .tablemenu-item:focus-visible { background: rgb(255 255 255 / .08); }
.tablemenu-danger { color: var(--danger, #f87171); }

/* The buzz latch, on a stage that is now dark.
 *
 * It hatches itself out of --surface and --surface-2, which are the SHELL's
 * light greys — correct while the game sat on a white page, and a bright white
 * bar across the middle of a broadcast stage once the game went dark. Caught by
 * looking at the lead-in on a live table rather than by reading the sheet:
 * "COUNTDOWN" was the most prominent thing on screen and should be the least.
 *
 * Scoped inside .jg so it only applies where the stage is. */
.jg .jg-latch {
  color: var(--stage-ink);
  border-color: var(--stage-line);
  background: repeating-linear-gradient(-45deg,
    #2a1142 0 9px, #1f0c33 9px 18px);
}
/* The lead-in digits sit on the same dark ground and were using --accent, which
   is the shell's graphite. */
.jg .jg-count { color: var(--stage-lit); text-shadow: 0 0 26px rgb(245 136 221 / .5); }

/* ═══════════════════════════════════════════════════════════════════════════
   WHEEL OF FORTUNE — the puzzle board

   52 cells in four rows of 12 / 14 / 14 / 12, drawn on a 14-column grid with
   the short rows inset one cell at each end. The engine
   (games/wheel-of-fortune/engine/board.mjs) decides which cell holds what; this
   decides only what a cell looks like.

   EVERY NUMBER HERE IS A CUSTOM PROPERTY ON PURPOSE. Nothing about this board's
   appearance is published — not one official hex value, not the gap between
   cells, not the cell proportion. A five-way research pass found no source for
   any of them and correctly refused to invent one. So the values below are
   sourced where they can be, labelled where they cannot, and all of them sit in
   one place so that measuring a single broadcast frame later retunes the board
   without touching a rule.
   ═══════════════════════════════════════════════════════════════════════════ */
.wof {
  /* CELL PROPORTION — width divided by height. Derived, not measured: each cell
     of the 1997-2022 board was a Sony PVM-2030 CRT "placed on its side"
     (BuzzerBlog, quoting production paperwork), and the manual's 50.6 cm picture
     in a 516x409 mm cabinet puts the rotated raster at 3:4 = 0.750 and the
     rotated cabinet pitch at 409/516 = 0.793. The lit cell is somewhere between.
     What is certain is the direction: cells are TALLER THAN WIDE. Never 1. */
  --wof-cell-aspect: .77;

  /* GREEN, WHITE AND THE LETTER. No official value exists for any of these.
     These are from the most careful fan recreation found — a Three.js remake
     that implements the show's blue-flash reveal — and are therefore somebody
     else's eye, not a specification. The off-white matters: the board has never
     been pure #ffffff, and Season 40 pushed it warmer still. */
  --wof-green: #1e7c40;          /* unused space, and the gap between words */
  --wof-white: #f4f6f0;          /* in play, not yet called */
  --wof-ink: #0a0d10;            /* the letter itself */
  --wof-flash: #1d52d8;          /* the beat between white and letter */

  --wof-plate: #0a0d10;          /* the face the cells are set into */
  --wof-trim: #d6dbe4;
  --wof-gap: .14em;              /* "slightly separated", which is all anybody
                                    has ever written down about the gap */
  --wof-radius: .12em;

  display: grid;
  gap: var(--s3);
  justify-items: center;
  /* WITHOUT THIS THE BOARD COLLAPSES. Its own parent centres its items, which
     sizes them to their content, and `.wof-board` asks for `width: 100%` of a
     box that has therefore become as narrow as one cell. Measured at a board
     roughly 40px wide before this line existed. */
  width: 100%;
}

.wof-board {
  display: grid;
  grid-template-columns: repeat(14, 1fr);
  gap: var(--wof-gap);
  width: 100%;
  max-width: 60rem;
  padding: var(--wof-gap);
  background: var(--wof-plate);
  border: 2px solid var(--wof-trim);
  border-radius: var(--r2);
  /* NO aspect-ratio ON THE BOARD. Setting one here and one on the cell gives two
     rules for the same shape, and the board's padding and fixed gaps mean they
     disagree — measured at .93 where the cell asked for .77. The cell owns its
     proportion and the board's height follows from it. */
}

.wof-cell {
  aspect-ratio: var(--wof-cell-aspect);
  border-radius: var(--wof-radius);
  background: var(--wof-green);
  display: grid;
  place-items: center;
  /* Heavy condensed grotesque: the board went to Helvetica Condensed when the
     electronic board debuted in 1997 and the letters have been heavy and
     condensed ever since. No font file is shipped for one game — the stack below
     is what a browser already has, and `font-stretch` leans on the variable
     faces where they exist. */
  font-family: 'Helvetica Neue', 'Arial Narrow', Helvetica, Arial, sans-serif;
  font-stretch: condensed;
  font-weight: 800;
  font-size: clamp(.5rem, 2.4vw, 1.9rem);
  line-height: 1;
  color: var(--wof-ink);
  transition: background-color .18s linear, color .18s linear;
}

/* The inset at each end of a short row. Not a cell — the physical board has no
   screen there — so it is a hole in the grid rather than a dark cell. */
.wof-cell[data-kind="absent"] { background: transparent; border-radius: 0; }

/* In play and not yet called: white, blank. The letter IS in the DOM so a screen
   reader is not told the puzzle, and `visibility` rather than a colour trick,
   because text painted white-on-white is still selectable and still copied. */
.wof-cell[data-kind="letter"] { background: var(--wof-white); }
.wof-cell[data-kind="letter"]:not([data-shown]) > span { visibility: hidden; }

/* Punctuation is on the board before the first spin and can never be called. */
.wof-cell[data-kind="punct"] { background: var(--wof-white); }

/* The reveal: white -> blue -> letter, the two-stage animation the 1997 board
   introduced and the LED board kept. */
@keyframes wof-reveal {
  0%   { background-color: var(--wof-white); color: transparent; }
  35%  { background-color: var(--wof-flash); color: transparent; }
  100% { background-color: var(--wof-white); color: var(--wof-ink); }
}
.wof-cell[data-shown] { animation: wof-reveal .5s ease-out both; }
@media (prefers-reduced-motion: reduce) {
  .wof-cell[data-shown] { animation: none; }
}

/* Forced colours strips background-color entirely, which would leave a board of
   identical empty boxes — the one thing this board must never be. Fall back to
   borders, which forced-colours mode does keep. */
@media (forced-colors: active) {
  .wof-cell { border: 1px solid CanvasText; }
  .wof-cell[data-kind="gap"] { border-style: dotted; }
  .wof-cell[data-kind="absent"] { border: 0; }
}

/* The category strip. Position and size relative to the grid are undocumented —
   nobody has written down where it sits — so this is the site's own treatment
   rather than a reproduction. */
.wof-category {
  font: var(--w-bold) var(--t1)/1 var(--mono);
  letter-spacing: .22em; text-transform: uppercase;
  color: var(--wof-trim);
}

/* ── Wheel of Fortune: the stage around the board ────────────────────────── */
.wof-stage { display: grid; gap: var(--s4); justify-items: center; }
.wof-head { display: grid; gap: var(--s1); justify-items: center; text-align: center; }
.wof-round {
  font: var(--w-med) var(--t1)/1 var(--mono);
  letter-spacing: .18em; text-transform: uppercase; color: var(--wof-trim, #d6dbe4);
}
.wof-note { font: var(--w-med) var(--t2)/1.3 var(--font, inherit); color: var(--accent, inherit); }
.wof-solution {
  font: var(--w-bold) clamp(1.1rem, 3.6vw, 1.8rem)/1.2 var(--font, inherit);
  letter-spacing: .04em; text-align: center;
}

/* The wedge the wheel stopped on. A label rather than a spinning disc: the
   number is what a player acts on, and an animation that has to finish before
   the value is legible is a thing that can fail to finish. */
.wof-wheel { display: grid; place-items: center; min-height: 3.2rem; }
.wof-wedge {
  padding: var(--s2) var(--s5);
  border-radius: var(--r2);
  font: var(--w-bold) clamp(1.1rem, 4vw, 2rem)/1 var(--mono);
  letter-spacing: .06em;
  color: #0a0d10; background: #e8c34a;
  box-shadow: 0 6px 18px rgb(0 0 0 / .35);
}
.wof-wedge[data-kind="bankrupt"] { background: #111417; color: #fff; }
.wof-wedge[data-kind="lose-turn"] { background: #f4f6f0; color: #0a0d10; }
.wof-wheel-idle { font: var(--w-med) var(--t2)/1 var(--font, inherit); opacity: .72; }

/* Podiums. Bank and total are separate numbers because they behave differently
   — Bankrupt takes one and never the other — and showing one is how a player is
   surprised at the end of a round. */
.wof-players { display: flex; flex-wrap: wrap; gap: var(--s3); justify-content: center; }
.wof-podium {
  min-width: 7rem; padding: var(--s2) var(--s3);
  border: 1px solid var(--line, #3a4250); border-radius: var(--r2);
  display: grid; gap: 2px; justify-items: center;
}
.wof-podium[data-control="true"] { border-color: #4ade80; box-shadow: 0 0 0 1px #4ade80 inset; }
.wof-podium[data-me="true"] .wof-podium-name { font-weight: var(--w-bold); }
.wof-podium-name { font: var(--w-med) var(--t1)/1 var(--font, inherit); }
.wof-podium-bank { font: var(--w-bold) var(--t3)/1 var(--mono); }
.wof-podium-total { font: var(--w-reg, 400) var(--t1)/1 var(--mono); opacity: .66; }

.wof-controls { display: grid; gap: var(--s2); justify-items: center; width: 100%; }
.wof-status { font: var(--w-med) var(--t2)/1.3 var(--font, inherit); opacity: .85; text-align: center; }

/* The row that holds SPIN beside USE WILD CARD, and Keep beside FLIP IT. It had
   no rule at all, so two buttons that are a CHOICE between each other sat edge
   to edge with nothing between them — and on a narrow screen ran off the side
   rather than wrapping. */
.wof-actions { display: flex; flex-wrap: wrap; gap: var(--s2); justify-content: center; align-items: center; }

.wof-keys { display: flex; flex-wrap: wrap; gap: 4px; justify-content: center; max-width: 34rem; }
.wof-key {
  min-width: 2.1rem; padding: var(--s1) var(--s2);
  font: var(--w-bold) var(--t2)/1 var(--mono);
  border: 1px solid var(--line, #3a4250); border-radius: var(--r1);
  background: transparent; color: inherit; cursor: pointer;
}
.wof-key:hover:not(:disabled) { background: rgb(255 255 255 / .08); }
/* A called letter stays VISIBLE and disabled rather than disappearing: which
   letters are gone is information the player is using. */
.wof-key:disabled { opacity: .3; cursor: default; }
.wof-key[data-used="true"] { text-decoration: line-through; }

.wof-spin {
  padding: var(--s2) var(--s6);
  font: var(--w-bold) var(--t3)/1 var(--mono); letter-spacing: .16em;
  border: 0; border-radius: var(--r2); cursor: pointer;
  background: #15803d; color: #fff;
}
.wof-spin:disabled { background: var(--line, #3a4250); cursor: default; }

.wof-solveform { display: flex; gap: var(--s2); width: min(28rem, 100%); }
.wof-input {
  flex: 1; padding: var(--s2) var(--s3);
  font: var(--w-med) var(--t2)/1 var(--font, inherit);
  border: 1px solid var(--line, #3a4250); border-radius: var(--r1);
  background: transparent; color: inherit;
}
.wof-clock { font: var(--w-bold) var(--t2)/1 var(--mono); opacity: .6; min-height: 1em; }

/* ── Jeopardy: wagers and the final ──────────────────────────────────────── */
.qb-final-list { display: grid; gap: var(--s1); width: min(30rem, 100%); }
.qb-final-row {
  display: grid; grid-template-columns: 1fr 2fr auto; gap: var(--s3); align-items: baseline;
  padding: var(--s1) var(--s2); border-radius: var(--r1);
  background: rgb(255 255 255 / .05);
}
/* Right and wrong are distinguished by more than colour: the row carries a
   symbol in the wager column either way, so the outcome survives a colour-blind
   reader and a forced-colours mode that drops the background. */
.qb-final-row[data-correct="true"] { background: rgb(74 222 128 / .16); }
.qb-final-who { font: var(--w-bold) var(--t2)/1 var(--font, inherit); }
.qb-final-said { font: var(--w-med) var(--t2)/1.2 var(--font, inherit); opacity: .9; }
.qb-final-bet { font: var(--w-bold) var(--t2)/1 var(--mono); }
/* The wager box is a number input; browsers give it spinners that are useless
   at this size and steal a third of the field. */
.qb-input[type="number"] { appearance: textfield; -moz-appearance: textfield; }
.qb-input[type="number"]::-webkit-outer-spin-button,
.qb-input[type="number"]::-webkit-inner-spin-button { appearance: none; margin: 0; }

/* ── Wheel of Fortune: special wedges and the bonus round ────────────────── */
.wof-wedge[data-kind="mystery"]  { background: #7c3aed; color: #fff; }
.wof-wedge[data-kind="express"]  { background: #dc2626; color: #fff; }
.wof-wedge[data-kind="prize"]    { background: #0ea5e9; color: #06263a; }
.wof-wedge[data-kind="wild"]     { background: #f4f6f0; color: #0a0d10; }
.wof-wedge[data-kind="million"]  { background: #e8c34a; color: #0a0d10; box-shadow: 0 0 24px rgb(232 195 74 / .5); }

/* Tokens on a podium. Shown because Bankrupt takes them: a player has to be
   able to see what a bad spin would cost. */
.wof-podium-held { display: flex; gap: 3px; margin-block-start: 2px; }
.wof-token {
  font: var(--w-bold) 9px/1.4 var(--mono);
  letter-spacing: .06em; padding: 1px 4px; border-radius: 3px;
  background: rgb(255 255 255 / .14);
}
.wof-token-million { background: #e8c34a; color: #0a0d10; }

.wof-wild, .wof-express { border-color: #e8c34a; }
/* The flip is the irreversible one, so it does not look like the safe one. */
.wof-flip { background: #7c3aed; }

.wof-bonuspick { display: grid; gap: var(--s2); justify-items: center; }
/* A letter the winner has chosen but not yet locked in. Distinguished by border
   AND weight, not by colour alone. */
.wof-key[data-picked="true"] {
  background: #e8c34a; color: #0a0d10; border-color: #e8c34a; font-weight: 800;
}
.wof-bonusresult { display: grid; gap: var(--s2); justify-items: center; }
.wof-bonusresult[data-won="true"] .wof-wedge { box-shadow: 0 0 30px rgb(232 195 74 / .6); }

/* ── Wheel of Fortune: the toss-up buzzer ────────────────────────────────── */
.wof-buzzer {
  padding: var(--s4) var(--s6);
  font: var(--w-bold) var(--t3)/1 var(--mono); letter-spacing: .18em;
  border: 0; border-radius: 999px; cursor: pointer;
  background: #e01e5a; color: #fff;
  box-shadow: 0 8px 24px rgb(224 30 90 / .35);
}
.wof-buzzer:hover:not(:disabled) { transform: translateY(-1px); }
.wof-buzzer:disabled { background: var(--line, #3a4250); box-shadow: none; cursor: default; }
@media (prefers-reduced-motion: reduce) { .wof-buzzer:hover { transform: none; } }

/* ── touch targets and the Express stake ─────────────────────────────────── */
/* 24 px keys with 4 px gaps were the letter board on a phone, where a mis-tap
   while riding the Express is a Bankrupt. The site's own convention elsewhere
   is 44 px (see .tt-actions .btn). */
@media (pointer: coarse) {
  .wof-key { min-height: 44px; min-width: 2.75rem; }
  .wof-keys { gap: var(--s2); }
  .wof-buzzer { min-height: 56px; }
}
.wof-stake { color: #f87171; font-weight: var(--w-bold); }
.qb-wrong { opacity: .8; }

/* ── the end of a Jeopardy match ─────────────────────────────────────────── */
/* It used to be the two words "Game over" and nothing else. */
.qb-over { display: grid; gap: var(--s3); justify-items: center; width: 100%; }
.qb-over-head { font: var(--w-bold) var(--t4, 1.3rem)/1.2 var(--font, inherit); margin: 0; text-align: center; }
.qb-standings { display: grid; gap: var(--s1); list-style: none; margin: 0; padding: 0; width: min(24rem, 100%); }
.qb-standing {
  display: grid; grid-template-columns: 2.5rem 1fr auto; gap: var(--s2); align-items: center;
  padding: var(--s2) var(--s3); border: 1px solid var(--line, #3a4250); border-radius: var(--r1);
}
.qb-standing[data-won="true"] { border-color: #fbbf24; }
.qb-standing[data-me="true"] { background: rgb(255 255 255 / .06); }
.qb-standing-place { font: var(--w-bold) var(--t1)/1 var(--mono); opacity: .7; }
.qb-standing-name { font: var(--w-med) var(--t2)/1 var(--font, inherit); }
.qb-standing-score { font: var(--w-bold) var(--t2)/1 var(--mono); }

/* ── the wheel, as a reel ────────────────────────────────────────────────── */
/* Not a circle. What a spin has to convey is that it could have landed
   anywhere and that it slowed down to this; a strip of the round's real wedges
   running past a marker gives both, and an SVG wheel rotating under a pointer
   would be a lot of geometry for the same two facts.

   THE RESULT IS NOT BEHIND THIS. The reel is painted at its landing position
   first and only then wound back to be spun, so a browser that refuses the
   transition shows the answer immediately — and the value is printed in words
   underneath either way. Jingle Guesser shipped three bugs in one evening that
   were all "content whose only route to being visible was an animation". */
.wof-reel-view {
  position: relative; overflow: hidden; width: min(30rem, 100%);
  padding-block: var(--s2);
  -webkit-mask-image: linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent);
  mask-image: linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent);
}
.wof-reel { display: flex; gap: var(--s2); width: max-content; will-change: transform; }
/* 2.6s, and the curve is the point: nearly all the distance goes by in the
   first third and the last few wedges crawl. A linear spin reads as a list
   scrolling; this reads as a wheel losing momentum. */
.wof-reel[data-spinning="true"] { transition: transform 2.6s cubic-bezier(.12, .72, .12, 1); }
.wof-reel-wedge {
  flex: 0 0 auto; min-width: 7.5rem; text-align: center;
  font-size: var(--t3); padding: var(--s2) var(--s3);
  box-shadow: none; opacity: .82;
}
/* The marker the wedge stops under. Drawn over the reel, not in it. */
.wof-reel-mark {
  position: absolute; inset-block: 0; inset-inline-start: 50%;
  translate: -50% 0; width: 3px; border-radius: 2px;
  background: var(--accent, #9ecbff); box-shadow: 0 0 10px var(--accent, #9ecbff);
  pointer-events: none;
}
.wof-wheel-result { margin-top: var(--s2); }

@media (prefers-reduced-motion: reduce) {
  .wof-reel[data-spinning="true"] { transition: none; }
}
