/* Snarps — the visual spine.
   Visual authority: _bmad-output/planning-artifacts/ux-designs/design_handoff_snarps_ice/README.md
   Spine and precedence: .../ux-Snarples-2026-08-14/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.

   ONE SURFACE. Ice. There is no felt, no tile, no picker and nothing anywhere in this
   file or in the client asks which surface is showing, because there is only one.

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

   The ICE handoff is authored against the same 1280x720 frame the felt one was: every
   one of its thirteen screens is a `width:1280px; height:720px` box with the rink drawn
   edge to edge inside it, under a 6px `#e9ece6` rail and a 14px radius — which are the
   `.table` border and `--r-table` below. So the divisor did not move and every number in
   this file still reads against the handoff's own. `--px` is the length that one of its
   pixels takes on this table.

       --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 sheet 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 § 2 Palette, verbatim ----------
     Ice is the palette, not a background that replaced one. Light ground, dark type,
     blue lines as structure, the dasher board as chrome — so the tokens below are the
     handoff's own rows in the handoff's own order, and everything after them either IS
     one of these or is named as a reference to one. */
  --ice-top: #f6fafd;          /* sheet gradient start */
  --ice-mid: #dde9f5;          /* sheet gradient 62% */
  --ice-base: #cddef0;         /* sheet gradient end */
  --blue-line: #3a5fd0;        /* blue lines, at 88%; and centre ice, see --centre-ice */
  --red: #d8262b;              /* centre red line, goal lines, neutral-zone faceoff spots; also punt and danger */
  --circle: #93aee0;           /* the washed rink behind the reading screens, and the rules panel's accent */
  --boards: #151a22;           /* headers, list chrome */
  --panel: #10141c;            /* jumbotron, dark cards */
  --rail: #e9ece6;             /* white cap rail, screen border */
  --yellow: #f7e93f;           /* primary accent, scores */
  --yellow-lit: #ffe95c;       /* primary button gradient, start */
  --yellow-deep: #e2c910;      /* primary button gradient, end */
  --green-live: #3ad14a;       /* seat card border */
  --green-solid: #1aa832;      /* "waiting" / toggle on */
  --ink: #10141c;              /* type on ice */
  --ink-2: #22364d;            /* body copy on ice */
  --ink-3: #46617f;            /* labels on ice */
  --ink-4: #3d5573;            /* mono meta on ice */
  --muted: #8397ab;            /* disabled, secondary */
  --muted-deep: #5c748c;
  --panel-text: #dfe9f4;       /* type on dark panels */
  --panel-text-2: #8fa6bd;
  /* The three dim layers, § 2's last line. */
  --dim-modal: #0b1c2f7a;
  --dim-wash: #eaf2f9cf;
  --dim-punt: #3b0c0ea8;

  /* ---------- the component literals: handoff § 4 ----------
     Values the palette does not carry because they belong to one part. Each is cited
     where it is used and defined only here. */
  /* CENTRE ICE TAKES THE BLUE LINE'S OWN BLUE (story 12.15). The owner supplied
     `center_ice.png` and said the game should look like it, and the picture settles this
     without a judgement call: sampled, its blue lines and its centre ice faceoff circle and
     spot are the SAME ink, `#0039a6` in all three places. So this is a reference to
     `--blue-line` rather than a second blue — one rink, one blue — and the pale `--circle`
     that used to draw centre ice stays where it still belongs, on the washed rink behind the
     reading screens and on the rules panel's accent.
     What it buys, measured: `#93aee0` on the sheet's mid stop `#dde9f5` is 1.82:1, which is
     what story 12.8 had to spend a 14% interior wash to survive. `#3a5fd0` on the same ground
     is 4.59:1 — over the 3:1 a drawn mark needs — so the wash could go and the circle is the
     unfilled ring the reference draws. See § centre ice below. */
  --centre-ice: var(--blue-line);
  /* The white dashes along the centre red line (story 12.15). PAINTED white rather than left
     as gaps for the sheet to show through, and the reference is why the difference matters:
     its ice is `#faffff`, so a gap in the paint there IS white — ours runs `#f6fafd` to
     `#cddef0`, and the same gap on our sheet would read as a row of pale blue notches at the
     ends of the line and darker ones toward the boards. The dash is a mark, so it is drawn. */
  --rink-dash: #ffffff;
  --seat-card: #0d1116;        /* the seat card's body and its Snarps strip */
  --seat-hard: #16324e;        /* its hard offset — a shadow on ice is cold, not black */
  --seat-ambient: #0f2a4a3d;   /* and its soft ambient */
  --seat-caption: #2b3a4d;     /* the status caption below the card */
  --track: #2a3546;            /* a countdown track, under the yellow fill */
  /* What a menu stands on. Named `--jumbo-lift` until story 13.3 abandoned the jumbotron;
     the SHADOW was never the problem and is unchanged in value, but a token carrying the
     name of a fitting nobody may build again is how the fitting comes back. */
  --menu-lift: 0 calc(18 * var(--px)) calc(40 * var(--px)) #00000073;
  /* Its halo. The caption is printed straight onto the sheet and the sheet has a blue
     line running through it, so the letterforms carry a pale ring that is the ground
     whatever is actually behind them — see `.seat .sub`. Not a fourth ink: it is the
     card face at an alpha, so it reads as light rather than as a second colour. */
  --seat-caption-halo: #ffffffcc;
  --seat-name-stroke: #0e1b2b; /* the name label's stroke above it */
  /* The seat card's diagonal hatch, RinkSeat.dc.html's two stripes over the dark ground. */
  --panel-hatch-a: #1b2431;
  --panel-hatch-b: #212c3b;
  /* ---------- THE BORDER READS AGAINST THE ICE (story 12.25) ----------
     THE SEAT CARD'S TWO STATE COLOURS, AND WHY THEY ARE TOKENS OF THEIR OWN. The border was
     `--green-live` in the hand and `--muted` sitting out. Measured off the pixels against the
     ice as story 12.21 left it, those came to 1.45..1.50:1 and 2.16..2.24:1 on the darkest
     plain ice a seat card stands on — both under the 3:1 this product holds a drawn mark to,
     and the defect story 12.9 reported every run and was not authorised to fix.

     They could not be fixed by moving the palette rows, and that is why these exist. Both of
     those tokens have other jobs on other grounds: `--green-live` is a chat author's name on
     the feed's near-black ground and the sit-or-play panel's lit strip, and `--muted` is
     `--text-dim`, a switch's off track and a glass card's rail. Taking either DOWN to 3:1 on
     pale ice takes it down against those dark grounds too, which is the same defect moved
     rather than fixed. So the seat card's states are their own two rows, and the palette rows
     keep their own values and their own jobs.

     THE NUMBERS, AND THE BAND THEY HAD TO LAND IN. A seat card's border is drawn against two
     grounds at once — the ice on its outside and the card's own near-black hatch on its
     inside — and those two pull in opposite directions, so there is exactly one band of
     luminance that satisfies both. Against the darkest plain ice a card stands on (#cfdfed on
     the first sweep of four frames, relative luminance 0.7216) a border needs L <= 0.2064 to
     reach 3:1 — the check's own three frames find #cddced, #d0deee and #d2e0ef, all a shade
     lighter, so this is the strict end of the range; against the hatch's
     darker stripe (#1b2431, L 0.0172) it needs L >= 0.1497. Both of these sit in the middle
     of that band, at L 0.185 and 0.184, and `--red` — which is not moved, because it is the
     rink's own red and the goal lamp's — is already in it at L 0.161.

       in the hand     #108a37   3.19..3.32:1 on the ice   3.51:1 on the hatch   (was 1.45..1.50)
       sitting out     #66798d   3.21..3.34:1              3.49:1                (was 2.16..2.24)
       your turn       --red     3.56..3.70:1              3.15:1                unchanged

     The ice figures are RANGES because they are measured rather than derived: the worst ice
     under a card is #cddced on the wide sheet, #d0deee on the phone and #d2e0ef on a tall
     tablet, and `groundComplaints` takes each frame's own. The hatch figures are arithmetic
     against `--panel-hatch-a` and are the same three 12.9 stated, recomputed.

     AND THEY ARE STILL THREE DIFFERENT SIGNALS: 189, 295 and 312 apart summed over the three
     channels, against the floor of 60 `browser-client.mjs` holds them to. Green is still in
     the hand, slate is still out, red is still your turn — the hues moved for the ground and
     the vocabulary did not. `groundComplaints` in `browser-client.mjs` measures all of it off
     a photograph, including the grounds these could NOT be tuned against; see § THE SEAT
     CARD'S STATES for that arithmetic, which is the reason this is a fix and not a claim. */
  --seat-in: #108a37;          /* the seat card's border, in the hand */
  --seat-out: #66798d;         /* and sitting out */
  /* WHO IS IN THE HAND is said by WHERE A SEAT STANDS and by the word under its card, and by
     nothing else. Six tokens stood here — `--pool-rim`, `--spot-core`, `--spot-mid`,
     `--spot-edge`, `--spot-bloom` and, before them, `--house-dim` — and every one of them
     lit a seat. Story 12.9 took the last of them out on the owner's direction; see § THE SEAT
     CARD for the rule they were serving and what carries it now. */
  --glass-hard: #b3c6da;       /* the glass card's hard offset */
  --glass-ambient: #12304f26;  /* and its ambient */
  --glass: #ffffffec;          /* the glass card's own fill (handoff § 4 Chrome) */
  --glass-edge: #ffffff;       /* and its edge */
  --card-face: #ffffff;
  --white: #fdfdfa;            /* the seat name label */
  --yellow-edge: #6b5c00;      /* primary button border */
  --yellow-hard: #8d7a00;      /* primary button base shadow */
  --ink-btn: #11140f;          /* text on a yellow primary */
  --chrome-top: #ffffff;       /* secondary button gradient, start */
  --chrome-bottom: #c8d3de;    /* and end */
  --chrome-edge: #55606c;      /* its border */
  --chrome-hard: #7d8b99;      /* its base shadow */
  --chrome-ink: #14181a;       /* and its text */
  --on-dark: #232c38;          /* the on-dark button kind */
  --on-dark-edge: #46566a;
  --quiet-edge: #7e93ab;       /* the on-ice quiet kind's border */
  --danger-ink: #ff9a9e;       /* danger text, on a dark ground */
  /* The danger kind's own fill, § 4's button table. Declared here for the first time
     because until `3l` was built there was nowhere a danger button stood on a dark panel:
     the two elements wearing `.chrome.red` are both on ice, where a near-black fill would
     be a hole punched in the sheet, so they carry the hue in their TYPE and this fill
     belongs to the jumbotron alone. */
  --danger-fill: #3a1113;
  --dashed-edge: #9fb3c9;      /* the empty-slot kind */
  /* The browse card's occupancy blocks, § 3b: a taken chair and a free one, ON GLASS.
     Neither is a re-use of a palette row — the deep blue is the same family as the sheet's
     own lines and the pale one is the ice under them, which is what makes six blocks read
     as six places on a rink rather than as six switches. */
  --occ-taken: #1b3a6b;
  --occ-free: #c9d8e6;
  /* The create screen's inset row, § 3c: the ice one shade down, for a group of controls
     standing INSIDE a glass card. Not a second white — two whites with a hairline between
     them is a seam, not a panel. */
  --ice-inset: #f1f6fb;
  --ice-inset-edge: #d3e0eb;
  /* The chosen option's fill. `--yellow` at an alpha low enough that ink still reads on it
     — the border and the weight are the loud two channels, and this is the third. */
  --yellow-wash: #f7e93f2e;
  --rink-inset: #2b4a7526;     /* the sheet's own inset, Rink.dc.html */
  --rink-sheen: #ffffffee;     /* its white sheen */
  --rink-gloss: #ffffff5c;     /* its diagonal gloss */
  --puck-orange: #e2521a;
  /* ---------- the goal lamp (story 12.4) ----------
     THE LAMP IS ITS OWN RED AND IT HAS TO BE. `--red` above is already spoken for on a seat
     card: it is the border and glow of YOUR turn, and the spec for this story says the seat
     that took the trick, the seat whose turn it is and the seats that are in the hand must
     stay three tellable-apart readings. So the lamp is a hotter, orange red with no blue in
     it at all — `#ff4500` against `#d8262b` is 39 + 31 + 43 = 113 of channel distance, which
     `browser-client.mjs` measures off the PAINTED pixels rather than trusting this line.

     And the hue is only half of it: the readings differ in KIND. Your turn is a 4 --px border
     and being in the hand is a 4 --px border in another colour; the lamp FLOODS the card and
     throws a halo no state on it is allowed to. A border is not a flood, and since story 12.9
     that sentence is the whole of the difference rather than one of two — both of the other
     readings had a glow of their own until the owner asked for them to come off. */
  --goal-lamp: #ff4500;        /* the lamp itself */
  --goal-lamp-hot: #ff5a14;    /* the filament at the middle of the glass */
  --goal-lamp-deep: #b32700;   /* its falloff at the corners of the card */
  --goal-lamp-glow: #ff450099; /* what it throws onto the ice around the card */
  --card-back-blue: #1c3f8f;   /* the field the card-back art sits on */
  --ink-btn-soft: #3a3200;

  /* ---------- what the long-standing names mean now ----------
     REFERENCES, NOT SECOND DEFINITIONS. Every one of these resolves to a row above; the
     names are kept because they say what the value is FOR, and because a rename would
     touch nineteen hundred lines to say nothing. Type on ice takes an ink; type on a
     dark panel takes a panel text — DESIGN.md § Colours names picking the wrong one as
     the likeliest contrast failure in this direction, so the two are never interchanged. */
  --panel-inset: var(--seat-card);
  --panel-shadow: var(--seat-hard);
  --text-body: var(--panel-text);
  --text-muted: var(--panel-text-2);
  --text-dim: var(--muted);
  --red-card: var(--red);
  --red-bright: var(--red);
  --red-pale: var(--danger-ink);
  --red-hot: var(--danger-ink);
  --seat-taken: var(--green-solid);
  --cut-blue: var(--blue-line);
  --card-meta: var(--muted-deep);
  --white-soft: var(--panel-text);
  --shade: var(--seat-ambient);
  --shade-soft: var(--glass-ambient);
  --ambient: var(--rink-inset);

  /* Alphas of the tokens above. A dark card standing on ice is `--panel` at an alpha,
     never a second hue: black over ice reads as a hole, and a hue that is nearly the
     panel's is two panels that fail to line up. */
  --meter-ground: #10141ccc;
  --feed-ground: #10141ce6;
  --feed-ground-fade: #10141ca6;
  --banner-ground: #10141cf0;
  --edge-faint: #ffffff26;
  --edge: #ffffff33;
  --edge-strong: #ffffff40;
  --edge-meter: #ffffffaa;
  --edge-dialog: #ffffff2e;
  --scrim: var(--dim-modal);
  --scrim-light: var(--dim-wash);   /* 3g Draw: a flat ice wash, the hand is the subject */
  --punt-scrim: var(--dim-punt);
  /* The score panel that stands ON the punt wash, § 6 3h: a near-opaque `--panel`, NOT a
     second coat of the wash. It was `--dim-punt` — the same #3b0c0ea8 as the ground behind
     it — so the panel and its ground differed by one alpha multiply and every figure in it
     was set on a translucent red. The handoff draws it as a dark card with a red edge, and
     a dark card is what makes the yellow score and the red arrow legible. */
  --punt-panel: #10141ce6;
  --punt-edge: #ff9a9e59;
  --punt-text: var(--danger-ink);
  --dump-scrim: #10141cd9;

  /* ---------- type: handoff § 3 ----------
     THREE JOBS, THREE FACES. Archivo is display and UI labels, Barlow is body copy, and
     JetBrains Mono is anything numeric or meta.

     BARLOW IS BACK, and why it left is the point. Story 10.1 dropped Barlow and Barlow
     Condensed from this file and from the font request in one commit and left `--body`
     pointing at Archivo — so every sentence in the client was set in the display face at
     a lighter weight, which is a heading pretending to be a paragraph. Archivo is a
     grotesk drawn to be shouted at 900 italic behind a stroke; Barlow is the one drawn to
     be read at 15px for a paragraph at a time. One family carrying both is a saving on
     the font request and a loss of the difference between a title and a sentence.

     BARLOW CONDENSED IS NOT BACK. Nothing in this file sets it, and a face requested that
     nothing sets is the other half of the same defect — see the font request in
     index.html, which now asks for exactly the four families' worth of weights the rules
     below actually set.

     THE WORDMARK HAS ITS OWN TOKEN, and that is a boundary rather than a convenience. See
     `.wordmark` for the whole argument; the short version is that Sarpanch belongs to six
     letters at 136px and to nothing else, while `--display` has roughly forty sites. */
  --display: "Archivo", "Arial Black", "Segoe UI", sans-serif;
  --body: "Barlow", system-ui, -apple-system, "Segoe UI", sans-serif;
  --mono: "JetBrains Mono", ui-monospace, "Cascadia Mono", Consolas, monospace;
  /* ONE ELEMENT WEARS THIS. Kept beside the other three because it is a type token and
     this is where type tokens are declared — not because it is a fourth general face.
     A second `var(--wordmark)` site is a decision somebody has to take deliberately. */
  --wordmark: "Sarpanch", "Archivo", "Arial Black", sans-serif;

  /* ---------- the unit ----------
     The landscape sheet, authored against 1280 x 720: `0.078125cqw` is 1/1280th of the
     table's width and `0.138889cqmin` is 1/720th of its height at every aspect the app
     admits, so the smaller of the two is "one design unit of the frame that fits".

     THE TALL FRAME HAS ITS OWN, and it is the same sentence with the axes swapped — see
     § THE TALL FRAME at the foot of this file, where every portrait rule lives together. */
  --px: min(0.138889cqmin, 0.078125cqw);

  /* ---------- THE CAMERA, AND THE ONE SCALE IT LOOKS THROUGH (story 12.21) ----------

     THE SECOND UNIT ON THIS SHEET, AND IT HAS A DIFFERENT JOB FROM `--px`. `--px` is one
     handoff pixel: it sizes the FURNITURE — cards, seats, the pile, the chrome, every
     radius and shadow — and it is a share of the authored 1280x720 frame. `--ft` is one
     FOOT OF REAL ICE: it places the RINK, and it is a share of a 200 x 85 ft sheet.
     Neither is derivable from the other and neither is redundant, because the two are
     measuring different things — a card is 96 design units wide whatever rink it is lying
     on, and a blue line is 25 feet from centre ice whatever screen it is drawn on.

     THE RINK IS NOT DRAWN TO FIT THE WINDOW. It is real ice, 200 ft by 85, and this is a
     CAMERA looking at it: a point on the sheet and a scale in pixels per foot. What the
     window's shape changes is HOW MUCH ICE IS VISIBLE — a wider window sees further along
     the rink, a taller one further across it — and never what shape the rink is. Until
     this story `.rink` was `inset: 0` inside a table of `100% x 100%`, so the ice was
     whatever shape the window happened to be: at the owner's 1276x1262 window the sheet
     drew a SQUARE rink, and every marking on it was still at the regulation share of a
     box that was 2.353 times the wrong shape.

     WHERE THE CAMERA IS POINTED. Two numbers, in FEET, from the middle of the rink:
     `--cam-l` along its 200 ft length and `--cam-w` across its 85 ft width. THIS IS THE
     ONE PLACE TO CHANGE TO PUT THE GAME ON A DIFFERENT FACEOFF SPOT, which is the owner's
     stated next step — "we are using centre ice first, get it right and we will do
     faceoffs at other locations too". Every one of the nine spots, in these coordinates:

       centre ice                 0, 0
       neutral zone               -20 or +20,  -22 or +22
       end zone                   -69 or +69,  -22 or +22

     Nothing else moves. The rink is one piece of geometry and the whole of it hangs off
     these two numbers, so pointing the camera somewhere else is a change of value and not
     a second drawing job.

     THE SCALE FALLS OUT OF THE BOARDS. The camera may not show ice that does not exist,
     so the scale is the smallest that still keeps the frame full of rink: the length has
     `100 - |--cam-l|` feet of ice to its nearer end board and the width has
     `42.5 - |--cam-w|` to its nearer side board, and `--ft` is whichever of those two
     bounds binds. Written as `min(100 - l, 100 + l)` rather than with `abs()` so it needs
     no recent CSS function to be correct.

     ---- AND STORY 13.8 TOOK THE CAMERA BACK OUT OF THE SCALE, WHICH IS THE ONE THING FR-30
     COULD NOT BE BUILT WITHOUT ----

     "The camera may not show ice that does not exist" is the sentence above, and it is what
     the two `min()`s in the old `--ft` enforced. It cannot survive a camera that MOVES, and
     the arithmetic says so in one line: at an end-zone spot the camera stands 69 ft from
     centre and 31 from the end board, so a frame that had to be full of rink toward that
     board needed `50cqmax / 31` instead of `50cqmax / 100` — a 3.2x zoom. The width is worse
     and it is worse for a reason that is structural rather than incidental: a regulation
     sheet is 1 : 2.353 and every window this app admits is squarer than that (the squarest
     is 1:1 and the longest a 9:19.5 phone at 2.16), so the WIDTH term binds `--ft` at every
     single one of them — the short axis is exactly 85 ft of ice with nothing spare, and a
     camera 22 ft across it needed `50cqmin / 20.5`, a 2.07x zoom. Measured at 1280x720 that
     puts the faceoff circle's own clearance at 372 --px against the 360 the sheet has, so
     the players no longer fit on the sheet they are standing on.

     Zooming was already refused by the owner ("everything shrinks toward the 11px floor")
     and so was distorting. What is left is the third answer: THE SCALE STOPS READING THE
     CAMERA. `--ft` is now the scale at which a CENTRED rink fills the frame, and it is the
     same number at every one of the nine spots — so panning changes what you are looking at
     and never how big anything is. What it costs is stated rather than hidden: at an
     off-centre spot the frame shows a board and the rail beyond it, which is drawn art that
     already exists (`.rink`'s own 30 ft of `--rail`) and is what a camera pointed at the
     corner of a real rink sees. `--cam-fill` keeps what `--cam-lead` used to mean — the larger of the two
     shares the principal point leaves — and is now a per-FRAME constant rather than a term
     the camera can move.

     WHERE THE CAMERA WENT INSTEAD: `.rink` is translated by `-(cam x ft)`. One composited
     transform on one element, and every one of the 44 markings rides it without knowing.
     The pile, the faceoff and the bench do not move at all — they are drawn about the
     principal point, and the principal point IS the drop, which is the whole reason the
     layout arithmetic written in epic 12 survives a camera that moves.

     `cqmax` is the sheet's LONG axis and `cqmin` its short one, which is the whole of the
     orientation rule: a regulation rink is a 1 : 2.353 portrait shape, so its LENGTH runs
     along whichever of the sheet's own axes is the longer one (story 12.3). One
     declaration, no branch — the container query below turns which CSS property each
     coordinate is written to, and never how big a foot is.

     WHERE THE CAMERA'S CENTRE IS DRAWN, which is a LAYOUT decision and not an ice one: the
     principal point is `(50%, var(--trick-y))`, so the trick pile sits on the spot the camera
     is looking at by construction. On a wide sheet `--trick-y` is on the rink's WIDTH, which
     has no slack — 42.5 ft to either board — so it is 50cqh and cannot be anything else. On a
     tall sheet it is on the rink's LENGTH, where there are 100 ft either way against the ~57
     the window shows, so it is free for the layout to choose and it is 48cqh (see § THE TALL
     FRAME for what that buys). `--cam-fill` is the larger of the two shares that leaves, and
     it is what stops an off-centre principal point quietly showing ice past an end board. */
  --cam-l: 0;
  --cam-w: 0;
  /* The faceoff spot this hand is being played at, in the same feet — which is where the
     enlarged drop MARK is drawn, and is not always where the camera is pointed. See § THE NINE
     SPOTS for the two corrections that separate them and the measurements that forced both. */
  --spot-l: 0;
  --spot-w: 0;
  /* WHICH SIDE OF THE RINK’S WIDTH THE SPOT IS ON: -1, +1, and -1 at centre ice where either
     would do. The deck stands one pile-clearance off the pile and hangs on the side AWAY from
     this, so a pile travelling toward one long edge does not push the deck off the sheet ahead
     of it — measured: without it the deck left the ice in 30 of 70 readings at both upright
     viewports. A sign rather than a branch, because a stylesheet cannot branch, and it belongs
     in the camera table because it is a fact about the spot. */
  --spot-side: -1;
  /* THE SCALE'S OWN SHARE, AND IT IS NOT `--cam-lead`. It was, until story 13.8 gave
     `--cam-lead` the job AD-15 named it for — a per-spot pull-back in FEET, see § THE NINE
     SPOTS. Two different quantities under one name is a landmine: the spot table writes 0 or 18
     into it, and the scale would have read 0.18cqmax at an end-zone spot. Renamed rather than
     reused, and the per-frame value stays exactly what it was (50 wide, 52 tall). */
  --cam-fill: 50;
  --cam-lead: 0;
  --ft: max(calc(var(--cam-fill) * 1cqmax / 100), calc(50cqmin / 42.5));

  /* Where the trick pile's centre sits on the sheet. ONE declaration, used by the pile
     itself and by the rink's faceoff circle. A container unit inside a custom property
     resolves at the point of USE, so this is 50% of the table's height for every
     descendant of `.table` even though `:root` is outside it.

     AND IT IS THE CAMERA'S OWN CENTRE NOW (story 12.21). It used to be 41cqh wide and
     50cqh tall, and the wide sheet's 41 was bought to leave room below the pile for the
     corridor. With a real camera the drop IS the point the camera is looking at, and the
     point the camera is looking at is the middle of what the window shows — so the two
     collapsed into one number, and this is it. Story 12.13 refused to move this to 50cqh
     because "the pile ran into the corridor"; that refusal was measured on a STRETCHED
     rink and has been re-derived rather than inherited. What it costs is that the wide
     sheet's boards lines spread FURTHER apart (their spacing is the head-end room, which
     grew from 147.04 to 211.84 design units) and the arc stands where it stood; what it
     buys is that both side boards are on screen at once instead of one of them being 13
     ft off the top, which is what `NHL_Hockey_Rink.png` looks like.

     THIS IS THE ONE DECLARATION. `.rink .circle`, `.rink .spot`, `.rink .drop`, `.centre`,
     the two `--decide-*` bounds and table.js's `lineCeil` and card-flight target all read
     it rather than restate it. */
  --trick-y: 50cqh;

  /* ==========================================================================
     § THE HAND, AND THE BOX NOTHING MAY COVER (story 13.4, FR-6 as amended)

     > No UI element may ever cover the player's hand.

     Stated as a RULE and not as a placement, because a placement is renegotiated every time
     the layout changes and this one has been renegotiated enough. It replaces and subsumes
     the two corridor invariants that preceded it — *trump is never overlapped*, *the hand is
     centred* — which were rules about one fixture and one axis when the thing that actually
     mattered is that you cannot play a card you cannot see.

     WHAT "THE HAND" MEANS, AND IT IS NOT `#hand`. The protected box is the union of
     everything in the hand band at its MAXIMUM RAISED EXTENT:

       the cards themselves;
       the requirement line above them — *"2 more or punted"* — which is the sentence that
         says whether the hand is still winnable (`.handreq`, 12 --px above the cards);
       the must-win line that replaces it (`.mustwin`, the same band);
       the RAISED position of a card, not its resting one. A card lifts 4 --px on hover and
         10 when it is picked or armed, and the pick carries a `0 0 0 8 --px` ink backing
         outside its own border box — so the highest a card ever paints is 18 --px above where
         it rests. A menu that cleared the hand at rest would be covered by the hand itself
         the moment a player reached for a card;
       and for a WATCHER there is no hand at all: the spectator bar occupies the corridor in
         its place, so the rule protects the corridor's occupant, whichever it is.

     `--hand-reach` is the seated case written down — the corridor's own 16 --px rail plus the
     145 --px a card is — and `--card-raise` is the lift plus its painted backing. Together
     they are the DECLARED floor. table.js measures the real thing every render and writes
     `--guard-top` over it (see `measureGuard`), because a declared box the right size can
     still paint outside itself and because a watcher's bar is content-shaped. The declared
     value is what stands when nothing is on screen to measure, so a menu can never grow into
     the corridor while waiting to be told where it is.

     THE GAP IS A STATED NUMBER, FLOORED WELL ABOVE ZERO. 24 design units — 11.2 real px at
     1600x900, 12.8 on a 390x844 phone. A one-pixel clearance is a rounding error wearing a
     rule's clothes, and this project has already shipped a "0px clearance is not a clearance"
     fix once. ========================================================================== */
  --card-w: 96;
  --hand-gap-n: 9;
  --hand-cards: 5;
  --hand-half: calc((var(--hand-cards) * var(--card-w)
                     + (var(--hand-cards) - 1) * var(--hand-gap-n)) * var(--px) / 2);
  --trump-w: calc(96 * var(--px));
  --trump-gap: calc(16 * var(--px));
  --card-raise: calc(18 * var(--px));
  --hand-reach: calc(161 * var(--px));
  --guard-gap: calc(24 * var(--px));

  /* ---------- the head-end band, wide (story 12.13) ----------
     The same three tokens the tall frame declares, and for the same readers: table.js's
     `build` reads `--chrome-reach` for the spacing its boards lines can take, and the chat
     box below reads all three. They are HERE rather than counted in table.js because the
     cluster's own reach is not a whole number of design units — the gear and EXIT are
     `<button>`s carrying the client's 44 REAL pixel hit-target floor, which does not scale
     with the table, so the cluster reaches 74 --px at 1600x900 and 79.4 at 900x900.

     THE HEAD USED TO CARRY THE CHAT BOX AND DOES NOT ANY MORE (story 13.5). Story 12.13 put
     the box in the head-centre band, between the two corners, and this block used to hold its
     head and its foot as `--chat-head` / `--chat-foot`. The wide sheet's feed now stands at
     the FOOT, right of the hand — see § the feed for the measurement that chose the corner —
     so the only thing at the head of a wide sheet is the cluster, and `--chrome-reach` is the
     whole of what anything at that end is held off. */
  --chrome-reach: max(calc(74 * var(--px)), calc(16 * var(--px) + var(--hit-target)));
  /* ---------- the wide sheet's feed, at the foot (story 13.5) ----------
     `--chat-rail` is its distance from the FOOT edge, `--chat-cap` how far it grows UP from
     there, and `--chat-reach` the two together — the same three quantities the tall frame
     names, read off the other end of the sheet.

     HOW FAR IT GROWS. 180 --px is the cap the tall frame gives it, and on a wide sheet that
     cap is not always the binding one: what closes the band from the drop's side is the
     faceoff CIRCLE, whose radius grows with the frame. So it is a `min()` of the two, which is
     the same shape `--chrome-reach` is and for the same reason — one term is a share of the
     sheet and the other is a count of design units. `--trick-y` is 50cqh in this composition
     (the camera's principal point is on the rink's WIDTH here, which has no slack), so the
     half-sheet below the drop is the same distance as the half above it and the arithmetic is
     unchanged in value from the head-end version it replaces.

     AND IT IS WHERE STORY 12.13's ONE TYPO COST AN AFTERNOON: the first cut left the closing
     marker of the paragraph above in place and opened a second one, so three lines of prose
     sat loose inside `:root`. CSS error recovery swallowed the declaration under them, `--chat-cap`
     became guaranteed-invalid, `max-height` fell back to `none`, and the feed grew until the
     hand overlapped it at six-handed. `layout-check.mjs` said "hand overlaps feed" at exactly
     the mixes with the most feed lines, which is what a cap that stopped capping looks like
     from the outside. Nothing about the rink was wrong; a comment was. */
  --chat-rail: calc(16 * var(--px));
  --chat-cap: min(calc(180 * var(--px)),
                  calc(100cqh - var(--trick-y) - 15 * var(--ft) - var(--chat-rail) - 12 * var(--px)));
  --chat-reach: calc(var(--chat-rail) + var(--chat-cap));

  /* ---------- radius: handoff § 4 ---------- */
  --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: handoff § 4 ----------
     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 sheet, and DESIGN.md
     names it the single most identity-carrying decision in the system — the one thing
     that survived the change of surface unaltered in SHAPE. Only the colours moved: a
     shadow under an object on ice is a cold blue-grey, and the black ones the felt used
     read as holes cut in the sheet. */
  --lift: 0 calc(5 * var(--px)) 0 var(--seat-hard), 0 calc(12 * var(--px)) calc(20 * var(--px)) var(--seat-ambient);
  --lift-button: 0 calc(5 * var(--px)) 0 var(--seat-hard);
  --lift-primary: 0 calc(6 * var(--px)) 0 var(--yellow-hard), 0 calc(14 * var(--px)) calc(24 * var(--px)) var(--glass-ambient);
  --lift-chrome: 0 calc(5 * var(--px)) 0 var(--chrome-hard), 0 calc(10 * var(--px)) calc(18 * var(--px)) var(--glass-ambient);
  --lift-glass: 0 calc(6 * var(--px)) 0 var(--glass-hard), 0 calc(14 * var(--px)) calc(24 * var(--px)) var(--glass-ambient);

  /* ---------- the focus ring ----------
     NOT INHERITED. Story 6.2 shipped a yellow ring against a dark green ground; on ice
     the same ring is #f7e93f against #f6fafd, which is 1.2:1 — present, authored, a
     change from the unfocused state, and invisible. Every check 6.2 wrote would have
     gone on passing, which is exactly why this story re-establishes it rather than
     assuming it carried.

     The ring is therefore TWO-VALUED, and the value is chosen by THE GROUND rather than
     by a list of controls: `--ring` is ink at the root, because the root ground is the
     sheet, and every dark container below re-declares it to yellow. A control inherits
     the right ring from wherever it is standing, so a button added tomorrow inside a
     dialog gets the dialog's ring with nothing written for it — which is the same
     argument the `:where()` rule below is built on, applied to colour instead of reach.
     Measured as CONTRAST against the element's own backdrop by layout-check.mjs, so
     neither half can be quietly retuned into invisibility again.

     The width and the colour are SEPARATE properties and are composed in the rule rather
     than here, and that is not a style choice. A `var()` inside a custom property is
     substituted where the property is DECLARED — so a `--focus-ring: 3px solid var(--ring)`
     written on `:root` bakes in `:root`'s ring colour and inherits the finished string
     everywhere, and every ground below it is ignored in silence. Measured: the first run of
     the contrast check found six bid buttons and a spectator's chat box drawing an ink ring
     on an ink panel at 1:1 while the containers underneath them said yellow. */
  --ring: var(--ink);
  --ring-width: calc(3 * var(--px));
  --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: light;
}

* { box-sizing: border-box; }

html, body {
  margin: 0;
  height: 100%;
  /* The sheet's own end colour. Nothing is drawn here — every screen is a `.surface`
     fixed over it — but a window taller than the app still shows this, and on ice that
     has to be ice rather than the dark the felt left behind. */
  background: var(--ice-base);
  color: var(--ink-2);
  font-family: var(--body);
  font-weight: 600;
  overflow: hidden;
  -webkit-text-size-adjust: 100%;
}

/* THE GROUND DECIDES THE RING. A ring is drawn OUTSIDE its control, at
   `--focus-offset`, so what it has to contrast with is the background of whatever the
   control is standing IN — never the control's own fill. So the yellow half is declared
   on the dark containers, once each, and inherits down to every control inside them; the
   ink half is `:root`'s default, which is the sheet.

   A list of GROUNDS, not of controls. A control cannot be forgotten by it — only a new
   kind of dark box can, and a new dark box is something somebody writes a background for
   and will be looking at. The two light grounds that sit inside this set (the draw
   screen's ice wash and the Spade Out) declare their way back in their own blocks.

   THREE NAMES CAME OFF THIS LIST IN STORY 10.4, and they left because their GROUND changed
   rather than because anybody looked at their controls. `.tcard` and `.optgroup`/`.opt` were
   dark panels standing on ice; the browser's cards are the handoff's GLASS card now and the
   create screen's groups are on-ice panels, so a yellow ring on them would be #f7e93f on
   near-white — drawn, authored, and at 1.2:1 invisible. That is the exact failure this
   two-valued ring exists for, and it is caught rather than argued: `layout-check.mjs` reads
   the ring's contrast against the composited ground and holds it to 3:1. `.qbar` joined for
   the mirror-image reason — a new dark box, with a primary button standing in it.

   A FOURTH NAME NARROWED IN STORY 10.5, and again because the GROUND moved. `.trow` was on
   this list as a dark standings row; § 6 3j draws every row but the winner's on GLASS, so
   the entry is now `.trow.win` — the one row that is still `--panel`. A yellow ring left on
   the whole list would be #f7e93f on #ffffffec at 1.2:1, which is the same failure `.tcard`
   and `.opt` left this list for one story ago. The other rows take `:root`'s ink.

   A FIFTH AND SIXTH NARROWED IN STORY 10.6, for the same reason a fourth did. `.rpanel` and
   `.rchip` were the rules screen's dark cards; `3k` draws that screen as GLASS cards with
   one dark panel among them, so the entry is now the dark one alone and the chip — a pale
   inset on glass — leaves the list outright. And `.sheet.jumbo` JOINS it: `3l` is the
   jumbotron, so the settings screen is now a `--panel` housing with four controls and three
   buttons standing inside it, and an ink ring on `#10141c` is the mirror image of the
   failure this list exists for. */
.menu, .sheet.jumbo, .rpanel[data-panel="score"], .trow.win, .feed, .specbar, .bhead, .qbar, .sbar,
.trump, .turnban, .banner, .netban, .ov, .chip, .strip, .meter {
  --ring: var(--yellow);
}

.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 — handoff § 3. The stroke is
   `#10141c` and it is doing more work here than it did on felt: on a light sheet the dark
   ground that used to separate yellow letterforms from everything behind them is gone, so
   the stroke IS the separation. `paint-order: stroke fill` keeps it from eating the fill. */
.disp {
  font-family: var(--display);
  font-weight: 900;
  font-style: italic;
  text-transform: uppercase;
  letter-spacing: -0.025em;
  color: var(--yellow);
  -webkit-text-stroke: 0.055em var(--ink);
  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(--ring-width) solid var(--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.
   The handoff draws every one of its thirteen screens on the sheet: each is a
   1280x720 frame with `Rink` imported edge to edge under it. So these stand on ice
   too — their full compositions belong to the entry-and-wrap stories, but the ground
   under them is this story's and it is the same ground the table has.
   ========================================================================== */

.surface {
  position: fixed;
  inset: 0;
  /* THE WINDOW THE PLAYER IS LOOKING THROUGH, NOT THE ONE THE PAGE WAS LAID OUT IN.
     `position: fixed; inset: 0` resolves against the LAYOUT viewport, and on a phone the
     two are not the same box: a browser toolbar mid-collapse grows the layout viewport
     while the visual one has not caught up, and for that moment every surface — the sheet,
     the ring, the corridor — is laid out larger than the window and cut off on all four
     edges. app.js writes `--vv-w`/`--vv-h` from `visualViewport` and re-writes them on its
     own change event, so the size follows the window rather than the layout.

     `inset: 0` STAYS, and the pair is deliberate rather than redundant: with `left`,
     `width` and `right` all given, `right` is dropped and the box is anchored top-left at
     the size below — and a browser with no `visualViewport` never sees the properties at
     all and falls back through to `inset: 0` behaving exactly as it did. */
  width: var(--vv-w, 100%);
  height: var(--vv-h, 100%);
  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: var(--ice-mid);
  color: var(--ink-2);
}
/* THE SHEET, on a screen that has no rink markup to draw it with.
   `position: fixed` so a screen that scrolls does not drag the ice up with it — the sheet
   is the room these panels stand in, not the top of a page. Drawn in exactly the layer
   order Rink.dc.html builds, top of the list painting first here because a CSS background
   list paints its FIRST layer last: gloss, dot, circle, lines, sheen, then the gradient.
   Proportions are the handoff's own percentages; the two round marks take vmin off the
   720-tall frame (520/720 = 72.2vmin, 46/720 = 6.4vmin) for the same reason `--px` uses
   one unit on both axes — a circle that scales differently on each is an ellipse. */
.surface::before {
  content: "";
  position: fixed;
  inset: 0;
  pointer-events: none;
  background:
    /* THE FLAT WASH, § 2's third dim layer, on top of the stack. Every pre-table screen
       puts copy straight onto the sheet today, and a blue band or the centre line under a
       line of text is 2.7:1 — the copy is unreadable exactly where a marking crosses it,
       and which words those are depends on the viewport. The handoff answers this with
       board headers and glass cards; building those is 10.2 through 10.7, and this is the
       ground-level answer in the meantime, using the handoff's own layer for quieting the
       sheet under something being read. The TABLE takes no wash: the handoff draws it on
       the raw rink, its panels are opaque and its dialogs bring the modal dim.

       WHICH LAYER IT IS, is the surface's to say. One screen draws a jumbotron rather than
       a sheet of panels — `3l`, the settings — and a jumbotron hangs over the MODAL dim, not
       the flat wash. Written as a token with the wash as its default so that is a one-line
       statement on the screen that makes it, rather than a second copy of this whole
       seven-layer stack with one gradient changed. */
    linear-gradient(0deg, var(--surface-dim, var(--dim-wash)), var(--surface-dim, var(--dim-wash))),
    linear-gradient(115deg, #ffffff00 38%, var(--rink-gloss) 46%, #ffffff00 54%),
    radial-gradient(circle at 50% 50%, color-mix(in srgb, var(--circle) 65%, transparent) 0 3.2vmin, transparent 3.2vmin),
    radial-gradient(circle at 50% 50%,
      transparent 0 35.7vmin,
      color-mix(in srgb, var(--circle) 50%, transparent) 35.7vmin 36.1vmin,
      transparent 36.1vmin),
    linear-gradient(90deg,
      transparent 0 13.4%,
      color-mix(in srgb, var(--blue-line) 88%, transparent) 13.4% 18.4%,
      transparent 18.4% 48%,
      color-mix(in srgb, var(--red) 50%, transparent) 48% 52%,
      transparent 52% 81.6%,
      color-mix(in srgb, var(--blue-line) 88%, transparent) 81.6% 86.6%,
      transparent 86.6%),
    radial-gradient(56% 58% at 50% 40%, var(--rink-sheen) 0%, #ffffff00 72%),
    linear-gradient(180deg, var(--ice-top) 0%, var(--ice-mid) 62%, var(--ice-base) 100%);
  box-shadow: inset 0 0 12.5vmin var(--rink-inset);
}
.sheet {
  position: relative;
  /* Above the sheet drawn behind it. Both are in the surface's stacking context and both
     are positioned, so DOM order would already do it — stated because it is load-bearing
     and a `::before` is easy to forget when this rule is next edited. */
  z-index: 1;
  /* `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 § 6 sets the landing's at 136px,
   and § 10 retires `wordmark.png` outright because it carries the old SNARPLES name.
   136px against the handoff's 720-tall frame is 18.9vmin; the `min()` with a vw term is
   the same trick `--px` plays, so six large letters cannot run off a narrow window.

   NOT `.disp`, AND NOT `--display`. Both of those are Archivo, and this is the one piece
   of type in the client that is not. `--wordmark` exists so that stays true: the owner's
   direction was "use it for the snarples text on landing", which is six letters, and
   Sarpanch is a Devanagari-companion face drawn for technical setting. Behind a 5px stroke
   at 136px it is a wordmark; at 15px in a feed line it is a different product. There are
   roughly forty `var(--display)` sites and this token keeps every one of them out of the
   decision that was actually taken.

   THE TRADE IS ON THE RECORD. The free faces that carry the chamfer were drawn for
   technical use rather than for sport, so Sarpanch reads engineered rather than athletic.
   Weight is the half worth having at this size — 800 behind an ink stroke is what a
   wordmark needs — and the athletic half is the half given up. Said here rather than
   discovered later.

   THE OBLIQUE IS FAKED, and how matters. Sarpanch ships no italic and no oblique face at
   any weight, so `font-style: italic` would hand the browser a bare instruction to lean
   it and let each engine pick its own angle. `oblique 12deg` is the CSS naming the angle
   instead: the same synthesis, but a number this file owns and every engine reproduces,
   which is the difference between faking a face and stating one. 12deg sits just under
   Archivo 900 italic's own slant, so the wordmark leans with the headings under it rather
   than past them.

   THE WORDMARK IS YELLOW. Yellow is the handoff's primary accent (§ 2) and it is the
   game's colour, not only the colour of a score — and the front door carried none of it
   at all, which is what this reverses. What stood here argued the opposite: "NOT `.disp`:
   that is yellow, and the wordmark is white." That sentence was written when white looked
   like the safer read on a pale sheet, and safe is what it is: white on ice at 1.2:1 is
   held apart by the stroke and by nothing else, exactly as yellow is. Both of them live or
   die on the same 5px of ink, so choosing white bought no contrast — it only spent the
   accent.

   AND THE CONTRAST IS MEASURED, ON THE GROUND IT LANDS ON. Read off the rendered pixels at
   1200x900, the sheet directly under the wordmark is `rgb(237, 244, 250)` — `--ice-top`
   with § 2's flat wash and the rink sheen composited over it, which is why it is not the
   token. Against that ground the yellow fill is 1.13:1 and fails outright. That pair is
   never adjacent: `paint-order: stroke fill` lays the ink down first, so what touches the
   sheet is the STROKE at 16.61:1 and what touches the stroke is the fill at 14.65:1. The
   stroke is therefore load-bearing rather than a finish — 0.04em is § 3's own 5–6px at this
   size, and thinning it is a colour decision, not a detail. (White was no better: `--white`
   on the same ground is 1.09:1, which is the measurement that makes "yellow is riskier than
   white here" false.) The three numbers are re-read on every deploy by browser-client.mjs,
   off a strip screenshotted across the middle of the wordmark, so this comment cannot drift
   away from what ships. `-webkit-text-stroke` is the only way to say this, so a browser
   without it draws the fill on the sheet at 1.13:1 — the same bargain `.disp` already takes
   on every heading in the client, and the tagline immediately below is ink on ice and says
   the game's name in words regardless. */
.wordmark {
  font-family: var(--wordmark); font-weight: 800; font-style: oblique 12deg;
  text-transform: uppercase; letter-spacing: -0.035em;
  font-size: min(18.9vmin, 15vw); line-height: 0.9; margin: 0; max-width: 100%;
  color: var(--yellow);
  -webkit-text-stroke: 0.04em var(--ink); paint-order: stroke fill;
}
/* THE FRONT DOOR'S CARD HAS A MEASURE OF ITS OWN, and it is the number the hero's lane is
   cut from. `.bcard.full` takes the sheet's whole width on the browser and the create
   screens, where the card IS the screen; on the landing that would rule a board header edge
   to edge across the window over one paragraph and four buttons, and — the half that matters
   — it would leave the figure nowhere to stand but underneath an opaque glass panel. 75vmin
   is the same stack at a measure a pitch actually wants, and what it buys is the LANE: the
   card is centred, so each side of it is `(100% - 75vmin)/2` of clear ice, and that is the
   whole of the arithmetic `.hero` below is written against. Change one of these two numbers
   and the other has to move with it.

   THE SHEET ITSELF IS NOT NARROWED, and that is deliberate rather than an omission. The
   wordmark is set at `min(18.9vmin, 15vw)` and six letters of Sarpanch at that size measure
   about 82vmin — wider than this card. Pulling the whole sheet in to 75vmin clamps the
   wordmark's box to it and pushes the letters out of their own box, which is the one piece of
   type in this client whose rendered pixels a deploy reads. So the identity block keeps the
   sheet's measure and only the card takes this one.

   And the header WRAPS here, on this screen alone. `.bcard` is `overflow: hidden`, so a
   header too wide for its card loses the tail of its own `white-space: nowrap` meta line
   silently. On a card half the width the other three carry theirs on, wrapping is what that
   overflow should become instead. Measured at 844x390, the narrowest shape this screen is
   drawn at: the header is still one row, so nothing wraps today and this is the floor under
   a longer word rather than a layout anybody is relying on. */
/* ==========================================================================
   THE LANDING'S ICE — the front door stands on the real sheet (story 13.2)

   THE DEFECT, MEASURED. `.surface::before` above draws the HANDOFF's rink: two blue bands at
   13.4% of the window, a red line at 48–52%, a 72.2vmin circle and a 6.4vmin dot. DESIGN.md
   § What the build overtook names that drawing as superseded by name and by number since
   story 12.21, and it was still what a stranger saw first — a rink of the wrong proportions,
   stretched to whatever shape the window was, on the one screen whose whole job is to say
   what this game is. Read on the running client at eight frames: `#landing .rink` count 0,
   `#landing .rink i` count 0, against 44 markings on the table.

   THE ANSWER IS THE TABLE'S OWN ICE, NOT A LIKENESS OF IT. `index.html` carries the felt's
   markup mark for mark and this box gives it the two things it needs to resolve: a container
   to measure `cq*` against, and a camera.

   ---- WHY IT IS A THIRD `.rink` AND NOT THE `::before` RETUNED ----
   The `::before` stack is a picture of a rink; `.rink` is a rink. The markings are written in
   FEET on a 200 x 85 ft sheet and there is no way to say "25 ft from centre" in a background
   gradient — which is exactly why the table stopped using one. A retuned gradient would have
   been a fourth set of numbers to keep in step with the other three.

   ---- WHAT THE CAMERA SHOWS, AND WHY IT IS CENTRE ICE ----
   The landing has no game, so it has no faceoff spot, and `index.html` deliberately writes no
   `data-spot` here: AD-15 fixes the behaviour for an absent identity — render centre ice — so
   the absence IS the instruction and this screen gets centre ice by the same rule a client
   reading an unrecognised spot off the wire does. The three alternatives are each refused by a
   decision already taken:

     - the whole 200 ft in frame — needs `--ft` at 100/31 of the centred scale, a 3.2x zoom
       OUT, and the owner refused zooming because everything shrinks toward the 11px floor;
     - an end-zone or neutral-zone camera — shows a board and the rail beyond it, which is
       what a camera pointed at the corner of a real rink sees and is the wrong sentence for a
       front door;
     - the live game's spot — there is no game here to have one, and inventing one would be
       this screen asserting a fact about a table nobody is at.

   ---- THE UNIT AND THE CAMERA ARE DECLARED HERE, NEVER INHERITED ----
   Same reasoning as `.rdice` and the same trap. `--px` and `--ft` are declared on `:root` and
   a `var()` inside a custom property is substituted where that property is DECLARED, so an
   upright window would bake the tall frame's terms into a box whose own shape decides the
   mapping. Both are therefore re-declared here in the same shape. `--cam-l` / `--cam-w` are
   pinned home for the reason AD-15 gives in so many words: the camera is written on `.table`
   and this box is not inside one, so it would inherit `:root`'s zeroes anyway — and it is
   stated regardless, because "never write the camera on `:root`" is a rule somebody will break
   once and this is the line that makes breaking it harmless here.

   `--cam-fill` is NOT re-declared and that is not an omission: `:root` already carries 50 wide
   and 52 upright, keyed on `data-compose`, which is the window's own composition and is what
   this full-bleed box is. `--trick-y` IS re-declared, at 50%, because `:root`'s is `50cqh` /
   `48cqh` — the tall frame's 48 buys room for a formation, and there is no formation here.

   ---- THE LAYERS, IN THE SAME ORDER THE TABLE PAINTS THEM ----
   the ice gradient (here) · the markings (`.rink`) · the sheen and gloss (`.vig`) · then the
   flat wash, which is this screen's own and is `::after` rather than the shared `::before`.
   The wash is why `#landing` keeps its copy readable at all: the landing wordmark's ink stroke
   measures 16.61:1 against the ground it stands on and its yellow fill measures 1.13:1 against
   that same ground, and `browser-client.mjs` re-reads all three off the rendered pixels on
   every deploy. Moving the ground under them without the wash moves a number a gate reads.
   ========================================================================== */
.lice {
  position: fixed; inset: 0; z-index: 0; pointer-events: none; overflow: hidden;
  container-type: size;
  --px: min(0.138889cqmin, 0.078125cqw);
  --ft: max(calc(var(--cam-fill) * 1cqmax / 100), calc(50cqmin / 42.5));
  --cam-l: 0; --cam-w: 0;
  --trick-y: 50%;
  background-color: var(--ice-mid);
  background-image: linear-gradient(180deg, var(--ice-top) 0%, var(--ice-mid) 62%, var(--ice-base) 100%);
}
:root[data-compose="portrait"] .lice { --px: min(0.138889cqw, 0.078125cqh); }
/* The flat wash, § 2's third dim layer, over the ice and under the type — the same layer the
   shared `::before` puts there, through the SAME per-surface token, so a screen that wants a
   different dim states it in one place exactly as `3l`'s settings do. One line, one token: a
   second copy of the seven-layer stack with one gradient changed is what this replaces. */
.lice::after {
  content: ""; position: absolute; inset: 0; z-index: 2;
  background: var(--surface-dim, var(--dim-wash));
}
/* AND THE SHARED SHEET IS OFF, on this screen alone. `.surface::before` is the handoff's rink
   as a gradient; drawing it over real ice would put two rinks on one screen, at two scales,
   with the wrong one on top. Exactly what `#table.surface::before` already does, and for the
   same reason: this surface draws its own. */
#landing.surface::before { content: none; }
#landing .bcard.full { width: min(100%, 75vmin); }
#landing .bhead { flex-wrap: wrap; }
/* AND ON A SHORT WINDOW THE FRONT DOOR TIGHTENS ITS OWN GAPS. Everything on this screen is
   sized in vmin and scales with the window — except `--hit-target`, which is 44 real pixels
   because a thumb is the same size whatever the screen is. On a 900-tall window three button
   rows are 15% of the sheet; on a 390-tall phone in landscape they are 34% of it, and the
   card ran 27px past the bottom edge. Measured, not guessed, and the height is the condition
   because the height is what the fixed floor is being compared against. The gaps give way
   rather than the padding of the glass card or the header's own 78px, which are the system's
   numbers and not this screen's. */
@media (max-height: 560px) {
  #landing .sheet { gap: 1vmin; }
  #landing .bcard > .glass { padding: 1.8vmin 2.6vmin; gap: 1vmin; }
  #landing .row { gap: 1vmin; }
}

/* THE LANDING'S HERO — the commissioned art, in the slot handoff § 6 draws for it on screen
   3a and at the size § 9 exports it: 420 square in a 720-tall frame, "skating out of the
   corner, stick low", transparent.

   THE CORNER, NOT THE RIGHT COLUMN, and this is the one place this story and a drawn screen
   disagree — said here rather than settled quietly, which is DESIGN.md's own rule. 3a builds
   the landing as two columns, type left and hero right; what ships is one centred stack, and
   turning it into two columns is a composition the entry stories own, not something to slip
   in under an asset swap. So the hero takes the corner its own brief names and leaves the
   stack alone. Left rather than right because the figure's stick sweeps down and out to its
   own right: in the left corner it points into the composition, in the right one it points
   off the screen.

   IT STANDS CLEAR OF BOTH EDGES, which is story 11.6 and the reason the rules above changed.
   `left: 0; bottom: 0` is not a crop and it is not a wide image: the art's opaque bounding
   box IS the figure's own, so anchoring that box on the boundary puts the outermost pixels
   of stick and glove exactly on it and any overflow clipping takes them. Measured on the
   deployed client at 844x390: 148x155 at x=0, y=235 — flush to both. `--hero-clear` is the
   band of ice between the figure and each edge, and it is bigger than the drop-shadow's own
   reach (1.2vmin of offset plus 2vmin of blur) so the SHADOW clears the edge too and the
   figure is not standing on a cut one.

   AND IT IS SIZED INTO THE LANE BESIDE THE CARD, never into the card itself. The width is
   the lane's own width less the clearance at each end, capped: `(100% - <card>)/2` is the
   ice to the left of the centred card — `100%` here is the SURFACE's padding box, which is
   the containing block of an absolutely positioned child, so it is the window rather than
   the sheet's content box, which is the 6vmin the inner `min()` subtracts back off before
   comparing with 75vmin. The cap stays a vmin term for the reason it always was: at the widest
   window it reaches, the drawn width stays under the 880px the file is exported at, so the
   hero is never upscaled. `max(0px, …)` means the term can never go negative and drive the
   width to a nonsense value on a window with no lane at all.

   AND ON A WINDOW TOO NARROW TO GIVE IT A LANE IT IS ABSENT, not squeezed. Below 5:4 the
   lane cannot hold a figure anyone would call a figure — at 1:1 the formula yields 3.5vmin
   of it, a postage stamp — and the choice the story gives for a narrow window is "clear of
   the controls, or absent". At 5:4 exactly it yields 16vmin, so nothing between 16vmin and
   nothing is ever drawn. Written as an aspect query rather than a width one because the lane
   is an aspect: the sheet is measured in vmin and the window in vw.

   BEHIND, AND DEAF TO THE MOUSE. `.sheet` carries `z-index: 1` and this carries none, so
   every control stays on top of it wherever a small window brings the two together, and
   `pointer-events: none` means it can never take a click meant for one. That is the last
   line of defence and not the plan: the lane arithmetic keeps the whole figure off the
   sheet's box, because a figure BEHIND an opaque glass card is a figure with a hole in it. */
.hero {
  --hero-clear: 6vmin;
  position: absolute; left: var(--hero-clear); bottom: var(--hero-clear);
  width: min(38vmin, max(0px, (100% - min(100% - 6vmin, 75vmin)) / 2 - var(--hero-clear) - 3vmin));
  height: auto;
  pointer-events: none;
  filter: drop-shadow(0 1.2vmin 2vmin var(--seat-ambient));
}
@media (max-aspect-ratio: 5/4) { .hero { display: none; } }

/* The accent bar under the wordmark. It STAYS RED, and the reason it used to give is no
   longer available: "the one colour that means this game" was true while the wordmark was
   white, and the wordmark is yellow now, so that claim has moved to the type above this bar
   and cannot be held by two things at once.

   What red still is here is the CENTRE LINE — one of the three jobs `--red` is documented
   for at the top of this file, and the only one that is furniture rather than a moment. A
   flat horizontal band ruled across the sheet under a name is a centre line drawn on ice;
   it is not a punt and it is not a danger, and nothing about it says either, because it
   carries no state and is `aria-hidden`. It does not compete with the sheet's own centre
   line either: that one runs vertically down `.surface::before` at 48–52%, and this one
   runs across, so the two read as the same mark in the two directions a rink has rather
   than as two of anything.

   The alternative considered and refused was retiring the bar with the claim it used to
   make. A yellow wordmark over nothing is the name floating; the rule under it is what
   sets it on a surface, and the surface is ice. Decorative, and marked so. */
.wmbar { width: min(41.7vmin, 60vw); height: 1.2vmin; min-height: 3px; background: var(--red); border-radius: 0.4vmin; }
/* A UI label on ice — § 3's tracked uppercase Archivo, in the ink the palette keeps for
   labels. NOT yellow: yellow means score, choice or primary action, and at 2.6vmin on a
   #f6fafd sheet it would also be unreadable. */
.tagline {
  color: var(--ink-2); font-size: 2.6vmin; margin: 0; letter-spacing: 0.24em;
  font-family: var(--display); font-weight: 700; 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(--ink-2); font-weight: 500; }

.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: the handoff's SECONDARY kind (§ 4) — a top-light vertical gradient, a
   dark edge, a hard drop and dark text. 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.025em;
  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(--chrome-hard), 0 1.4vmin 2.5vmin var(--glass-ambient);
  color: var(--chrome-ink);
  cursor: pointer;
  padding: 1.4vmin 4vmin;
  font-size: 5vmin;
  line-height: 1.1;
  min-height: 44px;
}
/* RED IS NOT A WAY OF SAYING "SECONDARY". `--red` is documented at the top of this file as
   the centre line, a punt and danger, and this modifier was worn by four buttons: Browse
   tables, two Backs, and Leave table. Three of those are navigation — pressing them costs
   nothing and undoes nothing — and a navigation button in the palette's alarm colour
   shouted louder than the primary standing beside it, which is the whole complaint. They
   have given it up.

   ONE ACTION IS LEFT, and it is the one the palette's own word covers: Leave table gives up
   a seat and does not hand it back. That is danger, so the modifier survives rather than
   being deleted — but it is a claim about the ACTION and not about rank, and nothing that
   merely goes somewhere may take it again. Story 10.7 added the SECOND element wearing it,
   and it is the same action: the portrait refusal's own exit, which is there because a
   player whose device will not turn could otherwise not leave the table at all. Two
   elements, one action; that is the rule holding, not bending. */
.chrome.red { color: var(--red); }
/* THE SECONDARY, where two buttons of the same size stand together. `.chrome` is already
   the handoff's secondary KIND — the whole landing row is chrome, and the primary/secondary
   distinction inside a row of chrome was doing nothing but hue. So the quieter of the pair
   takes the handoff's ON-ICE QUIET kind (§ 4) instead: the same near-white fill and cold
   `--quiet-edge` border the text inputs on these surfaces wear, over an ink label.

   THREE CHANNELS DOWN FROM ITS NEIGHBOUR, not one. Flat where the chrome kind has a
   top-light gradient, a pale cold border where it has a dark one, and a softer base shadow
   where it has a hard drop — so which of two same-sized buttons is the primary survives a
   greyscale screenshot, which "one of them is red" never did.

   NOT A SIZE AND NOT A POSITION: the box, the padding, the type and the hit-target floor
   are the chrome kind's own, untouched, because this story moves no layout. `.small`
   already carries rank wherever a row mixes sizes (a Back beside a Create), so this is only
   for the landing's two full-size buttons. */
.chrome.quiet {
  background: var(--chrome-top);
  border-color: var(--quiet-edge);
  color: var(--ink-2);
  box-shadow: 0 0.7vmin 0 var(--glass-hard), 0 1.4vmin 2.5vmin var(--glass-ambient);
}
.chrome.quiet:hover:not(:disabled) { box-shadow: 0 0.5vmin 0 var(--glass-hard), 0 1vmin 2vmin var(--glass-ambient); }
/* THE PRIMARY, and the reason it did not exist until now. `.chrome` is the handoff's
   SECONDARY kind and every button on every pre-table surface wore it — so the front door
   offered a way in and a way to browse drawn identically, and 3a draws them as two
   different KINDS: Play now is the primary (§ 4, `#ffe95c → #e2c910` over a `#6b5c00` edge
   on an `#8d7a00` drop), Browse tables the secondary, How to play and Settings the on-ice
   quiet. Three kinds, three ranks, and the rank survives a greyscale screenshot.

   Yellow is not decoration here — DESIGN.md § Colours reserves it for a score, an active
   choice or THE PRIMARY ACTION, and this is the third. It is the same fill `.btn` carries
   inside a dialog; the difference is the unit, because these surfaces are sized in vmin
   and `--px` is the table's container unit. */
.chrome.primary {
  background: linear-gradient(180deg, var(--yellow-lit), var(--yellow-deep));
  border-color: var(--yellow-edge);
  color: var(--ink-btn);
  box-shadow: 0 0.85vmin 0 var(--yellow-hard), 0 1.4vmin 2.5vmin var(--glass-ambient);
}
.chrome.primary:hover:not(:disabled) { box-shadow: 0 0.6vmin 0 var(--yellow-hard), 0 1vmin 2vmin var(--glass-ambient); }
/* The ON-DARK kind (§ 4): flat `#232c38`, a `#46566a` edge, panel text, and no drop at all
   — a button standing INSIDE the board header rather than on the sheet, where a hard offset
   would be a shadow cast onto a wall two pixels behind it. */
.chrome.dark {
  background: var(--on-dark);
  border-color: var(--on-dark-edge);
  color: var(--panel-text);
  box-shadow: none;
}
/* With no drop to shorten there is nothing for a hover translate to move INTO, so the two
   states are re-stated rather than inherited: hover lightens the fill and stays put, and
   the press is the only thing that moves. */
.chrome.dark:hover:not(:disabled) { background: var(--boards); box-shadow: none; transform: none; }
.chrome.dark:active:not(:disabled) { transform: translateY(0.3vmin); }
.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(--chrome-hard), 0 1vmin 2vmin var(--glass-ambient); }
.chrome:disabled { opacity: 0.45; cursor: not-allowed; box-shadow: none; }
.chrome:active:not(:disabled) { transform: translateY(0.7vmin); box-shadow: none; }

/* ---------- a control that moves on hover must not move out from under the pointer ----------
   DESIGN.md § Elevation: "hover shortens the hard offset and translates down". A transform
   moves the element's HIT BOX as well as its paint, and that closes a loop: a pointer
   resting in the top 0.2vmin of a button hovers it, the button drops 0.2vmin, the pointer is
   now above it, it un-hovers, it comes back up — and it does that for as long as the cursor
   sits there. A player sees a strobing button and a click that will not land.
   Found on `settingsBack`: 3l made the footer buttons full-width, which put one of them
   exactly under where the landing's Settings button had been, and Playwright reported it as
   "element is not stable" for eight seconds. The flicker was the client's, not the driver's.
   Measured: y alternating between 501.2 and 502.6 at 1280x720, forever.

   ENLARGING THE BUTTON DOES NOT FIX IT. Any fixed hover-catcher moves with the element, so
   the unstable band is not removed — it is relocated by exactly the same distance. What
   removes it is a catcher that DOES NOT MOVE: a pseudo-element covering the element's own
   rectangle, counter-translated by whatever the element is translated by, so the region that
   decides hover is a fixed rectangle on screen and the state is a pure function of where the
   pointer is. The paint still moves; only the hit box stands still.

   Pseudo-elements are not event targets, so every click still lands on the button and every
   hit test still resolves to it. The catcher is transparent and adds nothing to any screen.

   THE SAME LATENT DEFECT IS IN FOUR MORE RULES and is not touched here: `.btn`/`.suitbtn`
   (2 --px on hover), `.gear`/`.exit` (2 --px) and `.hand .cardv` (-4 --px, so its unstable
   band is along the BOTTOM edge). None of them is on a screen this story rebuilt and all
   three are under gates that are green today; they are named so the next person to meet a
   flickering control knows this rule is the pattern rather than the exception. */
.chrome { position: relative; }
/* `-0.4vmin`, and both terms of that are measured. An absolutely positioned child resolves
   `inset` against the PADDING box, so `inset: 0` leaves the 0.35vmin border uncovered — and
   the border is where the pointer was sitting, so the first cut of this fixed nothing at the
   edge it was written for. And it has to be at least the distance the element moves
   (0.2vmin), so that the catcher CONTAINS the button in both states and the region deciding
   hover is the same region either way. 0.4 clears both. */
.chrome::after { content: ""; position: absolute; inset: -0.4vmin; border-radius: inherit; }
.chrome:hover:not(:disabled)::after { transform: translateY(-0.2vmin); }
.chrome:active:not(:disabled)::after { transform: translateY(-0.7vmin); }
/* THE COUNTER MUST MATCH THE MOVE, INCLUDING WHEN THE MOVE IS ZERO. The on-dark kind has no
   drop to shorten, so it does not translate on hover — and a catcher that counter-translated
   anyway would open the identical loop along the button's BOTTOM edge instead. Its press does
   move, by its own smaller distance, and is countered by that. */
.chrome.dark:hover:not(:disabled)::after { transform: none; }
.chrome.dark:active:not(:disabled)::after { transform: translateY(-0.3vmin); }

/* A field label sits on the sheet, so it takes an ink and not a panel text. */
.field { display: grid; gap: 0.8vmin; justify-items: center; font-size: 2.4vmin; color: var(--ink-3); }
/* SIXTEEN PIXELS IS A FLOOR AND NOT A SIZE (the follow-up to story 12.20).
   iOS — which is every browser on an iPhone, since they are all WebKit — ZOOMS THE PAGE when a
   text field whose computed font-size is under 16px takes focus. Nothing here asked for that
   and there is no way to decline it except to be at or over 16.

   IT IS THE ROOT CAUSE OF A BUG THE OWNER RE-REPORTED FROM REAL HARDWARE. Story 12.20 taught
   the client to tell a keyboard from a small window by watching which viewport moved: a visual
   viewport that got SHORTER while the layout viewport and the visual WIDTH both held still is
   something lying on the window. That is right, and on iOS it never fired — because the focus
   zoom moves the visual width first, so tapping the chat box still threw the player out of
   their game with "there is not enough room on the screen". Measured at 390x844: the chat
   input computed 7.58px, the spectator's 7.58px, the invite code 10.14px, the host name
   13.26px and the display name 10.92px. EVERY text field in the product was under the line,
   and the smallest supported frame put the chat input at 4.59px.

   A FLOOR, WRITTEN AS max(). The design is drawn in --px and vmin so that everything scales
   with the sheet, and that stays true everywhere the sheet is big enough for 16px to be the
   smaller number — a desktop window is untouched to the pixel. What changes is only the phone,
   which is the only place the zoom was ever reachable.

   NOT `user-scalable=no`, and not `maximum-scale=1`. Those disable pinch-zoom for everybody,
   including the people who need it to read anything at all, and they fix this by breaking
   something more important. The 12.20 discriminator is ALSO hardened against a scale change
   (see app.js, "which viewport moved") so that this floor is a fix and not the only defence. */
.field input[type="text"] {
  font-family: var(--display);
  font-weight: 800;
  font-style: italic;
  font-size: max(16px, 3.4vmin);
  text-align: center;
  padding: 1.2vmin 2vmin;
  min-height: 44px;
  border-radius: 0.8vmin;
  /* The on-ice quiet kind, § 4: a near-white fill with a cold border and dark type. A
     dark well here would be a hole punched in the sheet. */
  border: 0.4vmin solid var(--quiet-edge);
  background: #ffffffb8;
  color: var(--ink);
}
.field input[type="text"]::placeholder { color: var(--muted-deep); }

.toggle { display: flex; gap: 1.4vmin; align-items: center; font-size: 2.6vmin; cursor: pointer; color: var(--ink-2); font-weight: 500; }
/* THE SWITCH — `3l` and `3c` both draw one: a 62x34 pill with a 28px knob, `#1aa832` when
   on and `#39465a` when off, the knob white on and `#a9b8c9` off. Built ONCE and here,
   beside the label it belongs to, because the two screens that draw it are the settings and
   the create screen and a second declaration is how one of them stops matching.
   62/720 = 8.6vmin, 34/720 = 4.7vmin, 28/720 = 3.9vmin.

   `appearance: none` and drawn, not `accent-color` on a native box. The native control
   cannot be a pill in any engine, and § 2's "toggle on" is a track colour rather than a
   tick — so the handoff's switch and a green checkmark are two different objects, and the
   one drawn on every screen of the handoff is this one.

   THE KNOB IS A `::before`, NOT A SECOND ELEMENT, so the markup is untouched: every one of
   these is an `<input type=checkbox>` with its own id, its own hook and its own `checked`
   property, and nothing about how it is read, pressed, tabbed to or announced changes. What
   `layout-check.mjs` measures — the box's own x, and that it clears the hit floor — is
   measured on the same element it always was.

   `green solid` is the handoff's own "toggle on" (§ 2), and it is also what the palette's
   semantics demand: yellow means a score, an active CHOICE or the primary action, and a
   switch that is on is none of those. It is also the readable half of the pair on ice.

   THE OFF TRACK IS THE ONE THING THE GROUND DECIDES. `#39465a` is drawn for a dark panel;
   on the create screen's glass card the same value is a dark slot cut into it, so the token
   defaults to the cold muted the rest of the ice's furniture uses and the jumbotron
   re-declares it — the same "the ground decides" the focus ring is built on. */
.toggle input {
  /* The track's height is the 44px floor or 4.7vmin, whichever is larger — the floor is a
     thumb against a screen and does not scale, and a pill that met it by being STRETCHED
     (which is what the shared `min-height` above does to a fixed height) would be a rounded
     rectangle with its knob adrift. So the height leads and the width follows it at 62:34,
     and everything inside is a fraction of the track rather than a second set of lengths. */
  --switch-h: max(var(--hit-target), 4.7vmin);
  appearance: none; -webkit-appearance: none;
  position: relative; flex: 0 0 auto; cursor: pointer;
  height: var(--switch-h); width: calc(var(--switch-h) * 62 / 34);
  border: none; margin: 0; border-radius: 999px;
  background: var(--switch-off, var(--muted));
  transition: background 160ms ease;
}
/* 28/34 of the track's height, inset 3 — and the travel is the knob's OWN width, because
   62 - 28 - 3 - 3 is 28. One number the handoff already agrees with itself about. */
.toggle input::before {
  content: ""; position: absolute; top: 8.8%; left: 4.84%;
  height: 82.4%; width: calc(var(--switch-h) * 28 / 34); border-radius: 50%;
  background: var(--switch-knob-off, #eaf1f8);
  transition: transform 160ms ease, background 160ms ease;
}
.toggle input:checked { background: var(--green-solid); }
.toggle input:checked::before { transform: translateX(100%); background: #ffffff; }
.toggle input:disabled { opacity: 0.45; cursor: not-allowed; }

/* 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%; }
/* These hints stand on the SHEET, where `.optgroup`'s stand on a dark panel — the same
   element, two grounds, and the ink/panel-text pair is the whole of the difference. */
.prefs .opthint { max-width: 52ch; color: var(--ink-3); }

/* ---------- 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%;
  text-align: left;
}
/* THE GROUPS ARE ON ICE NOW. What stood here was a `--panel` box with a white-alpha edge —
   a dark card that had been correct on felt and was, on a light sheet, a hole punched in the
   ice with pale grey type in it. That is the "create table looks really bad now" the owner
   reported, in one rule. `3c` draws the whole form inside a glass card, and a group inside it
   is a pale inset panel: the handoff's own `#f1f6fb` over a `#d3e0eb` edge, which is the ice
   one shade down rather than a second colour.

   The group is NOT itself a `.glass` — a glass card inside a glass card is two white boxes
   with an invisible seam. It steps DOWN from the card it stands in, which is what makes the
   card read as the surface and the groups as the things arranged on it. */
.optgroup {
  display: grid;
  /* Rows keep their own height. Without it the shorter of two side-by-side groups is
     stretched to match its neighbour and the stretch lands on the OPTIONS — a pair of
     100px-tall radio boxes beside a normal pair, which reads as two different controls. */
  align-content: start;
  gap: 1.2vmin;
  min-width: 0;
  margin: 0;
  padding: 1.2vmin 1.6vmin 1.6vmin;
  background: var(--ice-inset);
  border: 0.4vmin solid var(--ice-inset-edge);
  border-radius: 1.2vmin;
  box-shadow: none;
}
/* Table chat gets the whole row. */
.optgroup.wide { grid-column: 1 / -1; }
/* The type is `.uilabel`'s — see that rule for why this one and the felt's fill-seats label
   are the same declaration. Only the size and the padding are the legend's own. */
.optgroup legend { padding: 0 0.6vmin; font-size: 2vmin; }
.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(--ink-3); 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;
  /* The on-ice quiet kind's own edge (§ 4), which is what every other input on these
     surfaces already wears — so an unchosen option and the name field read as one family. */
  border: 0.3vmin solid var(--quiet-edge);
  border-radius: 0.8vmin;
  background: var(--card-face);
  color: var(--ink-2);
  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 ink type. Colour alone never carries a state here.

   YELLOW, and `3c` draws it as a solid deep-blue segment instead. The handoff disagrees
   with itself across two screens — `3b`'s active chip is "solid yellow with dark text",
   verbatim — and DESIGN.md § Colours breaks the tie: yellow means a score, THE ACTIVE
   CHOICE, or the primary action, so an active choice drawn in anything else makes every
   other yellow ambiguous. Recorded in the spec's change log. The one thing that had to
   move is the type: white on white said nothing, so the chosen option is inked. */
.opt:has(input:checked) { border-color: var(--yellow); color: var(--ink); font-weight: 700; background: var(--yellow-wash); }
@media (max-width: 700px) {
  .opts { grid-template-columns: minmax(0, 1fr); }
}
/* The draw-phase switch, as `3c` draws it: a row in the same inset the groups stand in,
   with the setting on the left and the control on the right, so it reads as one more thing
   arranged on the card rather than as a stray checkbox floating above the groups.

   SCOPED TO THE CREATE SCREEN. `.toggle` is also the settings screen's idiom, and settings
   is story 10.6's — a shared rule edited here would draw a screen this story is not allowed
   to be changing. */
#create .toggle {
  width: 100%;
  justify-content: space-between; gap: 2vmin;
  background: var(--ice-inset); border: 0.4vmin solid var(--ice-inset-edge);
  border-radius: 1.2vmin; padding: 1.2vmin 1.6vmin;
  text-align: left;
}
/* The label takes the room and the box keeps its size — without this the checkbox is the
   flex item that gives way and the 44px floor is spent on a long sentence. */
#create .toggle > span { flex: 1 1 auto; min-width: 0; }
#create .toggle input { flex: 0 0 auto; }

/* The surface picker stood here. It is gone with the felt: there is ONE surface, so
   there is nothing to pick between, no attribute to write and no branch anywhere in the
   client asking which sheet it is standing on. A two-option control with one option is
   the `chatEnabled` defect wearing a settings row.

   `3l` DRAWS A SURFACE TILE WHERE THE PICKER WAS — a swatch, "Ice rink", and "Every table
   is played on the sheet." It is not built. It is a control-shaped row that controls
   nothing, sitting exactly where a player last saw a control, on the one screen in this
   client whose gate enumerates anything that talks about a surface. Recorded in the spec's
   change log rather than quietly skipped: the comment above is what says the picker is
   gone, and it says it to the only reader who can act on it. */

/* ==========================================================================
   SETTINGS — `3l`, hung from the roof.

   Story 10.3 measured the drawing and settled it: `3l` is the jumbotron. Same `--panel`
   body, same 5px `--rail` housing, same two 5px rods, same 760-wide panel and same modal
   dim as `3d` and `3e`, and 10.3 left this screen to INHERIT the fitting rather than build
   a second one.

   INHERIT, WITH ONE TRANSLATION. `.menu` and `.rods` are written in `--px`, which is
   `.table`'s container unit and resolves to about 0.7px out here — the same trap the legend
   above re-bases for. These surfaces are measured in vmin off the handoff's 720-tall frame,
   so the fitting's own numbers are taken in that unit and nowhere else: 5/720 = 0.7vmin for
   the housing and the rods, 30/720 = 4.2vmin for the rods' length, 170/720 = 23.6vmin
   between them, 760/720 = 105.6vmin across, 12/720 = 1.7vmin of radius.

   The dim is the MODAL layer, not the flat wash every other pre-table surface takes — a
   jumbotron hangs over a dimmed sheet, and `.surface::before` reads it from this token. */
#settings {
  --surface-dim: var(--dim-modal);
  /* The panel hangs from the TOP EDGE, so the surface stops centring it and gives up its
     top padding: the rods have to start at the edge itself or the assembly reads as a panel
     with two marks floating above it. */
  justify-content: flex-start;
  padding-top: 0;
}
/* The rods, in flow above the panel rather than absolutely positioned: this surface is a
   flex column and the panel's top edge is exactly where they end, which is the same
   "one fitting, not a panel with marks over it" the table's own rule states.

   THE `z-index` IS LOAD-BEARING AND IT IS NOT DEAD CODE ON A STATIC BOX (story 11.5). A
   flex ITEM with a z-index other than `auto` is painted as though it were positioned even
   while its `position` stays static (css-flexbox-1 § 5.4), and `.surface` is a flex column
   — so this is what keeps the rods out of the in-flow layer that `.surface::before` paints
   over. Without it these two marks are the same defect the portrait refusal shipped with:
   drawn, correctly coloured, and washed out by the surface's own dim. The audit in
   `layout-check.mjs` reads exactly this pair of properties, which is why it exonerates the
   rods rather than reporting them. */
#settings > .rods {
  display: flex; position: static; transform: none;
  flex: 0 0 auto; gap: 23.6vmin; height: 4.2vmin; z-index: 2;
}
#settings > .rods i { width: 0.7vmin; height: 100%; background: var(--rail); }
/* THE HOUSING. `.sheet` keeps its own job — it is what `layout-check.mjs` measures this
   screen's width and overhang against — and takes the jumbotron's body on top of it. */
.sheet.jumbo {
  /* The switch's off track, which is the one part of it the ground decides — `#39465a` is
     drawn for a dark panel and would be a slot cut into the create screen's glass card. */
  --switch-off: #39465a;
  --switch-knob-off: #a9b8c9;
  width: min(100%, 105.6vmin);
  background: var(--panel);
  border: 0.7vmin solid var(--rail);
  border-radius: 1.7vmin;
  box-shadow: 0 2.5vmin 5.6vmin #00000073;
  padding: 3vmin 3.4vmin 3.2vmin;
  gap: 2vmin;
  justify-items: stretch;
  text-align: left;
  color: var(--panel-text);
}
/* Title and meta on one baseline, as `3l` sets them. */
.jhead { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 1.2vmin 2vmin; }
.jhead h2.disp { margin: 0; font-size: 4.4vmin; color: var(--white); }
.jmeta {
  font-family: var(--mono); font-weight: 500; font-size: 1.7vmin;
  letter-spacing: 0.1em; text-transform: uppercase; color: var(--panel-text-2);
}
/* THE SWITCH ROWS. `3l` draws each setting as a full-width `#1b2330` row with its label at
   one end and its switch at the other — which is also what puts the three boxes at one x
   without a rule about alignment, because a row that stretches has only one right edge.
   The block's own alignment claim (`layout-check.mjs` measures the three) is therefore
   carried by the layout rather than by a coincidence of label widths. */
#settings .prefs { justify-items: stretch; gap: 0.9vmin; width: 100%; }
#settings .toggle {
  flex-direction: row-reverse; justify-content: space-between;
  background: var(--panel-inset); border-radius: 1.2vmin;
  padding: 1.6vmin 2vmin; min-height: 44px;
  color: var(--panel-text); font-weight: 700; font-size: 2.4vmin;
}
#settings .toggle > span { flex: 1 1 auto; min-width: 0; }
#settings .toggle input { flex: 0 0 auto; }
/* The hint belongs to the row above it and is set as `3l` sets it — mono, quiet, under the
   switch it explains rather than out on the sheet. Indented to the row's own padding so it
   reads as part of that row and not as a fourth setting. */
#settings .prefs .opthint {
  font-family: var(--mono); font-size: 1.7vmin; line-height: 1.45;
  color: var(--panel-text-2); max-width: 74ch; padding: 0 2vmin; margin-top: -0.2vmin;
}
/* The display name, on the dark panel: `3l`'s own `#1b2330` well with a `#46566a` edge,
   left-aligned like everything else in the housing. The label takes the UI-label register
   with the panel's ink. */
#settings .field { justify-items: stretch; text-align: left; gap: 1vmin; }
#settings .field label {
  font-family: var(--display); font-weight: 800; font-size: 1.9vmin;
  letter-spacing: 0.14em; text-transform: uppercase; color: var(--panel-text-2);
}
#settings .field input[type="text"] {
  text-align: left; font-style: normal; font-size: max(16px, 2.8vmin);
  background: var(--panel-inset); border-color: var(--on-dark-edge); color: var(--white);
  min-height: 50px;
}
#settings .field input[type="text"]::placeholder { color: var(--panel-text-2); }
#settings .field .opthint { color: var(--panel-text-2); }
/* Three buttons filling the footer, as `3l` draws them. */
#settings .row { display: flex; gap: 1.4vmin; width: 100%; }
#settings .row .chrome { flex: 1 1 0; min-width: 0; }
/* THE DANGER KIND, § 4: `#3a1113` over a `#d8262b` edge in `#ff9a9e`. Scoped to this screen
   rather than folded into `.chrome.red`, because the other element wearing that class is the
   portrait refusal's exit and it stands on a GLASS card — the same action, two grounds, and
   a dark fill on glass would be a hole punched in it. The claim `.chrome.red` makes is about
   the action, and it is unchanged: two elements, one action. */
#settings .chrome.red {
  background: var(--danger-fill); border-color: var(--red); color: var(--danger-ink); box-shadow: none;
}
#settings .chrome.red:hover:not(:disabled) {
  background: color-mix(in srgb, var(--danger-fill) 70%, var(--red)); box-shadow: none; transform: none;
}

/* 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.
   § 6 3j draws it as a list too, which is the one composition on that screen the handoff
   and the shipped product already agreed about.

   ON ICE, THE ROW IS THE GLASS CARD (§ 4 Chrome, story 10.4's component). 3j draws every
   row but the first as `#ffffffec` at a white edge over the hard-offset-plus-ambient
   shadow, which is exactly `.glass` — so the standings take the component rather than a
   second near-copy of it, and the only thing written here is the row's own shape. What
   stood here was `--panel` at `--edge-strong`: a dark card from the felt direction,
   retinted by nothing, sitting on the results screen's flat ice wash.

   `--ring` is NOT declared here any more. It used to be, on the dark-ground list, and the
   ground it named has changed underneath it — an ink ring is what a control standing on
   glass needs, and `:root`'s default is already ink. The winner's row keeps the dark fill
   and so keeps the yellow, declared beside it below. */
.rows { display: grid; gap: 1.2vmin; width: 100%; max-width: 120vmin; }
.trow {
  display: flex; align-items: center; gap: 2vmin; text-align: left;
  background: var(--glass);
  border: 0.3vmin solid var(--glass-edge);
  border-radius: 1vmin;
  box-shadow: 0 0.6vmin 0 var(--glass-hard), 0 1.4vmin 2.4vmin var(--glass-ambient);
  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(--ink); }

/* ---------- 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(--ink-4); 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(--ink-3);
  min-width: 8vmin; text-align: left;
}

/* Where the winner/out marks sit. Empty for most rows, which is the normal case.
   SCOPED TO THE ROW, because `.rmarks` is also the rules screen's table-marks legend and
   that one is written 400 lines further down — same name, same specificity, later in the
   file, so it was winning here and drawing the standings' marks as a `display: grid` list
   with the legend's own `--px` override inherited into them. Two badges then stacked
   vertically inside a flex row instead of sitting beside each other. Qualifying by `.trow`
   settles it on specificity rather than on order, which is the half that would have come
   back the next time either rule moved. THE OTHER HALF is scoped too now (story 11.3): the
   legend's rule was left bare and still reached this row, so neither name is a claim on the
   whole document any more. */
.trow .rmarks { display: flex; gap: 1vmin; align-items: center; }

/* One counted figure over its word — the label is what tells tricks from punts. Ink, not
   a panel text: these stand on glass now, and DESIGN.md § Colours names picking the wrong
   one of those two as the likeliest contrast failure in this direction. */
.rstat { display: flex; flex-direction: column; align-items: center; min-width: 12vmin; }
.rstat b { font-family: var(--mono); font-size: 2.8vmin; color: var(--ink); font-weight: 700; }
.rstat span {
  font-family: var(--display); font-size: 1.7vmin; color: var(--ink-3);
  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(--ink);
  min-width: 10vmin; text-align: right;
}

/* THE WINNER'S ROW IS THE ONE DARK ROW, which is how § 6 3j draws first place: `#10141c`
   under yellow figures, with every other row on glass beneath it. It keeps the heavier
   yellow edge as well, so the row differs from its neighbours in fill, in edge weight and
   in the badge — three channels before a hue is read.
   ITS TYPE GOES BACK TO THE PANEL TEXTS, because its ground went back to dark, and so does
   its ring: this row is the one place on the results screen where a control would be
   standing on `--panel`, and an ink ring there is the 1:1 failure the two-valued ring
   exists to prevent. It is on the grounds list beside `.menu` and `.bhead`. */
.trow.win {
  border-color: var(--yellow); border-width: 0.6vmin; background: var(--panel);
  box-shadow: 0 0.6vmin 0 var(--seat-hard), 0 1.4vmin 2.4vmin var(--seat-ambient);
}
/* "(you)" on the standings row, smaller than the name it follows and never a colour on its
   own — the same treatment the seat card has carried since story 10.10, on the one screen that
   had never said which row was yours. */
.trow .tname .you { font-size: 0.62em; font-style: normal; font-weight: 700; opacity: 0.8; }
.trow.win .tname { color: var(--white); }
.trow.win .rstat b { color: var(--panel-text); }
.trow.win .rstat span { color: var(--panel-text-2); }
.trow.win .rrank, .trow.win .rscore { color: var(--yellow); }
.badge.winner { color: var(--yellow); }

/* An eliminated seat: sunk a shade into the glass and dimmed, plus § 6 3j's own red left
   edge — the one row that screen draws in the punt colour. Three channels and none of them
   the hue alone: the fill drops, the row carries a 1vmin red rail, and the word "Out" is
   printed in a bordered box beside the name. `--red` and not the pale danger ink, which is
   drawn for a DARK ground and measures under 2:1 on glass.

   `:not(.win)` is cheap insurance rather than a live case: a seat cannot both reach zero
   and be out at it, so the two classes never meet — but if they ever did, the light fill
   below would land under the winner's YELLOW ring and take it to 1.2:1, which is the one
   failure on this screen that ships looking like a working one. */
.trow.out:not(.win) {
  background: var(--ice-inset); border-color: var(--ice-inset-edge);
  border-left: 1vmin solid var(--red); opacity: 0.78;
}
.trow.out .tname { color: var(--ink-2); }
.badge.out { color: var(--red); }

/* 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 table. 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(--ink-2); font-weight: 500; }

/* ==========================================================================
   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%;
}
/* 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.

   ON ICE THE UNPRESSED CHIP IS PALE, and it used to be a black pill. `--panel-inset` over
   `--text-body` was a dark chip on dark felt; standing on the glass card `3b` draws these on,
   three black lozenges are the loudest thing on a screen whose subject is the cards under
   them. It takes the on-ice quiet kind (§ 4) — the same near-white fill and cold border as
   the invite field beside it — so the row reads as one family of controls at rest, and the
   active one is the only thing on the row with any colour in it. */
.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(--quiet-edge); border-radius: 6vmin;
  background: #ffffffb8; color: var(--ink-2);
  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. It DARKENS
   on ice where it used to lighten on felt: the same "more of it, not a different colour"
   move, read the other way round on a light ground. */
.fchip:hover:not(.on) { color: var(--ink); border-color: var(--ink-3); background: var(--card-face); }

/* 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%;
}
@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.

   IT IS A `.glass` NOW, and that is the whole of what story 10.4 did to this card. What
   stood here was a `--panel` dark card with a white-alpha edge — the felt direction's room
   card, tinted. `3b` draws six GLASS cards on the sheet, and the handoff's glass card is a
   component this file already owed: near-white, a white edge, the cold hard-offset lift, and
   the status as a 5px top border. So the fill, the edge, the radius and the lift are all
   inherited from `.glass` and nothing is restated here; what is left below is the layout and
   the type, which is what a room card adds to a glass card.

   THE STATUS COLOURS MOVED WITH IT, and one of them CHANGED. Handoff § 4 gives the glass
   card `#1aa832` waiting and `#d8262b` in play. What shipped was `--green-live` and
   `--yellow` — and the yellow was a palette violation as well as a drift: DESIGN.md § Colours
   keeps yellow for a score, an active choice or the primary action, and "somebody else's game
   is under way" is none of the three. Both now come off `.glass[data-status]`. */
.tcard {
  display: flex; flex-direction: column; gap: 1vmin; align-items: flex-start;
  text-align: left; min-width: 0;
  padding: 1.4vmin 1.8vmin 1.6vmin;
}
/* 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.

   THE MUTED RAIL IS THE HANDOFF'S THIRD STATUS COLOUR, finding the only home this product
   has for it. § 4 names `#8397ab` for "invite only", and an invite-only table is not in the
   browse response at all — the server filters it, and it has done since epic 4. Rather than
   declare a colour nothing can ever wear, the state that IS unreachable-but-listed takes it:
   a table with every chair taken. Same fact, same grey, and the card keeps a rail so a full
   card and a waiting one differ in the same channel rather than one of them having none. */
.tcard[data-status="full"], .tcard[data-status="closed"] {
  opacity: 0.62; border-top: 0.7vmin solid var(--muted);
}

/* INK, not `--white`. This card is on ice now, and DESIGN.md § Colours names picking a
   panel text for a light ground as the likeliest contrast failure in this direction. */
.tcard .tname {
  font-family: var(--display); font-style: italic; font-weight: 900;
  font-size: 2.8vmin; line-height: 1.05; margin: 0; color: var(--ink);
  max-width: 100%; overflow-wrap: anywhere;
}
/* The status word, in the same colour as the rail above it — handoff § 4's own pairing on
   the glass card, and never an inferred absence: every card says which one it is. Mono,
   because `3b` sets it as meta beside a display name and § 3 gives meta to JetBrains. */
.tcard .tstat {
  font-family: var(--mono); font-weight: 700; font-size: 1.8vmin;
  text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-deep);
}
.tcard[data-status="waiting"] .tstat { color: var(--green-solid); }
.tcard[data-status="in-play"] .tstat { color: var(--red); }

/* The occupancy row: one segment per seat, filled for taken and an empty pale block for
   free. Handoff § 3b — a deep `#1b3a6b` for a taken chair and `#c9d8e6` for a free one.
   ON ICE THE EMPTY SEGMENT IS A FILL, not a dashed outline: a white-alpha dash was the
   right edge over dark felt and is nothing at all on a near-white card, and the pair has to
   be legible in a screenshot with no colour — which a solid block against a pale one is and
   an outline against white is not.

   `i.off` KEEPS ITS NAME. The class is what `layout-check.mjs` and `browser-lobby.mjs` count
   free chairs from, and it says which seat this is rather than how it is drawn. */
.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(--occ-taken); }
.occ i.off { background: var(--occ-free); }

.tcard .twho { font-size: 2vmin; font-family: var(--mono); color: var(--ink-4); }
.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(--ink); font-family: var(--mono); }
.tcard .tfoot .chrome.small { font-size: 2.4vmin; padding: 0.8vmin 2vmin; }
/* The badges stand on glass here and nowhere else, so their two colours are re-read for
   ice rather than left as the panel-text pair they were drawn in. */
.tcard .badge.draw { color: var(--ink-2); }
.tcard .badge.nochat { color: var(--muted-deep); }

/* 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;
  /* The handoff's EMPTY SLOT kind (§ 4): a dashed `#9fb3c9` on the sheet. A white alpha
     was the right edge over dark felt and is nothing at all over ice. */
  border: 0.4vmin dashed var(--dashed-edge); 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.

   BOTH VALUES ARE `--red` NOW, not `--punt-edge`/`--red-pale`. Those two are the pale red
   and the red alpha the palette keeps for type and edges on a DARK panel; this cell stands
   on the sheet, where `#ff9a9e` at 59% is a pink smudge nobody can read. The panel it
   belongs to moved to ice in story 10.4 and its one colour had not. */
.tempty.bad { border-color: var(--red); }
.tempty.bad .tename { color: var(--red); }
/* 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(--ring); outline-offset: 0.9vmin;
}

/* ---------- the chip row's right-hand end: the invite code ----------
   `3b` puts "Have an invite code?" on the same line as the chips, at the far end. It is the
   OTHER way into a table and it belongs beside the filters rather than under the grid, where
   it used to sit six cards below the thing it is an alternative to. Nothing about it changed
   but where it stands: the same field, the same button, the same hooks.

   It wraps as a unit. Below the two-column breakpoint the chips take one line and this takes
   the next, rather than the field being squeezed into whatever the chips left. */
.frow {
  display: flex; flex-wrap: wrap; align-items: center; justify-content: center;
  gap: 1.6vmin; width: 100%;
}
.frow .fchips { flex: 1 1 auto; width: auto; justify-content: flex-start; }
.frow .field { flex: 0 0 auto; grid-auto-flow: column; align-items: center; gap: 1.2vmin; }
.frow .field input[type="text"] { font-size: max(16px, 2.6vmin); }
@media (max-width: 900px) {
  .frow .fchips { justify-content: center; }
}

/* ---------- the quick-play bar ----------
   Handoff § 6, screen `3b`: a bar pinned along the bottom of the browser, in the boards'
   own `--boards` with the yellow accent on its headline and the primary action at its right
   end. EXPERIENCE.md § Navigation is explicit that this is a good home for the press and
   that it changes nothing about what the press DOES — "Play now prefers people, and asks
   nothing" is the same rule from either door.

   IT IS NOT A SECOND FRONT DOOR AND IT DOES NOT CARRY THE FRONT DOOR'S HOOK. `play-now` is
   the landing's, and a second element wearing it would shadow the real one in document
   order — the same trap the create screen's own comment names about the seating id. This one
   is `quick-play`, and the handler it calls is the same function, told which button it was
   pressed from so the "working" state lands on the button the player actually pressed. */
.qbar {
  display: flex; align-items: center; flex-wrap: wrap; gap: 1.6vmin;
  width: 100%;
  background: var(--boards); border-radius: 1.4vmin;
  padding: 1.6vmin 2.4vmin;
  text-align: left;
}
.qbar .qtitle {
  font-family: var(--display); font-weight: 900; font-style: italic;
  text-transform: uppercase; letter-spacing: -0.02em;
  font-size: 2.8vmin; line-height: 1.1; color: var(--yellow); margin: 0;
}
/* `flex: 1 1 auto` with a zero floor, so the sentence is what gives way when the bar is
   narrow — without it the BUTTON was the item that wrapped, and the one thing on this bar
   worth pressing dropped onto a line of its own under a full-width line of explanation. */
.qbar .qwhat {
  font-family: var(--mono); font-weight: 500; font-size: 1.9vmin;
  color: var(--panel-text-2); margin: 0; flex: 1 1 auto; min-width: 0;
}
/* The press goes to the right end, and it is the only thing here that moves. */
.qbar .chrome { margin-left: auto; font-size: 2.8vmin; padding: 1.1vmin 3vmin; }
@media (max-width: 900px) {
  .qbar { justify-content: center; text-align: center; }
  .qbar .chrome { margin-left: 0; }
}

/* ==========================================================================
   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; }

/* THE HEADER IS THE BOARD HEADER. `3k` draws this screen under the same 78px `--boards`
   bar with the same 5px `--rail` under it that `3b` and `3c` carry, and story 10.4 built
   that once as `.bcard`/`.bhead`/`.glass`. What stood here was `.rhead` — a title, a
   summary and a Back laid out on their own on the sheet, which is the felt direction's
   answer to a screen with no chrome to put them in.

   The summary takes the card's lower half. It is one sentence rather than a form or a grid,
   so the shared `.bcard > .glass` padding — sized for `3c`'s form and the portrait refusal's
   two paragraphs — is brought in, and the text goes left because it is prose being read
   rather than a lede being announced. */
#rules .bcard > .glass { padding: 1.8vmin 3vmin; justify-items: start; text-align: left; }
.rsum { margin: 0; max-width: 88ch; font-size: 2.3vmin; line-height: 1.4; font-weight: 500; color: var(--ink-2); }

/* `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;
  /* Each panel at its own height. On felt every card was the same dark as the sheet behind
     it and a stretched one was invisible; on ice a glass card stretched to its neighbour's
     height is a white box with a foot of nothing in it, and the score panel — which is the
     shortest and the only dark one — was the worst of the five. `3k` stacks its cards at
     content height. The punt panel's footer chip still takes `margin-top: auto`, which now
     simply has nothing to push against, and is unchanged in every case where it did. */
  align-items: start;
  gap: 1.6vmin; width: 100%; text-align: left;
}
/* THE PANEL IS THE GLASS CARD NOW, and the top rail survives on it.
   What stood here was `--panel` at `--edge-strong`: five dark cards, correct on felt and,
   on a light sheet, five holes punched in the ice — the same defect the create screen's
   groups and the results standings each had, and the same answer. `3k` draws this screen as
   glass cards on a washed rink, so these take § 4's glass card (story 10.4's component)
   rather than a sixth near-copy of it, and the coloured `border-top` that says which
   question a panel answers rides on top of it exactly as `.glass[data-status]`'s does.

   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(--glass);
  border: 0.3vmin solid var(--glass-edge);
  border-top: 0.8vmin solid var(--rail);
  border-radius: 1.4vmin;
  padding: 1.6vmin 1.8vmin 1.8vmin;
  box-shadow: 0 0.85vmin 0 var(--glass-hard), 0 1.95vmin 3.35vmin var(--glass-ambient);
  color: var(--ink-2);
}
/* ONE PANEL IS DARK, AND IT IS THE HANDOFF'S OWN. `3k` draws "Count down to zero" as a
   `--panel` card with a yellow display heading and everything else on the screen as glass —
   the score is the thing the whole game is about, and one dark card among five is how that
   screen says so. It is the score panel, so the token that says which one is the panel's
   name and not a decoration class. */
.rpanel[data-panel="score"] {
  background: var(--panel);
  /* THREE SIDES, NOT THE SHORTHAND, and that is the whole of epic 10's action item 8.
     `border-color` sets all four, including the top — and the top belongs to the RAIL rules
     below, which are `[data-rail="..."]`: a class plus an attribute, exactly the specificity
     this selector has. Nothing broke, because the rail rules come AFTER and equal specificity
     is settled by source order. That is the defect: the score panel's green rail was surviving
     on the ORDER OF TWO BLOCKS and nothing said so. Move either block past the other — a tidy-up,
     a merge, a sort — and the rail silently becomes `--edge-strong` on the one panel the whole
     screen is built around, with no diff that looks like a colour change.
     Naming the three sides this rule actually means leaves the top uncontested, so the cascade
     stops being load-bearing. Computed output is unchanged: the rail won before and it is the
     only claimant now.
     KEPT AS TWO ATTRIBUTES on purpose. Every `.rpanel` carries `data-rail` and `data-panel`
     with the SAME value (index.html:876-971), so one of them is redundant — but collapsing
     them is a markup change across five sections that `check-selectors-resolve.mjs` and the
     browser suites both read, and it cannot be run from here. The collision is what was
     dangerous; the redundancy is only untidy. */
  border-right-color: var(--edge-strong);
  border-bottom-color: var(--edge-strong);
  border-left-color: var(--edge-strong);
  box-shadow: var(--lift-button);
  color: var(--text-body);
}
.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); }

/* Ink, because these stand on glass now. DESIGN.md § Colours names picking the wrong one
   of the ink/panel-text pair as the likeliest contrast failure in this direction, and a
   `--white` heading on a `#ffffffec` card is that failure exactly. The dark panel keeps the
   pair's other half, and takes the yellow `3k` sets its heading in. */
.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(--ink);
}
.rpanel[data-panel="score"] .rtitle { color: var(--yellow); }
.rpanel[data-panel="punted"] .rtitle { color: var(--red); }

/* 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: inherit; font-weight: 400;
}
.rlist li { position: relative; padding-left: 2.6vmin; overflow-wrap: break-word; }
/* The dot follows the panel it is on. Yellow is legible on the dark score panel and is
   1.13:1 on glass — the same measurement DESIGN.md § Typography makes about the landing
   wordmark — so on the glass panels it is the blue line, which is the sheet's own marking
   colour and reads as one of the rink's rather than as a spent accent. */
.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(--blue-line);
}
.rpanel[data-panel="score"] .rlist li::before { 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(--ice-inset); border: 0.3vmin solid var(--red);
  border-radius: 0.8vmin; padding: 1vmin 1.4vmin;
}
/* The figure in `--red`, which is the palette's own punt hue and is what this chip is
   about. `--red-pale` was drawn to sit on a dark panel; on a pale inset it disappears. */
.rchip .k {
  font-family: var(--display); font-weight: 900; font-style: italic;
  font-size: 3.4vmin; line-height: 1; color: var(--red);
}
.rchip .v { font-size: 2vmin; line-height: 1.3; color: var(--ink-3); 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.

   IN INK, NOT IN YELLOW. It was yellow against a dark panel; this panel is glass, and
   `--yellow` on `#ffffffec` is the 1.13:1 DESIGN.md § Typography measures on the landing
   wordmark — where it reads only because a 16.61:1 ink STROKE stands between the two. There
   is no stroke to hide behind at 8.6vmin outlined, so the glyph takes the ink. A spade is
   black besides, which is the one place this direction gets a contrast decision for free. */
.rspade { flex: 0 0 auto; font-size: 8.6vmin; line-height: 1; color: var(--ink); }
@supports (-webkit-text-stroke: 1px black) {
  .rspade { color: transparent; -webkit-text-stroke: 0.35vmin var(--ink); paint-order: stroke fill; }
}

/* ---------- the legend: the marks, as the table draws them ----------
   These are `.puck` and `.bdg` — the SAME rules the table 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 sheet at a
   size this surface can read. `min-height`/`min-width` are not needed: --px has a floor. */

/* SCOPED TO THE PANEL, the other half of story 10.5's fix. `.rmarks` is also the standings
   row's marks cell (`:1150`), and 10.5 qualified THAT side to `.trow .rmarks` while leaving
   this one bare — so this rule still reached the standings row and landed `margin`,
   `padding`, `list-style`, `display: grid` and a re-based `--px` on it. Inert, because the
   `.trow` rule outranks it on the one property they both declare; live the moment either
   rule declares anything the other does not. Scoping settles it on specificity in both
   directions, which is what "one name, one owner" actually costs. The descendants below go
   with it: a bare `.rmarks .puck` is the same claim on the whole document, and it only
   failed to land because the standings draw `.badge` rather than a puck. */
.rpanel .rmarks {
  --px: max(0.2vmin, 1.1px);
  margin: 0; padding: 0; list-style: none; display: grid; gap: 1.6vmin;
}
.rpanel .rmarks li { display: flex; align-items: flex-start; gap: 1.6vmin; }
.rpanel .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. */
.rpanel .rmarks .puck { position: static; transform: none; transition: none; flex: 0 0 auto; }
/* Ink, for the reason the panel headings are: this legend stands on a glass card now, and
   `--text-body` is the token drawn for type on a dark panel. */
.rpanel .rmarks .rwhat { min-width: 0; font-size: 2vmin; line-height: 1.35; color: var(--ink-2); font-weight: 400; }
.rpanel .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(--ink);
}

/* ==========================================================================
   THE PROXIMITY DIAGRAM STOOD HERE AND IS GONE (owner, epic 14).

   `3k`'s small rink drew six figures to explain "where everyone stands" — on a screen a
   player opens before they have stood anywhere. Epic 13's recon is explicit that up-front
   explanation does not survive contact and that the teaching which works arrives AT the
   decision, which is what story 13.12's served lines do. The formation teaches itself by
   being the ice in front of the player.

   ~100 lines of stylesheet went with it, along with `renderRulesDiagram` in app.js and the
   check that measured it. The rules screen keeps what a player looks UP mid-hand.
   ========================================================================== */


/* ---------- 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(--ink-4); font-family: var(--mono); }

/* ---------- board header + glass card ----------
   Handoff § 4 Chrome, the two pieces of chrome the system gives a screen it never drew.
   The header's numbers are the handoff's own 78px tall / `--boards` / 5px `--rail` bottom
   border / 30px display italic, taken to vmin off the same 720-tall frame every other
   pre-table measurement on this sheet uses (78/720 = 10.8vmin, 5/720 = 0.7vmin,
   30/720 = 4.2vmin) — because these surfaces are sized in vmin and not in `--px`, which is
   the table's container unit and resolves to about 0.7px out here.

   Built by the portrait refusal (story 10.7) and WIDENED here rather than copied — story
   10.4's first task is "built once and shared", and half of it already existed. What 10.4
   added is the parts a header the handoff actually drew needs: a mono meta line beside the
   title (`3b`'s "7 rinks live"), an actions group pushed to the right edge (`3b`'s Refresh
   and Create table), and a `.glass` that is a COMPONENT in its own right rather than only
   the bottom half of one — the create screen's form stands in one, and `3b`'s six room
   cards are six of them with a status border. Three screens carry this now. */
.bcard {
  width: min(92vmin, 96%); max-width: 90vmin;
  border-radius: 1.4vmin; overflow: hidden;
  box-shadow: 0 0.9vmin 0 var(--glass-hard), 0 2vmin 3.4vmin var(--glass-ambient);
}
/* The two screens that ARE the card take the sheet's whole measure; the refusal keeps the
   narrower one it was drawn at, which is what a sentence wants and what a grid of six does
   not. `max-width: none` so the SHEET is the single thing deciding how wide these screens
   are — a cap here as well would leave the card floating inside a sheet it does not fill,
   and then the grid inside floating inside the card. One measure, one place. */
.bcard.full { width: 100%; max-width: none; }
.bhead {
  min-height: 10.8vmin; box-sizing: border-box;
  display: flex; align-items: center; gap: 2vmin;
  padding: 1.6vmin 3vmin;
  background: var(--boards); border-bottom: 0.7vmin solid var(--rail);
}
/* The dark half of the card is in the list of GROUNDS above, so a control inside it takes
   the yellow ring rather than the ink one — see "THE GROUND DECIDES THE RING". */
.bhead h2.disp {
  margin: 0; font-family: var(--display); font-weight: 900; font-style: italic;
  text-transform: uppercase; letter-spacing: -0.02em; font-size: 4.2vmin; line-height: 1.1;
  color: var(--white);
}
/* The mono line the handoff sets beside the title — a fact about what is on the screen,
   never a control and never a number this client made up. § 3 keeps anything numeric or
   meta in JetBrains Mono, and § 2's yellow is the accent it is set in on the boards. */
.bhead .bmeta {
  font-family: var(--mono); font-weight: 500; font-size: 1.8vmin;
  letter-spacing: 0.1em; text-transform: uppercase; color: var(--yellow);
  white-space: nowrap;
}
/* `margin-left: auto` rather than `justify-content: space-between`, so the meta stays next
   to the title it qualifies instead of drifting to the middle of an empty header. */
.bhead .bacts { margin-left: auto; display: flex; gap: 1.2vmin; align-items: center; flex-wrap: wrap; justify-content: flex-end; }
.bhead .chrome { font-size: 2.4vmin; padding: 0.8vmin 2vmin; }
.bglyph { font-size: 5.4vmin; line-height: 1; }
/* THE GLASS CARD, handoff § 4 Chrome: `#ffffffec` at 2px `#ffffff`, radius 10, and the
   signature hard-offset-plus-ambient in the cold blue-grey a shadow on ice has. Its STATUS
   is a 5px top border, which is the second channel beside whatever word the card says.

   A component, not a modifier of `.bcard`: it stands alone on the create screen and it is
   what a browser card is made of. Inside a `.bcard` it is the header's lower half instead,
   which is the override below — no top border of its own (the header is above it) and the
   card's radius on the bottom two corners only.

   IN VMIN, NOT IN `--lift-glass`. That token is written in `--px`, which is `.table`'s own
   container unit; outside that container it falls back to the viewport and happens to land
   near the right number, which is worse than being wrong — it is a shadow whose size nobody
   can predict from the rule. Every other measurement on these surfaces is vmin off the
   handoff's 720-tall frame, so these are too: 6/720, 14/720, 24/720. */
.glass {
  background: var(--glass);
  border: 0.3vmin solid var(--glass-edge);
  border-radius: 1.4vmin;
  box-shadow: 0 0.85vmin 0 var(--glass-hard), 0 1.95vmin 3.35vmin var(--glass-ambient);
  color: var(--ink-2);
}
/* The three the handoff names. `invite only` has no user in this product — the server
   keeps invite-only tables out of browse entirely — and it is declared anyway, once, as
   the component's own third state rather than left for whoever needs it to re-derive.
   Recorded in the spec's change log rather than silently dropped. */
.glass[data-status="waiting"] { border-top: 0.7vmin solid var(--green-solid); }
.glass[data-status="in-play"] { border-top: 0.7vmin solid var(--red); }
.glass[data-status="invite"]  { border-top: 0.7vmin solid var(--muted); }
.bcard > .glass {
  border-top: 0; border-radius: 0 0 1.4vmin 1.4vmin; box-shadow: none;
  padding: 3.4vmin 3vmin; display: grid; gap: 2vmin; justify-items: center; text-align: center;
}
.bcard > .glass p.lede { margin: 0; max-width: 46ch; }

/* ---------- the UI label, in one place ----------
   § 3's third register: Archivo 700/800, uppercase, tracked 0.10–0.18em, 11px floor. It is
   what names a group of controls, and it is written HERE because two of its sites are the
   two halves of one decision: "fill empty seats" on the create screen and the same question
   asked again at the felt. Those two must read the same — a host who set an intent one
   screen ago has to recognise the control that honours it — and two labels built separately
   diverge on the first edit to either.

   The `--label-ink` indirection is what lets one rule serve both grounds: ink on the create
   screen's glass, a panel text on the felt's dark seating panel, and the RULE is the same
   rule. Its default is the ink, because the root ground is the sheet. */
.uilabel {
  font-family: var(--display); font-weight: 800;
  text-transform: uppercase; letter-spacing: 0.14em;
  color: var(--label-ink, var(--ink-3));
}

/* Rotate prompt. Portrait is refused deliberately: a ring restacked into a grid loses
   turn-order adjacency, and that is load-bearing in a trick-taking game. Story 10.7 gave
   it the system above and, more to the point, three things to press — the refusal was a
   dead end, and a player whose device is orientation-locked had no way off this screen at
   all. Its own rules are the two below and nothing more; everything else it wears is the
   component.

   AND IT HAS NO RULE HERE FOR STANDING ABOVE THE WASH, deliberately (story 11.5). The card
   used to sit directly on the surface, in the in-flow layer, under `.surface::before` — so
   the whole refusal was invisible except for its three `position: relative` buttons. The
   answer is the one every other surface already uses: `index.html` wraps the card in
   `.sheet`, which is `position: relative; z-index: 1`. Nothing is added here, because a
   second way of saying "above the wash" is how two screens end up disagreeing about it. */
#rotate { z-index: 90; }
/* Why it exists at all, in the smaller of the two body sizes so the offer reads as a
   footnote to the refusal rather than as a second refusal. */
#rotate .rotout {
  margin: 0; font-size: 2.1vmin; font-weight: 500; color: var(--ink-3);
  font-family: var(--body);
}

/* ==========================================================================
   THE TABLE — the sheet. Handoff § 4 Rink, and Rink.dc.html as built.
   ========================================================================== */

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

.table {
  position: relative;
  width: 100%;
  height: 100%;
  overflow: hidden;
  container-type: size;
  container-name: tbl;
  /* The gradient IS the sheet's first layer — `linear-gradient(180deg,#f6fafd,#dde9f5
     62%,#cddef0)`, Rink.dc.html's own. The border and radius are the frame every one of
     the handoff's thirteen screens is drawn inside.

     The COLOUR under the gradient is declared as well as the image, and that is not
     belt-and-braces: `layout-check.mjs` reads the ground a focus ring is drawn against by
     compositing background colours up the ancestor chain, and a box carrying an image with
     no colour reports its parent's. The sheet's own mid stop is what that reading should
     find here. */
  background-color: var(--ice-mid);
  background-image: linear-gradient(180deg, var(--ice-top) 0%, var(--ice-mid) 62%, var(--ice-base) 100%);
  border: calc(6 * var(--px)) solid var(--rail);
  border-radius: var(--r-table);
}

/* ==========================================================================
   THE ICE — the only surface.

   Drawn ENTIRELY IN CSS: there is no tile, no image and nothing for trump to re-motif.

   IT IS A WHOLE RINK NOW, AND A CAMERA LOOKING AT PART OF IT (story 12.21). Every figure
   below is a distance in FEET on a 200 x 85 ft sheet, and `--ft` at § the unit turns feet
   into pixels — one scale, both axes, at every window shape. The camera is `--cam-l` /
   `--cam-w`, and it is on the centre-ice faceoff spot.

     the vertical gradient (on `.table` above)
     the white sheen        radial-gradient(56% 58% at 50% 40%, #ffffffee, transparent 72%)
     the boards             200 x 85 ft, 28 ft corner radius, 1 ft of paint, and the cap
                            rail painted over everything past them
     the trapezoids         11 ft behind each goal line, 22 ft wide at the line and 28 at
                            the boards — its two diagonals
     two goal creases       8 ft across, 6 ft deep, opening onto the ice from the goal line
     two goal lines         89 ft from centre ice, 2 in wide, 73.5 ft long
     four end-zone
       faceoff circles      69 ft from centre, 22 ft off the middle, 30 ft across, in RED,
                            with a 2 ft spot and four hash marks each
     two blue lines         25 ft either side of centre, 12 in wide
     the centre red line    centre ice, 12 in wide, red with white dashes along it
     the referee's crescent 10 ft on the boards at centre ice
     four neutral-zone
       faceoff spots        20 ft from centre (5 ft inside a blue line), 22 ft off the middle
     the centre ice
       faceoff circle       30 ft across, 1.283 ft of --blue-line, on the drop
     the centre ice
       faceoff spot         12 in, with the drop spot over it at 4 ft
     the diagonal gloss     linear-gradient(115deg, …, #ffffff5c 46%, …)
     the inset              inset 0 0 90px #2b4a7526

   THE NAMES ARE `Ice_hockey_layout.png`'s (story 12.15) — boards, goal line, blue line,
   centre red line, neutral zone, end zone faceoff spot and circle, centre ice faceoff spot
   and circle. The owner supplied that diagram in order to describe where things go by it, so
   an instruction given in its words has to land on something here. What this sheet draws is
   everything on that diagram except the nets, which story 12.3 made Ask First and 12.21 did
   not renegotiate.

   The drawn sheet's own numbers were 5%-wide blue bands at 13.4%, a 4%-wide centre line and a
   520px circle. Story 12.2 took centre ice to regulation, story 12.3 took the bands with it as
   shares of the length, and story 12.21 took the whole sheet off shares and onto feet — see
   § the unit for the camera and THE RINK TURNS WITH THE SHEET below for the orientation rule.
   Story 12.15 took the COLOURS and the dash pattern from the owner's own reference:
   `center_ice.png` is the picture the game should look like, and centre ice is blue on it.

   THE DEPARTURE FROM THE DRAWN SHEET IS GONE, AND SO IS THE PROBLEM IT SOLVED. Centre ice
   used to be anchored to the TRICK PILE rather than to the middle of the rink, because
   centred on a stretched sheet it landed in the dead space between the pile and the hand.
   With a camera the two are the same place by construction: `--trick-y` IS where the camera's
   centre is drawn, so the pile sits on the drop and played cards land inside the circle —
   DESIGN.md § The ring — without anything being anchored to anything.

   Round marks are sized with ONE LENGTH ON BOTH AXES for the reason the dealer puck is:
   one length on both axes is what keeps a circle from becoming an ellipse as the table's
   aspect changes. Since 12.21 that length is a diameter in FEET, which is the same claim
   made where it cannot be quietly lost — a circle drawn round on stretched ice is still a
   lie about the ice, and `layout-check.mjs` measures its diameter on both axes in feet. */
/* z-index 2 — ABOVE `.vig`'s sheen and gloss at 1, and below everything that carries
   information: the pile at 8, the deal flight at 10, the deck at 11, the seats at 12 and the
   puck at 13. Nothing at all stands between this and the pile since story 12.9 took the pools
   of light out at 3, and `layout-check.mjs` reads that band off THIS element's z-index and the
   pile's rather than off these numbers. See `.vig` below for the measurement that moved this
   layer and for what the old order was doing to every marking on the sheet. */
/* AND IT IS A LAYER OF ITS OWN (story 12.21). The sheet is vector art now — four red arcs, a
   crescent, two rounded creases and a 28 ft corner radius, all antialiased — and a seat card
   above it transitions its border colour, which promotes the card and forces the tile behind it
   to be re-rasterised. Chromium's second rasterisation of a curve is not bit-identical to its
   first: `browser-client.mjs` photographs one card in five states from a fixed clip and
   SUBTRACTS the shots, and 45 pixels of an end-zone circle's antialiased edge came back 20 to
   120 units different, which that reader can only read as paint the state drew — 47.9px outside
   a card whose own states reach 0. `will-change` pins the ice on its own layer so a card's
   transition cannot re-raster it. The ice is a static backdrop; it has no business being
   repainted because a border changed colour. */
.rink {
  position: absolute; inset: 0; z-index: 2; pointer-events: none; display: block;
  will-change: transform;
  /* ---------- THE PAN (story 13.8) ----------
     The camera is a translation of the ICE and of nothing else. Every mark inside this box is
     drawn at its own place in feet about the principal point; sliding the box by `-(cam x ft)`
     puts the camera's point under that principal point, and the pile, the faceoff and the
     bench — which are drawn about the principal point and are not in this box — stay exactly
     where they were. That is why a moving camera did not have to re-derive one number of the
     layout: the drop is still at the principal point, it is the ice underneath it that moved.

     WIDE: the rink's length is the sheet's WIDTH, so `--cam-l` translates X. Tall, they swap —
     see the container query at § THE RINK TURNS WITH THE SHEET, which is the one place the two
     mappings are written and where this transform is restated for the turned frame.

     1.15s is the SKATE, deliberately: the seats re-form for the new hand on `.seat`'s own
     1.15s left/top transition, so the ice and the players arrive together instead of the sheet
     landing under seats still travelling. */
  transform: translate(calc(-1 * var(--cam-l) * var(--ft)), calc(-1 * var(--cam-w) * var(--ft)));
  transition: transform 1.15s cubic-bezier(0.22, 0.61, 0.36, 1);
}

/* ==========================================================================
   THE NINE SPOTS — THE ONE TABLE, IN ONE PLACE (AD-15, story 13.8)
   ==========================================================================

   The server chooses which faceoff spot a hand is played at and publishes its IDENTITY on
   every frame. table.js writes that identity onto `#felt` as `data-spot` and writes NOTHING
   ELSE — no coordinate, no branch, no arithmetic. This is where an identity becomes a place.

   ON THE TABLE, NEVER ON `:root`. `.rdice`, the rules screen's ice diagram, reads `--cam-l`
   and `--cam-w` too; a `:root` write would pan a static diagram in step with a live game
   nobody is looking at it from. `.rdice` re-declares both to zero anyway (see § the rules
   diagram), so the rule is stated twice on purpose: once where it is written and once where
   it must not arrive.

   THE COORDINATES ARE THE STYLESHEET'S OWN, and they are the same nine the markings are drawn
   at up at § EVERY MARK IS A PLACE ON REAL ICE — neutral-zone spots 20 ft off centre and 22
   across, end-zone spots 69 and 22. West is the `.l` end and East the `.r` end; North is the
   `.a` side and South the `.b` side, which is the mapping the enum's own comment carries.

   ---- EVERY SEAT STAYS ON THE ICE, AND THAT IS WHAT DECIDES THE TWO CORRECTIONS BELOW ----

   Owner's ruling: no seat is drawn on the rail at any spot. It binds, and it kills the width
   pan outright — not because of the BENCH but because of the PLAYERS. A camera 22 ft across
   the width leaves 20.5 ft of ice between the drop and the near board, and the faceoff's own
   wings stand 215.6 --px off the drop wide and 261.9 upright. Measured at the seven admitted
   viewports, in --px from the drop's line:

       viewport          1 ft is    ice to the near board    the faceoff needs    verdict
       1:1   900x900     15.06           309                       215.6          fits
       4:3  1200x900     11.26           231                       215.6          fits by 15
       16:9 1600x900      8.47           174                       215.6          OFF by 41.6
       16:9 1280x720      8.47           174                       215.6          OFF by 41.6
       1:0.98 1280x1260  14.82           304                       215.6          fits
       9:19.5 390x844     8.47           174                       261.9          OFF by 87.9
       3:4   810x1080    11.26           231                       261.9          OFF by 30.9

   Four of seven, and the thing that does not fit is the faceoff, so no bench arrangement can
   rescue it. `--cam-w` IS THEREFORE 0 AT EVERY SPOT. The short screen axis is exactly 85 ft of
   ice by construction of `--ft`, so with the camera home on the width **the whole visible width
   is ice** and no seat — at the drop or at the bench — can leave it across that axis, at any
   spot, at any window shape. It is a guarantee rather than a measurement.

   WHAT IT COSTS, STATED: the drop's own 22 ft of width offset is no longer brought to the
   middle of the screen, so the enlarged drop mark stands off the pile. That is why the table
   carries `--spot-l` / `--spot-w` as well: the MARK is drawn on the faceoff spot the server
   actually named — the sheet says which faceoff this hand is — while the pile and the formation
   stay on the principal point, which is the point every clearance in table.js is written
   against. Bringing them together needs a pan the ice cannot pay for, and the table above is
   the receipt.

   AND `--cam-lead` HAS REAL WORK AFTER ALL, which corrects this file's own earlier note. It is
   the per-spot pull-back, IN FEET, toward centre ice — exactly the "correction that depends on
   which spot was selected" AD-15 provisioned. An end-zone spot is 69 ft out and 31 ft from the
   end board, and the faceoff's own deepest seat stands `--face-r` plus its badge column off the
   drop: 298.71 + 85.6 = 384.3 --px wide, which at 16:9 is 45.4 ft against the 31 there are. So
   the camera stops 18 ft short, which puts the board 49 ft out — 415 --px at 16:9 against the
   384.3 the faceoff needs, 31 --px of air — and every other frame has more. `--cam-l` is the
   spot's own length LESS the lead, and the two are written side by side rather than one being
   arithmetic on the other, because a reader has to be able to see both.

   TWELVE FEET WAS TRIED FIRST AND MEASURED SHORT, and the arithmetic that missed is worth
   recording: it was derived from the arc's facing LEG (260 --px), which is where a WING stands,
   and the deepest seat on the arc is the CENTRE, at the full radius. The probe said so — in
   seats off the ice by 9.4 to 22.7 px at both 16:9 frames, at all four end-zone spots.

   THE ALTERNATIVE — shrinking the faceoff at an end-zone spot instead of pulling the camera
   back — is refused with a number: the ice there is 31 ft, so the arc's radius would have to
   come inside 176.4 --px at 16:9, against the 229.6 the faceoff CIRCLE's own clearance already
   demands. The seats would be standing in the circle, which is what stories 12.2, 12.8 and
   12.12 exist to prevent.

   THE LEAD IS ZERO WHERE IT IS NOT NEEDED. A neutral-zone spot is 80 ft from its near board
   (678 --px at 16:9) against the 384.3 the faceoff needs, so nothing is pulled back and the
   drop mark sits at the principal point along the length.

   AN UNRECOGNISED IDENTITY HAS NO ROW AND THEREFORE READS THE `:root` DEFAULTS — centre ice.
   That is the fail-closed clause holding structurally rather than by a branch in JS. */
.table[data-spot="CentreIce"] {
  --spot-l: 0; --spot-w: 0; --cam-lead: 0; --cam-l: 0; --cam-w: 0;
  --spot-side: -1;
}
.table[data-spot="NeutralZoneWestNorth"] {
  --spot-l: -20; --spot-w: -22; --cam-lead: 0; --cam-l: -20; --cam-w: 0;
  --spot-side: -1;
}
.table[data-spot="NeutralZoneWestSouth"] {
  --spot-l: -20; --spot-w: 22; --cam-lead: 0; --cam-l: -20; --cam-w: 0;
  --spot-side: 1;
}
.table[data-spot="NeutralZoneEastNorth"] {
  --spot-l: 20; --spot-w: -22; --cam-lead: 0; --cam-l: 20; --cam-w: 0;
  --spot-side: -1;
}
.table[data-spot="NeutralZoneEastSouth"] {
  --spot-l: 20; --spot-w: 22; --cam-lead: 0; --cam-l: 20; --cam-w: 0;
  --spot-side: 1;
}
.table[data-spot="EndZoneWestNorth"] {
  --spot-l: -69; --spot-w: -22; --cam-lead: 18; --cam-l: -51; --cam-w: 0;
  --spot-side: -1;
}
.table[data-spot="EndZoneWestSouth"] {
  --spot-l: -69; --spot-w: 22; --cam-lead: 18; --cam-l: -51; --cam-w: 0;
  --spot-side: 1;
}
.table[data-spot="EndZoneEastNorth"] {
  --spot-l: 69; --spot-w: -22; --cam-lead: 18; --cam-l: 51; --cam-w: 0;
  --spot-side: -1;
}
.table[data-spot="EndZoneEastSouth"] {
  --spot-l: 69; --spot-w: 22; --cam-lead: 18; --cam-l: 51; --cam-w: 0;
  --spot-side: 1;
}

/* ---------- AND WHAT A PLAYER WHO ASKED FOR REDUCED MOTION SEES (story 13.8) ----------

   THE PAN IS THE LARGEST MOTION EVENT IN THE PRODUCT and it is the one motion in this app
   whose reduced-motion answer is NOT "arrive already there". Everywhere else the moving thing
   is an OBJECT — a card, a seat, a puck — and removing its journey leaves the ground still.
   Here the moving thing IS the ground: a teleport swaps the entire visual field between two
   frames, which is the presentation the vestibular reason for this preference most objects
   to. Neither the pan nor the teleport, and the third answer is the one film has always used
   for "we are somewhere else now":

     THE PAN BECOMES A CUT, ON A DISSOLVE. The ice does not travel: it fades down, is moved
     while it cannot be seen to move, and fades back up in its new place. Nothing translates
     across the retina at all, and the 360ms it takes is longer than the change is, so the
     player is told a change happened rather than being shown it happening.

   The dissolve is on the ICE ALONE. The seats, the pile, the hand and the chrome never fade —
   they are not moving, so there is nothing about them to soften, and dimming the cards a
   player is being asked about to announce a camera move would be the surface interrupting the
   game. `table.js` adds `.cut` for the length of the fade and takes it off again; it is added
   only when the spot has actually changed, so a re-render cannot flicker the ice.

   It is also carried in WORDS, which is the half a picture cannot do under this preference:
   the feed line for the deal is what tells a player where the hand is being played, and it is
   there whether or not anything moved. */
@media (prefers-reduced-motion: reduce) {
  /* No journey for the transform — the ice arrives in its new place between two paints —
     and the only thing that is allowed to take time is the fade that hides the arrival. */
  .rink { transition: opacity var(--cut-ms) linear; }
  .rink.cut { opacity: 0; }
}
/* Read by table.js so the wait and the fade are ONE number rather than two that drift: the
   camera is not moved until the ice is down, and it is only down for as long as this. */
:root { --cut-ms: 150ms; }

/* ---------- EVERY MARK IS A PLACE ON REAL ICE (story 12.21) ----------

   A MARK CARRIES TWO NUMBERS, BOTH IN FEET, both measured from the middle of the rink:
   `--l` along its 200 ft length and `--w` across its 85 ft width. Those go through the
   camera — subtract where it is pointed, multiply by `var(--ft)` — and come out as `--at`
   and `--across`, two lengths on the sheet. That substitution is the whole of the change
   this story makes: `--at` used to be a PERCENTAGE OF THE CONTAINER, which is a number that
   means nothing unless the container is already 200 : 85, and the container was whatever
   shape the window was.

   THE SIGNS ARE CLASSES, so a family of mirrored marks is one rule rather than four. `l`
   and `r` are the two ends of the LENGTH — the same two ends the goal lines have always
   used — and `a` and `b` the two sides across it; `p`/`q` and `s`/`t` are the second pair
   of mirrors the end-zone hash marks need. A mark with none of them sits on the axis. */
.rink i {
  position: absolute;
  --lsign: 0; --wsign: 0; --psign: 0; --ssign: 0;
  --l: 0; --w: 0;
  /* THE ANCHOR IS THE CAMERA'S PRINCIPAL POINT, `(50%, var(--trick-y))`. Whichever of the two
     rink axes maps to `top` anchors at `--trick-y` and the other at 50%, so the drop is drawn
     where the trick pile is and centre ice cannot drift from it. Wide, the rink's WIDTH is the
     vertical, so `--across` takes it; tall, its LENGTH is, so `--at` does (see the container
     query below). */
  /* NO CAMERA TERM HERE SINCE STORY 13.8. A mark is at its own place in feet about the
     principal point, full stop; the camera is a translate on `.rink` itself, so one
     composited transform moves all 44 of these instead of 44 recalculated positions. What
     was `(--l - --cam-l)` is now `--l`, and the subtraction happens once on the box. */
  --at: calc(50% + var(--l) * var(--ft));
  --across: calc(var(--trick-y) + var(--w) * var(--ft));
  /* EVERY SIZE A MARK CAN CARRY, IN FEET, AND ALL OF THEM DECLARED HERE. They are on the base
     rule rather than on `.band` / `.shape` / `.round` for a reason worth stating, because the
     first cut of this story got it wrong and the sheet said so: `.rink i.band` is a class AND a
     type, so it outranks `.rink .goal`, and a goal line asking for 73.5 ft of span was answered
     with the band default of 85. Defaults belong on the least specific rule there is.
       `--paint`  a band's own width, along the rink   `--span`  how far it runs across
       `--d`      a round mark's diameter              `--dl`/`--dw`  a shape's two extents
       `--tilt`   a shape's own turn on top of the sheet's */
  --paint: 0; --span: 85; --d: 0; --dl: 0; --dw: 0; --tilt: 0deg;
  /* Which way the sheet is holding the rink, for the shapes that are not symmetric about
     both axes. Wide, the rink's length is the sheet's width and nothing turns; tall, the
     shape is rotated a quarter turn so its own long axis runs down the sheet, and flipped
     so that the quarter turn does not mirror it across. `--tsign` is the same turn read by
     anything carrying its own tilt (the trapezoid's two diagonals). */
  --turn: 0deg; --flip: 1; --tsign: 1;
}
.rink i.l { --lsign: -1; }
.rink i.r { --lsign: 1; }
.rink i.a { --wsign: -1; }
.rink i.b { --wsign: 1; }
.rink i.p { --psign: -1; }
.rink i.q { --psign: 1; }
.rink i.s { --ssign: -1; }
.rink i.t { --ssign: 1; }

/* ---------- THE RINK TURNS WITH THE SHEET (story 12.3) ----------

   A REGULATION RINK IS A PORTRAIT SHAPE, AND THAT IS THE WHOLE ARGUMENT. 200 x 85 ft is
   1 : 2.353. A phone held upright is about 1 : 2.16 — within a tenth of it. Landscape 16:9 is
   1 : 1.78. So the rink's LENGTH runs along whichever of the sheet's own axes is the longer
   one: across the width when the sheet is wide, from foot to head when it is tall. Upright
   that puts the blue lines and the centre red line ACROSS the sheet, the goal lines at the
   head and the foot, and story 12.8's two lines of players either side of the centre line
   rather than both on one side of it.

   Until this story the bands were `top: 0; bottom: 0; width: 5cqw` at every size, with no rule
   anywhere in the tall block at the foot of this file to turn them — so on a sheet twice as
   tall as it was wide, the rink's length ran the short way and the faceoff was drawn across a
   line that was parallel to it.

   THE TURN IS ONE RULE AND IT IS ASKED OF THE BOX, NOT OF THE COMPOSITION. `@container
   (aspect-ratio < 1)` reads the aspect of the nearest size container — `.table` for the felt,
   `.rdice` for the rules screen's diagram — so the marks follow the shape of the box they are
   painted in, with nothing written per mark and nothing keyed to `data-compose`. A third
   composition would inherit the turn rather than have to remember it, and the 16:9 diagram
   keeps the wide mapping without being held out of anything, which is the exception it has
   needed for every other tall rule (see § THE TALL FRAME).

   AND THE NUMBERS DO NOT CHANGE WHEN IT TURNS, which is why this is a handful of declarations
   rather than two sets of marks. A mark carries `--l` and `--w`, its place on the ice in FEET,
   and the camera turns those into `--at` along the rink's length and `--across` over its width.
   Wide, `--at` becomes `left` and `--across` becomes `top`; tall, they swap. One pair of
   numbers per marking, whichever axis the length is on. `l` and `r` are the two ends of the
   LENGTH — left and right on a wide sheet, head and foot on a tall one.

   REGULATION, and the source is the rulebook rather than this file: an NHL sheet is 200 x 85 ft;
   goal lines 11 ft from the end boards and 2 in wide; blue lines 75 ft from the end boards,
   50 ft apart and 12 in wide; the centre line at 100 ft and 12 in wide; the corner radius 28 ft.

     goal line       11 ft from the end boards      -89 ft and +89 from centre ice
     blue line       75 ft from the end boards      -25 ft and +25   (50 ft apart)
     centre line     100 ft                           0 ft
     blue and centre line width      12 in            1 ft of paint
     goal line width                  2 in            0.1667 ft

   Measured to the CENTRE of the paint. `layout-check.mjs` asserts every one of them IN FEET, at
   seven viewports and in both compositions, against a scale it derives from the markings
   themselves on both axes — which is the reading that catches this story's own defect coming
   back, and the reading the shares this table used to carry could never have taken.

   WHAT THAT REPLACES, TWICE OVER. The blue bands were 5cqw wide at 13.4cqw from each end: ten
   times regulation and at a third of the right place, the drawn sheet's proportions rather than
   a rulebook's. Story 12.3 put them at 37.5% and 62.5% of the length, which was right in the
   only sense a share can be — and wrong in the sense that mattered, because the box those were
   shares OF was `inset: 0` in a table of `100% x 100%` and therefore whatever shape the window
   was. At the owner's 1276x1262 window the sheet drew a SQUARE rink with every marking at its
   regulation share of it. Feet cannot be right about a rink and wrong about its shape.

   WHAT IS NO LONGER LEFT OUT. Story 12.3 recorded a deliberate omission here — the corner
   radius, the creases, the trapezoids — on two grounds: that `--r-table` is the handoff's frame
   and must not become 14%, and that "more lines is not more realism". The first still stands and
   `--r-table` is untouched; the corner radius is 28 ft on the BOARDS, which is a marking on the
   ice at the ice's own scale and not the table's frame. The second was renegotiated by the owner
   in this story's own words — "we eventually want to go to the other faceoff zones so it's
   important to have the whole rink" — so the whole sheet is drawn, and the camera is what
   decides how much of it is on screen. Only the nets are still off.

   AND THE MARKINGS AGREE WITH EACH OTHER BY CONSTRUCTION NOW. Story 12.10 had to make the
   centre line and centre ice agree, because the line was a share of the sheet and the circle was
   anchored to the trick pile, and the two could part company — they did, by 10cqh, for two
   stories. There is nothing left to disagree: the whole rink is one piece of geometry hanging
   off one camera, `--trick-y` is where that camera's centre is drawn, and the pile is drawn
   there too. `layout-check.mjs` still measures both directly — the seats against the line, and
   the circle's own centre against it — because a claim that is true by construction today is
   the kind that a later story removes the construction from. */
/* A BAND is a line ACROSS the ice: `--paint` feet of it along the rink's length, `--span`
   feet of it across the rink's width. It used to be `inset: 0 auto 0 var(--at)` — as long as
   the CONTAINER, which is why a goal line ran the full width of a sheet it is 73.5 ft of, and
   why a line drawn on a square sheet was a line drawn on a square sheet. A line is as long as
   the ice is wide, and the ice is 85 ft wide. */
.rink i.band {
  left: var(--at); top: var(--across);
  width: var(--thick); height: calc(var(--span) * var(--ft));
  transform: translate(-50%, -50%);
  --thick: calc(var(--paint) * var(--ft));
  /* WHICH WAY THE LINE ITSELF RUNS, for anything drawn ALONG it rather than across it. A
     band spans the sheet's SHORT axis, so its own axis is vertical on a wide sheet and
     horizontal on a tall one — and that is the only thing the centre red line's dashes need
     to know (story 12.15). One property, turned in the same two declarations everything else
     here is turned in, instead of a second dashed line in the tall block. */
  --along: 180deg;
}
/* A SHAPE is anything whose two axes carry different distances and whose geometry is written
   in the rink's own frame rather than the screen's — the boards and their 28 ft corner radius,
   the creases, the trapezoid's diagonals, the referee's crease. `--dl` feet along the rink,
   `--dw` across it, and the quarter turn below is what puts the rink's length down the sheet
   when the sheet is tall. Rotating rather than swapping two lengths is what lets a corner
   radius, a rounded crease and a tilted diagonal be written once. */
.rink i.shape {
  left: var(--at); top: var(--across);
  width: calc(var(--dl) * var(--ft)); height: calc(var(--dw) * var(--ft));
  transform: translate(-50%, -50%) rotate(calc(var(--turn) + var(--tilt))) scaleY(var(--flip));
}
/* ANYTHING THAT NEEDS BOTH AXES rather than one. A band is a line and carries a single share
   — where it sits along the length. A SPOT is a point and carries two: `--at` along the
   length, exactly as a band does, and `--across` over the width. The turn below swaps which
   share becomes `left` and which becomes `top`, and that is the whole of it: nothing about a
   spot is written twice and no spot knows which composition it is in. Added for the
   neutral-zone faceoff spots (story 12.15); `.circle`, `.spot` and `.drop` do not use it
   because centre ice is anchored to the trick pile rather than to a share of the sheet. */
.rink i.zone {
  left: var(--at); top: var(--across);
  transform: translate(-50%, -50%);
}
/* A round mark: ONE LENGTH ON BOTH AXES, always, which is what keeps a circle from becoming
   an ellipse. `--d` is its diameter in FEET, so it is regulation at every window shape rather
   than at one of them. */
.rink i.round {
  width: calc(var(--d) * var(--ft)); height: calc(var(--d) * var(--ft));
  border-radius: 50%;
}
@container (aspect-ratio < 1) {
  /* The two coordinates swap which CSS property they are written to, so the anchors swap with
     them: the rink's LENGTH is the vertical here, so `--at` takes `--trick-y`. */
  .rink i {
    --at: calc(var(--trick-y) + var(--l) * var(--ft));
    --across: calc(50% + var(--w) * var(--ft));
  }
  /* AND THE PAN TURNS WITH THEM (story 13.8). The rink's length is the vertical here, so
     `--cam-l` translates Y and `--cam-w` translates X — the same swap the two coordinates
     above make, written once for the box they all live in. This is the ONLY place either
     mapping is stated. */
  .rink {
    transform: translate(calc(-1 * var(--cam-w) * var(--ft)), calc(-1 * var(--cam-l) * var(--ft)));
  }
  .rink i.band {
    left: var(--across); top: var(--at);
    width: calc(var(--span) * var(--ft)); height: var(--thick);
    --along: 90deg;
  }
  .rink i.zone {
    left: var(--across); top: var(--at);
  }
  .rink i.shape {
    left: var(--across); top: var(--at);
    --turn: 90deg; --flip: -1; --tsign: -1;
  }
}

/* The sheen and the gloss. Its inset is the rink's own, which is what stops the ice reading
   as paper at the boards.

   IT USED TO BE ABOVE THE MARKINGS, and story 12.15 found out what that cost by photographing
   it. The sheen is `radial-gradient(56% 58% at 50% 40%, #ffffffee, transparent 72%)` — 93%
   white at its hot spot, which is 50%/40% of the sheet, which is where centre ice is. So the
   sheet's markings were being painted and then fogged, and the fog was thickest exactly on the
   marking the whole formation faces across. Measured off a screenshot of the shipped table at
   1280x800, through the middle of the circle:

     the centre ice faceoff circle   drawn #3a5fd0    painted #c1cdf0   1.51:1 on its ground
     a blue line                     drawn #3a5fd0    painted #a3b4e9   1.95:1
     a neutral-zone faceoff spot     drawn #d8262b    painted #eda0a3   1.99:1
     the centre red line at centre   drawn #d8262b    painted #f2f4fc   ~1.0:1 — gone

   None of that is visible from a token, a computed style or a box, which is why every gate on
   this sheet was green over it. It is also the real explanation for story 12.8: the circle was
   drawn "heavier because #93aee0 is 1.82:1 on ice" — 1.82 was the arithmetic, 1.51 was the
   screen, and no amount of stroke width fixes a marking that is being whitened.

   SO THE MARKINGS PAINT OVER THE POLISH NOW, which is one property and not a redraw. The sheen
   and the gloss are unchanged and still lie on the ice; what changed is that the paint is on
   top of them rather than under them. `center_ice.png` settles which way round it goes: its
   markings are at full strength on light ice, and the owner's instruction is that the game
   should look like that. It is a stated departure from `Rink.dc.html`'s layer order, and it is
   the second one on this sheet — the first is the circle being anchored to the trick pile. */
.vig {
  position: absolute; inset: 0; z-index: 1; pointer-events: none;
  background:
    linear-gradient(115deg, #ffffff00 38%, var(--rink-gloss) 46%, #ffffff00 54%),
    radial-gradient(56% 58% at 50% 40%, var(--rink-sheen) 0%, #ffffff00 72%);
  box-shadow: inset 0 0 calc(90 * var(--px)) var(--rink-inset);
}

/* ---------- THE BOARDS, WHICH ARE REAL TOO (story 12.21) ----------

   200 x 85 ft with a 28 ft corner radius, drawn as ONE box because that is what it is. Story
   12.3 deliberately did not draw them and said why: a 14% corner on the sheet's own frame
   would take a bite out of each end of the ice about the size of the trick pile. That
   reasoning was about the TABLE's radius, and it still holds — `--r-table` is untouched, and
   the handoff's frame is still the handoff's frame. This is a different object: the boards
   are a marking on the ice at the ice's own scale, and at centre ice they are usually exactly
   at the edge of what the window shows or beyond it.

   AND IT PAINTS OUT EVERYTHING PAST ITSELF, with one outer shadow. The camera may not invent
   rink, so where the window is a shape the ice cannot fill — a very wide one shows the whole
   200 ft length and only 50 ft of width — what is beyond the boards is the cap rail, which is
   what is beyond the boards in a building. `--rail` is the token that already means that.

   THE PAINT IS 1 FT, and that is taken from the reference rather than from a rulebook: a
   rulebook has nothing to say about how wide a dasher board is drawn on a plan, and on
   `NHL_Hockey_Rink.png` the black boards measure 8px against 7.22 px/ft — 1.1 ft. */
.rink .boards {
  --dl: 200; --dw: 85;
  border: calc(1 * var(--ft)) solid var(--ink-2);
  border-radius: calc(28 * var(--ft));
  /* THIRTY FEET OF RAIL AND NOT A SCREENFUL, and the number is bounded rather than generous.
     What can ever show outside the boards is a CORNER: the camera's scale keeps the ice over
     both frame edges, so the only gap is where the 28 ft radius cuts in, and that cut is
     `28 - 28/sqrt(2) = 8.2 ft` at its deepest. 30 ft covers it three times over.

     It was `100cqmax`, which is a shadow the size of the window on an element already twice
     its width — and `browser-client.mjs`'s halo reader caught what that cost. That reader
     photographs one card in five states from a fixed clip and SUBTRACTS the shots, on the
     premise that "screenshots of one settled page are identical pixel for pixel". A shadow
     that big re-rasterises across tiles, so the ice near the card came back a few units
     different between two shots of the same page and the subtraction read it as paint: 45
     stray pixels reaching 47.9px past a card whose own states reach 0. Nothing was drawn
     wrong; a paint rectangle the size of a window is not free. */
  box-shadow: 0 0 0 calc(30 * var(--ft)) var(--rail);
}

.rink .blue { --paint: 1; background: var(--blue-line); opacity: 0.88; }
.rink .blue.l, .rink .blue.r { --l: calc(25 * var(--lsign)); }

/* THE CENTRE LINE IS PAINTED LIKE THE BLUES NOW, at 0.88 rather than 0.5. Its half opacity
   was buying back a 4cqw slab of red across the sheet — eight times the paint a 12-inch line
   is — and at 0.5% of the length that slab is a line: 6.4 real px wide at 16:9 and 4.2 on a
   phone. A line drawn at half strength on ice at 1 : 1.2 against the sheet is a line nobody
   sees, and this is the one the whole formation faces across. Same argument story 12.8 made
   for the circle's stroke: regulation fixes the SIZE and says nothing about the weight, so the
   weight is what pays for the size being honest. */
/* ---------- AND IT IS DASHED, WHICH IS WHAT MAKES IT READ AS A CENTRE LINE (story 12.15) ----
   The owner's reference draws the centre red line as red with WHITE DASHES along it, and that
   dash pattern is doing more work than its size suggests: a plain red band across pale ice is
   a divider, and the same band broken by white is the one line on a sheet that everybody
   recognises. Sampled off `NHL_Hockey_Rink.png` — 614px of ice across an 85 ft sheet, 7.22
   px/ft — the pattern is a 6.5px white dash every 46px: 0.90 ft of white on a 6.37 ft pitch.

   BOTH FIGURES ARE SHARES OF THE SHEET'S SHORT AXIS, and that is deliberate rather than a
   break with the rule that markings are shares of the LENGTH. Everything else here is a
   POSITION along the length and must not move when the sheet is stretched across its short
   axis. A dash is a distance ALONG THE LINE, and the line lies on the short axis — so a share
   of that axis is the same derivation, taken on the axis the thing actually runs on. It also
   means the dash count across the sheet is the reference's own at any aspect, which is the
   half of "it should match this" a foot-accurate dash would quietly lose.

   AND IT COSTS NOTHING PER ORIENTATION. `repeating-linear-gradient` resolves its percentages
   against the GRADIENT LINE, and `--along` (set once, in `.rink i.band` above, and turned
   once, in the container query) points that line down the band's own long side — which is the
   sheet's short axis in the wide frame and in the tall one alike. So 7.5% is 7.5% of the same
   physical distance either way, and there is no second copy of this rule anywhere.

   ANCHORED AT THE MIDDLE OF THE LINE, NOT AT AN EDGE. A repeating gradient tiles its stops
   infinitely in BOTH directions, so declaring one period centred on 50% puts a dash at the
   line's own midpoint and makes the pattern symmetric about it — which is the reference's own
   property: its thirteen gaps run 209..768 about an ice centre of 488.5. Anchored at the start
   edge instead, the pattern would land wherever the sheet's short axis happened to leave it,
   and the two ends of the line would not match.

   That midpoint is the middle of the BOARDS, and on a real rink it is also centre ice. Here it
   is centre ice in the tall composition and not in the wide one, because the wide sheet puts
   the drop at `--trick-y` 41cqh rather than at half the height (story 12.10). Measured at
   1280x800 the middle dash lands at y 396-403 and the drop spot at 317-340. That is the
   anchoring being honest about which thing it is symmetric about; moving it to `--trick-y`
   would make the line lopsided against the boards to line one gap up with a dot that is
   already drawn in blue on top of it.

   The red stays a background COLOUR under the gradient rather than being stops in it: a line
   that lost its dashes is still a red line, and `layout-check.mjs` reads `backgroundColor`
   off this element for the seat caption's ground. */
/* AND THE DASH IS IN FEET NOW (story 12.21), which is the same derivation said in the unit the
   thing is actually drawn in. It was a share of the sheet's short axis, and that share was
   correct only because the line was as long as the sheet; the line is 85 ft of ice now, so the
   pitch and the dash are the reference's own 6.37 ft and 0.90 ft directly. The dash count
   across the sheet comes out at the reference's own thirteen either way. */
.rink .redline {
  --l: 0; --paint: 1;
  --dash-pitch: calc(6.37 * var(--ft));   /* 46px of 614 on NHL_Hockey_Rink.png */
  --dash-length: calc(0.9 * var(--ft));   /* 6.5px of 614 */
  background-color: var(--red);
  background-image: repeating-linear-gradient(var(--along),
    #0000 calc(50% - var(--dash-pitch) / 2),
    #0000 calc(50% - var(--dash-length) / 2),
    var(--rink-dash) calc(50% - var(--dash-length) / 2),
    var(--rink-dash) calc(50% + var(--dash-length) / 2),
    #0000 calc(50% + var(--dash-length) / 2),
    #0000 calc(50% + var(--dash-pitch) / 2));
  opacity: 0.88;
}

/* THE GOAL LINES, 11 ft off the end boards and 2 in wide — 0.083% of the length, which is
   0.7 real px on a phone and 1.1 at the authored wide frame. They are hairlines and they are
   MEANT to be: a 2-inch line seen from the top of the stands is a hairline, and the honest
   alternative — drawing them at a size a screen finds comfortable — is the thing this story
   exists to stop. They carry no state, nothing is placed against them, and at this weight they
   cannot compete with a card for attention. Red, because a goal line is red. */
/* AND IT IS 73.5 FT LONG, NOT 85 (story 12.21). Eleven feet in from the end boards the line
   is already inside the 28 ft corner radius, so the boards there are not at 42.5 ft: about the
   corner's own centre at (72, 14.5), 28^2 = 17^2 + 22.25^2 puts them at 36.75, and the line
   that runs between them is 73.5 ft. That is a figure the old rule could not have carried at
   all — a band was as long as its container, so a goal line ran the full width of a sheet it
   is three quarters of. */
.rink .goal { --paint: 0.1667; --span: 73.5; background: var(--red); opacity: 0.88; }
.rink .goal.l, .rink .goal.r { --l: calc(89 * var(--lsign)); }

/* ---------- THE GOAL CREASE (story 12.21) ----------
   Eight feet across the mouth and six deep, opening onto the ice from the goal line, in the
   pale blue the reference fills it with inside a red edge. It is a SHAPE rather than a band or
   a zone because its two axes carry different distances AND one end of it is round: the front
   is an arc of the crease's own 6 ft radius, so the horizontal radius is the full depth and the
   vertical one is half the mouth. Which end is round is the only thing the two ends differ by,
   and it is which way the ice is. */
/* The fill is `--circle`, the handoff's own washed-rink blue, rather than a new hex: the palette
   is authority (DESIGN.md § Fidelity), the reference's crease is a pale blue, and this token's
   documented meaning is already "the washed rink". */
.rink .crease { --dl: 6; --dw: 8; background: var(--circle); border: calc(0.1667 * var(--ft)) solid var(--red); }
.rink .crease.l { --l: -86; border-radius: 0 calc(6 * var(--ft)) calc(6 * var(--ft)) 0 / 0 50% 50% 0; }
.rink .crease.r { --l: 86;  border-radius: calc(6 * var(--ft)) 0 0 calc(6 * var(--ft)) / 50% 0 0 50%; }

/* ---------- THE GOAL NET (story 12.21) ----------
   THE GOAL ITSELF, WHICH WAS NEVER ON THIS SHEET. Until now the goal was a goal line and a
   crease and nothing else — no net here, no net on Android, no rule in this file and no
   `MarkKind` there. BOTH CLIENTS AGREED PERFECTLY, which is exactly why nobody filed it in the
   life of the project: a parity pass asks whether the two match, and they did.

   SIX FEET ACROSS THE MOUTH AND 40 IN DEEP BEHIND THE GOAL LINE — the published goal-frame
   footprint in plan, not a figure read off a picture. It stands BEHIND the line, which is the
   point of drawing it at all: the crease opens onto the ice in FRONT of the line and the net
   stands behind it, so the two are on opposite sides of it rather than one blue pill straddling
   it. A goal drawn as a crease alone puts the whole goal in front of the line.

   THE REFERENCE CORROBORATES BOTH FIGURES, measured against `NHL_Hockey_Rink.png`'s own
   614 px = 85 ft — the same calibration `.boards` and `.redline` already cite. The net's red
   frame there is 25.5 px deep = 3.53 ft against the 3.333 below, and 53.5 px across = 7.41 ft
   OUTER against a 6 ft mouth plus its two posts. Corroboration only: the numbers used are the
   rulebook's, and the crease measured by the same method comes out at 8.0 ft, which is what
   says the calibration is sound.

   WHAT IS DELIBERATELY NOT DRAWN IS THE BACK'S CURVE. `NHL_Hockey_Rink.png` rounds the two rear
   corners; `Ice_hockey_layout.png`, the only reference that carries dimensions, does not draw
   the net at all. So there is no dimensioned source for that radius anywhere in this repo, and
   a radius here would be a number nobody could cite. At 40 in deep it is under three pixels at
   the authored frame. SQUARE UNTIL SOMETHING DIMENSIONS IT.

   THE FILL IS `--rail` RATHER THAN A NEW HEX, because the palette is authority (DESIGN.md
   § Fidelity) and `--rail` is already the token for a white structural surface that is not ice.
   It is #e9ece6 against the reference net's measured #eceeee. The fit is honest but weaker than
   the crease's `--circle`, whose documented meaning is literally "the washed rink" — if the net
   is ever wanted on a token of its own, this paragraph is the reason it did not get one. */
.rink .net { --dl: 3.3333; --dw: 6; background: var(--rail); border: calc(0.1667 * var(--ft)) solid var(--red); }
.rink .net.l { --l: -90.6667; }
.rink .net.r { --l: 90.6667; }

/* ---------- THE TRAPEZOID (story 12.21) ----------
   The restricted area behind each goal line: 22 ft across at the goal line, 28 at the end
   boards, and what is DRAWN is its two diagonals — the other two sides are the goal line and
   the boards, both already on the ice. Each diagonal runs 11 ft along the rink and 3 across, so
   it is a bar of hypotenuse 11.40 ft tilted 15.26 degrees, and the tilt's sign is the product
   of which end and which side it is on. `--tsign` flips it upright, because a quarter turn of
   the sheet turns the bar the other way round its own centre. */
.rink .trap {
  --dl: 11.4; --dw: 0.1667;
  --l: calc(94.5 * var(--lsign)); --w: calc(12.5 * var(--wsign));
  --tilt: calc(15.26deg * var(--lsign) * var(--wsign) * var(--tsign));
  background: var(--red); opacity: 0.88;
}

/* ---------- THE REFEREE'S CRESCENT (story 12.21) ----------
   A 10 ft half-circle on the boards at centre ice, on the scorekeeper's side. It is in this
   sheet for one reason: it is IN `center_ice.png`, the picture the owner said the game should
   look like, as the red arc at the left of that crop. Its flat side is the boards, which the
   boards already draw, so only the curve is stroked.

   ---- AND IT IS ON THE OTHER BOARDS NOW: NO PLAYER STANDS IN THE REF'S CREASE ----

   `--w` was -37.5, which is the same long side story 13.7 put the players' bench on. Measured
   on the running client: the crease centres at -172px across a 390-wide upright sheet and at
   -318px on a 1280x720 one, and `benchEnd: 0` puts the bench at that same edge in both frames.
   So every seat that was waiting stood inside the referee's crease. The owner: "its critical
   the players dont end up on the referree crease... its something a real hockey player would
   notice." He is right — on a real sheet the benches are on one long side and the crease is on
   the scorekeeper's side opposite, and a player reads that instantly.

   WHY THE CREASE MOVED AND NOT THE BENCH. Upright the bench could take the other rail cleanly.
   Wide it cannot: `benchEnd: 1` there is the FOOT, and the hand, the turn banner and trump's
   lane are all down the middle of the foot — the bench would stand on the player's own cards,
   which is the one thing no element may ever do. A fix that only works in one composition is
   two different rinks, so it is not a fix.

   THIS IS NOT A CHANGE TO THE RINK, and that matters because the rink is not ours to change.
   The crease is the ONLY mark on this sheet that is asymmetric about the long axis — the
   trapezoids, the goal creases, the circles, the spots and the hash marks are all `l`/`r`
   pairs about the length or are centred. Flipping it is the same regulation rink seen from
   the other side of the ice, which is what a broadcast camera on the bench side shows.

   WHAT IT COSTS, WRITTEN DOWN: wide, the crease now sits behind the hand corridor and is
   mostly covered. A rink marking hidden under the UI is worth strictly more than six players
   standing in it. */
.rink .refcrease {
  --dl: 20; --dw: 10;
  --l: 0; --w: 37.5;
  border: calc(0.1667 * var(--ft)) solid var(--red);
  /* THE FLAT SIDE IS THE BOARDS, so moving the crease to the other long side MIRRORS it as
     well as translating it — `--w` alone is a slide, and a slid crescent ends up with its
     flat edge facing the ice and its curve bulging out through the boards. This is the same
     pair `.crease.l` and `.crease.r` already carry one axis over. */
  border-bottom: 0;
  border-radius: calc(10 * var(--ft)) calc(10 * var(--ft)) 0 0 / 100% 100% 0 0;
  opacity: 0.88;
}

/* ---------- THE FOUR NEUTRAL-ZONE FACEOFF SPOTS (story 12.15) ----------

   The name is `Ice_hockey_layout.png`'s own — "neutral zone faceoff spot" — because the owner
   supplied that diagram to describe positions by, and an instruction given in its words has to
   land on something. They are the four red dots inside the blue lines in `center_ice.png`, and
   they are most of why that crop reads as a rink rather than as three lines.

   THE REGULATION POSITION, DERIVED. The layout diagram carries both figures: the spots are
   44 FT APART across the sheet and sit 5 FT from the blue line, on the neutral-zone side.
   The blue lines are 25 ft either side of centre ice (50 ft apart), so 5 ft inside one is
   20 FT FROM CENTRE, straddling the centre line and clear of the blue lines. Across, 44 ft
   apart is 22 FT EITHER SIDE of the middle of an 85 ft sheet.

   The spot itself is 2 ft across. Measured against the reference rather than taken on trust:
   on `NHL_Hockey_Rink.png` the ice is 614px wide for 85 ft (7.22 px/ft), the spots are 14px
   across (1.94 ft), sit 161px off the middle (22.3 ft) and 37px inside the blue line (5.1 ft).
   All three agree with the rulebook figures above.

   NOTHING IS WIDENED HERE, and that is worth saying because story 12.8's floor would have
   allowed it: two feet is 9.0 real px on a 390x844 phone and 16.7 at 1280x720, which reads
   without help. The mark that IS drawn past regulation on this sheet is the drop spot over
   centre ice, and it is stated at its own declaration below.

   `zone round` rather than `band` because a spot is a point and needs both axes — see
   `.rink i.zone` above, which is where these learn which way the rink lies. `l` and `r` are the
   two ends of the LENGTH, the same two ends the goal lines and blue lines use; `a` and `b` are
   the two sides across it. One length on both axes for the diameter, as every round mark here.

   AND ALL THREE FIGURES ARE WRITTEN IN FEET NOW (story 12.21), not as the 1% / 40% / 24.12%
   they were derived into. Same numbers, said in the unit they came from — and the derivation
   above is now what the stylesheet says rather than a comment explaining a share. */
.rink .nzspot { --d: 2; background: var(--red); opacity: 0.88; }
.rink .nzspot.l, .rink .nzspot.r { --l: calc(20 * var(--lsign)); }
.rink .nzspot.a, .rink .nzspot.b { --w: calc(22 * var(--wsign)); }

/* ---------- THE FOUR END-ZONE FACEOFF SPOTS AND CIRCLES (story 12.21) ----------

   THE OTHER FIVE OF THE NINE. The owner: "we eventually want to go to the other faceoff zones
   so it's important to have the whole rink" — so these exist as geometry whether or not the
   camera can currently see them, and pointing it at one of them is the two numbers at § the
   unit and nothing else.

   THE POSITION, from `Ice_hockey_layout.png`: 20 ft from the goal line, which is 11 ft from the
   end boards — so 89 - 20 = 69 ft from centre ice — and 44 ft apart, which is 22 ft either side
   of the middle, the same 22 the neutral-zone pairs use. The circle is 30 ft across, exactly as
   the one at centre ice is, and it is drawn in RED where centre ice is blue: that is what both
   references show, and it is the one thing on this sheet that tells the two kinds of circle
   apart at a glance.

   THE HASH MARKS are the four ticks outside each circle — 2 ft of paint reaching outward across
   the rink from the circle's two sides, in pairs 5.58 ft apart along it. They are bands, because
   a hash mark is a short line running ACROSS the ice exactly as a blue line is a long one, and
   `p`/`q` and `s`/`t` are which side of the circle and which of the pair. Its centre is 16 ft
   out from the spot: the circle's own 15 ft radius plus half the tick. */
.rink .ezcircle {
  --d: 30;
  --l: calc(69 * var(--lsign)); --w: calc(22 * var(--wsign));
  border: calc(1.283 * var(--ft)) solid var(--red); opacity: 0.88;
}
.rink .ezspot {
  --d: 2;
  --l: calc(69 * var(--lsign)); --w: calc(22 * var(--wsign));
  background: var(--red); opacity: 0.88;
}
.rink .hash {
  --paint: 0.1667; --span: 2;
  --l: calc(69 * var(--lsign) + 2.79 * var(--ssign));
  --w: calc(22 * var(--wsign) + 16 * var(--psign));
  background: var(--red); opacity: 0.88;
}

/* ---------- centre ice, at regulation (story 12.2) ----------
   A CENTRE FACEOFF CIRCLE IS 30 FEET ACROSS and the dot at its middle is 12 inches. Story 12.2
   wrote those as 15% and 0.5% of the sheet's long axis and story 12.21 writes them as 30 ft and
   1 ft, which is the same sentence with the second half of it made true: a share is regulation
   only on a sheet that is already 200 ft long, and the sheet was as long as the window was.
   Both are still one length used on both axes, which is what keeps a circle a circle.

   WHAT THAT REPLACES. The circle was 520 --px across — roughly 41% of the authored frame's
   length, two and a half times regulation — at opacity 0.5, which read as a faint arc
   somewhere behind the play rather than as a marking on the ice. And `.spot` was a 46 --px
   disc doing a dot's job at about seven times its size. Regulation is smaller than both and
   is drawn HARDER instead: the circle at 0.85 and the dot solid, because a rink's markings
   are painted under the ice and they are not shy.

   THE PILE IS NO LONGER WIDER THAN THE CIRCLE IT SITS IN, and that is a consequence of the
   camera rather than a decision (story 12.21). A 30 ft circle at `var(--ft)` is 250 real px at
   1280x720 against a six-card trick's 360 --px, and 315 at 900x900 against 253 --px of pile: it
   is wider than the pile at the square end of the range and narrower at 16:9, because `--ft` and
   `--px` do not move together. Neither way is a defect — the players still line up around the
   PILE, since that is the thing that must not be covered — but the standoff arithmetic in
   table.js now has a term that grows and shrinks against it, and `standIn` is where that is
   handled and measured.

   ---------- AND IT HAS TO BE SEEN, WHICH IS A WEIGHT AND NOT A SIZE (story 12.8) ----------

   THE OWNER: "I know it doesn't show it on official but all have it." They are right about
   the rink and right about the reason — every sheet has a painted centre circle that reads
   from the stands — and this is where the puck is dropped in this game too: the trick pile
   lands here and the two lines of players face each other across it. So the circle is the
   thing being contested and it has to read from a phone.

   WHAT WAS WRONG WAS NEVER THE DIAMETER. Story 12.2's own report says it: the regulation
   twelve-inch dot came out at "~4px of pale blue — measurable but not perceptible". The
   SIZES here are regulation and they stay regulation; what this story spends is the two
   things regulation says nothing about.

     THE COLOUR IS NOT ONE OF THEM. `--circle` is the handoff's token and the handoff's
     palette is authority (DESIGN.md § Fidelity and precedence), so it is not darkened to
     make this easier. Measured, `#93aee0` on the sheet's mid stop `#dde9f5` is 1.82:1 —
     under the 3:1 a drawn mark needs — which is precisely why the weight has to do the work
     that a darker hue would otherwise have done, and why it is spent generously.

     THE OWNER SETTLED THE COLOUR AFTERWARDS, AND IT IS BLUE (story 12.15). "center_ice.png
     is how i think we should present our game." In that picture the centre ice faceoff circle
     and its spot are drawn in the SAME blue as the blue lines — sampled, `#0039a6` in all
     three places — so centre ice now takes `--centre-ice`, which is a reference to
     `--blue-line` and not a new colour. That is a renegotiation of the paragraph above rather
     than a drift past it: the palette is still authority, and what changed is which row of it
     centre ice is drawn from. `#3a5fd0` on `#dde9f5` is 4.59:1 against `#93aee0`'s 1.82:1.

     WEIGHT. A regulation 2-inch marking line is 0.1667 ft: 0.75 real px at 390x844, which is
     three quarters of one device pixel. Drawn at 1.283 ft — 7.7x regulation, 5.8 real px on that
     phone and 10.7 at 1280x720 — at full opacity rather than 0.85, because a painted line under
     the ice is not shy. That is what a centre circle looks like from the stands, and it is what
     survives a marking that has to be seen from a phone. (Story 12.8 spent this as 10 --px and
     story 12.21 respends it as 7.7 x 2 inches: the same multiplier, in the unit the marking is
     drawn in, so it now scales with the ICE rather than with the furniture — and the four
     end-zone circles take the identical weight rather than a second number.)

     THE 14% INTERIOR WASH IS GONE, and it went for the reason it arrived. It was bought to
     give a 1.82:1 marking an AREA as well as an edge; at 4.59:1 the edge carries itself, and
     the reference is unambiguous — its centre circle is an UNFILLED ring on plain ice. A wash
     of the new blue would be a tinted disc under the trick pile, which is both wrong against
     the picture and the one place on this sheet nothing may compete with the cards.

     A DROP SPOT. `.spot` stays exactly where it was, at the regulation twelve inches — 4.5 real
     px at 390x844, the "measurable but not perceptible" figure itself — and `.drop` is drawn
     over it at 4 FT: four times regulation, 18.1 real px at the phone and 33.3 at 1280x720,
     about an eighth of the circle's own diameter. Two marks rather than one resized, so the
     regulation figure is still on the ice and still measured by `layout-check.mjs`, and the
     departure is a thing you can point at rather than a number that moved.

   ---------- WHAT IS SPENT PAST REGULATION, AND WHAT IS NOT ----------

   THE ONLY TWO DEPARTURES ON THIS SHEET, and both are WEIGHTS rather than sizes:

     the circle's stroke   1.283 ft against a 2-inch marking line   7.7x  (and the end-zone
                                                                          circles take the same)
     the drop spot         4 ft over the regulation 1 ft dot        4x
     the boards' paint     1 ft, taken from the reference           n/a — a rulebook does not
                                                                    say how wide a dasher board
                                                                    is drawn on a plan

   Every other figure is regulation. Story 12.3 settled the one temptation that is not on that
   list: growing the circle so it swallows the trick pile, which would have wanted 1.9x and would
   have taken the near line's skate-in to zero. It is still refused, and the reason is unchanged
   — the players line up around the PILE and the marking stays honest. What HAS gone is the third
   departure this block used to name: anchoring centre ice to `--trick-y` rather than to the
   middle of the rink. There is nothing to anchor now; `--trick-y` is where the camera's centre
   is drawn and centre ice is what the camera is pointed at (story 12.21). */
/* ---------- AND ALL THREE ARE PLACES ON THE ICE NOW (story 12.21) ----------
   They were `left: 50%; top: var(--trick-y)` with a diameter of `max(15cqw, 15cqh)` — the
   circle anchored to the trick pile rather than to the sheet, and its size a share of the long
   axis. Both are gone and neither is a loss: the circle is 30 FEET, and it sits at the camera's
   own centre, which IS the trick pile because `--trick-y` is where the camera's centre is drawn.
   Anchor and marking cannot drift apart any more, because there is only one of them.

   THE STROKE IS THE SAME READABILITY DEPARTURE story 12.8 bought, restated in feet: a regulation
   marking line is 2 inches, this is 1.283 ft — 7.7x — which comes out at 10.9 real px at 1280x720
   and 5.9 on a 390x844 phone, against the 10 --px and 5.4 real px it was. Regulation fixes the
   SIZE and says nothing about the weight, and a painted line under the ice is not shy. */
.rink .circle {
  --d: 30;
  border: calc(1.283 * var(--ft)) solid var(--centre-ice); opacity: 1;
}
/* The centre ice faceoff SPOT, at the regulation twelve inches. */
.rink .spot { --d: 1; background: var(--centre-ice); opacity: 1; }
/* And the mark drawn over it at 4 ft — four times regulation, story 12.8's stated departure,
   and the ONE marking on this sheet that is wider than the rulebook. Blue now, and drawn OVER
   the centre line: in the reference the blue dot stands exactly where a white dash would be —
   at x 486-491 of a line whose other gaps are white — so blue on the line is what the picture
   shows, and that is what the paint order here produces. */
/* AND SINCE STORY 13.8 IT IS DRAWN ON WHICHEVER SPOT THE HAND IS BEING PLAYED AT, not on
   centre ice. It is the one mark on this sheet that is not regulation — it exists because a
   2 ft dot is 17 real px on a phone — so it is also the one mark that belongs to the HAND
   rather than to the rink, and it follows the camera by reading the camera's own two numbers.
   The regulation marks underneath it (`.spot`, `.circle`, `.nzspot`, `.ezspot`) do not move:
   they are the ice, and the ice is not changed.

   ITS COLOUR IS THE ZONE'S. Centre ice is blue and every other faceoff spot on a sheet is
   red, so the mark takes red by default and blue only where it is standing on centre ice —
   which is the same rule the marks under it already follow, stated once here rather than
   nine times in the camera table. */
.rink .drop {
  --d: 4; opacity: 1;
  /* ON THE SPOT THE SERVER NAMED, not on the camera. The two are the same point at centre ice
     and differ at the other eight — the camera stays home on the width and stops 12 ft short at
     an end zone, both so that no player is drawn off the ice (see § THE NINE SPOTS). Reading
     the camera here instead would put the enlarged mark on a piece of ice that is not a faceoff
     spot at all, which is the one thing this mark exists to say. */
  --l: var(--spot-l); --w: var(--spot-w);
  background: var(--red);
}
.table[data-spot="CentreIce"] .rink .drop { background: var(--centre-ice); }

/* ==========================================================================
   THE POOLS OF LIGHT ARE GONE — story 12.9, and this is where they stood.

   Story 12.2 drew one pool of warm light on the ice under every seat in the hand: a `.pools`
   holder at z-index 3, a 240x136 --px ellipse per seat inside a solid `--pool-rim` edge that
   measured 3.77:1 against the sheet's mid stop, travelling with its seat on the same 1.15s
   transition the seat itself travels on. Before it, story 10.2 drew ONE pool, on the seat whose
   turn it was, with the whole rink multiplied down 22% underneath so that it could be seen at
   all.

   THE OWNER LOOKED AT IT ON THE SHIPPED SHEET AND SAID: "Don't put circles around each player
   image show." That is the whole of the reason, and it is recorded here as a REVERSAL rather
   than smoothed over, because the arithmetic under the pool was sound and the arithmetic is not
   what was wrong with it. What was wrong is that the light was carrying a distinction the
   POSITION and the WORD were already carrying: a seat in the hand stands at the faceoff and its
   caption reads "playing"; a seat that is out stands at the boards, at 0.84 scale, with its art
   dimmed and grayscaled, wearing an S badge, and its caption reads "sitting out". Five signals
   and six — and the light was the sixth and the seventh.

   NOTHING REPLACES IT AND NOTHING MAY. § THE SEAT CARD below carries the rule that is left and
   the state-by-state accounting behind it. `tests/e2e/layout-check.mjs` holds every `.pbox`
   state to a plain card's own shadow at every mix and every count, and `browser-client.mjs`
   photographs one card in each state and subtracts the shots, so a light coming back here —
   under this name or any other — goes red in two places.

   And what stands on the ice between the sheet's own markings and the trick pile is now nothing
   at all, which is itself an assertion rather than a fact about today: `haloComplaints` reads
   that band's two bounds off the live `.rink` and `.centre` and names whatever is standing in
   it. ========================================================================== */

/* ---------- reduced motion ----------
   The state still arrives; only the journey goes. Every seat is simply already at its end
   position — so the arrangement still says exactly what it said, and the caption under every
   card says it in words besides. Story 6.2's rule, and the reason the travel is allowed to be
   the point in the first place. */
@media (prefers-reduced-motion: reduce) {
  .seat, .sbody, .pbox, .puck { transition: none !important; }
  /* THE LAMP LOSES ITS FLICKER AND KEEPS ITS LIGHT (story 12.4), which is the whole of
     story 6.2's rule applied to a signal that only exists to be noticed. There is no sweep
     here either — `sweepPile` clears the felt outright under this preference — so the lamp
     is the one thing left saying who took the trick on screen, and it still burns its full
     1100ms rather than being skipped. It arrives at once instead of igniting. */
  .seat[data-lamp] .lamp { animation: none !important; opacity: 1; filter: none; }
}

/* ---------- 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; }

/* THE MENU BAR IS A TALL-FRAME PIECE AND IS ABSENT FROM THE WIDE ONE (story 12.20). The owner
   asked for it "while in portrait mode", and the wide sheet has its own answer at the head
   already: the gear and EXIT in the two corners with story 12.13's chat box standing BETWEEN
   them in the head-centre band. There is no band left across a wide head to put a bar in, and
   nothing about the wide sheet is touched by this story.

   Declared here rather than inside the portrait block so that the element's default is OFF —
   a composition that forgot to mention it draws no bar, rather than drawing one across a
   desktop table. The rules that turn it on are all in § the menu bar, at the foot of this
   file, with the rest of the tall frame.

   `.fexp` — the control that opens the feed (story 12.23) — joins them for the same reason and
   with one extra consequence: an operable element that is `display: none` is not enumerated by
   `operable.mjs`, so the wide sheet is not asked to account for a control it does not have. */
.menubar, .barmark, .fexp { display: none; }

/* ==========================================================================
   PROXIMITY IS MEANING — handoff § 5, as the owner amended it.

   Who is playing this hand and who is not has to read at a glance, and the thing that
   carries it is WHERE A SEAT IS. The seats in the hand travel to centre ice for the drop;
   the seats that are out stay at the boards, smaller and drained of colour. The shape of
   the table is the answer and the caption underneath only confirms it.

   Three states and no more, keyed off `data-prox` (table.js decides which):

     in       gathered at the pile     scale 1     card at full colour
     out      boards edge              scale 0.84  card at 0.5, grayscale 0.7
     waiting  boards edge              scale 1     card at 0.62, grayscale 0.6

   TWO THINGS HERE ARE NOT THE HANDOFF'S, both on the owner's authority.

   The DIM APPLIES TO THE CARD, NOT THE SEAT. § 5 dims everything, including the name, the
   score and the status caption. A seat that is out still has a score a player needs to
   read across the sheet, and the caption is the WRITTEN half of the in-versus-out signal —
   the half that has to survive reduced motion and a screen reader. So the opacity and the
   grayscale land on `.pbox` alone and the type stays at full contrast.

   And the TRAVEL IS THE POINT. The seats do not appear at their positions, they skate to
   them: 1.15s on an ease that starts fast and settles, which reads as a skater's push and
   glide rather than as a UI transition. This is the one animation in the product that is
   meant to be noticed, and the ice is the reason its direction means anything.

   IT IS NEVER THE ONLY CARRIER. Under reduced motion every seat is simply already at its
   end position — and the status caption says "sitting out" or "playing" either way, so a
   player who sees no movement at all still has the answer.

   The centring is `translate(-50%, -50%)` on `.seat` and the SCALE is on `.sbody` inside
   it. handoff § 8 trap 1: an animation that lands on the centring transform overwrites it,
   and a seat whose `translate(-50%, -50%)` is destroyed is half a card off its anchor —
   including the anchor every dealt card is flying to. Outer centres, inner moves. */
/* THE CARD IS 0.8 IN BOTH COMPOSITIONS SINCE STORY 12.13, so the wrapper is 120 units
   wide whichever way up the sheet is — see `.sbody` below, and table.js § ONE FACEOFF, TWO
   AXES for the column of boxes that decided it. */
.seat {
  position: absolute; width: calc(120 * var(--px)); z-index: 12;
  transform: translate(-50%, -50%);
  transition: left 1.15s cubic-bezier(0.34, 0.85, 0.28, 1),
              top 1.15s cubic-bezier(0.34, 0.85, 0.28, 1);
}
/* HOW BIG A SEAT CARD IS DRAWN, and the only place it is written. `table.js` reads the same
   number as `SEAT_SCALE` and derives every clearance on the sheet from it — the pile's
   standoff, the bench's step, the boards line's corner. It was 0.8 and the owner asked for
   the whole UI to be bigger ("these guys have large thumbs"; "look at the legacy game
   pictures how big everything is"), so it is 1.15. Measured before: a seat's score was 3.3%
   of a 390x844 screen's height against the legacy's 8.5%, its name 8.5px, its caption 5.1px. */
:root { --seat-scale: 0.8; }
.sbody { position: relative; transition: transform 1.15s cubic-bezier(0.34, 0.85, 0.28, 1); }

/* ---------- THE TURN MARKER (story 13.7, closing DQ-turn-marker) ----------

   ONE MARK, ON THE SEAT WHOSE TURN IT IS, AND NOTHING ELSE ON THE SHEET SAYS IT. Reversing
   the zig-zag took the answer out of reading order — the row runs along the bench now, and
   the next player is beside the last, which is not something a still frame states. table.js
   carries why it is a shape rather than a border colour or the lamp's treatment; this is what
   it looks like.

   A PENNANT, STANDING OFF THE CARD'S HEAD EDGE and pointing down at it. Outside the card on
   purpose: the card's own three border states are the seat's, and a fourth mark drawn INSIDE
   the same box would be read as a fourth one of them. Above it rather than beside it because
   `data-side` already spends both flanks — the badge column hangs off one and the dealer's
   puck off the other — and the head edge is the one direction a seat never puts anything.

   12 --px of point on an 18 --px base, which is 12 real px at 1280x720 and 5.5 on a 390x844
   phone: the same order as the dealer puck's own 36 --px and rather more than the 2 ft faceoff
   spot it is standing over. It is drawn with borders rather than a `clip-path` so it takes the
   ink of one property and nothing has to be re-rasterised when it appears.

   IT IS NOT COLOUR ALONE, which is the one rule this could have broken: the message is the
   presence of a shape that is absent everywhere else on the sheet, and the yellow is the
   accent it is drawn in. A player who cannot tell yellow from the ice still sees an arrow.

   AND IT FADES RATHER THAN APPEARING. The element is built once with the seat wrapper and
   lives at opacity 0, so the turn moving from one chair to the next is two crossfades rather
   than two elements being created and destroyed — and under `prefers-reduced-motion` the
   crossfade is dropped and the state simply arrives, which is this project's standing answer
   for everything that is not the camera. */
.seat .turnmark {
  position: absolute;
  left: 50%; top: calc(-16 * var(--px));
  width: 0; height: 0;
  border-left: calc(9 * var(--px)) solid transparent;
  border-right: calc(9 * var(--px)) solid transparent;
  border-top: calc(12 * var(--px)) solid var(--yellow);
  transform: translate(-50%, 0);
  opacity: 0;
  transition: opacity 0.22s linear;
  pointer-events: none;
  z-index: 13;
  filter: drop-shadow(0 calc(1 * var(--px)) 0 var(--yellow-edge));
}
.seat[data-turn] .turnmark { opacity: 1; }
@media (prefers-reduced-motion: reduce) {
  .seat .turnmark { transition: none; }
}
/* AND THE SCALE THAT MAKES IT 0.8, which is the WIDE frame's declaration; § THE TALL FRAME
   re-declares the same sentence with the axes swapped.

   WHY THE WIDE SHEET TOOK IT TOO (story 12.13). The tall frame dropped to 0.8 because three
   210 --px name boxes will not stand across 720 units of ice. The wide frame now has to stand
   three RANKS of boxes across half its own width — the pile, the arc, and the chair the arc's
   player walked away from — and with story 12.4's deck on one of them the full card does not
   fit in 640 units either. table.js carries the column.

   `--px` is RE-DECLARED rather than a transform or a zoom, so every number the card is built
   from follows it: the 110-unit panel, the 60-unit score lane, the 20-unit name, the five
   pips, the hatch, the strip. Nothing about the card's own geometry is restated, which is why
   it cannot drift between the frames. It is also why the two traps § 8 names still hold:
   `scrollWidth`/`clientWidth` are layout values, so the score lane and the name box are
   measured at this scale rather than through a transform that hides them.

   It is declared on `.sbody` and NOT on `.seat`, and that is load-bearing: `.seat` carries the
   inline `left`/`top` table.js writes in the TABLE's unit, and a seat whose own `--px` were
   0.8 would stand at 0.8 of its place in the formation. So the wrapper keeps the full unit and
   only what is inside the card takes the scale, with `.seat`'s own width brought to the same
   120 units by hand above, since that is the box the card lays out inside.

   The expression is written out rather than derived from `--px`, because a `var()` inside a
   custom property is substituted where the property is DECLARED — the trap already recorded
   at `--focus-ring`. `--px: calc(0.8 * var(--px))` would resolve against `:root`'s own unit
   and silently draw the wrong card. */
.sbody { --px: calc(var(--seat-scale) * min(0.138889cqmin, 0.078125cqw)); }
/* ONLY `out` IS SCALED, AND `waiting` IS BACK AT FULL SIZE (owner, epic 14).

   I put `waiting` on this rule to fix a label collision: `benchStep` reserved
   `deep * 0.84 + rail` for every seat at the bench while only `out` was drawn at 0.84, so a
   waiting seat sat full-size in a slot 84% of its width and its name printed across the
   caption above it. Shrinking the card closed the gap and made every seat on the ice smaller —
   the owner: "everything on top of the ice looks small... i feel like we made things smaller
   to fit it". He is right, and it was the wrong direction: the STEP was what disagreed with
   the drawing, so the step moved instead (see `benchStep` in table.js). */
.seat[data-prox="out"] .sbody { transform: scale(0.84); }
/* THE CARD DIMS AND THE TYPE DOES NOT — and the score is TYPE, even though it is printed
   inside the card. That is the owner's amendment stated precisely: the portrait and the
   hatch fill go back, and the name above the card, the score in it and the caption below it
   all stay at full contrast, because a seat that is out still has a score a player needs to
   read across the sheet. So the dim lands on the art rather than on the card's own box —
   `opacity` on `.pbox` would take the score, the pips and the border with it, and there is
   no way to lift a child back out of a parent's opacity. */
.seat[data-prox="out"] .pbox::before,
.seat[data-prox="out"] .pbox .face,
.seat[data-prox="out"] .pbox .brandbar { opacity: 0.5; filter: grayscale(0.7); }
.seat[data-prox="waiting"] .pbox::before,
.seat[data-prox="waiting"] .pbox .face,
.seat[data-prox="waiting"] .pbox .brandbar { opacity: 0.62; filter: grayscale(0.6); }
.pbox::before, .pbox .face, .pbox .brandbar { transition: opacity 600ms ease, filter 600ms ease; }
.pbox { transition: border-color 180ms ease, box-shadow 180ms ease; }

/* ---------- the seat card ----------
   handoff § 4 / RinkSeat.dc.html: 150x110, 4px border, radius 9, a diagonal hatch fill,
   a 17px Snarps strip across the top, a portrait slot inset left 6 / right 68 and a 60px
   score lane at right 4, and five trick pips along the bottom. */
.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 the sheet rather than inside the panel, so they may run wider than it —
     otherwise two players truncate to the same "Player …". The inset that keeps this
     overhang on the sheet is the anchors' own 34px edge clearance. */
  width: 140%; margin-left: -20%;
  /* A STROKE, NOT A GLOW. The felt's white name was held apart from a dark ground by a
     black drop and a black halo; on ice both of those are a smudge under white letters on
     a white sheet, and the name would be the first thing to disappear. RinkSeat.dc.html
     sets this label at 20px Archivo 900 italic in #fdfdfa with a 3px #0e1b2b stroke and
     `paint-order: stroke fill` — which is what draws the letterforms rather than washing
     the ground behind them. */
  -webkit-text-stroke: calc(3 * var(--px)) var(--seat-name-stroke);
  paint-order: stroke fill;
}
/* 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; box-sizing: border-box;
  height: calc(110 * var(--px));
  border: calc(4 * var(--px)) solid var(--seat-in);
  border-radius: calc(9 * var(--px));
  background: var(--seat-card);
  box-shadow: var(--lift);
}
/* The hatch, not a flat panel: RinkSeat.dc.html fills the card with a 135deg two-tone
   stripe over the dark ground, which is what stops a rank of six cards reading as six holes
   cut in the ice. Its own layer rather than the card's `background`, so the out treatment
   can take it back without taking the score with it — see the proximity block above. */
.pbox::before {
  content: ""; position: absolute; inset: 0;
  background:
    repeating-linear-gradient(135deg, var(--panel-hatch-a) 0 calc(8 * var(--px)),
                              var(--panel-hatch-b) calc(8 * var(--px)) calc(16 * var(--px)));
}
/* ---------- THE SEAT CARD'S STATES: BORDER AND CAPTION, AND NO LIGHT (story 12.9) ----------
   What this rule used to say was "border colour IS the state, and an active state adds an
   outward glow in the same hue". The glow is gone, on the owner's direction — "Don't put
   circles around each player image show" — and so is the pool of light story 12.2 put on the
   ice under every seat in the hand. The ordinary drop stays: a card still sits ON something,
   it just stops radiating. `var(--lift)` is that drop and it is the SAME on every state, which
   is the property `layout-check.mjs` measures a plain probe card against.

   EVERY STATE IS STILL SAID AT LEAST TWICE, AND THE TWO ARE DIFFERENT KINDS OF THING. That is
   the whole difficulty of this change rather than a footnote — removing a channel from a state
   that had exactly two would leave it on one — so the accounting is written out here and each
   line is something that can be checked rather than a claim:

     in the hand      .pbox.play, --seat-in border     + caption "playing"
                      + the seat STANDS at the faceoff (data-prox="in") and its art is at full
                        colour, where a seat that is out is at 0.84 with 0.5 opacity and 0.7
                        grayscale. Position and colour-of-art, neither of them a hue.
     sitting out      .pbox.sit, --seat-out border     + caption "sitting out"
                      + the boards position, the 0.84 scale, the dimmed art, and the S badge —
                        a DRAWN letter, which is the channel that survives colour blindness.
     your turn        .pbox.turn, --red border         + caption "your turn"
                      + the turn banner across the corridor and the countdown running in it.
                        Only ever on YOUR seat: table.js adds `.turn` under `isMe`.
     an opponent's    caption "thinking…"              + .pbox.thinking, a brightness PULSE
       turn             — a motion, not a hue, and the two channels a watching seat has. There
                        is no banner and no border for somebody else's turn and there never
                        was; what the pool added was a third channel on a state that had two.
     out of the game  .pbox.out, --red border          + caption "you're out of the game"
                      + the ✕ over the whole card. It shares the on-turn RED deliberately and
                        is told apart from it by covering the card rather than edging it —
                        which is why `browser-client.mjs` measures how much of the card each
                        state repaints and not only what colour it painted.
     reconnecting     .pbox.away, the art at 0.5       + caption "reconnecting…"
                      — and the score and the name stay at full contrast underneath, because
                        that is what the rest of the table is reading while they are gone.
     the dealer       the puck parked beside the seat  + the deck standing next to it
                      — both objects ON the ice beside the card, neither of them on it.
     your own seat    "(you)" after the name           + `data-me` on the wrapper, and the
                      near line of the faceoff whenever you are in the hand.
     a spectator      no seat is `.turn` and none says "(you)" — every seat on turn reads
                      "thinking…" and pulses, which is the same two channels an opponent has.

   THE BORDER IS THE WEAKER OF THE TWO, AND SAYING SO IS THE POINT. Measured against the ice
   the card stands on, `--green-live` was 1.64:1 and `--muted` 2.45:1 — under the 3:1 a drawn
   mark needs — because the border reads against the card's own near-black hatch on its INNER
   side, where they were 7.74:1 and 5.20:1. `browser-client.mjs` photographed those numbers
   and printed them on every run without asserting them, because retuning the palette was not
   story 12.9's to do. What follows from them is that the CAPTION is not decoration: it is the
   channel that does not depend on picking a hue out of pale ice, and no state above is
   allowed to lose it.

   ---------- AND NOW IT READS AGAINST THE ICE TOO (story 12.25) ----------

   THE OWNER ASKED FOR THE RETUNE 12.9 WAS NOT ALLOWED TO MAKE, so the two thin states moved
   into `--seat-in` #108a37 and `--seat-out` #66798d — see § the palette for the band of
   luminance those two numbers had to land in, which is narrow and is set by the two grounds
   pulling opposite ways. `--red` did not move: it is the rink's own red, the goal lamp's and
   the punt's, and it was already over the line.

     state          border      on the ice     on the hatch   apart from the others
     in the hand    #108a37     3.19..3.32:1   3.51:1         189 from out, 312 from turn
     sitting out    #66798d     3.21..3.34:1   3.49:1         189 from in,  295 from turn
     your turn      --red       3.56..3.70:1   3.15:1         295 / 312     (was 4.01 on the
                                                                             old sheet)

   RE-MEASURED AGAINST THE ICE AS STORY 12.21 LEFT IT, which is the half of this that could
   not be inherited. 12.9's figures were taken on the old sheet, before the ice was real and
   before the markings came up over `.vig`'s sheen. So the ground was measured again, off the
   pixels, with the whole formation taken OFF the sheet — a card's own drop shadow is the
   card's paint, and holding a border to contrast against a shadow the same card casts is
   holding it to contrast against itself. Sampling the band each card's border occupies, at
   six seats and three window shapes, the ground came back as two kinds and the split is a
   function of WHERE THE FORMATION STANDS rather than of the palette:

     the wide sheet   1280x800    13,999 px of ice, worst #cddced   545 px of paint: an
                                                                    end-zone circle, #d93d44,
                                                                    under 3.0% of the band
     the tall sheet    390x844     9,646 px of ice, worst #d0deee    50 px: the same red arc,
                                                                    grazing, 0.15%
     a tall tablet    810x1080    11,280 px of ice, worst #d2e0ef   840 px: a BLUE LINE at full
                                                                    strength, #4e6fd5..#5071d6,
                                                                    under 6.0% — a whole edge

   THE STANDARD IS THE DARKEST PLAIN ICE AT EACH FRAME, AND THE MARKINGS ARE STATED RATHER
   THAN MET — because they cannot be, and the arithmetic says so exactly rather than as a
   judgement. On the tablet the three states measure 1.00:1, 1.01:1 and 1.10:1 on the blue
   line, and there is no palette that changes that.
   A colour clears 3:1 on pale ice (L 0.7216) only below L 0.2064, and clears 3:1 on a
   full-strength blue line (L 0.1778) only below L 0.0259 or above L 0.6335. The overlap is
   L <= 0.0259 — a near-black — and a near-black border on the card's near-black hatch (L
   0.0172) is 1.13:1, which is the near ground gone. There is no colour that satisfies all
   three, so tuning against the worst ground of all would mean giving up the ground a player
   actually looks at, and this story is forbidden to trade one for the other. What carries a
   seat whose edge is lying on a blue line is what carries every other state on this card: the
   position, the caption, the score, and — for the two states that can be confused — the ✕
   over the whole card. `browser-client.mjs` measures the markings beside the ice on every run
   and prints them, which is 12.9's own pattern applied to what THIS story could not fix. */
.pbox.play { border-color: var(--seat-in); box-shadow: var(--lift); }
/* SITTING OUT IS NOT AN ACCENT. This border was yellow with a yellow glow, and both had to
   go. Yellow means a score, an active choice or the primary action (DESIGN.md § Colours,
   and it says in so many words that a yellow border meaning something else is a bug) — and
   with the score no longer dimmed, a yellow-ringed card with a bright yellow number in it
   was the loudest thing on the sheet, worn by the seats that are NOT in the hand. It read
   backwards. Muted, with no glow: a seat that is out recedes, which is the whole point.
   And no blanket opacity, which is what used to take the score down with the card. Sitting
   out is said by the boards position, the 0.84 scale, the dimmed art, the S badge and the
   caption — five signals, none of them a hue on its own.

   AND IT IS `--seat-out` RATHER THAN `--muted` SINCE STORY 12.25. Same reading of the same
   state — a seat that is out recedes — one shade deeper, because `--muted` came to 2.21:1 on
   the ice it recedes against and a signal nobody can find is not receding, it is missing. */
.pbox.sit  { border-color: var(--seat-out); box-shadow: var(--lift); }
.pbox.turn { border-color: var(--red); box-shadow: var(--lift); }
.pbox.thinking { animation: pulse 1.1s ease-in-out infinite; }
@keyframes pulse { 50% { filter: brightness(1.35); } }

/* ---------- THE GOAL LAMP (story 12.4) ----------
   Every rink has a red lamp behind each net, and when it lights the whole building knows at
   once that it counted and who did it. The owner asked for "something hockey related on the
   player card" to say who took a trick, and that is the sport's own answer to exactly this
   problem: announcing a scoring event to people who were looking somewhere else, with no
   reading time at all.

   THE GLASS IS ALWAYS DRAWN AND THE ATTRIBUTE IS WHAT LIGHTS IT. `table.js` renders `.lamp`
   into every seat card on every frame and writes `data-lamp` onto the seat WRAPPER, which is
   the one part of a seat a re-render does not rebuild — so a lamp cannot be dropped by a
   broadcast arriving while it burns, and nothing inside `renderSeats` has to know it exists.

   IT FLOODS RATHER THAN OUTLINING, and that is the third reading rather than a third hue.
   `.pbox.turn` is a red border and `.pbox.play` a green one; a fourth border in a fourth
   colour would be a state told the same way as two others and read at the same glance. A card
   that goes hot from edge to edge with a 46 --px halo around it is a different KIND of signal,
   which is what "unmistakable at a glance across the sheet" asks for.

   AND THIS IS THE ONE LIGHT ON THE SHEET, WHICH IS WHY IT SURVIVED STORY 12.9. That story took
   the coloured glow off `.pbox.play` and `.pbox.turn` and the pool of light off the ice under
   every seat in the hand, on the owner's direction, and a reader arriving from it will want to
   know why a 46 --px halo is still here. The line is not the SIZE of the light. It is whether
   the light is WORN or FIRED:

     A HALO IS WORN. It hangs off a state a seat holds for a whole hand — in the hand, on turn
       — so it is on the sheet continuously, on as many as six cards at once, and what it draws
       is a ring around a player's picture. That is the thing that was refused.
     AN EVENT LIGHT IS FIRED. It hangs off `data-lamp`, which table.js writes at the instant a
       trick is taken and removes 1100ms later, so it is on ONE card and it is gone before the
       next trick. It is not a ring around the picture; the card floods.

   Both halves of that are measured rather than left as an argument. `layout-check.mjs` holds
   every `.pbox` state to a plain card's own shadow AND requires `.seat[data-lamp] .pbox` to
   throw one — so a lamp deleted along with the halos is red, not green. `browser-client.mjs`
   photographs one card in each state, subtracts the shots, and requires the state's paint to
   stop at the card's own edge and the lamp's to reach past it.

   IT IS UNDER THE SCORE AND THE METER, by DOM order — table.js appends it after the portrait
   and before both. The lamp is the celebration; the number and the pips are what a player
   reads off the card, and a flood that took them with it would hide the count that is ticking
   on this very frame. */
/* SCOPED, because a bare class name in a stylesheet with no scoping is how `.strip` became two
   components (see § the seat card's brand bar). This one only ever means the glass in a seat
   card, and it says so. */
.pbox .lamp {
  position: absolute; inset: 0; opacity: 0; pointer-events: none;
  background: radial-gradient(circle at 50% 40%,
    var(--goal-lamp-hot) 0%, var(--goal-lamp) 34%, var(--goal-lamp-deep) 100%);
}
/* THE IGNITION IS THE SERVER'S HOLD, TO THE MILLISECOND. `TableSession.HoldTrick` waits half
   an AI beat — 200ms — while the resolved trick is on the felt, and this is what fills it: the
   lamp comes up over exactly that window, and the moment it is fully lit the next frame lands
   and the pile leaves for the seat it is burning on. Nothing in the window is a still frame,
   which is the whole of the owner's complaint ("it looks like lag").

   THEN IT HOLDS AND THROBS, once, the way a beacon does, and stays lit until table.js puts it
   out — which is when its 1100ms have passed AND the pile it announced has finished travelling,
   whichever is later. The animation fills forwards for that reason: the light has no idea how
   long it is going to be needed for, and a keyframe that ended by fading to nothing would take
   the lamp off a card whose cards had not arrived yet. */
.seat[data-lamp] .lamp { animation: goalLamp 1100ms ease-out both; }
/* THE THROB IS A BRIGHTNESS AND NOT AN OPACITY, and that is a correction rather than a
   flourish. Drawn as an opacity swing it was a translucent red sheet over the card's portrait,
   so what a player actually saw — and what a photograph of it actually measured — was 83% of
   the lamp plus 17% of a hockey player: measured at 60% of the sampled card reading as red,
   with the misses on the figure's own light pixels. A lamp is not translucent. Once it is lit
   it is OPAQUE, and what pulses is how hard it is burning. Every stop stays red at every point
   of the swing, which is the property `browser-client.mjs` reads off the pixels: the palest of
   the three is `--goal-lamp-hot` and at the 1.18 peak it paints rgb(255, 106, 24). */
@keyframes goalLamp {
  0%   { opacity: 0; filter: brightness(0.55); }
  18%  { opacity: 1; filter: brightness(1.18); }
  62%  { opacity: 1; filter: brightness(0.88); }
  78%  { opacity: 1; filter: brightness(1.14); }
  100% { opacity: 1; filter: brightness(1); }
}
.seat[data-lamp] .pbox {
  border-color: var(--goal-lamp);
  box-shadow: 0 0 calc(46 * var(--px)) calc(6 * var(--px)) var(--goal-lamp-glow), var(--lift);
}

/* The brand bar across the top — handoff § 4, 17px of `#0d1116` with a 10px yellow
   wordmark. Brand furniture, and the only thing on the card that is not about this seat.

   IT WAS CALLED `.strip` AND THAT NAME WAS ALREADY TAKEN. The phase dialog's consequence
   strip has owned the bare `.strip` since before epic 10 (§ THE DECISION, ~940 lines below)
   and it declares a 2px border, a button radius and 12/16 padding. This rule declares a
   height and no box properties at all, so all three fell straight through onto a bar that
   never meant to share a name with them — and with `* { box-sizing: border-box }` the border
   and padding come to 28 --px against a DECLARED 17, so the content box floored at zero and
   the used border-box height was 28. The bar rendered 65% too tall on every seat card at the
   felt and in the rules diagram, and ran 8 --px under the portrait slot that starts at 20.

   Nothing could see it. Every assertion in this repository reads a DECLARED value, and a
   declared 17 stays 17 however the box is actually used — which is why this shipped for a
   whole epic while the art around it was measured to the byte. `layout-check.mjs` now
   resolves the winning declaration out of the cascade and compares it against the used
   border box (§ THE USED BOX AGAINST THE DECLARED ONE), so the next one of these is loud.

   The FIX is the rename, not a scope: `.pbox .strip` was already qualified and it made no
   difference, because a descendant selector does not stop the bare rule from also matching.
   Two rules cannot share a name and be two components. The newcomer moved; the dialog's
   strip is untouched. */
.pbox .brandbar {
  position: absolute; left: 0; right: 0; top: 0; height: calc(17 * var(--px));
  background: var(--panel); display: grid; place-items: center;
  font-family: var(--display); font-weight: 900; font-style: italic;
  font-size: calc(10 * var(--px)); line-height: 1; letter-spacing: 0.16em;
  text-transform: uppercase; color: var(--yellow);
}

/* The character slot — handoff's portrait slot, inset left 6 / right 68 / top 20 /
   bottom 20, which is what leaves the score its own lane. Decorative: the seat's name,
   score and state are all in text.

   THE CROP LIVES IN THE FILE, NOT IN THIS RULE. What used to be here was `auto 270%` at
   `50% 45%`, and the comment beside it said why: the legacy mascot was drawn on a whole
   K-of-spades playing card, so the slot had to scale the art past its own box to find the
   face inside it — otherwise a seat card had a second card printed in it, competing with
   the real cards on the table. None of that reasoning survives the commissioned art, which
   is a square with the figure centred; inheriting those numbers would frame its chest.
   So the decision was re-made by looking: `player-seat.webp` is exported already cropped
   to this slot's own 76:70 — helmet crown to jersey, the face filling most of the frame —
   judged on a rendered six-seat table at --px 1.28, 2 and 3 rather than on the source.
   `cover` then draws it edge to edge at every scale, and a slot whose aspect ever moves
   crops rather than letterboxes.

   AND IT NEEDS ICE UNDER IT. The art is red, black and white on a transparent ground and
   this card is #0d1116: on that, the helmet, the gloves and every outline vanish and a face
   floats. So the slot draws its own two-stop sheet from the rink's own top and mid stops —
   the portrait is a window onto the ice, the black reads against it, and the red jersey is
   the warm thing on the cold ground, which is the whole argument for this art on this
   surface. A gradient layer rather than a flat colour, so the window matches the sheet the
   seat is standing on. */
.pbox .face {
  position: absolute; left: calc(6 * var(--px)); right: calc(68 * var(--px));
  top: calc(20 * var(--px)); bottom: calc(20 * var(--px));
  border-radius: calc(6 * var(--px));
  background-color: var(--panel-inset);
  background-image: url(assets/art/player-seat.webp),
                    linear-gradient(180deg, var(--ice-top), var(--ice-mid));
  background-repeat: no-repeat;
  background-size: cover;
  background-position: 50% 50%;
}
/* THE SCORE LANE, and handoff § 8's second trap lives in it: a two-digit score in Archivo
   900 italic with a stroke needs about 48 --px, the lane is 60 for that reason, and a
   fixed-width lane clips it SILENTLY. `layout-check.mjs` measures `scrollWidth <=
   clientWidth` here on every seat on the ring. Measured: the widest two-digit score is 48.0
   --px in the 60 --px lane, which is 12 --px clear and is exactly the margin § 8 says the
   lane is 60 for.

   AND THE SECOND SCALE IS NOT A SECOND LAYOUT, which is worth saying so that nobody reads
   more into the check than it does. § 8 warns to measure at every scale seats are drawn at,
   and the proximity rule does draw them at two — but the out state's 0.84 is a `transform`,
   and a transform scales the lane and its contents by the same factor. `clientWidth` and
   `scrollWidth` are layout values, so they are identical at both, and clipping that is not
   there at full size cannot appear at 0.84. A future out state that reached for a smaller
   `font-size` instead would be a genuinely second layout, and then the check would be doing
   the work § 8 describes rather than restating the first one.

   `overflow: visible` is deliberate: a lane that clipped would
   hide the defect from the eye while the measurement still caught it, and the measurement
   is the thing that has to be able to fail. */
.pbox .sc {
  position: absolute; right: calc(4 * var(--px)); width: calc(60 * var(--px));
  top: calc(20 * var(--px)); bottom: calc(20 * var(--px));
  display: flex; align-items: center; justify-content: center; overflow: visible;
  font-family: var(--display); font-weight: 900; font-style: italic;
  font-size: calc(36 * var(--px)); line-height: 1; color: var(--yellow);
  font-variant-numeric: tabular-nums; white-space: nowrap;
  /* handoff § 4: the score lane's stroke is the ink, 4px at 36px type. */
  -webkit-text-stroke: calc(4 * var(--px)) var(--ink); paint-order: stroke fill;
}
/* THE JEOPARDY TINT, AND IT IS A DIFFERENT KIND OF RED FROM THE ONE BESIDE IT. This colours a
   HELD value — a score sitting within one punt of the drop score — and FR-32 as amended says
   in so many words that red is not reserved and that KIND tells its meanings apart: a border is
   a state, a flood is an event, a fill is a meter, and a TINT is jeopardy. The mark below is
   the only red on this card that means a direction of travel, and it appears for 4500ms and
   goes. Neither is the goal lamp, which floods the whole card and means an event. Three reds on
   one seat card, three kinds, and this paragraph is why that is not a collision. */
.pbox .sc.danger { color: var(--red-pale); }

/* ---------- WHICH WAY THE SCORE JUST WENT (FR-32 as amended, story 13.10) ----------
   RED IS POINTS GOING ON; GREEN IS POINTS COMING OFF. It marks a CHANGE and never a held
   value: a score sitting at 9 is not coloured, a score moving from 9 is. `table.js` writes
   `data-move` from the difference between two served frames and takes the mark off after
   4500ms; everything visual is here.

   IT STANDS LEFT OF THE SCORE LANE, so the delta and the value it moved to read as one
   statement — `↑+5  16` — rather than as two marks on opposite ends of a card. Absolutely
   positioned over the portrait, which is decorative art, and OUTSIDE the 60 --px lane, so the
   score's own clipping measurement (`scrollWidth <= clientWidth`, handoff § 8 trap 2) is
   measuring exactly what it was measuring before.

   THREE CHANNELS AND NEVER THE HUE ALONE. The arrow points the way the FIGURES went — up when
   the number went up, which in this game is the bad news — and the signed number beside it is
   the arithmetic printed. A player who cannot separate the two hues reads it off either of the
   other two; a player who reads only the hue, the way the rest of the world does, would read
   this game backwards, which is why colour is never asked to carry it alone.

   ITS OWN GROUND, because the card behind it is a portrait and a portrait is not a colour. The
   plate is the meter's own dark ground, which is the one token on this card that already means
   "a readable field over the art", and on it `--red` measures 3.72:1 and `--green-live`
   9.13:1 — both clearing 3:1 at this size and weight. Measured, not assumed; the numbers are
   in the story's implementation notes.

   NO ANIMATION, DELIBERATELY. It appears, it is read, it goes. There is nothing here for a
   reduced-motion preference to turn off, which is the cheapest possible answer to the one
   question DESIGN.md leaves open about motion on this sheet. */
.scmove {
  position: absolute; right: calc(66 * var(--px)); top: 50%; transform: translateY(-50%);
  display: flex; align-items: center; gap: calc(2 * var(--px));
  padding: calc(2 * var(--px)) calc(5 * var(--px));
  border-radius: var(--r-badge); background: var(--meter-ground);
  font-family: var(--display); font-weight: 900; font-style: italic;
  font-size: calc(17 * var(--px)); line-height: 1; white-space: nowrap;
  font-variant-numeric: tabular-nums;
  color: var(--red);
}
.scmove[data-move="off"] { color: var(--green-live); }
.scmove .scarrow { font-size: calc(19 * var(--px)); }
.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));
}
/* A seat whose connection is down goes back the same way an out seat does — on the ART,
   not on the card. Their score is exactly what the rest of the table is looking at while
   they are gone, and "reconnecting…" is in the caption underneath either way. */
.pbox.away::before, .pbox.away .face, .pbox.away .brandbar { opacity: 0.5; }

/* Colour is never the only channel: every panel state is mirrored here in words.
   The status caption sits BELOW the card and on the sheet, so it takes an ink and never a
   panel text — RinkSeat.dc.html sets it at 11px mono, `#2b3a4d`, 0.16em, uppercase. It
   used to be a pale label with a black shadow because the ground was dark felt, and there
   used to be a rule flipping it when the rink was showing; with one surface there is one
   value and no flip. Its `danger` variant follows for the same reason: `--red-pale` is a
   pale label and a pale label on pale ice cannot be read. */
/* AND IT HAS TO SURVIVE A BLUE LINE. The sheet is not one colour: the two blue bands run
   the full height of the rink at 88%, and the ring puts seat cards across them at both the
   boards and the pile — so the ground under this caption is `#4e70d4` as often as it is
   ice, and `#2b3a4d` on that band is 2.5:1. It reads on ice and disappears on blue, which
   is worse than either, because the state it disappears in is "your turn".

   A STROKE, NOT A COLOUR CHANGE. Darkening the ink to clear the blue would spend the
   contrast it has against the sheet, and a halo is what actually holds a letterform apart
   from an unknown ground: `paint-order: stroke fill` draws the pale ring first and the ink
   over it, so the effective ground is the halo whatever is behind it. That is 8.9:1 on the
   blue line and 11.5:1 on ice. `layout-check.mjs` measures both grounds and will not take
   the halo on trust — it reads the stroke's own width and paint order too. */
/* THE CAPTION IS RAISED WITHIN THE CARD, not only with it. At 12 --px it drew 5.1px on a
   390-wide phone — below the size at which type is read at all — and scaling the card alone
   would have left it at 7.3. It is the line that says what a seat OWES, which is the one
   thing on a seat card a player has to read rather than glance at. */
.seat .sub {
  font-family: var(--mono); font-weight: 700; font-size: max(9px, calc(16 * var(--px)));
  letter-spacing: 0.16em; text-transform: uppercase; text-align: center;
  color: var(--seat-caption); line-height: 1.2; margin-top: calc(5 * var(--px));
  min-height: calc(14 * var(--px));
  -webkit-text-stroke: calc(3 * var(--px)) var(--seat-caption-halo);
  paint-order: stroke fill;
}
.seat .sub.danger { color: var(--red); }

/* ---------- what this seat owes and what it has, in words (story 12.18) ----------
   The owner was drawn three ways of answering "how many tricks does each player have to win,
   and how many have they won" and chose BOXES PLUS A PLAIN LINE — so the five-box meter above
   is untouched and this row is added under the caption.

   ITS OWN ROW, not a second tenant of `.sub`. That caption is a STATE ("playing", "sitting
   out", "your turn", "N from out", "reconnecting…") and stories 12.9 and 12.20 have both
   written to it inside this epic; a pair of numbers sharing it would have pushed one of them
   off. The cost is measured rather than eyeballed: 3 --px of margin plus a 13 --px line is
   16.2 --px on the card's own scale, which is 12.96 design units, and `table.js`'s
   `SHEET.seatH` carries it (155.4 -> 171.6, so 124.32 -> 137.28 at the 0.8 card). Every
   clearance the ring is built from is derived from that number, so the ice pays for this row
   once and `layout-check.mjs` re-measures the result.

   SMALLER THAN THE CAPTION AND IN THE SAME INK. The caption is the seat's state and this is
   its arithmetic, so it reads as the second line rather than as a competing first one. It
   takes the caption's halo for the same reason the caption has one: the ring puts seat cards
   across the two blue lines as often as it puts them on ice, and `paint-order: stroke fill`
   is what holds a letterform apart from a ground this file cannot know.

   `nowrap` and a `min-height` because the corridor and the ring are both laid out against a
   FIXED seat height — "has 1 needs 4" wrapping to two lines on some future longer wording
   would silently move every seat on the sheet. Tabular figures so the numbers do not shuffle
   sideways as tricks are won. */
.seat .owes {
  font-family: var(--mono); font-weight: 700; font-size: calc(11 * var(--px));
  letter-spacing: 0.10em; text-transform: uppercase; text-align: center; white-space: nowrap;
  color: var(--seat-caption); line-height: 1.2; margin-top: calc(3 * var(--px));
  min-height: calc(13 * var(--px)); font-variant-numeric: tabular-nums;
  -webkit-text-stroke: calc(3 * var(--px)) var(--seat-caption-halo);
  paint-order: stroke fill;
}

/* ---------- trick pips ----------
   As many as the hand is tricks long, along the bottom of the card — the count is the
   SERVED `handSize` since story 12.17 and no longer a five typed into table.js, which is why
   these are `flex: 1` and the row divides itself rather than being sized per box. Handoff
   § 4's geometry (9px tall, radius 2, inset 4 a side) with THREE states rather than the two
   drawn. Won = filled yellow; still owed on the bid = red with an inset white ring;
   unplayed = a hairline outline over nothing.

   ALL THREE ARE THE LEGACY'S OWN, which story 12.17 established from the source rather than
   from taste: `PlayerStatus.UpdateTrickCount` is `trickCount > i` yellow, else `bidCount > i`
   red, else a white outline, and it swaps the sprite with the colour so filled and outline
   differ in shape as well. So this palette is not a departure after all — the handoff's
   `#d8262b` for a WON trick is what differs from the original, and red meaning the debt is
   the game's own reading and matches red being punt and danger everywhere else here. The
   shape difference is also what story 6.2 asks for: the pair that has to survive colour
   blindness — won from owed — is the one that warns a player they are about to be punted,
   and `layout-check.mjs` measures these three treatments for exactly that. */
.meter {
  position: absolute; left: calc(4 * var(--px)); right: calc(4 * var(--px));
  bottom: calc(4 * var(--px)); display: flex; gap: calc(3 * var(--px));
}
/* ---------- AND THE WON BOX IS GREEN (story 13.10) ----------
   RED MEANS POINTS GOING ON; GREEN MEANS POINTS COMING OFF. That is FR-32 as amended, and this
   meter is where the grammar is smallest and most often read: a box a seat has WON is a point
   coming off at the end of the hand, and a box it still OWES is the punt going on if it does
   not. So `have` moves off `--yellow` — which meant "your score, an active choice, the primary
   action" and never meant a direction — and `need` stays exactly where it was.

   THIS IS THE ONE TOKEN THE UX CONTRACT SAYS HAS TO MOVE, and the claim under it was checked
   rather than taken: `need` is already `--red` and `spare` is already an outline, so nothing
   else here changes, and the three treatments already differ in SHAPE — filled / filled-with-
   an-inset-ring / outline — so the colour-blind channel is untouched by the swap. Read off the
   running client, the three come back `filled`, `filled ringed` and `hollow edged`.

   `--green-live` AND NOT `--seat-in`, AND THE REASON IS ARITHMETIC. On the meter's own ground
   (`--meter-ground` over the card's near-black hatch) the candidates measure: `--green-live`
   9.13:1, `--green-solid` 5.88:1, `--seat-in` 4.14:1 — against the yellow's 14.65:1 that is
   being given up and the red's 3.72:1 beside it. `--seat-in` also sits at 1.11:1 against that
   red, so the pair this meter exists to separate would have been two boxes of the same
   apparent colour told apart by a 1px ring; `--green-live` is 2.46:1 against it. No new token
   was minted: this is the green the palette already carries. */
.tsq { flex: 1; height: calc(9 * var(--px)); border-radius: calc(2 * var(--px)); }
.tsq.have { background: var(--green-live); }
.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)) var(--ink);
  paint-order: stroke fill;
}

/* ---------- centre, puck, deck, cards ---------- */
/* The trick pile. Centred on the sheet, 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. */
/* ---------- THE PILE STANDS ON THE DOT (story 13.9's ruling) ----------

   Cards land where the puck dropped. `--pile-along` is the journey from the principal point to
   the named spot along the rink's LENGTH and `--pile-across` the one over its WIDTH, both signed
   lengths, both already clamped by table.js to what the sheet can hold — the arithmetic and the
   list of things that may not be covered are there, beside the formation they are derived with.

   WIDE the rink's length runs across the screen, so `--pile-along` is the X term; tall they swap,
   in the container query at the foot of § THE RINK TURNS WITH THE SHEET. Both default to 0, so a
   sheet that has not been handed a formation yet draws the pile exactly where it always was.

   IT TRANSITIONS ON THE SKATE'S OWN 1.15s, like the seats and the ice, so the pile, the players
   and the sheet all arrive for the new hand together instead of one of them jumping. */
.centre {
  position: absolute;
  top: calc(var(--trick-y) + var(--pile-across));
  left: calc(50% + var(--pile-along));
  transform: translate(-50%, -50%); z-index: 8;
  transition: left 1.15s cubic-bezier(0.22, 0.61, 0.36, 1),
              top 1.15s cubic-bezier(0.22, 0.61, 0.36, 1);
}
:root { --pile-across: 0px; --pile-along: 0px; }
@media (prefers-reduced-motion: reduce) { .centre { transition: none; } }
.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 sheet 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)) var(--seat-ambient), 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 sheet at all.

   IT SITS BESIDE THE DEALER AND TRAVELS WHEN THE DEAL PASSES (story 12.4). What was here
   was a FIXED spot — `left: calc(50cqw - 230 * var(--px)); top: 43.8cqh` on the wide sheet,
   the bottom-right corner on the tall one — with a paragraph of clearances measured against
   the ring of the day and a note asking whoever changed it to keep `DECK` in table.js in
   step. The owner watched a hand dealt from a stack nobody was sitting at and refused it.

   So this rule no longer says WHERE. `table.js` writes `left`/`top` from the dealer's own
   seat, by the same derivation the dealer puck is placed by (`markFor`), one mark further
   out along the axis that seat has free — and the deal flourish reads the flight's origin
   off this element's laid-out box, so the stack and the cards coming off it cannot disagree.
   There is no longer a pair of numbers here and in table.js that can drift apart.

   Drawn at 0.62 of a table card: the handoff's own 96x145 stack is a full card tall and the
   sheet cannot hold one of those beside a six-card pile, let alone beside a seat. A smaller
   stack reads as a deck further away, which is what it is — and it is what makes a mark this
   size fit in the lane the puck already proves is clear.

   THE SLIDE IS THE PUCK'S ALONE, and that is deliberate rather than an omission. The deal
   passes and the deck is HANDED over, then dealt from; a stack still skating across the ice
   while cards fly off it is dealing from two places at once. The puck's 0.45s glide is what
   draws the eye to the change of dealer, and the deck is simply already there when the
   flourish starts. It also keeps `deckOrigin()` honest: an element mid-transition reports
   the position it is passing through, and the deal would launch off the last dealer. */
.deck {
  position: absolute;
  width: calc(96 * var(--px)); height: calc(145 * var(--px)); z-index: 11;
  transform: translate(-50%, -50%); pointer-events: none;
}
/* `?v=2` IS A CACHE BUST AND IT IS NOT DECORATION (owner, epic 14).

   The referee shipped, the deployment served him, and the owner still saw the old player on
   every card back — twice. `/assets/*` had been sent `public, max-age=604800, immutable`, and
   `immutable` means a browser will not revalidate for the whole week even after the header
   itself is corrected: the copy it already holds stays FRESH until its original max-age runs
   out. Fixing the header stops the next one going stale; it cannot reach the one already on a
   player's phone.

   Changing the URL is the only thing that does. This client has no build step and therefore no
   content-hashed filenames, so the version is written by hand and bumped when the bytes change.
   If you re-derive `card-back.webp`, bump it — the file is not what a browser keys on.

   `check-production-wiring.mjs` holds the header; nothing can hold this but the person who
   changed the art, which is why it is said here rather than left to be noticed. */
/* THE CARD BACK IS THE THIRD PLACE THE RETIRED NAME WAS PAINTED, and the only one no
   document had noticed. `card-back.png` was the 2013 mascot with `SNARPLES` lettered across
   its helmet band — larger and far more legible than the seat portrait's, on every
   face-down card, on the deck that is on screen for the whole game, and blown up to 118
   --px in the draw dialog. Story 10.3c's criterion is that the name leaves the PIXELS, not
   that one file leaves the folder, so it went with the mascot. Its replacement is the same
   commissioned figure cropped to a card, on the same blue field the old back sat on: one
   illustrator, one direction, and the composition the back always had. Three slots share
   this file — the deck and a face-down card both at 96x145 (a deck is a stack of cards and is
   drawn the size of one since epic 14) and the draw dialog’s stack at
   118x178 — all within a --px of the same 0.66, so one 300x453 export covers every one of
   them at the device pixels the widest screen can ask for. */
.deck i {
  position: absolute; inset: 0; border-radius: var(--r-badge);
  border: calc(3 * var(--px)) solid var(--ink);
  background: var(--card-back-blue) url(assets/art/card-back.webp?v=2) 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 var(--ink); border-radius: var(--r-card);
  box-shadow: 0 calc(8 * var(--px)) calc(18 * var(--px)) var(--seat-ambient);
  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; }

/* The gap is `--hand-gap-n` and not a literal 9, because trump's lane is now measured off the
   hand's own full-deal width and that arithmetic needs the same number this rule draws with.
   One declaration, two readers — see § THE HAND for `--hand-half`. */
.hand { display: flex; gap: calc(var(--hand-gap-n) * 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; }
/* CHOSEN, and it has to survive the ground it is standing on. A card sits on the sheet,
   so a bare yellow outline round it is #f7e93f against #dde9f5 — which is the felt's ring
   defect one element over. The `box-shadow` here is the yellow's BACKING: it fills the
   outline's offset gap and shows a hairline outside it, so the mark reads as a yellow band
   between two dark ones whatever it is over. The card's own lift rides along after it. */
/* `.cardv.sel` stood beside `.picked` here and nothing has ever set it — no JS, no markup,
   no check. Removed rather than kept "in case": a second class name for the chosen card is a
   second thing to keep in sync, and the next person reading this rule would reasonably assume
   both were live. `.picked` is the one the client actually sets. */
.cardv.picked {
  outline: calc(4 * var(--px)) solid var(--yellow); outline-offset: calc(3 * var(--px));
  box-shadow: 0 0 0 calc(8 * var(--px)) var(--ink),
              0 calc(8 * var(--px)) calc(18 * var(--px)) var(--seat-ambient);
  transform: translateY(calc(-10 * var(--px)));
}
/* Focus takes the ring the ground decides (`--ring`, which is ink out here on the sheet),
   at the card's own weight — deliberately NOT the yellow above, so a keyboard player
   standing on a card can still tell whether they have chosen it. */
.cardv:focus-visible {
  outline: calc(4 * var(--px)) solid var(--ring); 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));
  box-shadow: 0 0 0 calc(8 * var(--px)) var(--ink),
              0 calc(8 * var(--px)) calc(18 * var(--px)) var(--seat-ambient);
  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, 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 is measured OFF THE HAND rather than
     off the frame — see below.

   ---------- TRUMP IS TIED TO THE HAND NOW, IN BOTH FRAMES (story 13.5) ----------

   WHAT WAS WRONG, MEASURED. The lane stood at `left: calc(250 * var(--px))`, a number chosen
   to clear the FEED's old bottom-left corner. The feed left that corner in epic 12 and the
   anchor stayed, so on the wide sheet trump drew at 250..346 with the hand's edge at 382 — a
   36 --px gap over empty ice, read live at 250..346 against 387.79 at 1:1 and 255.7..351.7
   against 392.18 at 1600x900. DESIGN.md § Trump lane names the general lesson: *a fixture
   anchored to a coordinate outlives the reason for the coordinate.*

   AND UPRIGHT WAS NOT THE COUNTER-EXAMPLE THE CONTRACT THOUGHT IT WAS. Both spines record
   "portrait already does — 16 units clear at the corridor's left margin", and that is true of
   a 720-unit upright sheet and of nothing else. `--px` upright is `min(w/720, h/1280)`, so at
   3:4 the sheet is 969 design units across, not 720: measured at 810x1080 the lane drew
   17.98..97.98 with the hand starting at 232.49 — a gap of 134.51 --px, eight times the
   stated 16. The tall frame had the same defect as the wide one and nobody had measured the
   frame where it shows.

   SO THE LANE IS WRITTEN AGAINST THE HAND'S OWN HALF-WIDTH, which is the object it must sit
   beside, and the same one declaration serves both compositions:

     right: 50% + --hand-half + --trump-gap

   `--hand-half` is the hand AT FULL DEAL — `--hand-cards` cards of `--card-w` at
   `--hand-gap-n` apart — and table.js writes the count from the SERVED hand size. Full deal
   rather than the hand as it stands, deliberately: a hand shrinks by one card per trick, and a
   trump box sliding inward five times a hand would be motion that meant nothing. It is the
   widest the corridor's centred occupant ever is, so the clearance it states is the tightest
   one there is.

   WHAT IT COMES TO. Wide: --hand-half 258, gap 16, so the lane is 274..370 --px off the
   midline at every aspect. Upright: 252 and 16, so 268..348. A watcher has no hand and the
   spectator bar stands in the corridor in its place, at 459 --px wide (460 upright): its half
   is 229.5, so trump clears it by 44.5 --px wide and 38 upright, and `layout-check.mjs`
   measures that pair rather than trusting this line. */
.corridor { position: absolute; left: 0; right: 0; bottom: calc(16 * var(--px)); height: 0; z-index: 13; }
.corridor .lane {
  position: absolute; bottom: 0;
  right: calc(50% + var(--hand-half) + var(--trump-gap));
  width: var(--trump-w); 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.

   ON THE ICE DIRECTION SINCE STORY 10.7. Handoff § 9 admits the spectator state was never
   designed, and what epic 5 shipped was a plain dark box with a text label at the top and a
   `.chrome` button in it — the PRE-TABLE button kind, which is sized in vmin and was
   therefore the one control at the felt that did not scale with the sheet. The bar now
   takes the system's own two pieces: the board header along its top edge (§ 4 Chrome — the
   dasher board and its white cap rail) carrying who is here, and the table's own button
   system for the action, in `--px` like everything else on this surface. None of that moves
   it: the width, the centring and both corridor invariants are exactly the arithmetic
   above, which is why `layout-check.mjs` re-measures rather than re-derives them. */
/* ---------- AND THE LAYOUT GAVE WAY TO THE ICE (story 12.21) ----------

   TWO THINGS THE 500 --px WIDTH WAS NEVER DERIVED AGAINST, and both arrived in the same story.
   The width above was set against TRUMP's lane, which is still the constraint it clears; what it
   had never been asked about is the FACEOFF, because until story 12.13 the wide sheet drew a ring
   and until 12.21 the drop sat at 41cqh, so the arc never reached the corridor.

     THE ARC. A wing on a wide sheet stands `--face-r` off the drop across the sheet and 140.66
       --px down it, so with the drop at the middle its card's bottom edge is 562.82 --px down a
       720-unit sheet and its INNER edge is `295.6 - 60 = 235.6` --px off the middle. A box
       centred on the middle at 500 --px reaches 250 and lay 14.4 --px across it — measured at
       1280x720, where `layout-check.mjs` said "spectator bar overlaps seat panel" twice.
       459 --px reaches 229.6, six clear of the card, and the pair no longer share a column at
       all — which is a better answer than clearing them vertically by a pixel or two, because
       the arc's own reach is arithmetic and the bar's height is its CONTENT.
     THE NEUTRAL-ZONE FACEOFF SPOTS, which are 22 ft off the middle of the ice — 20.5 ft from
       the side boards, and a real 20.5 ft now. At 16:9 that is 165 design units from the foot,
       of which the corridor's own rail takes 16: the bar has 149 --px and it was 155.7. The
       vertical padding on its two rows is what gave (10 -> 6 and 8 -> 5), which brings it to
       139.6 --px — 9.6 clear rather than 6.4 over.

   THE PLAYER'S OWN CORRIDOR NEVER HAD THIS PROBLEM and that is the useful comparison: a hand is
   145 --px tall and 360 wide, so it fits under the spots and stands well inside the arc's column.
   The bar is the watcher's equivalent and it stacks a header, a chat input and a seat action, so
   it is the tallest and widest thing the corridor holds. Both numbers here are it being measured
   against the ice rather than against trump alone. `layout-check.mjs` holds both. */
.specbar {
  width: calc(459 * var(--px)); max-width: calc(459 * var(--px)); box-sizing: border-box;
  display: flex; flex-direction: column; align-items: stretch;
  background: var(--feed-ground); border: calc(2 * var(--px)) solid var(--rail);
  border-radius: calc(8 * var(--px)); overflow: hidden;
  box-shadow: 0 calc(6 * var(--px)) calc(16 * var(--px)) var(--shade);
  text-align: left;
}
/* The board header, at the table's own unit. Handoff § 4: `--boards` behind, a white cap
   rail under it, the title in display italic. Full-bleed, which is what the `overflow:
   hidden` and the zero padding on the card above are for. */
.specbar .sbar {
  display: flex; flex-wrap: wrap; align-items: baseline;
  gap: calc(4 * var(--px)) calc(10 * var(--px));
  background: var(--boards); border-bottom: calc(3 * var(--px)) solid var(--rail);
  padding: calc(5 * var(--px)) calc(14 * var(--px));
}
/* Everything under the header. */
/* THE VERTICAL PADDING AND THE GAP ARE WHERE THE LAYOUT GAVE WAY (story 12.21), and both are
   measured rather than nudged. See `.specbar` above for the budget this box now has to fit in. */
.specbar .sbits {
  display: flex; flex-direction: column; gap: calc(6 * var(--px));
  padding: calc(6 * var(--px)) calc(14 * var(--px));
}
/* The action, in the table's primary kind rather than the landing's. `min-height` because
   `.btn`'s own box is a function of `--px` and the 44px hit-target floor is a real pixel
   count — at the narrowest viewport the app admits, the dialog's own numbers come out
   under it, and a control a thumb cannot find is the same defect wherever it stands. */
.specbar .sbtn { margin: 0; min-height: var(--hit-target); font-size: calc(20 * 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));
  /* The floor again — same rule, same reason (see above). A watcher tapping this on an iPhone
     zoomed the page exactly as a player tapping the feed's copy did. The bar is the tallest
     thing the corridor holds and story 12.21 measured it to 9.6 --px of the neutral-zone
     faceoff spots, so `line-height: 1` is here to keep the row's height its padding and its
     border rather than the browser's default leading on a type size that is now a floor. */
  color: var(--white); font-family: var(--body); font-size: max(16px, calc(14 * var(--px)));
  line-height: 1;
  /* SIZED TO ITS TEXT (story 13.5), the same rule and the same mechanism the feed's own input
     takes. It costs the bar nothing — the bar's width is trump's clearance arithmetic and does
     not move — but a watcher's chat box was 431 --px across for a fourteen-character
     placeholder, and "a chat input is the size of its content" is a claim about every one of
     them or about none. `align-self` for the same reason the feed's own input needs it: `.sbits`
     is a flex column and a flex item's default cross-axis alignment is `stretch`, which fills
     the bar whatever the width says. */
  width: auto; max-width: 100%; align-self: flex-start;
}
.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 ---------- */
/* CAPPED IN BOTH FRAMES (story 12.13). The banner is the corridor's only occupant whose width
   is its CONTENT — the constraint sentence and the stated default are both server copy — and
   the wide sheet's arcs are stood off it sideways, so an uncapped banner is a fixture the
   formation cannot be derived against. 340 is table.js's `BANNER_MAX`; the tall frame keeps
   its own 500, which is trump's lane's number. Wrapping is what gives way, and a banner two
   lines tall still stands above the hand rather than in it. */
.turnban {
  max-width: calc(340 * var(--px));
  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)) var(--ink); }
.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. */
/* THE CARD TURN'S TEACHING LINE (story 13.12), and the reason it is `nowrap` is arithmetic
   rather than taste.

   This is the one of six decisions with no dialog to carry it, so the banner is its phase
   prompt. The banner stands in the bottom corridor, and the corridor is FULL: measured on the
   running client with a five-card trick on the felt, the pile's own box clears the banner's
   top by 27.66 --px at 1600x900 and 28.52 at 1280x720. One text row here costs 18.4 --px
   (12 --px type at 1.2, plus the 4 --px margin) and two cost 32.8 — so a line that wrapped
   once would put the banner under the trick. `SHEET.footStack` cannot pay for it either: it is
   266, and story 13.9 put the pile ON the drop, so raising it re-opens exactly the defect that
   story measured away.

   THE CLAMP IS THE GEOMETRY AND THE GATE IS THE COPY. `nowrap` makes the row exactly one line
   whatever arrives, so no served sentence can ever break the corridor; `browser-client.mjs`
   then fails when the served line does not FIT in that line, so a truncated sentence is a red
   build rather than a quiet ellipsis on a player's screen. Two mechanisms because they refuse
   two different things.

   The tall frame is not the binding case and gets the same treatment anyway: its banner is
   capped at 500 --px against trump's lane, so a line that fits the wide frame's 340 fits it. */
.turnban .x {
  display: block; font-family: var(--body); font-weight: 500; font-size: calc(12 * var(--px));
  color: var(--white-soft); line-height: 1.2; margin-top: calc(4 * var(--px));
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* `display: block` above beats the UA's `[hidden]` rule, so the absence has to be restated —
   and it has to be `display: none` rather than `visibility` or an empty string, because "a
   missing line draws nothing" is a claim about the banner's HEIGHT in a corridor with 27 --px
   to spare, not about whether anything is legible. */
.turnban .x[hidden] { display: none; }
.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));
}

/* ---------- what your own hand still owes (story 12.18) ----------
   ALWAYS ON while you are in a hand — which is the whole of it: the bid screen states the
   consequence and then closes, so from that moment until scoring nothing on the felt said
   what you still had to do, and that is exactly the stretch where it matters. Four words, in
   the game's voice.

   WORDS ONLY. A version carrying the score arithmetic was drawn for the owner and declined,
   so there is no number here but the count of tricks still needed.

   IT IS OUT OF FLOW, AND THAT IS THE INTERESTING PART. It was written first as a fourth row
   in `.corridor .mid`, under the hand, which is the obvious place for it — and
   `layout-check.mjs` refused it at every 16:9 aspect. THE CORRIDOR HAS NO ROOM: the
   neutral-zone faceoff spots sit at 75.88% across the sheet, which leaves the band under them
   149 --px deep, and a hand is 145 of those. Story 12.21 already spent that margin measuring
   the spectator bar down to 139.6; the four units left are not a row. A row cost the hand 27
   --px of height (a 15 --px line and the column's 12 --px gap), which put it over both spots
   and, at centre ice, over two seat cards.

   So the line hangs above the cards instead, out of flow, inside a wrapper whose height is
   the hand's height. `#hand` does not move by a pixel, which means every clearance measured
   before this story is unchanged BY CONSTRUCTION rather than by re-measurement — and
   `SHEET.corridorTop` in table.js stays at 310.

   WHAT MAKES THAT LEGAL RATHER THAN A DODGE: the band it hangs in is where the spots are, so
   the line has to clear them SIDEWAYS. It does, by arithmetic — the two spots stand at 36.85%
   and 63.14% of a 1280-unit sheet, so 471.7 and 808.2, and a `nowrap` line centred on 640 is
   about 136 --px wide (16 characters of 12 --px mono at 0.12em) reaching 572..708. That is
   100 --px clear of each spot. `layout-check.mjs` NAMES this box and holds it to the same
   rule the hand, the gear, EXIT and the spectator bar are held to, so the margin is measured
   at every aspect rather than trusted to this comment — an unnamed box sitting over the ice
   is exactly the defect the badge column taught this file in story 12.12.

   THE GAP IT HANGS IN IS THE COLUMN'S OWN. With the turn banner up, `.mid` puts 12 --px
   between it and the hand and this line is 12 --px tall, so the two never touch and the
   banner does not move either. With the banner hidden — which is most of a hand — it hangs
   over the ice.

   IT IS NOT A BANNER. No border, no ground, no glow: the banner is an event ("your turn!")
   and this is a standing fact, and giving a standing fact the same weight as the event would
   leave the corridor shouting continuously. Yellow because it is the thing to read, dropping
   to the muted ink once the obligation is met — colour is not carrying it either way, the two
   states say different words. It takes no pointer: it hangs directly over the cards' top
   edge, and a label that swallowed a tap meant for a card would be worse than no label.
   `nowrap` because a second line would reach down onto the cards. */
.handwrap { position: relative; }
.handreq {
  position: absolute; left: 50%; bottom: 100%; transform: translateX(-50%);
  pointer-events: none;
  font-family: var(--mono); font-weight: 700; font-size: calc(12 * var(--px));
  letter-spacing: 0.12em; text-transform: uppercase; text-align: center; white-space: nowrap;
  line-height: 1; height: calc(12 * var(--px)); color: var(--yellow);
  font-variant-numeric: tabular-nums;
  -webkit-text-stroke: calc(3 * var(--px)) var(--ink); paint-order: stroke fill;
}
.handreq.met { color: var(--text-muted); }
/* IT PRESENTS AND THEN FADES (owner's rule, epic 14). It is a notification about a number that
   just changed, not a label on the hand — standing there for the whole hand it is furniture
   over the cards. Opacity and not `display`, because the corridor is a bottom-anchored stack
   and a row that stopped taking space would push the hand up and down between hands. */
.handreq { transition: opacity 700ms ease-out; }
.handreq.faded { opacity: 0; }
/* Reduced motion gets the sentence and no fade at all: the point of the setting is that things
   do not move or vanish on their own, and a line that is simply there costs nothing. */
@media (prefers-reduced-motion: reduce) {
  .handreq { transition: none; }
  .handreq.faded { opacity: 1; }
}

/* ---------- you must win them all (story 12.19) ----------
   THE ONE MOMENT IN A HAND WHERE IT IS STILL ENTIRELY IN YOUR HANDS. What is still needed
   equals what is left: every remaining trick is yours or you are punted. The owner asked for
   it in those words — *"notify the player they must win the rest of the tricks to not get
   punted. they dont need to click anything but show it for a few seconds while they select a
   card"* — and separately for the other half: *"just make sure you dont show them the
   notification if they have no chance of getting the trick score"*. So it is an EQUALITY. A
   seat needing 3 with 2 left is told nothing at all; that verdict was offered in story 12.18
   and declined, and a `>=` here would ship it by accident.

   IT IS NOT A MOMENT. The punt and the Spade Out take the whole sheet and close on the
   server's own hold. This is a line beside a hand, timed by the client, with play continuing
   under it — no scrim, no ground, nothing to press, and `pointer-events: none` so the tap
   that plays a card goes to the card.

   IT SHARES THE REQUIREMENT LINE'S BAND, and that is measured rather than chosen. The corridor
   has FOUR spare design units (story 12.18 spent the rest), and the turn banner — which is up
   for the whole of this, because this only ever fires on your own card turn — already stands
   303.8 --px of the 310 `SHEET.corridorTop` reserves at 390x844. So a second row above the
   cards would push the hand over the neutral-zone faceoff spots exactly as 12.18's first cut
   did, and a row hung above THIS one would stand on the banner. There is one band and the two
   lines take turns in it: `renderMustWin` hides `#handReq` while this is up and gives it back
   when it clears. Nothing is lost meanwhile — this is the same obligation said louder.

   IT IS LOUDER BY THREE CHANNELS AND NOT BY SIZE. Growing the type would reach up into the
   banner, so the weight is carried by the WORDS (a different sentence, not a bigger one), by
   the red the punt is already drawn in, and by a flash. Colour carries nothing alone: "win the
   rest or punted" and "2 more or punted" are different sentences before they are different
   colours.

   REDUCED MOTION STOPS THE FLASH AND NOT THE NOTICE. It may stop moving; it may not stop
   existing — a player who asked for less motion did not ask to be punted without warning.

   `nowrap` and centred, like the line it shares the band with: a second line would reach down
   onto the cards. MEASURED, at six viewports on the running client: the sentence is 250.5 --px
   wide at every one of them (it is one string at one size, so the only thing that changes is
   `--px`), and its clearance from the nearer neutral-zone faceoff spot is

       1:1  900x900     160.9 --px      9:19.5 390x844      52.7 --px
       4:3  1200x900     88.8 --px      3:4    810x1080    111.4 --px
       16:9 1280x720     35.7 --px      smallest 339x602    52.7 --px

   — tightest at 16:9, where the sheet is widest against the spots' own share of it, and clear
   at every one. 12.18's shorter line has more room by exactly the difference in their lengths.
   `layout-check.mjs` names this box separately and measures both, at every aspect, and plants
   the state to do it: the notice is up for six seconds on one turn a hand, so a pass that
   waited for a real table to reach the equality would measure it almost never. */
.mustwin {
  position: absolute; left: 50%; bottom: 100%; transform: translateX(-50%);
  pointer-events: none;
  font-family: var(--mono); font-weight: 700; font-size: calc(12 * var(--px));
  letter-spacing: 0.12em; text-transform: uppercase; text-align: center; white-space: nowrap;
  line-height: 1; height: calc(12 * var(--px)); color: var(--red-pale);
  font-variant-numeric: tabular-nums;
  -webkit-text-stroke: calc(3 * var(--px)) var(--ink); paint-order: stroke fill;
  animation: mustwin 0.5s steps(1, end) 4;
}
@keyframes mustwin { 50% { color: var(--yellow); } }
@media (prefers-reduced-motion: reduce) {
  .mustwin { animation: none; }
}

/* ---------- 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. */
/* ---------- AND ON A WIDE SHEET IT IS AT THE HEAD, DOWN THE MIDDLE (story 12.13) ----------

   IT WAS IN THE BOTTOM-LEFT CORNER, and the owner's complaint about the desktop table names
   it: the wide sheet still carried the pre-epic-12 furniture. With the players facing off
   left against right, that corner is no longer free — the far side's lower wing stands at
   x 300..468 --px with its card running to 498 down the sheet, and the box's own 244 x 238
   in the corner reaches x 244 and y 482. They overlap, and every other corner was measured
   before this one was chosen:

     BOTTOM-LEFT   the far side's lower wing lies across it (above). The wing cannot move:
                   its standoff is the arc's, and the arc's is the pile's.
     HEAD-LEFT     worse. The far side's CENTRE stands 295.6 --px off the drop's own column,
                   so its name box runs from x 264.9 — a 244-wide box on the left rail ends
                   at 254 and the two are 11 --px apart with the box's whole depth to fall
                   through. Every count puts a seat there.
     THE RAILS     there is 96 --px of ice outside the boards lines at the narrowest wide
                   frame and a name box is 168.
     HEAD-CENTRE   free at every count, and free BY CONSTRUCTION rather than by luck: the
                   arcs are stood off the corridor's own stack sideways, which leaves a band
                   340 --px wide down the middle of the sheet that no seat may enter. A
                   244-wide box centred in it clears each arc by 48. Below it is the
                   circle (see `--chat-cap`); above it, the gear and EXIT are in the two
                   corners rather than across the head, so the box stands BESIDE them on the
                   same 16 --px rail rather than under them — which is the one way this
                   differs from the tall frame's answer, and it is a difference in where the
                   chrome is rather than in the rule.

   It is pinned to the head and grows DOWNWARD, exactly as the tall one does, so the
   measurement `layout-check.mjs` takes against a FILLED feed is the same measurement it
   always was. */
/* ---------- AND NOW IT IS AT THE FOOT, RIGHT OF THE HAND (story 13.5) ----------

   THE BENCHES CAME TO THE HEAD, so head-centre stopped being free. Story 13.7 put both lines
   along one long edge and chose the head: the wide sheet's long sides are its head and its
   foot, the foot is taken by the hand, the turn banner and trump's lane down the middle, so
   the seats went to the head — where the box was. The 13.7 spec measured what that cost and
   named this story as the one that pays it back: *"the 86 --px the chrome takes is the whole
   of what makes the wide sheet tight. Story 13.5 is the story that moves chat off the head of
   a wide sheet."*

   WHY THE FOOT-RIGHT CORNER AND NOT SOMEWHERE ELSE, measured at 1600x900 (a 1300.3 --px sheet):

     FOOT-CENTRE   the hand is there. `--hand-half` is 258 and the banner reaches 170, so the
                   middle 516 --px of the foot is the one band nothing may enter at all.
     FOOT-LEFT     trump's lane is there — 274..370 --px off the midline since this same story
                   tied it to the hand — leaving 280 --px between the lane and the rail against
                   the box's 244. It fits, and it is refused: a watcher has a spectator bar in
                   the corridor instead of a hand, and the bar's own header would then have the
                   feed standing directly over the seat action at the left.
     FOOT-RIGHT    the hand's right edge is at 908.14 --px and the rail at 1284.3, so there are
                   376.2 --px of foot with nothing in it. The box takes 244 and clears the hand
                   by 132.2. It is free of the ice too: the neutral-zone faceoff spots nearest
                   this edge stand 22 ft either side of the middle of the sheet — 461 and 839
                   --px at this frame — and both are LEFT of the hand's own right edge, so the
                   corner the box takes is the one part of the foot that has no marking under it.

   IT IS PINNED TO THE FOOT AND GROWS UPWARD, which is the same sentence the head-end version
   made with `bottom: auto`: the input stays where a thumb left it while the lines fill in
   above. `layout-check.mjs` still measures it against a FILLED feed, which is still the worst
   case and now still the only one.

   WHAT IT BUYS THE PILE: NOTHING, AND THAT WAS NOT THE EXPECTATION. `pileFix` in table.js is
   written against the FIXTURES rather than against numbers of its own, so the head-end term
   did change with the box — `--chat-foot + 12` (208 --px at 1600x900) became `--chrome-reach +
   12` (86), and the term itself went from 79.5 --px of journey to 201.5. The pile did not move
   by a pixel. Measured, with ONLY that fixture swapped back to a literal 196 and the bench
   left where it is, at five spots and five landscape viewports: -331.29 / -188.01 / -69.35 /
   -69.35 / -326.07, identical to the byte either way. The bench binds first — story 13.7 put a
   row of chairs at the head, and `--bench-out` less half a bench-scale card and the pile's own
   half is 69.35 --px at 16:9 against the chat box's 79.5. The chair had already taken that
   bound before this story arrived.

   WHAT IT DOES BUY IS THE MENU'S BAND, which is the same 190 --px read at the other end of the
   arithmetic. `--menu-ceil` on a wide sheet is the 16 --px head rail because the head is empty;
   with the box still at head-centre it would have had to be `--chat-foot` plus its air, 208 --px,
   and the free band above the hand at 1600x900 would be 314.7 --px against the 396.7 the bid
   panel needs. It would not fit, at the widest window this game admits, and the never-cover
   rule would be reporting a failure at that viewport instead of a clearance. Moving the chat
   is what makes story 13.4 satisfiable on the wide sheet — not what makes the pile travel. */
.feed {
  position: absolute; right: var(--chat-rail); bottom: var(--chat-rail); z-index: 15;
  /* SIZED TO ITS TEXT, UP TO A STATED CAP (story 13.5). `fit-content` on both axes, capped at
     the 244 x `--chat-cap` box this was fixed at — the cap does not disappear, which is the
     half of "sized to its text" that is easy to lose. EXPERIENCE.md is explicit: *"an
     unbounded feed eats the sheet, and epic 12 already shipped a bar that met every stated
     constraint honestly and took a sixth of the screen because nothing bounded the whole."*
     So the box is never wider or taller than it was and is usually smaller. */
  width: fit-content; max-width: calc(244 * var(--px));
  max-height: var(--chat-cap); box-sizing: border-box;
  /* The colour is declared under the gradient as well as in it. `layout-check.mjs` reads
     the ground a focus ring is drawn against by compositing background COLOURS up the
     ancestor chain, and a box carrying only an image reports its parent's — which had the
     chat input's ring measured against the sheet two layers up, where the feed's own dark
     is the answer. The gradient still does the fading. */
  background-color: var(--panel);
  background-image: linear-gradient(90deg, var(--feed-ground), var(--feed-ground-fade));
  border: calc(2 * var(--px)) solid var(--edge-strong);
  border-radius: calc(10 * var(--px));
  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(--body); font-weight: 500; 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-live); }
/* 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));
  /* 16px is a FLOOR here — see "SIXTEEN PIXELS IS A FLOOR AND NOT A SIZE" above. This is the
     input the owner's report is about: tapping it on an iPhone zoomed the page, which moved
     the visual viewport's WIDTH, which is the one thing story 12.20's discriminator required
     to hold still. The 44px thumb target below is unaffected — the content box at 16px is
     about 24px tall, so `min-height` still governs and the head bar's measured clearances
     (story 12.20) do not move. */
  font-family: var(--body); font-weight: 400; font-size: max(16px, calc(14 * var(--px)));
  color: var(--white); background: #10141c99; box-sizing: border-box;
  min-height: 44px;
  /* ---------- SIZED TO ITS TEXT, UP TO A STATED CAP (story 13.5) ----------
     It was `width: 100%` in both compositions, which is the box being sized to the STRIP
     rather than to what it carries: measured at 810x1080 the input drew 613.4 real px across
     for a placeholder fourteen characters long, and 233.1 at 390x844 and 256.9 at 1600x900.

     `size`, THE ATTRIBUTE, AND NOT `field-sizing: content`. The property was the first cut and
     was taken out after looking at it: it sizes an empty field to its VALUE and not to its
     placeholder, so the box collapsed to its own `min-width` and the prompt — which on this
     input is the SERVED chat limit, "Say something… (up to 200 characters)" — was cut to "Say
     somethin" on a phone. A chat box a player cannot read the rules of is not an improvement
     on a chat box that is too wide. `size` is the intrinsic width every engine has honoured
     since HTML had inputs, it is stable whether the field is empty or full, and it is 24
     characters here — under half of what the strip was giving it and over what the fallback
     placeholder needs.

     THE CAP DOES NOT DISAPPEAR, which is the half of "sized to its text" that is easy to
     lose: `max-width` is 100% of whatever row the box is in, so the input can only ever
     SHRINK from where it stood. That direction matters — `browser-client.mjs` holds EXIT a
     whole 44px thumb clear of this input on the tall sheet, and a box that only shrinks can
     only widen that gap. */
  width: auto; max-width: 100%;
  /* AND `align-self` IS WHAT MAKES THE WIDTH MEAN ANYTHING. `.feed` is a flex column and a
     flex item's default cross-axis alignment is `stretch`, which fills the track whatever the
     width says — the first cut of this changed nothing at all at any landscape frame, and the
     three upright ones only moved because the tall bar is a GRID whose own rule sets
     `justify-self`. Same defect, same fix, two layout modes. */
  align-self: flex-start;
}
.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 MENU — one shell, five decisions, one treatment
   (stories 13.3 "the lines come down", 13.4 "nothing covers your hand",
    13.6 "one uniform, and it is yellow"; FR-6 and FR-26 as amended)

   THE JUMBOTRON IS ABANDONED. It has now been rejected twice by different routes — once as
   a direction during the original exploration, once as the ice handoff's decision overlay —
   and the second failure is the informative one: it was drawn against a single frame and did
   not survive the range of screens this game is actually played on. Story 12.24 deleted the
   two suspension rods it hung from (they painted above the scrim and were the white bars the
   owner reported); this story deletes the shell and the vocabulary with it. A third proposal
   for a hanging fitting has two failures to be read against, not none.

   WHAT REPLACES IT IS A MENU, and the class is `.menu` because "one class carries the whole
   treatment" is the first of the four rules that make "uniform" checkable rather than
   aspirational. The four, from DESIGN.md § Menu:

     1. ONE CLASS CARRIES THE WHOLE TREATMENT. Per-kind colour overrides are forbidden
        outright — not discouraged, forbidden — because a per-kind override is exactly how
        one shell came to wear four treatments across five decisions. What a `[data-kind]`
        rule below may still say is LAYOUT: which way a body runs, whether a panel hangs.
        Nothing below sets a background, a border colour, a strip or a radius.
     2. THE CHECK DRAWS EVERY KIND AND COMPARES COMPUTED STRINGS. `layout-check.mjs`
        (`menuUniformComplaints`) opens each decision in turn and requires the background,
        the border, the strip and the radius back BYTE-IDENTICAL. That moves with a rename
        instead of emptying quietly, which is the same technique the colour-pairing sweep
        already uses to derive meter treatments from the stylesheet.
     3. THE YELLOW IS THE PALETTE'S YELLOW — `--yellow-lit` to `--yellow`, the primary
        button's own gradient tokens, over `--yellow-edge`. No hue is introduced here.
     4. BODY TYPE ON IT IS `--ink`, and it is MEASURED. Moving off a dark panel flips the
        type token with the ground, and DESIGN.md § Colours names picking the panel-text
        token on a light ground as the single most likely defect in this change. So the
        panel re-declares the on-dark names it inherits (see the block below) rather than
        thirty rules each being re-tinted by hand: one place to be wrong, one place to fix.

   WHAT THE FIVE LOOKED LIKE BEFORE, measured at 810x1080 — four treatments across five
   decisions. A lit strip in `--red` for the bid and the all-sat punt, `--yellow` for the
   trump call, `--green-live` for sit-or-play (which also drops the scrim), and no panel at
   all for the draw; over declared widths of 720 / 760 / 800 / 900 --px. Uniform now means
   one ground, one border, one strip, one radius, one width and one set of paddings, at every
   frame and in both compositions.

   DQ-YELLOW-ON-YELLOW, CLOSED. A yellow menu wants a primary button and the primary button
   is yellow. Of the three routes DESIGN.md names, this takes the first: THE BUTTON SYSTEM
   GAINS AN ON-YELLOW KIND. Inside a menu `.btn` inverts — the ink gradient the panel's own
   type is set in, with `--yellow` lettering and the primary's own hard offset in
   `--yellow-edge` — so the loudest thing on the panel is still the loudest thing on the
   panel and it is legible against its ground rather than dissolving into it. The frame-not-
   fill route was refused because rule 4 above is explicit that the GROUND flips; a yellow
   frame round a dark panel keeps the panel-text token and answers none of it.

   DQ-DRAW-MENU, CLOSED, AND THE REASON IS 13.4's. The draw was the one decision with no
   panel, deliberately: "a fitting dropping from the roof would put the shell in front of the
   very cards the decision is about". That reasoning is exactly what the never-cover rule
   abolishes — no menu may cover the hand, at any frame, so the draw's panel cannot land on
   the cards it is asking about. It therefore takes the shell like the other four, and "one
   shell for every decision" stops needing a footnote. What it KEEPS is its ice wash instead
   of the modal scrim: it is the one decision *about* the hand, and dimming the hand it is
   about would undo the half of the exception that was right. A scrim is not one of the four
   properties uniform is defined over, and the difference is declared below rather than
   inherited by accident.

   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, and they do NOT
   cover the hand. That last one is a rule now and not a placement; see the next block. */

/* ---------- WHERE A MENU GOES, IN ORDER (story 13.4) ----------
   EXPERIENCE.md § The rule that outranks placement writes the law in five steps, and the
   whole of it is expressible in one declaration because CSS already has the shape:

     1. IF THE MENU FITS IN THE FREE BAND, IT CENTRES IN IT. Centred is the preference, and
        it is the only thing in the list that is a preference.
     2. IF IT DOES NOT FIT, IT PUSHES UP: its foot pins to the protected box's top less the
        gap, and it grows toward the head.
     3. IT NEVER SCROLLS TO FIT. A scrolling menu puts its footer below a fold, and the
        footer is the sentence naming what happens if nobody answers — the whole reason the
        shell exists.
     4. IT NEVER SHRINKS TYPE BELOW THE FLOOR to buy room.
     5. IF IT STILL DOES NOT FIT, THAT IS A REPORTED FAILURE AT THAT VIEWPORT, not a menu
        that overlaps a little. A layout that cannot hold a decision above the hand is a
        layout to change.

   `top` + `bottom` + `height: fit-content` + `margin-block: auto` IS steps 1 and 2, in that
   order, with no branch and no per-orientation variant. A box shorter than the band is
   over-constrained and the auto margins resolve it by centring; a box taller than the band is
   clamped by `max-height` to exactly the band, which pins its foot at `--menu-floor` and
   grows it toward `--menu-ceil`. Step 3 is what `layout-check.mjs` refuses (`scrolls`), step
   4 is what the type floors already hold, and step 5 is what a refused scroll IS — the check
   names the viewport and the kind rather than the geometry quietly overlapping.

   THE BAND'S TWO ENDS ARE `--menu-ceil` AND `--menu-floor`, both on `:root` beside the
   protected box they are derived from. Nothing else in this file may state a menu's `top`.

   WHAT THE OLD REGION IS STILL FOR. The four `--decide-*` insets are
   the band the FORMATION leaves — written against the standoffs table.js publishes, so they
   follow the faceoff rather than restating it. They no longer place anything; they bound the
   ONE decision that does not dim, where an opaque panel landing on a seat card hides the
   information the screen exists to show (handoff § 8 trap 3). The three that dim are held to
   the hand and to the sheet's own rail and to nothing else, which is the ranking
   EXPERIENCE.md sets out: the hand is absolute, then the pile, then the seats on sit-or-play
   alone, then centring, which gives way to all three. `layout-check.mjs` reads all of it off
   the running client rather than restating a rectangle here. */
.table {
  /* ---------- THE SEAT BAND: where the ONE undimmed decision may stand (story 13.4) -------
     Four insets rather than a top and a bottom, because the thing being cleared is on a
     different EDGE in each composition and a pair of vertical bounds cannot say so.

     WHAT IT USED TO BE AND WHY THAT STOPPED WORKING. Story 12.8 made this the band between the
     two BOARDS LINES; 12.5 gave that band to all four decisions; 12.13 added a sideways bound
     against `--line-out-f`. Story 13.7 then deleted the boards lines, moved every waiting seat
     to a BENCH along one long edge, and re-pointed `--line-out-*` at the FACEOFF — which is
     the one place on the sheet nobody stands while a sit-out is being asked, because table.js
     holds the sit-out UNSETTLED and every seat is at its bench station. So the region reserved
     empty ice and left the bench uncovered. Measured with the panel open and six on the bench:
     seats 0, 3 and 4 under it at 390x844, 810x1080 and 339x602 — the whole left rail — and no
     check said so, because every check was measuring the band it had been handed. 13.7 left
     this here by name: *"Where a menu should sit relative to the BENCH is story 13.4's."*

     WIDE, the bench is at the HEAD, so the bound is a `top` and the two rails are free. The
     panel is 900 --px wide here where the retired faceoff bound gave it 413, which is most of
     why it no longer scrolls at 16:9. */
  --decide-top: var(--bench-in);
  --decide-bottom: calc(176 * var(--px));
  --decide-left: calc(15 * var(--px));
  --decide-right: calc(15 * var(--px));

  /* ---------- THE FREE BAND, and why these three are on `.table` and not on `:root` --------
     `--guard-top` is the top of the protected box: the DECLARED value here, and the MEASURED
     one table.js writes over it as an inline property on this very element (`measureGuard`).
     A `var()` inside a custom property is substituted where that property is declared, so
     `--menu-floor` has to be declared on the same element as the thing it reads or it would
     quietly keep the fallback for ever. That trap is already recorded twice in this file, at
     `--focus-ring` and at `--px`; this is the third and it is the one where being wrong is
     invisible, because the fallback is a plausible number.

     `--menu-ceil` is the head of the usable sheet. Wide, the head carries the gear and EXIT in
     its two CORNERS and nothing across the middle since story 13.5 moved the chat to the foot,
     so it is the same 16 --px head rail every other fixture is pinned to; upright the head is
     a bar and the ceiling is its foot (see § THE TALL FRAME). Two values, one declaration
     each, read by one rule — that is what "one design answers both form factors" costs. */
  --guard-top: calc(100cqh - var(--hand-reach) - var(--card-raise));
  --menu-ceil: calc(16 * var(--px));
  --menu-floor: calc(var(--guard-top) - var(--guard-gap));
}
.menu {
  position: absolute; left: 50%; transform: translateX(-50%); z-index: 25;
  /* ---------- THE PLACEMENT LAW, in four properties (story 13.4) ----------
     `top` and `bottom` are the free band; `height: fit-content` lets the box be its content;
     `margin-block: auto` centres it in the band when it fits and does nothing when it does
     not; `max-height` is the band itself, which is what pins the foot and grows the box
     toward the head in the case where it does not. No branch, no per-orientation variant. */
  top: var(--menu-ceil);
  bottom: calc(100cqh - var(--menu-floor));
  height: fit-content; margin-block: auto;
  max-height: calc(var(--menu-floor) - var(--menu-ceil));
  /* ONE WIDTH, and it is the widest of the four the shell used to carry. A wider panel is a
     SHORTER panel, and height is the scarce axis now that nothing may reach the hand — the
     900 the draw was authored at buys the bid about 80 --px of height back at 16:9. The sheet
     bounds it sideways at every frame; sit-or-play tightens `--menu-side` to the formation's
     own bound below, because it is the one that may not land on a seat. */
  --menu-w: calc(900 * var(--px));
  --menu-side: calc(15 * var(--px));
  width: min(var(--menu-w), calc(100cqw - 2 * var(--menu-side)));
  padding: calc(16 * var(--px)) calc(20 * var(--px)) calc(18 * var(--px));
  text-align: center;
  display: flex; flex-direction: column; gap: calc(14 * var(--px));

  /* ---------- THE TREATMENT. One class, four properties, no per-kind override ---------
     THE COLOUR IS DECLARED UNDER THE GRADIENT as well as in it, and that is the same reading
     `.table` and `.feed` already declare for: `layout-check.mjs` composites background
     COLOURS up the ancestor chain to find the ground a focus ring is measured against, and a
     box carrying only an image reports its PARENT's. A gradient-only menu would have every
     control inside it judged against the ice two layers up, which is the wrong answer by
     about 20:1. The gradient still does the shading. */
  background-color: var(--yellow);
  background-image: linear-gradient(180deg, var(--yellow-lit), var(--yellow));
  border: calc(5 * var(--px)) solid var(--yellow-edge);
  border-radius: var(--r-dialog);
  /* The strip, the menu's own lift, and then the 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.

     THAT PROPERTY IS THE WHOLE TRICK AND IT IS NOT DECORATION. If a menu ever needs a real
     overlay element, the table's liveness has to be re-established some other way and said
     out loud; `browser-game.mjs` holds it, with a decision open, by clicking the feed's chat
     input through the dim and sending a line.

     THE STRIP IS ONE COLOUR NOW. It used to name the decision — red for the bid, yellow for
     the trump call, green for sit-or-play — which is precisely the per-kind override rule 1
     forbids, and it was also the one thing that made five decisions look like four screens.
     What names the decision is the HEADING, which is a sentence rather than a hue. */
  box-shadow:
    inset 0 calc(5 * var(--px)) 0 var(--yellow-deep),
    var(--menu-lift), 0 0 0 100vmax var(--scrim);
  overflow-y: auto;

  /* ---------- AND THE GROUND FLIPPED, SO THE TYPE TOKENS FLIP WITH IT ----------
     Rule 4. Every name below is one of the file's on-dark references (see § what the
     long-standing names mean now); each is re-pointed at its on-light twin for the subtree
     this panel owns. That is one place to be wrong instead of thirty rules re-tinted by hand,
     and it is the same technique `--ring` already uses — a control inherits the right value
     from wherever it is STANDING rather than from a list of controls.

     `--ring` itself is ink here for the same reason: the ground is light. */
  color: var(--ink);
  --ring: var(--ink);
  --panel-inset: #ffffffd9;
  --edge-faint: #10141c26;
  --edge: #10141c40;
  --edge-strong: #10141c59;
  --edge-dialog: #10141c33;
  --white: var(--ink);
  --white-soft: var(--ink-2);
  --text-body: var(--ink-2);
  --text-muted: var(--ink-3);
  --text-dim: var(--ink-4);
  --panel-text: var(--ink-2);
  --panel-text-2: var(--ink-3);
  --track: #10141c26;
  /* `--danger-ink` is drawn for a dark ground and is invisible on this one; the punt's own
     red already reads at 4.0:1 over `--yellow-lit`. Three names, one value, because all
     three of them mean "this is the bad outcome" on whichever ground they are said. */
  --danger-ink: var(--red);
  --red-pale: var(--red);
  --red-hot: var(--red);
}
/* 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. That is the
   whole of what this rule says now.

   IT USED TO HANG, and it does not any more (story 13.4). Story 10.3 gave it `3m`'s own
   34 --px drop from the top edge, on the reasoning that "the housing is what tells a player
   that the thing in front of them is the table's own announcement". The housing is gone with
   the jumbotron, and EXPERIENCE.md § Menu is explicit that the placement law binds the menus
   a player opens themselves as well as the ones the server pushes: *"Menus also carry things
   that are not decisions: settings, the rules, the seating menu, and the invite. Anything
   that is a menu obeys the placement law."* So it takes the band like every other kind, and
   its old `top` / `max-height` pair goes rather than being re-tuned — a second placement is
   how a shell drifts apart.

   NOTHING ELSE MOVED. Every control is where the product put it, at the felt, and the seats
   are live on the sheet behind the dim exactly as `3m` draws them. */
.menu[data-kind="seating"] { display: block; }
/* THE FILL-SEATS CONTROL, and the reason it is written next to the create screen's in this
   file's own mind rather than only in this comment: they are two halves of one decision. A
   host says what the table should want on the create screen and is asked the same question
   again here, by the control that honours it — so the label is the same `.uilabel`
   declaration on both, and on both of them YELLOW IS THE CHOSEN ONE.

   That last part was inverted before this story. `renderSeatingMenu` drew every number the
   host had NOT chosen as the primary yellow and the chosen one as the quiet on-dark kind —
   six primaries and one grey — which says the grey one is the odd number out and says
   nothing at all about which is selected. It also spends yellow, which DESIGN.md § Colours
   reserves for a score, an active choice or the primary action, on five numbers that are
   none of those. `3m` draws the opposite: the chosen number is the yellow one. Inverted in
   app.js; the layout is here. */
.menu[data-kind="seating"] .uilabel {
  --label-ink: var(--panel-text-2);
  display: block; font-size: calc(13 * var(--px)); margin-bottom: calc(9 * var(--px));
}
/* One row, every number the same width, filling the panel — `3m`'s own shape. `flex: 1`
   rather than a column count, because the number of buttons is `maxSeats - humans` and
   changes as people arrive. */
.menu[data-kind="seating"] .airow {
  display: flex; gap: calc(10 * var(--px)); width: 100%; margin-bottom: calc(16 * var(--px));
}
.menu[data-kind="seating"] .airow .btn {
  flex: 1 1 0; min-width: 0; margin: 0;
  padding: calc(14 * var(--px)) 0; font-size: calc(28 * var(--px)); font-style: normal;
}
/* START is the primary action on this panel and takes the primary kind — `3m` draws it as
   the widest, loudest thing on the screen, because it is the only thing the host came here
   to press. */
.menu[data-kind="seating"] .start {
  display: block; width: min(100%, calc(340 * var(--px))); margin: 0 auto;
  padding: calc(14 * var(--px)) calc(26 * var(--px)); font-size: calc(30 * var(--px));
}

/* ---------- WHAT STOOD HERE, AND WHY THREE RULES BECAME NONE (story 13.4) ----------
   Three declarations placed the four panels that had one: a shared `top: var(--decide-top)`
   (story 12.5, "every decision arrives in the same place"), a shared sideways cap against
   a sideways cap, and a downward exemption letting the three that dim outgrow the region.
   All three are subsumed by the band on `.menu` above.

   THEY ARE NOT LOST, THEY ARE STRONGER. "Every decision arrives in the same place" was one
   `top` shared by four kinds; it is now one BAND shared by all five and by seating too, and
   `layout-check.mjs` still measures the heads and the midlines across every kind that draws.
   The downward exemption existed because the region's foot was above the panel's natural
   height and its footer would have fallen below a fold — the band's foot is the HAND's, which
   is lower, so the exemption has nothing left to buy. And the sideways cap belongs to the one
   decision that may not land on a seat, which is where it now lives. */
/* ---------- NOTHING STANDS ON THE ICE (story 12.24) ----------

   THE TABLE'S JUMBOTRON HANGS FROM NOTHING NOW. The owner, on an iPhone: "there are white
   vertical bars when the bid screen shows, it doesn't look right." Measured at 390x844 with
   a real bid open, the two bars were `#menuRods > i`: 2.66 x 235 px each, at x 160.3 and
   227.0 on a 390-wide sheet — 66.7px apart, symmetric about its midline — `var(--rail)`
   #e9ece6 at 0.45, z-index 26, absolutely positioned in `#felt` from `top: 0` down to
   `--decide-top`. They were the suspension rods, and NOTHING on the rink was wrong.

   WHY A DIM IS WHAT EXPOSED THEM, WHICH IS THE WHOLE OF THE DEFECT. The scrim is the panel's
   own outer box-shadow at z-index 25, so it darkens everything on the sheet BELOW that layer
   and nothing above it. The rods were the one decorative thing above it. Story 12.5 chose
   0.45 so "a seat name reads THROUGH it" — a weight measured over BRIGHT ice, which is the
   only ground the rods have when nothing is dimming. Under the scrim the ice drops away and
   a near-white rail at 0.45 does not, so the same two marks that were a faint cable on an
   undimmed sheet become the brightest thing on a dimmed one. In the tall frame they also
   cross furniture that is not a roof at all: the head of a portrait sheet carries the bar
   (story 12.20), the feed and the chat box, so the cable was drawn over the wordmark and
   over the chat input. The wide frame has the same bars, shorter — 4.91 x 267.5 px at
   1280x720 — so this was never a portrait-only defect and it is not fixed by a portrait rule.

   THE THREE WAYS OUT THAT ARE NOT AVAILABLE. Dropping them under the scrim is hiding the
   offender behind the dim; taking the opacity down again is painting it out; and the scrim
   itself is correct behaviour and is not this story's to change. So the drop goes. The panel
   keeps its lit strip and its own lift, which are what say it is an object standing over the
   sheet, and the sheet keeps every marking on it — the rink was never the offender and is
   not touched here.

   AND STORY 13.3 TOOK THE MARKUP TOO. 12.24 left `#menuRods` in the document with a
   `display: none` beside it, "so that nothing else has to learn it is gone" — which is the
   right call for a fix and the wrong one for an abandonment. FR-26 as amended is explicit:
   *"the suspension lines and the jumbotron shell are deleted rather than hidden."* So the
   element is out of `index.html`, this declaration is out of this file, and the reader that
   asked every decision whether it had grown rods is out of `layout-check.mjs` — a check that
   can only ever be answered "no" by a class nobody can write is a check measuring nothing.

   The settings screen's own `3l` housing is a different element on a different surface and
   keeps its rods — see `#settings > .rods`, which declares its own display, geometry and
   stacking and never read anything here.

   AND IT CANNOT COME BACK QUIETLY, which is what makes deleting the reader safe.
   `layout-check.mjs`'s `dimStackComplaints` reads every element inside the table under each
   dimming decision and refuses anything painting at or above the scrim that is not the panel
   or the chrome a player must still be able to press. That is a claim about the STACK rather
   than about a colour, so it cannot be satisfied by fading whatever gets raised next — and a
   rod put back would be caught by it whatever it was called. */

/* ---------- sit or play: the sheet is not dimmed, so the quadrant is RESERVED ----------
   handoff § 8 trap 3, and the one screen it is written about. The player is deciding
   whether to be in the hand and the hand is what they are looking at, so there is no dim
   here and no dim is coming. Which makes the panel opaque over a readable rink, and an
   opaque panel landing on a seat card hides the information the screen exists to show.

   THE ANSWER IS A RESERVED REGION, NOT A NUDGE. A panel that moves to dodge a seat moves
   whenever the seat does, and after story 10.2b seats travel at the start of every hand and
   story 10.7 gave every seat count its own composition — so dodging is a per-frame
   negotiation with a moving target that will be wrong at some count nobody screenshotted.
   A region is a fixed fact `layout-check.mjs` can measure every composition against.

   WHERE THE REGION IS, AND WHY IT IS FREE. `table.js` holds the sit-out as UNSETTLED, so
   while this decision is open every seat is still at the boards — the travel to the pile
   fires when sit-or-play RESOLVES. The boards ring is an arc from your own corner, up the
   right rail, across the top and down the left; the bottom left is the feed's and the bottom
   centre is the hand's. That leaves the middle of the ice, which at this moment carries
   nothing at all: no trick has been played into the pile, because the round has not started.

   THE FOUR BOUNDS ARE MEASURED, NOT CHOSEN, against every composition at 1:1, 4:3 and 16:9.
   Sideways: the ring's two arms are the constraint, and the widest band that clears both at
   every count is 720 design units centred on the table's own midline — which is the width
   this dialog already had. Below: 176 off the bottom clears the hand's lane, whose top is a
   fixed `--px` offset from the same edge. Above: the top of the arc is pinned in `cqh`, so a
   bound written in `--px` would be right at one aspect and wrong at the other two — the top
   of the region tracks the SAME `13.34cqh` the deepest top-row station is pinned to, and
   clears its full height with about 15 units to spare.

   AND THE PANEL IS LAID OUT FOR THE REGION. At 16:9 the region is ~271 units deep, and the
   stacked panel the dimmed screens use is 404. A dialog that scrolled would put its FOOTER
   below the fold — the one sentence naming what happens if you say nothing — so the
   undimmed screen turns its body sideways instead: the two choices beside the three things
   you are choosing on, which is the same information in a box the reserved region can hold.
   Nothing is dropped, and every rule the shell carries is still carried.

   THE BOUNDS ARE `--decide-top` AND `--decide-bottom`, AND SINCE STORY 13.4 THEY ARE ONE OF
   TWO PAIRS RATHER THAN THE PAIR. The band is the tighter of the formation's region and the
   menu's own free band, on each edge, because both claims are true at once: a panel here may
   not stand on a seat AND may not reach the hand. `max()` on each offset and a `min`/`max`
   pair on the cap is that sentence with no branch in it — and it is why the third claimant in
   EXPERIENCE.md's ranking (*the seats, on sit-or-play only*) needs no rule of its own
   anywhere else in this file.

   Both offsets stay DECLARED rather than folded into the cap, because they are what lets
   `layout-check.mjs` read the region off the RUNNING client instead of restating it. */
.menu[data-kind="sit-or-play"] {
  top: max(var(--menu-ceil), var(--decide-top));
  bottom: max(calc(100cqh - var(--menu-floor)), var(--decide-bottom));
  max-height: calc(min(var(--menu-floor), 100cqh - var(--decide-bottom))
                   - max(var(--menu-ceil), var(--decide-top)));
  /* CENTRED ON THE SHEET, like every other menu (owner's report, epic 14).
     WHAT STOOD HERE: it centred in the REGION rather than on the sheet — wide, the bench is at
     the head so the region IS the sheet sideways and this degenerated to ordinary centring;
     upright, the bench is down the left rail, so the panel centred in the ice LEFT OVER beside
     it. Measured: 95..379 on a 390 sheet, a centre of 237 against the sheet's 195. That is 42px
     right of centre, and the owner read it as exactly what it was — not centred.
     The bench may sit behind it. The rule that binds is that nothing covers the HAND, and the
     placement law above still holds that; dodging the SEATS was never the requirement, and
     buying it with an off-centre panel is a worse trade than the one it was avoiding. */
  left: 50%; right: auto; transform: translateX(-50%);
  width: min(var(--menu-w), calc(100cqw - 2 * var(--menu-side))); margin-inline: 0;
  gap: calc(12 * var(--px));
  /* No scrim: the rink stays readable. The lift stays — it is an object standing on the
     ice, and on an undimmed sheet the shadow is the only thing separating it from one. The
     strip is the same strip, in the same colour, because it is one of the four properties
     "uniform" is defined over and a screen that dropped it would not be a menu. */
  box-shadow: inset 0 calc(5 * var(--px)) 0 var(--yellow-deep), var(--menu-lift);
}
.menu[data-kind="sit-or-play"] .mh { font-size: calc(26 * var(--px)); }
.menu[data-kind="sit-or-play"] .mbody {
  flex-direction: row; align-items: stretch; gap: calc(12 * var(--px));
}
.menu[data-kind="sit-or-play"] .tiles { flex: 1.2; }
.menu[data-kind="sit-or-play"] .chips { flex: 1; align-items: center; }
/* ---- AND UPRIGHT IT STACKS (owner's report, epic 14) ----
   The row above is a WIDE sheet's shape. Upright it put the explanation, both choices and
   three chips into one 390px row — six columns of about fifty pixels each — and the sentence
   "Sit out and your score does not move…" came out one word per line, as a vertical strip of
   single words. The owner's screenshot is that.
   A phone reads down, so upright it reads down: the explanation, then the two choices, then
   the chips, each getting the full width. The tiles and chips stay rows inside it — two
   buttons and three chips fit a phone across, which is the whole reason they are grouped. */
:root[data-compose="portrait"] .menu[data-kind="sit-or-play"] .mbody { flex-direction: column; }
:root[data-compose="portrait"] .menu[data-kind="sit-or-play"] .tiles,
:root[data-compose="portrait"] .menu[data-kind="sit-or-play"] .chips { flex: 0 0 auto; width: 100%; }
:root[data-compose="portrait"] .menu[data-kind="sit-or-play"] .tiles > .choice { flex: 1; }
.menu[data-kind="sit-or-play"] .chip { padding: calc(6 * var(--px)) calc(10 * var(--px)); }
/* Shorter than the dimmed screens' choices, and still over the 44px floor at every aspect —
   90 design units is 63 real px at the narrowest window this app admits. */
.menu[data-kind="sit-or-play"] .choice { min-height: max(96px, calc(90 * var(--px))); padding: calc(10 * var(--px)); }
.menu[data-kind="sit-or-play"] .choice .tn { font-size: calc(26 * var(--px)); }

/* ---------- the draw, which is a menu now (story 13.6, closing DQ-draw-menu) ----------
   WHAT STOOD HERE: "the draw screen steps the shell back behind the hand: no panel, no
   housing, no rods, no lit strip, and the handoff's own FLAT ICE WASH over the sheet
   (§ 6, 3g)... a fitting dropping from the roof would put the shell in front of the very
   cards the decision is about."

   THE PREMISE WENT WITH THE ROOF. Nothing drops from anywhere now, and no menu may cover the
   hand at any frame — so the draw's panel cannot land on the cards it is asking about, which
   is the whole of what the exception was protecting. It takes the shell, and "one shell for
   every decision" stops needing a footnote. Measured before the change was made, so it is a
   fit and not a hope: the draw's own content is 440..475 --px tall across the seven admitted
   frames against a free band of 522.7 at its tightest (16:9), so it centres at every one.

   WHAT SURVIVES OF THE EXCEPTION IS THE SCRIM, and only that. This is the one decision that
   is ABOUT the hand, so dimming the hand it is about would undo the half of the old reasoning
   that was right; it keeps the handoff's flat ice wash where the other three take the modal
   dim. A scrim is not one of the four properties uniform is defined over — the ground, the
   border, the strip and the radius all come back byte-identical with the rest, which is what
   `menuUniformComplaints` compares — and the difference is DECLARED here rather than left to
   be inherited by accident. */
.menu[data-kind="draw-phase"] {
  box-shadow:
    inset 0 calc(5 * var(--px)) 0 var(--yellow-deep),
    var(--menu-lift), 0 0 0 100vmax var(--scrim-light);
}
.menu[data-kind="draw-phase"] .mhead { flex-direction: column; align-items: center; text-align: center; gap: calc(8 * var(--px)); }
.menu[data-kind="draw-phase"] .mh { font-size: calc(44 * var(--px)); }
.menu[data-kind="draw-phase"] .mmeta {
  font-family: var(--body); font-weight: 600; font-size: calc(16 * var(--px)); color: var(--ink-2);
}

/* 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;
}
.menu .mh { font-size: calc(38 * var(--px)); line-height: 1; letter-spacing: -0.03em; -webkit-text-stroke: calc(2 * var(--px)) var(--ink); }
.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. */
.menu .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(12 * var(--px)); margin-bottom: calc(12 * var(--px));
  /* WRAPS, because the plate is a square and the field wants a measure. At the narrow end of
     the range the two stack — the plate above, the link under it — and neither is squeezed to
     make one row fit. `flex-wrap` and nothing else: no breakpoint, no per-composition variant,
     and the menu's own `max-height` still bounds the whole (story 13.4). */
  flex-wrap: wrap;
}
/* The link and its button, which used to BE this row. They keep their own line so the QR can
   stand beside them without being laid out as a third control on a row built for two. */
/* ---------- THE LINK GETS A MEASURE, AND THE BUTTON KEEPS ITS WORDS ----------

   The basis was `calc(260 * var(--px))`, which is a share of the sheet — 78px on a 390-wide
   phone. A basis that small never asks `.invrow` to wrap, so the line sat beside the QR plate
   and was squeezed into whatever was left: measured at 390x844, the field got 121px for a URL
   needing 364, and the Copy button got 44px for words needing 55 and spilled them out of its
   own box. The owner saw both: "Copy link button text overflow", "URL hard to read also".

   A REAL-PIXEL BASIS IS WHAT MAKES THE WRAP HAPPEN. 260px is a measure rather than a share, so
   on a narrow sheet the plate and the line cannot both fit on one row and the line takes the
   next one whole — which is exactly what `.invrow`'s `flex-wrap` was already there to do and
   was never being asked for. */
.invline {
  display: flex; align-items: center; justify-content: center;
  gap: calc(8 * var(--px)); flex: 1 1 max(260px, calc(260 * var(--px))); min-width: 0;
}
/* AND WHERE THE ROW IS STILL SHORT, THE BUTTON GOES UNDER THE LINK rather than beside it. With
   the wrap above the line gets its own row, but the Copy button was still taking 93px of it and
   the field showed two thirds of a URL. Stacked, the field has the whole measure — 343px of the
   366 a localhost link needs, and more than the 326 a `https://snarps.com/#/invite/XXXXXX` one
   does, which is the link a player is ever actually handed. 420px is where the two fit side by
   side without squeezing either, so that is where the stack stops. */
@media (max-width: 420px) {
  .invline { flex-direction: column; align-items: stretch; }
  .invrow .btn { align-self: center; }
}

/* ---------- THE QR PLATE (AD-16, story 13.11) ----------
   ITS OWN LIGHT GROUND, AND THAT IS NOT A STYLE CHOICE. A QR needs a light quiet zone and a
   real contrast between module and ground to be read at all, and since story 13.6 the menu it
   is shown from is YELLOW — DESIGN.md § QR invite rules out drawing it on that ground in so
   many words. So the plate is white, the encoder draws its own four-module quiet zone inside
   the SVG, and the yellow stops at the plate's edge.

   SIZED FOR A SECOND PHONE AT ARM'S LENGTH, in the panel's own unit with a real-pixel floor
   under it. `--px` scales with the window, and a QR that scaled all the way down would be
   unreadable on the smallest frame the menu is drawn at — which is the frame where a host is
   most likely to be holding the phone up for somebody. 132 REAL px is the floor: at 21 modules
   plus 8 of quiet zone that is 4.5px a module, comfortably above the ~2px most phone cameras
   need at 30cm. The cap keeps it from taking the whole panel on a desktop, and it is 150 rather
   than the 168 first written because HEIGHT IS THE SCARCE AXIS HERE: measured on the running
   client, the plate costs the seating panel 98 to 136 --px, and at 1600x900 the panel's foot
   ended 8.9px above the protected box with the larger cap. A QR on a 1600px-wide monitor is
   being scanned from closer and in better light than one on a phone, so the cap is the cheap
   end of that trade.

   AND THE FLOOR IS 124 RATHER THAN THE 132 FIRST WRITTEN, for a reason that was MEASURED rather
   than felt. At 1280x720 the seating panel with this plate on it came to 470 --px against a free
   band of 470; add the one thing the server can put on that panel that is not always there — the
   seating note, when a CPU fill is refused — and it was 489 against 485. `.menu` carries
   `overflow-y: auto`, so the never-cover rule still held and the panel SCROLLED instead, which
   the contract forbids in the same breath ("they never overlap, and they never scroll to fit").
   Four pixels. The plate is the newest and least essential thing on that panel — the link beside
   it is the mechanism and this is the convenience — so the plate is what gives way, along with
   this row's own bottom margin. At 124px a 29-module code plus its 8 modules of quiet zone is
   3.35 real px a module, which is still over the floor `browser-invite.mjs` holds it to. */
.invqr {
  flex: 0 0 auto;
  display: flex; flex-direction: column; align-items: center; gap: calc(3 * var(--px));
}
.invqrart {
  width: clamp(124px, calc(132 * var(--px)), 150px); aspect-ratio: 1;
  background: #ffffff; border-radius: calc(4 * var(--px));
  /* The SVG fills the plate exactly. The white in `createSvgTag`'s own `<rect>` and the white
     here are the same white on purpose: the quiet zone has to read as one continuous field
     with the plate around it, or the code's edge becomes a mark of its own. */
  padding: 0; overflow: hidden;
}
.invqrart svg { display: block; width: 100%; height: 100%; }
/* The words beside the mark, and the token is `--ink` rather than the panel-text pair. This
   caption stands on the MENU and the menu is yellow since story 13.6 — DESIGN.md § Menu names
   picking the panel-text token on a yellow ground as the single most likely defect in that
   change, and this is a new caption arriving after it. Measured, not assumed: the number is in
   the story's implementation notes. */
.invqrcap {
  font-family: var(--mono); font-weight: 600; font-size: calc(11 * var(--px)); line-height: 1.1;
  letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink);
}
.invrow .invlink {
  flex: 1 1 auto; min-width: 0;
  /* 16px FLOOR, same as every other text field (story 12.18's iOS fix). This one was
     MISSED by that sweep — it is built in JS rather than declared in `index.html`, so the
     pass that floored the five declared fields never saw it, and it computed to ~6px at the
     smallest window. iOS zooms the page into any focused field under 16px, which is what
     defeated story 12.20's keyboard fix; this is the field a host taps on a phone. */
  font-family: var(--mono); font-weight: 500; font-size: max(16px, 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;
  /* NEVER THE THING THAT GIVES WAY. `nowrap` alone kept the words on one line and let flex
     shrink the box out from under them; the field beside it is what has slack, and it already
     carries `min-width: 0` and an ellipsis for exactly that. */
  flex: 0 0 auto;
  font-size: max(13px, 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. */
/* ---------- A CONTROL IS SIZED FOR A THUMB (owner, epic 14) ----------

   "the UI has to be big these guys have large thumbs", and the reference the owner gave for
   how big: `legacy-screenshot-1.jpg`, where the four trump buttons are 250x210 on a 2048x1536
   sheet — 12.2% of its width and 13.7% of its height EACH. Measured here before this on a
   390x844 phone, a decision button drew 52x55: the same share of the width and well under half
   the height.

   So the padding grows rather than the width: these buttons already reach across the sheet
   correctly and it is the BOX UNDER THE THUMB that was thin. Type grows with it, because a
   bigger button carrying the same letters reads as a bigger gap rather than as a bigger
   control — which is the half of the legacy screen that is easy to miss.

   THE 44px FLOOR IS NOT WHAT THIS IS. Every control already cleared 44 on every pre-game
   screen when it was audited; a floor is what stops a control being unusable, and the owner is
   asking for the opposite end — a control that is obviously, deliberately large. */
.btn {
  display: inline-block;
  background: linear-gradient(180deg, var(--yellow-lit), var(--yellow-deep));
  border: calc(2 * var(--px)) solid var(--yellow-edge); border-radius: var(--r-button);
  padding: calc(18 * var(--px)) calc(30 * var(--px));
  /* Same argument as `.bidtile`: on a phone the --px term alone drew these at the 44px floor,
     which is the size that stops a control being unusable rather than the size the owner asked
     for. The type carries a real floor too — a big button with small letters reads as a gap. */
  min-height: max(56px, calc(64 * var(--px)));
  font-family: var(--display); font-weight: 900; font-style: italic;
  font-size: max(17px, calc(32 * 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 ON-YELLOW KIND (story 13.6, closing DQ-yellow-on-yellow) ----------
   "A yellow menu wants a primary button, and the primary button is yellow." DESIGN.md named
   three routes and this is the first of them: the button system gains a kind for the one
   ground its primary cannot stand on.

   IT IS THE PRIMARY INVERTED AND NOT A NEW BUTTON. Same box, same radius, same hard-offset-
   plus-ambient geometry, same press behaviour — what swaps is which of the two colours is the
   fill and which is the lettering. `--yellow` on `--ink` measures 12.4:1; the yellow primary
   it replaces measures 15.4:1 on ice and 1.1:1 on this panel, which is the whole reason this
   exists. The hard offset keeps `--yellow-edge`, so the object still reads as the primary
   standing on the panel rather than as a hole cut in it.

   WHY THESE FOUR SELECTORS AND NOT `--yellow` RE-DECLARED ON `.menu`. A custom property is
   substituted where it is USED, and `.menu`'s own background uses `--yellow-lit`/`--yellow`
   — so re-pointing them on the panel would repaint the panel with the button's colours. The
   type tokens above can flip that way because the panel reads none of them; the yellows
   cannot, and the difference is worth stating because it is the trap `--focus-ring` already
   recorded once in this file.

   AND THE SPECIFICITY IS DELIBERATE. `.bidtile` beats `.btn` by ORDER at one class each, so a
   two-class `.menu .btn` would beat it and turn every bid number into a primary. `.menu
   .bidtile` restores the well at matching specificity, later in the file, which is the same
   bargain the base pair already strikes. */
.menu .btn {
  background: linear-gradient(180deg, var(--ink-2), var(--ink));
  border-color: var(--ink); color: var(--yellow);
  box-shadow: 0 calc(6 * var(--px)) 0 var(--yellow-edge), 0 calc(14 * var(--px)) calc(24 * var(--px)) var(--seat-ambient);
}
/* The secondary keeps being the quiet one: a light plate on the yellow, ink lettering, and
   the plain hard offset. Keeping your hand, sitting out, taking the smaller loss — first-class
   choices, so they are a plate rather than absent. */
.menu .btn.g {
  background: var(--panel-inset); border-color: var(--ink); color: var(--ink);
  box-shadow: 0 calc(5 * var(--px)) 0 var(--seat-hard);
}
.menu .btn.choice .tw { color: var(--yellow-lit); }
.menu .btn.g.choice .tw { color: var(--ink-3); }
/* The row of numbers: a well on the panel, and the one that takes the bid wearing the kind
   above. `.tq` follows its tile's ground — the qualifier under a promoted number is on ink. */
.menu .bidtile {
  background: var(--panel-inset); border-color: var(--edge); color: var(--ink);
  box-shadow: none;
}
.menu .bidtile.pick {
  background: linear-gradient(180deg, var(--ink-2), var(--ink));
  border-color: var(--ink); color: var(--yellow);
  box-shadow: 0 calc(6 * var(--px)) 0 var(--yellow-edge), 0 calc(14 * var(--px)) calc(24 * var(--px)) var(--seat-ambient);
}
.menu .bidtile.pick .tq { color: var(--yellow-lit); }
/* ---------- and the four places yellow was the TYPE ----------
   Each of these was yellow lettering on a dark panel and is now yellow on yellow, which is
   the same defect as the primary and is fixed the same way: the ink the panel's own copy is
   set in. The countdown's FILL is the interesting one — it is the thing a player watches
   drain, so it takes the strongest mark the panel has rather than a tint of its ground. */
.menu .mbar i { background: var(--ink); }
/* AND THE HEADING, which is the one that nearly shipped. `.disp` is the handoff's display kind
   — YELLOW fill with an ink stroke — and it is drawn that way because display type on this
   product stands on ice or on a dark panel. On a yellow menu the fill is the ground: measured,
   `--yellow` on `--yellow-lit` is 1.09:1, and every word of "YOUR BID" was being held together
   by a 2 --px stroke. The ground flipped, so the fill flips with it and the stroke stays, which
   is the same translation the rest of this block makes. */
.menu .disp { color: var(--ink); }
.menu .strip .sk.gold { color: var(--ink); }
.menu .strip .sv b { color: var(--ink); }
.menu .chip .v.big { color: var(--ink); }
/* The draw screen's "trump" tab stands ABOVE its card, on the panel rather than on the card,
   so it takes the on-yellow kind's colours too. */
.menu .ttab { background: var(--ink); color: var(--yellow); }
/* 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. The name is `.bidtile` and not the obvious short one because
   the felt's suit-pattern layer once held that, `position: absolute; inset: 0`, and
   swallowed the whole dialog. The layer is retired with the felt; the name stays earned. */
/* THE FLOOR IS IN REAL PIXELS, because `--px` is a share of the sheet and a phone's share is
   small: 104 --px drew a 55px tile on a 390x844 screen. The legacy game's trump buttons are
   13.7% of their sheet's height — 116px at this size — and the owner's direction is that scale
   ("these guys have large thumbs"). 96px is most of the way there and is what the row of six
   bids fits at. The --px term stays and wins on a big screen, where it is already larger. */
.bidtile {
  flex: 1; min-height: max(96px, 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-lit), 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 var(--ink);
  border-radius: var(--r-panel); box-shadow: 0 calc(6 * var(--px)) 0 var(--seat-hard);
  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 var(--seat-hard), 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.

   THIS IS THE ONLY `.strip` NOW. Story 10.2 gave the seat card's brand bar the same name and
   inherited the border, the radius and the padding below onto an element that declared a
   17 --px height and nothing else; the bar rendered at 28. That element is `.brandbar`
   (§ THE SEAT CARD) and this rule was left exactly as it was, because it was never the one
   that was wrong. Anything added here still lands on every `.strip` in the document, which
   is what a bare class name costs in a 3,400-line stylesheet with no build step. */
.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; }

/* ---------- WHAT THE GAME SAYS WHILE IT IS ASKING (story 13.12, AD-14) ----------
   The served sentence about the decision in front of you, at the head of the panel that is
   asking, above the controls — read the rule, then act on it.

   IT IS NOT A `.strip` AND THAT IS THE DECISION WORTH RECORDING. The strip beneath it states
   the consequence in THIS PLAYER'S OWN ARITHMETIC — "Bid 3, win 3 … you're down to 13 from 16"
   — built in table.js out of the frame. This states the RULE, in the game's own constants,
   served. The two are complementary and a first-timer needs both, so they are told apart by
   weight: the strip keeps the display face and the red key, this is body copy with a rule down
   its left edge, and neither can be mistaken for the other at a glance.

   ON YELLOW, WHICH IS WHERE EVERY MENU STANDS SINCE STORY 13.6. The ink is `--text-body`,
   which `.menu` has already re-pointed at its on-light twin for this whole subtree — the same
   route every other control in a panel gets its ink by, rather than a colour chosen here for a
   ground that could change again.

   NO HEIGHT WORRIES HERE, and that is measured rather than assumed: the phase panel clears its
   own band by 429 --px at the smallest window this client draws (339x602) and by 601 at
   390x844, against about 50 --px for this row. The corridor's copy of the same line has 27.66
   and is clamped to one line for it; see `.turnban .x`. */
.mteach {
  text-align: left;
  font-family: var(--body); font-weight: 500; font-size: calc(14 * var(--px));
  line-height: 1.35; color: var(--text-body);
  background: var(--panel-inset);
  border: calc(2 * var(--px)) solid var(--edge-faint);
  border-left: calc(5 * var(--px)) solid var(--yellow-deep);
  border-radius: var(--r-button);
  padding: calc(9 * var(--px)) calc(14 * var(--px));
}

/* 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.

   THE BAR IS THE HANDOFF'S TRACK-AND-FILL (§ 4 Chrome, the jumbotron's timer) and the
   seconds beside it are the server's: `table.js` counts to an absolute instant the server
   stamped, corrected for this machine's skew, and every readout on screen is a sink on that
   one clock. Nothing here starts a timer, and nothing here decides when a decision expires
   — which is why the width below is only ever painted, never animated to zero. */
.mfoot { display: flex; flex-direction: column; gap: calc(6 * var(--px)); }
.mbar {
  height: calc(8 * var(--px)); border-radius: calc(4 * var(--px));
  background: var(--track); 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;
}
/* The divider stands on the draw screen's light wash, so a white hairline is invisible
   there where it was the right mark on a dark scrim. */
.ddiv { flex: 0 0 auto; width: calc(2 * var(--px)); height: calc(178 * var(--px)); background: #10141c33; 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 var(--ink);
  /* The same back the deck draws — see `.deck i` for why the art changed. */
  background: var(--card-back-blue) url(assets/art/card-back.webp?v=2) center / contain no-repeat;
  box-shadow: 0 calc(8 * var(--px)) calc(18 * var(--px)) var(--seat-ambient);
}
.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 — and
   the handoff draws it (§ 6, 3i) as WHITE ICE with a spade watermark, not as the dark
   green wash the felt direction lit up. The wash it takes is § 2's own flat one, so the
   sheet is still visible under it and the screen reads as the rink at its brightest. */
.ov[data-kind="spadeout"] { background: var(--dim-wash); --ring: var(--ink); }
/* THE WATERMARK, § 6 3i: one ♠ at 520 design units in ink at 8% behind everything the
   screen says. Its own element rather than a `::before`, for two reasons that both come
   from this epic's own findings — a generated-content glyph is announced by some engines
   inside an `alertdialog` whose whole name is supposed to be its heading, and a rule
   nothing can query is a rule no check can catch the loss of. It carries a testid and
   `aria-hidden`, so `layout-check.mjs` reads the mark itself rather than the container it
   sits in, which is the empty-query defect story 10.8 already paid for once.
   `z-index: 0` under `.ovin`'s 1: the copy has to sit ON the mark, and a watermark that
   landed over the heading would be the same 520-unit glyph passing every assertion. */
.spmark {
  position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
  z-index: 0; pointer-events: none; user-select: none;
  font-size: calc(520 * var(--px)); line-height: 1; color: #10141c14;
}
.ovin { position: relative; z-index: 1; 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(--ink);
}
/* INK, AND NO STROKE — the handoff sets 3i's "Nobody sits" in `#10141c` straight onto the
   white ice, and the sums say why. The yellow that stood here is 1.2:1 on this ground and
   read only through its own ink stroke, which is the landing wordmark's bargain taken on a
   screen that does not need it: ink on the Spade Out's ice measures 16:1 unaided. It is
   also the semantic answer — DESIGN.md § Colours keeps yellow for a score, an active choice
   or the primary action, and a heading that reports what just happened is none of the
   three. The punt's title above keeps its stroke because ITS ground is dark. */
.ov[data-kind="spadeout"] .ovh {
  font-size: calc(112 * var(--px)); line-height: 0.9; letter-spacing: -0.045em;
  color: var(--ink); -webkit-text-stroke: 0;
}
/* MONO, § 3: the eyebrow over each moment's title is a line of meta — who called it, what
   the game just did — and § 3 keeps anything meta in JetBrains Mono. It was display 800,
   which is the UI-label register and is what names a group of controls. */
.ovsub {
  font-family: var(--mono); font-weight: 500; font-size: calc(14 * var(--px));
  letter-spacing: 0.2em; text-transform: uppercase; color: var(--punt-text);
}
/* On white ice, a panel text is no text at all. */
.ov[data-kind="spadeout"] .ovsub { color: var(--ink-3); }

/* The score change, in the direction that matters — this game counts DOWN.
   § 6 3h's own card: a dark panel at a 3-unit `--red` edge. THE EDGE IS THE FULL RED, not
   the pale `--danger-ink` at 35% it wore. DESIGN.md § Colours gives `--red` three jobs —
   the centre line, a punt, and danger — and a punt is the one this box exists for, so
   drawing it in the washed-out relative was the palette's loudest hue declining to do the
   one job it was reserved for. */
.scores {
  display: flex; align-items: center; gap: calc(20 * var(--px));
  background: var(--punt-panel); border: calc(3 * var(--px)) solid var(--red);
  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)) var(--ink); paint-order: stroke fill;
}
/* THE ARROW. It is the move in one mark — the score going one way or the other — and it is
   printed rather than washed, in `--red` or in `--green-live`, keyed off the direction the
   figures either side of it actually travelled. On the panel's own `#10141c` the red measures
   3.7:1 and the green 9.1:1, both clearing the large-text floor at 38 design units of 900
   italic, and this is the one place on this screen either is printed at full strength.

   ---------- AND WHICH WAY IT POINTS IS THE STORY (story 13.10) ----------
   It was `→` in `--red`, unconditionally, on the one takeover that can carry either move: a
   Spade Out is points coming OFF and falls through to this same strip whenever the served hand
   size has not landed, so the screen that announced the best hand in the game drew it in the
   punt's colour with the punt's arrow. Now `data-move` on the strip says which happened —
   written from `scoreAfter - scoreBefore`, two served numbers and no rule — and hue, arrow and
   arithmetic all follow it.

   `↑` FOR ON AND `↓` FOR OFF, matching the FIGURES and not the feeling. Snarps counts down, so
   points going on is the number going UP, and an arrow that matched the sentiment instead
   would be the one mark on the screen disagreeing with the two numbers beside it. That
   inversion is the whole difficulty this grammar exists for, and it is story 13.13's to
   present; this is only the mark telling the truth about the arithmetic. */
.scores .arrow { font-family: var(--display); font-weight: 900; font-style: italic; font-size: calc(38 * var(--px)); color: var(--red); }
.scores[data-move="off"] { border-color: var(--green-live); }
.scores[data-move="off"] .arrow { color: var(--green-live); }
.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[data-move="off"] .pts { color: var(--green-live); }
.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 var(--ink);
  border-radius: var(--r-card); box-shadow: 0 calc(8 * var(--px)) calc(18 * var(--px)) var(--seat-ambient);
  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 skip and the auto-advance. THE CHARACTER SLOT THAT USED TO STAND BESIDE THEM IS GONE
   and the takeover is not waiting for a replacement. handoff § 9 commissions four sized
   slots; two were drawn — the landing hero and the seat portrait — and the jeering and
   celebrating figures these screens want were not. What stood here was the seat portrait's
   mascot borrowed for a screen it was never drawn for, and it carried the retired name on
   its helmet band at 110 --px, which is the largest either word was ever printed in this
   client. An empty slot beside the button is better than the wrong art in it, and the
   punt and Spade Out screens say everything they say in type either way.

   The defect the old rule guarded is worth keeping in writing, because the next art to
   arrive can repeat it: `.ovslot img` had to state an ABSOLUTE height rather than 100%,
   since a percentage height on a replaced element inside a content-sized grid row resolves
   against the image's INTRINSIC size once it loads — so a 1024px mascot came back at 1024px
   and covered the whole takeover. Every image this client draws is now bounded by the box
   it is in and exported at the size that box asks for; nothing ships at its source size.

   `justify-content` is new with the slot's removal: the footer was a two-item row that
   filled its width, and one item left in a flex row lands at flex-start. Centred, the
   button and its clock stay under the middle of the panel they close. */
.ovfoot { display: flex; align-items: center; justify-content: center; gap: calc(16 * var(--px)); }
.ovact { display: flex; flex-direction: column; align-items: stretch; gap: calc(7 * var(--px)); }
/* `max()` against the real 44px floor, not the design number alone. `--px` is a container
   query unit, so 56 of them is 39px at the 1:1 viewport this app admits — the takeover's
   only control, five pixels under the hit target, on the one screen a player is most likely
   to be reaching for in a hurry. Exactly the fix `.specbar .sbtn` already carries and for
   exactly the reason written there: the 44px floor is a thumb against a screen and does not
   scale with the table. Found the first time any check opened a moment (story 10.5) — the
   overlay had never been measured, so the shortfall had shipped since the takeover did. */
.btn.ovbtn {
  margin: 0; min-height: max(var(--hit-target), 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; }
/* Yellow on white ice is the same 1.2:1 the focus ring had to give up, and this bar is
   the window the screen closes itself in — so on the bright ground it runs in the ink
   the palette keeps for type on ice, and its track darkens to match. */
.ov[data-kind="spadeout"] .ovbar { background: #10141c26; }
.ov[data-kind="spadeout"] .ovbar i { background: var(--ink-3); }
.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(--ink-4); }

/* 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 table, 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 var(--seat-ambient);
}

/* 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 sheet 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; }
}

/* ==========================================================================
   THE TALL FRAME — the portrait composition (story 12.1)

   A PHONE HELD UPRIGHT IS NOT THE LANDSCAPE SHEET SQUEEZED. `--px` above resolves to
   1.0 at the authored 1280x720 and to 0.30 at 390x844, so the shipped composition does
   not shrink gracefully into a tall window — it collapses. What is below is a second
   composition of the same table, authored against a frame of its own: 720 x 1280, the
   same sheet turned. At 390x844 that resolves 0.54 rather than 0.30.

   ONE ATTRIBUTE SELECTS IT, and app.js writes it (`applyComposition`) off the same VISUAL
   viewport the surface sizing and the refusal are read from. It is a composition and not
   an orientation: both frames are measured, the one that resolves the larger unit wins,
   and the refusal fires only when NEITHER can hold a table. So a square window takes
   landscape, a phone upright takes this, and a window dragged between them re-composes —
   `Table.setCompose` redraws the last frame the server sent, so play continues.

   EVERYTHING PORTRAIT CHANGES IS HERE, in one block, deliberately. The alternative — a
   portrait clause beside each of the twenty rules below — is how a composition ends up
   half-applied, and this file has 3,500 lines to hide the other half in. Landscape is
   untouched: every selector here is `:root[data-compose="portrait"] ...`, which cannot
   match while the sheet is wide.

   ONE THING IS DECLARED ELSEWHERE, and it is an exception in the other direction: `.rdice`,
   the rules screen's diagram, states the WIDE unit at its own declaration so that this
   block's `:root` override cannot reach into it. A rule about a box being held out of a
   composition belongs on the box, not in the composition it is held out of.

   The formation itself is table.js's — a faceoff across the dot between the players in the
   hand since story 12.12, built from a formula (a side, an angular span about the drop, an
   index across it) rather than from stations, with the chairs that sat out left on the two
   boards lines it replaced; this is the furniture it is measured against, and two pieces of that furniture
   (the chat box and the deck) moved in story 12.2 to make room for it.
   ========================================================================== */

/* THE UNIT, mirrored. `0.138889cqw` is 1/720th of the table's width and `0.078125cqh` is
   1/1280th of its height, so the smaller of the two is one design unit of the tall frame.

   Declared on `.table` rather than on `:root`, so that every screen that is NOT the sheet
   — the landing, the rules, the settings, the browser — keeps the unit it already had.
   Portrait is additive; nothing measured and shipped is re-pinned to make it pass. */
/* ON `:root`, NOT ON `.table`, and that is the difference between a composition and a patch.
   A `var()` inside a custom property is substituted where the property is DECLARED (see
   `--focus-ring` above for the epic this already cost) — so `--r-card`, the five `--lift`s,
   `--ring-width` and `--focus-offset` all bake in whatever `--px` was on `:root` when THEY
   were declared. Re-declaring the unit further down the tree leaves every one of them
   resolving the wide frame's expression inside a tall table: radii at 56% of themselves and
   a focus ring under a pixel wide, on the one device this story is about.

   Declaring it here means every token derived from it follows, and the container units still
   resolve against `.table` for everything drawn on the sheet, because that is where a
   `cq*` unit is resolved — at the point of USE. Off the sheet they fall back to the window,
   which is the same box the sheet very nearly is.

   ONE BOX HAS TO BE HELD OUT, and it is held out at its own declaration rather than here:
   `.rdice`, the rules screen's diagram, is a 16:9 container of its own drawing the WIDE
   formation whatever shape the window is (app.js `RULES_DIAGRAM_FRAME`). It declares the
   wide unit itself, so this rule cannot reach into it. */
:root[data-compose="portrait"] {
  --px: min(0.138889cqw, 0.078125cqh);
  /* AND THE CAMERA'S ALONG-LEAD (story 12.21). Upright the rink's LENGTH is the vertical, so
     the principal point at `--trick-y` sits 48% of the way down and 52% of the sheet's length
     is below it. `--ft` bounds the ice against the LARGER share, so this is 52 rather than 50 —
     and it is declared here rather than on `.table` because `var()` inside a custom property is
     substituted where that property is DECLARED, and `--ft` is declared on `:root`. */
  --cam-fill: 52;
}
:root[data-compose="portrait"] .table {
  /* CENTRE ICE IS AT CENTRE ICE (story 12.10). This was 40cqh, a shade above landscape's
     41, on the reasoning that a tall frame puts more below the pile than beside it. What
     that reasoning did not account for is that the tall frame turns the RINK: upright, the
     sheet's length is its height, so the centre red line at the regulation 50% of the length
     runs across the sheet at 50cqh — and the faceoff circle, the dot, the drop spot and the
     pile are all anchored here. Ten cqh apart, the circle floated above the line it is
     painted on and its upper edge crossed the first blue line at 37.5%. On a rink centre
     ice, the circle, the dot and the red line are one place, so they are one place here.

     WHAT IT COST AND WHO PAID: the near line's ceilings do not move with the anchor, so
     lowering the drop by 10cqh spends 10cqh of its budget — measured, that took its skate-in
     to zero at 810x1080 with the chat box still in the bottom-left corner. That is why the
     box left that corner first; see "the chat box, at the head end" below for the measurement
     and for the arithmetic that says trimming it in place could not have worked. With the box
     at the head the near line's journey is 83.68 --px there against 31.68 before, and 207.51
     at 390x844 against 184.62 — both lines still skate in, at every count from two to six.

     AND THE OVERRIDE IS GONE (story 12.21), because the wide frame came here rather than
     the other way round. `--trick-y` is the CAMERA'S OWN CENTRE now — the point on the ice
     the sheet is looking at, drawn at the middle of what the window shows — and a camera
     centre does not have a composition. One declaration on `:root`, 50cqh, read by both
     frames; see § the unit for the re-derivation that let the wide sheet give up its 41.

     AND UPRIGHT IT IS 48cqh (story 12.21), which is the layout spending the one axis that has
     slack. `--trick-y` is the camera's principal point, and upright that point is on the rink's
     LENGTH — where there are 100 ft of ice either way against the 57 the window shows — so
     moving it costs no ice at all, where moving it on a wide sheet would run the camera past a
     side board. What it BUYS is the room a real 30 ft circle needs: at 810x1080 the circle is
     169 --px of radius against the 97 it used to be, the faceoff has to stand 268.5 --px off
     the drop for a wing's card to clear the ring, and the near line's ceiling at 50cqh is
     247.84. 48cqh is where the two ceilings meet — near `100cqh - trick-y - 392.16` against far
     `trick-y - 340.16`, equal at 614 --px of a 1280-unit sheet — and both come to 273.4, which
     clears 268.5. Anything nearer 50 starves the near line and anything nearer 46 starves the
     far one; this is the only value at which both lines still skate in.

     THIS IS THE ONE DECLARATION PER FRAME. `.rink i`, `.centre`, the two `--decide-*` bounds and
     table.js's `lineCeil` and card-flight target all read it rather than restate it. */
  --trick-y: 48cqh;

  /* ---------- AND THE PILE'S TWO JOURNEYS SWAP WITH THE SHEET (story 13.9) ----------
     Upright the rink's LENGTH is the vertical, so `--pile-along` is the Y term and
     `--pile-across` the X — the same swap `.rink i` makes, and the same one `--at` / `--across`
     make. Written here rather than in the `@container (aspect-ratio < 1)` block the markings use
     because that block sits ABOVE `.centre`'s own rule in this file and would lose to it on
     source order; the composition blocks are the last thing the sheet reads. Keyed on
     `data-compose` for the same reason `--trick-y` is: the pile is layout, not a marking. */
  /* ---------- THE BAR IS A STRIP NOW (story 12.23) ----------
     THE OWNER, LOOKING AT WHAT STORY 12.20 SHIPPED: "It's way too big. The chat should be at
     the top with minimal anything else. Snarps text somewhere small. Allow tap to expand and
     scroll. Only show like the last 3."

     WHAT WAS WRONG WITH IT IS NOT THAT ANY ONE PIECE WAS TOO BIG. 12.20 was told the bar had to
     carry four things and that each had to be legible, hittable and clear of a mis-tap, and it
     satisfied every one of those honestly — at full price each, stacked: a 16 --px rail, a 44px
     chrome row for the gear and EXIT, a 12 --px air gap, then a 168-unit chat box with its own
     border, padding, heading, two gaps and a 44px input, then a 6 --px close. Measured on the
     running client the bar came to 151.5 / 154.4 / 217.3 real px at 339x602 / 390x844 /
     810x1080 — a quarter of the smallest window the composition admits — and the one thing it
     exists to carry got 19.0 / 15.1 / 45.3 px of that. At the phone that is not one whole feed
     line: the number of lines FULLY visible in the collapsed bar was ZERO. A spec that
     enumerates contents without bounding the whole gets a correct answer that is the wrong
     shape, and the correction is a bound rather than a re-list.

     SO THE STACK BECOMES ONE ROW PLUS THE FEED. The gear, the chat input and EXIT share the
     single 44px row that a hit target already forces to exist, and the last three feed lines
     run under it. Nothing else is left: no heading row, no border, no padding, no second rail,
     no air gap between a cluster and a box. The bar's whole height is

       --bar-rail + --chrome-row + --feed-gap + --feed-3 + --bar-rail

     and every term in it is a thumb, a line of type, or a hairline.

     AND THE MIS-TAP GAP TURNS THROUGH NINETY DEGREES, which is the one derivation this story
     had to redo rather than inherit. 12.20 kept EXIT a thumb clear of the chat input by putting
     a whole row between them, and floored the chat box's height at `2 x --hit-target + 8 --px`
     to guarantee it. THAT FLOOR IS WHAT MAKES A BAR TALL — it is 91.7 of the 151.5 at 339x602 —
     and no strip can carry it. With the two controls on the SAME row the distance between them
     is horizontal, and horizontal is where this sheet has room to spare: the chat input's right
     margin is `--exit-w + --hit-target + 8 --px`, so EXIT's left edge and the input's right edge
     are one whole thumb plus eight units apart at every window, by construction rather than by
     tuning. Two rectangles a thumb apart on EITHER axis are a thumb apart, so `browser-client`
     and `layout-check` now measure the separation on both axes and take the larger — one rule
     that judges 12.20's arrangement and this one alike, so a later re-layout cannot slip
     between two different readings of the same requirement. Re-measured on the running client;
     the numbers are in the story's implementation notes. */

  /* The bar's own margin, top and bottom, and the only rail the strip has. Seven and not
     sixteen: what stands inside it is a 44 REAL pixel thumb whatever the sheet's unit is, so
     the rail is trim rather than structure. Seven and not six because the plate closes with a
     5 --px `--rail` border and the feed's last line has to stop above it. */
  --bar-rail: calc(7 * var(--px));

  /* THE ONE ROW, and it is a `max()` for the reason `--chrome-reach` has always been one: the
     gear, EXIT and the chat input are all `<button>`-grade targets carrying
     `min-height: var(--hit-target)`, 44 REAL pixels, which does not scale with the table. Below
     --px 1 the row is that thumb; above it the sheet's own 44 units win. Declared once and worn
     by all three, so the row IS a row rather than three boxes that happen to line up — which is
     what 12.20's `max(74 --px, 16 --px + --hit-target)` was doing the hard way, from the outside,
     after the fact. */
  --chrome-row: max(var(--hit-target), calc(44 * var(--px)));
  --gear-w: var(--chrome-row);
  /* AND EXIT'S WIDTH IS DECLARED RATHER THAN LEFT TO ITS OWN TEXT, which is load-bearing here
     in a way it was not before: the chat input's right margin is written against it, and a
     keep-out written against a width nobody has stated is a mis-tap gap that moves the day
     somebody re-words a button or ships a different display face. 92 --px holds "EXIT" at
     22 --px with room either side at every unit, and the `max()` keeps the control itself a
     thumb wide when the sheet is small. */
  --exit-w: max(var(--hit-target), calc(92 * var(--px)));
  --chrome-reach: calc(var(--bar-rail) + var(--chrome-row));

  /* THREE LINES, AND THE THREE IS THE OWNER'S OWN NUMBER — "only show like the last 3". The
     collapsed feed is SIZED to exactly three lines and their two leads rather than capped at
     three, so "the last three" is geometry and not a count kept in JavaScript that a later
     change to the feed could stop honouring.

     AND THE TYPE HAS A REAL-PIXEL FLOOR, for the same reason the chat input one row above does.
     14 --px is 6.5 real px at 339x602 and 7.5 at 390x844: three lines nobody can read is the
     same defect as one line nobody can read, reached from the other side. Ten is where lower
     case Barlow stops being a texture on a phone. It is a FLOOR and not a size — at 810x1080
     the sheet's own 14 --px is already 11.7 and the floor does nothing.

     WHAT IT COSTS, so the owner can dial it: without the floor the bar comes to 81.6 / 87.0 /
     111.5 instead of 95.7 / 97.3 / 111.5. Fourteen real pixels of bar at each of the two small
     frames, and nothing at the large one. */
  /* 13px AND NOT 10 (owner's report, epic 14): "you can't really read anything well". The
     floor is what governs on a phone — 14 --px resolves under it at every upright frame — so
     every feed line on every phone was drawn at ten pixels. That is below what this product
     holds any other text to, and the feed is the only narration of what just happened.
     It costs the bar three lines x 1.35 x 3px = ~12px of depth, which is the trade: story
     12.23 made this a strip so it would stop taking a sixth of the screen, and it still is
     one. A strip you cannot read is not cheaper than one you can. */
  --feed-type: max(13px, calc(14 * var(--px)));
  --feed-lead: calc(3 * var(--px));
  --feed-3: calc(3 * 1.35 * var(--feed-type) + 2 * var(--feed-lead));
  --feed-gap: calc(4 * var(--px));

  /* WHERE THE WORDMARK STANDS NOW AND WHAT IT DISPLACES. It comes off the chrome row — that row
     is the gear, the input and EXIT, and there is no third of it left — and takes the head of
     the feed's own band instead, at the left, where the only thing it pushes is text that can be
     indented. `--mark-gutter` is that indent, and it is the wordmark's MEASURED advance plus air
     rather than a guess: read off the running client at all three upright frames, "SNARPS" in
     Sarpanch 800 oblique at 32 --px draws 129.4 --px wide (60.2 / 69.0 / 108.1 real px at
     339x602 / 390x844 / 810x1080 — the same 129.4 units at each, which is what says the reading
     is the face's and not one window's). The gutter is 148, leaving 18.6 --px of air between the
     last letter and the first character of a feed line. `browser-client.mjs` pairs the drawn box
     against the gear and EXIT, so a longer word or a fatter face goes red rather than quietly
     overlapping something. */
  --mark-size: calc(32 * var(--px));
  --mark-gutter: calc(148 * var(--px));

  /* THE BAND THE BAR OCCUPIES, in the vocabulary every other clearance at this end is already
     written in. `--chat-head` is the feed's head, `--chat-cap` its height, `--chat-foot` its
     foot; `table.js` stands every far-line seat `SHEET.chatAir` (12 --px) below the foot, and
     the decision band and the skate-in are written against it too, so all three follow the bar
     down without being told anything. The NAMES are unchanged from story 12.10 deliberately:
     what the box IS has now changed four times and what these three numbers MEAN has not once. */
  --chat-head: var(--bar-rail);
  --chat-cap: calc(var(--chrome-row) + var(--feed-gap) + var(--feed-3));
  --chat-foot: calc(var(--chat-head) + var(--chat-cap));
  --bar-reach: calc(var(--chat-foot) + var(--bar-rail));

  /* HOW FAR IT OPENS, and it is a disclosure rather than a screen: 42% of the sheet leaves the
     pile, the near seats and the hand behind it, so a player who taps to read back has not left
     the table. `--chat-foot` above does NOT move when it opens — the formation is the collapsed
     bar's, so expanding the feed cannot re-lay-out the ring under a thumb. */
  --feed-open: 42cqh;
  --bar-open-reach: calc(var(--chat-head) + var(--chrome-row) + var(--feed-gap) +
                         var(--feed-open) + var(--bar-rail));

  /* ---------- THE SEAT BAND IN THIS FRAME (story 13.4) ----------
     Upright the bench runs down the LEFT rail — the rink's length is the vertical here, so its
     long sides are the two rails and the left one is taken (story 13.7) — so what bounds the
     one undimmed decision is a `left` and not a `top`. Six seats span 272..1247 --px of a
     1583-deep sheet, so there is no horizontal band clear of them at all: 79 --px above the
     first and 127 below the last, against a panel that is 215. The free ice is beside the
     bench, not above or below it, and that is what this says.

     The other three insets are the sheet's own rails and the menu band's own floor, which is
     `.menu[data-kind="sit-or-play"]`'s `max()` picking the tighter of the two. */
  --decide-top: calc(15 * var(--px));
  --decide-bottom: calc(176 * var(--px));
  --decide-left: var(--bench-in);
  --decide-right: calc(15 * var(--px));

  /* AND THE FREE BAND'S CEILING IS THE BAR'S FOOT (story 13.4). The head of a tall sheet is a
     menu bar — the wordmark, the gear, EXIT and the chat row — and it is opaque, so a menu
     starting above `--bar-reach` would start behind it. `--chatAir`'s own 12 --px of air, the
     same gap table.js stands the far line off the bar with, so the two fixtures under the bar
     agree about how close anything may come to it.

     `--guard-top` and `--menu-floor` are NOT re-declared: the corridor is the same 16 --px off
     the foot with the same 145 --px of card in it in both compositions, and the measured
     override table.js writes lands on this same element. One frame owes the band a ceiling and
     nothing else. */
  --menu-ceil: calc(var(--bar-reach) + 12 * var(--px));
}

/* THE SEAT CARD IS DRAWN AT 0.8, and this one declaration is the whole of it. Three 210
   --px name boxes will not stand across 720 units of ice, and dropping a seat from the top
   row would break the adjacency the ring exists to carry — turn order IS the geometry.

   `--px` is RE-DECLARED rather than a transform or a zoom, so every number the card is
   built from follows it: the 110-unit panel, the 60-unit score lane, the 20-unit name, the
   five pips, the hatch, the strip. Nothing about the card's own geometry is restated here,
   which is why it cannot drift from the landscape one. It is also why the two traps § 8
   names still hold: `scrollWidth`/`clientWidth` are layout values, so the score lane and
   the name box are measured at this scale rather than through a transform that hides them.

   It is declared on `.sbody` and NOT on `.seat`, and that is load-bearing: `.seat` carries
   the inline `left`/`top` table.js writes in the TABLE's unit, and a seat whose own `--px`
   were 0.8 would stand at 0.8 of its place in the formation. So the wrapper keeps the full
   unit and only what is inside the card takes the scale — with `.seat`'s own width brought
   to the same 120 units by hand, since it is the box the card lays out inside.

   The expression is written out rather than derived from `--px`, because a `var()` inside a
   custom property is substituted where the property is DECLARED — the trap already recorded
   at `--focus-ring` above. `--px: calc(0.8 * var(--px))` would resolve against `:root`'s
   landscape unit and silently draw the wrong card. */
:root[data-compose="portrait"] .sbody { --px: calc(var(--seat-scale) * min(0.138889cqw, 0.078125cqh)); }

/* ---------- the chat box, at the head end (story 12.10) ----------
   Story 12.1 gave the feed the full width of the sheet — a strip between the ice and the
   cards — because 244 --px of 720 is a third of the row with the hand's own 504 in it. What
   that cost was VERTICAL room, and vertical room is what the ring needs: a full-width strip
   below the pile is a band no seat may enter, and it is a third of the frame's height once
   the corridor is counted with it. Story 12.2 put it back in a corner at its own 244 --px,
   the way it stands in the wide composition, and chose the BOTTOM-left one.

   THAT CORNER IS WHAT KEPT CENTRE ICE OFF THE CENTRE LINE, and it is the whole reason this
   rule moved. The faceoff circle is anchored to `--trick-y`; the centre red line is at 50%
   of the length; upright those were 10cqh apart, so the circle floated above the line it is
   supposed to be painted on and crossed the first blue line. Moving the anchor to 50cqh is
   one number — and it is one number the near line pays for on its own, because the standoff
   FLOORS come from the pile and the circle (which move with the anchor) while the CEILINGS
   come from the head, the chrome, the corridor and this box (which do not). Measured on the
   client with the anchor collapsed and moved and nothing else touched, the near line's
   skate-in fell from 184.62 to 27.49 --px at 390x844, and from 31.68 to EXACTLY ZERO at
   810x1080 — `lineCeil`'s own `max()` clamping the ceiling to the standoff, which is a line
   that has nowhere to skate from. The deficit is fixed wherever the sheet is 9:16 or squarer,
   because there `--px` is height-bound and 50cqh is a flat 640 --px; and this box's own floor
   is about 111 --px — padding, a 21-unit head, two gaps, four of border and an input with a
   hard 44px min-height — so trimming it in place could not have bought the ~96 back either.
   The box had to leave the bottom, not shrink in it.

   SO IT GOES TO THE HEAD, into the one band on a tall sheet that NEITHER line reaches: below
   the chrome cluster, above the far line. It coexists with the gear and EXIT rather than
   standing under or over them — and WHERE the cluster's foot is is not a constant, because
   both controls are `<button>`s carrying the 44 REAL pixel hit-target floor. See
   `--chrome-reach` on `.table` above for that arithmetic and for the 6.1 real px overlap the
   first cut of this rule shipped. The box's head and foot are `--chat-head` and
   `--chat-foot`, both derived from it. What that comes to, in --px, read off the running
   client at the two upright frames the story measures:

                        390x844          810x1080
     the cluster        16 .. 98.5       16 .. 74      (the floor bites on the phone only)
     the box's head     110.5            86            one `chromeAir` below the cluster
     the box's foot     290.5            266           head plus its own 180 cap
     the box across     10 .. 254 at every frame, on the same left rail it always had
     gear to the box    6.4 real px      14.5 real px  measured, both over the 12 --px asked

   and the clearances that follow:

     EVERY far line     bounded by this box's own FOOT: table.js's `lineCeil` carries
                        `var(--trick-y) - var(--chat-foot) - 74.16` as one of its far terms, so
                        the far line — whose left-hand seat stands under this corner — stops
                        12 --px below it at every window, the phone included.
                        AT EVERY COUNT SINCE STORY 12.12. That term used to drop for a line of
                        ONE, which stood on the midline where this box is not: `50cqw +- 84` is
                        never nearer than 338 at any admitted frame against the box's 254, so
                        22 --px clear at the narrowest. A line fills its ENDS first now — the
                        ice down the middle belongs to the faceoff — so a line of one stands
                        under a corner like every other line and takes the same bound
     the near line      has no corner fixture at all. Its only bound is the corridor's
                        own stack, 310 off the foot, which is what takes the near side's
                        journey at 810x1080 from 31.68 --px to 23.39 against a faceoff whose
                        own radius is 226.82
     the decision band  starts 434.8 (phone) / 410.3 (tablet) --px off the head, so no panel
                        reaches this box either

   It is pinned to the head now and grows DOWNWARD to the same cap, so the measurement
   `layout-check.mjs` takes against a FILLED feed is the same measurement it always was —
   `bottom: auto` is what makes the growth go the other way, and `--chat-foot` is the one
   number every clearance above is written against.

   ---------- AND NOW IT IS THE CHAT ROW OF A MENU BAR (story 12.20) ----------

   TWO THINGS CHANGED AND NEITHER IS THE BAND. The box still hangs from `--chat-head` and
   still stops at `--chat-foot`; everything measured above still holds. What is different is
   that it takes the WIDTH of the head instead of a corner of it, and that it is drawn AT its
   cap rather than up to it.

   THE WIDTH, because the owner asked for a menu bar and a 244-unit box on the left rail of a
   720-unit bar is a box sitting on a bar rather than the bar's own chat. It costs nothing: the
   far line's ceiling carries `var(--trick-y) - var(--chat-foot) - 74.16` UNCONDITIONALLY —
   every line takes that term whatever its count, since story 12.12 made a line fill its ends
   first — so no seat was ever cleared by being to the side of this box, and widening it
   crosses nothing that the foot did not already hold off.

   THE HEIGHT, because a growing box moves the input. `height` as well as `max-height` puts the
   input at the FOOT of the bar at all times, which is the far edge from EXIT — with the box
   free to be short, an empty feed drew the input 31 real px under EXIT at 390x844, against the
   46 an always-full-height box gives. It is also the reading `layout-check.mjs` already takes:
   the box was measured against a FILLED feed precisely because that was the worst case, and it
   is now the only case.

   THIS IS THE CHAT BOX'S THIRD HOME AND NONE OF THE THREE WAS A MISTAKE. Story 12.1 gave it
   the full width between the ice and the cards; story 12.2 put it in a bottom corner on the
   owner's instruction — "text box should be in a bottom corner" — when the head of a tall sheet
   was two loose controls and nothing else; story 12.10 brought it to a corner of the head
   because centre ice needed the bottom back. Each was right for the sheet it was made on, and
   the sheet kept changing under it. The head is a BAR now, and a bar is a place chat belongs:
   it is where the other things you reach for are. What would be a mistake is a fourth home
   moved to without a reason of this kind.

   ---------- AND NOW IT IS THE BAR (story 12.23) ----------

   IT IS NO LONGER A BOX ON A BAR; IT IS THE BAR'S CONTENTS. The border, the radius, the
   padding, the gradient and the "✉ TABLE" heading are all gone in this composition, because
   each of them was a hairline or a row spent drawing a boundary between the chat and the
   strip the chat is. What is left is two rows on the plate's own ground:

     row 1   the chat input, between the gear and EXIT, all three at --chrome-row
     row 2   the last three feed lines, indented past the wordmark and stopping short of
             the expand control

   A GRID AND NOT A COLUMN, and the fixed first track is the reason. `.feed.nochat` and
   `.feed.spectating` both remove the input — chat off, and a spectator who says things from
   the corridor instead — and in a flex column that pulls the feed lines up into the row the
   gear and EXIT are standing in. A `var(--chrome-row) auto` grid keeps the row whether
   anything is in it or not, so those two states change what the bar CARRIES and never where
   it is.

   THE GROUND IS DECLARED, and it is the same reading `layout-check.mjs` takes that made the
   old rule declare `background-color` under its gradient: a focus ring is judged against the
   ground it is drawn on, composited up the ancestor chain, and the plate this sits on is a
   SIBLING rather than an ancestor. A transparent feed would have the chat input's ring
   measured against the ice two layers up, which is the wrong answer by about 20:1. So the
   feed paints the bar's own dark itself. */
:root[data-compose="portrait"] .feed {
  left: var(--bar-rail); right: var(--bar-rail); transform: none; width: auto;
  top: var(--chat-head); bottom: auto;
  height: auto; max-height: none;
  /* `max-width: none` is not tidiness. The wide sheet's feed is `fit-content` capped at its own
     244 --px (story 13.5) and this rule inherits that cap unless it says otherwise — measured
     once without it, the head bar of a phone drew 130px wide across a 390px screen with the
     gear, the input and EXIT crushed into it. This composition's box IS the bar and its width
     is the sheet's. */
  max-width: none;
  display: grid; grid-template-columns: 1fr;
  grid-template-rows: var(--chrome-row) auto;
  row-gap: var(--feed-gap);
  border: 0; border-radius: 0; padding: 0;
  background-image: none;
  background-color: var(--boards);
}
/* The heading is the WIDE sheet's now. Upright, a strip that spends a whole row naming itself
   is a strip with two rows of chrome and one of chat, which is the shape this story was sent
   to undo — and what the bar is is already said, better, by the game's own name standing in
   it. Kept in the markup and kept drawn in the other composition: this is the heading being
   out of a strip, not out of the product. */
:root[data-compose="portrait"] .feed .fh { display: none; }
/* THE INPUT SITS BETWEEN THE OTHER TWO, and its margins are the whole of the mis-tap keep-out.
   Left: clear of the gear by one gap — mis-tapping the gear opens settings, which costs a
   player a tap. Right: clear of EXIT by a whole thumb plus eight units — mis-tapping EXIT ends
   the game. The two sides are not symmetrical because the consequences are not. */
:root[data-compose="portrait"] .feed input {
  grid-row: 1; align-self: stretch;
  height: var(--chrome-row);
  margin-left: calc(var(--gear-w) + var(--feed-gap));
  margin-right: calc(var(--exit-w) + var(--hit-target) + 8 * var(--px));
  border-radius: var(--r-input);
  /* SIZED TO ITS TEXT HERE TOO (story 13.5), and `justify-self` is what makes that mean
     anything: a grid item's default is `stretch`, which fills the track whatever the width
     says. Anchored at the START — beside the gear — so the box can only shrink AWAY from
     EXIT, which is the direction `browser-client.mjs`'s thumb floor needs it to move in. */
  justify-self: start; width: auto;
  max-width: calc(100% - var(--gear-w) - var(--feed-gap)
                  - var(--exit-w) - var(--hit-target) - 8 * var(--px));
}
/* THREE LINES, SIZED TO THREE. `max-height` and not `height`: a table that has said one thing
   so far draws one line and the rows below it are not reserved as blank space. The bar's own
   band is constant either way — the plate is `--bar-reach` and the formation is written against
   `--chat-foot` — so nothing on the ice moves while the feed fills up. */
:root[data-compose="portrait"] .feed .lines {
  grid-row: 2; min-height: 0; max-height: var(--feed-3); gap: var(--feed-lead);
  padding-left: var(--mark-gutter);
  padding-right: calc(var(--hit-target) + var(--feed-gap));
  cursor: pointer;
}
:root[data-compose="portrait"] .feed p { font-size: var(--feed-type); line-height: 1.35; }
/* ---- COLLAPSED, A LINE IS ONE ROW (owner's report, epic 14) ----
   "The last three" has to mean three ENTRIES, and it only does if an entry is a row. The
   region is `--feed-3` — three type-heights and two leads — so a line that wraps eats a
   neighbour's slot and the strip silently shows two of the last three. Real table lines are
   long ("Yzerman (CPU3) wins trick 4 with the queen of spades") and the 13px type this epic
   raised the floor to wraps sooner than the 10px it replaced, so this went from rare to
   common: `browser-client.mjs` has now read `2 line(s) of 45` twice on the deployment.
   Clamped to one row with an ellipsis, so the count on screen is the count in the DOM.
   OPEN it wraps again — see below — because there the region grows and nothing is competing
   for the space. */
:root[data-compose="portrait"]:not([data-feed="open"]) .feed p {
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  /* `flex: 0 0 auto` IS LOAD-BEARING, and the first cut without it read `40 whole feed lines
     of 40` in a 55px region — 1.4px a line. `.lines` is a flex column, and a flex item whose
     `overflow` is not `visible` gets an automatic minimum size of ZERO instead of its content,
     so every paragraph collapsed the moment the clamp above was added. The clamp fixes the
     wrapping; this keeps the row a row. */
  flex: 0 0 auto;
}
/* OPEN. The lines region is the only thing that grows — the input stays exactly where it was,
   which is what makes this a disclosure rather than a screen that replaced the last one. */
:root[data-compose="portrait"][data-feed="open"] .feed .lines {
  max-height: var(--feed-open); cursor: default;
}

/* THE CONTROL THAT OPENS IT. Absent from the wide sheet with the rest of the bar (see
   `.menubar, .barmark, .fexp` above), so it is neither drawn nor enumerated there.

   IT IS A REAL BUTTON AND IT IS A WHOLE THUMB, and both halves of that cost nothing: the
   feed's own band is 43.3 real px at the smallest window and the button is centred on it, so
   the 44 it must be by `browser-client.mjs`'s enumeration overhangs by less than a pixel into
   a 7 --px rail. Tapping the LINES opens it too — that is the gesture the owner asked for,
   "allow tap to expand" — but a click handler on a live region is not a control a keyboard or
   a screen reader can find, so the affordance a person is told about is this one and the tap
   is the shortcut. Closing is the button alone: a tap inside an open feed is somebody
   scrolling it. */
.fexp {
  /* Inside the feed, which is the containing block — so the offset is measured from the feed's
     own head and not from the sheet's. Centred on the COLLAPSED band and pinned there, so it
     does not travel down to the middle of an open panel when the feed grows under it. */
  position: absolute; right: 0; z-index: 16;
  top: calc(var(--chrome-row) + var(--feed-gap) + var(--feed-3) / 2);
  transform: translateY(-50%);
  width: var(--hit-target); height: var(--hit-target);
  /* NO `display` HERE. The default-off rule above (`.menubar, .barmark, .fexp`) is what keeps
     this out of the wide sheet, and it is EARLIER in the file — a `display` in this block would
     win on order and draw an expand control on a desktop table that has nothing to expand. It
     is turned on in the portrait block below and nowhere else. */
  place-items: center;
  background: none; border: 0; padding: 0; margin: 0;
  color: var(--yellow); cursor: pointer;
  font-size: calc(20 * var(--px)); line-height: 1;
}
:root[data-compose="portrait"] .fexp { display: grid; }
/* The chevron points the way it is going to move, and it is the only thing about this control
   that changes state. Drawn rather than typed, so no font has to have the glyph. */
.fexp i {
  display: block;
  width: calc(14 * var(--px)); height: calc(14 * var(--px));
  border-right: calc(3 * var(--px)) solid currentColor;
  border-bottom: calc(3 * var(--px)) solid currentColor;
  transform: rotate(45deg) translate(calc(-2 * var(--px)), calc(-2 * var(--px)));
  transition: transform 140ms ease;
}
:root[data-feed="open"] .fexp i {
  transform: rotate(225deg) translate(calc(-2 * var(--px)), calc(-2 * var(--px)));
}

/* ==========================================================================
   § THE MENU BAR — the head of the tall sheet, as the owner drew it (story 12.20)

   "I think we should make the top of the screen more like a menu bar while in portrait mode.
   Chat can be part of this menu bar. It allows settings, exit, and shows the chat and lets you
   input chat." And: "The menu bar should say Snarps on it somewhere so when people take
   screenshots it has the app name."

   FOUR THINGS, AND ONLY ONE OF THEM IS NEW. The gear, EXIT and the chat box were all already
   at the head end of this composition, in the three places story 12.10 measured them into. The
   bar does not gather them up: it is the BAND they stand on, drawn behind them, and the
   wordmark is the fourth thing standing on it. That is why `#menubar` is an empty plate — see
   index.html for why re-parenting the other four would tie the wide sheet's chrome to a
   portrait-only box, and § the wordmark below for why even the new one is a sibling — and it is
   why nothing in this story moved EXIT by a pixel.

   IT IS THE BOARD HEADER. `--boards` with a `--rail` bottom edge is § 4 Chrome's own header,
   the one every reading screen in this client already wears (`.bhead`); the only difference
   here is that it is ruled in `--px` so it scales with the sheet instead of with the window.
   The tall sheet gets the app's own chrome rather than a shape invented for it.

   WHAT IT COSTS THE ICE. The bar is opaque and it stands over the far end of the rink — which
   upright is the far goal line, its crease and one end-zone circle, about 30 ft of a 200 ft
   sheet at the tablet frame. That is the trade the owner asked for and it is worth naming: the
   camera (story 12.21) is pointed at centre ice and the head end of the picture is the part
   furthest from the play. Nothing that carries STATE is under it — every seat, the pile, the
   hand, trump and the banner are all below `--chat-foot` — and `layout-check.mjs` holds that
   as a measurement at every count and every in/out mix rather than as a claim here.

   IT DOES NOT TAKE A POINTER. `pointer-events: none` on the plate, so the bar is a background
   and the things standing on it are the only ones that can be pressed — a plate that ate taps
   would be a 154 real px dead zone across the top of the sheet at 390x844. It would not eat
   them for the gear, EXIT or the chat input, which are all ABOVE it in the stacking order; what
   it would eat is every press at the gaps BETWEEN them, which is most of the band. Nothing is
   gained by the plate being pressable, and `browser-client.mjs` asks the browser's own hit
   testing rather than reading the property, because a plate declaring `none` with a child
   declaring `auto` back is the same dead zone.

   Z-INDEX 14: over the rink and its vignette, under the chat box (15) and well under the
   chrome cluster (30). The one thing the plate must never do is come between a player and the
   things standing on it.

   AND IT GROWS WITH THE DISCLOSURE (story 12.23). When the feed is open the plate is open with
   it, so the `--rail` edge is still the bottom of whatever the bar currently is rather than a
   white line ruled across the middle of an open panel. `--bar-reach` is the collapsed height
   and is what `--chat-foot` and the whole formation are written against; `--bar-open-reach` is
   drawn and nothing is derived from it, which is what stops an expand from moving a seat.
   ========================================================================== */
:root[data-compose="portrait"] .menubar {
  display: block;
  position: absolute; left: 0; right: 0; top: 0; height: var(--bar-reach);
  z-index: 14; pointer-events: none;
  background: var(--boards);
  border-bottom: calc(5 * var(--px)) solid var(--rail);
  box-sizing: border-box;
}
:root[data-compose="portrait"][data-feed="open"] .menubar { height: var(--bar-open-reach); }

/* THE ONE COMPACT ROW (story 12.23). The gear and EXIT keep the corners they have stood in
   since story 12.10 and lose the two things that made them a ROW of their own: the 16 --px rail
   above them, and their own drawn size. Both are now `--chrome-row` exactly — the same track
   the chat input sits in between them — so the three of them are one row by construction
   rather than by three numbers that happen to agree.

   EXIT'S WIDTH IS THE KEEP-OUT'S OTHER TERM. See `--exit-w` on `.table` above: the input's
   right margin is written against it, so a stated width is what makes the mis-tap gap a
   derivation instead of a measurement that was true once. `padding: 0` because a fixed width
   and a fixed height leave nothing for padding to do but fight them. */
:root[data-compose="portrait"] .gear {
  top: var(--bar-rail); left: var(--bar-rail);
  width: var(--gear-w); height: var(--chrome-row);
  font-size: calc(20 * var(--px));
}
:root[data-compose="portrait"] .exit {
  top: var(--bar-rail); right: var(--bar-rail);
  width: var(--exit-w); height: var(--chrome-row); padding: 0;
  font-size: calc(22 * var(--px));
}

/* THE WORDMARK HAS A JOB AND IT IS NOT DECORATION. The owner's reason is the whole
   specification: "so when people take screenshots it has the app name." That makes it content,
   and it makes the test for it a SCREENSHOT at the smallest window this composition admits
   (339 x 602) rather than the element being in the DOM — `browser-client.mjs` photographs it
   there, reads the pixels and holds its drawn height, and `layout-check.mjs` names it in the
   fixture sweep so nothing on the felt can come and stand on it.

   WHERE — AND IT MOVED IN STORY 12.23. It used to be centred on the controls row, which was a
   row containing two controls and 480 units of clear rail. That row now carries the chat input
   as well and has no clear rail left in it, so the wordmark drops to the head of the feed's own
   band, at the left, vertically centred on the three collapsed lines. What it displaces there
   is text, and text can be indented: `--mark-gutter` on `.table` is the indent `.feed .lines`
   takes, and it is the wordmark's own measured advance plus air. `browser-client.mjs` still
   pairs the drawn box with both controls, so a longer word or a fatter face goes red rather
   than climbing into one of them.

   THE FACE IS THE LANDING'S. Sarpanch at 800 with the same faked 12deg oblique and the same
   -0.035em, because a wordmark drawn two ways is two wordmarks. What it does NOT take is the
   landing's `-webkit-text-stroke`: that stroke is load-bearing there because the letters land
   on ICE at 1.13:1 and the ink is the only thing holding them apart (§ the wordmark). Here the
   ground is `--boards` at #151a22 and the yellow measures 13.9:1 on it — so the stroke would be
   ink on ink, thickening the letters at the one size where they have least room to be
   thickened. The contrast is read off the rendered pixels on every deploy, not off this line.

   32 --px, WHICH IS SMALLER THAN 12.20's 36 AND IS STILL IN THE PICTURE. Story 12.20 photographed
   72.6 x 16.8 px of type at 339x602; this draws 14.9 there, against a floor of 12 that
   `browser-client.mjs` reads off the PHOTOGRAPH rather than off this line. Smaller was the
   instruction — "Snarps text somewhere small" — and absent was not: the owner's stated reason
   is that a screenshot should carry the game's name, so shrinking it until it stops
   photographing would be the same as deleting it. Sized in the sheet's own unit like everything
   else on it, and floored at nothing, because a floor here would be the wordmark growing while
   the bar it stands in did not. What holds it legible at the bottom of the range is the
   measurement, not a `max()`.

   Z-INDEX 30, WITH THE GEAR AND EXIT, and that is why it is a sibling of the plate rather than
   a child of it. A decision paints its scrim at z-index 25 (`.menu`'s own box-shadow), so
   everything under that dims — the plate at 14 and the chat box at 15 included. Measured
   through the dim the yellow comes to 4.7:1, and a screenshot taken while somebody is bidding
   is still a screenshot of this game. The two controls already ride above the scrim because
   they stay pressable (story 12.5); the name rides with them for a different reason and lands
   in the same place. A child of the plate could not: `z-index: 14` on the plate makes a
   stacking context its children cannot climb out of.

   IT TAKES NO POINTER AND NO VOICE. `pointer-events: none` because it is not a control and the
   band it sits in is the one a thumb crosses to reach the gear; `aria-hidden` in the markup
   because the game's name is not news to a screen reader on every table, and the feed beneath
   it is a live region that has actual news. */
:root[data-compose="portrait"] .barmark {
  display: block; position: absolute; z-index: 30; pointer-events: none;
  left: var(--bar-rail);
  top: calc(var(--chat-head) + var(--chrome-row) + var(--feed-gap) + var(--feed-3) / 2);
  transform: translateY(-50%);
  font-family: var(--wordmark); font-weight: 800; font-style: oblique 12deg;
  text-transform: uppercase; letter-spacing: -0.035em; line-height: 1;
  font-size: var(--mark-size);
  color: var(--yellow);
  white-space: nowrap;
}


/* ---------- the bottom corridor, in a 720-unit row ----------
   Both invariants are unchanged and are still geometry rather than tuning: the hand is at
   `left: 50%` translated back by half itself, so its centre IS the table's centre; and
   trump has a reserved lane the hand cannot reach into.

   THIS BLOCK USED TO PIN THE LANE AT `left: 12 --px`, on the arithmetic that an upright sheet
   is 720 design units wide: trump 12..92, the hand 108..612 (five 96-unit cards, 6 apart),
   sixteen units clear between them. Every one of those numbers is right and the premise is
   not. `--px` is re-declared in the tall block with the axes swapped, so an upright sheet is
   `min(w/720, h/1280)` wide in its own units — 731 at 390x844, and 969 at 3:4. The hand
   stayed centred and the lane did not move, so the sixteen units became 134.51 at 810x1080,
   measured. See § the bottom corridor for the two declarations that replace it; all this
   frame owes now is its own two numbers.

   The bar is 460 wide rather than 500, and the feed is neither beside trump nor above it any
   more — it is the head bar itself. `layout-check.mjs` measures the corridor as a clearance
   on either axis rather than as a signed horizontal gap, which is what makes one claim serve
   both compositions. */
:root[data-compose="portrait"] {
  --hand-gap-n: 6;
  --trump-w: calc(80 * var(--px));
}
:root[data-compose="portrait"] .specbar {
  width: calc(460 * var(--px)); max-width: calc(460 * var(--px));
}
/* The turn banner is the corridor's third centred occupant and the only one whose width is
   its CONTENT — the constraint sentence and the stated default are both server copy. In a
   1280-unit row there is no width it could reach that trump's lane would notice; in a
   720-unit one there is. Capped at the same arithmetic the bar is: centred on 360, 500 wide
   puts its left edge at 110, eighteen clear of the lane's 92. Wrapping is what gives way,
   and a banner two lines tall still stands above the hand rather than in it. */
:root[data-compose="portrait"] .turnban { max-width: calc(500 * var(--px)); }
/* The pile's two journeys, turned — see § the tall frame's `--trick-y` for why this is keyed on
   the composition rather than on the container's aspect. */
:root[data-compose="portrait"] .centre {
  top: calc(var(--trick-y) + var(--pile-along));
  left: calc(50% + var(--pile-across));
}
/* An 80-unit lane is 16 narrower than the box the suit's name was set for, so the name
   takes a smaller size rather than wrapping "diamonds" onto a second line under the glyph.
   The glyph itself is unchanged: it is the thing a player checks mid-trick and it is the
   last thing to give way. */
:root[data-compose="portrait"] .trump {
  padding: calc(4 * var(--px)) calc(6 * var(--px)) calc(6 * var(--px));
}
:root[data-compose="portrait"] .trump .w { font-size: calc(10 * var(--px)); }

/* ---------- the deck's corner is gone, because the deck has a seat now (story 12.4) ----------
   WHAT STOOD HERE was `:root[data-compose="portrait"] .deck { left: calc(100cqw - 70 *
   var(--px)); top: calc(100cqh - 230 * var(--px)) }` — the bottom-right corner — with story
   12.2's reasoning above it: "there is no single place beside the pile that clears every
   count", so the deck was moved out of the formation's way entirely.

   That reasoning was answering the wrong question and story 12.4 is the correction. The deck
   does not need a place beside the PILE that clears every count. It needs a place beside the
   DEALER, and every seat already carries one: the axis its own dealer puck hangs on, which
   `onLine` and `onArc` choose per position for exactly this reason ("away from what the seat
   is standing next to, on the axis this composition has spare"). The deck stands one mark
   further out along it — see § the deck above, and `deckReach` in table.js for the numbers.

   So there is no portrait rule any more, and that is the point: one derivation, read on
   whichever shape the seat is standing in, rather than a wide answer and a tall answer that
   have to be kept in step with each other and with table.js. */

/* ---------- WHAT THE TALL FRAME USED TO SAY ABOUT MENUS, AND WHY IT SAYS NOTHING NOW ------
   (stories 13.3 and 13.4)

   FOUR PORTRAIT-ONLY RULES STOOD HERE and all four are gone:

     a `max-width` cap of `100cqw - 30 --px`, because the four panels were authored at
       720..900 units against a 1280-unit sheet and two of them were wider than an upright
       one. `.menu` bounds itself against `100cqw` now, so the cap is the composition-neutral
       version of the same sentence and this one is a second copy of it;
     a `max-height` of the region, for the four kinds that had a panel;
     a tighter padding and gap for the three that dim;
     and a tighter `.mbody` gap under those three.

   THE LAST TWO WERE THE PROBLEM, AND IT IS THE ONE STORY 13.3 IS ABOUT. FR-26 as amended
   asks for *"decisions that arrive as menus that work on phone and desktop"*, and
   EXPERIENCE.md § Menu spells out what that costs: *"one design answers both form factors —
   that is the requirement, not 'a phone version and a desktop version'."* A shell that is
   16/20/18 with a 14 --px gap upright and 26/32/24 with a 20 upright is two designs, and the
   only reason the wide one was ever the looser of the two is that it had never been asked to
   stand above a hand. So the TIGHT set is the set, in both compositions — it is declared once
   on `.menu` — and the wide sheet gets about 80 --px of height back for nothing.

   WHAT THE REGION USED TO BE, kept because the reasoning is still live for sit-or-play and
   because deleting a measured argument reads as an oversight. Sit-or-play does not dim, so
   its panel is opaque over a readable rink and may not land on a seat card (handoff § 8 trap
   3). Story 12.1 put it in the lower left, because the ring ran down the two rails and left
   that quadrant empty; 12.2 moved it to the middle of the circle, because a ring has no empty
   quadrant; 12.8 made it the band between the two lines with the pile at its middle; 12.5
   gave that band to all four decisions, because story 10.3's reasoning for exempting the
   three that dim — "a dimmed sheet is not being read" — does not survive a tall frame, where
   the bid hung at 34 --px, ran 421 units down and covered three of six seats including the
   dealer the feed was naming.

   AND THE BOUNDS WERE NEVER NUMBERS, which is the part that survives intact. `table.js`
   writes the standoffs onto the table as `--line-out-f` / `--line-out-n` and the region is the
   band those two leave, less half a seat and the air between — so it follows the formation
   rather than restating it, and story 12.10 proved the property when the drop moved 10cqh and
   this rule did not have to be touched. Since 13.7 those two carry the FACEOFF's own standoff
   rather than a boards line's, and the band followed that too.

   The four `--decide-*` insets are declared for this composition in
   § THE TALL FRAME above and are read by `.menu[data-kind="sit-or-play"]` and by
   `layout-check.mjs`, which reads all of them off the running client. */

/* ---------- the draw screen's hand ----------
   Five cards at 118 units, a divider and the deck come to 778 — which fits the 900-unit
   panel it was authored for and not the 690 above. Scaled to fit rather than wrapped: a
   hand that wraps is a hand you cannot read as a hand. The card keeps its shape (84 x 127
   is the same 0.66 a playing card has) and stays over the 44 real pixel floor at every
   window this composition admits. */
:root[data-compose="portrait"] .dhand { gap: calc(8 * var(--px)); }
:root[data-compose="portrait"] .dhand .cardv {
  width: calc(84 * var(--px)); height: calc(127 * var(--px));
}
:root[data-compose="portrait"] .dhand .cardv .rk { font-size: calc(32 * var(--px)); }
:root[data-compose="portrait"] .ddiv { height: calc(127 * var(--px)); }
:root[data-compose="portrait"] .ddeck {
  width: calc(84 * var(--px)); height: calc(127 * var(--px));
}
