/* Game-specific styles, loaded after app-base.css (see index.html).
   The base palette (navy + command-blue) is set in Program.cs via ThemeColors; the motion toolkit
   (.font-display, .animate-*) lives in app-base.css. Add your game's styles here. */

/* ---------------------------------------------------------------------------
   Design tokens
   These are NEW variables (not overrides), so defining them here is safe even though App.razor injects
   the theme :root block after this stylesheet. The gold is the hero accent — reserved for the primary
   call-to-action button.
   --------------------------------------------------------------------------- */
:root {
    --accent-gold:       #f0b429;
    --accent-gold-deep:  #d99a1f;
    --hero-glow:         rgba(240, 180, 41, 0.45);

    /* Warm secondary accent used for playful highlights on the bright surfaces. */
    --accent-warm:       #ff9f4d;

    /* The table experience's accent — distinct from the community blue, so the two group surfaces are never
       mistaken for each other at a glance. */
    --accent-teal:       #0e8f88;
    --accent-teal-deep:  #0a6f6a;

    /* Elevation on the shell's dark canvas. A shadow only reads on a surface lighter than it, so on navy the
       depth cue is the inverse pair: a deep cast shadow that sinks the page away, plus a hairline of light
       along the top edge that catches the raised surface. Retuned for the table below, where the light comes
       from above a white page instead. */
    --shadow-sm:  0 1px 2px rgba(0, 0, 0, 0.30), inset 0 1px 0 rgba(190, 215, 255, 0.06);
    --shadow-md:  0 10px 26px -14px rgba(0, 0, 0, 0.62), 0 2px 8px rgba(0, 0, 0, 0.28),
                  inset 0 1px 0 rgba(190, 215, 255, 0.07);
    --shadow-lg:  0 34px 70px -30px rgba(0, 0, 0, 0.78), 0 10px 26px -14px rgba(0, 0, 0, 0.45),
                  inset 0 1px 0 rgba(190, 215, 255, 0.08);
    /* Keyboard-focus ring. Two forms of the same idea: --ring is a soft box-shadow halo for elements that
       can safely paint one (no clipping ancestor); --focus-ring is the outline shorthand for the app-wide
       baseline below, which must survive an ancestor's overflow:hidden — common on game boards — where a
       box-shadow would be clipped. --focus-ring-offset lifts the outline off the element's edge. */
    --ring:              0 0 0 3px color-mix(in srgb, var(--accent-blue) 30%, transparent);
    --focus-ring:        2.5px solid var(--accent-blue);
    --focus-ring-offset: 2px;

    /* A fully-rounded end cap — a pill, a chip, a round icon button. Any radius past half the box's shorter
       side clamps to the same shape, so this is one name for a family that was otherwise spelled two ways. */
    --radius-pill:       9999px;

    /* Shared accent art fill for a game's tile (catalog cards + history rows): a soft pastel wash — a bright
       top bloom over a gentle accent→deep diagonal, kept light and painterly rather than saturated-flat.
       Substituted lazily, so it reads the element's inline --game-accent / --game-accent-deep at use. */
    --accent-tile-bg:
        radial-gradient(120% 130% at 50% -10%, color-mix(in srgb, var(--game-accent) 40%, white), transparent 72%),
        linear-gradient(160deg, color-mix(in srgb, var(--game-accent) 82%, white) 0%,
                                var(--game-accent-deep) 118%);

    /* Text colour on a coloured/gradient accent surface (CTA buttons, badges, toasts on a tint). A literal
       rather than a palette name: an accent fill is the same brightness on either canvas, so the text on it is
       near-white in both — reading it off a surface token would flip it to navy the moment the shell went dark. */
    --text-on-accent:        #f7fbff;
    /* Dark text on a bright gold/ember accent (gold/ember badge labels). */
    --text-on-accent-deep:   #2a1a00;

    /* The pale stop of the gold gradient — brighter and bluer than the gold→white mix line reaches, so it
       holds its own literal rather than a color-mix approximation (a hero CTA surface). */
    --accent-gold-pale:      #ffe27a;
    /* Pale accent-blue used for focus rings and tints — bluer than accent-blue→white reaches, so it holds
       its own literal; composited to the wanted opacity via color-mix. */
    --accent-blue-soft:      #78beff;

    /* Accent-blue as *ink*, for a label sitting on a faint accent tint of the surface (a badge, a skill pill,
       a panel action). The accent alone is tuned to be a fill, so at small sizes on a tint of its own hue it
       is short of contrast; pulling it toward the canvas's text colour keeps the hue and earns the contrast,
       in the right direction on either canvas (restated on the table's scope below, with the rest of the
       family). */
    --accent-blue-ink:       color-mix(in srgb, var(--accent-blue) 55%, var(--text-primary));

    /* Accent-green as *ink*, for a label on a faint green tint (a genre chip) — pulled toward text-primary for contrast, mirroring --accent-blue-ink. */
    --accent-green-ink:      color-mix(in srgb, var(--accent-green) 55%, var(--text-primary));

    /* The rest of the ink family. An accent is tuned as a FILL, and the same value is not an ink: as small text
       the warm and pale accents read 7-12:1 on the shell's navy and 1.9-2.4:1 on the table's white cards. Each
       ink pulls its accent toward --text-primary. That mix is substituted where it is declared, so this block
       is the shell's half and the table restates the family on its own scope below — the pair is what lightens
       the hue on navy and darkens it on white. The mix share is per accent — the largest share that still
       clears 4.5:1 on the table's dimmest panel (--bg-secondary), which is the worst case for a dark ink.
       An accent that *is* the text reads its -ink token; the bare accent stays the fill, border, glow and ring
       every game already depends on. */
    --accent-gold-ink:       color-mix(in srgb, var(--accent-gold) 45%, var(--text-primary));
    --accent-amber-ink:      color-mix(in srgb, var(--accent-amber) 50%, var(--text-primary));
    --accent-warm-ink:       color-mix(in srgb, var(--accent-warm) 50%, var(--text-primary));
    --accent-red-ink:        color-mix(in srgb, var(--accent-red) 60%, var(--text-primary));
    /* The pale-blue family as ink. --text-accent-soft and --accent-blue-soft are both tuned for a dark surface
       (the tutorial's coaching card, a focus tint on navy) and stay that way; this is the twin for the same
       label on a light one. */
    --accent-blue-soft-ink:  color-mix(in srgb, var(--accent-blue-soft) 45%, var(--text-primary));

    /* Modal / dialog backdrop navy; composited to the wanted opacity via color-mix. */
    --scrim:                 rgb(18 36 66);

    /* Urgent / ember warning accent — the turn-clock-nearly-out treatment and the destructive action.
       A gradient family: pale → base → deep, plus a light tint for text-on-ember. */
    --warn-ember:            #e2603f;
    --warn-ember-light:      #ff8a63;
    --warn-ember-deep:       #b23a22;
    --warn-ember-pale:       #ffb27a;
    /* The ember as *ink*, for the warning sentence itself. The gradient family is tuned as a fill and a border,
       and neither its base nor its deep tone is legible as small type on both canvases — the deep tone reads
       2.52:1 on the shell's card. This is the twin, restated on the table scope with the rest of the family; at
       70% it clears the floor on each canvas's worst surface (4.91:1 on --bg-secondary, 5.10:1 on --bg-hover)
       while keeping the ember hue. */
    --warn-ember-ink:        color-mix(in srgb, var(--warn-ember) 70%, var(--text-primary));

    /* Status text on the shell's dark canvas (playing presence, your-move attention) — light enough to carry
       its hue against navy. Both are re-stated for the table below, where the same text sits on white. */
    --text-status-positive:  #5fd694;
    --text-status-attention: #ffc95c;

    /* Pink "favorite" heart accent. */
    --accent-fav:            #e0507a;

    /* The scrollbar thumb, on the shell's dark canvas: a pale wash, since a thumb is only visible when it
       contrasts with the surface under it. Restated for the table below, where the surface is white. */
    --scrollbar-thumb:       rgba(190, 215, 255, 0.20);
    --scrollbar-thumb-hover: rgba(190, 215, 255, 0.34);

    /* The invitation panel's paper. The one surface in the app that stands for a physical object — the thing
       a host holds up for someone else's camera — so it is warm stock rather than the app's blue-white card,
       with its own edge, ink, and a slightly brighter card tone for a control sitting on it. */
    --invite-paper:          #fbf6ec;
    --invite-paper-deep:     #f4ecdd;
    --invite-paper-edge:     #8a7a5f;
    --invite-paper-ink:      #6b5c45;
    --invite-paper-control:  #fffdf8;

    /* Pale blue-white text on a dark surface (turn-clock tick label, tutorial hint cue). */
    --text-accent-soft:      #d6e6ff;

    /* The tabletop mat — a very light blue-grey card stock. */
    --stage-mat:             #f2f5fa;

    /* Stage-canvas score-popup and banner valence fills (transient flourishes over the board's own art) —
       brighter than accent-green/red→white reaches, so each holds its own literal rather than a color-mix. */
    --stage-gain:            #8ef0a2;
    --stage-gain-pale:       #aef0bd;
    --stage-loss:            #ff8a80;
    --stage-loss-pale:       #ff9b94;

    /* Scales every duration in the animation framework (wwwroot/js/tat-stage.js). 1 = normal;
       0 collapses game-piece motion to settled scenes (reduced motion, and the E2E harness). */
    --tat-motion:        1;

    /* Tactile piece treatment (.tat-piece--interactive): the hover-lift + shadow-growth `lift` affordance,
       the :active press scale, and the transition that connects them. --tat-motion scales the duration
       (reduced motion / the E2E harness collapse it to 0).

       The duration carries its unit, unlike the stage vocabulary below, which JS reads as numbers. It is
       consumed by a CSS calc() — `calc(var(--tat-motion, 1) * var(--tat-tactile-ms))` — where a unitless
       number times a unitless number is not a <time>, which invalidates the whole `transition` shorthand
       and silently makes the lift and the press instant. */
    --tat-tactile-ms:    120ms;
    --tat-lift-px:       -5px;
    --tat-press-scale:   0.96;

    /* Stage motion vocabulary (wwwroot/js/tat-stage.js): durations in ms and named easings, read once per
       beat. --tat-motion scales them all (reduced motion / the E2E harness set it to 0); retune a value
       here without touching the engine. tat-stage.js carries the same values as its read fallbacks. */
    --tat-travel-ms:      420;
    --tat-stagger-ms:      50;
    --tat-emerge-ms:      380;
    --tat-vanish-ms:      380;
    --tat-popup-ms:       950;
    --tat-popup-hold-ms:   450;
    /* A chapter announcement (a round turning over) runs longer than a sting and holds longer after it, because
       it marks a structural boundary rather than punctuating a move — the table is meant to look up. */
    --tat-chapter-ms:    1400;
    --tat-chapter-hold-ms: 900;
    --tat-herald-ms:     1400;
    --tat-herald-hold-ms:  900;
    --tat-pause-ms:       650;
    --tat-flip-ms:        340;
    --tat-shake-ms:       420;
    --tat-ease-travel:     cubic-bezier(0.25, 0.9, 0.3, 1.05);
    /* The reflow slide's easing. Same curve family as --tat-ease-travel with the final control point pinned to
       1: control y values 0 ≤ 0.9 ≤ 1 ≤ 1 make it monotone non-decreasing, so a sliding piece never passes its
       slot and comes back. A travel may overshoot (it reads as momentum on a piece the player launched); a
       reflow may not, at any delta — see tat-stage.js slide(). */
    --tat-ease-slide:      cubic-bezier(0.25, 0.9, 0.3, 1);
    --tat-ease-settle:     cubic-bezier(0.4, 0.1, 0.3, 1.2);
    --tat-ease-emphasize:  cubic-bezier(0.5, 0, 0.9, 0.4);
    --tat-ease-jump:       cubic-bezier(0.3, 0.5, 0.5, 1);
    --tat-ease-fade:       ease-out;
    --tat-ease-popup:      ease-out;

    /* The same stage durations as <time>, for a stylesheet. The engine reads the bare -ms names with Number(),
       so those cannot carry a unit; a CSS calc() multiplying --tat-motion by a unitless number does not yield a
       <time> and silently invalidates the whole transition shorthand. A CSS consumer names the -t twin, the
       engine names the -ms one, and MotionTokensTests pins each pair equal so the two spellings cannot drift.
       Only the durations a stylesheet has reason to echo are twinned; a flourish's own timing stays engine-side. */
    --tat-travel-t:       420ms;
    --tat-stagger-t:       50ms;
    --tat-emerge-t:       380ms;
    --tat-flip-t:         340ms;
    --tat-shake-t:        420ms;

    /* The drag layer's own durations (wwwroot/js/tat-interaction.js reads these the way tat-stage.js reads the
       stage vocabulary). Unitless for the same reason, and scaled by --tat-motion at use. */
    --tat-snap-ms:        180;
    --tat-flyhome-ms:     260;

    /* UI motion scale — the room's chrome, the app shell, and a game's non-stage surfaces. The stage vocabulary
       above is the board's dialect; this is the same language for everything around it, so a sheet names a beat
       instead of inventing one. Six steps at roughly a 1.3 ratio, each named for the kind of change it times
       rather than for a number. A consumer writes
       `calc(var(--tat-motion, 1) * var(--tat-ui-base))`, so the one reduced-motion lever collapses chrome motion
       exactly as it collapses the board's, with no per-surface media block to remember. A duration off this
       scale earns a comment saying why. */
    --tat-ui-tap:          90ms;   /* a press acknowledgement */
    --tat-ui-quick:       120ms;   /* a hover lift, a colour swap (the same beat as --tat-tactile-ms) */
    --tat-ui-base:        150ms;   /* the default: a state change on a control or a panel */
    --tat-ui-settle:      200ms;   /* a box resizing, a panel disclosing */
    --tat-ui-enter:       260ms;   /* something arriving: a dialog, a toast, a card */
    --tat-ui-grand:       340ms;   /* a full-surface entrance, and the ceiling for anything on a tap path */

    /* Idle-loop periods — three states, because that is how many an idle loop has to say. An idle loop animates
       opacity only (docs/design/client-animation.md → the affordance rule) and carries its own reduced-motion
       escape or --tat-motion gate. */
    --tat-idle-urgent:   1000ms;   /* a deadline closing — the one loop meant to be noticed */
    --tat-idle-cue:      1600ms;   /* "you may act here", "this is live" */
    --tat-idle-breathe:  2400ms;   /* ambient presence: a shimmer, a glow, a skeleton */

    /* The two ends of the breath `tat-idle-pulse` swings between (the keyframe below). Named here so the
       recipe is one thing and only its range is a board's to choose. The defaults are a visible breath rather
       than a no-op: a subject that names the keyframe and forgets a token still pulses, where an unresolved
       var() would drop that stop from the keyframe and leave the loop measuring the element's own opacity. */
    --tat-pulse-from:    0.35;
    --tat-pulse-to:      1;

    /* The overshoot easing for a control that lands with weight. Lives here so the whole easing family shares
       one home and one prefix; --ease-spring (css/app-base.css) is the same curve under its original name. */
    --tat-ease-spring:     cubic-bezier(0.34, 1.56, 0.64, 1);

    /* The avatar rung scale. PlayerAvatar sizes itself from --avatar-size, which each host sets, and a surface
       naming a rung says what kind of place the disc is in rather than picking a number: a list row and a
       roster row one rung apart is a designed relationship, an arbitrary pair of nearby lengths is not. Five
       rungs cover every surface that seats a person, across the shell, the room and the social sheets. */
    --avatar-xs: 1.5rem;    /* stacked in a card, a voice bar, a seat chip */
    --avatar-sm: 1.9rem;    /* a dense roster row */
    --avatar-md: 2.25rem;   /* a standalone list row */
    --avatar-lg: 2.75rem;   /* a picker option */
    --avatar-xl: 4.5rem;    /* a profile hero */
}

