/* Snarps — the visual spine.
   Visual authority: _bmad-output/planning-artifacts/ux-designs/design_handoff_snarps_ui/README.md
   Spine and precedence: .../ux-Snarples-2026-08-11/DESIGN.md

   ONE SOURCE FOR EVERY VALUE. Everything below is a custom property or a multiple of
   one. No screen restates a hex, a radius or a shadow.

   ------------------------------------------------------------------------------
   THE UNIT RULE — `--px` is one handoff pixel.

   The handoff is authored against a 1280x720 table. `--px` is the length that one of
   its pixels takes on this table, so every value in this file is the handoff's own
   number times `--px` and the two can be read side by side.

       --px: min(0.138889cqmin, 0.078125cqw)
             \_ 1/7.2 of the table's SMALLER side   \_ 1/1280 of the table's WIDTH

   The min() is the whole trick. Below 16:9 the width term wins, so the composition is
   pinned to the table's WIDTH and is always exactly 1280 --px across — the handoff's
   horizontal layout is then reproduced exactly at every aspect, and the bottom
   corridor's clearances are invariant rather than something to re-tune per viewport.
   At and above 16:9 the height term wins, so the table stops growing with a widening
   window and the extra width becomes margin.

   Because both terms are container units of the same box, a card's width and height
   scale by the same factor at every aspect: shapes cannot deform, which is the defect
   the earlier cqw/cqh mix produced (thin cards, an elliptical puck).

   SIZES are `calc(N * var(--px))`. VERTICAL POSITIONS in the middle of the felt stay
   in `cqh` percentages, so the ring and the trick spread down a taller table instead
   of bunching under the chrome; top- and bottom-anchored things stay in --px, where
   the handoff pins them to an edge.
   ------------------------------------------------------------------------------ */

:root {
  /* ---------- colour: handoff § Design tokens, verbatim ---------- */
  --felt-base: #10441c;
  --felt-light: #1b5a29;
  --felt-deep: #0a2c13;
  --panel: #0b2712;
  --panel-inset: #06190b;
  --panel-shadow: #061a0c;
  --ink: #0b1f10;
  --rail: #e9ece6;
  --white: #fdfdfa;
  --card-face: #ffffff;
  --yellow: #f7e93f;
  --yellow-deep: #e2c910;
  --yellow-edge: #6b5c00;
  --red: #d8262b;
  --red-bright: #f0524d;
  --red-card: #c8181d;
  --red-pale: #ff9a90;
  --green-live: #3ad14a;
  --green-pale: #8ee79a;
  /* A taken seat in the browser's occupancy row — handoff § 3, used verbatim. Its own
     token because it is a FILL that has to sit under a `--green-live` edge, which neither
     of the two greens above can do. */
  --seat-taken: #1b5a29;
  --puck-orange: #e2521a;
  --cut-blue: #1b6fd6;
  --card-back-blue: #1c3f8f;
  --text-body: #c3c9bf;
  --text-muted: #a9b0a6;
  --text-dim: #8b9189;

  /* ---------- the rink: DESIGN.md § Colors > Added by this spine ----------
     Five tokens the handoff never had, because it shipped the rink as a settings swatch
     and recorded that the table treatment had not been drawn. `--ice-label` is the one
     that flips by surface: the labels that sit ON the surface are pale for felt and
     would disappear on ice. */
  --ice: #e8f2f8;
  --ice-shade: #cfe0ec;
  --rink-red: #c8202a;
  --rink-blue: #1f4fa8;
  --ice-label: #33424e;

  /* Alphas, used verbatim. Named for the one job each does. */
  --meter-ground: #000000cc;
  --feed-ground: #000000d9;
  --feed-ground-fade: #000000a0;
  --banner-ground: #000000e0;
  --edge-faint: #ffffff26;
  --edge: #ffffff33;
  --edge-strong: #ffffff40;
  --edge-meter: #ffffffaa;
  --scrim: #04120759;
  --punt-scrim: #3a060899;
  --shade: #0009;
  --shade-soft: #0007;
  --chrome-top: #ffffff;
  --chrome-bottom: #b6bdb4;
  --chrome-edge: #4c5450;
  --chrome-ink: #1b241c;
  --ambient: #00000094;
  /* The rink's own two, the paler equivalents of the felt's highlight and ambient. */
  --ice-sheen: #ffffff9c;
  --ice-ambient: #4d6f8747;
  /* The dialog and takeover literals, handoff § Screens 5–10, used verbatim. */
  --edge-dialog: #ffffff2e;
  --card-meta: #5c6660;        /* metadata printed ON a card face */
  --scrim-light: #0412074d;    /* the draw screen: the hand is the subject */
  --dump-scrim: #0b2712d9;
  --punt-panel: #12040599;
  --punt-edge: #ff9a9059;
  --punt-text: #ffb3ae;
  --punt-stroke: #6b0d10;
  --red-hot: #ff5a52;
  --white-soft: #dfe4dc;
  --ink-btn: #11140f;          /* text on a yellow primary */
  --ink-btn-soft: #3a3200;

  /* ---------- type: handoff § Typography ---------- */
  --display: "Archivo", "Arial Black", "Segoe UI", sans-serif;
  --body: "Barlow", system-ui, -apple-system, "Segoe UI", sans-serif;
  --dense: "Barlow Condensed", "Barlow", system-ui, sans-serif;
  --mono: "JetBrains Mono", ui-monospace, "Cascadia Mono", Consolas, monospace;

  /* ---------- the unit ---------- */
  --px: min(0.138889cqmin, 0.078125cqw);

  /* Where the trick pile's centre sits on the felt. ONE declaration, used by the pile
     itself and by the rink's faceoff circle, which is anchored to the pile rather than
     to the geometric centre — the two drifting apart is exactly the defect that would
     put the circle back in the dead space the rink pass moved it out of. A container
     unit inside a custom property resolves at the point of USE, so this is 41% of the
     table's height for every descendant of `.table` even though `:root` is outside it. */
  --trick-y: 41cqh;

  /* ---------- radius: handoff § Spacing, radius, shadow ---------- */
  --r-badge: calc(4 * var(--px));
  --r-card: calc(7 * var(--px));
  --r-input: calc(5 * var(--px));
  --r-button: calc(8 * var(--px));
  --r-panel: calc(9 * var(--px));
  --r-dialog: calc(11 * var(--px));
  --r-table: calc(14 * var(--px));

  /* ---------- elevation ----------
     The signature shadow: a hard offset base plus a soft ambient. It is what makes a
     panel, a card or a button read as an object standing ON the felt, and DESIGN.md
     names it the single most identity-carrying decision in the system. */
  --lift: 0 calc(5 * var(--px)) 0 var(--panel-shadow), 0 calc(12 * var(--px)) calc(22 * var(--px)) var(--shade);
  --lift-button: 0 calc(4 * var(--px)) 0 #00000099;
  --lift-primary: 0 calc(5 * var(--px)) 0 #00000099;
  --lift-chrome: 0 calc(4 * var(--px)) 0 var(--shade), 0 calc(10 * var(--px)) calc(20 * var(--px)) var(--shade-soft);
  --focus-ring: calc(3 * var(--px)) solid var(--yellow);
  --focus-offset: calc(2 * var(--px));
  /* The smallest a control may be on either axis. A real CSS pixel and not `--px`: a hit
     target is a thumb against a screen, so it does not scale with the table the way the
     composition does — the whole point is that it stays the same size when everything
     around it shrinks. Measured at every viewport by layout-check.mjs. */
  --hit-target: 44px;

  color-scheme: dark;
}

* { box-sizing: border-box; }

html, body {
  margin: 0;
  height: 100%;
  background: var(--felt-deep);
  color: var(--white);
  font-family: var(--body);
  font-weight: 600;
  overflow: hidden;
  -webkit-text-size-adjust: 100%;
}

.hidden { display: none !important; }
/* Rule-bearing copy only. It ships `hidden` and is un-hidden once the server has served
   every number in it (applyRuleText, app.js), so it has to beat any author rule that sets
   `display` on the element it lands on — the UA's own [hidden] rule does not, and a number
   that has not arrived must not be able to show up.
   Scoped to [data-rule] deliberately: a bare `[hidden] { !important }` would redefine the
   attribute app-wide and leave it unoverridable everywhere else. */
[data-rule][hidden] { display: none !important; }
.sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}

/* Display type is Archivo 900 italic, uppercase, tightened. Over busy ground it
   carries a stroke with `paint-order: stroke fill` so the fill is never eaten. */
.disp {
  font-family: var(--display);
  font-weight: 900;
  font-style: italic;
  text-transform: uppercase;
  letter-spacing: -0.02em;
  color: var(--yellow);
  -webkit-text-stroke: 0.055em #000;
  paint-order: stroke fill;
}
.num { font-family: var(--display); font-style: italic; font-variant-numeric: tabular-nums; }

/* ---------- everything operable, in two rules ----------
   ONE RULE EACH, reaching whatever is interactive, rather than a ring and a size bolted
   onto each control as it is written. That is not tidiness: the criterion is "EVERY
   interactive element", and a per-control declaration is satisfied by every control that
   was thought of — the next one added is simply missing, silently, and looks exactly like
   the rest of the stylesheet. A rule that names what interactive MEANS cannot be missed by
   a new button.

   The selector is the same set `tests/e2e/operable.mjs` enumerates from, deliberately: the
   check and the stylesheet have to mean the same thing by "interactive" or one of them is
   quietly measuring a smaller client than the other.

   `:where()` keeps the ring at zero specificity, so a component may draw a bigger one of
   its own (`.cardv`, below) without `!important`. What it may not do is draw none — an
   outline of zero width, of style `none`, or in a transparent colour all fail the sweep. */
