/* ---------------------------------------------------------------------------
   Game room — the app-level chrome around a live session: a full-screen "table" the game is played at.
   The room backdrop, the scrolling stage that fills it, and the floating chrome over both: the Undo/menu
   cluster (top-right), a game's turn banner and seat plaques, and the replay transport bar. Backed by
   Components/GameRoom.razor (with TurnBanner.razor and SeatPlaques.razor a game renders itself).
   The game owns the whole canvas — its board fills the stage — and the platform's only reserved chrome is the
   floating Undo/menu cluster top-right. Everything else (the turn pill, the seat roster) is a reusable
   component a game drops where it wants. The default look stays on the platform brand (navy + command-blue,
   gold reserved for whose-turn emphasis) with a subtle board-gaming flavor: a lamp-lit tabletop and a play mat
   in the middle.

   Games override it from .game-room--<gameId>: retheme by redefining the --room-* / --turn-* variables below,
   replace the backdrop wholesale via the component's Background fragment, or reposition the floating overlays
   from the game's own room class.
   --------------------------------------------------------------------------- */
.game-room {
    position: fixed;
    inset: 0;
    z-index: var(--z-room); /* above every hub-chrome tier: the room is full-screen and brings its own chrome */
    display: flex;
    flex-direction: column;
    gap: 0.75rem;
    /* The room is the query container for its subtree's width breakpoints (the shared chrome below and every
       game's board sheet): fixed inset:0 pins its inline size to its containing block — never to its content —
       so inline-size containment holds. container-type does not change fixed positioning — the room's fixed
       descendants (the menu scrim, the banners) still resolve against the viewport, pixel-identical because
       the room is viewport-sized; in the studio the pane's paint containment is their containing block.

       The room carries NO padding of its own, and that is load-bearing rather than incidental: an inline-size
       container query resolves against the container's CONTENT box, so any padding here would offset every
       room-scoped breakpoint in the shared chrome and in every game's board sheet by that padding — a rule
       written for 48rem would fire at 48rem plus however wide the padding happened to be at that width. The
       gutter therefore lives on the in-flow children (below), leaving the container's content box equal to the
       room's width so a breakpoint means the number it states. See theming.md → Responsive breakpoints.

       The container carries a NAME, and every query written about it names it. A container query selects a
       container — it walks up skipping any that cannot answer the features it asks about — rather than asking
       the nearest one and taking `false` for an answer, so an unnamed query silently re-points itself at any
       new container that appears between the room and the element being styled. The name pins each query to
       the box it was written about. It is a longhand, never the `container` shorthand, which would reset the
       type. See theming.md → Container names. */
    container-type: inline-size;
    container-name: room;
    /* The room's horizontal gutter. index.html asks for viewport-fit=cover, so on a notched handheld held in
       landscape the cutout and the rounded corner eat into the room's own edges — the gutter is therefore the
       larger of the proportional inset and whatever the device reserves, and every edge-pinned piece of chrome
       (the control cluster, the roster rail, the body gutter, the phone pill's yield) inherits the clearance
       from this one place. */
    --room-pad-x: max(clamp(1rem, 3cqw, 3rem), env(safe-area-inset-left), env(safe-area-inset-right));
    /* Every control in the floating cluster is one square of this size. The room's chrome is app chrome, so it
       is held to the app's 44px tap floor (docs/dev/game-design-checklist.md) — the same 2.75rem the nav items,
       the brand link and the panel close carry. The phone-width rules below reserve space for the cluster by
       reading this rather than restating its arithmetic. */
    --room-control: 2.75rem;
    --room-pad-b: calc(1rem + env(safe-area-inset-bottom));
    /* The floating chrome band reserved at the top of the stage so a board's top edge is not lost behind the
       Undo/menu cluster (and a game's turn banner). Roughly the controls' height plus the gap that separates
       them from the board. */
    --room-chrome-inset: 3.5rem;
    overflow: hidden;   /* the room itself never scrolls — the stage is the scroll surface */

    /* The room is the table canvas — light, so its own chrome (title, controls, plaques,
       banner) reads dark on clean white. The shell surrounding the room is dark, so the only
       light surface in view is this stage: a premium embossed tabletop set apart from the
       dark chrome by spacing alone. */
    --turn-color: var(--accent-gold);           /* whose-turn accent */
    --turn-glow:  var(--hero-glow);
}

/* --- Room backdrop: the tabletop ---------------------------------------------
   The backdrop IS the table: one continuous, borderless premium surface filling the whole canvas — very
   light blue-grey card stock, card suits (♠♥♦♣) blind-embossed into it (a soft highlight on each upper-left
   rim, a cool shadow on each lower-right, faces left as bare paper) in a sparse, off-grid scatter, over a
   barely-there paper grain. It carries no edge, frame, or radius of its own; the room's only chrome is the
   floating Undo/menu cluster (and a game's turn pill and seat plaques), so the room reads as one table, never
   a colored panel inset in white with a frame around it. A game retints it by redefining --stage-mat (and may
   swap the emboss), or replaces it wholesale via the GameRoom Background fragment. The
   suit tile and the paper grain tile independently. The suit file is named root-absolute: a relative url() in
   a stylesheet resolves against the *stylesheet*, and this sheet is served from /css/, so a bare "img/…" here
   would ask for /css/img/… and 404 with nothing to show for it — the layer would simply not paint.
   See docs/design/game-room-ui.md. */
.game-room__bg {
    position: absolute;
    inset: 0;
    z-index: -1;
    background-color: var(--stage-mat);
    background-image:
        /* Root-absolute: a url in a sheet resolves against THE SHEET, and this one is served from /css/, one
           directory below the document root the art sits at. */
        url("/img/table-suits.svg"),
        url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='180'%20height='180'%3E%3Cfilter%20id='p'%3E%3CfeTurbulence%20type='fractalNoise'%20baseFrequency='0.6'%20numOctaves='2'%20stitchTiles='stitch'/%3E%3CfeColorMatrix%20type='saturate'%20values='0'/%3E%3C/filter%3E%3Crect%20width='180'%20height='180'%20filter='url(%23p)'%20opacity='0.045'/%3E%3C/svg%3E");
    background-size: 600px 600px, 180px 180px;
}

/* --- Floating controls (Undo + overflow menu), top-right over the game ---
    The platform's only reserved chrome: a small cluster pinned to the top-right corner, floating over the
    board rather than holding a layout band of its own. The board fills the whole canvas; the stage keeps a top
    inset (see --room-chrome-inset) so a board's top edge is not lost behind the cluster. The cluster carries NO
    z-index of its own: the menu's scrim and panel (which live inside it) need to resolve in the room's stacking
    context above the cluster, so the cluster must not become a stacking context. It still paints above the board
    (an absolute box over in-flow content, later in DOM), and the scrim catches outside clicks above it. */
.game-room__controls {
    position: absolute;
    top: calc(0.5rem + env(safe-area-inset-top));
    /* The room's horizontal gutter — see the container-box note on .game-room. --room-pad-x is a clamp on 3cqw
       that resolves against .game-room (the nearest ancestor container, named `room`), i.e. 3% of the room's
       width. A cq unit cannot name its container the way a query can: theming.md → Container names. */
    right: var(--room-pad-x);
    display: flex;
    align-items: center;
    gap: 0.5rem;
}
/* The read-only spectator tag inside the phase pill — a quiet pill that marks the room as someone else's game. */
.game-room__spectating {
    font-size: 0.7rem;
    font-weight: 600;
    letter-spacing: 0.04em;
    white-space: nowrap;
    padding: 0.1rem 0.5rem;
    border-radius: var(--radius-pill);
    color: var(--accent-blue-ink);
    background: color-mix(in srgb, var(--accent-blue) 15%, transparent);
}
/* Undo stays on the bar as an icon-only round button. */
.game-room__undo {
    flex-shrink: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    width: var(--room-control);
    height: var(--room-control);
    border-radius: var(--radius-pill);
    background: var(--bg-card);
    border: 1px solid var(--border-color);
    color: var(--text-primary);
    cursor: pointer;
    transition: background-color 0.15s ease, border-color 0.15s ease, opacity 0.15s ease;
}
.game-room__undo:hover:not(:disabled) { background: var(--bg-hover); border-color: var(--accent-blue); }
.game-room__undo:disabled { opacity: 0.45; cursor: default; }
.game-room__undo .app-icon { width: 1.1rem; height: 1.1rem; }