/* The UA's own chrome — a native <select>'s popup list, a date picker, the default scrollbars where the custom
   thumb does not reach, a form control's focus ring — is painted from color-scheme, not from the palette. The
   shell is a dark room, so it declares dark and the popups come back navy instead of arriving as a white sheet
   over it. The table declares light alongside its palette below. Its own rule rather than a line in the token
   block above, which defines tokens and nothing else. */
:root {
    color-scheme: dark;
}

/* ---------------------------------------------------------------------------
   The table's half of the two canvases (docs/design/theming.md → Two canvases)
   The semantic palette is declared twice from configuration — the shell's on :root, the table's on the match
   surface — and these are the *static* tokens that have to follow it, because they encode lightness rather
   than hue: elevation reads as a cast shadow on a light page and as a lit top edge on a dark one, a status
   colour legible on navy is not legible on white, and a shadow tinted for a white page vanishes on navy.
   Everything else in the token block above is hue, geometry, or motion and is shared by both canvases, so it
   is stated once.

   The selector list mirrors ThemeColors.TableScope and must stay in step with it: the room element holds the
   board and the room chrome, the match surface wraps the dialogs and panels layered over it as its siblings,
   and the two reading cards are portalled to the document body by JS.

   These are scoped re-declarations of tokens the app stylesheet itself owns, never of a palette name — the
   palette stays configuration-owned and is injected after this sheet, so nothing here could win against it
   anyway. Anything on the table resolves them here; anything on the shell gets :root.
   --------------------------------------------------------------------------- */