:where(
  button, input, select, textarea, summary,
  a[href], area[href],
  [contenteditable=""], [contenteditable="true"],
  [tabindex],
  [role="button"], [role="link"], [role="checkbox"], [role="radio"], [role="switch"],
  [role="tab"], [role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"],
  [role="slider"], [role="spinbutton"],
  [role="textbox"], [role="combobox"], [role="option"], [role="searchbox"]
):focus-visible {
  outline: var(--focus-ring);
  outline-offset: var(--focus-offset);
}

/* And what a player PRESSES has to be big enough to hit. NOT `:where()` here: a size is not
   a treatment a component gets to opt out of, and the two places that did opt out — the
   settings checkboxes at 24px and the create screen's radios at 20px — are the reason this
   rule exists. `input[type=hidden]` is a value being carried, not a control.

   Deliberately a SUBSET of the ring's selector, and the difference is worth stating: a ring
   costs an inline link nothing, and a 44px floor would push one to its own line inside a
   sentence. So the pressable things carry the floor here, and the criterion itself is held
   by MEASUREMENT rather than by this rule — layout-check.mjs measures every element it
   enumerates from the live DOM, this set or not, so a small anchor used as a control is a
   red check and not a silent omission. */
button, input:not([type="hidden"]), select, textarea, summary,
[role="button"], [role="checkbox"], [role="radio"], [role="switch"] {
  min-width: var(--hit-target);
  min-height: var(--hit-target);
}

/* ==========================================================================
   PRE-TABLE SURFACES — landing, browser, create, rules, settings, results.
   Rebuilt to the handoff's palette and type; their full compositions belong to
   the entry-and-wrap stories, so the structure here is unchanged.
   ========================================================================== */

.surface {
  position: fixed;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  /* `safe` centring, NOT plain centring. A hard-centred item taller than its scroll
     container overflows BOTH ends and only the downward range is scrollable, so the
     top of a long sheet became unreachable. `safe` falls back to start-alignment
     exactly when that would happen; a browser without it lands on flex-start, which
     is also scrollable. The failure mode is "not centred", never "clipped". */
  justify-content: safe center;
  padding: 3vmin;
  overflow-y: auto;
  background-color: var(--felt-deep);
  background-image: url(assets/felt/suits.png);
  background-repeat: repeat;
  background-size: 14vmin auto;
}
.surface::before {
  content: "";
  position: fixed;
  inset: 0;
  box-shadow: inset 0 0 18vmin var(--ambient);
  pointer-events: none;
}
.sheet {
  position: relative;
  /* `100%`, not `96vw`. The surface around this is padded by 3vmin a side, so a sheet
     measured against the WINDOW is wider than the box it sits in wherever vmin approaches
     vw — at 1:1 that is 96vw + 6vmin = 102vw, and the sheet hangs 9px off each edge with
     the surface scrolling sideways to reach it. Found by the settings screen's geometry
     pass (layout-check.mjs) and true of every pre-table surface: landing, browser, create,
     settings and results all wear this rule. `100%` is the padded content box, which is
     what the 96vw was reaching for; the 150vmin cap is unchanged and still what decides
     the width at every viewport wider than square. */
  width: min(100%, 150vmin);
  flex: 0 0 auto;
  display: grid;
  gap: 2.4vmin;
  justify-items: center;
  text-align: center;
}

/* The wordmark is LIVE TYPE, never a bitmap — handoff § 2 Landing sets it in Archivo 900
   italic at 132px with a 4px ink stroke, and § Assets retires the 2013 `wordmark.png`.
   132px against the handoff's 720-tall frame is 18.3vmin; the `min()` with a vw term is
   the same trick `--px` plays, so six large letters cannot run off a narrow window.
   NOT `.disp`: that is yellow, and the wordmark is white over its own red bar. */
.wordmark {
  font-family: var(--display); font-weight: 900; font-style: italic;
  text-transform: uppercase; letter-spacing: -0.03em;
  font-size: min(18.3vmin, 15vw); line-height: 0.9; margin: 0; max-width: 100%;
  color: var(--white);
  -webkit-text-stroke: 0.03em var(--ink); paint-order: stroke fill;
}
/* The handoff's 300x8px accent bar under the wordmark, in the one colour that means
   "this game", not a state. Decorative, and marked so. */
.wmbar { width: min(41.7vmin, 60vw); height: 1.2vmin; min-height: 3px; background: var(--red); border-radius: 0.4vmin; }
.tagline {
  color: var(--yellow); font-size: 2.6vmin; margin: 0; letter-spacing: 0.24em;
  font-family: var(--display); font-weight: 600; text-transform: uppercase;
}
.surface h2 { font-size: 8vmin; line-height: 0.95; margin: 0; }
.surface p.lede { font-size: 2.6vmin; margin: 0; max-width: 60ch; color: var(--text-body); font-weight: 400; }

.stack { display: grid; gap: 1.6vmin; justify-items: center; width: 100%; }
.row { display: flex; gap: 1.6vmin; flex-wrap: wrap; justify-content: center; align-items: center; }

/* Chrome buttons: a top-light vertical gradient, a dark edge, and a hard drop — an
   object you press, not a flat rectangle. Shared inside and outside the table. */
.chrome {
  font-family: var(--display);
  font-weight: 900;
  font-style: italic;
  text-transform: uppercase;
  letter-spacing: -0.02em;
  background: linear-gradient(180deg, var(--chrome-top), var(--chrome-bottom));
  border: 0.35vmin solid var(--chrome-edge);
  border-radius: 1.2vmin;
  box-shadow: 0 0.7vmin 0 var(--shade), 0 1.2vmin 2.4vmin var(--shade-soft);
  color: var(--felt-base);
  cursor: pointer;
  padding: 1.4vmin 4vmin;
  font-size: 5vmin;
  line-height: 1.1;
  min-height: 44px;
}
.chrome.red { color: var(--red-card); }
.chrome.small { font-size: 3.2vmin; padding: 1vmin 2.6vmin; }
.chrome:hover:not(:disabled) { transform: translateY(0.2vmin); box-shadow: 0 0.5vmin 0 var(--shade), 0 1vmin 2vmin var(--shade-soft); }
.chrome:disabled { opacity: 0.45; cursor: not-allowed; box-shadow: none; }
.chrome:active:not(:disabled) { transform: translateY(0.7vmin); box-shadow: none; }

.field { display: grid; gap: 0.8vmin; justify-items: center; font-size: 2.4vmin; color: var(--text-muted); }
.field input[type="text"] {
  font-family: var(--display);
  font-weight: 800;
  font-style: italic;
  font-size: 3.4vmin;
  text-align: center;
  padding: 1.2vmin 2vmin;
  min-height: 44px;
  border-radius: 0.8vmin;
  border: 0.4vmin solid var(--edge-strong);
  background: var(--panel-inset);
  color: var(--white);
}

.toggle { display: flex; gap: 1.4vmin; align-items: center; font-size: 2.6vmin; cursor: pointer; color: var(--text-body); font-weight: 400; }
/* The box takes the hit-target floor from the shared rule above; the vmin term is what
   lets it grow with the sheet on a large screen. Its own `min-*` used to sit here at 24px
   and shadowed that rule, which is how a 39.6px checkbox shipped under a 44px criterion. */
.toggle input { width: 4.4vmin; height: 4.4vmin; accent-color: var(--yellow); }

/* The settings screen's switches, in one block. The sheet centres each of its children
   independently, so three toggles of three different label widths came out with their
   boxes at three different x positions — which reads as three unrelated controls rather
   than as one list of settings. Aligned on the boxes; the block itself is still centred. */
.prefs { display: grid; gap: 1.2vmin; justify-items: start; text-align: left; max-width: 100%; }
.prefs .opthint { max-width: 52ch; }

/* ---------- the create screen's three groups: DESIGN.md § Create dialog, variant B ----------
   Two named groups side by side, then table chat across the full width. A layout on top of
   the form idioms above, not a new system: each option is the same visible control plus its
   own words, in a panel with the same edge and lift as everything else on these surfaces.

   The labels are the reason this shape was chosen over the tighter 3x2, so nothing here may
   clip one. `white-space: nowrap` on an option keeps "Wait for humans" whole, `flex-wrap`
   on the pair drops the second option onto its own line rather than squeezing it, and the
   grid columns are `minmax(0, 1fr)` so a group can actually shrink instead of forcing the
   sheet wider than the window. Below 700px the two-up becomes one column outright. */
.opts {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 1.6vmin;
  width: 100%;
  max-width: 120vmin;
  text-align: left;
}
.optgroup {
  display: grid;
  gap: 1.2vmin;
  min-width: 0;
  margin: 0;
  padding: 1.2vmin 1.6vmin 1.6vmin;
  background: var(--panel);
  border: 0.4vmin solid var(--edge-strong);
  border-radius: 1.2vmin;
  box-shadow: var(--lift-button);
}
/* Table chat gets the whole row. */
.optgroup.wide { grid-column: 1 / -1; }
.optgroup legend {
  padding: 0 0.6vmin;
  font-family: var(--display);
  font-weight: 700;
  font-size: 2vmin;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--text-muted);
}
.seg { display: flex; flex-wrap: wrap; gap: 1.2vmin; }
/* One line of explanation under a group. Wraps freely — unlike the option labels it is a
   sentence, so it is allowed to take two lines rather than run off the panel. */
.opthint { margin: 0; font-size: 2vmin; line-height: 1.35; color: var(--text-muted); font-weight: 400; white-space: normal; }
.opt {
  flex: 1 1 auto;
  display: flex;
  align-items: center;
  gap: 1.2vmin;
  min-height: 44px;
  padding: 0.8vmin 1.6vmin;
  border: 0.3vmin solid var(--rail);
  border-radius: 0.8vmin;
  background: var(--panel-inset);
  color: var(--text-body);
  font-size: 2.4vmin;
  font-weight: 400;
  white-space: nowrap;
  cursor: pointer;
}
/* Same bargain as the settings checkbox: the floor is the shared rule's, not a 20px one
   written here. `flex: 0 0 auto` so the radio keeps its size when the option label is long
   — without it the box is the flex item that gives way and the floor is spent again. */
.opt input { width: 3.4vmin; height: 3.4vmin; flex: 0 0 auto; margin: 0; accent-color: var(--yellow); }
/* The chosen option, in three channels rather than one: the radio's own filled dot, the
   yellow edge, and the heavier white type. Colour alone never carries a state here. */
.opt:has(input:checked) { border-color: var(--yellow); color: var(--white); font-weight: 700; }
@media (max-width: 700px) {
  .opts { grid-template-columns: minmax(0, 1fr); }
}

/* ---------- the surface picker: handoff § 13 Settings ----------
   Two labelled options, the current one marked with the yellow border — which here means
   what yellow always means, the active choice. Each option shows the surface itself
   rather than a colour chip, because "green felt" and "ice rink" are geometries and a
   swatch that hid that would be picking on trust. */
.surf {
  display: grid; gap: 0.9vmin; justify-items: center; cursor: pointer; font: inherit;
  background: var(--panel); color: var(--text-body);
  border: 0.4vmin solid var(--edge-strong); border-radius: 1.2vmin;
  padding: 1.2vmin; min-height: 44px; box-shadow: var(--lift-button);
}
.surf[aria-pressed="true"] { border-color: var(--yellow); color: var(--white); }
.surf:hover { transform: translateY(0.2vmin); }
.surf .swn {
  font-family: var(--display); font-weight: 800; font-style: italic; font-size: 2.2vmin;
  text-transform: uppercase; letter-spacing: 0.04em;
}
.surf .sw {
  position: relative; display: block; isolation: isolate; overflow: hidden;
  width: 26vmin; height: 14vmin; border-radius: 0.8vmin;
  border: 0.3vmin solid var(--rail);
}
/* THE TRAP, handoff § 13. `suits.png` is fully opaque, so a background colour behind it
   never shows and multiply against its near-black artwork only darkens — set as a
   compound background these two swatches come out the same mud. The working recipe is a
   solid base with the tile as a SEPARATE overlay; `isolation` above keeps the blend
   inside the swatch. */
.surf .sw.felt { background: var(--felt-base); }
.surf .sw.felt i {
  position: absolute; inset: 0;
  background: url(assets/felt/suits.png) repeat 0 0 / 7vmin auto;
  opacity: 0.3; mix-blend-mode: multiply;
}
/* And the rink, in CSS: the table's own geometry at swatch scale. */
.surf .sw.ice { background: var(--ice); }
.surf .sw.ice i { position: absolute; top: 0; bottom: 0; background: var(--rink-blue); opacity: 0.92; }
.surf .sw.ice i.l { left: 15%; width: 8%; }
.surf .sw.ice i.r { left: 77%; width: 8%; }
.surf .sw.ice i.redline { left: 48.4%; width: 3.2%; background: var(--rink-red); }
.surf .sw.ice i.circle {
  left: 50%; top: 50%; transform: translate(-50%, -50%);
  width: 10vmin; height: 10vmin; bottom: auto; border-radius: 50%;
  border: 0.4vmin solid var(--rink-blue); background: none; opacity: 0.55;
}

/* Plain list rows — the results standings. The table browser used to be one of these and
   is now a grid of cards below; this stayed because a final scoreboard IS a list. */
.rows { display: grid; gap: 1.2vmin; width: 100%; max-width: 120vmin; }
.trow {
  display: flex; align-items: center; gap: 2vmin; text-align: left;
  background: var(--panel);
  border: 0.4vmin solid var(--edge-strong);
  border-radius: 0.9vmin;
  padding: 1.4vmin 2vmin;
  min-height: 44px;
}
.trow .tname { font-family: var(--display); font-style: italic; font-weight: 900; font-size: 3.2vmin; flex: 1; color: var(--white); }

/* ---------- the standings row: rank, marks, tricks, punts, score ----------
   A game's record, not just its final number. Everything here is drawn from the FINAL
   frame the server sent; nothing on this screen is worked out from another number on it.

   NOTHING BELOW CARRIES STATE BY HUE ALONE. The winner and an eliminated seat are each
   marked with a WORD in a bordered box beside a glyph, and the row itself changes its
   border weight and its fill — so the mark survives a greyscale screenshot, a colour
   deficiency, and a phone in bright sun. The colours are the fourth channel, not the
   first. */

/* Hands played and how long it took, under the heading. Qualified the same way
   `.surface p.lede` is — it is a `.lede` too, and a bare `.rmeta` loses to that rule on
   specificity, so the size and colour below would sit there looking applied and not be. */
.surface p.rmeta {
  font-family: var(--mono); font-size: 2.2vmin; color: var(--text-muted); letter-spacing: 0.06em;
}

/* The finishing position. Fixed width so the names line up whatever the ordinal is. */
.rrank {
  font-family: var(--display); font-weight: 900; font-style: italic;
  font-size: 2.8vmin; color: var(--text-muted);
  min-width: 8vmin; text-align: left;
}

/* Where the winner/out marks sit. Empty for most rows, which is the normal case. */
.rmarks { display: flex; gap: 1vmin; align-items: center; }

/* One counted figure over its word — the label is what tells tricks from punts. */
.rstat { display: flex; flex-direction: column; align-items: center; min-width: 12vmin; }
.rstat b { font-family: var(--mono); font-size: 2.8vmin; color: var(--white); font-weight: 700; }
.rstat span {
  font-family: var(--display); font-size: 1.7vmin; color: var(--text-dim);
  text-transform: uppercase; letter-spacing: 0.1em;
}

/* The final score, still the biggest number in the row: it is what the game is played for. */
.rscore {
  font-family: var(--mono); font-size: 3.4vmin; font-weight: 700; color: var(--white);
  min-width: 10vmin; text-align: right;
}

/* The winner's row: a heavier yellow edge and a lifted fill. Three channels before the
   badge is even read. */
.trow.win { border-color: var(--yellow); border-width: 0.6vmin; background: var(--felt-base); }
.trow.win .rrank, .trow.win .rscore { color: var(--yellow); }
.badge.winner { color: var(--yellow); }

/* An eliminated seat: sunk into the surface and dimmed, which reads as "no longer in it"
   without depending on the red at all. */
.trow.out { background: var(--panel-inset); border-color: var(--edge); opacity: 0.78; }
.trow.out .tname { color: var(--text-body); }
.badge.out { color: var(--red-pale); }

/* A word in a hairline box, in whatever colour the modifier sets. Unscoped rather than
   under `.trow` because the browser's CARDS are the only thing that draws one now — the
   standings rows never did — and scoping it to the list idiom it happens to have been
   written under would have left the cards inheriting nothing. */