/* Overflow menu: the once-a-session commands live behind the ⋯ button. */
.game-room__menu-wrap {
    position: relative;
    flex-shrink: 0;
}
.game-room__menu-btn {
    position: relative;
    display: flex;
    align-items: center;
    justify-content: center;
    width: var(--room-control);
    height: var(--room-control);
    border-radius: var(--radius-pill);
    background: var(--bg-card);
    border: 1px solid var(--border-color);
    color: var(--text-primary);
    font-size: 1.25rem;
    font-weight: 700;
    line-height: 1;
    cursor: pointer;
    transition: background-color 0.15s ease, border-color 0.15s ease;
}
.game-room__menu-btn:hover { background: var(--bg-hover); border-color: var(--accent-blue); }
.game-room__menu-btn .game-room__menu-badge {
    position: absolute;
    top: -0.15rem;
    right: -0.15rem;
    width: 0.55rem;
    height: 0.55rem;
    padding: 0;
    min-width: auto;
    border-radius: var(--radius-pill);
    background: var(--accent-red, #e5484d);
    box-shadow: var(--shadow-sm);
}
/* Scrim behind the open menu: transparent, catches outside clicks, sits above room content (z-60) but below
   the rules panel (z-70) and modal dialogs (z-100). */
.game-room__menu-scrim {
    position: fixed;
    inset: 0;
    z-index: var(--z-room-overlay);
}
.game-room__menu {
    position: absolute;
    top: calc(100% + 0.4rem);
    right: 0;
    z-index: calc(var(--z-room-overlay) + 1);
    min-width: 11rem;
    display: flex;
    flex-direction: column;
    gap: 0.15rem;
    padding: 0.35rem;
    border-radius: 0.75rem;
    background: var(--bg-elevated);
    border: 1px solid var(--border-color);
    box-shadow: var(--shadow-lg);
    animation: menu-pop 0.12s ease both;
}
.game-room__menu-item {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.75rem;
    width: 100%;
    /* The row's own padding sets its rhythm; the floor keeps it a 44px target whatever the label's size. */
    min-height: var(--room-control);
    padding: 0.55rem 0.7rem;
    border-radius: 0.5rem;
    background: transparent;
    border: none;
    color: var(--text-primary);
    font-size: 0.9rem;
    font-weight: 600;
    text-align: left;
    cursor: pointer;
    transition: background-color 0.12s ease;
}
.game-room__menu-item:hover { background: var(--bg-hover); }
/* The menu paints on the table canvas, which is light, so the active row takes the light-surface twin of the
   soft-blue ink rather than the pale one the shell's dark surfaces use. */
.game-room__menu-item--active {
    color: var(--accent-blue-soft-ink);
    background: color-mix(in srgb, var(--accent-blue-soft) 15%, transparent);
}
.game-room__menu-item--danger { color: color-mix(in srgb, var(--accent-red, #ef4444) 78%, var(--text-primary)); }
.game-room__menu-item--danger:hover { background: color-mix(in srgb, var(--accent-red, #ef4444) 10%, var(--bg-hover)); }
.game-room__menu-sep {
    height: 1px;
    margin: 0.25rem 0.35rem;
    background: var(--border-color);
}
.game-room__menu-badge {
    min-width: 1.05rem;
    height: 1.05rem;
    padding: 0 0.25rem;
    display: inline-grid;
    place-items: center;
    border-radius: var(--radius-pill);
    background: var(--accent-red, #e5484d);
    color: var(--text-on-accent);
    font-size: 0.66rem;
    font-weight: 700;
    line-height: 1;
    box-shadow: var(--shadow-sm);
}

@keyframes menu-pop { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }

/* A menu item's keyboard hint — the bare letter that summons it from a clean room (see js/tat-shortcuts.js).
   Serves keyboard/pointer users only: hidden on touch (coarse pointer) where it would read as clutter. */
.game-room__menu-key {
    display: none;
    align-items: center;
    font-family: var(--font-mono);
    font-size: 0.68rem;
    font-weight: 700;
    line-height: 1;
    padding: 0.18rem 0.32rem;
    border-radius: 0.3rem;
    color: var(--text-secondary);
    background: var(--bg-hover);
    border: 1px solid var(--border-color);
}
@media (hover: hover) and (pointer: fine) {
    .game-room__menu-key { display: inline-flex; }
}

@media (prefers-reduced-motion: reduce) {
    .game-room__menu { animation: none; }
}

/* --- Body: the game area, filling the whole canvas -----------------------------------------
   The stage holds the board and is the room's scroll surface; it takes all the width and height the room has.
   The seat roster is not a reserved column here — the platform floats it over the board (see
   .game-room__roster-float) or a game places it in a band of its own, so the board fills the canvas either way. */
.game-room__body {
    flex: 1;
    min-height: 0;
    width: 100%;
    display: flex;
    /* Carries the room's horizontal gutter and its bottom inset — see the container-box note on .game-room. */
    padding: 0 var(--room-pad-x) var(--room-pad-b);
    box-sizing: border-box;
}

/* --- Replay transport bar: the playback controls (step / scrub / play) ---------------------------
   The read-only replay's controls, floated top-centre in place of a game's turn pill (see GameRoom's
   ReplayBar slot). A row of transport buttons and a flexible scrubber over the recorded moves. */
.game-room__replay-float {
    position: absolute;
    top: calc(0.5rem + env(safe-area-inset-top));
    left: 50%;
    transform: translateX(-50%);
    z-index: var(--z-chrome);
    /* Leave the Undo/menu cluster room on the right and cap the bar so the scrubber stays meaningful. The bar
       is centred, so the reserve covers the cluster on both sides of it. */
    max-width: min(100% - 4 * var(--room-control), 32rem);
}
.replay-bar {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    width: 100%;
}
.replay-bar__btn {
    flex: 0 0 auto;
    width: var(--room-control);
    height: var(--room-control);
    padding: 0;
    border-radius: var(--radius-pill);
    border: 1px solid var(--border-color);
    background: var(--bg-card);
    color: var(--text-primary);
    font-size: 1rem;
    line-height: 1;
    cursor: pointer;
}
.replay-bar__btn:disabled { opacity: 0.4; cursor: default; }
.replay-bar__btn--primary {
    background: var(--accent-gold);
    border-color: var(--accent-gold-deep);
    color: var(--text-on-accent-deep);   /* 9.04:1 on the gold fill */
}
.replay-bar__scrub {
    flex: 1 1 auto;
    min-width: 0;
    accent-color: var(--accent-gold);
    cursor: pointer;
}
.replay-bar__pos {
    flex: 0 0 auto;
    font-variant-numeric: tabular-nums;
    font-size: 0.85rem;
    color: var(--text-secondary);
    white-space: nowrap;
}
/* On a phone the transport bar floats beside the cluster rather than centred over it, and it wraps instead of
   shrinking: the transport buttons are app chrome and hold the tap floor at every width, so what gives is the
   number of rows the bar takes. The scrubber is the flexible item, so it is what drops to a line of its own. */
@container room (max-width: 30rem) {
    .game-room__replay-float {
        left: var(--room-pad-x);
        right: calc(2 * var(--room-control) + var(--room-pad-x) + 0.5rem); /* undo + menu + gutter + gap */
        transform: none;
        max-width: none;
    }
    .replay-bar { gap: 0.35rem; flex-wrap: wrap; }
    .replay-bar__scrub { flex: 1 1 8rem; }
}

/* --- Seat-roster float (the platform's own placement for the roster) --------------------
   A right-edge rail that floats over the board, holding the platform seat roster: one chip per seat, stacked
   like a table roster. This element is PLACEMENT and nothing else — position, insets, stacking and pointer
   transparency — so the roster inside it carries none of that and the same component serves a game that puts it
   in a band of its own board (Dominion's top bar). It carries no pointer events of its own — only the chips do
   — so taps in the surrounding margin reach the board underneath. Below a tablet width it drops to a horizontal
   strip across the top. A game that declines the float (its own board already names every player, or it places
   the roster itself) renders nothing here and the board fills the width. */
.game-room__roster-float {
    position: absolute;
    top: calc(env(safe-area-inset-top) + var(--room-chrome-inset));
    right: var(--room-pad-x);
    bottom: var(--room-pad-b);
    width: 17rem;
    z-index: var(--z-chrome);
    pointer-events: none;
}
.game-room__roster-float .seat-plaque { pointer-events: auto; }

/* Down the floating rail the chips are a column at their own heights, and each gets the rail's whole width —
   which the chip answers on its own, since it declares both axes (see .seat-plaque below). The rail is sized
   so that width clears the full rung's minimum with the widest detail among the games that float the roster
   added to it (Othello's disc count), so a floated roster reads in full rather than degrading in a place that
   has room. A game with a wider detail than that places the roster itself, in a band it has sized for it —
   which is what Dominion's three tallies do. */
.game-room__roster-float > .seat-plaques {
    flex-direction: column;
    gap: 0.75rem;
}

/* --- Seat roster (composition: the chips and their ladder) ----------------------------------
   The roster proper. It owns what a chip says and how the row degrades as it narrows, and owns no placement:
   whoever mounts it decides where it sits and which way it runs. The default axis is a single non-wrapping row
   of equal chips — the shape a band in a board wants — because a row that wrapped would float a second line of
   chips over the board, which is the failure the ladder exists to replace (#1098).

   Chips are equal flex items with no intrinsic minimum, so a chip's width is a function of the roster's width
   and the seat count alone. That is what the width watcher measures and what the ladder is chosen from, and it
   is why the per-rung rules below reach only INSIDE a chip — which parts are drawn, the gaps between them, the
   disc's diameter — and never the chip's own outer box, its width or its padding. A rung that moved one of
   those would be moving the number that chose it. A game restyling the roster is held to the same rule
   (docs/design/game-room-ui.md → Seat roster).

   The active seat carries a shimmer sweep; when the active seat is YOURS the chip also glows gold so it can't
   be missed. */
.seat-plaques {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-direction: row;
    gap: 0.4rem;
    min-inline-size: 0;
    /* No scroll container and no per-chip floor. Below the bare rung's width the chips keep dividing the row
       and the discs are trimmed by the chip's own overflow — the roster's one job is to say who is at the
       table, so it may squeeze but never omit, and the design sweep's chrome-fit assertion reports what is
       left of the bare rung's declared minimum as the fit failure it is. What that assertion reads a bare chip
       in is the chip's PADDING box, because that is where the trimming happens: the disc is centred and does
       not shrink, so it overflows the content box and paints on across the padding until the overflow below
       cuts it (SeatRosterLadder.PaddingBoxFrame, #1247). Making this a scroll
       container instead would put a scrollbar axis on both axes (a visible axis beside an auto one computes to
       auto) and clip the my-turn bloom below, which is an element box-shadow precisely so it can escape. */
}
/* --- THE PLAQUE TOKEN CONTRACT ------------------------------------------------------------
   Everything a game may say about a chip, it says by naming one of the --plaque-* custom properties below.
   Nothing else: a game writes no selector that lands on a chip or on a part of one.

   WHY A TOKEN AND NOT A SELECTOR. A game's own rule leads with the game's class, so `.sp-seats .seat-plaque`
   is (0,2,0) while every state rule here — the active tint, the my-turn bloom, the move deadline's urgent
   ember — is (0,1,0) on a chip the game's later-loading sheet also matches. The game therefore wins every
   contest it enters, including the ones it never meant to enter: a hairline written as `border-color` takes
   the whole turn escalation with it, and a material lift written as `box-shadow` takes the bloom — silently,
   on a board nobody reviewing the sheet would suspect (#1125). A token cannot do that: the value travels by
   INHERITANCE and lands in the declaration below, which every state rule outranks by sitting later in this
   file at equal weight. So a game may set a token from any selector it likes, however heavy, and still reach
   only the neutral voice.

   THE INVARIANT THAT KEEPS IT TRUE, stated exactly: no state rule DRAWS AN ESCALATION PROPERTY from a
   --plaque-* token a game can set. A state rule may read the contract freely for anything else, and three of
   them do — --me takes its ground and its label ink from --plaque-me-bg / --plaque-me-ink, and --active takes
   its ground from --plaque-active-bg and its ring from --plaque-active-ring. None of those is a property the
   warning is drawn from. The escalation itself is the border-color family, box-shadow and outline-color, and
   what those are drawn from is a SHELL token every time — --turn-color and --turn-glow, which a game re-voices
   on the band the roster is mounted in (the urgent rule re-points --turn-color on the chip, so a band value can
   never reach the ember), and --warn-ember, which is the warning and which no game sheet may declare anywhere.
   Where a state tints against the chip's own material it reads --plaque-edge, which is this sheet's own name
   for a value a game supplied — resolved once on the neutral rule, so what a state reads is the platform's.
   SeatPlaqueTokenContractCssTests holds every half of this — the games to tokens, this sheet to the invariant,
   and the shell's warning token to nobody.

   Three names below are the PLATFORM's own working values rather than the game's: --plaque-ground,
   --plaque-edge and --plaque-label-ink, each declared here so a state can re-point what the neutral rule
   paints. The gate reads the contract as "the --plaque-* names this sheet reads but never declares", so those
   three are outside it and a game naming one fails. What a game may say is documented in
   docs/design/theming.md → The plaque contract.

   Metrics stay a straight pass-through: every token's fallback is the value the platform draws without it, so
   a game moving one moves exactly what its old declaration moved and the seat-roster ladder's arithmetic
   (SeatRosterLadder, #1247) reads the same numbers either way. */
.seat-plaque {
    position: relative;
    /* Contains the shimmer sweep — and, at the bare rung, is the edge the disc is cut at. An overflow region is
       the PADDING box, so a bare chip's disc has the padding to spill into before anything is lost, which is
       why the bare rung's minimum is read there rather than in the content box (SeatRosterLadder.FrameName). */
    overflow: hidden;
    display: flex;
    align-items: center;
    /* The two gaps are separate because the ladder tightens only one of them. The per-rung rules below close
       the COLUMN gap as the chip narrows — a metric that is a term in that rung's declared minimum — while a
       stacked chip's row gap is the game's whole way to voice how close its two lines sit, and must survive
       every rung. Written as one `gap` the rung's tightening would take the row gap with it. */
    column-gap: var(--plaque-gap, 0.55rem);
    row-gap: var(--plaque-row-gap, 0.55rem);
    /* THE CHIP SAYS BOTH AXES, so it is the right box whichever way the band it was mounted in runs.
       Across the INLINE axis a chip is the band divided between the seats: `flex-basis: auto` reads the 100%
       inline size below, every chip's base is therefore the whole band, and equal bases shrink by equal
       amounts — so a chip comes out (band - gaps) / seats, which is the number the ladder is chosen from.
       Down the BLOCK axis a chip is its own content: the same `auto` basis reads `block-size: auto` where the
       band runs as a column, and `min-block-size: min-content` is what stops a band shorter than its seats
       from squashing them instead of scrolling to them.
       A zero basis would say the first of those on whichever axis the band happens to run, and in a column
       that is a height no chip has: the box collapses to its own border while its contents draw straight out
       of it, containers, anchors and keyed pieces included, every one of them answering a rectangle outside
       the chip it belongs to (#1145). */
    flex: 0 1 auto;
    inline-size: 100%;
    min-inline-size: 0;
    /* The block floor a chip refuses to squash below, and the inline ceiling past which extra band buys a
       seat nothing but a longer bar. Both are the game's to name, because both are facts about the band the
       roster was mounted in rather than about the chip: a floor is what a game lays something else over the
       chip against, and a ceiling is what stops two seats taking half a desktop each. */
    min-block-size: var(--plaque-min-block, min-content);
    max-inline-size: var(--plaque-max-inline, none);
    padding-block: var(--plaque-pad-block, 0.5rem);
    /* ONE VALUE, so the two inline paddings are equal. The bare rung's minimum is read in the padding box, and
       what makes that the room the disc has is that a centred item overflows the CONTENT box symmetrically —
       it therefore reaches only the narrower of the two paddings, while the measure adds both. Split the
       shorthand and the chip is credited with room on a side nothing can paint into
       (SeatRungFrameContractTests, docs/design/game-room-ui.md → Which box a minimum is counted in). */
    padding-inline: var(--plaque-pad-inline, 0.9rem);
    border-radius: var(--plaque-radius, 0.8rem);
    /* A chip is labels, not prose: the document's 1.5 leading is set for paragraphs, and applied to a name over
       a status it adds a line's worth of height that nothing in the chip is using. At 1.2 the disc is what sets
       the chip's height, which is the relationship the ladder's per-rung diameters are written against. Stated
       on the chip so a game's own detail line inherits it too. */
    line-height: 1.2;
    /* The floating chrome's own surface, kept slightly translucent so a board reads through the chip. Mixed
       from the surface token rather than from white, so a game that retints the table moves the chips with the
       pill and the buttons instead of leaving them behind.

       Painted from --plaque-ground rather than from --plaque-bg directly, because a state re-points the
       GROUND (below) instead of declaring a background of its own: --me and --active would otherwise both
       name the property, and a chip that is both would take whichever rule sat later — silently dropping the
       local player's tint the moment their turn came round. */
    --plaque-ground: var(--plaque-bg, color-mix(in srgb, var(--bg-card) 90%, transparent));
    background: var(--plaque-ground);
    /* The chip's own type colour, inherited by the game's detail inside it as well as by the labels below. */
    color: var(--plaque-ink, inherit);
    --plaque-label-ink: var(--plaque-ink, var(--text-primary));
    /* The hairline the states tint AGAINST. Resolved once here so --me and --active can mix their accent into
       the chip's own edge rather than into the shell's neutral one — the difference between a gold-tinted
       ceramic frame and a gold-tinted blue-grey the board never uses. */
    --plaque-edge: var(--plaque-border, var(--border-color));
    border: 1px solid var(--plaque-edge);
    /* A rule down the leading edge, for a game that marks a seat's identity there. Only the WIDTH is the
       game's: the colour is a neutral-voice declaration like every other on this rule, so a turn tint — and
       the deadline's ember above all — still takes all four edges, which is what an urgent chip must look
       like whatever else the game is saying with the fourth one. */
    border-inline-start-width: var(--plaque-spine, 1px);
    border-inline-start-color: var(--plaque-spine-ink, var(--plaque-edge));
    /* The chip's lift off the board, and any glass behind it. A filter rather than a box-shadow BY DESIGN:
       box-shadow is where the my-turn bloom and the urgent ember are drawn, so a material shadow written
       there would take both. Given its own token, a game cannot reach that property at all. */
    filter: var(--plaque-lift, none);
    backdrop-filter: var(--plaque-backdrop, none);
    /* The turn tint is a border-color value at every state (neutral → --active → --my-turn / --urgent), so
       transitioning border-color alone eases the hand-off as the turn passes between seats. border-color only:
       the shimmer (transform on ::after) and the turn/urgent glow (a static outer box-shadow on the element
       plus an opacity-animated inset glow on ::before) own transform and box-shadow, and must not be eased
       here (and the plaque is room chrome, not a stage node, so it never enters a FLIP snapshot). --tat-motion
       scales the duration, so reduced motion collapses it to instant. */
    transition: border-color calc(var(--tat-motion, 1) * 220ms) var(--ease-spring, ease);
}
/* The shared participant avatar disc (PlayerAvatar.razor): a round badge sized by --avatar-size, filled by a
   robot face (computer), an identity-coloured initials disc (human, colours set inline from SeatPalette), or a
   chair (empty seat). Each surface sets --avatar-size on the disc via its own context class below. */
.player-avatar {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex: none;
    /* Size comes from each surface's context class via --avatar-size; the var() fallback keeps this
       order-independent, so a context class defined before this rule still wins (it need only set the var). */
    width: var(--avatar-size, 2rem);
    height: var(--avatar-size, 2rem);
    border-radius: var(--radius-pill);
    overflow: hidden;
    line-height: 1;
    user-select: none;
}
.player-avatar__initials { font-weight: 700; font-size: calc(var(--avatar-size, 2rem) * 0.42); letter-spacing: 0.01em; }
.player-avatar__glyph { font-size: calc(var(--avatar-size, 2rem) * 0.5); line-height: 1; }
.player-avatar--empty { background: var(--bg-secondary); border: 1px solid var(--border-color); }
.player-avatar--computer { background: color-mix(in srgb, var(--bg-secondary) 60%, var(--bg-card)); border: 1px solid var(--border-color); }
.player-avatar--human { border: 1px solid rgba(0, 0, 0, 0.08); }
/* A live-audio ring on a participant's disc — the standard green cue. On the platform's own component rather
   than on each surface that hosts one, so a voice bar, a group hero and any later caller draw one ring. */
.player-avatar--speaking { box-shadow: 0 0 0 2px var(--accent-green); border-radius: var(--radius-pill); }

/* The disc a chip draws, and the one metric a game is given a say in (--seat-avatar on the chip). The min()
   is what makes that say one-directional: a game may name a smaller disc than the rung draws, never a larger
   one, so the ladder's declared minimum stays an upper bound on what a restyled chip lays out and the disc
   still draws DOWN as the composition narrows. The cap at each rung is that rung's term in the minimum
   (SeatRosterLadder.AvatarRemFor); the pair is pinned by SeatRosterAvatarCssTests.

   The cap governs --seat-avatar, so it is one-directional only for a game that goes through it. A game naming
   the disc directly — --avatar-size on the avatar element (whose class is .player-avatar), or a width on
   .seat-plaque__avatar — would beat this rule on specificity at the wide rungs and lose to the per-rung rules
   below at the narrow ones: a disc drawing LARGER on a smaller chip, which is the failure the min() exists to
   prevent, arriving through the other door. Those routes are closed by the same static test rather than by the
   cascade. */
.seat-plaque__avatar {
    --avatar-size: min(var(--seat-avatar, 2.2rem), 2.2rem);
    filter: drop-shadow(0 2px 4px rgba(24, 58, 112, 0.22));
}
/* The platform's own shared components inside a chip, each of which draws a hairline of the SHELL's from its
   own rule: an unoccupied or computer seat's disc (PlayerAvatar) and a settled friend request (AddFriendButton).
   On a chip they draw the CHIP's hairline instead, because the chip is the surface they sit on — a blue-grey
   line reads as the shell's furniture left behind on a board's ceramic, walnut or slate.
   Read as --plaque-edge rather than as the game's own token, so a board that names no hairline still gets the
   shell's; and led with the chip, which is what outranks each part's own single-class rule. It is the platform
   drawing a border on a part of its own, not a state rule, so nothing here can be reached by the escalation. */
.seat-plaque .player-avatar--empty,
.seat-plaque .player-avatar--computer,
.seat-plaque .add-friend--requested {
    border-color: var(--plaque-edge);
}
.seat-plaque__text { display: flex; flex-direction: column; min-width: 0; }
/* No text-overflow: the rung's own budget is what makes a name fit, applied in C# with the whole name kept on
   the chip's title and accessible name. An ellipsis here would put a decorative full stop on a name the ladder
   was supposed to have shortened, and would read as intentional to the design sweep's chrome-fit assertion —
   which measures this box against its max-content width precisely so a residual overrun stays visible as one. */
.seat-plaque__name {
    display: flex;
    align-items: center;
    gap: 0.35rem;
    font-size: var(--plaque-name-size, 0.9rem);
    font-weight: var(--plaque-name-weight, inherit);
    color: var(--plaque-label-ink);
    white-space: nowrap;
    overflow: hidden;
}
.seat-plaque__you {
    font-size: 0.6rem;
    text-transform: uppercase;
    letter-spacing: 0.08em;
    color: var(--plaque-you-ink, #0a1626);
    background: var(--plaque-you-bg, var(--accent-blue));
    border-radius: var(--radius-pill);
    padding: 0.05rem 0.4rem;
}
.seat-plaque__status {
    font-size: var(--plaque-status-size, 0.72rem);
    color: var(--plaque-ink-soft, var(--text-secondary));
    white-space: nowrap;
    overflow: hidden;
}
/* The local player's own chip. The border tint is the platform's mark and stays one; the ground and the label
   ink are re-pointed rather than painted, so the neutral rule above draws them and a chip that is both yours
   and the acting seat keeps this tint under the acting seat's ring. */
.seat-plaque--me {
    border-color: color-mix(in srgb, var(--accent-blue) 55%, var(--plaque-edge));
    --plaque-ground: var(--plaque-me-bg, var(--plaque-bg, color-mix(in srgb, var(--bg-card) 90%, transparent)));
    --plaque-label-ink: var(--plaque-me-ink, var(--plaque-ink, var(--text-primary)));
}

/* The narrow rungs' name forms. The tag is the whole of what the minimal rung says about a seat, so it is set
   at the name's weight rather than as a caption; the badge rides the bare rung's disc, where there is no column
   for anything beside it. Both are produced in C# (SeatRosterLadder.ShortTag), never by clipping a name. */
.seat-plaque__tag {
    /* A flex item with a definite width, which is what makes its data-fit declaration measurable: an inline box
       would size to its own content and could never be found short of it. */
    min-inline-size: 0;
    /* The tag follows the name's size unless a game says otherwise, because most boards want one type size for
       the label whichever form the rung draws. It gets a token of its own because the two carry DIFFERENT
       platform defaults — the tag is already the smaller of the pair — so a game tightening the name from 0.9
       would otherwise GROW the tag past the 0.82 the minimal rung's budget is written against. */
    font-size: var(--plaque-tag-size, var(--plaque-name-size, 0.82rem));
    font-weight: var(--plaque-name-weight, 600);
    color: var(--plaque-label-ink);
    white-space: nowrap;
    overflow: hidden;
    font-variant-numeric: tabular-nums;
}
.seat-plaque__badge {
    position: absolute;
    inset-block-end: 0.05rem;
    inset-inline-end: 0.05rem;
    font-size: 0.55rem;
    font-weight: 700;
    line-height: 1;
    padding: 0.08rem 0.18rem;
    border-radius: 0.3rem;
    color: var(--plaque-ink, var(--text-primary));
    background: var(--plaque-badge-bg, color-mix(in srgb, var(--bg-card) 82%, transparent));
    pointer-events: none;
}

/* The ladder's rungs, inside the chip: the gaps tighten and the disc draws down as the composition does. The
   chip's own width and padding are deliberately untouched — see the note on .seat-plaques for why. Every metric
   here is a term in that rung's declared minimum (SeatRosterLadder), so one cannot move without the other.

   These rules lead with .seat-plaques on purpose, and the disc is set as --avatar-size on the AVATAR rather
   than as --seat-avatar on the chip. A game re-voices the roster from a selector led by its own prefix
   (.dom-topbar .seat-plaque), which ties a bare chip selector on specificity and wins on load order — so
   written the short way the platform's per-rung tightening would be silently undone by every game that
   restyles a chip, which is precisely where the ladder is used. */
.seat-plaques .seat-plaque[data-rung="minimal"] { column-gap: 0.3rem; justify-content: center; }
.seat-plaques .seat-plaque[data-rung="minimal"] .seat-plaque__avatar { --avatar-size: min(var(--seat-avatar, 2.2rem), 1.9rem); }
/* The bare rung's pair — a zero column gap and centred justification — is also what decides the frame its
   declared minimum is read in. One item, not shrinking, centred: too wide for the content box it overflows that
   box symmetrically and is cut at the padding box, so the padding box is the room it has (SeatRosterLadder →
   PaddingBoxFrame). Giving another rung this same pair, or taking it off this one, moves that answer;
   SeatRungFrameContractTests holds the two together rather than leaving the link to be noticed. */
.seat-plaques .seat-plaque[data-rung="bare"] { column-gap: 0; justify-content: center; }
.seat-plaques .seat-plaque[data-rung="bare"] .seat-plaque__avatar { --avatar-size: min(var(--seat-avatar, 2.2rem), 1.6rem); }

/* A chip whose game declared its detail OnItsOwnLine: the disc and the platform's text column across the top,
   the game's own line underneath. The platform ships the two-row box rather than describing it, because the
   shape a game reaches for instead — a wrapping flex row — fails the game's own declared minimum BY
   CONSTRUCTION and says nothing on the way: OnItsOwnLine folds the two lines with Max, and the instruments read
   the whole chip at max-content, where a grid row contributes its own width and a wrapping flex row contributes
   the SUM of both. So the chip would report roughly twice what the composition needs, at every rung, however
   honest the declaration behind it (SeatDetailWidthTests, docs/design/game-room-ui.md → Seat roster).

   The second track's zero floor is the other half: a grid item's automatic minimum is its content, so an `auto`
   track would refuse to be given less than the detail wants — and being given less than it wants is the whole
   premise of the ladder. The game's own detail spans both tracks from its own sheet; everything else about the
   chip (padding, ground, type) stays the game's. */
.seat-plaques .seat-plaque--stacked {
    display: grid;
    grid-template-columns: auto minmax(0, 1fr);
}
/* At the floor the chip is the disc and nothing else, so it stops reserving a column for a name that is not
   drawn and centres what is left. */
.seat-plaques .seat-plaque--stacked[data-rung="bare"] {
    grid-template-columns: auto;
    justify-items: center;
}

/* STOWED: a part its rung does not draw, laid over its own chip at no inline cost rather than dropped. It keeps
   its box, so its container and anchor still answer a rectangle on the RIGHT chip, and every keyed piece inside
   it stays present — which is how a game reconciles "a detail lays nothing out at bare" with "a rung never
   deletes a FLIP destination". Every declaration here is load-bearing and none is obvious, which is why it is
   the platform's rather than each game's (#1131):

   - `grid-column: auto` — an absolutely-positioned grid child with a DEFINITE placement is laid out in its grid
     AREA, which on a narrow rung is one column wide (the avatar's). Placed auto, the containing block is the
     grid container's padding box, which is the chip. Without it the box is a ~27px sliver.
   - `inset: 0` — pins that box to the chip rather than to the static position it would otherwise take.
   - the descendant collapse — `overflow: hidden` clips PAINTING only, so a row left in flow inside a box a
     fraction of its width still reports its own getBoundingClientRect(). A travel flown to that container, or a
     popup positioned from that anchor, lands on the NEIGHBOURING chip: present, keyed, and drawn over the wrong
     player. The sizes go with the positions, or a box with a definite width keeps it and answers a rectangle of
     its own choosing anchored at the chip's edge.

   The failure mode is invisible to a render test (the element is present, with the right key) and invisible on
   screen (the box is transparent). It shows only as a flourish over the wrong player, which is why what holds it
   is a browser assertion on the outcome — SeatStripGeometry, run by every game that stows — rather than any one
   of these declarations.

   Both rules lead with the roster, for the same reason the per-rung rules above do and with one extra: the very
   declaration this class exists to reverse — `grid-column: 1 / -1`, spanning the detail across the stacked
   chip's two tracks — is one a game writes on this same element from a single-class selector of its own. At
   (0,1,0) the two would tie and the game's sheet would win on load order, leaving the stow laid out in a
   one-column grid area, which is precisely the sliver the class is for.

   Led with the roster it is (0,2,0), which is where every bare-rung recipe it replaces sat
   (`.sp-seat[data-rung="bare"]` and its neighbours) — but not where all of them did: a stow written for a rung
   ABOVE bare is a descendant selector, so Stone Age's minimal-rung pair was (0,3,0), and it also sat in a sheet
   loading after this one. So a game may write a rule in a stowed subtree that outranks these, and the only thing
   holding the recipe is that no game does: a heavier rule that set a position, an inset, a margin, a padding or
   a size on a stowed box would take that declaration back, silently, in the same way the recipe exists to
   prevent. Which is why what the contract is held to is the measured outcome — SeatStripGeometry, at every
   rung — rather than the standing of any rule here. */
.seat-plaques .seat-detail--stowed {
    position: absolute;
    grid-column: auto;
    inset: 0;
    overflow: hidden;
    opacity: 0;
    pointer-events: none;
}
.seat-plaques .seat-detail--stowed * {
    position: absolute;
    inset: 0;
    margin: 0;
    padding: 0;
    inline-size: auto;
    block-size: auto;
    width: auto;
    height: auto;
}

/* Add-a-friend affordance for a human opponent — on the live seat plaques and the results screen. A small
   pill that reads as an action, then settles to a quiet "Requested" / "Friend" once the graph updates. */
.add-friend {
    align-self: flex-start;
    margin-top: 0.25rem;
    font-size: 0.66rem;
    font-weight: 600;
    line-height: 1;
    padding: 0.22rem 0.5rem;
    border-radius: var(--radius-pill);
    white-space: nowrap;
}
.add-friend--action {
    cursor: pointer;
    color: var(--accent-blue-ink);
    background: color-mix(in srgb, var(--accent-blue) 12%, transparent);
    border: 1px solid color-mix(in srgb, var(--accent-blue) 42%, transparent);
    transition: background 120ms ease, border-color 120ms ease;
}
.add-friend--action:hover { background: color-mix(in srgb, var(--accent-blue) 22%, transparent); }
.add-friend--requested { color: var(--text-muted); border: 1px solid var(--border-color); }
.add-friend--confirmed {
    color: var(--text-status-positive);
    background: color-mix(in srgb, var(--accent-green) 14%, transparent);
    border: 1px solid color-mix(in srgb, var(--accent-green) 28%, transparent);
}
/* On the results row (a horizontal flex line) the pill sits at the end, not stretched. */
.result-row .add-friend { align-self: center; margin-top: 0; margin-left: 0.15rem; }

/* Whose turn: a soft shimmer sweeps across the active seat's plaque. A game may add a ground of its own and a
   ring — an outline, which leaves the turn tint (a border-color) and the two blooms (box-shadows on this same
   element) free to draw. Where the ring sits is the platform's and not the game's: the negative offset pulls it
   INSIDE the chip's border box, so it never crowds the neighbouring chip. It does NOT clear the chip's own
   hairline — an outline's inner edge sits at the border edge plus the offset and the outline grows outward from
   there, so at -2px a 2px ring occupies the outer 2px of the border box and paints over the 1px border, which
   the outline phase draws above in any case. That is why the ember re-colours the ring below rather than
   sitting under it. Unset the ring is `none`, so a game that wants none pays for no outline rather than a
   transparent one — which forced-colors mode would repaint. */
.seat-plaque--active {
    border-color: color-mix(in srgb, var(--turn-color) 55%, var(--plaque-edge));
    background: var(--plaque-active-bg, var(--plaque-ground));
    outline: var(--plaque-active-ring, none);
    outline-offset: -2px;
}
.seat-plaque--active::after {
    content: "";
    position: absolute;
    inset: 0;
    background: linear-gradient(105deg,
        transparent 38%,
        color-mix(in srgb, var(--turn-color) 16%, transparent) 50%,
        transparent 62%);
    transform: translateX(-100%);
    animation: room-shimmer 2.4s ease-in-out infinite;
    pointer-events: none;
}
/* …and when the active seat is yours, the shimmer brightens and the plaque breathes a gold glow. */
.seat-plaque--my-turn {
    border-color: var(--turn-color);
    --glow-color: var(--turn-glow);
    /* The outer bloom is a static element box-shadow: an element's own shadow escapes its overflow:hidden,
       where a pseudo's outer shadow would be clipped. The breathing is a separate inset glow on ::before
       faded via opacity, so it composites instead of repainting each frame. */
    box-shadow: 0 10px 30px -5px var(--glow-color), inset 0 1px 0 rgba(255, 255, 255, 0.35);
}
.seat-plaque--my-turn::before {
    content: "";
    position: absolute;
    inset: 0;
    border-radius: inherit;
    pointer-events: none;
    box-shadow: inset 0 0 22px var(--glow-color);
    opacity: 0.5;
    animation: seat-glow-breathe 2.4s ease-in-out infinite;
}
.seat-plaque--my-turn::after {
    background: linear-gradient(105deg,
        transparent 35%,
        color-mix(in srgb, var(--turn-color) 38%, transparent) 50%,
        transparent 65%);
    animation-duration: 1.6s;
}
/* The local player's own plaque rests while it's their turn: the top-bar turn banner already carries the
   call to act, so the plaque need not also breathe and shimmer. It keeps the gold border to mark the turn. */
.seat-plaque--me.seat-plaque--my-turn {
    box-shadow: none;
}
.seat-plaque--me.seat-plaque--my-turn::before {
    display: none;
}
.seat-plaque--me.seat-plaque--my-turn::after {
    animation: none;
}
/* The per-move clock is nearly out on the active seat: the gold turn tint escalates to an ember-red flash —
   the same warning palette as the shelf's urgent match card. Everyone watching the table sees it; the mover
   also hears a tick (fired from the match view). Re-pointing --turn-color reddens the shimmer sweep too. */
.seat-plaque--urgent {
    --turn-color: var(--warn-ember);
    border-color: var(--warn-ember);
    box-shadow: 0 0 14px 3px color-mix(in srgb, var(--warn-ember) 70%, transparent);
    /* The acting ring joins the warning instead of hiding it. A game's ring is drawn at a negative offset, so
       it covers the hairline this rule just reddened (see the note on --active); recolouring it is what makes
       that harmless at every ring width a game might name, where an offset chosen to clear a 1px border would
       hold only for the widths it was arithmetic for. A chip with no ring has outline-style: none, so this
       draws nothing. */
    outline-color: var(--warn-ember);
}
.seat-plaque--urgent::before {
    content: "";
    position: absolute;
    inset: 0;
    border-radius: inherit;
    pointer-events: none;
    box-shadow: inset 0 0 18px color-mix(in srgb, var(--warn-ember) 90%, transparent);
    opacity: 0.5;
    animation: seat-glow-breathe 1s ease-in-out infinite;
}
@keyframes seat-glow-breathe {
    0%, 100% { opacity: 0.5; }
    50%      { opacity: 1; }
}

@keyframes room-shimmer {
    0%       { transform: translateX(-100%); }
    60%, 100% { transform: translateX(100%); }
}

/* --- Turn banner -------------------------------------------------------------
   A one-line call to action: whose move it is right now. A game drops <TurnBanner/> where it wants the pill;
   it floats at the top-centre of the room by default (.turn-banner-float) and a game restyles that position
   from its own room class. The pill's size is fixed in both axes: a fixed height (the chrome band) and a width
   capped so a long label crops with an ellipsis instead of growing into the Undo/menu cluster. The cap sits
   under what the room can spare at its narrowest width, so the pill never has to wrap. */
.turn-banner-float {
    /* Viewport-fixed so the pill stays put at the room's top-centre regardless of where in its board a game
       drops <TurnBanner/>: the board's own roots can be positioned (and in the studio the pane's paint
       containment reanchors fixed overlays to the pane, the same as every other fixed overlay — see the fit-box
       note above). */
    position: fixed;
    top: calc(0.5rem + env(safe-area-inset-top));
    left: 50%;
    transform: translateX(-50%);
    z-index: var(--z-chrome);
    height: 2.4rem;
    display: flex;
    align-items: center;
}
.turn-banner {
    position: relative;
    display: inline-flex;
    align-items: center;
    gap: 0.5rem;
    box-sizing: border-box;
    height: 100%;
    /* min-width: 0 so the pill can shrink below its label's min-content — without it a flex item's automatic
       minimum is that label, and no max-width can pull it back in. */
    min-width: 0;
    max-width: min(100%, 22rem);
    margin: 0;
    padding: 0 1.2rem;
    border-radius: var(--radius-pill);
    font-size: 0.95rem;
    background: var(--bg-card);
    border: 1px solid var(--border-color);
    color: var(--text-secondary);
    white-space: nowrap;
}
/* Phase label shown outside Playing — the same pill chrome as the turn banner, but muted. */
.turn-banner--phase {
    color: var(--text-muted);
}
/* The pill's one line of text — the local player's next-step hint, or the seat that owes the move. It is the
   part that yields: it shrinks inside the capped pill and crops with an ellipsis rather than wrapping. */
.turn-banner__label {
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
.turn-banner--you {
    overflow: hidden;
    background: linear-gradient(180deg, var(--accent-gold-pale) 0%, var(--accent-gold) 55%, var(--accent-gold-deep) 100%);
    border-color: transparent;
    color: var(--text-on-accent-deep);
    text-shadow: 0 1px 0 rgba(255, 255, 255, 0.35);
}
.turn-banner--you::after {
    content: "";
    position: absolute;
    inset: 0;
    background: linear-gradient(105deg, transparent 35%, rgba(255, 255, 255, 0.45) 50%, transparent 65%);
    transform: translateX(-100%);
    animation: room-shimmer 1.6s ease-in-out infinite;
    pointer-events: none;
}
/* The per-move clock is nearly out: the gold call-to-action escalates to an ember-red flash — the same
   warning palette as the shelf's urgent match card and the urgent seat plaque. Applies to everyone watching
   the table; the mover also hears a tick. Defined after --you so it wins the shared background/color on the
   mover's own banner; its ::after carries the ember flash and resets the --you shimmer that pseudo
   would otherwise inherit. */
.turn-banner--urgent {
    position: relative;
    background: linear-gradient(180deg, var(--warn-ember-light) 0%, var(--warn-ember) 55%, var(--warn-ember-deep) 100%);
    border-color: transparent;
    color: var(--text-on-accent-deep);
}
/* The urgent escalation is the final warning before an auto-played turn — a safety state that must never be
   hidden by a game's own mover-pill restyle. The triple class makes this selector (0,3,0) outrank any
   per-game .turn-banner--you override (0,2,0), so the mover's pill reads ember whenever the clock's window
   is open. The box-shadow is also reset, so the games' gold outer glow clears and the urgent state reads as
   clean ember. A game should restyle .turn-banner--you but never .turn-banner--urgent. */
.turn-banner.turn-banner--you.turn-banner--urgent {
    background: linear-gradient(180deg, var(--warn-ember-light) 0%, var(--warn-ember) 55%, var(--warn-ember-deep) 100%);
    border-color: transparent;
    color: var(--text-on-accent-deep);
    box-shadow: none;
}
/* The ember flash rides ::after as a static box-shadow faded via opacity (composited); background/transform
   are reset to drop the --you shimmer this pseudo would otherwise inherit, and there is no overflow:hidden
   here to clip the outer glow. */
.turn-banner--urgent::after {
    content: "";
    position: absolute;
    inset: 0;
    border-radius: inherit;
    pointer-events: none;
    background: none;
    transform: none;
    box-shadow: 0 0 20px 4px color-mix(in srgb, var(--warn-ember) 80%, transparent);
    opacity: 0;
    animation: turn-urgent-flash 1s ease-in-out infinite;
}
@keyframes turn-urgent-flash {
    0%, 100% { opacity: 0; }
    50%      { opacity: 1; }
}

/* --- The move clock's ring ----------------------------------------------------
   The realtime per-move deadline's closing window, drawn on the pill's own border rather than as a number
   ticking inside it: the clock costs the pill no width, and it is absent entirely until the window opens
   (see MoveDeadlineGauge.RingSeconds), so a turn shows a clock only once one is worth watching.

   The band itself is a mask cut to the pill's border — the standard gradient-border trick, so it follows the
   radius exactly with no extra element or SVG — and it is emptied by a LINEAR wipe across that band, left to
   right, not a conic sweep around it. On a pill the two are very different: measured from the centre, the
   rounded caps subtend nearly all of the angle and the long top and bottom edges almost none, so a conic
   drain would sit still on one cap for half the countdown and then cross a whole edge in a few degrees. A
   horizontal wipe drains at a constant rate along the shape a player actually reads.

   --turn-ring is the fraction still filled, restamped once a second by the room; registering it as a typed
   property is what lets those steps transition into a continuous drain instead of jumping. */
@property --turn-ring {
    syntax: "<number>";
    inherits: false;
    initial-value: 1;
}
.turn-banner { --turn-ring-color: var(--text-primary); }
.turn-banner--you,
.turn-banner--urgent { --turn-ring-color: var(--text-on-accent-deep); }
.turn-banner__ring {
    position: absolute;
    inset: 0;
    border-radius: inherit;
    padding: 2px;
    pointer-events: none;
    background: linear-gradient(to right,
        var(--turn-ring-color) calc(var(--turn-ring) * 100%),
        transparent 0);
    -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
    -webkit-mask-composite: xor;
    mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
    mask-composite: exclude;
    transition: --turn-ring 1s linear;
}

/* --- Phone-width pill: it shrinks beside the cluster -------------------------
   On a phone the floating pill's own text is wider than the room can spare beside the Undo/menu cluster, so
   the pill is the part that gives: it crops its label to fit the width left between the cluster and the left
   gutter. It never wraps — its height is fixed, so a second line would be clipped rather than shown. This is
   the only width-aware behaviour the floating pill has; it lives here in the shared sheet and applies to every
   game, and to the replay transport bar floated in the same place. */
@container room (max-width: 30rem) {
    .turn-banner-float {
        height: 2.1rem;
        /* Yield to the cluster: take the width left of it rather than centring over it. */
        left: var(--room-pad-x);
        right: calc(2 * var(--room-control) + var(--room-pad-x) + 0.5rem); /* undo + menu + gutter + gap */
        transform: none;
        justify-content: flex-start;
    }
    .turn-banner {
        max-width: 100%;
        min-width: 0;
        padding: 0 0.7rem;
    }
}

/* --- Stage: where the game happens --------------------------------------------
   The game area: it takes the whole canvas and is the room's scroll surface, so a tall board (many seats)
   scrolls here without the room clipping its top. Content top-aligns and centers horizontally; a board
   stretches to full width, while a small placeholder panel centers itself
   vertically via its own auto margins (which never clip when content is taller than the stage). It is a
   transparent scroll surface over the tabletop backdrop — the table shows through the stage and its
   surrounding padding alike, so no frame reads around the board. */
.game-room__stage {
    flex: 1;
    min-width: 0;
    min-height: 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 0.75rem;
    overflow-y: auto;
    overscroll-behavior: contain;
    -webkit-overflow-scrolling: touch;
    /* Sized against the room container (.game-room, named `room`, is inline-size containment), so the padding
       scales with the room rather than the viewport. In real play the room spans the viewport and the two match; in the
       design studio the room fills a pane narrower than the window, so viewport units would overshoot.
       The top inset reserves the floating chrome band (the Undo/menu cluster and a game's turn banner) so a
       board's top edge starts below them rather than under; the board fills the rest of the width and height. */
    padding: calc(env(safe-area-inset-top) + var(--room-chrome-inset)) clamp(1rem, 2cqw, 1.75rem) clamp(1rem, 2cqw, 1.75rem);
}

/* --- Fit box: the shape a board is composed for -------------------------------
   The stage's content box, expressed as a box a game can ask questions of. The stage itself cannot be that
   box: it is the scroll surface, so its height is the height it OFFERS, while its scroll height is whatever
   its contents took — and a container established on it would answer with the offered height while the board
   overflows it. A separate in-flow child separates the two, and the room hands the board this one.

   It holds the board and nothing else. Platform chrome that rides the stage (the interaction layer's
   multi-select bar) is a sibling below it, through GameRoom's StageChrome slot: chrome that appears with a
   selection and vanishes on confirm would otherwise resize the very box a game composed its board against,
   mid-move, in the middle of the FLIP the move animates. Outside, it takes its height off the fit box before
   the game is asked anything, so the box's measurements are the board's measurements at every moment.

   Its geometry is the stage's, handed on unchanged:
   - `width: 100%` against the stage's `align-items: center` — a board that stretches gets the full content
     width, while a narrower panel centres, both against the same width the stage would have given them.
   - `flex: 1 1 auto` with the default `min-height: auto` — it GROWS to the stage's content box when the board
     is shorter (which is what makes the box equal that content box, and what keeps a board's `margin-block:
     auto` centring it in the stage's height), and it cannot shrink below its content, so a board taller than
     the stage still makes the stage scroll rather than being squeezed into a box that hides it.
   - `gap: inherit` — the stage's own gap, including a game's override of it, so a game that zeroes the
     stage's gap does not get 0.75rem back inside this box.

   CONTAINMENT FOLLOWS THE GAME'S DECLARATION. Every game answers `GameClientModule.AnswersTheBlockAxis` —
   which box bounds its board's block axis — and the room writes `--room-fit-contain: size` inline on
   `.game-room` for the games that answer with this one (`BlockAxisFit.RoomBox`). A game sheet writes the
   property nowhere; the answer is a C# line the build requires, so a game cannot arrive without one and
   nothing — here or anywhere else — keeps a list of who gave which.

   For the other two answers the type computes to `normal` and this establishes no container at all — a query
   naming `room-fit` keeps walking, and a `cq` length below still resolves against whatever container it
   resolves against without this box. That is what makes a conditionally-typed box safe to name (the same
   reason .sp-board is). Unconditional `size` is not available: it would kill the stage's scroll height for
   every game whose phone composition is a scrolling column, which is the accepted phone composition (see
   docs/design/game-room-ui.md → The fit box).

   Two things move for a game the room contains this box for, and neither is announced: every `cqw`/`cqh`
   under the board re-points from the room to this box, and a `cqh` with no other container above it stops
   measuring the small viewport and starts measuring this. See docs/design/theming.md → Container names.

   The type is a var() and the name is a constant, so both are longhands: the `container` shorthand resets
   whichever it is not given, which here would drop the var()-driven type.

   What this box must never acquire is a containing block for `position: fixed`. The room's fixed overlays —
   a game's end-of-game warning (.azul-endgame-warn), the stage's score popups (.tat-popup) — resolve against
   the viewport, and any ancestor that establishes such a containing block reanchors every one of them into
   itself. `container-type` does not establish one: the CSSWG resolved in 2024-07 (csswg-drafts#10544) that it
   does not carry layout containment. Measured in the live room at 1366x768, a `position: fixed; inset: 0`
   probe appended inside this box, against a box painted at 68,92 1229x633:

     what the box declares          probe rect in Chromium 149   in WebKit 26.5
     (no --room-fit-contain)        0,0 1366x768                 0,0 1366x768
     container-type: size           0,0 1366x768                 0,0 1366x768
     container-type: inline-size    0,0 1366x768                 0,0 1366x768
     contain: layout                68,92 1229x633               68,92 1229x633
     contain: paint                 68,92 1229x633               68,92 1229x633
     transform: translateZ(0)       68,92 1229x633               68,92 1229x633

   The bottom three rows are the controls: they are what reanchoring looks like, and they are what this box may
   never carry. Containment through --room-fit-contain is free of it in both engines, on both types. */
.game-room__fit {
    container-type: var(--room-fit-contain, normal);
    container-name: room-fit;
    flex: 1 1 auto;
    width: 100%;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: inherit;
}

/* Below a tablet width the floating rail can't spare the room: drop the roster into a horizontal strip across
   the top of the board instead of down the right edge. It is a SINGLE row of equal chips — the ladder is what
   makes them fit, so nothing wraps and no chip ever lands on a second line floating over the board. */
@container room (max-width: 56rem) {
    .game-room__roster-float {
        top: calc(env(safe-area-inset-top) + var(--room-chrome-inset));
        right: var(--room-pad-x);
        left: var(--room-pad-x);
        bottom: auto;
        width: auto;
    }
    .game-room__roster-float > .seat-plaques { flex-direction: row; gap: 0.4rem; }
}

/* --- Rotate prompt: what the room shows in a shape the game declined ----------------------------------
   A game declares the screen shapes its board is composed for (docs/design/game-room-ui.md → Which shape a
   board composes for). Where it declined one, the room renders a known state in that shape — this prompt, and
   no stage — rather than painting a composition into a shape it was not drawn for.

   Only CSS knows how the device is being held, so the reveal lives here and the component contributes the two
   halves CSS cannot know: whether the game declined a shape at all, and whether this player has asked to be let
   through. Both arrive as one attribute — data-orientation-gate names the shape the board is held for, and is
   simply absent otherwise — so a rule below fires only when the room means it to.

   The gate is a HANDHELD rule: (pointer: coarse) is the whole of the device test. A desktop window is any shape
   its owner drags it to and cannot be turned, and a tall board in a wide window is the ordinary case for a
   portrait-only design rather than a failure state (overview.md → Devices and orientation), so no prompt ever
   stands between a mouse and a board.

   .game-room__body goes out of layout rather than under a cover: a board that is merely hidden is still
   focusable, still read out, and still measured. Taking it out is also what makes the state checkable — the
   sweep asserts prompt painted AND stage not painted here, the exact inverse of what it asserts everywhere
   else. The floating Undo/menu cluster stays reachable: leaving the match is the room's control, not the
   board's. */
.game-room__rotate {
    display: none;          /* revealed only by the two orientation gates below */
    flex: 1;
    min-height: 0;
    place-items: center;
    padding: 0 var(--room-pad-x) var(--room-pad-b);
    box-sizing: border-box;
    /* The room clips (overflow: hidden), and the shortest box this ever gets is a landscape handheld. The
       card fits there today with room to spare, but the button inside it is the ONLY escape
       the design guarantees a player who cannot rotate — so this scrolls rather than trusting the margin to
       survive a larger text-size preference, a longer game name, or a shorter device. */
    overflow-y: auto;
    overscroll-behavior: contain;
}
.game-room__rotate-card {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 0.75rem;
    max-width: 22rem;
    padding: 1.5rem;
    text-align: center;
    border-radius: 1rem;
    background: var(--bg-card);
    border: 1px solid var(--border-color);
    box-shadow: var(--shadow-lg);
}
/* The handset is drawn upright by the font, so the shape being ASKED for is the rotated one: a landscape gate
   turns it on its side, a portrait gate leaves it standing. The turn is the whole message, so it is animated
   into place where motion is allowed and simply arrives there where it is not. */
.game-room__rotate-art {
    font-size: 2.75rem;
    line-height: 1;
    transition: transform 600ms ease-in-out;
}
.game-room__rotate-art--landscape { transform: rotate(90deg); }
.game-room__rotate-art--portrait  { transform: rotate(0deg); }
@media (prefers-reduced-motion: reduce) {
    .game-room__rotate-art { transition: none; }
}
.game-room__rotate-title {
    text-wrap: balance;
    margin: 0;
    font-size: 1.15rem;
    font-weight: 600;
    color: var(--text-primary);
}
.game-room__rotate-blurb {
    text-wrap: pretty;
    margin: 0;
    font-size: 0.9rem;
    line-height: 1.45;
    color: var(--text-secondary);
}

/* A board composed for landscape only, on a handheld held upright. */
@media (pointer: coarse) and (orientation: portrait) {
    .game-room[data-orientation-gate="landscape"] > .game-room__body { display: none; }
    .game-room[data-orientation-gate="landscape"] > .game-room__rotate { display: grid; }
}
/* A board composed for portrait only, on a handheld turned on its side. */
@media (pointer: coarse) and (orientation: landscape) {
    .game-room[data-orientation-gate="portrait"] > .game-room__body { display: none; }
    .game-room[data-orientation-gate="portrait"] > .game-room__rotate { display: grid; }
}

@media (prefers-reduced-motion: reduce) {
    .seat-plaque--active::after,
    .seat-plaque--my-turn::before,
    .seat-plaque--urgent::before,
    .turn-banner--you::after,
    .turn-banner--urgent::after {
        animation: none;
    }
    /* The move clock's ring still drains — it is information, not decoration — but in the whole-second steps
       the room restamps rather than interpolated into continuous motion. */
    .turn-banner__ring { transition: none; }
}