.match-surface,
.game-room,
.rich-tip,
.rules-inspect-card {
    /* Soft, blue-tinted elevation for the tabletop's light surfaces — subtle, never harsh. */
    --shadow-sm:  0 1px 2px rgba(24, 58, 112, 0.06), 0 2px 6px rgba(24, 58, 112, 0.05);
    --shadow-md:  0 8px 22px -12px rgba(24, 58, 112, 0.22), 0 2px 8px rgba(24, 58, 112, 0.06);
    --shadow-lg:  0 26px 60px -28px rgba(24, 58, 112, 0.30), 0 8px 22px -14px rgba(24, 58, 112, 0.14);

    /* Dark status text drawn on the table's light surfaces (positive log scores, attention). */
    --text-status-positive:  #237842;
    --text-status-attention: #946400;

    /* A scrollbar thumb is a navy wash on the table's white panels. The shell's is the inverse (below). */
    --scrollbar-thumb:       rgba(24, 58, 112, 0.18);
    --scrollbar-thumb-hover: rgba(24, 58, 112, 0.32);
}

/* The table's ink, re-resolved rather than left to inherit — and a separate rule from the token block above,
   which defines tokens and nothing else. `color` is an inherited *value*, not a live reference: the document
   sets it once from --text-primary, and everything below inherits the colour that produced, so re-declaring
   the token further down changes what later rules read and nothing at all about what is inherited. Without
   this, every element on the table that never states a colour wears the shell's near-white ink, on white. */