.badge {
  font-size: 2vmin; padding: 0.4vmin 1.2vmin; border-radius: 0.5vmin;
  border: 0.3vmin solid currentColor; white-space: nowrap;
  font-family: var(--display); font-weight: 700; text-transform: uppercase; letter-spacing: 0.1em;
}
.badge.draw { color: var(--yellow); }
/* Chat off is a fact about a table you can still choose to sit at, so it is stated in
   the list rather than discovered at the felt. Muted, not alarming: it is a house rule,
   not a fault.

   There is no `.badge.locked` any more. An invite-only table is not in the browse
   response at all — the server filters it — so the padlock had nothing left to sit on. */
.badge.nochat { color: var(--text-muted); }
.seats { font-size: 2.4vmin; white-space: nowrap; font-family: var(--mono); color: var(--text-muted); }
.empty-note { font-size: 2.8vmin; color: var(--text-body); font-weight: 400; }

/* ==========================================================================
   THE TABLE BROWSER — handoff § 3.
   A filter chip row over a three-column grid of table cards. Three chips, not the
   handoff's four: the fourth filtered on clock speed, and no table has one (PRD FR-13).
   Nothing in this block may ever grow a clock suffix.
   ========================================================================== */

/* `.fchip`, not `.chip`: the sit-or-play dialog already owns `.chip`/`.chips` further
   down this file (your hand, your score, trump), and those are read-only facts in a
   column, not pressable filters. Naming these the same thing let the dialog's later rules
   win on background, border, radius and padding — the filter row inherited a dialog's
   look while its own declarations sat there apparently in force. Different idiom,
   different name. */
.fchips {
  display: flex; flex-wrap: wrap; gap: 1.2vmin;
  justify-content: center; width: 100%; max-width: 120vmin;
}
/* The chip idiom: a pill, not a `.chrome` button and not a `.badge`. Yellow means the
   same thing here it means everywhere else on these surfaces — the active choice. */
.fchip {
  font-family: var(--display); font-weight: 700; font-style: italic;
  text-transform: uppercase; letter-spacing: 0.04em;
  font-size: 2.2vmin; line-height: 1.1; white-space: nowrap;
  padding: 1vmin 2.6vmin; min-height: 44px;
  border: 0.3vmin solid var(--edge-strong); border-radius: 6vmin;
  background: var(--panel-inset); color: var(--text-body);
  cursor: pointer;
}
/* "Solid yellow with dark text", verbatim from the handoff. Three channels, not one: the
   fill, the darker ink, and the heavier weight — so the active chip is still obvious
   without colour. `aria-pressed` carries it for anyone not looking at all. */
.fchip.on {
  background: var(--yellow); border-color: var(--yellow);
  color: var(--ink-btn); font-weight: 900;
}
/* Hover is deliberately NOT yellow. Yellow means "this is the active choice" everywhere on
   these surfaces, and a yellow-edged pill under the pointer read as a second chip being
   down — two filters apparently on at once, which is not a state this row has. */
.fchip:hover:not(.on) { color: var(--white); border-color: var(--edge-meter); background: var(--panel); }

/* Three columns at the reference width, dropping to two and then one rather than letting
   a card squeeze narrower than its own name. `minmax(0, 1fr)` so a long host name cannot
   force the column — and the sheet — wider than the window. */
.tgrid {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 1.4vmin;
  width: 100%; max-width: 120vmin;
}
@media (max-width: 980px) { .tgrid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 620px) { .tgrid { grid-template-columns: minmax(0, 1fr); } }
/* The empty state takes the whole row — a one-third-wide "no open tables" reads as a card
   that failed to render. Nothing else is ever a child of this grid: a note about something
   that happened to YOU (a join turned away) goes in the sheet, because the grid is rebuilt
   from scratch on every render and would erase it. */
.tgrid > .tempty { grid-column: 1 / -1; }

/* A column rather than a grid, for one reason: the footer takes `margin-top: auto` and
   drops to the bottom of whatever height the row settled on, so the seat count and the
   Sit in button line up across a row whose cards carry different numbers of badges. */
.tcard {
  display: flex; flex-direction: column; gap: 1vmin; align-items: flex-start;
  text-align: left; min-width: 0;
  background: var(--panel);
  border: 0.4vmin solid var(--edge-strong);
  /* The 4px top rail, which is the card's status in a second channel beside the word. */
  border-top: 0.8vmin solid var(--rail);
  border-radius: 0.9vmin;
  padding: 1.4vmin 1.8vmin 1.6vmin;
  box-shadow: var(--lift-button);
}
.tcard[data-status="waiting"] { border-top-color: var(--green-live); }
.tcard[data-status="in-play"] { border-top-color: var(--yellow); }
/* Full, or otherwise not open. Dimmed, and carrying no action — it is still listed,
   because "that table exists and you cannot sit at it" is worth knowing.

   There is no `done` here any more. A table never finishes: the GAME on it does, and the
   table goes straight back to waiting (story 5.2), so the dimmed rail that key drew was
   for a state nothing could ever be in. */
.tcard[data-status="full"], .tcard[data-status="closed"] { opacity: 0.62; }

.tcard .tname {
  font-family: var(--display); font-style: italic; font-weight: 900;
  font-size: 2.8vmin; line-height: 1.05; margin: 0; color: var(--white);
  max-width: 100%; overflow-wrap: anywhere;
}
/* The status word. Pale green waiting, yellow in play — handoff § 3 — and never an
   inferred absence: every card says which one it is. */
.tcard .tstat {
  font-family: var(--display); font-weight: 700; font-size: 2vmin;
  text-transform: uppercase; letter-spacing: 0.14em; color: var(--text-muted);
}
.tcard[data-status="waiting"] .tstat { color: var(--green-pale); }
.tcard[data-status="in-play"] .tstat { color: var(--yellow); }

/* The occupancy row: one segment per seat, filled for taken and a dashed outline for
   free. Handoff § 3 — 26px tall, `#1b5a29` filled, `1px dashed #ffffff40` empty. */
.occ { display: flex; gap: 0.5vmin; width: 100%; }
.occ i { flex: 1 1 0; min-width: 0; height: 2.6vmin; min-height: 14px; border-radius: 0.4vmin; }
.occ i.on { background: var(--seat-taken); border: 0.2vmin solid var(--green-live); }
.occ i.off { border: 0.2vmin dashed var(--edge-strong); }

.tcard .twho { font-size: 2vmin; font-family: var(--mono); color: var(--text-dim); }
.tcard .tbadges { display: flex; flex-wrap: wrap; gap: 0.8vmin; }
.tcard .tbadges:empty { display: none; }
.tcard .tfoot {
  display: flex; align-items: center; justify-content: space-between;
  gap: 1.4vmin; width: 100%; margin-top: auto;
}
.tcard .tfoot .seats { font-size: 2.6vmin; color: var(--white); }
.tcard .tfoot .chrome.small { font-size: 2.4vmin; padding: 0.8vmin 2vmin; }

/* The dashed cell the handoff draws last in the grid, standing in for the whole grid when
   there is nothing to fill it with. Never a blank panel. */
.tempty {
  display: grid; gap: 1.4vmin; justify-items: center; text-align: center;
  border: 0.4vmin dashed var(--edge-strong); border-radius: 0.9vmin;
  padding: 3vmin 2vmin;
}
.tempty .tename { font-size: 3.4vmin; margin: 0; }
/* The same cell when the lookup FAILED rather than came back empty. Red-edged, because
   this one is not a fact about the lobby — it is this client saying it does not know. */
.tempty.bad { border-color: var(--punt-edge); }
.tempty.bad .tename { color: var(--red-pale); }
/* An invite that did not let you in. The same cell again, but at the TOP of the lobby
   sheet and full width rather than inside the grid: it is about the thing the player just
   tried, not about the list underneath it, and the list stays right there as the next
   move. */
.tempty.invite-failed { width: 100%; }
/* Focus is moved here the instant the panel appears, so the ring is drawn deliberately and
   clear of the dashed edge rather than left to the browser to paint on top of it. Never
   removed: it is the only thing telling a keyboard player where they have just been put. */
.tempty.invite-failed:focus {
  outline: 0.35vmin solid var(--yellow); outline-offset: 0.9vmin;
}

/* ==========================================================================
   THE RULES — handoff § Screens > 12.

   A header with a one-line summary and the way back, then a 3-column grid of panels
   grouped BY QUESTION: the score, the hand, getting punted, then the suit that changes
   the rules, then the marks the table draws. What it replaced was `.rules-list`, a
   sixteen-item numbered list — a reference, not something a player mid-hand can find one
   answer in. The grouping is the feature; the panels are the questions.

   Sized in vmin like every other pre-table surface, NOT in `--px`. `--px` is a container
   unit off `.table`, and outside that container it resolves against the viewport at
   roughly 0.7px — every table-scaled idiom brought over here would render at a tenth of
   its intended size. The one deliberate exception is the legend, below, which re-bases
   `--px` on purpose.
   ========================================================================== */
/* `100%`, not the shared sheet's `96vw`. 96vw is WIDER than the surface's content box —
   the surface adds 3vmin of padding on each side — so the sheet overhangs it by a few
   pixels and the surface grows a horizontal scrollbar. Every pre-table screen does this
   and it is nearly invisible on them; here it is an acceptance criterion in its own right
   ("nothing scrolls sideways at any supported viewport"), so this screen's sheet is bound
   to the box it is actually standing in. Measured by layout-check.mjs at every viewport. */
.rules-sheet { width: min(100%, 150vmin); gap: 2vmin; }

/* Title, summary and the Close, on one line that wraps rather than clips. The handoff
   puts the way back in the header; it is still `#rulesBack` and still returns the player
   where they came from — reachability is not this screen's to move. */
.rhead {
  display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between;
  gap: 1.2vmin 2vmin; width: 100%; text-align: left;
}
.rhead h2 { font-size: 6vmin; flex: 0 0 auto; }
.rhead .rsum {
  flex: 1 1 28ch; min-width: 0; margin: 0;
  font-size: 2.3vmin; line-height: 1.35; font-weight: 400; color: var(--text-body);
}
.rhead .chrome { flex: 0 0 auto; align-self: center; }

/* `grid-template-rows: min-content 1fr` is the handoff's own. The three question panels
   take the first row at their content height; the spades panel and the legend take the
   second. `minmax(0, 1fr)` columns so a long word can never force the grid — and the
   sheet — wider than the window. */
.rgrid {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  grid-template-rows: min-content 1fr;
  gap: 1.6vmin; width: 100%; text-align: left;
}
/* The same object every other panel on these surfaces is: the panel colour, the strong
   edge, the button lift — plus a coloured top rail carrying which question it answers.
   Column, so the punt panel's footer chip can take `margin-top: auto` and sit on the
   panel's floor however tall the row settles. */
.rpanel {
  display: flex; flex-direction: column; gap: 1.2vmin; min-width: 0;
  background: var(--panel);
  border: 0.4vmin solid var(--edge-strong);
  border-top: 0.8vmin solid var(--rail);
  border-radius: 0.9vmin;
  padding: 1.6vmin 1.8vmin 1.8vmin;
  box-shadow: var(--lift-button);
}
.rpanel[data-rail="score"]  { border-top-color: var(--green-live); }
.rpanel[data-rail="hand"]   { border-top-color: var(--yellow); }
.rpanel[data-rail="punted"] { border-top-color: var(--red); }

.rtitle {
  margin: 0; font-family: var(--display); font-weight: 900; font-style: italic;
  text-transform: uppercase; letter-spacing: -0.02em;
  font-size: 3vmin; line-height: 1; color: var(--white);
}

/* Rules as bullets, not as numbers. The old list's ordinals were the arrangement being
   replaced: "rule 12" is a thing to cite, not a thing to find. The dot is drawn rather
   than a `::marker` so a rule line that is still waiting on its number takes its bullet
   into hiding with it — `[data-rule][hidden]` sets `display: none` on the whole `li`. */
.rlist {
  margin: 0; padding: 0; list-style: none; display: grid; gap: 1.1vmin;
  font-size: 2.1vmin; line-height: 1.4; color: var(--text-body); font-weight: 400;
}
.rlist li { position: relative; padding-left: 2.6vmin; overflow-wrap: break-word; }
.rlist li::before {
  content: ""; position: absolute; left: 0; top: 0.55em;
  width: 0.9vmin; height: 0.9vmin; min-width: 4px; min-height: 4px;
  border-radius: 50%; background: var(--yellow);
}

/* The handoff's footer chip on the punt panel. Two elements, not one sentence: the figure
   is served and the warning is not, so the figure can go missing on its own and leave
   the words standing rather than taking the chip with it. */
.rchip {
  margin: auto 0 0; display: flex; align-items: center; gap: 1.4vmin;
  background: var(--panel-inset); border: 0.3vmin solid var(--punt-edge);
  border-radius: 0.8vmin; padding: 1vmin 1.4vmin;
}
.rchip .k {
  font-family: var(--display); font-weight: 900; font-style: italic;
  font-size: 3.4vmin; line-height: 1; color: var(--red-pale);
}
.rchip .v { font-size: 2vmin; line-height: 1.3; color: var(--text-muted); font-weight: 400; }
/* No figure, no chip. The words are a CAPTION for the number — "what one punt costs you"
   with nothing beside it is a box pointing at an absence, which is worse than the panel
   simply reading shorter, and reading shorter is what every other unserved line does. */
.rchip:has(.k[hidden]) { display: none; }

/* Spades across the pair, with the handoff's 62px outlined glyph beside its own words.
   62px against the handoff's 720-tall frame is 8.6vmin. Wraps rather than squeezes. */