.match-surface,
.game-room,
.rich-tip,
.rules-inspect-card {
    color: var(--text-primary);
    /* The table is a lit tabletop, so the UA's own chrome flips with it (see the :root declaration above). */
    color-scheme: light;
}

/* The ink family, restated for the table — the same substitution rule that forces the `color` rule above,
   applied to the tokens rather than to the property. A custom property's var()s are substituted at the element
   the property is DECLARED on, and what inherits from there is the finished value: an ink declared on :root
   resolves its var(--text-primary) against the shell's near-white once, and every element on the table inherits
   that resolved colour no matter what --text-primary says further down. Declaring the family here re-runs the
   mix against the table's own --text-primary (injected on this same selector list by ThemeColors), so a label
   drawn in an ink darkens on the table's white panels and lightens on the shell's navy. The shares are the
   :root ones — each was picked as the largest that still clears 4.5:1 on the table's dimmest panel, which is
   this canvas. Mirrors ThemeColors.TableScope; pinned by TwoCanvasInvariantTests. */
.match-surface,
.game-room,
.rich-tip,
.rules-inspect-card {
    --accent-blue-ink:       color-mix(in srgb, var(--accent-blue) 55%, var(--text-primary));
    --accent-green-ink:      color-mix(in srgb, var(--accent-green) 55%, var(--text-primary));
    --accent-gold-ink:       color-mix(in srgb, var(--accent-gold) 45%, var(--text-primary));
    --accent-amber-ink:      color-mix(in srgb, var(--accent-amber) 50%, var(--text-primary));
    --accent-warm-ink:       color-mix(in srgb, var(--accent-warm) 50%, var(--text-primary));
    --accent-red-ink:        color-mix(in srgb, var(--accent-red) 60%, var(--text-primary));
    --accent-blue-soft-ink:  color-mix(in srgb, var(--accent-blue-soft) 45%, var(--text-primary));
    --warn-ember-ink:        color-mix(in srgb, var(--warn-ember) 70%, var(--text-primary));
}

/* The match surface generates no box: it exists only to carry the table's canvas over the room and every
   overlay layered beside it, and a box here would become the containing block its fixed overlays resolve
   against. */
.match-surface { display: contents; }

@media (prefers-reduced-motion: reduce) {
    :root {
        --tat-motion: 0;
    }
}

/* ===========================================================================
   The idle pulse (docs/design/client-animation.md → The interaction layer)
   The one recipe behind "you may act here" / "this is live": a two-stop breath in OPACITY, over one of the
   --tat-idle-* periods above. Opacity is the whole of it because geometry is what an idle loop must not carry —
   a transformed box rasterizes on whole device pixels in Gecko, so a scale or a bob over a hint's little travel
   renders as three or four steps rather than a breath, and a transform baked into a keyed node corrupts the
   rect the stage's FLIP snapshot() reads back.

   A subject adopts it by naming the keyframe and stating its own range:

       .my-hint { --tat-pulse-from: 0.38; --tat-pulse-to: 0.90; }
       @media (prefers-reduced-motion: no-preference) {
           .my-hint { animation: tat-idle-pulse var(--tat-idle-cue) ease-in-out infinite; }
       }

   A keyframe rather than a class, because half the subjects in the catalog are ::after pseudo-elements and a
   class cannot reach one. Reduced motion therefore stays the subject's own: cancelling is right where the
   resting opacity already reads, and a subject whose static state is invisible has to SUBSTITUTE a steady
   cue instead — one shared `animation: none` would delete that board's affordance outright.
   =========================================================================== */
@keyframes tat-idle-pulse {
    0%, 100% { opacity: var(--tat-pulse-from); }
    50%      { opacity: var(--tat-pulse-to); }
}

/* ===========================================================================
   Studio freeze (docs/design/design-studio.md → The studio route)
   The studio's ?t param freezes board motion at a reproducible offset for review and capture. The board's
   FLIP travels and flourishes are WAAPI (tat-stage.js), which CSS play-state cannot reach, so the JS engine
   pauses and seeks each Animation directly; this class is the CSS half — it pauses every @keyframes animation
   the WAAPI half does not own (app and per-game pulse/shake/confetti/float animations). It is toggled on
   documentElement by tat-stage.js applyStudioFreeze, the same call that sets the engine freeze offset and
   sweeps WAAPI animations in flight. Only animation-play-state is touched — animation-delay is left alone, so
   existing per-element staggers are not clobbered (the negative-delay trick would). Under prefers-reduced-motion
   the board is already settled (--tat-motion: 0), so the studio renders both motion-on and motion-off cleanly.
   =========================================================================== */
.studio-frozen *,
.studio-frozen *::before,
.studio-frozen *::after {
    animation-play-state: paused;
}

/* ===========================================================================
   Studio interaction states (docs/design/design-studio.md → The coverage model)
   The studio's ?interaction param renders a board in a fixed pointer state so a design can be reviewed outside
   the stray hover a live cursor leaves behind. The attribute rides the studio pane wrapper; the selectors apply
   the shared tactile layer every interactive piece shares (.tat-piece--interactive), not per-game bespoke hover
   styling (a follow-up). hover mirrors the lift affordance (--tat-lift-px), pressed mirrors the press scale
   (--tat-press-scale), and touch disables hover-derived treatment (touch has no hover). The focus state uses a
   real element.focus() from JS (studioFocusInteractive) so :focus-visible styling renders genuinely.
   =========================================================================== */
[data-interaction="hover"] .tat-piece--interactive:not(:disabled) {
    transform: translateY(var(--tat-lift-px)) rotate(var(--rot, 0deg));
    box-shadow: var(--shadow-md);
    z-index: 1;
}
[data-interaction="pressed"] .tat-piece--interactive:not(:disabled) {
    transform: translateY(calc(var(--tat-lift-px) / 2)) rotate(var(--rot, 0deg)) scale(var(--tat-press-scale));
}
[data-interaction="touch"] .tat-piece--interactive:hover {
    transform: none;
    box-shadow: none;
    z-index: auto;
}

/* ===========================================================================
   Studio route chrome (docs/design/design-studio.md → The studio route)
   The /studio route's shell: a sidebar of pickers (game, scenario, viewport, interaction, freeze) beside a
   width-constrained pane hosting the real <MatchView/>. The pane width IS the viewport the board's container
   queries respond to — a single resizable surface covers every supported size from one boot. Shell styling
   only (app.css is the shared surface; a game's own CSS lives in its namespaced sheet).
   =========================================================================== */
.studio-shell {
    display: flex;
    align-items: flex-start;
    gap: 1.5rem;
    padding: 1rem;
    background: var(--bg-primary);
    min-height: 100vh;
}

.studio-chrome {
    flex-shrink: 0;
    width: 13rem;
    display: flex;
    flex-direction: column;
    gap: 1.25rem;
    position: sticky;
    top: 1rem;
}

.studio-chrome__group {
    display: flex;
    flex-direction: column;
    gap: 0.4rem;
}

.studio-chrome__label {
    font-size: 0.7rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: var(--text-muted);
}

.studio-chrome__value {
    text-transform: none;
    font-weight: 400;
    letter-spacing: 0;
    color: var(--text-secondary);
}

.studio-chrome__pills {
    display: flex;
    flex-wrap: wrap;
    gap: 0.3rem;
}

.studio-chrome__row {
    display: flex;
    align-items: center;
    gap: 0.4rem;
}

.studio-pill {
    font: inherit;
    font-size: 0.8rem;
    line-height: 1;
    padding: 0.35rem 0.6rem;
    border: 1px solid var(--border-color);
    border-radius: 0.4rem;
    background: var(--bg-card);
    color: var(--text-secondary);
    cursor: pointer;
    transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease;
}

.studio-pill:hover {
    background: var(--bg-hover);
    color: var(--text-primary);
}

.studio-pill--active {
    background: color-mix(in srgb, var(--accent-blue) 14%, var(--bg-card));
    border-color: var(--accent-blue);
    color: var(--accent-blue-ink);
    font-weight: 600;
}

/* Compounded with the primitive's class so the tighter size wins over the base recipe: chrome.css loads after
   every surface sheet, so an adopter that restates a property the primitive also sets must out-specify it. */
.studio-input.app-input { width: 5rem; font-size: 0.8rem; padding: 0.3rem 0.4rem; border-radius: 0.4rem; }