.rpanel.spades { grid-column: span 2; flex-direction: row; flex-wrap: wrap; align-items: flex-start; gap: 1.6vmin 2.4vmin; }
.rpanel .rbody { flex: 1 1 24ch; min-width: 0; display: flex; flex-direction: column; gap: 1.2vmin; }
/* Outlined, not filled — and filled is the FALLBACK, not the default that gets overridden.
   A transparent fill with no stroke support is an invisible glyph, which is the one
   outcome this panel cannot have. */
.rspade { flex: 0 0 auto; font-size: 8.6vmin; line-height: 1; color: var(--yellow); }
@supports (-webkit-text-stroke: 1px black) {
  .rspade { color: transparent; -webkit-text-stroke: 0.35vmin var(--yellow); paint-order: stroke fill; }
}

/* ---------- the legend: the marks, as the table draws them ----------
   These are `.puck` and `.bdg` — the SAME rules the felt uses, not a second drawing of
   them. That is the whole point: a legend that redrew the marks could drift from the
   table without either changing, and a player would be matching a picture against
   something that no longer looks like it.

   Which forces one thing: those rules are written in `--px`, the table's container unit,
   and outside `.table` it resolves to about 0.7px. So `--px` is RE-BASED here, on vmin,
   and every proportion inside the marks comes out exactly as it does on the felt at a
   size this surface can read. `min-height`/`min-width` are not needed: --px has a floor. */
.rmarks {
  --px: max(0.2vmin, 1.1px);
  margin: 0; padding: 0; list-style: none; display: grid; gap: 1.6vmin;
}
.rmarks li { display: flex; align-items: flex-start; gap: 1.6vmin; }
.rmarks .rmark { flex: 0 0 calc(44 * var(--px)); display: flex; justify-content: center; }
/* The table absolutely positions the puck and centres it on its coordinate; in a legend
   it is simply in the row. The BODY of it — the circle, the black, the orange centre, the
   D — is untouched. */
.rmarks .puck { position: static; transform: none; transition: none; flex: 0 0 auto; }
.rmarks .rwhat { min-width: 0; font-size: 2vmin; line-height: 1.35; color: var(--text-body); font-weight: 400; }
.rmarks .rwhat b {
  display: block; font-family: var(--display); font-weight: 800; font-style: italic;
  text-transform: uppercase; letter-spacing: 0.08em; font-size: 1.9vmin; color: var(--white);
}

/* ---------- how it collapses ----------
   Two columns below the reference width, one at phone widths. LAST in the block, and that
   is not tidiness: `.rpanel.spades { grid-column: span 2 }` above has the same specificity
   as the override here, so with these queries written first the span won everywhere. At
   one column a `span 2` is not ignored — it creates an IMPLICIT second column and the four
   unspanned panels collapse into it, 16px wide with their text sideways. Measured at
   600x400, which the app admits: it is landscape, so the rotate prompt never fires. */
@media (max-width: 1000px) { .rgrid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 620px) {
  .rgrid { grid-template-columns: minmax(0, 1fr); }
  .rpanel.spades { grid-column: auto; }
}

.credit { font-size: 2vmin; color: var(--text-dim); font-family: var(--mono); }

/* Rotate prompt. Portrait is refused deliberately: a six-seat ring restacked into a
   grid loses turn-order adjacency, and that is load-bearing in a trick-taking game. */
#rotate { z-index: 90; }
#rotate .glyph { font-size: 18vmin; line-height: 1; }

/* ==========================================================================
   THE TABLE — handoff § Screens > 1. The table
   ========================================================================== */

#table.surface { padding: 0; background-image: none; display: block; }
#table.surface::before { content: none; }

.table {
  position: relative;
  width: 100%;
  height: 100%;
  overflow: hidden;
  container-type: size;
  container-name: tbl;
  background-color: var(--felt-base);
  border: calc(6 * var(--px)) solid var(--rail);
  border-radius: var(--r-table);
}

/* The suit tile, at the handoff's 126px. A layer of its own rather than a background
   on the felt itself, so the trump swap changes the motif without touching the base
   colour underneath — and so the surface picker can later replace this one layer with
   the rink rather than unpick a compound background. */
.tile {
  position: absolute; inset: 0; z-index: 0; pointer-events: none;
  background-image: url(assets/felt/suits.png);
  background-repeat: repeat;
  background-size: calc(126 * var(--px)) auto;
}
.table[data-trump="Spades"]   .tile { background-image: url(assets/felt/spades.png); }
.table[data-trump="Hearts"]   .tile { background-image: url(assets/felt/hearts.png); }
.table[data-trump="Clubs"]    .tile { background-image: url(assets/felt/clubs.png); }
.table[data-trump="Diamonds"] .tile { background-image: url(assets/felt/diamonds.png); }

/* The surface highlight and its vignette, in one layer over the tile: attention to
   the centre, and nothing light floating off the edge of the felt. */