.studio-pane {
    flex-shrink: 0;
    border: 2px dashed var(--border-color);
    border-radius: 0.5rem;
    /* The pane is a full-viewport scroll surface by name (see .main-layout__content): the definite height is what
       stands for the emulated device's screen — the room's fixed layer fills it, and the sweep's utilization
       divides by it. It carries no padding: the pane IS the device's screen, and a real screen has no inset
       inside its own edge — the app's safe-area allowance belongs to the app's surfaces, not to a frame drawn
       around one. Overflow is both axes here, unlike those surfaces: a board reviewed at a width it does not fit
       should be scrollable into view, not clipped out of the frame. */
    height: 100dvh;
    overflow: auto;
    overscroll-behavior: contain;
    -webkit-overflow-scrolling: touch;
    /* Paint containment scopes the real room's fixed full-viewport shell to the pane. The room's position:fixed
       layer (and its fixed overlays — the menu scrim, the endgame banner) resolves against the pane rather than
       the viewport, so the room fills the pane and clips to it instead of overlaying the picker chrome beside
       it. The room's own internal layout is left untouched. Inline-size containment (it accumulates with paint)
       makes the pane a query container for whatever MatchView renders OUTSIDE .game-room — the match dialogs,
       the game log, the rules panel, the siblings MatchView puts beside the room. It is not what the room's own
       gutter resolves against: --room-pad-x is a clamp on 3cqw that is CONSUMED on .game-room__controls and
       .game-room__body, both descendants of .game-room, so its cqw answers to the room here exactly as it does
       in play. The pane's inline size is its explicit style width, so size containment has nothing to solve.
       The name is what a query about the pane must state; nothing inside the room is written about it. */
    contain: paint;
    container-type: inline-size;
    container-name: studio-pane;
    background: var(--bg-secondary);
    /* The min height keeps the board visible even at very narrow widths where the room chrome compresses. */
    min-height: 80vh;
}

.studio-error {
    padding: 2rem;
    color: var(--text-primary);
    font-size: 0.95rem;
}

.studio-measure-overlay {
    display: flex;
    flex-direction: column;
    gap: 0.25rem;
}

.studio-measure-row {
    display: grid;
    grid-template-columns: 1fr auto;
    grid-template-areas: "name flag" "stat stat";
    align-items: center;
    gap: 0.1rem 0.4rem;
    padding: 0.25rem 0.4rem;
    border: 1px solid var(--border-color);
    border-radius: 0.3rem;
    background: var(--bg-card);
}

.studio-measure-row__name {
    grid-area: name;
    font-size: 0.72rem;
    line-height: 1.15;
    color: var(--text-secondary);
}

.studio-measure-row__flag {
    grid-area: flag;
    font-size: 0.78rem;
    font-weight: 600;
    color: var(--text-status-positive, #237842);
}

.studio-measure-row__stat {
    grid-area: stat;
    font-size: 0.68rem;
    font-variant-numeric: tabular-nums;
    color: var(--text-muted);
}

.studio-measure-row--fail {
    border-color: var(--accent-red, #e5484d);
    background: color-mix(in srgb, var(--accent-red, #e5484d) 10%, var(--bg-card));
}

.studio-measure-row--fail .studio-measure-row__flag {
    color: var(--accent-red-ink);
}

/* The third outcome: the assertion had no subject on this frame, so it neither held nor broke. Muted rather
   than alarming, and listed after the fail rules so a row that is BOTH — a gating assertion that fails by
   having measured nothing — keeps the fail tint on its border and takes the muted glyph. */
.studio-measure-row--unmeasured {
    border-style: dashed;
}

.studio-measure-row--unmeasured .studio-measure-row__flag {
    color: var(--text-muted);
}

/* ---------------------------------------------------------------------------
   Keyboard-focus baseline
   One consistent, on-brand focus ring for every interactive element across the app shell and all games,
   shown only for keyboard navigation (:focus-visible), never on a mouse click. Wrapped in :where() so it
   carries zero specificity: any component that wants its own focus treatment overrides this with a plain
   class selector and never has to fight it. Uses outline (not box-shadow) so the ring is never clipped by
   an ancestor's overflow:hidden, which game boards routinely set.
   --------------------------------------------------------------------------- */
:where(a, button, input, select, textarea, summary, [role="button"], [tabindex]:not([tabindex="-1"])):focus-visible {
    outline: var(--focus-ring);
    outline-offset: var(--focus-ring-offset);
}

/* The other half of that rule. tabindex="-1" marks a *focus target*, not a control: the router's
   FocusOnNavigate parks focus on each page's <h1> after every navigation so a screen reader announces where
   it landed. Nobody navigated there by keyboard, so the ring says nothing — but the baseline above only
   declines to draw one, and the browser's own ring is still painted. Stand it down, so a page never opens
   with a box drawn around its heading. */
:where([tabindex="-1"]):focus,
:where([tabindex="-1"]):focus-visible {
    outline: none;
}

/* ===========================================================================
   Tactile piece utility (.tat-piece--interactive)
   The interaction layer's `lift` affordance: a piece raises and grows its shadow on hover, presses down
   on :active, and the keyboard focus ring shows (from the :focus-visible baseline above). It is a CSS
   affordance, not a stage-engine gesture — no beat, no FLIP travel, instant and local, never in the
   timeline (docs/design/client-animation.md → The interaction layer). A game adopts it by adding the class
   to a playable piece element.

   FLIP-snapshot safety (the load-bearing constraint): the stage's snapshot() (wwwroot/js/tat-stage.js)
   records getBoundingClientRect() — which includes any live transform — of every [data-piece-id] and
   [data-container] element. A hover translateY baked into such a keyed node at snapshot time yields a
   spurious start-jitter on that piece's travel. Apply this class ONLY to the interactive piece element
   that carries neither [data-piece-id] nor [data-container] (a <button> child of a keyed container is the
   safe shape) — never to the keyed or decorative node itself.

   The transform re-declares each game's tilt (rotate(var(--rot, 0deg)), the cross-game convention) so the
   lift composes with it; the :active state (specificity 0,3,0) wins over the global button:active baseline
   (0,2,1 in app-base.css) and re-declares the tilt too, so a tilted piece keeps its tilt while it presses.
   Every duration is calc(var(--tat-motion, 1) * ...), so reduced motion / the E2E harness collapse it.
   =========================================================================== */
.tat-piece--interactive {
    transition:
        transform calc(var(--tat-motion, 1) * var(--tat-tactile-ms)) var(--ease-spring, ease),
        box-shadow calc(var(--tat-motion, 1) * var(--tat-tactile-ms)) ease,
        filter calc(var(--tat-motion, 1) * var(--tat-tactile-ms)) ease;
}
.tat-piece--interactive:not(:disabled):hover {
    transform: translateY(var(--tat-lift-px)) rotate(var(--rot, 0deg));
    box-shadow: var(--shadow-md);
    z-index: 1;
}
.tat-piece--interactive:not(:disabled):active {
    transform: translateY(calc(var(--tat-lift-px) / 2)) rotate(var(--rot, 0deg)) scale(var(--tat-press-scale));
}

/* ===========================================================================
   Drag layer (wwwroot/js/tat-interaction.js) — the interaction layer's drag affordances
   The unified input layer's drag CSS: the grab cursor + touch-action on the drag sources (the stage root
   carries .tat-draggable for a game whose moves pair a Source with a Target, and the marking pass paints
   .tat-tappable on every entry-point source), the floating preview clone, and the drop-hover highlight.
   All FLIP-safe by construction (the stage's snapshot() records getBoundingClientRect() of every
   [data-piece-id]/[data-container] node, so a transform baked into such a keyed node would corrupt its
   travel): the preview is a non-keyed overlay clone (transform-only); the highlights are outline only —
   never transform/border/margin on a keyed target. touch-action: none lets a touch drag begin instead of a
   scroll/pan (touch parity).
   =========================================================================== */
.tat-draggable .tat-tappable {
    cursor: grab;
    touch-action: none;          /* a touch drag is not co-opted as a scroll/pan (touch parity) */
}
.tat-draggable .tat-tappable:active {
    cursor: grabbing;
}

/* The floating preview: a non-keyed wrapper in the stage's .tat-overlay holding a clone of every source tile
   the drop will draft (the whole same-colour cluster, not just the tile grabbed), positioned by transform (set
   inline by tat-interaction.js) and lifted by a slight scale. pointer-events: none so hit-testing skips it; the lift
   is a drop-shadow + scale (transform), never on a keyed node. Tracked with no CSS transition so it follows the
   pointer at rAF rate; the release settle, shake, and fly-home are WAAPI (durations scaled by --tat-motion).
   will-change promotes it for the whole of its short life — it exists only while a carry is in flight, and every
   frame of that life rewrites its transform over a blurred shadow the compositor would otherwise re-rasterize. */
.tat-drag-preview {
    position: absolute;
    top: 0;
    left: 0;
    margin: 0;
    display: inline-flex;
    align-items: center;
    gap: 0.15rem;
    pointer-events: none;
    transform-origin: center center;
    will-change: transform;
    filter: drop-shadow(0 8px 10px rgba(24, 58, 112, 0.35));
}
/* Each cloned tile in the cluster: an inert, non-keyed copy of a source tile. Its own game classes carry the
   size and colour; reset margins (the wrapper's gap spaces them) and force full opacity so a cloned disabled
   <button> is never dimmed by the user-agent's disabled styling. */
.tat-drag-preview__tile {
    margin: 0;
    opacity: 1;
    flex: none;
}

/* Drop-hover + keyboard-cursor highlights: outline only (FLIP-safe — never transform/border/margin on a keyed
   [data-container] target). The hover rides the gold hero accent; the keyboard cursor's ring lives in
   tat-interaction.css, which owns the whole of the marker's paint. */
.tat-drop--hover {
    outline: 2px solid var(--accent-gold);
    outline-offset: 2px;
    border-radius: 4px;
}

/* ---------------------------------------------------------------------------
   Full-screen, app-like behavior (phone, tablet and desktop browser alike — see
   docs/design/overview.md → Devices and orientation)
   - The page itself never scrolls or zooms.
   - No pull-to-refresh / rubber-band overscroll.
   - No double-tap zoom, no long-press selection menus.
   --------------------------------------------------------------------------- */
html,
body {
    height: 100%;
    margin: 0;
    /* The document's own ink. Without it the initial value — black — is what every element that never states a
       colour inherits, so the palette would only reach the text that happens to name a token. It has to be
       stated: with two canvases of opposite lightness, no single unstated default is right on both. Stated
       once here, it follows whichever canvas an element is on — the room re-declares --text-primary for the
       table, so text inside it inherits the table's ink and text outside it the shell's. */
    color: var(--text-primary);
    overflow: hidden;                 /* the page (document) never scrolls */
    overscroll-behavior: none;        /* no pull-to-refresh / rubber-band */
    touch-action: manipulation;       /* no double-tap-to-zoom */
    -webkit-user-select: none;
    user-select: none;                /* no long-press text selection menus */
    -webkit-touch-callout: none;      /* no iOS long-press Copy/Share/Save popup */
    -webkit-tap-highlight-color: transparent;
}

/* Block image drag / drag-to-save. */
img {
    -webkit-user-drag: none;
}

/* Lock the body in place so mobile Safari can't drag the whole page around. */
body {
    position: fixed;
    inset: 0;
}

/* Text entry keeps a caret and a selectable value even though selection is off document-wide.
   The value is `text`, never `auto`: `auto` is the one ambiguous keyword — WebKit reads it as "the
   UA default" and restores selection, while Blink resolves it back to the ancestor's `none`. Listing
   `button` here would therefore hand every game piece back its text selection on iOS (tiles, cards,
   and the chrome buttons are all `<button>`, and user-select inherits into their labels), which is
   exactly the long-press highlight this section exists to prevent. */
input,
textarea,
[contenteditable] {
    -webkit-user-select: text;
    user-select: text;
}

/* Reading surfaces opt back in: rules, tooltips, the log, tutorial copy, a game's blurb — prose a
   player may legitimately want to select or quote. `.selectable` is the shared hook for any surface
   that later grows prose; the named classes are the ones that carry it. Long-press callout
   comes back with it, since on these surfaces the iOS edit menu is the point. */
.selectable,
.rules-body,
.rules-inspect-card,
.tip__body,
.game-log__scroll,
.tut-card__text,
.gd__blurb {
    -webkit-user-select: text;
    user-select: text;
    -webkit-touch-callout: default;
}

/* A control nested inside a prose surface is still a control: it takes back the document default
   rather than inheriting the opt-in, so a button label can never become selectable text. (The panels'
   own chrome — the log's jump button, the rules tabs and close — sits outside these containers and is
   unaffected; this guards a control placed within the prose itself.) */
.selectable button,
.rules-body button,
.game-log__scroll button {
    -webkit-user-select: none;
    user-select: none;
    -webkit-touch-callout: none;
}

/* ---------------------------------------------------------------------------
   Shared element hooks
   Two statements a bare element makes about itself, in the same family as `.selectable` above: a class the
   markup adds alongside its own, rather than a component. They live here because they belong to no surface —
   a surface sheet that owned one would be the sheet every other surface had to reach into.
   --------------------------------------------------------------------------- */

/* Present to assistive tech and as a focus/heading landmark, but visually removed. Also the resting state of
   an element revealed conditionally (an invite link that appears only when the clipboard is blocked): the
   revealing class un-hides it by restating position/size, and wins by loading later. */
.sr-only {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    /* Both clipping forms: clip-path is the current one, clip the legacy fallback older engines still honour. */
    clip-path: inset(50%);
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
}

/* An image that fills its box and crops rather than distorts — a game's banner art, a group's cover, an
   avatar's photo. `display: block` kills the inline baseline gap that would otherwise show as a hairline at
   the bottom of a tightly-fitted frame. The `--fill` modifier is for the common case of art painted behind
   its own content: the frame is the positioned ancestor, and the image is laid over it edge to edge. */
.cover-img {
    width: 100%;
    height: 100%;
    object-fit: cover;
    display: block;
}

.cover-img--fill {
    position: absolute;
    inset: 0;
}

/* ---------------------------------------------------------------------------
   Full-width fluid layout
   The shell fills the whole viewport width instead of sitting in a narrow column, at every width and in
   either orientation: the app outside a match is owed both (docs/design/overview.md → Devices and
   orientation). The inline padding grows with the viewport, so the same rule gives a phone edge-to-edge
   content with a thumb's margin and a desktop a comfortable one without a hard cap. The top padding is the
   gap under the floating top bar; the bottom one respects the home-indicator safe area.

   It is also a scroll surface, and only when a view is taller than the screen — vertical only. Everything else
   stays put, and the transparent top bar floats over the top of this area.

   That posture belongs to the surfaces that ask for it BY NAME — this one and .studio-pane — never to `main`
   as an element type. Most of the app's <main> landmarks sit inside a layout that already sizes them
   (.game-room__stage in the room's flex column, .table-page__main and .group-clubhouse__main in the hub's
   columns), and there a definite height is actively wrong: in a flex ROW it is the item's CROSS size, which
   outranks align-items: stretch (Flexbox §9.4), so the landmark silently stands taller than its parent
   instead of erroring — the room stage ran past the bottom of the viewport that way (#887). A landmark that
   wants this posture joins the list; one that doesn't gets it by writing nothing. The hub shell's own scroll
   surface is .app-main (css/shell.css), a <div> inside the fixed .app-shell with its own phone-width posture. */
.main-layout__content {
    width: 100%;
    padding-inline: clamp(1rem, 3vw, 3rem);
    padding-top: 1.25rem;
    height: 100dvh;
    overflow-y: auto;
    /* Hide horizontal overflow so an over-wide child never turns this surface into a horizontal page scroller
       (the visible↔auto coupling would otherwise compute overflow-x to auto). hidden, not clip: clip can't stay
       clip beside a scrolling y axis (it resolves to hidden anyway) and is dropped outright by older engines,
       which would reinstate the bug. Mirrors the hub's .app-main. */
    overflow-x: hidden;
    overscroll-behavior: contain;
    -webkit-overflow-scrolling: touch;
    /* Respect the home-indicator / bottom safe area. */
    padding-bottom: calc(1.25rem + env(safe-area-inset-bottom));
}