.vig {
  position: absolute; inset: 0; z-index: 1; pointer-events: none;
  background: radial-gradient(64% 66% at 50% 40%, #1e6b31b3 0%, var(--felt-deep) 100%);
  box-shadow: inset 0 0 calc(140 * var(--px)) var(--ambient);
}

/* ==========================================================================
   THE ICE RINK — DESIGN.md § Components > Rink surface

   A SIBLING of the felt recipe above, not a variant of it. The felt is a base colour
   plus an opaque tile; the rink is a base colour plus geometry, and shares nothing but
   the layer stack it draws into.

   It is drawn ENTIRELY IN CSS, and that is not a stylistic preference. `suits.png` is
   fully opaque: a background colour set behind it never shows, and `mix-blend-mode:
   multiply` against its near-black artwork only darkens — so the colour-swap route
   collapses every surface to the same mud. The rink uses no tile at all.

   The geometry, and the decisions it forced (.working/ice-rink-table.html):

     blue lines at 15–23% and 77–85% of the table's WIDTH, behind the seat rows;
     a red centre line at 48.4–51.6%, drawn THINNER than the blues as on a real sheet,
       running up through the centred hand — which reads as deliberate precisely
       because the hand is dead centre, so the corridor invariant becomes visible;
     end-zone faceoff dots;
     the centre faceoff circle anchored to the TRICK PILE (`--trick-y`) rather than the
       geometric centre. A real sheet centres it; centred here it lands in the dead
       space between the trick and the hand. Anchored to the pile, played cards land
       INSIDE the circle, which is the whole point of the treatment.

   Circles are sized in `--px` on BOTH axes for the reason the dealer puck is: one unit
   on both axes is what keeps a circle from becoming an ellipse as the table's aspect
   changes. Line positions stay in `cqw`/`cqh`, because a rink's markings are
   proportions of the sheet.
   ========================================================================== */
.rink { position: absolute; inset: 0; z-index: 0; pointer-events: none; display: none; }
.rink i { position: absolute; }

/* The surface picker is one attribute, and it swaps ONE layer: the tile goes, the rink
   arrives, and the base colour and vignette below and above it re-tune. Nothing else on
   the table knows which surface it is standing on. */
.table[data-surface="ice"] .tile { display: none; }
.table[data-surface="ice"] .rink { display: block; }

/* The ice itself. `--ice-shade` carries the depth toward the boards, so the sheet reads
   as ice rather than as paper. */
.table[data-surface="ice"] {
  background-color: var(--ice);
  background-image: radial-gradient(72% 76% at 50% 38%, var(--ice) 0%, var(--ice-shade) 100%);
}
/* The felt's radial highlight and its ambient, in their paler equivalents. */
.table[data-surface="ice"] .vig {
  background: radial-gradient(58% 62% at 50% 34%, var(--ice-sheen) 0%, transparent 70%);
  box-shadow: inset 0 0 calc(140 * var(--px)) var(--ice-ambient);
}

.rink .blue { top: 0; bottom: 0; width: 8cqw; background: var(--rink-blue); opacity: 0.92; }
.rink .blue.l { left: 15cqw; }
.rink .blue.r { left: 77cqw; }
.rink .redline { top: 0; bottom: 0; left: 48.4cqw; width: 3.2cqw; background: var(--rink-red); opacity: 0.92; }

.rink .dot {
  width: calc(28 * var(--px)); height: calc(28 * var(--px)); border-radius: 50%;
  background: var(--rink-red); opacity: 0.55; transform: translate(-50%, -50%);
}
.rink .d1 { left: 8cqw;  top: 22cqh; }
.rink .d2 { left: 8cqw;  top: 70cqh; }
.rink .d3 { left: 91cqw; top: 22cqh; }
.rink .d4 { left: 91cqw; top: 70cqh; }

/* 435 --px across — wide enough to hold a full six-card trick (360 --px) with the pile's
   own height to spare, so the cards land in the circle rather than straddling it. */
.rink .circle {
  left: 50%; top: var(--trick-y); transform: translate(-50%, -50%);
  width: calc(435 * var(--px)); height: calc(435 * var(--px)); border-radius: 50%;
  border: calc(7 * var(--px)) solid var(--rink-blue); opacity: 0.55;
}
.rink .spot {
  left: 50%; top: var(--trick-y); transform: translate(-50%, -50%);
  width: calc(44 * var(--px)); height: calc(44 * var(--px)); border-radius: 50%;
  background: var(--rink-blue); opacity: 0.7;
}

/* ---------- chrome cluster ---------- */
.gear, .exit {
  position: absolute; z-index: 30; top: calc(16 * var(--px));
  background: linear-gradient(180deg, var(--chrome-top), var(--chrome-bottom));
  border: calc(2 * var(--px)) solid var(--chrome-edge);
  border-radius: var(--r-button);
  box-shadow: var(--lift-chrome);
  display: grid; place-items: center; color: var(--chrome-ink);
  cursor: pointer; padding: 0; font: inherit;
}
.gear { left: calc(16 * var(--px)); width: calc(54 * var(--px)); height: calc(46 * var(--px)); font-size: calc(22 * var(--px)); }
.exit {
  right: calc(16 * var(--px)); padding: calc(6 * var(--px)) calc(22 * var(--px));
  font-family: var(--display); font-weight: 900; font-style: italic; font-size: calc(28 * var(--px));
  text-transform: uppercase; letter-spacing: -0.02em; color: var(--red-card);
}
.gear:hover, .exit:hover { transform: translateY(calc(2 * var(--px))); box-shadow: 0 calc(2 * var(--px)) 0 var(--shade), 0 calc(8 * var(--px)) calc(16 * var(--px)) var(--shade-soft); }
.gear:active, .exit:active { transform: translateY(calc(4 * var(--px))); box-shadow: none; }

/* ---------- seat panel ----------
   handoff: 152px wide; name above; a 3px state-coloured border; an 86px art area with
   the score overlaid; the trick meter strip; the status label below. */
.seat { position: absolute; width: calc(152 * var(--px)); z-index: 12; }
.seat .nm {
  font-family: var(--display); font-weight: 900; font-style: italic;
  font-size: calc(20 * var(--px)); line-height: 1.05; letter-spacing: -0.02em;
  text-transform: uppercase; text-align: center; color: var(--white);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  margin-bottom: calc(5 * var(--px));
  /* Names sit on felt rather than inside the panel, so they may run wider than it —
     otherwise two players truncate to the same "Player …". The felt inset that keeps
     this overhang on the felt is the anchors' own 34px edge clearance. */
  width: 140%; margin-left: -20%;
  text-shadow: 0 calc(2 * var(--px)) 0 #000, 0 0 calc(10 * var(--px)) #000;
}
/* Secondary to the name, so it is the part that gives way when the box is tight. */
.seat .nm .you { font-size: 0.62em; opacity: 0.85; }

.pbox {
  position: relative; overflow: hidden;
  border: calc(3 * var(--px)) solid var(--rail);
  border-radius: calc(6 * var(--px));
  background: var(--panel);
  box-shadow: var(--lift);
  /* The turn moves and the border follows it — DESIGN.md asks for ~180ms. */
  transition: border-color 180ms ease, box-shadow 180ms ease;
}
/* Border colour IS the state, and an active state adds an outward glow in the same
   hue. Glow is never decorative and never appears on a neutral panel. */
.pbox.play { border-color: var(--green-live); box-shadow: 0 0 calc(18 * var(--px)) #3ad14a66, var(--lift); }
.pbox.sit  { border-color: var(--yellow); box-shadow: 0 0 calc(18 * var(--px)) #f7e93f66, var(--lift); opacity: 0.85; }
.pbox.turn { border-color: var(--red); box-shadow: 0 0 calc(22 * var(--px)) #d8262b88, var(--lift); }
.pbox.thinking { animation: pulse 1.1s ease-in-out infinite; }
@keyframes pulse { 50% { filter: brightness(1.35); } }

/* The character slot. Placeholder art, and honest about it (handoff § Assets): the
   legacy mascot is a whole card, so the panel crops into it to frame the face rather
   than showing a second card inside a seat. Decorative — name, score and state are
   all in text. */
.pbox .face {
  width: 100%; height: calc(86 * var(--px)); display: block;
  background-color: var(--panel-inset);
  background-image: url(assets/art/mascot.png);
  background-repeat: no-repeat;
  /* 270% of the 86 --px band frames the face inside the panel. The source is the whole
     K-of-spades card the legacy mascot was drawn on, and shown at a scale that fits the
     card, a seat panel reads as "a card" — which competes with the actual cards on the
     table. Background rather than <img> because only a background can be scaled past
     its box to crop into art. Placeholder, and honest about it: the commissioned slot
     is a 600x900 chest-up portrait (handoff § Character art). */
  background-size: auto 270%;
  background-position: 50% 45%;
}
.pbox .sc {
  position: absolute; top: 0; left: 0; right: 0; height: calc(86 * var(--px));
  display: grid; place-items: center;
  font-family: var(--display); font-weight: 900; font-style: italic;
  font-size: calc(58 * var(--px)); line-height: 1; color: var(--yellow);
  font-variant-numeric: tabular-nums;
  -webkit-text-stroke: calc(5 * var(--px)) #000; paint-order: stroke fill;
}
.pbox .sc.danger { color: var(--red-pale); }
.pbox.out { border-color: var(--red); }
.pbox.out::after {
  content: "✕"; position: absolute; inset: 0; display: grid; place-items: center;
  background: #d8262bcc; color: var(--white);
  font-family: var(--display); font-weight: 900; font-style: italic; font-size: calc(64 * var(--px));
}
.pbox.away { opacity: 0.5; }

/* Colour is never the only channel: every panel state is mirrored here in words. */
.seat .sub {
  font-family: var(--display); font-weight: 600; font-size: calc(12 * var(--px));
  letter-spacing: 0.14em; text-transform: uppercase; text-align: center;
  color: var(--white); line-height: 1.2; margin-top: calc(5 * var(--px));
  min-height: calc(14 * var(--px));
  text-shadow: 0 calc(1 * var(--px)) calc(3 * var(--px)) #000;
}
.seat .sub.danger { color: var(--red-pale); }

/* THE ONE TOKEN THAT FLIPS BY SURFACE (DESIGN.md § Colors > Added by this spine).
   Pale on felt, the sub-label vanishes on ice, so on the rink it goes to a dark ink and
   drops the shadow it only ever needed over busy felt. `danger` goes to the rink's own
   red for the same reason: `--red-pale` is a pale label, and a pale label on pale ice is
   unreadable. Seat PANELS are untouched on both surfaces — they are objects ON the ice,
   not part of it, and darkening or lightening them would take that away.

   The sub-label is not the only text drawn straight onto the surface: `.seat .nm` above
   is white and overhangs its panel by design (see its own note). It is left alone here
   because it carries a hard black drop plus a 10px black glow, so it reads as outlined
   letterforms rather than as a pale label — the glow that exists for busy felt is what
   also keeps it off the ice. That is a judgement made by reading the rule, not by
   looking at a rendered rink; if the names turn out to be weak on ice, this is the
   second token to flip and the "one token" framing above is what has to give. */
.table[data-surface="ice"] .seat .sub {
  color: var(--ice-label); text-shadow: none; font-weight: 700;
}
.table[data-surface="ice"] .seat .sub.danger { color: var(--rink-red); }

/* ---------- trick meter ----------
   Five segments, always. Won = filled yellow; still owed on the bid = red with an
   inset white ring; unplayed = a hairline outline over nothing. Three treatments that
   differ in shape as well as hue, which is the accessibility floor. */
.meter { display: flex; gap: calc(3 * var(--px)); padding: calc(4 * var(--px)); background: var(--meter-ground); }
.tsq { flex: 1; height: calc(10 * var(--px)); border-radius: calc(2 * var(--px)); }
.tsq.have { background: var(--yellow); }
.tsq.need { background: var(--red); box-shadow: inset 0 0 0 calc(1 * var(--px)) var(--card-face); }
.tsq.spare { border: calc(1 * var(--px)) solid var(--edge-meter); }

/* ---------- badges: drawn, never emoji ----------
   handoff § Screens > 1: 22–26px, radius 4px, at top:26px on the panel's OUTER side,
   offset −30 to −32px. S says a seat is sitting out.

   C is the CUT WINNER's mark — the seat that cut highest for first deal — and table.js
   now renders it for exactly that (`seat.seat === state.cutWinner`). It stood for the
   table owner while no cut existed on the wire, a disagreement recorded as F8 in the
   epic-3 retrospective; story 5.1 put the cut on the wire and the renderer, this comment
   and the rules legend changed together in that commit.

   These same two rules draw the badges in the rules legend (`.rmarks`, § THE RULES). That
   is deliberate — one drawing, two places — and it is why nothing here may assume it is
   inside `.table`. */
.badges {
  position: absolute; top: calc(26 * var(--px)); z-index: 14;
  display: flex; flex-direction: column; gap: calc(3 * var(--px));
}
.seat[data-side="right"] .badges { right: calc(-32 * var(--px)); align-items: flex-end; }
.seat[data-side="left"]  .badges { left: calc(-32 * var(--px)); align-items: flex-start; }
.bdg {
  position: relative;
  width: calc(26 * var(--px)); height: calc(26 * var(--px)); border-radius: var(--r-badge);
  display: grid; place-items: center;
  font-family: var(--display); font-weight: 900; font-style: italic; font-size: calc(15 * var(--px));
  line-height: 1; box-shadow: var(--lift-button);
}
.bdg.c { background: var(--cut-blue); border: calc(2 * var(--px)) solid var(--card-face); color: var(--card-face); }
.bdg.s {
  background: #06170bf0; border: calc(2 * var(--px)) solid var(--yellow); color: var(--yellow);
  width: calc(22 * var(--px)); height: calc(22 * var(--px)); font-size: calc(13 * var(--px));
}
/* Two sit-outs in a row is the cap — the third hand must be played. The count rides
   the badge rather than adding a second mark. */
.bdg.s[data-count="2"]::after {
  content: "2"; position: absolute; transform: translate(calc(11 * var(--px)), calc(-10 * var(--px)));
  font-size: calc(10 * var(--px)); color: var(--white); -webkit-text-stroke: calc(2 * var(--px)) #000;
  paint-order: stroke fill;
}

/* ---------- centre, puck, deck, cards ---------- */
/* The trick pile. Centred on the felt, and the one thing on the table that must never
   be covered — seat panels draw above it, so it is kept clear of them by construction
   rather than by luck. */
.centre { position: absolute; top: var(--trick-y); left: 50%; transform: translate(-50%, -50%); z-index: 8; }
.trick { display: flex; gap: calc(8 * var(--px)); }
/* The pile OVERLAPS itself rather than spreading. Six cards laid out flat are 616 --px
   wide and reach under the upper seat panels, which draw above the trick — a played
   card would simply be hidden behind a player. Overlapping to the right keeps every
   card's top-left rank visible (the only place a rank is printed) and brings a full
   six-card trick to 360 --px, clear of both panels, of the deck and of the dealer
   puck's path at every aspect the app admits. */
.trick .cardv + .cardv { margin-left: calc(-51.2 * var(--px)); }

.puck {
  /* 36px circle, black body, orange centre — handoff § Screens > 1, Badges. A true
     circle because it is a puck, which one unit on both axes is what guarantees. */
  position: absolute; width: calc(36 * var(--px)); height: calc(36 * var(--px));
  border-radius: 50%; background: #0a0a0a; border: calc(3 * var(--px)) solid #000;
  display: grid; place-items: center; z-index: 13;
  /* Centred on its coordinate, not hung off it. table.js places the puck 55% of the way
     from the middle of the felt toward the dealer, and without this the box lands half
     a puck down and right of where that says — which is how it ended up on the deck. */
  transform: translate(-50%, -50%);
  box-shadow: 0 calc(6 * var(--px)) calc(14 * var(--px)) #000c, inset 0 calc(2 * var(--px)) 0 var(--edge);
  transition: top 0.45s ease, left 0.45s ease;
}
.puck i {
  width: 64%; height: 64%; border-radius: 50%; background: var(--puck-orange);
  display: grid; place-items: center; font-style: normal; font-weight: 900;
  font-size: calc(15 * var(--px)); line-height: 1; color: #0a0a0a; font-family: var(--display);
}

/* ---------- the deck ----------
   Cards have to come from somewhere: the deal flies backs off this stack, and the draw
   phase draws from it, which is what earns it a place on the felt at all.

   On open felt LEFT of the trick pile, as the handoff places it — it must not sit in
   the centre column, which is the one part of the felt that must never be obscured.

   Positioned relative to the CENTRE rather than as a percentage of the width, so the
   230 --px it sits left of the pile is the same distance at every aspect: a percentage
   would drift toward the middle as the table widens and end up under the trick. Its
   clearances, in --px and invariant:

     trick pile   left edge  centre-180   deck right  centre-200   (20 apart)
     dealer puck  upper-left seat, its closest approach — passes 11 above the deck
     mid-left seat, the feed, the turn banner, the hand    all clear by 40 or more

   Drawn at 0.62 of a table card: the handoff's own 96x145 stack at this spot is a full
   card tall and the felt cannot hold it beside a six-card pile. A smaller stack reads
   as a deck further away, which is what it is. Keep DECK in table.js in step with this
   — the CSS draws the deck, the JS deals from it, and the two drifting apart would
   launch cards off empty felt. */
.deck {
  position: absolute; left: calc(50cqw - 230 * var(--px)); top: 43.8cqh;
  width: calc(60 * var(--px)); height: calc(91 * var(--px)); z-index: 11;
  transform: translate(-50%, -50%); pointer-events: none;
}
.deck i {
  position: absolute; inset: 0; border-radius: var(--r-badge);
  border: calc(3 * var(--px)) solid #000;
  background: var(--card-back-blue) url(assets/art/card-back.png) center / contain no-repeat;
  box-shadow: 0 calc(4 * var(--px)) calc(10 * var(--px)) var(--shade);
}
/* Offset and fanned, so the stack reads as several cards rather than one. */
.deck i:nth-child(1) { transform: translate(calc(-4 * var(--px)), calc(-4 * var(--px))) rotate(-3.5deg); }
.deck i:nth-child(2) { transform: translate(calc(-2 * var(--px)), calc(-2 * var(--px))) rotate(1.5deg); }
/* Dealing lifts the top of the stack, so the deck visibly answers the deal. */
.deck.dealing i:nth-child(3) { animation: deckTap 0.32s ease-in-out 3; }
@keyframes deckTap { 50% { transform: translate(calc(2 * var(--px)), calc(-5 * var(--px))) rotate(2deg); } }

/* Card geometry — handoff § Card geometry. 96x145, 4px black border, 7px radius; one
   big rank top-left, one big suit glyph filling the body. Stark by design: it is why
   the face still reads at a seat panel's size, and it is not a simplification to undo. */
.cardv, .backv {
  width: calc(96 * var(--px)); height: calc(145 * var(--px));
  position: relative; flex: 0 0 auto; display: block;
  background: var(--card-face); border: calc(4 * var(--px)) solid #000; border-radius: var(--r-card);
  box-shadow: 0 calc(8 * var(--px)) calc(18 * var(--px)) #000a;
  color: var(--red-card); padding: 0; font: inherit; text-align: left;
}
.cardv:not(.red) { color: var(--ink); }
.cardv .rk {
  position: absolute; top: 0; left: calc(8 * var(--px));
  font-family: var(--display); font-weight: 900; font-style: italic;
  font-size: calc(36 * var(--px)); line-height: 1.1; letter-spacing: -0.03em;
}
.cardv .su {
  position: absolute; top: calc(46 * var(--px)); right: calc(6 * var(--px));
  bottom: calc(4 * var(--px)); left: calc(6 * var(--px));
  display: grid; place-items: center; font-size: calc(58 * var(--px)); line-height: 1;
}
.backv { background: var(--card-back-blue); overflow: hidden; }
/* `contain`, not `cover`. The back art is 896x1248 (0.718) against a 0.662 card box, so
   cover crops the design; the blue field showing at the edges reads as the card's
   border rather than as a zoomed-in crop of the artwork. */
.backv img { width: 100%; height: 100%; object-fit: contain; display: block; }

.hand { display: flex; gap: calc(9 * var(--px)); justify-content: center; }
.hand .cardv { cursor: pointer; transition: transform 0.12s ease; }
.hand .cardv:hover:not(:disabled) { transform: translateY(calc(-4 * var(--px))); }
.cardv.illegal { opacity: 0.45; filter: grayscale(0.6); cursor: not-allowed; box-shadow: none; }
.cardv.picked, .cardv.sel {
  outline: calc(4 * var(--px)) solid var(--yellow); outline-offset: calc(3 * var(--px));
  transform: translateY(calc(-10 * var(--px)));
}
.cardv:focus-visible {
  outline: calc(4 * var(--px)) solid var(--yellow); outline-offset: calc(3 * var(--px));
}

/* ---------- confirm every card (settings) ----------
   The armed card says so ON ITS OWN FACE. Nothing is added to the corridor and nothing
   covers the turn banner or its countdown: the whole point of the setting's design is
   that the clock the player is racing stays visible and the second tap lands on the same
   target as the first. `.armed` borrows `.picked`'s lift and outline — a card raised out
   of the hand is what "chosen, not yet played" already looks like on the draw screen.

   `pointer-events: none` on the overlay is load-bearing: the card is the button, and a
   scrim that ate the click would leave a confirm step nothing could confirm. */
.cardv.armed {
  outline: calc(4 * var(--px)) solid var(--yellow); outline-offset: calc(3 * var(--px));
  transform: translateY(calc(-10 * var(--px)));
}
.cfm {
  position: absolute; inset: 0; border-radius: calc(3 * var(--px));
  background: var(--dump-scrim); pointer-events: none;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: calc(3 * var(--px));
}
.cfm b {
  font-family: var(--display); font-weight: 900; font-style: italic;
  font-size: calc(20 * var(--px)); text-transform: uppercase; color: var(--yellow);
}
.cfm i {
  font-family: var(--body); font-style: normal; font-weight: 700;
  font-size: calc(11 * var(--px)); letter-spacing: 0.06em;
  text-transform: uppercase; color: var(--white);
}

/* ---------- the bottom corridor ----------
   handoff § Screens > 1, "The bottom corridor — read this carefully."

   One full-width row, zero height, holding two children that cannot reach each other:
   trump in a reserved lane at a fixed left offset, and the hand centred on the table.
   Both invariants fall out of the geometry rather than being tuned per viewport —

     the hand's centre IS the table's centre, because it is placed at left:50% and
     translated back by half its own width, and nothing else is in its flow;

     trump can never be overlapped, because its lane sits between the feed's fixed
     width (244) and the hand's furthest possible left edge (640 - 258 = 382 --px),
     and the composition is always exactly 1280 --px across.

   The gaps at their tightest, in --px: feed ends 244, trump 250..346, hand starts 382.
   Six either side, and identical at every aspect the app admits. */
.corridor { position: absolute; left: 0; right: 0; bottom: calc(16 * var(--px)); height: 0; z-index: 13; }
.corridor .lane {
  position: absolute; bottom: 0; left: calc(250 * var(--px));
  width: calc(96 * var(--px)); box-sizing: border-box;
}
.corridor .mid {
  position: absolute; bottom: 0; left: 50%; transform: translateX(-50%);
  display: flex; flex-direction: column; align-items: center; gap: calc(12 * var(--px));
  text-align: center;
}

/* ---------- the spectator bar ----------
   Story 5.6. The corridor's other centred occupant: where a player has a hand, a watcher
   has this. It sits in the same `.mid` column, so it inherits the same centring the hand
   does and the corridor's first invariant — the centred thing's centre IS the table's
   centre — holds for it by the same geometry rather than by a second rule.

   THE WIDTH IS THE SECOND INVARIANT, and it is arithmetic, not taste. The composition is
   1280 --px across, so the centre is 640 and trump's reserved lane ends at 346. A box
   centred on 640 with width W has its left edge at 640 - W/2, which must stay right of
   346 — so W < 588. 500 puts the left edge at 390, which is the hand's own furthest-left
   edge (382) plus a little, i.e. the bar is never wider than the widest hand the corridor
   was laid out for. Trump's clearance is therefore the same 44 --px it has from the hand,
   at every aspect the app admits, and `layout-check.mjs` measures it rather than trusting
   this comment.

   `display: none` when hidden (.hidden, above) so a SEATED player's corridor is exactly
   what it was: the bar contributes no width, no height and no flow. */
.specbar {
  width: calc(500 * var(--px)); max-width: calc(500 * var(--px)); box-sizing: border-box;
  display: flex; flex-direction: column; align-items: stretch; gap: calc(8 * var(--px));
  background: var(--feed-ground); border: calc(2 * var(--px)) solid var(--rail);
  border-radius: calc(8 * var(--px)); padding: calc(10 * var(--px)) calc(14 * var(--px));
  box-shadow: 0 calc(6 * var(--px)) calc(16 * var(--px)) var(--shade);
  text-align: left;
}
/* Who else is here. The count is the display face; the names are the detail, and they are
   allowed to wrap rather than to truncate — a watcher list that ends in an ellipsis is the
   same defect as a truncated seat name, one screen over. */
.specbar .swho { display: flex; flex-wrap: wrap; align-items: baseline; gap: calc(4 * var(--px)) calc(10 * var(--px)); }
.specbar .scount {
  font-family: var(--display); font-weight: 900; font-style: italic;
  font-size: calc(14 * var(--px)); letter-spacing: 0.08em; text-transform: uppercase;
  color: var(--yellow);
}
.specbar .snames { font-size: calc(13 * var(--px)); line-height: 1.3; color: var(--text-muted); min-width: 0; }
/* The same input the feed draws, in the place a watcher is actually looking. */
.specbar .ssay {
  width: 100%; box-sizing: border-box;
  background: var(--panel-inset); border: calc(2 * var(--px)) solid var(--edge-faint);
  border-radius: calc(6 * var(--px)); padding: calc(6 * var(--px)) calc(10 * var(--px));
  color: var(--white); font-family: var(--body); font-size: calc(14 * var(--px));
}
.specbar .ssay::placeholder { color: var(--text-dim); }
.specbar .sseat { display: flex; flex-wrap: wrap; align-items: center; gap: calc(6 * var(--px)) calc(10 * var(--px)); }
/* A disabled action that still reads as an action is how "a seat is coming" gets implied.
   It is dimmed, it refuses the pointer, and the sentence beside it says what would have to
   happen instead. */
.specbar .sseat button[disabled] { opacity: 0.45; cursor: not-allowed; }
.specbar .swhy { font-size: calc(12 * var(--px)); line-height: 1.3; color: var(--text-muted); flex: 1 1 18ch; min-width: 0; }

/* ---------- trump ---------- */
.trump {
  border: calc(2 * var(--px)) solid var(--rail); background: var(--meter-ground);
  border-radius: calc(6 * var(--px)); box-shadow: 0 calc(6 * var(--px)) calc(16 * var(--px)) var(--shade);
  padding: calc(5 * var(--px)) calc(14 * var(--px)) calc(7 * var(--px));
  text-align: center; width: 100%; box-sizing: border-box;
}
.trump .l {
  font-family: var(--display); font-weight: 900; font-style: italic; font-size: calc(13 * var(--px));
  letter-spacing: 0.1em; text-transform: uppercase; color: var(--white);
}
.trump .v { font-size: calc(56 * var(--px)); line-height: 1; color: var(--red-bright); }
.trump .v.blk { color: var(--white); }
.trump .v.none { color: var(--text-dim); font-size: calc(44 * var(--px)); }
.trump .w {
  font-family: var(--display); font-weight: 600; font-size: calc(13 * var(--px));
  letter-spacing: 0.1em; text-transform: uppercase; color: var(--red-pale);
}
.trump .w.blk { color: var(--text-muted); }
/* Spades change the rules for everyone, so the box says so before anyone reads a label. */
.trump.hot { border-color: var(--yellow); box-shadow: 0 0 calc(18 * var(--px)) #f7e93f66, 0 calc(6 * var(--px)) calc(16 * var(--px)) var(--shade); }

/* ---------- your turn ---------- */
.turnban {
  border: calc(3 * var(--px)) solid var(--red); border-radius: var(--r-card);
  background: var(--feed-ground); padding: calc(7 * var(--px)) calc(26 * var(--px)) calc(10 * var(--px));
  box-shadow: 0 0 calc(26 * var(--px)) #d8262b55;
}
.turnban .t { font-size: calc(28 * var(--px)); line-height: 1; -webkit-text-stroke: calc(2 * var(--px)) #000; }
.turnban .s {
  display: block; font-family: var(--body); font-weight: 700; font-size: calc(14 * var(--px));
  color: var(--white); line-height: 1.2; margin-top: calc(3 * var(--px));
}
/* The table's own stated default. Every screen that blocks play says what happens if you
   do nothing BEFORE the clock runs out, and the card turn is a screen that blocks play. */
.turnban .d {
  display: block; font-family: var(--mono); font-weight: 500; font-size: calc(11 * var(--px));
  color: var(--text-muted); line-height: 1.2; margin-top: calc(4 * var(--px));
}

/* ---------- the countdown ----------
   A depleting bar plus a mono readout, derived from the server's deadline. Three
   channels — width, number, and a red warning state — so neither colour nor motion
   carries it alone. */
.clock { display: block; margin-top: calc(7 * var(--px)); }
/* `display: block` is load-bearing, not tidiness. The bar is a <span>, so without it the
   width and height below were simply ignored and the depleting bar has never drawn —
   the readout beside it was carrying the countdown on its own. */
.clock .bar {
  display: block;
  position: relative; width: calc(220 * var(--px)); height: calc(6 * var(--px));
  border-radius: calc(3 * var(--px)); background: var(--edge-faint); overflow: hidden; margin: 0 auto;
}
.clock .bar i {
  position: absolute; inset: 0 auto 0 0; display: block; border-radius: inherit;
  background: var(--yellow); transition: width 0.25s linear;
}
.clock .secs {
  display: block; font-family: var(--mono); font-weight: 500; font-size: calc(11 * var(--px));
  color: var(--text-body); margin-top: calc(4 * var(--px)); font-variant-numeric: tabular-nums;
}
.clock.warn .bar i { background: var(--red); }
.clock.warn .secs { color: var(--red-pale); }
.turnban.warn { animation: tick 1s steps(1, end) infinite; }
@keyframes tick { 50% { border-color: var(--yellow); } }

/* ---------- feed: play-by-play and chat in one stream ----------
   It is where the game narrates itself, so the panel REMAINS when table chat is off —
   only the input and the chat lines go. It is also the canonical narration for a
   screen reader, which is why it is a polite live region rather than a decorative log. */
.feed {
  position: absolute; left: 0; bottom: 0; z-index: 15;
  width: calc(244 * var(--px)); max-height: calc(238 * var(--px)); box-sizing: border-box;
  background: linear-gradient(90deg, var(--feed-ground), var(--feed-ground-fade));
  border-top: calc(2 * var(--px)) solid var(--edge-strong);
  border-right: calc(2 * var(--px)) solid var(--edge-strong);
  border-radius: 0 calc(10 * var(--px)) 0 0;
  padding: calc(12 * var(--px)) calc(16 * var(--px));
  display: flex; flex-direction: column; gap: calc(6 * var(--px));
}
.feed .fh {
  font-family: var(--display); font-weight: 900; font-style: italic; font-size: calc(16 * var(--px));
  text-transform: uppercase; letter-spacing: -0.01em; color: var(--yellow);
}
.feed .lines { overflow-y: auto; flex: 1; scrollbar-width: thin; display: flex; flex-direction: column; gap: calc(3 * var(--px)); }
.feed p {
  margin: 0; font-family: var(--dense); font-weight: 600; font-size: calc(14 * var(--px));
  line-height: 1.35; color: var(--text-body);
}
.feed p.hot { color: var(--yellow); font-weight: 700; }
/* Punts are the line that most needs to look unlike ordinary play-by-play. */
.feed p.bad { color: var(--red-pale); font-weight: 700; }
.feed p.chat { color: var(--white); }
.feed p.chat b { color: var(--green-pale); }
/* A watcher's line (story 5.6). Two channels, deliberately: the name goes a different
   colour AND the line carries the word "watching" before it, because somebody who cannot
   separate the two greens would otherwise read a spectator's words as a player's. */
.feed p.chat.watching b { color: var(--text-muted); }
.feed p.chat .wtag {
  font-family: var(--body); font-style: normal; font-weight: 700;
  font-size: calc(9 * var(--px)); letter-spacing: 0.08em; text-transform: uppercase;
  color: var(--text-dim); border: 1px solid var(--edge-faint); border-radius: calc(4 * var(--px));
  padding: 0 calc(4 * var(--px)); margin-right: calc(5 * var(--px));
}
.feed .move { color: var(--yellow); font-weight: 700; }
.feed input {
  border: calc(2 * var(--px)) solid var(--edge-strong); border-radius: var(--r-input);
  padding: calc(8 * var(--px)) calc(10 * var(--px));
  font-family: var(--body); font-weight: 400; font-size: calc(14 * var(--px));
  color: var(--white); background: #00000099; width: 100%; box-sizing: border-box;
  min-height: 44px;
}
.feed input::placeholder { color: var(--text-dim); }
/* Chat off: the input goes, the narration stays. */
.feed.nochat input { display: none; }
/* A spectator says things from the bar in the corridor, which is where they are looking —
   so the feed's own input goes, rather than the screen carrying two of them. The LINES stay:
   this is not chat being off, it is chat being somewhere else. */
.feed.spectating input { display: none; }

/* ---------- the phase dialog: ONE shell, four decisions ----------
   handoff § Screens 5 (Bid), 6 (Pick trump), 7 (Sit or play), 8 (Draw).

   Same head, same body, same footer. The footer is the reason this is one shell and not
   four: every decision states the default it resolves to IN WORDS beside the countdown
   to it, and four separately-built screens is how that sentence goes missing from one
   of them.

   They COVER the seats beneath them — when the game demands a decision, that decision is
   the only thing that matters — but the table stays mounted and live underneath. */
.modal {
  position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); z-index: 25;
  width: var(--dw, calc(760 * var(--px))); max-width: calc(1180 * var(--px));
  background: var(--panel); border-radius: var(--r-dialog);
  border: calc(2 * var(--px)) solid var(--edge-dialog);
  border-top: calc(5 * var(--px)) solid var(--rail);
  padding: calc(26 * var(--px)) calc(32 * var(--px)) calc(24 * var(--px));
  text-align: center;
  display: flex; flex-direction: column; gap: calc(20 * var(--px));
  /* The dialog's own lift, plus the overlay scrim as an outer spread rather than a
     second element. A shadow never takes pointer events, so the table underneath stays
     live — the feed keeps scrolling and its chat input keeps working — while still
     reading as an overlay. `.table` clips the spread. */
  box-shadow: var(--lift), 0 0 0 100vmax var(--scrim);
  /* cqh, like the feed cap: this is "fit inside the table's height", not a size. */
  max-height: 88cqh; overflow-y: auto;
}
/* The top rail names the decision before the heading does — the handoff gives each
   phase dialog its own: the bid is a danger, trump is a choice, sit-or-play is an
   invitation to keep playing. Anything unlisted takes the neutral rail. Widths are the
   handoff's own, per screen. */
.modal[data-kind="your-bid"]     { --dw: calc(760 * var(--px)); border-top-color: var(--red); }
.modal[data-kind="everyone-sat"] { --dw: calc(720 * var(--px)); border-top-color: var(--red); }
.modal[data-kind="pick-trump"]   { --dw: calc(800 * var(--px)); border-top-color: var(--yellow); }
.modal[data-kind="sit-or-play"]  { --dw: calc(720 * var(--px)); border-top-color: var(--green-live); }
.modal[data-kind="draw-phase"]   { --dw: calc(900 * var(--px)); border-top-color: var(--yellow); }
/* Seating is not a phase dialog — it is the pre-game table's own panel, and it lays its
   children out in normal flow rather than as a decision's head/body/footer. */
.modal[data-kind="seating"] { display: block; --dw: calc(600 * var(--px)); }

/* The draw screen steps the shell back behind the hand: a lighter scrim, no panel, no
   rail. The handoff is explicit that the hand is the subject here. */
.modal[data-kind="draw-phase"] {
  /* The scrim on the panel as well as spread around it, and square-cornered, so the two
     are one wash. With a transparent panel the dialog cut a lighter hole in its own
     scrim — the exact shape of the box that is supposed to have stepped back. */
  background: var(--scrim-light); border-color: transparent; border-radius: 0;
  box-shadow: 0 0 0 100vmax var(--scrim-light);
}
.modal[data-kind="draw-phase"] .mhead { flex-direction: column; align-items: center; text-align: center; gap: calc(8 * var(--px)); }
.modal[data-kind="draw-phase"] .mh { font-size: calc(44 * var(--px)); }
.modal[data-kind="draw-phase"] .mmeta {
  font-family: var(--body); font-weight: 600; font-size: calc(16 * var(--px)); color: var(--white-soft);
}
.modal[data-kind="draw-phase"] .mhead, .modal[data-kind="draw-phase"] .mrow {
  text-shadow: 0 calc(1 * var(--px)) calc(4 * var(--px)) #000;
}

/* Head: the decision on the left, the state that provoked it on the right. */
.mhead {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: calc(16 * var(--px)); text-align: left;
}
.modal .mh { font-size: calc(38 * var(--px)); line-height: 1; letter-spacing: -0.03em; -webkit-text-stroke: calc(2 * var(--px)) #000; }
.mmeta {
  font-family: var(--mono); font-weight: 500; font-size: calc(13 * var(--px));
  color: var(--text-muted); white-space: nowrap;
}
.mbody { display: flex; flex-direction: column; gap: calc(20 * var(--px)); }
/* The seating panel still uses the old subhead. */
.modal .msub {
  font-family: var(--mono); font-weight: 500; font-size: calc(13 * var(--px));
  color: var(--text-muted); margin-bottom: calc(16 * var(--px));
}

/* The invite, both ways: the code above to read aloud, this to paste. Sized in --px like
   everything else on this panel — the sheet's vmin buttons are built for a full screen and
   would dwarf a dialog. The field takes the room and the button keeps its words on one
   line, so a long origin shortens the box rather than wrapping the control. */
.invrow {
  display: flex; align-items: center; justify-content: center;
  gap: calc(8 * var(--px)); margin-bottom: calc(16 * var(--px));
}
.invrow .invlink {
  flex: 1 1 auto; min-width: 0;
  font-family: var(--mono); font-weight: 500; font-size: calc(13 * var(--px));
  color: var(--white-soft); background: var(--panel-inset);
  border: calc(2 * var(--px)) solid var(--edge); border-radius: calc(6 * var(--px));
  padding: calc(8 * var(--px)) calc(10 * var(--px)); text-overflow: ellipsis;
}
.invrow .btn {
  margin: 0; white-space: nowrap;
  font-size: calc(18 * var(--px)); padding: calc(8 * var(--px)) calc(16 * var(--px));
}

/* ---------- the buttons every dialog is built from ----------
   Declared BEFORE the components that specialise them: `.bidtile`, `.choice` and the
   moment's own `.ovbtn` all carry `.btn` as well, and at equal specificity the later
   rule wins — so a base declared afterwards would take the panel back off every tile
   and put the primary gradient on all six bid numbers. */
.btn {
  display: inline-block;
  background: linear-gradient(180deg, var(--yellow), var(--yellow-deep));
  border: calc(2 * var(--px)) solid var(--yellow-edge); border-radius: var(--r-button);
  padding: calc(10 * var(--px)) calc(26 * var(--px));
  font-family: var(--display); font-weight: 900; font-style: italic; font-size: calc(26 * var(--px));
  text-transform: uppercase; letter-spacing: -0.02em; line-height: 1.2;
  color: var(--ink-btn); box-shadow: var(--lift-primary);
  margin: calc(5 * var(--px)) calc(6 * var(--px)); cursor: pointer;
}
/* The secondary. Keeping your hand, sitting out, taking the smaller loss — first-class
   choices, so they are dark rather than absent. */
.btn.g {
  background: var(--panel-inset); border-color: var(--edge-strong); color: var(--white);
  box-shadow: var(--lift-button);
}
.btn:hover:not(:disabled), .suitbtn:hover:not(:disabled) { transform: translateY(calc(2 * var(--px))); }
.btn:disabled, .suitbtn:disabled { opacity: 0.45; cursor: not-allowed; box-shadow: none; }
.btn:active:not(:disabled), .suitbtn:active:not(:disabled) { transform: translateY(calc(5 * var(--px))); box-shadow: none; }
/* The seating panel's own copy lines. */
.hint { color: var(--text-body); font-family: var(--body); font-weight: 400; font-size: calc(16 * var(--px)); margin-top: calc(14 * var(--px)); }
.hint em { color: var(--red-pale); font-style: normal; font-weight: 700; }
/* A hint that is reporting something that went wrong, in the same red the feed uses for
   a rejection — so "the server said no" reads the same wherever it is said. */
.hint.bad { color: var(--red-pale); font-weight: 700; }

/* A row of choices. Every decision on this shell is one of these. */
.tiles { display: flex; gap: calc(10 * var(--px)); align-items: stretch; }

/* Bid tiles — the number and what that number does, so the qualifier teaches the rule
   at the moment it applies. NOT `.tile`: that name belongs to the felt's suit-pattern
   layer, which is `position: absolute; inset: 0` and swallowed the whole dialog. */
.bidtile {
  flex: 1; min-height: calc(104 * var(--px)); margin: 0; padding: calc(8 * var(--px));
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: calc(2 * var(--px));
  background: var(--panel-inset); border: calc(2 * var(--px)) solid var(--edge);
  border-radius: var(--r-panel); box-shadow: none; color: var(--white);
}
.bidtile .tn { font-family: var(--display); font-weight: 900; font-style: italic; font-size: calc(44 * var(--px)); line-height: 1; }
.bidtile .tq {
  font-family: var(--display); font-weight: 700; font-size: calc(12 * var(--px));
  letter-spacing: 0.14em; text-transform: uppercase; color: var(--text-dim);
}
/* A bid at or below the standing one takes nothing — the round only hands the lead over
   on a strictly higher bid. Dimmed, not disabled: the server accepts it, it simply does
   what passing does, and the client holds no rules. */
.bidtile.dim { opacity: 0.45; }
.bidtile.pick {
  flex: 1.15;
  background: linear-gradient(180deg, var(--yellow), var(--yellow-deep));
  border-color: var(--yellow-edge); box-shadow: var(--lift-primary); color: var(--ink-btn);
}
.bidtile.pick .tn { font-size: calc(52 * var(--px)); }
.bidtile.pick .tq { color: var(--ink-btn); }

/* Trump tiles are CARDS, not buttons — white faces with the card's own black edge. */
.suitbtn {
  position: relative; flex: 1; min-height: calc(168 * var(--px));
  margin: 0; padding: calc(10 * var(--px));
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: calc(4 * var(--px));
  background: var(--card-face); border: calc(3 * var(--px)) solid #000;
  border-radius: var(--r-panel); box-shadow: 0 calc(6 * var(--px)) 0 #00000080;
  cursor: pointer; font: inherit; color: var(--ink);
}
.suitbtn .sgl { font-size: calc(74 * var(--px)); line-height: 1; }
.suitbtn.r .sgl { color: var(--red-card); }
.suitbtn .nme {
  font-family: var(--display); font-weight: 900; font-style: italic;
  font-size: calc(17 * var(--px)); text-transform: uppercase; color: var(--ink);
}
.suitbtn .cnt { font-family: var(--mono); font-weight: 500; font-size: calc(11 * var(--px)); color: var(--card-meta); }
/* Spades takes the sit-out away from the whole table, so the tile says so across its
   own top rather than in a footnote nobody reads inside ten seconds. */
.suitbtn.hot {
  flex: 1.1; border-color: var(--red);
  box-shadow: 0 calc(6 * var(--px)) 0 #00000080, 0 0 0 calc(3 * var(--px)) #d8262b59;
}
.suitbtn .flag {
  position: absolute; top: 0; left: 0; right: 0;
  background: var(--red); color: var(--card-face);
  font-family: var(--display); font-weight: 900; font-style: italic; font-size: calc(11 * var(--px));
  letter-spacing: 0.1em; text-transform: uppercase; text-align: center;
  padding: calc(3 * var(--px)) 0; border-radius: calc(6 * var(--px)) calc(6 * var(--px)) 0 0;
}
.suitbtn.hot .sgl { margin-top: calc(14 * var(--px)); }

/* Sit-or-play and everyone-sat: two tiles, each carrying its own consequence. */
.choice {
  flex: 1; min-height: calc(150 * var(--px)); margin: 0; padding: calc(16 * var(--px));
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: calc(6 * var(--px)); border-radius: var(--r-panel);
}
.choice .tn { font-family: var(--display); font-weight: 900; font-style: italic; font-size: calc(34 * var(--px)); line-height: 1; text-transform: uppercase; }
.choice .tw {
  font-family: var(--body); font-weight: 600; font-size: calc(14 * var(--px));
  line-height: 1.35; text-transform: none; letter-spacing: 0; text-wrap: pretty;
}
.btn.choice .tw { color: var(--ink-btn-soft); }
.btn.g.choice .tw { color: var(--text-muted); }

/* The consequence strip — the game teaching itself at the moment of the decision, with
   the player's own score substituted. */
.strip {
  display: flex; align-items: center; gap: calc(14 * var(--px)); text-align: left;
  background: var(--panel-inset); border: calc(2 * var(--px)) solid var(--edge-faint);
  border-radius: var(--r-button); padding: calc(12 * var(--px)) calc(16 * var(--px));
}
.strip .sk {
  flex: 0 0 auto; font-family: var(--display); font-weight: 900; font-style: italic;
  font-size: calc(15 * var(--px)); letter-spacing: 0.06em; text-transform: uppercase;
  color: var(--red-pale);
}
.strip .sk.gold { color: var(--yellow); }
.strip .sv { font-family: var(--body); font-weight: 400; font-size: calc(15 * var(--px)); line-height: 1.4; color: var(--text-body); }
.strip .sv b { color: var(--yellow); font-family: var(--display); font-weight: 800; font-style: italic; }

/* Labelled facts a decision depends on: the hand, the score, trump. */
.chips { display: flex; gap: calc(10 * var(--px)); }
.chip {
  display: flex; flex-direction: column; align-items: center; gap: calc(3 * var(--px));
  background: var(--panel-inset); border: calc(2 * var(--px)) solid var(--edge-faint);
  border-radius: var(--r-button); padding: calc(12 * var(--px)) calc(14 * var(--px));
}
.chip.wide { flex: 1; align-items: flex-start; text-align: left; }
.chip .k {
  font-family: var(--display); font-weight: 700; font-size: calc(11 * var(--px));
  letter-spacing: 0.14em; text-transform: uppercase; color: var(--text-dim);
}
.chip .v { font-family: var(--mono); font-weight: 500; font-size: calc(14 * var(--px)); color: var(--white-soft); }
.chip .v.big { font-family: var(--display); font-weight: 900; font-style: italic; font-size: calc(24 * var(--px)); line-height: 1; color: var(--yellow); }
.chip .v.suit { font-size: calc(24 * var(--px)); line-height: 1; color: var(--red-hot); }
.chip .v.suit.blk { color: var(--white); }

/* ---------- the footer every decision carries ----------
   The stated default on the left, the server's countdown on the right. The sentence is
   a promise `SafeDefaultDecider` keeps; the bar is the window it keeps it in. */
.mfoot { display: flex; flex-direction: column; gap: calc(6 * var(--px)); }
.mbar {
  height: calc(6 * var(--px)); border-radius: calc(3 * var(--px));
  background: var(--edge-faint); overflow: hidden;
}
.mbar i {
  display: block; height: 100%; width: 100%; border-radius: inherit;
  background: var(--yellow); transition: width 0.25s linear;
}
.mrow {
  display: flex; justify-content: space-between; align-items: baseline; gap: calc(12 * var(--px));
  font-family: var(--mono); font-weight: 500; font-size: calc(12 * var(--px));
}
.mrow .dfl { color: var(--text-muted); text-align: left; }
.mrow .msecs { color: var(--white-soft); white-space: nowrap; }
.mfoot.warn .mbar i { background: var(--red); }
.mfoot.warn .msecs { color: var(--red-pale); }

/* ---------- the draw screen's own furniture ----------
   Cards at the handoff's 118x178, a divider, and the deck they are swapped against. */
.dhand { display: flex; gap: calc(14 * var(--px)); align-items: flex-end; justify-content: center; }
.dhand .cardv { width: calc(118 * var(--px)); height: calc(178 * var(--px)); border-radius: calc(8 * var(--px)); }
.dhand .cardv .rk { left: calc(10 * var(--px)); font-size: calc(44 * var(--px)); }
.dhand .cardv .su {
  top: calc(56 * var(--px)); right: calc(8 * var(--px));
  bottom: calc(6 * var(--px)); left: calc(8 * var(--px)); font-size: calc(70 * var(--px));
}
/* A card marked for the dump says so on its face, in order, so two taps are two
   distinguishable decisions rather than one ambiguous highlight. */
.dump {
  position: absolute; inset: 0; border-radius: calc(5 * var(--px));
  background: var(--dump-scrim);
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: calc(4 * var(--px));
}
.dump b {
  font-family: var(--display); font-weight: 900; font-style: italic;
  font-size: calc(19 * var(--px)); text-transform: uppercase; color: var(--yellow);
}
.dump i {
  width: calc(34 * var(--px)); height: calc(34 * var(--px)); border-radius: 50%;
  border: calc(3 * var(--px)) solid var(--yellow); display: grid; place-items: center;
  font-family: var(--display); font-weight: 900; font-style: normal;
  font-size: calc(19 * var(--px)); color: var(--yellow);
}
/* The one card you are least likely to want to dump, marked before you tap it. */
.ttab {
  position: absolute; top: calc(-11 * var(--px)); left: 50%; transform: translateX(-50%);
  background: var(--yellow); color: var(--ink-btn);
  font-family: var(--display); font-weight: 900; font-style: italic; font-size: calc(11 * var(--px));
  letter-spacing: 0.08em; text-transform: uppercase;
  padding: calc(2 * var(--px)) calc(8 * var(--px)); border-radius: var(--r-badge); white-space: nowrap;
}
.ddiv { flex: 0 0 auto; width: calc(2 * var(--px)); height: calc(178 * var(--px)); background: var(--edge-faint); margin: 0 calc(6 * var(--px)); }
.ddeck { position: relative; flex: 0 0 auto; width: calc(118 * var(--px)); height: calc(178 * var(--px)); }
.ddeck i {
  position: absolute; inset: 0; border-radius: calc(8 * var(--px));
  border: calc(4 * var(--px)) solid #000;
  background: var(--card-back-blue) url(assets/art/card-back.png) center / contain no-repeat;
  box-shadow: 0 calc(8 * var(--px)) calc(18 * var(--px)) #000a;
}
.ddeck i:first-child { transform: translate(calc(-6 * var(--px)), calc(-6 * var(--px))) rotate(-3deg); }
.dactions { display: flex; align-items: center; justify-content: center; gap: calc(12 * var(--px)); }

/* ---------- moment takeovers: punts and Spade Outs only ----------
   handoff § Screens 9 (Punted) and 10 (Spade Out). Full-screen and informational, and
   they CLOSE THEMSELVES on the server's own window — the bar in the footer is that
   window running down. The button is a skip, not the only way out; a player who has
   walked away cannot be allowed to hold the table on a screen that tells them something. */
.ov {
  position: absolute; inset: 0; z-index: 40; display: grid; place-items: center;
  text-align: center; overflow: hidden;
  background: var(--punt-scrim); cursor: pointer;
}
/* The celebration ground, not a scrim: a Spade Out is the rarest win in the game. */
.ov[data-kind="spadeout"] { background: radial-gradient(52% 56% at 50% 42%, #2a7f3fcc 0%, #04120a 100%); }
.ovin { display: flex; flex-direction: column; align-items: center; gap: calc(22 * var(--px)); padding: calc(20 * var(--px)); }
.ovtitle { display: flex; flex-direction: column; align-items: center; gap: calc(4 * var(--px)); }
.ov[data-kind="punt"] .ovtitle { transform: rotate(-3deg); }
.ovh { font-family: var(--display); font-weight: 900; font-style: italic; text-transform: uppercase; paint-order: stroke fill; }
.ov[data-kind="punt"] .ovh {
  font-size: calc(150 * var(--px)); line-height: 0.86; letter-spacing: -0.05em;
  color: var(--white); -webkit-text-stroke: calc(7 * var(--px)) var(--punt-stroke);
}
.ov[data-kind="spadeout"] .ovh {
  font-size: calc(112 * var(--px)); line-height: 0.9; letter-spacing: -0.045em;
  color: var(--yellow); -webkit-text-stroke: calc(5 * var(--px)) var(--ink);
}
.ovsub {
  font-family: var(--display); font-weight: 800; font-size: calc(19 * var(--px));
  letter-spacing: 0.26em; text-transform: uppercase; color: var(--punt-text);
}
.ov[data-kind="spadeout"] .ovsub { color: var(--white-soft); }
.spglyph {
  font-size: calc(96 * var(--px)); line-height: 1; color: var(--ink);
  -webkit-text-stroke: calc(4 * var(--px)) var(--yellow); paint-order: stroke fill;
}

/* The score change, in the direction that matters — this game counts DOWN. */
.scores {
  display: flex; align-items: center; gap: calc(20 * var(--px));
  background: var(--punt-panel); border: calc(2 * var(--px)) solid var(--punt-edge);
  border-radius: var(--r-dialog); padding: calc(18 * var(--px)) calc(26 * var(--px));
}
.scores .cell { display: flex; flex-direction: column; align-items: center; gap: calc(2 * var(--px)); }
.scores .k {
  font-family: var(--display); font-weight: 700; font-size: calc(11 * var(--px));
  letter-spacing: 0.14em; text-transform: uppercase; color: var(--punt-text);
}
.scores b { font-family: var(--display); font-weight: 900; font-style: italic; line-height: 1; font-variant-numeric: tabular-nums; }
.scores .was b { font-size: calc(52 * var(--px)); color: var(--white-soft); }
.scores .now b {
  font-size: calc(74 * var(--px)); color: var(--yellow);
  -webkit-text-stroke: calc(3 * var(--px)) #000; paint-order: stroke fill;
}
.scores .arrow { font-family: var(--display); font-weight: 900; font-style: italic; font-size: calc(38 * var(--px)); color: var(--red-hot); }
.scores .rule { width: calc(2 * var(--px)); height: calc(64 * var(--px)); background: var(--edge-faint); }
.scores .why { display: flex; flex-direction: column; gap: calc(3 * var(--px)); text-align: left; }
.scores .pts { font-family: var(--display); font-weight: 900; font-style: italic; font-size: calc(21 * var(--px)); text-transform: uppercase; color: var(--red-hot); }
.scores .out { font-family: var(--body); font-weight: 400; font-size: calc(15 * var(--px)); color: var(--white-soft); }

/* Five spades, fanned. No ranks: which cards took the tricks is not on the wire. */
.fan { display: flex; gap: calc(10 * var(--px)); }
.fcard {
  position: relative; width: calc(94 * var(--px)); height: calc(142 * var(--px));
  background: var(--card-face); border: calc(4 * var(--px)) solid #000;
  border-radius: var(--r-card); box-shadow: 0 calc(8 * var(--px)) calc(18 * var(--px)) #000a;
  display: grid; place-items: center; transform: rotate(var(--rot, 0deg));
}
.fcard .su { font-size: calc(56 * var(--px)); line-height: 1; color: var(--ink); }

/* The character slot, the skip, and the auto-advance. */
.ovfoot { display: flex; align-items: center; gap: calc(16 * var(--px)); }
.ovslot {
  width: calc(110 * var(--px)); height: calc(110 * var(--px));
  display: grid; place-items: center; overflow: hidden;
}
/* An ABSOLUTE height, not 100%. A percentage height on a replaced element inside a grid
   row that is sized from its own content resolves against the image's intrinsic size
   once it loads — so the 1024px mascot came back at 1024px and covered the takeover. */
.ovslot img {
  height: calc(110 * var(--px)); width: auto; max-width: 100%; object-fit: contain;
  filter: drop-shadow(0 calc(10 * var(--px)) calc(22 * var(--px)) #000c);
}
.ovact { display: flex; flex-direction: column; align-items: stretch; gap: calc(7 * var(--px)); }
.btn.ovbtn { margin: 0; min-height: calc(56 * var(--px)); padding: 0 calc(34 * var(--px)); font-size: calc(24 * var(--px)); }
.btn.ovbtn.white {
  background: linear-gradient(180deg, var(--chrome-top), var(--chrome-bottom));
  border-color: var(--chrome-edge); color: var(--chrome-ink);
}
.ovbar { width: 100%; height: calc(6 * var(--px)); border-radius: calc(3 * var(--px)); background: var(--edge-faint); overflow: hidden; }
.ovbar i { display: block; height: 100%; width: 100%; border-radius: inherit; background: var(--red-hot); transition: width 0.25s linear; }
.ov[data-kind="spadeout"] .ovbar i { background: var(--yellow); }
.ovsecs { font-family: var(--mono); font-weight: 500; font-size: calc(12 * var(--px)); color: var(--punt-text); text-align: center; }
.ov[data-kind="spadeout"] .ovsecs { color: var(--white-soft); }

/* The notice that belongs to no screen (story 6.1).
   Sized in viewport units rather than in `--px`, deliberately: `--px` is a container
   query unit and its container is the felt, which this element is outside of by design —
   it is drawn over the rules screen, the settings, the lobby and the landing just as
   readily as over a table. `position: fixed` for the same reason: it has no positioned
   ancestor to hang off, and it must not scroll away from a reader who was mid-page.

   YELLOW, NOT RED. `.banner` is red because a dead table is dead; this is a condition
   that is being recovered from, and giving the two the same livery would teach a player
   that the recoverable one is fatal. */
.netban {
  position: fixed; left: 50%; top: 2vmin; transform: translateX(-50%); z-index: 100;
  background: var(--banner-ground); border: 0.35vmin solid var(--yellow);
  border-radius: 1vmin; padding: 1.4vmin 3vmin;
  font-family: var(--body); font-weight: 600; font-size: 2.1vmin; color: var(--white);
  text-align: center; max-width: 84vw; box-shadow: 0 0.6vmin 2vmin #000000aa;
}

/* A dead table must look dead. */
.banner {
  position: absolute; left: 50%; top: 46cqh; transform: translateX(-50%); z-index: 45;
  background: var(--banner-ground); border: calc(3 * var(--px)) solid var(--red);
  border-radius: var(--r-button); padding: calc(14 * var(--px)) calc(30 * var(--px));
  font-family: var(--body); font-weight: 600; font-size: calc(18 * var(--px));
  text-align: center; max-width: 70cqw; box-shadow: var(--lift);
}

/* ---------- cards physically move ----------
   The deal travels seat to seat, a played card slides to the centre, and the winner
   sweeps the pile. All three are driven by --dx/--dy set in table.js from the seat's
   ring coordinates, so one keyframe serves every seat rather than six hand-authored
   paths. */
@keyframes dealIn {
  from { transform: translate(var(--dx, 0), var(--dy, calc(-40 * var(--px)))) scale(0.55) rotate(var(--rot, 0deg)); opacity: 0; }
}
.cardv.dealt, .backv.dealt { animation: dealIn 0.38s cubic-bezier(0.22, 0.61, 0.36, 1) both; }

/* A played card starts at its owner's seat and lands in the trick. Travelling from the
   seat rather than fading up in place is the whole point — it is what tells you WHO
   just played without reading the feed. */
@keyframes playIn {
  from { transform: translate(var(--dx, 0), var(--dy, calc(18 * var(--px)))) scale(0.8); opacity: 0.25; }
}
.trick .cardv { animation: playIn 0.34s cubic-bezier(0.22, 0.61, 0.36, 1) both; }

/* The winner takes the pile. Runs on the outgoing nodes before the trick clears. */
@keyframes sweepOut {
  to { transform: translate(var(--dx, 0), var(--dy, 0)) scale(0.5) rotate(var(--rot, 0deg)); opacity: 0; }
}
.trick.sweeping .cardv { animation: sweepOut 0.4s ease-in both; }

/* The dealer's flight deck: card backs in transit live here, above the felt and below
   the seats, and are removed as soon as they land. */
.flight { position: absolute; inset: 0; z-index: 10; pointer-events: none; }

/* prefers-reduced-motion: every motion resolves instantly to its end state. No
   information is carried by motion alone, so nothing is lost by removing it. */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
  }
  .puck { transition: none; }
}
