/* Spielgefühl — Design System */

/* Z-index — a conventional layer stack whose content tier is primacy-ordered.
   Four zones, back → front. Band-z goes on a component's CONTAINER; positional
   children (hand index, trick-slot play-order) keep their own scoped z inside it.
   Pick from a zone; don't invent values outside this scheme. The content-tier
   order is the paint side of docs/components/PRIMACY.md (back = most secondary).

   — Background —
   0     player-zone auras + active edge (.hand/.opponent ::before/::after)
   1     ink-ripple layer (.ink-ripple-layer) — the sea's event ripples; rings
         pass UNDER all content (the sea is what the play sits on)

   — Content tier (primacy-ordered, back → front) —
   10    trick piles (.trick-piles)                  PRIMACY #9
   15    prev-trick mini (.prev-trick-mini)          #9 — designed overlap on its own pile
   20    skat pile host (.skat-pile)                 #8 (Ramsch skat 35+: it carries game-type)
   30    game-type watermark (.trick-type-indicator) #7 (container-bound; see spec § divergences)
   40    opponent hands (.opponent)                  #6
   60    played cards / trick area (.trick-area)     #2 (slots keep child play-order 1..3)
   70    user hand cards (.hand)                     #1
   75    score-story result reveal (.score-story-board-layer) — over all board content;
         since slice 4 the result strip lives INSIDE its panel (a panel row), so the
         controls ride this layer (the panel re-enables pointer-events)

   — Interactive controls (generic, above content) —
   80    prompts / controls (.prompt-zone), Menu (.menu-button), the standalone
         result strip (#skat-app > .result-strip — the no-panel rounds)
   82    hint button (.hint-button)

   — Transient / overlay (always on top, back → front) —
   100   exchange / deal in-flight cards, bid numbers
   110   settle ghosts (mini-settle, result-piles-settle)
   120   played-card flight + trick-resolve ghost
   140   hint reason text (.hint-reason)
   150   lifted UI — score-float, hint-float button
   160   drag grip (held card)
   1000  modal overlays — confirm dialog, result overlay, app-loader, dispatch
   9999  debug overlays (dev-only)

   Scoped child z (slot play-order, hand index, .position-indicator in its seat,
   role icons, intra-component pseudo-elements) is NOT on this ladder — it orders
   siblings inside one container's own stacking context, never competing for a band.
*/

/* === Fonts === */

@font-face {
  font-family: "Satoshi";
  src: url("/fonts/Satoshi-Regular.woff2") format("woff2");
  font-weight: 400;
  font-display: swap;
  font-style: normal;
}

@font-face {
  font-family: "Satoshi";
  src: url("/fonts/Satoshi-Medium.woff2") format("woff2");
  font-weight: 500;
  font-display: swap;
  font-style: normal;
}

@font-face {
  font-family: "Satoshi";
  src: url("/fonts/Satoshi-Bold.woff2") format("woff2");
  font-weight: 700;
  font-display: swap;
  font-style: normal;
}

/* === Color System === */

:root {
  /* Tell the browser the page is dark so native form controls (select popups,
     scrollbars, checkboxes) render with the UA's dark palette. Without this,
     dark GTK/OS themes can paint <option> popups dark-on-dark. */
  color-scheme: dark;

  /* Background — Midnight Petrol (Goldilocks Teal-Navy) */
  --bg-inner: #164e63;
  --bg-outer: #082f49;
  --bg-deep: #082d46; /* the ink sea's outer field — a whisper below --bg-outer */
  --bg-abyss: #082c44; /* the ink sea's edge — deepest stage color, also the pre-paint/overscroll color */
  --bg-surface: rgba(255, 255, 255, 0.05);
  --bg-elevated: rgba(255, 255, 255, 0.08);

  /* The ink sea — the stage as a material (DESIGN Principle 1, "the floor is ink").
     One composition, consumed by body and .app-loader (which must paint identically
     so the boot dissolve is seamless); both also carry the matching background-size
     list, because the two SVG data-URI layers are non-tiling and stretch to the
     viewport. All layers share the golden pole (50% 38.2%) and are fully static.
     Round 2 ("keep the old color", 2026-07-06): the vibrant pre-ink petrol holds
     across most of the field — the liquid read comes from atmosphere and light,
     not corner darkness (edge falloff and vignette are a whisper of round 1's,
     which Andre judged too dark and grim). Composition tuned in /background-lab
     ("Mist & ripples" preset). Top-down:
     1. peripheral mist — pale-petrol fog banks (fractal noise, alpha contrast-
        expanded so banks form with gaps, radially masked to gather toward the
        edges and thin to nothing over the play; horizontal stretch reads as sea
        fog lying in layers)
     2. specular core — the light's tight reflection on the surface
     3. broad sheen — the light lying across the gloss
     4. frozen ripples — six rings radiating from the pole (spacing ~×√φ), drawn
        as turbulence-displaced blurred SVG strokes: hand-inked and irregular,
        not a sterile target
     5. vignette — a last whisper of depth in the very corners
     6. base — petrol pole to deep petrol edge, golden-ratio stops */
  --ink-sea:
    url("data:image/svg+xml,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width%3D'900'%20height%3D'560'%3E%3Cdefs%3E%3CradialGradient%20id%3D'm'%20gradientUnits%3D'userSpaceOnUse'%20cx%3D'450'%20cy%3D'214'%20r%3D'568'%3E%3Cstop%20offset%3D'38%25'%20stop-color%3D'black'%2F%3E%3Cstop%20offset%3D'100%25'%20stop-color%3D'white'%2F%3E%3C%2FradialGradient%3E%3Cmask%20id%3D'k'%3E%3Crect%20width%3D'100%25'%20height%3D'100%25'%20fill%3D'url(%23m)'%2F%3E%3C%2Fmask%3E%3C%2Fdefs%3E%3Cfilter%20id%3D'n'%20x%3D'0'%20y%3D'0'%20width%3D'100%25'%20height%3D'100%25'%20color-interpolation-filters%3D'sRGB'%3E%3CfeTurbulence%20type%3D'fractalNoise'%20baseFrequency%3D'0.0040%200.0100'%20numOctaves%3D'2'%20seed%3D'9'%20stitchTiles%3D'stitch'%2F%3E%3CfeColorMatrix%20type%3D'saturate'%20values%3D'0'%2F%3E%3CfeColorMatrix%20type%3D'matrix'%20values%3D'0%200%200%200%200.62%200%200%200%200%200.82%200%200%200%200%200.90%201%200%200%200%200'%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D'linear'%20slope%3D'2.2'%20intercept%3D'-0.55'%2F%3E%3C%2FfeComponentTransfer%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D'linear'%20slope%3D'0.55'%20intercept%3D'0'%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Crect%20width%3D'100%25'%20height%3D'100%25'%20filter%3D'url(%23n)'%20mask%3D'url(%23k)'%2F%3E%3C%2Fsvg%3E"),
    radial-gradient(
      ellipse 29.9% 11.5% at 50% 38.2%,
      rgba(196, 236, 248, 0.07) 0%,
      rgba(196, 236, 248, 0) 70%
    ),
    radial-gradient(
      ellipse 73.6% 28.17% at 50% 38.2%,
      rgba(148, 214, 233, 0.1) 0%,
      rgba(148, 214, 233, 0.042) 45%,
      rgba(148, 214, 233, 0) 72%
    ),
    url("data:image/svg+xml,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width%3D'1200'%20height%3D'750'%3E%3Cfilter%20id%3D'w'%20x%3D'-20%25'%20y%3D'-20%25'%20width%3D'140%25'%20height%3D'140%25'%20color-interpolation-filters%3D'sRGB'%3E%3CfeTurbulence%20type%3D'fractalNoise'%20baseFrequency%3D'0.011'%20numOctaves%3D'2'%20seed%3D'7'%20result%3D't'%2F%3E%3CfeDisplacementMap%20in%3D'SourceGraphic'%20in2%3D't'%20scale%3D'42'%20xChannelSelector%3D'R'%20yChannelSelector%3D'G'%2F%3E%3CfeGaussianBlur%20stdDeviation%3D'7.0'%2F%3E%3C%2Ffilter%3E%3Cg%20filter%3D'url(%23w)'%3E%3Ccircle%20cx%3D'600'%20cy%3D'286.5'%20r%3D'205'%20fill%3D'none'%20stroke%3D'rgba(255%2C255%2C255%2C0.0105)'%20stroke-width%3D'9'%2F%3E%3Ccircle%20cx%3D'600'%20cy%3D'286.5'%20r%3D'262'%20fill%3D'none'%20stroke%3D'rgba(255%2C255%2C255%2C0.0165)'%20stroke-width%3D'9'%2F%3E%3Ccircle%20cx%3D'600'%20cy%3D'286.5'%20r%3D'334'%20fill%3D'none'%20stroke%3D'rgba(255%2C255%2C255%2C0.0195)'%20stroke-width%3D'9'%2F%3E%3Ccircle%20cx%3D'600'%20cy%3D'286.5'%20r%3D'424'%20fill%3D'none'%20stroke%3D'rgba(255%2C255%2C255%2C0.0165)'%20stroke-width%3D'9'%2F%3E%3Ccircle%20cx%3D'600'%20cy%3D'286.5'%20r%3D'538'%20fill%3D'none'%20stroke%3D'rgba(255%2C255%2C255%2C0.0100)'%20stroke-width%3D'9'%2F%3E%3Ccircle%20cx%3D'600'%20cy%3D'286.5'%20r%3D'682'%20fill%3D'none'%20stroke%3D'rgba(255%2C255%2C255%2C0.0055)'%20stroke-width%3D'9'%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E"),
    radial-gradient(
      ellipse 130% 130% at 50% 38.2%,
      rgba(1, 8, 14, 0) 62%,
      rgba(1, 8, 14, 0.06) 100%
    ),
    radial-gradient(
      circle at 50% 38.2%,
      var(--bg-inner) 0%,
      #0e3e58 38.2%,
      var(--bg-outer) 61.8%,
      var(--bg-deep) 85%,
      var(--bg-abyss) 100%
    );

  /* The size list matching --ink-sea layer-for-layer: the two SVG layers (mist,
     ripples) stretch to the painting area (the viewport — body's attachment is
     fixed, .app-loader is an inset-0 fixed box), gradients keep their own size. */
  --ink-sea-size: 100% 100%, auto, auto, 100% 100%, auto, auto;

  /* Suit colors live in JS: constants.js (TRUMP_COLOR) and utils.js (suitColor()).
       They're used in SVG card rendering and JS-driven inline styles, not CSS rules. */

  /* Text */
  --text-primary: #f1f5f9;
  --text-secondary: #94a3b8;
  --text-accent: #ffffff;
  --text-error: #fecaca;

  /* UI */
  --ui-warning: #d97706; /* Matches Deep Amber suit — used in active highlights, hint glows, game-start */
  --score-positive: #2bd47e;
  --score-negative: #ff5a78; /* lightened from #FF3B5C for AA on petrol (4.62:1) */

  /* Chart-weight ink — the break-down chart vocabulary's round-type palette (chart-vocabulary
     design, 2026-07-09): suit hues + Royal Purple lifted in lightness until they pass CVD-
     separation (adjacent ΔE ≥ 12) and ≥3:1 mark contrast on deep petrol; Null/Ramsch are
     deliberate neutrals (identity never rides color alone here — every mark also carries an
     icon). Consumed via JS inline styles (js/charts/charts.js), not a skat.css rule — see
     docs/design/COLORS.md § Chart weight. Diverging marks use --score-positive/-negative instead. */
  --chart-clubs: #0284c7;
  --chart-spades: #059669;
  --chart-hearts: #f43f5e;
  --chart-diamonds: #d97706;
  --chart-grand: #a855f7;
  --chart-null: #94a3b8;
  --chart-ramsch: #64748b;

  --bg-toggle: rgba(255, 255, 255, 0.15);

  /* Cards — sizes set by layout engine via JS (--card-height, --opp-card-height) */
  --card-ratio: 0.714; /* 2.5:3.5 — MUST mirror SOLVER_DEFAULTS.cardRatio (layout-solver.js); CSS sizes the .card div, the engine sizes the fan/slots, they desync silently if only one changes */
  /* Card background (#E2E8F0) is hardcoded in JS (cards.js, icons/) for SVG rendering */
  --card-radius: var(--radius-md);
  --card-shadow: none; /* Glass borders used instead */
  --card-border: 1px solid rgba(255, 255, 255, 0.25);

  /* Border radius */
  --radius-sm: 4px;
  --radius-md: 8px;
  --radius-lg: 12px;
  --radius-full: 50%;

  /* Spacing */
  --space-xs: 4px;
  --space-sm: 8px;
  --space-md: 12px;
  --space-lg: 16px;
  --space-xl: 24px;
  --space-2xl: 32px;

  /* Typography */
  --fs-xs: 0.75rem;
  --fs-sm: 0.85rem;
  --fs-base: 1rem;
  --fs-md: 1.1rem;
  --fs-lg: 1.2rem;
  --fs-xl: 1.6rem;
  --fs-2xl: 2.2rem;
  --fs-display: 3rem;

  /* Motion — transitions */
  --transition-snap: 50ms ease-out;
  --transition-fast: 150ms ease-out;
  --transition-normal: 200ms ease-out;

  /* Player zone — neutral (structural, always visible). Solid-ish center
       fading to edges, matching trick slot visual language. */
  --zone-gradient: radial-gradient(
    ellipse at center,
    rgba(255, 255, 255, 0.04) 0%,
    rgba(255, 255, 255, 0.02) 60%,
    transparent 100%
  );

  /* Alliance zone gradients — Ink Void for enemies, Glass Lift for friends.
       Instead of just 'dark,' enemy-gradient uses a deep charcoal-indigo void. */
  --friend-gradient: radial-gradient(
    ellipse at center,
    rgba(255, 255, 255, 0.15) 0%,
    rgba(255, 255, 255, 0.08) 50%,
    transparent 100%
  );
  --enemy-gradient: radial-gradient(
    ellipse at center,
    rgba(0, 0, 0, 0.4) 0%,
    rgba(15, 23, 42, 0.2) 60%,
    transparent 100%
  );

  /* Hint colors */
  --system-hint: #22d3ee; /* Vibrant Cyan for non-clashing card hints */

  /* Repeated raw values — tokenized for single source of truth */
  --glow-gold: 255, 215, 0; /* Gold glow for selection, hints, accent borders */
  --gradient-surface-light: linear-gradient(
    180deg,
    rgba(226, 232, 240, 0.07) 0%,
    rgba(226, 232, 240, 0.02) 100%
  );

  /* Amber accent — rgba(217, 119, 6, ...) at context-specific opacities.
       Intentionally NOT tokenized: each opacity is tuned to its context.
       Base color matches --ui-warning (#D97706).

       Opacity map (sorted):
         0.10  — overlay-hand-badge background (subtle badge)
         0.15  — overlay-declarer-tag background, box-shadow glow (game-start)
         0.20  — overlay-hand-badge border, history-separator
         0.30  — declarer-tag border, hint glow
         0.40  — play-button hover glow, special-note rule, breakdown-total / history-total borders
         0.50  — hint-float selected glow
    */
}

/* === Base === */

*,
*::before,
*::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

html,
body {
  /* Match #skat-app's dynamic-viewport height. Under 100% these resolve to the
     layout viewport, which on Chrome-Android stays at the bar-shown height on a
     non-scrollable in-game page while #skat-app's 100dvh grows to the bar-hidden
     height — so body (overflow:hidden) clips the board's bottom ~96px and a blank
     strip shows below the hand (address-bar-visual-viewport-desync). */
  height: 100vh;
  height: 100dvh;
  overflow: hidden;
}

/* Five page classes scroll the page: game-start (the menu top-anchors and fits to the
   viewport, but the leaderboard flows below it and is reached by page scroll — the stage
   reserves the scaled content extent as real in-flow height, game-start-layout.js), the
   /games history page (a document-flow page whose trick tables run well past the
   viewport), /help (the app reference — six sections of prose run well past the
   viewport too), /rules (the game reference — eight sections of long-form prose,
   longer still), and the two .legal-page documents (/impressum, /datenschutz — long-form
   German legal prose). Every other phase keeps the base `html, body { overflow: hidden }`
   above. A new standalone page must join both lists or it renders fine and silently
   can't scroll (caught live on /datenschutz; legal-pages-smoke.spec.js guards it now).

   The scroll lands on the document (html), not body: html becomes the scroll container
   and body stays `overflow: visible` so the content's overflow bubbles up to html. A
   clean document scroll is what scroll-restoration, `scrollTo`, and assistive tech expect. */
html:has(.game-start),
html:has(.games-page),
html:has(.help-page),
html:has(.rules-page),
html:has(.legal-page) {
  overflow-y: auto;
}
body:has(.game-start),
body:has(.games-page),
body:has(.help-page),
body:has(.rules-page),
body:has(.legal-page) {
  overflow: visible;
}

/* Native <option> popups: Chrome on Linux can paint the popup canvas white while
   the options inherit the page's near-white text color — unreadable. Explicit
   colors, and a SOLID background: the translucent rgba surface tokens would
   blend into the white popup canvas. */
select option {
  background-color: var(--bg-outer);
  color: var(--text-primary);
}

body {
  font-family:
    "Satoshi",
    system-ui,
    -apple-system,
    sans-serif;
  font-weight: 400;
  color: var(--text-primary);
  background-color: var(--bg-abyss);
  background-image: var(--ink-sea);
  background-size: var(--ink-sea-size);
  /* Anchor the radial gradient to the viewport rather than the body box, so the bright
     inner pole stays fixed at its intended screen position regardless of the body's own
     box. Every phase but game-start is `overflow: hidden` at viewport height; game-start
     scrolls the page (the leaderboard flows below the menu), and `fixed` keeps the
     gradient anchored to the viewport as the board scrolls past instead of scrolling
     away with the content. */
  background-attachment: fixed;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

/* === Layout === */

#skat-app {
  width: 100%;
  height: 100vh;
  height: 100dvh;
  /* A plain positioned block — every board zone (.trick-area, .hand, both
     opponents, piles, prompts) is position: absolute; inset: 0 against this
     element, so #skat-app positions nothing by flow. It was a grid until the
     board-zone flatten (the grid template was inert — it positioned nothing). */
  padding: var(--space-xl) var(--space-2xl) 0;
  position: relative;
  /* The game screen is a no-select surface: nothing inside an active phase is
     text to select, so drag/double-click selection (bid numbers, nameplates,
     prompts) is always accidental. user-select inherits, so this one rule covers
     every phase including game-start — don't re-add per-element user-select
     inside the app (css/CLAUDE.md § Key Conventions). The sibling .site-footer
     sits outside #skat-app and owns its own rule. */
  user-select: none;
}

/* Game-start alone lets #skat-app grow past the fixed 100dvh above. `.site-footer` is
   now a normal-flow sibling (§ Site Footer) that must land after the true bottom of the
   composition, not at the fixed viewport edge — but `.game-start-content` is absolutely
   positioned (its own top: 4vh, not flow), so only `.game-start` itself (in flow, its
   height set by game-start-layout.js#placeStartAssembly) drives this. min-height keeps
   the full-bleed background on short content — there the footer starts exactly at the
   viewport's bottom edge, i.e. just below the fold, reached by a footer-height scroll
   (deliberate, approved: legal links stay quiet; the page always ends with the footer);
   height: auto lets the box grow to enclose a taller composition (leaderboard-present
   phone-landscape) instead of clipping it at the viewport edge. Every other phase keeps
   the fixed height above — the absolute board zones need an exact 100dvh, and the footer
   is hidden there anyway (`body:has(.menu-button) .site-footer`, § Site Footer). */
body:has(.game-start) #skat-app {
  height: auto;
  min-height: 100vh;
  min-height: 100dvh;
}

.prompt-zone {
  /* Position set by layout engine via JS */
  display: flex;
  align-items: flex-start; /* anchor from top so buttons stay at same height across prompts */
  justify-content: center;
  min-height: var(--prompt-icon-size);
  z-index: 80; /* interactive controls band */
  pointer-events: none; /* child buttons have pointer-events: auto */
}

.prompt-zone .btn,
.prompt-zone .icon-tile,
.prompt-zone label {
  pointer-events: auto;
}

/* Player zone — arc-shaped area behind the card fan, giving visual structure.
   Always visible (neutral). Friend/enemy classes modify the gradient and shadow.
   position: absolute; inset: 0 spans #skat-app, so the aura fills the app exactly
   like .opponent::before does — the existence proof for hosting it on .hand. */
.hand::before {
  content: "";
  position: absolute;
  inset: 0;
  clip-path: var(--aura-clip);
  background: var(--zone-gradient);
  pointer-events: none;
}

.hand.is-friend::before {
  background: var(--friend-gradient);
}

@media (max-width: 900px) {
  #skat-app {
    padding: var(--space-md) var(--space-lg);
  }
}

@media (max-width: 700px) {
  #skat-app {
    padding: var(--space-sm) var(--space-md);
  }
}

/* === Opponents === */

.opponent {
  position: absolute;
  inset: 0;
  pointer-events: none;
  z-index: 40; /* content tier — opponent hands */
}

/* Opponent card sizing */
.opponent .card {
  height: var(--opp-card-height);
  width: calc(var(--opp-card-height) * var(--card-ratio));
}

/* Opponent zone — always visible (neutral). Friend/enemy modify gradient.
   box-shadow omitted — clip-path clips it, so only the gradient is visible. */
.opponent::before {
  content: "";
  position: absolute;
  inset: 0;
  clip-path: var(--aura-clip);
  background: var(--zone-gradient);
  pointer-events: none;
}

.opponent.is-enemy::before {
  background: var(--enemy-gradient);
}

.opponent.is-friend::before {
  background: var(--friend-gradient);
}

/* Active player edge — amber ring via ring-shaped clip-path on ::after.
   --active-clip is a ring polygon (evenodd) computed by applyArcAura():
   expanded zone minus original zone = thin amber border. No z-index needed. */
.hand::after,
.opponent::after {
  content: "";
  position: absolute;
  inset: 0;
  clip-path: var(--active-clip);
  background: var(--ui-warning);
  opacity: 0;
  transition: opacity var(--transition-fast);
  pointer-events: none;
}

.hand.is-active::after,
.opponent.is-active::after {
  opacity: 1;
}

/* === Alliance Bloom === */

/* The bidding-reveal allegiance INK-WASH: at the resolution rest the friend/enemy
   colours INK in behind a dark wet front that sweeps out from the declarer's seat
   instead of snapping (background is not transitionable, so the ::before fill can't
   tween). animation/aura-bloom.js spawns one transient .aura-bloom overlay per zone,
   holding a .aura-bloom-ink wash layer swept by transform:translate; the overlay is
   released at settle. The trump reveal's ink-wash language (components/trump-ink.js),
   carried to the table. */

/* The overlay inherits the zone's own --aura-clip (set on the zone element), so it
   covers exactly the ::before band, and CLIPS its .aura-bloom-ink child to that band.
   Inserted as the zone's first child — above ::before, below the card fan / role
   icons. */
.aura-bloom {
  position: absolute;
  inset: 0;
  clip-path: var(--aura-clip);
  pointer-events: none;
}

/* The wash layer: a flat alliance gradient (settled tint → dark wet lip →
   transparent) set inline per zone, oversized so the transform:translate sweep
   always covers the clipped band. The wet front sweeps out from the declarer's seat
   (transform-only — compositor, no animated mask/filter); ::before takes the settled
   colour under it at the tail fade. animation/aura-bloom.js#buildInkOverlay. */
.aura-bloom-ink {
  position: absolute;
  inset: -75%;
  pointer-events: none;
}

/* The neutral hold: the resting alliance classes are set synchronously (the #2
   idempotency marker), but held visually neutral so they don't snap the ::before
   colour before the bloom lands. Dropped at settle under the still-opaque overlay,
   so ::before takes over the alliance colour seamlessly. Must follow the
   .is-friend/.is-enemy rules above to win on equal specificity (source order). */
.hand.aura-bloom-hold::before,
.opponent.aura-bloom-hold::before {
  background: var(--zone-gradient);
}

/* === Trick Piles === */

/* Per-faction stacks of face-down card backs — height shows how many tricks a
   faction has won (Show-Don't-Tell). The layer spans #skat-app; render-piles.js
   anchors each .trick-pile at its engine-computed home (inline left/top) and
   offsets each back along the stack by an inline transform + width/height.
   Recessive board info — dimmed and pointer-transparent. */
.trick-piles {
  position: absolute;
  inset: 0;
  pointer-events: none;
  z-index: 10; /* content tier — trick piles (PRIMACY #9, backmost content) */
}

.trick-pile {
  position: absolute;
  opacity: 0.55;
}

.trick-pile .card {
  position: absolute;
  /* width/height + offset transform set inline by render-piles.js */
  border-radius: var(--card-radius);
  overflow: hidden;
  box-shadow: 0 1px 4px rgb(0 0 0 / 50%); /* static — stack-depth cue, never animated */
}

.trick-pile .card svg {
  display: block;
  width: 100%;
  height: 100%;
}

/* The game-type stamp, centred on the skat pile — every game type, not just
   Ramsch's fallen chalice (render-skat.js#buildStamp). Centering lives HERE,
   not inline, so the arrival choreography's clearProps hands the rested stamp
   back to this rule (animation/stamp-arrival.js). Size set inline
   (engine-derived × pile-card width). */
.skat-stamp {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  pointer-events: none;
  /* Vellum halo: a soft glow lifts the stamp off the petrol card-backs so the
     brand reads as pressed ON the prize, not tinted into it — the same "lift a
     dark mark off Midnight Petrol" need the role icons solve with a vellum rim
     (role-icons.js#needsRim), here a glow so the icon's own lines stay clean;
     shared across all four game-type icons (per-icon halo tuning only if a
     pixel review demands it — game-type-icon-on-skat-pile § Open questions).
     Two stacked drop-shadows deepen the glow; radius is engine-set
     (--stamp-halo ∝ stamp width, render-skat.js#buildStamp) so it holds at
     every card size. Static at rest and untouched by the arrival's transform
     tweens (which never set filter) — a one-shot, not a keyframed filter
     (css/CLAUDE.md § Never animate box-shadow/filter). */
  filter: drop-shadow(0 0 var(--stamp-halo) #e2e8f0)
    drop-shadow(0 0 var(--stamp-halo) #e2e8f0);
}

.skat-stamp svg {
  display: block;
  width: 100%;
  height: 100%;
}

/* Prev-trick mini — the last won trick, face-up and dimmed, resting ON the winning
   faction's pile. Recessive board info, one band above the count piles (15 vs 10) — the
   one DESIGNED overlap (the who-won spatial signal), not a clear-of-everything rest.
   render-piles.js positions it + its cards. */
.prev-trick-mini {
  position: absolute;
  z-index: 15; /* content tier — one above its own trick pile (10): the designed
                  overlap, the mini resting face-up on its faction pile. */
  pointer-events: none;
  /* "Past" — recessive, below the live trick in the visual hierarchy (like the
     old darkened prev-trick slots). Static filter, never animated. */
  opacity: 0.62;
  filter: brightness(0.7) saturate(0.85);
}

.prev-trick-mini .card {
  border-radius: var(--card-radius);
  overflow: hidden;
  box-shadow: 0 2px 8px rgb(0 0 0 / 55%);
}

.prev-trick-mini .card svg {
  display: block;
  width: 100%;
  height: 100%;
}

/* === Trick Slots === */

/* The aura lives on a backdrop child (rendered before the card) rather than the
   slot's own background. why: the slot is layer-promoted by its rotation transform;
   after the live flyAndFlip reveal promotes the card to its own compositing layer, a
   slot background composites OVER the card and dims it (the dark enemy radial ~43%) —
   while the card's DOM/computed-style stays clean, so the differential oracle can't see
   it. A sibling-before-card backdrop orders by DOM like the watermark icon does, immune
   to that layer inversion. See render-trick.js#addAllianceBackdrop. */
.trick-slot-backdrop {
  position: absolute;
  inset: 0;
  border-radius: var(--card-radius);
  pointer-events: none;
}

.trick-slot.is-enemy-card .trick-slot-backdrop {
  background: radial-gradient(
    ellipse at center,
    rgba(0, 0, 0, 0.5) 20%,
    rgba(15, 23, 42, 0.3) 60%,
    transparent 100%
  );
  box-shadow: inset 0 0 20px rgba(0, 0, 0, 0.4);
}

.trick-slot.is-enemy-card .trick-slot-backdrop::before {
  content: "";
  position: absolute;
  inset: -8px;
  border-radius: var(--radius-md);
  background: radial-gradient(
    ellipse at center,
    transparent 20%,
    rgba(0, 0, 0, 0.3) 100%
  );
  pointer-events: none;
}

.trick-slot.is-enemy-card .card {
  box-shadow: none;
  border-color: rgba(255, 255, 255, 0.1);
}

.trick-slot.is-friend-card .trick-slot-backdrop {
  background: radial-gradient(
    ellipse at center,
    rgba(255, 255, 255, 0.15) 20%,
    rgba(255, 255, 255, 0.05) 60%,
    transparent 100%
  );
  box-shadow: inset 0 0 20px rgba(255, 255, 255, 0.05);
}

.trick-slot.is-friend-card .trick-slot-backdrop::before {
  content: "";
  position: absolute;
  inset: -8px;
  border-radius: var(--radius-md);
  background: radial-gradient(
    ellipse at center,
    rgba(255, 255, 255, 0.15) 0%,
    transparent 100%
  );
  pointer-events: none;
}

.trick-slot.is-friend-card .card {
  border-color: rgba(255, 255, 255, 0.5);
}

/* Friend slots already glow — tone down watermark to avoid competing with cards */
.trick-slot.is-friend-card .trick-slot-icon {
  opacity: 0.2;
}

.trick-slot-icon {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  opacity: 0.35;
  pointer-events: none;
  z-index: 0;
}

/* Role-icon watermark size lives in CSS off the layout-engine var so a resize
   repositions the persistent wrapper without rebuilding the SVG (reflowTrickSlots).
   Sized off the slot's OWN size (--trick-slot-icon-size, engine-derived as a golden
   fraction of the slot height) — not --role-icon-size, whose slot↔hand corridor clamp
   collapses under the trick fill's grown slots (Run 12); the ghost's clearance is the
   slot box it paints inside. The ceremony `.role-icon` arms keep --role-icon-size in
   their own section.
   why: specs/2026-06/2026-06-10-ceremony-watermark-css-var-sizing-design.md */
.trick-slot-icon svg {
  width: var(--trick-slot-icon-size);
  height: var(--trick-slot-icon-size);
}

/* Counter-rotate icons inside the ±120° rotated opponent slots so they appear upright. */
.trick-slot-left .trick-slot-icon {
  transform: translate(-50%, -50%) rotate(-120deg);
}
.trick-slot-right .trick-slot-icon {
  transform: translate(-50%, -50%) rotate(120deg);
}

/* --- FINISHED rest posture (result-on-the-table slice 1a) --- */

/* The board's actors at rest. The winning gatehouse rests at its muster size — no size
   emphasis: the verdict reads through the crumble/pennant morph and this brightness lift,
   not a swell (result-show-performs-within-the-stage: nothing changes size across the
   choreography). The beat tweens opacity to OUTCOME_BEAT_DEFAULTS.opacityLift; this class
   holds it. Any future permanent size emphasis is a constant the placement accounts for,
   applied on the SVG CHILD (a wrapper `scale` composes outside the load-bearing centering
   translate and would shift the icon off its slot — probed live), not a transient lift. */
.trick-slot-icon--protagonist {
  opacity: 0.85;
}

/* play-test: the supporting cast lifts out of watermark dimness — the actors
   ARE the scene at rest, but the protagonist keeps the spotlight. */
.trick-slot-icon--rest {
  opacity: 0.6;
}

.trick-slot-icon--rest > svg {
  scale: 1.2;
}

/* Nameplate under each slot at FINISHED — position icon + name (+ Augen on
   Ramsch). Positioned by the engine in the trick-area frame (left/top inline,
   placeNameplateInLayout); only the horizontal centering lives here. */
.slot-nameplate {
  position: absolute;
  transform: translate(-50%, 0);
  display: flex;
  align-items: center;
  gap: var(--space-xs);
  color: var(--text-primary);
  font-size: var(--fs-sm);
  font-weight: 700;
  white-space: nowrap;
  pointer-events: none;
  z-index: 1;
}

/* The seat's persona bust leads the plate — identity-colored actor (IDENTITY §
   The court); the position glyph beside it stays the muted teaching mark. */
.slot-nameplate-persona {
  display: inline-flex;
}

.slot-nameplate-persona svg {
  /* play-test: a step above the 22px position glyph — the identity mark leads */
  width: 27px;
  height: 27px;
}

.slot-nameplate-icon {
  display: inline-flex;
  color: var(--text-secondary);
}

.slot-nameplate-icon svg {
  /* play-test: ~half the φ ladder step below the standards — present, not loud */
  width: 22px;
  height: 22px;
}

.slot-nameplate-augen {
  display: inline-flex;
  /* center, not baseline: the Zählbogen aligns on its own box — an SVG has no
     text baseline to share with the value beside it. */
  align-items: center;
  gap: 3px;
  font-variant-numeric: tabular-nums;
}

/* Descendant `svg`, not a class on the icon — the plate only needs the mark
   muted to match the unit text it replaced, and the factory's baked size stands
   (no `.score-story-big` em rule reaches the board). */
.slot-nameplate-augen svg {
  color: var(--text-secondary);
}

/* The round's loser carries the loss tint on the value (score-color family). */
.slot-nameplate.is-loser .slot-nameplate-augen-value {
  color: var(--score-negative);
}

/* Board actors' entrance on a fresh result — opacity only: the icon wrappers
   carry load-bearing inline transforms a transform keyframe would override
   mid-flight. Applied by result-ui#applyBoardEntrance, only when fresh. */
@keyframes rest-fade-in {
  from {
    opacity: 0;
  }
}

.rest-entrance {
  animation: rest-fade-in 0.45s ease-out backwards;
}

/* Nameplates fade in WITH the watermarks (one beat) — the former 0.25s "cast
   then names" stagger is retired: it lost its verdict drama to
   score-story-verdict, and the lone late name-fade left names flashing at
   mid-board before the staged stage covered them (result-ui#applyBoardEntrance). */

/* Game type watermark in the triangle center — FINISHED rest only. Trick play
   carries no board watermark; the game-type rides the skat pile's stamp
   instead (.skat-stamp above — game-type-icon-on-skat-pile). */
.trick-type-indicator {
  position: absolute;
  left: 50%;
  top: 55%;
  transform: translate(-50%, -50%);
  /* Notional content band 30 (PRIMACY #7), but a scoped child of .trick-area —
     kept at 0 so it stays behind the played-card slots (play-order 1..3) in the
     shared context. The cross-container order (above .opponent, since .trick-area
     is 60 > 40) is the documented watermark divergence; an exact fix would hoist
     this to its own low-band element. See the z-index spec § divergences. */
  z-index: 0;
  opacity: 0.45;
  pointer-events: auto;
  /* Size the indicator box off the layout-engine var; the inner svg fills it
     (width/height:100% below). A resize repositions this node in place rather
     than rebuilding the watermark (reflowGameTypeWatermark).
     why: specs/2026-06/2026-06-10-ceremony-watermark-css-var-sizing-design.md */
  width: var(--game-type-watermark-size);
  height: var(--game-type-watermark-size);
}

.trick-type-indicator svg {
  display: block;
  width: 100%;
  height: 100%;
}

/* === Cards === */

.card {
  width: calc(var(--card-height) * var(--card-ratio));
  height: var(--card-height);
  border-radius: var(--card-radius);
  border: var(--card-border);
  overflow: hidden;
  box-shadow: var(--card-shadow);
  flex-shrink: 0;
  position: relative;
  cursor: default;
}

.card svg {
  width: 100%;
  height: 100%;
  display: block;
  pointer-events: none;
}

/* Chrome re-rasters an SVG card's layer texture on every frame of a scale tween unless
   will-change pins it; on Android that raster runs on the GPU-process main thread and
   queues ahead of compositing, so a burst tweening dozens of cards (deal, declaration→
   trick) stalls frame presentation into a slideshow. Measured fix (Pixel Tablet, Chrome
   149, 2026-07-07 A/B): deal-window stall time 4.2–6.6s → 0.12–0.16s (−97%); declaration
   beat 1.2–1.4s → 0.06–0.13s. Scoped to the animation lock, not blanket — a resident
   will-change on every idle card is the anti-pattern this scoping avoids — mirrored from
   state.js#setAnimating via game.js#mirrorAnimatingToDom.
   The :has selector widens the pin to any showing prompt: the lock only engages at
   choreography start (~394ms after a declare submit), so the newly-promoted card layers'
   initial raster landed mid-beat — a ~100ms stall cluster the Pixel 5 pays where the
   tablet absorbs it (measured 2026-07-16, phone declare in-choreography stall median
   ~211ms → ~108ms). A prompt awaiting human input is the idle window right before that
   burst, and the mutation that empties the zone runs after the dispatch loop has set the
   lock, so the pin hands over to [data-animating] without a demotion gap. The `> *` child
   requirement is load-bearing, not decoration: renderPromptZone appends the zone NODE on
   every non-FINISHED render and renderPrompts builds nothing for non-prompt states, so an
   empty .prompt-zone persists through trick play — a bare `> .prompt-zone` guard would pin
   every card session-long, the resident anti-pattern this scoping exists to avoid (caught
   in review, 2026-07-16). Tripwire: device-ab.js's noprepin arm reproduces the burst on
   fixed HEAD. */
#skat-app[data-animating] .card,
#skat-app:has(> .prompt-zone > *) .card {
  will-change: transform;
}

/* During a 2D turn the card content lives in the collapsing flip layer below
   while the .card stays full-size — its static border would frame the squashed
   content as a fixed rectangle. Drop the outer frame for the turn's duration;
   the border rides on the layer instead (added to each layer rule below), so the
   card edge foreshortens with it. The layer's removal at settle restores this.
   The second selector handles every per-alliance trick-slot border — enemy (0.1),
   friend (0.5), self — which each set `.trick-slot.is-*-card .card` at (0,3,0) and
   out-rank the bare `.card:has` (0,2,0). Scoping the suppressor to `.trick-slot`
   ties that (0,3,0) and wins on source order (this rule sits after them), so it
   covers all alliances without enumerating each — every AI card runs this turn. */
.card:has(> [class$="-flip-layer"]),
.trick-slot .card:has(> [class$="-flip-layer"]) {
  border-color: transparent;
}

/* Transient layer for the 2D scale-flip (animation/components/scale-flip.js) — the
   cheap successor to the CSS-3D flip-layer above. GSAP whips its scaleX full→0→full
   to turn the card over; no perspective, no rotate3d, so the .card parent's normal
   rounded clip composites it like any 2D transform (the GPU-cost fix). transform-origin
   center so the squash collapses toward the card's spine. */
.card-scale-flip-layer {
  position: absolute;
  inset: 0;
  display: block;
  transform-origin: center center;
  border: var(--card-border);
  border-radius: var(--card-radius);
}

/* Full-card dim overlay riding the scale-flip — a child of the .card (NOT the scaled
   layer), so it stays full-size while the layer collapses. GSAP pulses its opacity to
   peak at the edge-on seam: a surface turned edge-on catches less light. Deep petrol
   (--bg-outer #082f49, the stage's own shadow) so it reads as dimming on near-white
   Pure Vellum faces where a bright glint would wash out. */
.card-scale-flip-shade {
  position: absolute;
  inset: 0;
  pointer-events: none;
  opacity: 0;
  border-radius: inherit;
  background: #082f49;
}

/* Transient layer for the swivel 2D turn variant (animation/components/
   swivel-flip.js) — the cheap successor to the CSS-3D flip-layer. Swivel drives
   the layer's scaleX + scaleY + skewY (a keystone splay reading as depth). A pure
   2D transform, so the .card parent's normal rounded clip composites it like any
   2D transform (the GPU-cost fix); the clip to the card silhouette is intended (a
   skewed layer reads as a card, not a splaying rectangle). transform-origin center
   is the swivel spine. animation/components/lit-swivel.js (the lit AI-reveal pool
   member) reuses this same layer box, dressing it with the two overlays below. */
.card-swivel-flip-layer {
  position: absolute;
  inset: 0;
  display: block;
  transform-origin: center center;
  border: var(--card-border);
  border-radius: var(--card-radius);
}

/* Transient layer for the peel 2D turn variant (animation/components/peel-flip.js)
   — hinged toward one edge (transform-origin per pivotXFrac, set inline by
   wrapFlipLayer) rather than the center spine, so the collapse reads as a card
   lifted and turned up by its edge. Pure 2D, same GPU-cost fix as the swivel/scale
   layers above. One of the four AI-reveal TURN_POOL members. */
.card-peel-flip-layer {
  position: absolute;
  inset: 0;
  display: block;
  transform-origin: center center;
  border: var(--card-border);
  border-radius: var(--card-radius);
}

/* The two lighting overlays shared by the AI-reveal pool's dressed turns — the
   petrol dim (peel-flip.js, lit-swivel.js) and the specular sheen (lit-swivel.js
   only). Children of the .card (NOT the scaled layer) so they stay full-size
   while the layer collapses; each turn pulses opacity 0→peak→0 across its own
   turn window (its addFlipTurn). Petrol-tinted dim so it reads as dimming on
   near-white Pure Vellum faces where a bright glint would wash out; the sheen is
   a soft diagonal glint band, off (opacity 0) at rest. */
.card-turn-shade,
.card-turn-sheen {
  position: absolute;
  inset: 0;
  pointer-events: none;
  opacity: 0;
  border-radius: inherit;
}
.card-turn-shade {
  background: #082f49;
}
.card-turn-sheen {
  background: linear-gradient(
    105deg,
    transparent 38%,
    rgba(255, 255, 255, 0.7) 50%,
    transparent 62%
  );
}

/* --- Card interactive states --- */

/* Hover is JS-driven (.hovered class) — CSS :hover can't detect the right card
   on overlapping elements because a lifted card's z-index covers its neighbors.
   The hand card's transform (see § Hand) composes its rest pose with the
   lift and tilt vars; with GSAP available, animation/hover-lift.js tweens
   those vars inline (lift + straighten + scale + neighbor parting + pointer
   tilt) and marks the hand data-lift-gsap. The rules here are the NO-GSAP /
   reduced-motion fallback: scoped to :not([data-lift-gsap]) they map the
   legacy fixed-lift offsets (--hover-x/y, written by positionHandCard) onto
   the same lift vars — today's pre-GSAP behavior, animated by the stylesheet
   transition (which the reduced-motion collapse zeroes for free). */
.hand:not([data-lift-gsap="true"]) .card.hovered {
  --lift-x: var(--hover-x, 0px);
  --lift-y: var(--hover-y, -12px);
  transition: transform var(--transition-fast); /* lifting — quick but not jarring */
}

/* Keyboard focus lifts on both substrates (no JS hook fires here, so the
   mapping stays unscoped; inline GSAP vars simply override it when present). */
.hand .card:focus-visible {
  --lift-x: var(--hover-x, 0px);
  --lift-y: var(--hover-y, -12px);
}

.card.legal {
  cursor: pointer;
}

/* Card-play queue (card-queue.js): queued-card badge showing the queue
   position. Bottom-left: the fan stacks left-below-right, so a card's left
   edge is its always-exposed side — a top-right badge hides under the right
   neighbor. Sits INSIDE the card: .card is overflow:hidden (the flip-corner
   clip), so an outside-the-corner badge would be clipped. pointer-events:none
   keeps the badge from eating the card's own clicks. */
.card.queued::after {
  content: attr(data-queue-pos);
  position: absolute;
  bottom: var(--space-xs);
  left: var(--space-xs);
  width: 1.6em;
  height: 1.6em;
  border-radius: 50%;
  background: var(--ui-warning);
  border: 2px solid #fff;
  box-shadow: 0 1px 4px rgb(0 0 0 / 0.45);
  color: #fff;
  font-weight: 700;
  font-size: var(--fs-lg);
  display: grid;
  place-items: center;
  z-index: 3;
  pointer-events: none;
}

.card.illegal {
  /* Visual-only: clicks must still reach the hand listener — the card-play
     queue accepts a currently-illegal card as a premove for a future trick
     (card-queue.js); with pointer-events:none the click would fall through to
     a neighboring card or the table (a click-anywhere queue reset). Click
     interpretation is gated in JS (wireHandClicks / onHandCardClick), not here. */
  opacity: 0.35;
}

/* Press squeezes the card IN PLACE — the full composed pose at 95% scale, so
   pressing a lifted card no longer snaps it back to rest first. !important
   beats the inline lift vars' transform only in the scale factor it wraps. */
.hand .card:active {
  transform: translate(
      calc(-50% + var(--lift-x, 0px)),
      calc(-50% + var(--lift-y, 0px))
    )
    rotate(calc(var(--card-rotation, 0deg) + var(--lift-rot, 0deg)))
    perspective(calc(var(--tilt-persp, 900) * 1px)) rotateX(var(--tilt-x, 0deg))
    rotateY(var(--tilt-y, 0deg)) scale(calc(var(--lift-scale, 1) * 0.95)) !important;
  transition: transform var(--transition-snap);
}

/* Selection rests lifted via the same lift vars (class-mapped, both substrates
   — the GSAP affordance tweens FROM and settles BACK TO this mapped base, then
   clears its inline vars so the mapping resumes seamlessly). */
.hand .card.selected {
  --lift-x: var(--select-x, 0px);
  --lift-y: var(--select-y, -16px);
  box-shadow: 0 0 16px rgba(var(--glow-gold), 0.6);
}

.hand .card.selected.hovered {
  box-shadow: 0 0 22px rgba(var(--glow-gold), 0.8);
}

.hand:not([data-lift-gsap="true"]) .card.selected.hovered {
  --lift-x: var(--select-hover-x, 0px);
  --lift-y: var(--select-hover-y, -18px);
}

/* Discard mode cue: a very subtle resting dim on the not-yet-chosen cards while
   discarding (render-hand.js sets data-discard-mode). Pairs with the simple
   fixed-lift hover (vs the rich play-lift) so the player feels they're sorting,
   not playing; the trick phase clears the flag and the cards brighten back. The
   two selected cards stay full-bright — their gold glow reads as "chosen" against
   the dimmed field. brightness, not opacity: keeps faces opaque/legible over the
   petrol canvas. Static (no transition) — never a looping/animated filter.
   Excludes .hint-float too: a hinted card isn't resting (it's "look here"), and
   the hint owns its own brightness lift (the .hint-float filter) — the
   dim must not bury it, same as it spares the chosen .selected cards. */
.hand[data-discard-mode="true"] .card:not(.selected):not(.hint-float) {
  filter: brightness(0.9);
}

/* --- Trump card accent — wider left stripe --- */

/* === Hand === */

.hand {
  position: absolute;
  inset: 0;
  pointer-events: none; /* hand covers entire app — only cards receive events */
  z-index: 70; /* content tier — user hand cards (topmost content, PRIMACY #1) */
}

.hand .card {
  position: absolute;
  left: var(--card-x);
  top: var(--card-y);
  /* Identity defaults for the lift/tilt vars — declared (not just var()
     fallbacks) so GSAP reads a real start value: a tween FROM an unset var
     reads 0, and --lift-scale starting at 0 would pop the card from nothing. */
  --lift-x: 0px;
  --lift-y: 0px;
  --lift-rot: 0deg;
  --lift-scale: 1;
  --tilt-x: 0deg;
  --tilt-y: 0deg;
  --tilt-persp: 900;
  /* Rest pose composed with the hover-lift affordance's vars (all default to
     identity): translate rides the lift offset, rotate adds the straighten
     delta, perspective() + rotateX/Y carry the pointer tilt (perspective is
     the load-bearing depth cue — ANIMATIONS.md flying-flip lesson), scale the
     grow. animation/hover-lift.js tweens the vars inline; the fallback rules
     in § Card interactive states map them from classes. Vars, not transforms,
     so the affordance can't fight the fan positioning. */
  transform: translate(
      calc(-50% + var(--lift-x, 0px)),
      calc(-50% + var(--lift-y, 0px))
    )
    rotate(calc(var(--card-rotation, 0deg) + var(--lift-rot, 0deg)))
    perspective(calc(var(--tilt-persp, 900) * 1px)) rotateX(var(--tilt-x, 0deg))
    rotateY(var(--tilt-y, 0deg)) scale(var(--lift-scale, 1));
  pointer-events: auto;
  /* The drag affordance (animation/card-drag.js, pointer events) owns touch
     gestures on a hand card. Without this the browser claims a touch-drag as a
     scroll/pan and fires pointercancel mid-grip, so the gesture never plays on
     touch — where it matters most. Tap-to-play still fires via the click path.
     The in-game board never scrolls (html/body overflow:hidden), so opting the
     card out of native touch panning costs nothing. */
  touch-action: none;
  /* Load-bearing beyond hand-feel: the GSAP hand re-fan (animation/trick-mutations.js
     #animateHandRefan) writes `transform` per frame, and this live CSS transition chases
     those writes a beat behind — the two easings compound into the under-damped overshoot
     that IS the re-fan "wiggle". Removing/shortening it (or detaching --transition-normal)
     flattens that wiggle. Keep it live; tune the feel via the lab's Re-fan tab (settleMs).
     (Also: hand interactions should feel direct, not decoratively draggy.)
     One frame is exempt: the re-fan staging suppresses this transition for its FLIP
     staging frame (stageHandRefanSlides) — otherwise the inverse offset eases in over
     this span while left/top snap, teleporting the fan on frame 1. The chase rides the
     slide only. */
  transition: transform var(--transition-normal);
}

/* A card held in the drag grip (animation/card-drag.js adds .dragging) casts a
   deep, soft shadow pooled below it — the visual weight cue that it's lifted high
   off the table toward the player. Cards are flat at rest (--card-shadow: none),
   so this shadow IS the "heavy, in your hand" read. Static, not animated: it
   snaps in/out with the class (the drag sets inline transition:none, and per
   css/CLAUDE.md box-shadow must never be tweened at 60fps), held steady while the
   card is gripped. The shadow rides the card's 3D bank, so it leans with it. */
.hand .card.dragging {
  box-shadow: 0 30px 55px -12px rgba(0, 0, 0, 0.6);
}

/* === Trick Area === */

/* COORDINATE ORIGIN INVARIANT:
   Main .trick-area MUST share its coordinate origin with #skat-app. The layout engine
   (layoutTrickSlots) computes trick slot positions as app-relative pixel coordinates —
   slot.style.left/top are set directly from these values. If .trick-area's origin ever
   diverges from the app's origin (e.g., via margin, padding, border, or switching from
   position:absolute to position:relative inside a grid cell), all trick slot positions
   and fly-in animations will silently shift. The `inset: 0` + `position: absolute` combo
   on a `position: relative` parent (#skat-app) guarantees origin alignment. */
.trick-area {
  position: absolute;
  inset: 0;
  pointer-events: none;
  z-index: 60; /* content tier — played cards / trick area (PRIMACY #2) */
}

.trick-slot {
  /* Sized by layout engine — TRICK_SCALE computed so adjacent slot corners never overlap */
  width: var(--trick-slot-width, calc(var(--card-height) * var(--card-ratio)));
  height: var(--trick-slot-height, var(--card-height));
  /* Transparent, not the always-visible dashed ring it used to frame (removed
     2026-07-10 as a dated aesthetic relic — the alliance backdrop + in-slot role
     ghosts carry the slot now): this border is pure box-sizing geometry ballast.
     Transparent, NOT none/0 — under box-sizing: border-box that would grow the
     content box and resize every card in a slot (the box-sizing border-box trap). */
  border: 2px solid transparent;
  border-radius: var(--card-radius);
  display: flex;
  align-items: center;
  justify-content: center;
  position: relative;
  /* Let pointer events pass through to game type watermark behind (z-index 0) */
  pointer-events: none;
}

/* The drag-to-play drop-zone bloom target (card-drag.js#bloomDropZone) — a
   first-child sibling of the card so DOM paint order keeps it under the card,
   immune to the slot's own compositing inversion (css/PITFALLS.md "slot's own
   background" entry). Invisible everywhere: only the human-scoped rule below
   (resting at opacity 0) ever paints anything. */
.trick-slot-drop-glow {
  position: absolute;
  inset: 0;
  border-radius: var(--card-radius);
  pointer-events: none;
}

.trick-slot .card {
  position: absolute;
  width: 100%;
  height: 100%;
  pointer-events: auto;
}

/* Triangular trick slot positions — each slot in front of its player's fan center.
   Percentages are relative to .trick-area container.
   Opponent fans are at ~6% (left) and ~94% (right) of container width,
   centered at ~32% height. Slots offset ~10% toward center with gap. */
.trick-slot-left {
  position: absolute;
  left: 40%;
  top: 42%;
  transform: translate(-50%, -50%) rotate(120deg);
}
.trick-slot-right {
  position: absolute;
  left: 60%;
  top: 42%;
  transform: translate(-50%, -50%) rotate(-120deg);
}
.trick-slot-human {
  position: absolute;
  left: 50%;
  top: 81%;
  transform: translate(-50%, -50%);
  /* Position + drag telegraph only — the human slot renders at the shared
     --trick-slot-* size like the opponents. There is no per-slot human size: the
     `opponents > human` primacy is a documented yield-order, not a render penalty
     (layout-solver.js solveTrickSlots / the design spec). The engine sets the inline
     left/top, which override the percentage fallback above; the size comes from the
     base .trick-slot rule. */
}

/* --- Drop-zone telegraph (drag-to-play affordance: animation/card-drag.js) --- */
/* card-drag.js#bloomDropZone tweens opacity (0→1) + scale while a held card is
   over the play zone — the human's slot warms (gold wash) and swells to tell the
   player a release here plays; off-zone it fades back. The gradient is pre-baked
   at full strength and rests at opacity 0, so the tween is compositor-only:
   the retired shape tweened a --drop-arm var inside the gradient's alpha, and
   each tween frame repainted the glow's unpromoted layer — measured on the
   Pixel Tablet as ~1s of frame stalls per drag through the zone
   (docs/PERFORMANCE.md § gesture rules). will-change pins the one glow element's
   layer so the opacity/scale frames never touch paint — a single element, not
   the at-multiplicity pinning the burst-scoped card rule avoids. Absent entirely
   (click-only / reduced motion) → no effect. (Formerly also ring-tinted the
   now-removed dashed outline's border — the bloom alone carries the tell since
   2026-07-10.) */
.trick-slot-human .trick-slot-drop-glow {
  background: radial-gradient(
    ellipse at center,
    rgba(var(--glow-gold), 0.45) 0%,
    rgba(var(--glow-gold), 0) 72%
  );
  opacity: 0;
  will-change: transform, opacity;
}

/* --- Score float (per-trick "+N" floating-combat-number: animation/score-float.js) --- */
/* A transient "+N Augen" badge that appears just above the resolved trick, floats straight up,
   and fades before the piles — fire-and-forget from the TrickResolved mutation. Positioned + sized
   inline by JS (left/top app-relative, font-size off the live card); GSAP owns y + opacity (no scale).
   Color carries the ALLIANCE the way the rest of the game does — light vs dark, not hue (COLORS.md
   "Alliance uses light/dark, not color"): friend/your-side = luminous vellum, enemy = the grimy
   identity slate. The enemy slate would vanish on dark petrol (LAYOUT.md, the rejected enemy tray),
   so it carries a vellum rim — the same legibility trick as enemy card backs/banners.
   z-index 150 (transient / lifted-UI band): above the played-card flight + trick-resolve
   ghost (120) so the number stays legible. */
.score-float {
  position: absolute;
  z-index: 150;
  font-family: "Satoshi", sans-serif;
  font-weight: 700;
  pointer-events: none;
  white-space: nowrap;
  line-height: 1;
}
.score-float.is-friend {
  color: #e2e8f0; /* Vellum — your side took the trick (alliance = light). Dark halo for depth. */
  /* Static outline — never animated (no box-shadow keyframes). */
  text-shadow:
    0 0 4px rgba(0, 0, 0, 0.7),
    0 2px 6px rgba(0, 0, 0, 0.5);
}
.score-float.is-enemy {
  color: #334155; /* Dark Slate identity grey — the opponents took it (alliance = dark, "grimy enemy"). */
  /* Vellum rim so the dark number reads on petrol — never animated (no box-shadow keyframes). */
  text-shadow:
    0 0 5px rgba(226, 232, 240, 0.9),
    0 0 2px rgba(226, 232, 240, 0.85);
}

/* --- Ink-ripple layer (the sea's rare event ripples: animation/ink-ripples.js) --- */
/* A transient full-app layer at the BACKGROUND band (z 1): the ripples belong to
   the ink sea the play sits ON, so rings pass under cards, piles, and zones —
   occlusion is the material story. Created per event, shed when the sea settles;
   ring/bloom children are procedural inline-styled nodes (transform/opacity only). */
.ink-ripple-layer {
  position: absolute;
  inset: 0;
  z-index: 1;
  overflow: hidden;
  pointer-events: none;
}

/* === Prompts === */

.prompt {
  text-align: center;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-md);
}

/* FINISHED rest entrance — the game type watermark fades in when the board
   settles into the result surface. The live declaration's drama plays on the
   skat pile's own stamp now (animation/stamp-arrival.js), never on this
   indicator (trick play carries no board watermark at all — the PLAYING usage
   of this class retired with it, game-type-icon-on-skat-pile). The old scale
   bump (1.65 → 1) was dropped — it doubled up with the live Stamp and was a
   generic attention-bump we've retired. */
.trick-type-indicator.declaration-entrance {
  animation: declaration-settle var(--declaration-reveal-ms, 800ms) ease-out
    both;
}

@keyframes declaration-settle {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

/* Bidding value — large and dramatic. Value changes animate via the GSAP
   per-digit rise (prompt-ui.js#buildBidValue), gated to display-string
   changes, not every render-rebuild — no CSS keyframe. tabular-nums keeps
   held digits still while a changed neighbour rises. */
.bid-value {
  font-size: calc(var(--prompt-icon-size, var(--fs-display)) * 0.8);
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  color: var(--text-accent);
  text-shadow: 0 0 30px rgba(255, 255, 255, 0.15);
}

.prompt-actions {
  display: flex;
  align-items: center;
  gap: var(--prompt-action-gap);
  justify-content: center;
}

.prompt-actions.committing {
  pointer-events: none;
  opacity: 0.82;
  filter: saturate(0.82);
}

.prompt.committing .btn,
.prompt.committing .icon-tile {
  cursor: progress;
}

.prompt-error {
  display: block;
  margin-bottom: var(--space-sm);
  text-align: center;
  color: var(--text-error);
  font-weight: 700;
  text-shadow: 0 0 12px rgba(127, 29, 29, 0.3);
}

/* === Buttons === */

.btn {
  font-family: "Satoshi", system-ui, sans-serif;
  font-size: var(--fs-md);
  font-weight: 700;
  padding: var(--space-lg) var(--space-2xl);
  border: none;
  border-radius: var(--radius-lg);
  cursor: pointer;
  transition: transform var(--transition-fast);
  color: var(--text-accent);
}

/* :not([data-plate-gsap]) — button-plate.js owns the transform when a plate is armed
   (it sets that attribute). :not(.btn-recessed) — the recessed register presses IN,
   not out, so it owns its own feedback. :not(.icon-tile) / :not(.menu-overlay-entry) —
   both reach .btn via btn() but own their own hover (the glass tile's inset-highlight
   `.icon-tile:hover`; the menu row's amber `translateX` slide `.menu-overlay-entry:hover`);
   without these the flat lift here (higher specificity, 0,4,0) clobbers them. This default
   lift stays for every other .btn. */
.btn:not([data-plate-gsap]):not(.btn-recessed):not(.icon-tile):not(
    .menu-overlay-entry
  ):hover {
  transform: translateY(-1px);
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); /* static, not transitioned — avoids repaint */
}

.btn:not([data-plate-gsap]):not(.btn-recessed):not(.icon-tile):not(
    .menu-overlay-entry
  ):active {
  transform: scale(0.97);
}

.btn:focus-visible {
  outline: 2px solid var(--text-accent);
  outline-offset: 2px;
}

.btn:disabled {
  opacity: 0.5;
  cursor: not-allowed;
  pointer-events: none;
}

/* Primary CTA — a STRUCK-AMBER PLATE, not a flat fill. The whole game's marks are
   etched into surfaces; buttons join that material world as convex precious metal —
   a top-down light gradient, a static inset bevel for the struck edge, and the label
   sitting debossed in the face. The hero register (.btn-plate: Play, Next Round) adds
   the outer glow + pointer-tracked sheen that make the primary action the brightest
   thing on screen (DESIGN principle 9); in-game primaries (discard "Done", dialogs)
   keep just the material so the petrol stage stays calm. Used for Next Round, Confirm
   Discard, Play, and other proceed actions. --ui-warning (amber) is the action color.
   why: redesign-play-cta-button (buttons as a material system). */
.btn-primary {
  position: relative;
  isolation: isolate; /* own stacking context so the .btn-plate glow sits behind content */
  background: linear-gradient(
    180deg,
    #f6b73e 0%,
    var(--ui-warning) 48%,
    #a1570a 100%
  );
  color: #082f49; /* Dark Petrol text on Gold button */
  font-size: 19px; /* bold 14pt+ → WCAG large text (4.36:1 passes at 3:1 threshold) */
  /* Static struck-metal bevel + grounding elevation. Static box-shadow only — the
     live glow/press ride opacity+transform (the no-animated-box-shadow repaint rule). */
  box-shadow:
    inset 0 1.5px 0 rgba(255, 246, 219, 0.8),
    /* top catch-light edge */ inset 0 3px 5px rgba(255, 228, 176, 0.3),
    /* convex upper sheen */ inset 0 -4px 7px rgba(86, 42, 0, 0.5),
    /* convex lower shade */ 0 5px 14px -4px rgba(0, 0, 0, 0.45),
    /* grounding elevation */ 0 1px 3px rgba(0, 0, 0, 0.3);
  /* The label reads engraved into the gold: a light edge below the dark glyphs. */
  text-shadow: 0 1px 0 rgba(255, 240, 210, 0.4);
}

/* The Play label is glyph-path SVG (text-shadow can't reach it) — a static drop-shadow
   gives the same engraved-into-the-plate edge. */
.btn-plate .play-button-label-text {
  filter: drop-shadow(0 1px 0 rgba(255, 240, 210, 0.45));
}

/* Hero register — the screen-defining CTAs (Play, Next Round). Adds the outer glow
   that makes the primary action the brightest, most-alive thing on screen, and the
   pointer-tracked specular sheen. The vars are the resting state AND the no-GSAP
   fallback; button-plate.js tweens transform + these vars on hover/press. */
.btn-plate {
  --plate-glow: 0.1; /* resting glow (0..1) — the no-GSAP fallback; armed buttons get this from BUTTON_PLATE_DEFAULTS.restGlow (kept equal for reduced-motion parity) */
  --spec-on: 0; /* sheen opacity — hover-driven */
  --spec-intensity: 0.35; /* sheen peak brightness (gradient alpha) — armButtonPlate writes BUTTON_PLATE_DEFAULTS.sheenIntensity; this default is the no-GSAP fallback (kept in parity) */
  --spec-size: 0.42; /* sheen spotlight spread — scales the highlight radius; armButtonPlate writes BUTTON_PLATE_DEFAULTS.sheenSize; this default is the no-GSAP fallback (kept in parity) */
  --spec-x: 50%;
  --spec-y: 0%;
}

/* Outer glow halo — a blurred amber aura BEHIND the plate; opacity is the live knob
   so the glow brightens/dims via compositing, never an animated box-shadow. */
.btn-plate::before {
  content: "";
  position: absolute;
  inset: -4px;
  z-index: -1;
  border-radius: inherit;
  background: var(--ui-warning);
  filter: blur(15px);
  opacity: var(--plate-glow);
  pointer-events: none;
}

/* Specular sheen — a soft highlight tracking the pointer across the face, clipped to
   the rounded plate by border-radius; only present on hover (--spec-on). screen blend
   so it reads as light glancing over the engraved label, not a flat overlay. */
.btn-plate::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: radial-gradient(
    calc(9em * var(--spec-size)) calc(7em * var(--spec-size)) at var(--spec-x)
      var(--spec-y),
    rgba(255, 248, 224, var(--spec-intensity)),
    rgba(255, 248, 224, 0) 70%
  );
  opacity: var(--spec-on);
  mix-blend-mode: screen;
  pointer-events: none;
}

.btn-secondary {
  background: var(--bg-elevated);
  color: var(--text-secondary);
}

/* Destructive CTA — Deep Rose (#be123c, the same hue used as the End-Run topbar
   hover signal and as the Hearts suit color). Reserved for buttons that commit
   irreversible actions (e.g. End-Run inside the confirm dialog). */
.btn-danger {
  background: #be123c;
  color: #fef3c7; /* Cream label on Deep Rose — high-contrast destructive text */
}

.btn-danger:hover {
  background: #9f1239; /* Slightly deeper Rose — pressed-but-not-committed feel */
}

/* Icon tiles — unified clickable icons for all prompts */

.icon-tile {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  /* Engine sets --icon-tile-padding (layout.promptIcon.padding); pure consumption. */
  padding: var(--icon-tile-padding);
  border-radius: var(--radius-lg);
  border: 1px solid rgba(255, 255, 255, 0.1);
  background: rgba(255, 255, 255, 0.03);
  transition:
    transform var(--transition-fast),
    border-color var(--transition-fast);
}

/* The prompt-zone wells are GLASS — "windows into the game" (the button-material
   decision, 2026-06-19: the CTA stays struck-amber metal, the in-game input buttons
   are glass). Warm refractive heritage glass that RECEDES (principle 17, low-weight
   chrome): a faint warm body, real backdrop refraction, and the glass tells that
   carry the material without weight — a crisp Fresnel top rim (the lit pane edge) and
   a whisper of a diagonal catch-light (::before). why: docs/DESIGN.md principle 18. */
.icon-tile {
  background: linear-gradient(
    180deg,
    rgba(255, 226, 184, 0.085) 0%,
    rgba(246, 208, 156, 0.045) 55%,
    rgba(255, 224, 180, 0.07) 100%
  );
  -webkit-backdrop-filter: blur(9px) saturate(1.12) brightness(1.03);
  backdrop-filter: blur(9px) saturate(1.12) brightness(1.03);
  border: 1px solid rgba(250, 222, 178, 0.22);
  box-shadow:
    inset 0 1px 0 rgba(255, 246, 226, 0.34),
    /* the lit Fresnel top rim — the glass pane catching light */ inset 0 -1px
      1px rgba(120, 80, 30, 0.1),
    /* a faint lower lip — the pane's bottom edge has thickness */ inset 0 0 0
      1px rgba(224, 184, 124, 0.05);
  /* No overflow clip: the backdrop-filter and the ::before catch-light are both
     clipped to border-radius natively, so the glass needs no clip here. (Before
     interactive-charts-and-tooltips, this also had to stay clip-free for the tile's
     own `.tooltip` child to escape it — that child is retired; the tile's hover/focus
     tooltip now rides the reparented `.app-tooltip` singleton, never a DOM descendant.) */
}
/* The catch-light glancing off the pane. Its DIRECTION is sun-anchored: animation/
   glare.js writes a per-tile --glare-angle (the tile's vector to the scene's
   focal "sun") plus a shared --glare-shift (the pointer tilting the whole app), and
   this gradient consumes them — CSS owns the material, JS owns the direction (css/
   CLAUDE.md engine-sets/CSS-consumes). The 150deg / 0deg fallbacks keep the shipped
   top-left sheen if the module never runs (no-JS, pre-arm). The peak/mid/reach are the
   approved-prototype values (2026-06-19): bolder than the prior 0.14 faintness so the
   position-correct splay reads at this small tile size — lab-tunable in /button-lab. */
.icon-tile::before {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  z-index: 0;
  border-radius: inherit;
  background: linear-gradient(
    calc(var(--glare-angle, 150deg) + var(--glare-shift, 0deg)),
    rgba(255, 248, 232, 0.3) 0%,
    rgba(255, 248, 232, 0.05) 30%,
    transparent 55%
  );
}
.icon-tile > svg {
  position: relative;
  z-index: 1; /* the etched icon rides above the catch-light */
}

.icon-tile:hover {
  transform: translateY(-2px);
  box-shadow:
    inset 0 1px 0 rgba(255, 248, 230, 0.42),
    /* keep the Fresnel rim, a hair brighter as the hand nears */ 0 4px 16px
      rgba(0, 0, 0, 0.3); /* static, not transitioned — avoids repaint */
  border-color: rgba(252, 230, 190, 0.34);
}

.icon-tile:active {
  transform: scale(0.95);
}

.icon-tile.committing {
  transform: scale(0.94);
  border-color: rgba(255, 255, 255, 0.28);
  background: rgba(255, 255, 255, 0.08);
  box-shadow: 0 6px 18px rgba(0, 0, 0, 0.28);
}

.icon-tile:focus-visible {
  outline: 2px solid var(--text-accent);
  outline-offset: 2px;
}

.icon-tile svg {
  display: block;
}

/* Prompt tiles size their SVG off the engine var instead of a build-baked
   width/height, so a viewport resize repositions them via CSS with no JS rebuild.
   Scoped to a marker class on the prompts rather than a blanket `.icon-tile svg`
   rule so a tile with its own sizing contract can opt out by omitting the marker.
   why: specs/2026-06/2026-06-10-prompt-tile-css-var-sizing-design.md */
.prompt--icon-var .icon-tile > svg {
  width: var(--prompt-icon-size);
  height: var(--prompt-icon-size);
}
.prompt--decision .icon-tile > svg {
  /* keep in sync with PROMPT_DECISION_SCALE (constants.js — the geometry estimate) */
  width: calc(var(--prompt-icon-size) * 1.4);
  height: calc(var(--prompt-icon-size) * 1.4);
}

.btn-small {
  font-size: var(--fs-sm);
  padding: var(--space-sm) var(--space-lg);
}

@media (max-width: 900px) {
  .btn {
    font-size: var(--fs-sm);
    padding: var(--space-sm) var(--space-lg);
  }
}

/* Coarse-pointer (touch) devices — the Android tablets where backdrop-filter is the most
   expensive CSS feature — drop the live refraction; the static glass tells (the warm
   gradient body, the Fresnel rim, the ::before catch-light) carry the material read on
   their own. The blur only resolves over content behind the tile, so at rest over petrol
   it's invisible anyway, and its one cost is re-rastering every tile per frame while cards
   fly behind the prompt during the deal. Fine-pointer (desktop) keeps the full live glass,
   where it's cheap. The ::before catch-light and the Fresnel rim don't use backdrop-filter,
   so they render unchanged here — only the live refraction goes. why: docs/DESIGN.md
   principle 18 (the glass keeps reading as a considered, physical thing while it recedes). */
@media (pointer: coarse) {
  .icon-tile {
    -webkit-backdrop-filter: none;
    backdrop-filter: none;
  }
}

/* === Tooltip === */

/* The app tooltip singleton (js/tooltip.js) — a `popover="manual"` div, reset off the UA's built-in
   popover stylesheet (which sets position:fixed; inset:0; width/height:fit-content; margin:auto;
   border; padding; overflow:auto; color:CanvasText; background:Canvas) into our own petrol pill.
   Painting in the top layer is the whole reason for the Popover API over a plain positioned div —
   it stacks above any open `<dialog>` (the progression overlay's chart, the hands/breakdown
   overlays' bid tags) with zero z-index bookkeeping, the same free lunch `.overlay-panel` gets
   (§ Result Overlays). Positioned via inline left/top (js/tooltip.js#position) — this block owns
   only the box's own geometry and paint, never placement. */
.app-tooltip {
  position: fixed;
  inset: auto;
  /* width/height stay the UA popover default (fit-content) — the pill sizes to its
     label/detail text; only max-width caps a long detail line. */
  margin: 0;
  max-width: 280px;
  padding: var(--space-xs) var(--space-sm);
  border: 1px solid rgba(226, 232, 240, 0.1); /* hairline, the overlay-panel precedent */
  border-radius: var(--radius-md);
  background: linear-gradient(
    180deg,
    rgba(22, 78, 99, 0.55) 0%,
    rgba(8, 47, 73, 0.55) 100%
  ); /* the petrol-surface pill — .result-strip's sheet treatment, compacted */
  overflow: visible;
  /* A display-only affordance — dismissal rides pointerdown/scroll/Escape (js/tooltip.js), never
     a click on the pill itself; matches .hint-reason's precedent (Hints, below). */
  pointer-events: none;
}

.app-tooltip-label {
  font-size: var(--fs-sm);
  color: var(--text-primary);
}

.app-tooltip-detail {
  font-size: var(--fs-xs);
  color: var(--text-secondary);
}

/* Entrance is a keyframe animation on :popover-open, not a transition — a transition can't fire
   from the `display: none` a hidden popover has (there's no prior frame for the property to
   transition away from). Mirrors the overlayBackdropFadeIn precedent (§ Result Overlays).
   One-shot by construction — showPopover() re-triggers the UA's popover-open state fresh on every
   open; the reduced-motion override (last section) collapses it like every other animation here. */
.app-tooltip:popover-open {
  animation: appTooltipIn var(--transition-fast);
}

@keyframes appTooltipIn {
  from {
    opacity: 0;
    transform: translateY(2px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

/* === Chart Interaction === */

/* The break-down chart vocabulary's hover emphasis (interactive-charts-and-tooltips): hovering one
   mark dims its siblings, keyed on `:has()` so no JS class-toggling is needed — charts.js stays a
   pure data producer (see its module doc-comment's "zero host CSS" bend: correctness stays inline,
   this is interaction polish riding a global class hook). `@media (hover: hover)` guards both rules
   so a tap on a touch browser can't leave a sticky dim behind (no pointerleave to ever clear it).
   Opacity-only, docs/PERFORMANCE.md-clean. */
@media (hover: hover) {
  .chart-donut path {
    transition: opacity var(--transition-fast);
  }
  .chart-donut:has(path:hover) path:not(:hover) {
    opacity: 0.72;
  }

  .chart-diverging-hit .chart-diverging-column {
    transition: opacity var(--transition-fast);
  }
  .chart-diverging-columns:has(.chart-diverging-hit:hover)
    .chart-diverging-hit:not(:hover)
    .chart-diverging-column {
    opacity: 0.72;
  }
}

/* === Hints === */

/* Card-specific hints: System Cyan double-glass border.
   Doesn't clash with Schellen (Gold). No physical lift to avoid layout engine conflicts.
   Uses a brightness boost and a one-time light pop for high visibility. */
.card.hint-float {
  border-color: var(--system-hint) !important;
  border-width: 2.5px !important;
  /* Resting shape: thick outer ring + soft glow. The one-shot GSAP ping (ring pop) drives two
     vars, both 0 at rest = this static shape:
       --hint-ping  → brightness flash (filter)
       --hint-ring  → outer-ring expansion (radar pop)
     box-shadow motion is one-shot only, never looping (css/CLAUDE.md). */
  box-shadow:
    0 0 0 calc(3px + var(--hint-ring, 0) * 8px) rgba(34, 211, 238, 0.4),
    0 0 30px rgba(34, 211, 238, 0.5) !important;
  /* Subtle lift via light, not physics; --hint-ping adds the entrance brightness flash
     (GSAP, hints.js#pingHintCard) — the entrance flourish for every hand-card hint
     (trick play + discard). */
  filter: brightness(calc(1.1 + var(--hint-ping, 0) * 0.3));
  /* Keep the card in its natural stacking order — hints should not cover neighboring cards. */
}

/* Button-specific hints: Amber Gold glow.
   Reuses --ui-warning as it works well on the Petrol background. */
.icon-tile.hint-float {
  border-color: var(--ui-warning) !important;
  border-width: 2px !important;
  box-shadow: 0 0 15px rgba(217, 119, 6, 0.3) !important;
  z-index: 150 !important; /* transient / lifted-UI band */
}

.hint-float.selected {
  border-color: var(--ui-warning) !important;
  border-width: 3px !important;
  box-shadow: 0 0 20px rgba(217, 119, 6, 0.5) !important;
}

.hint-reason {
  position: absolute;
  /* top set by layout engine via JS inline style */
  left: 50%;
  transform: translateX(-50%);
  max-width: var(--hint-max-width);
  /* max-width, font-size, padding-x, padding-y set by layout engine
       (computeHintZone) — this block consumes them, no local derivation. */
  padding: var(--hint-padding-y) var(--hint-padding-x);
  background: var(--bg-surface);
  border: 1px solid var(--bg-elevated);
  border-left: 3px solid rgba(var(--glow-gold), 0.6); /* gold accent matching hint glow */
  border-radius: var(--radius-md);
  font-size: var(--hint-font-size);
  line-height: var(
    --hint-line-height
  ); /* engine-set — keeps rendered height matching engine's height model */
  color: var(--text-primary);
  opacity: 0.95;
  pointer-events: none;
  z-index: 140; /* transient band — hint reason text */
}

/* .fresh gates the fade-in so it fires only on new reason content, not on
   every render that happens to rebuild this element. */
.hint-reason.fresh {
  opacity: 0;
  animation: hintReasonFadeIn var(--transition-normal) forwards;
}

@keyframes hintReasonFadeIn {
  to {
    opacity: 0.95;
  }
}

.hint-card-ref {
  font-weight: 700;
  white-space: nowrap;
}

.hint-card-ref svg {
  width: 1.15em;
  height: 1.15em;
  vertical-align: -0.125em; /* lower SVG so bottom edge sits at text baseline */
  margin-right: var(--space-xs);
}

/* Standalone suit pips (no rank text) need to be larger to match text visual weight —
   the pip SVG has internal whitespace that text characters don't. */
.hint-suit-only svg {
  width: 1.4em;
  height: 1.4em;
  vertical-align: 0;
  margin-right: 0;
}

/* === On-Demand Hint Button === */
/* Low-prominence corner affordance — visible when hints are off or delayed.
   Z-index 10 (interactive UI band per the hierarchy at the top of this file).
   Vellum at 0.5 opacity at rest, full opacity on hover; dimmed to 0.2 after
   click. Persistent across renders; .visible class drives the fade in/out. */
.hint-button {
  position: absolute;
  bottom: var(--space-sm);
  /* why: shares the Menu button's engine-set corner inset (--menu-inset), giving the
     top-right and bottom-right chrome a single right-edge x across viewports. */
  right: var(--menu-inset);
  z-index: 82; /* interactive controls band (just above prompts/Menu at 80) */
  background: none;
  border: none;
  /* Engine-driven sizing: matches the prompt-action icon family (icon-tile). */
  padding: var(--icon-tile-padding);
  margin: 0;
  cursor: pointer;
  color: #e2e8f0;
  /* why: button persists across renders (preserved through clearAppForRender)
     and toggles .visible to fade in/out instead of mount/unmount popping. */
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.3s ease;
  line-height: 0;
}

.hint-button.visible {
  opacity: 0.5; /* play-test: rest opacity — present but unobtrusive */
  pointer-events: auto;
}

.hint-button.visible:hover {
  opacity: 1;
}

.hint-button.visible[data-served="true"],
.hint-button.visible[data-served="true"]:hover {
  opacity: 0.2; /* play-test: served-and-dimmed, slightly above invisible */
  cursor: default;
}

/* why: SVG width/height attributes are set at construction, but the button
   persists across renders. Re-binding the SVG to the engine var here keeps
   the icon scaling with the layout when the viewport resizes. */
.hint-button svg {
  width: var(--prompt-icon-size);
  height: var(--prompt-icon-size);
}

/* === Bid Numbers === */

.bid-number {
  position: absolute;
  font-family: "Satoshi", sans-serif;
  font-weight: 700;
  font-size: var(
    --bid-number-size
  ); /* solver-driven — scales with screen size */
  color: var(--text-accent);
  text-shadow: 0 4px 16px rgba(0, 0, 0, 0.6);
  pointer-events: none;
  /* transform used for pulse animation (scale) — keep on main thread
       so getComputedStyle() reads mid-transition values reliably */
  will-change: transform;
  transform-origin: left center;
  font-variant-numeric: tabular-nums;
}

/* === Role Icons === */

.role-icon {
  position: absolute;
  pointer-events: none;
  z-index: 5;
}

/* Persistent role icons — positioned by layout engine anchor points */
.role-icon-slot {
  position: absolute;
  pointer-events: none;
  z-index: 5;
}

.role-icon svg {
  /* Remove baseline descender space so the wrapping .role-icon div bbox
     matches the SVG element bbox exactly. Load-bearing for the role-icon
     vertical clamps in player-side-geometry.js#computeRoleIconSize —
     gh_h = roleSize × 1.8 and banner_h = roleSize hold only when the
     wrapper does not add inline-baseline padding. */
  display: block;
}

/* The bidding constellation + settled ceremony icons size PER PIECE: each SVG
   carries its own width/height attribute, set by the render from the per-piece
   `roleIconConstellation` block (bidding-ui.js — the gatehouse at
   focal.size × GATEHOUSE_SCALE, each banner at its own piece size). There is
   deliberately NO generic `.trick-area .role-icon svg { width: var(--role-icon-size) }`
   arm: a single engine var can't express the per-piece sizes, and such a rule would
   override the per-element attributes — the 22px-squeeze the orbit slice exists to fix.
   Trick-play watermarks size off their own slot via `.trick-slot-icon svg`
   (--trick-slot-icon-size, Trick Slots section); the bidding path rebuilds on resize,
   so it needs no CSS-reflow var. */

.role-icon.neutral {
  color: #e2e8f0; /* Vellum — matches NEUTRAL_CLOTH (constants.js) for non-player surfaces. */
}

/* `.role-icon.friend` / `.role-icon.enemy` were retired with the player-identity work —
   role icons carry role + identity, not role + alliance. Alliance lives on the slot aura
   and player zones (ICONOGRAPHY.md "Coloring on role icons"). */

/* === Position Indicators === */
/* The in-play per-seat strip: for the two opponents a quiet etched name row above
   the position glyph (Geben/Hören/Sagen), plus on the declarer's seat the relocated
   bid/Hand/Ouvert; the human carries the glyph alone. The glyphs also live on the
   FINISHED slot nameplates. CSS-sized off the engine's --indicator-size; placed
   absolutely by JS (human cup / opponent corner —
   position-indicator.js#placePositionIndicator). No tooltip — the icon is the
   board's language (ICONOGRAPHY § Tooltips). */
.position-indicator {
  /* The seat's indicator: an opponent name row stacked above the icon strip — the
     position icon plus, for the declarer, the relocated declarer-state cells.
     Centred on the engine spot by JS (position-indicator.js).
     why: the cell proportions below are calc'd off the engine-pushed
     --indicator-size — within "engine owns every multiply" (continuous
     proportions of one engine value, not the media-query step-down or vw/vh
     desync the convention guards). The var is set PER ELEMENT (each seat's own
     size, in placePositionIndicator), not as one shared root var: the human's
     roomy central cup reads larger than the cramped opponent corners (PRIMACY §
     Seat is a primacy dimension). The engine clamps each seat's --indicator-size
     against its own room — the human's vertical pocket, each opponent's
     horizontal corner footprint (layout-engine.js) — so the wider declarer strip
     fits narrow corners and the human still reaches its readability floor. */
  position: absolute;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: calc(var(--indicator-size) * 0.18);
  color: var(--text-secondary);
  pointer-events: none;
  /* Scoped child z, NOT a band: each seat's strip is nested inside its seat container
     (the human's in .hand 70, an opponent's in .opponent 40), so it rides that
     container's band and 3 only orders it among the seat's own children. Its notional
     primacy band (4–5) can't be expressed by container-z without hoisting it out of the
     seat — the deferred per-seat-strip divergence. See the z-index spec § divergences. */
  z-index: 3;
}

/* The icon + declarer-state row. */
.position-strip-main {
  display: flex;
  align-items: center;
  gap: calc(var(--indicator-size) * 0.22);
  height: var(--indicator-size);
}

.position-indicator-icon {
  width: var(--indicator-size);
  height: var(--indicator-size);
  flex: none;
}

/* Hand/Ouvert feat cells — a touch smaller than the position glyph. */
.position-strip-feat {
  width: calc(var(--indicator-size) * 0.82);
  height: calc(var(--indicator-size) * 0.82);
  flex: none;
}

/* The bid value: a bright-white number; the trump/suit colour rides the rule
   beneath it (.bid-underline, --bid-accent set inline per game), so the number
   stays clean and the colour does the tie-to-trump work (spec § Bid treatment). */
.position-strip-bid {
  font-size: calc(var(--indicator-size) * 0.62);
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  line-height: 1;
  color: #f1f5f9;
}

/* The trump-coloured rule beneath the number — the colour without a UI box. */
.bid-underline {
  padding-bottom: calc(var(--indicator-size) * 0.08);
  border-bottom: calc(var(--indicator-size) * 0.1) solid var(--bid-accent);
}

/* Identity row above the icon strip (opponents only): the seat's persona bust
   beside the etched name — the court's "who sits here" mark (IDENTITY § The
   court). Rides the name's legibility gate: below the corner floor the seat
   keeps the position glyph alone. */
.position-strip-name-row {
  display: flex;
  align-items: center;
  gap: calc(var(--indicator-size) * 0.14);
}

.position-strip-persona {
  /* play-test: the bust outsizes even the position glyph — the identity figure
     is the row's mark, the name the quiet annotation beside it */
  width: calc(var(--indicator-size) * 1.2);
  height: calc(var(--indicator-size) * 1.2);
  flex: none;
}

.position-strip-persona svg {
  display: block;
  width: 100%;
  height: 100%;
}

/* Quiet etched name row (opponents only) — the glyph-word SVG above the icons,
   poking out beyond the corner pocket toward the screen corner. An SVG, not text:
   the names enter via the etch gesture (animation/bidding-stage-entrance.js). Sized by HEIGHT
   off the engine's --indicator-size (width follows the wordmark's aspect via the
   viewBox); overflow visible so the etch's round-capped stroke isn't clipped. */
.position-strip-name {
  display: block;
  height: calc(var(--indicator-size) * 0.5);
  width: auto;
  overflow: visible;
  opacity: 0.8;
}

.position-indicator-icon svg,
.position-strip-feat svg {
  /* Block strips baseline descender space so the wrapper bbox matches the SVG
     (the role-icon convention) — keeps the engine-clamped size exact. */
  display: block;
  width: 100%;
  height: 100%;
}

/* === Bidding Stage === */
/* Three banners at trick slot positions + gatehouse at sun — all positioned via JS inline styles.
   No CSS positioning needed. Banner elements use .bid-banner class. */

.bid-banner {
  pointer-events: none;
}

/* The bidding constellation (gatehouse + the three upright standards) and the
   settled-ceremony icons (claimed gatehouse + paired defenders + Ramsch toppled
   banners) size PER ELEMENT inline from their own constellation piece — set by
   bidding-ui.js, not a shared CSS var. Under the per-piece shape (§ _Output_) each
   gatehouse/banner can render at its own size, which a single var can't express;
   the engine relocates + grows them past the squeezed at-sun size to honour the
   readability floor (phone-landscape) without the trick watermarks (slot-sized via
   --trick-slot-icon-size) inheriting it. The open gatehouse carries the GATEHOUSE_SCALE
   (1.8×) multiply in that JS inline path now (appendBiddingGatehouse); the
   settled icons recede to --trick-slot-icon-size only at the trick-play boundary, a
   FLIP (declaration-reveal.js). The trick-watermark `.trick-slot-icon svg` rule (Trick
   Slots section) carries that slot-derived var. */

/* Trick slot fade-in — slots materialize around ceremony icons */
.trick-slot.slot-fade-in {
  opacity: 0;
  transition: opacity 300ms ease-in;
}
.trick-slot.slot-fade-in.slot-visible {
  opacity: 1;
}

/* === Result === */

/* The result lives ON the table since slice 1a (round-end design): the board
   in outcome posture is the surface, the strip below carries "what now?", and
   dense data is summonable as overlays. The scrolling result page, its scene
   tableau, and the html:has(.result) scroll carve-out retired with it. */

/* --- Result strip --- */

/* The compact rest bar — reason chip + KPIs + Next Round + overlay
   affordances. Two homes since slice 4 (the strip left the prompt zone):
   a score-story round carries it as the panel's LAST ROW (one object the
   assembly pass places), a no-panel round (Null/Ramsch/overbid) mounts it
   standalone on #skat-app, engine-positioned at the letterbox rest
   (result-ui.js#placeStandaloneStrip). The petrol sheet treatment survives
   from the retired result card, compacted. */
.result-strip {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-sm);
  /* keep in sync with RESULT_STRIP_MAX_WIDTH_PX (prompt-ui.geometry.js) */
  max-width: min(760px, 92%);
  padding: var(--space-md) var(--space-lg);
  border-radius: var(--radius-lg);
  background: linear-gradient(
    180deg,
    rgba(22, 78, 99, 0.55) 0%,
    rgba(8, 47, 73, 0.55) 100%
  );
  border: 1px solid rgba(226, 232, 240, 0.1);
  pointer-events: auto; /* the board-layer host is pointer-events: none */
  text-align: center;
}

/* The panel row (score-story rounds): the strip rides the panel's flex column,
   its gap below the duel bar engine-set (--result-strip-gap collapses with the
   FILLER curve when cramped — result-ui.js#applyResultElasticSizing). */
.result-score-story > .result-strip {
  align-self: center;
  margin-top: var(--result-strip-gap, 24px);
}

/* The standalone home (no-panel rounds): engine-positioned via inline left/top
   (bottom-anchored at the letterbox band), interactive-controls z like the
   prompt band it replaced. The explicit width carries the old zone's budget:
   an absolutely-positioned element with only a left coordinate shrinks-to-fit
   against the space RIGHT of it (half the container) and would wrap into a
   tower on narrow viewports — the failure the retired zone rule guarded.
   92% of #skat-app mirrors the geometry model's containerWidth * 0.92
   (RESULT_STRIP_MAX_WIDTH_PX keep-in-sync). */
#skat-app > .result-strip {
  position: absolute;
  z-index: 80;
  width: min(760px, 92%);
}

.result-strip-reason {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-md);
  flex-wrap: wrap;
}

.result-strip-actions {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-md);
  flex-wrap: wrap;
}

.result-strip-affordances {
  display: inline-flex;
  align-items: center;
  gap: var(--space-xs);
}

/* The strip's overlay affordances (Details/History/Stats/Tricks) — icon-only
   glass tiles, the Menu-button pattern (§ Icon tiles above): sized off
   --menu-size directly — since menu-on-start-screen-and-sizing all glass chrome
   tiles share one engine-set size (the old --result-affordance-size = --menu-size × 2
   derived var is retired; doubling MENU's constants in constants.js now produces
   that exact curve directly). `.icon-tile`'s intrinsic padding is zeroed like
   `.menu-button`'s, since the square size is explicit. */
.result-affordance {
  width: var(--menu-size);
  height: var(--menu-size);
  padding: 0;
}

/* Icon keeps the prompt tiles' icon:tile ratio (icon = tile ÷ 1.4, the
   .prompt--decision calc above) — the same calc-off-engine-var idiom, no
   further engine var needed at this size. */
.result-affordance > svg {
  width: calc(var(--menu-size) / 1.4);
  height: calc(var(--menu-size) / 1.4);
}

/* why: the strongbox's drawn content is ~27.5/48 units tall where the other three
   marks fill ~34/48 — ×1.25 normalizes the rendered height across the four tiles.
   The correction lives here because createWorthChest is a shared stat-icons cut
   (overlay headers, /games rows consume the same 48-box geometry). */
.result-affordance--chest > svg {
  width: calc(var(--menu-size) / 1.4 * 1.25);
  height: calc(var(--menu-size) / 1.4 * 1.25);
}

/* The strip's KPI tiles ride the summary-metric family, compacted to row
   scale — the full-size tiles live in the breakdown overlay. */
.result-strip .result-summary-metric {
  min-width: 72px;
  padding: var(--space-xs) var(--space-sm);
}

.result-strip .result-summary-metric-value {
  font-size: var(--fs-md);
}

/* --- The strip's Net tile: glass, icon lede, stacked values --- */

/* Andre's pixel-review recomposition, round two: the Net chip is a full member
   of the glass row — icon-tile material (the JS swaps its metric-family class,
   result-strip.js), the affordance tiles' engine height, the Bilanz scale at
   the same icon box leading on the left, and the values stacked beside it:
   cumulative total on top, the round's delta on its own row below. Scoped to
   the strip; the breakdown overlay's KPI row keeps the metric-family shape. */
.result-strip .result-net {
  display: flex;
  flex-direction: row;
  align-items: center;
  gap: var(--space-sm);
  height: var(--menu-size);
  padding: 0 var(--space-md);
  font-variant-numeric: tabular-nums; /* kept from the metric family it left */
}

/* Lift the tile's content above the ::before catch-light (z-index 0) — the
   affordance tiles get this from `.icon-tile > svg`; the Net tile's children
   are a label host and a value column, not a bare svg. */
.result-strip .result-net > * {
  position: relative;
  z-index: 1;
}

/* The Bilanz scale matches the affordance icons' box and brightness: the same
   ÷1.4 engine ratio (the attr size stays the overlay's 16 — CSS overrides it
   here, the prompt-tile engine-var sizing idiom), lifted from the label host's
   muted secondary to primary vellum. */
.result-strip .result-net .result-summary-metric-label {
  color: var(--text-primary);
}

.result-strip .result-net .result-summary-metric-label > svg {
  width: calc(var(--menu-size) / 1.4);
  height: calc(var(--menu-size) / 1.4);
}

.result-strip .result-net .result-summary-metric-value {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  line-height: 1.15;
}

/* The total reads neutral vellum — the win/loss story rides the delta row
   below it (the span keeps its sign class; presentation overrides it here). */
.result-strip .result-net .result-summary-metric-value > span:first-child {
  color: var(--text-primary);
}

.result-strip .result-net .result-net-last-round {
  margin-left: 0;
}

/* The single primary action — oversized type like its game-start twin, but
   width follows content on a horizontal bar (the 61.8%-of-card share died
   with the card). */
.result-strip .result-next-round {
  font-size: var(--fs-lg);
  padding: var(--space-sm) var(--space-2xl);
  position: relative;
}

/* --- Special note --- */

/* Overbid / Durchmarsch — quiet typographic note with amber rule, not a
   coloured pill. Rides the strip's reason row. */
.result-strip-note {
  padding: 2px 0 2px var(--space-md);
  border-left: 3px solid var(--ui-warning);
  color: #fcd34d;
  font-size: var(--fs-xs);
  font-weight: 700;
  letter-spacing: 0.18em;
  text-transform: uppercase;
}

/* --- Skat block --- */

/* The skat block lives as a flex child of .result-summary-row alongside the
   KPI metrics and the Show-score-breakdown toggle, so the top-of-card row
   carries result + skat + the hand affordance in one read. */
.result-skat-block {
  display: inline-flex;
  align-items: center;
  gap: var(--space-md);
  flex: 0 1 auto;
  flex-wrap: wrap;
}

.result-skat-label {
  font-size: var(--fs-xs);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--text-secondary);
  font-weight: 700;
  white-space: nowrap;
}

.result-skat-cards {
  display: inline-flex;
  gap: 4px;
}

.result-mini-card {
  height: 56px;
  width: calc(56px * var(--card-ratio));
}

.result-mini-card svg {
  width: 100%;
  height: 100%;
}

/* --- Metrics + breakdown --- */

.result-breakdown-wrapper {
  display: flex;
  flex-direction: column;
  gap: var(--space-sm);
}

.result-summary-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-md);
  flex-wrap: wrap;
}

.result-summary-metrics {
  display: flex;
  align-items: stretch;
  justify-content: flex-start;
  gap: var(--space-sm);
  flex-wrap: wrap;
  flex: 0 1 auto;
}

.result-summary-metric {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 2px;
  min-width: 96px;
  padding: var(--space-sm) var(--space-md);
  border-radius: var(--radius-md);
  border: 1px solid rgba(226, 232, 240, 0.1);
  background: rgba(226, 232, 240, 0.03);
  font-variant-numeric: tabular-nums;
  text-align: left;
}

.result-summary-metric-label {
  font-size: var(--fs-xs);
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--text-secondary);
  font-weight: 500;
  /* The Bid tile's weight glyph replaces this label's text (number-glyphs-result-summary-overlays)
     — inline-flex fixes the SVG's baseline gap; harmless on the text-only Round-result tile. */
  display: inline-flex;
  align-items: center;
  gap: 4px;
}

.result-summary-metric-value {
  font-size: var(--fs-lg);
  font-weight: 700;
  color: var(--text-primary);
  letter-spacing: -0.01em;
}

.result-net-last-round {
  margin-left: 0.5em;
  font-size: var(--fs-sm);
}

/* The Net chip's click-through to the session's Bilanz progression overlay
   (result-bilanz-chip-redesign) — cursor + a quiet focus ring since the tile
   picks up role="button"/tabindex keyboard affordance in JS. */
.result-net-clickable {
  cursor: pointer;
}

.result-net-clickable:focus-visible {
  outline: 2px solid var(--text-accent);
  outline-offset: 2px;
}

/* Promoted buttons in the summary row carry extra padding so their height
   matches the badge tiles next to them (~62 px). fs-sm text keeps the
   row from getting too wide for a single line at 1080p. why: vertical
   padding 22 px ≈ (badge height 62 - fs-sm line height ~17) / 2 — derived
   from the metric tile rather than picked by feel. */
.result-summary-row .btn-secondary {
  font-size: var(--fs-sm);
  padding: 22px var(--space-lg);
}

.result-breakdown {
  padding: var(--space-md) 0 0;
  border-top: 1px solid rgba(226, 232, 240, 0.1);
  text-align: left;
}

.result-breakdown-grid {
  display: grid;
  gap: 0;
}

.result-breakdown-row {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  gap: var(--space-md);
  padding: var(--space-xs) 0;
  border-bottom: 1px solid rgba(226, 232, 240, 0.06);
}

.result-breakdown-row:last-child {
  padding-bottom: 0;
  border-bottom: none;
}

.result-breakdown-row-total {
  margin-top: var(--space-xs);
  padding-top: var(--space-sm);
  border-top: 1px solid rgba(217, 119, 6, 0.4);
  border-bottom: none;
}

.result-breakdown-label {
  color: var(--text-secondary);
  font-size: var(--fs-sm);
}

.result-breakdown-value {
  color: var(--text-primary);
  font-size: var(--fs-sm);
  font-weight: 700;
  font-variant-numeric: tabular-nums;
}

.result-breakdown-base-value {
  display: inline-flex;
  align-items: center;
  gap: var(--space-xs);
}

.result-breakdown-base-value-icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 18px;
  height: 18px;
}

.result-breakdown-base-value-icon svg,
.result-breakdown-base-value-icon .mock-suit-pip,
.result-breakdown-base-value-icon .mock-chalice,
.result-breakdown-base-value-icon .mock-null-zero,
.result-breakdown-base-value-icon .mock-fallen-chalice {
  width: 18px;
  height: 18px;
}

/* Number-glyph label/value hosts (number-glyphs-result-summary-overlays): icon + text sit on one
   line, mirroring .overlay-bid-tag's inline-flex fix for the same SVG-baseline-gap issue. */
.result-breakdown-game-value-label,
.result-breakdown-augen-value {
  display: inline-flex;
  align-items: center;
  gap: 4px;
}

.result-breakdown-total {
  color: var(--text-accent);
  font-size: var(--fs-lg);
  font-weight: 700;
}

/* --- Actions --- */

/* φ-pop entrance — the result strip's "play a round" CTA pops in as the cascade's
   final beat (scale 0.8 → φ peak at the golden seam → settle to 1). The game-start
   Play button no longer shares it — its entrance became the etch introduction, which
   dropped the φ-pop (DESIGN § 15) — so this pop now stands alone. */
/* `backwards`, not `both`: it still hides the button through the delay, but
   releases the fill at the end (end state == natural state) — a `both`/
   `forwards` fill would pin the final keyframe's transform and override the
   .btn:hover / .btn:active feedback for as long as .fresh persists. */
.result-strip.fresh .result-next-round {
  animation: result-cta-pop 0.5s ease-out 0.65s backwards;
}

@keyframes result-cta-pop {
  0% {
    opacity: 0;
    transform: scale(0.8) translateY(14px);
    /* ≈ GSAP power1.out into the peak */
    animation-timing-function: cubic-bezier(0.25, 0.46, 0.45, 0.94);
  }

  61.8% {
    opacity: 1;
    /* play-test: gentle pop peak 1 + 1/φ³ ≈ 1.236 — softened from the bolder φ
       overshoot (1.618) so the per-round entrance doesn't wear out. */
    transform: scale(1.236) translateY(0);
    /* ≈ GSAP power1.inOut on the settle */
    animation-timing-function: cubic-bezier(0.455, 0.03, 0.515, 0.955);
  }

  100% {
    opacity: 1;
    transform: scale(1);
  }
}

/* --- Staggered reveal --- */
/* .fresh on the strip / dock roots gates these keyframes so the staggered
   fade-ups fire on first render of a round's result, not on reload or
   recovery re-renders. (The board actors' entrance is the Trick Slots
   section's rest-fade-in — different root, same freshness decision.) */

@keyframes result-fade-up {
  from {
    opacity: 0;
    transform: translateY(8px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.result-strip.fresh .result-reveal-2 {
  animation: result-fade-up 0.3s ease-out 0.2s both;
}
.result-strip.fresh .result-reveal-3 {
  animation: result-fade-up 0.3s ease-out 0.35s both;
}
.result-strip.fresh .result-reveal-4 {
  animation: result-fade-up 0.3s ease-out 0.5s both;
}
.result-strip.fresh .result-reveal-5 {
  animation: result-fade-up 0.3s ease-out 0.65s both;
}

/* Narrow overlay panels: the breakdown overlay's summary row and tables
   stack. (The strip's own metrics stay row-shaped — they're compact tiles.) */
@media (max-width: 700px) {
  .overlay-panel .result-summary-row {
    flex-direction: column;
  }

  .overlay-panel .result-summary-metrics {
    flex-direction: column;
  }

  .overlay-panel .result-summary-metric {
    width: 100%;
    min-width: 0;
  }

  .result-breakdown-row {
    grid-template-columns: 1fr;
  }
}

/* === Result Overlays === */

/* The summonable vellum surfaces of the FINISHED board: the shared native
   `<dialog>` shell (overlay-shell.js) and the overlays built on it. Promoted
   to a top-level section from the Result panel — the shell is app-wide chrome
   that the pause menu, identity, reference, and confirm overlays all build on. */

/* --- Overlays (shell + hands) --- */
/* The vellum overlay vocabulary (overlay-shell.js, js root): a native `<dialog>` shown with
   showModal(), shared app-wide (hands / breakdown / history / run stats / round inspector, the
   pause menu, the games overlay, the identity overlay, confirm-dialog.js). The top layer stacks
   the dialog above everything by construction — no z-index needed. The panel's max-height +
   overflow-y IS the height budget — an open overlay owns the interaction
   surface, so internal scroll is legitimate here (round-end design §4). */
.overlay-panel {
  /* Re-center in the top layer: a modal <dialog> centers via the UA's margin: auto,
     but the Base reset (`* { margin: 0 }`) zeroes it — restore it here. */
  margin: auto;
  background: linear-gradient(
    180deg,
    rgba(22, 78, 99, 0.95) 0%,
    rgba(8, 47, 73, 0.98) 100%
  );
  border: 1px solid rgba(100, 180, 220, 0.2);
  border-radius: 16px;
  padding: var(--space-xl);
  max-width: 980px;
  width: 94vw;
  max-height: 90vh;
  overflow-y: auto;
  /* Horizontal scrollbars are never a legitimate overlay behavior — `overflow-y: auto` alone
     would force overflow-x to compute to auto, so a decorative hover transform poking past the
     panel edge (the menu rows' translateX slide) spawns one. `clip` computes to hidden next to
     a scrolling axis: same guarantee, structurally. Transforms must still reserve their own
     room or they clip — see .menu-overlay-list. */
  overflow-x: clip;
  display: flex;
  flex-direction: column;
  gap: var(--space-md);
  /* One shared entrance for every overlay (overlay-shell-header-navigation design §5) — the shape
     confirm-dialog.js and the identity overlay already play-tested, now named for the shell and
     promoted to the base so all ~9 overlays arrive identically. Variant sections layer their own
     overrides on top by source order; none re-declare `animation` for the entrance anymore. */
  animation: overlayPanelIn var(--transition-normal);
}

@keyframes overlayPanelIn {
  from {
    opacity: 0;
    transform: scale(0.96);
  }
  to {
    opacity: 1;
    transform: scale(1);
  }
}

/* The dim + fade-in ride the dialog's own ::backdrop pseudo-element — no separate backdrop div to
   center or dim. Native UA margins center the dialog itself in the top layer. */
.overlay-panel::backdrop {
  background: rgba(2, 12, 20, 0.85);
  animation: overlayBackdropFadeIn var(--transition-fast);
}

@keyframes overlayBackdropFadeIn {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

.overlay-header {
  text-align: center;
}

.overlay-title-row {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-sm);
}

.overlay-header-icon {
  width: 28px;
  height: 28px;
}

.overlay-title {
  font-size: var(--fs-lg);
  font-weight: 700;
}

.overlay-subtitle {
  font-size: var(--fs-sm);
  color: rgba(226, 232, 240, 0.45);
  margin-top: 2px;
}

/* The one back affordance (overlay-shell.js §2 — setBack): a quiet square hit-target leading the
   title row, a text chevron (typography, not a new icon family — low-weight chrome stays plain
   per ICONOGRAPHY). ≥36px hit target per touch-target accessibility guidance. */
.overlay-back-btn {
  min-width: 36px;
  min-height: 36px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: none;
  border: none;
  border-radius: var(--radius-md);
  color: rgba(226, 232, 240, 0.7);
  font-size: var(--fs-xl);
  line-height: 1;
  cursor: pointer;
  transition: background-color var(--transition-fast);
}

.overlay-back-btn:hover {
  background-color: rgba(226, 232, 240, 0.1);
  color: var(--text-primary);
}

.overlay-hand-section {
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.overlay-hand-label {
  display: flex;
  align-items: center;
  gap: var(--space-sm);
  font-size: var(--fs-base);
  font-weight: 500;
  color: rgba(226, 232, 240, 0.6);
}

.overlay-player-name {
  color: rgba(226, 232, 240, 0.8);
}

.overlay-declarer-tag {
  font-size: var(--fs-sm);
  background: rgba(217, 119, 6, 0.15);
  border: 1px solid rgba(217, 119, 6, 0.3);
  border-radius: 10px;
  padding: 2px 10px;
  color: #fcd34d;
}

.overlay-role-tag {
  font-size: var(--fs-sm);
  background: rgba(226, 232, 240, 0.06);
  border: 1px solid rgba(226, 232, 240, 0.1);
  border-radius: 10px;
  padding: 2px 10px;
  color: rgba(226, 232, 240, 0.35);
}

.overlay-bid-tag {
  font-size: var(--fs-sm);
  color: rgba(226, 232, 240, 0.45);
  margin-left: auto;
  /* Icon + number sit on one line since the weight glyph replaced the "Bid:" text
     (number-glyphs sweep); harmless on the text-only pass tag. */
  display: inline-flex;
  align-items: center;
  gap: 4px;
}

.overlay-card-row {
  display: flex;
  gap: 3px;
  flex-wrap: wrap;
}

.overlay-card {
  height: 94px;
  width: calc(94px * var(--card-ratio));
}

.overlay-card svg {
  width: 100%;
  height: 100%;
}

.overlay-exchange {
  display: flex;
  align-items: center;
  gap: var(--space-sm);
  margin-top: 4px;
  padding: 6px 10px;
  background: rgba(226, 232, 240, 0.03);
  border-radius: 6px;
  border: 1px solid rgba(226, 232, 240, 0.05);
}

.overlay-exchange-label {
  font-size: var(--fs-sm);
  color: rgba(226, 232, 240, 0.45);
  white-space: nowrap;
}

.overlay-exchange-cards {
  display: flex;
  gap: 3px;
}

.overlay-exchange-card {
  height: 70px;
  width: calc(70px * var(--card-ratio));
}

.overlay-exchange-card svg {
  width: 100%;
  height: 100%;
}

.overlay-exchange-arrow {
  color: rgba(226, 232, 240, 0.25);
  font-size: var(--fs-base);
}

.overlay-hand-badge {
  font-size: var(--fs-sm);
  display: inline-flex;
  align-self: flex-start;
  background: rgba(217, 119, 6, 0.1);
  border: 1px solid rgba(217, 119, 6, 0.2);
  border-radius: 6px;
  padding: 4px 10px;
  color: rgba(252, 211, 77, 0.7);
  margin-top: 4px;
}

.overlay-ramsch-skat-note {
  display: flex;
  align-items: center;
  gap: var(--space-sm);
  padding: 8px 12px;
  background: rgba(226, 232, 240, 0.03);
  border-radius: 8px;
  border: 1px solid rgba(226, 232, 240, 0.06);
  font-size: var(--fs-sm);
  color: rgba(226, 232, 240, 0.45);
}

.overlay-actions {
  display: flex;
  justify-content: center;
  margin-top: var(--space-sm);
}

.overlay-close-btn {
  min-width: 120px;
}

/* The Bilanz progression overlay (result-bilanz-chip-redesign): the chart is fully
   self-contained (charts.js#buildDivergingColumns paints its own inline styles), so this panel's
   only job is centering it under the header — a delta on the shared `.overlay-panel` base, same
   shape as `.result-inspect-panel` below. A long session's column count can outgrow the panel's
   width; the base's own scroll (overflow-y: auto) doesn't cover the horizontal case, so this is
   the one overlay content area that scrolls sideways rather than clipping columns. */
.result-progression-panel {
  align-items: center;
}

.result-progression-panel .result-progression-scroll {
  max-width: 100%;
  overflow-x: auto;
}

/* --- Confirm dialog (variant) --- */
/* Themed replacement for window.confirm() at destructive moments (confirm-dialog.js) — the shell's
   `role: "alertdialog"` caller. Compact panel, hard backdrop dim: the dialog IS the focal moment;
   the game recedes. `.confirm-dialog-panel` carries only its deltas from the shared `.overlay-panel`
   base (narrower; the entrance now rides the shared base too) — moved here from its own section
   once confirm-dialog.js migrated onto the shell (base backdrop/panel rules retired with the
   migration; only the surviving variant + content builders remain). */
.confirm-dialog-panel {
  max-width: 420px;
  width: 90vw;
}

.confirm-dialog-title {
  font-size: var(--fs-xl);
  font-weight: 700;
  color: var(--text-primary);
}

.confirm-dialog-body-group {
  display: flex;
  flex-direction: column;
  gap: var(--space-md);
}

.confirm-dialog-body {
  font-size: var(--fs-base);
  color: var(--text-secondary);
  line-height: 1.5;
}

/* why: single-button (acknowledge mode) reads as off-balance against the
   right edge — center-align so it feels like a friendly acknowledgment, not
   a decision still pending. */
.confirm-dialog-actions:has(button:only-child) {
  justify-content: center;
}

.confirm-dialog-actions {
  display: flex;
  justify-content: flex-end;
  gap: var(--space-md);
  margin-top: var(--space-sm);
}

@media (max-width: 700px) {
  /* The round-inspector overlay renders the shared round-inspection fragment,
     whose trick fans are tuned to the near-full-width box the /games page gives
     them (viewport minus the page's own padding). The base overlay panel (94vw +
     space-xl padding) is ~40px narrower, which clips the 10-card first-trick fan
     into a horizontal scroll at 390px. Give the inspect panel the page's
     effective width so the fragment fits identically on all three consumers. */
  .overlay-panel.result-inspect-panel {
    width: 98vw;
    padding: var(--space-lg) var(--space-md);
  }
}

/* === Score Story === */

/* The round-end score show: the value-math story (jack row + multiplier
   equation + Augen duel bar) and its reveal choreography. Board-native since
   S3 — the score-story-board-* layer docks the panel in the band the actors
   vacate. Promoted from the Result panel; the board path reuses this family. */

/* --- Score story --- */

/* The visual score explanation for suit/Grand rounds: jack row + multiplier
   equation on top, the augen duel bar below. Resting state of the fresh-result
   choreography (animation/score-story-reveal.js); position: relative anchors
   the choreography's transient fliers. Surface matches the summary-metric
   tile family. */
.result-score-story {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: var(--space-md);
  padding: var(--space-md) var(--space-lg);
  border-radius: var(--radius-md);
  border: 1px solid rgba(226, 232, 240, 0.1);
  background: rgba(226, 232, 240, 0.03);
}

.score-story-formula {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-lg);
  flex-wrap: wrap;
}

.score-story-jacks {
  display: inline-flex;
  gap: var(--space-sm);
}

.score-story-jack-slot {
  position: relative;
  padding: 3px;
  border-radius: 6px;
  border: 1px dashed rgba(226, 232, 240, 0.25);
}

.score-story-jack-slot.held {
  border-style: solid;
  border-color: rgba(226, 232, 240, 0.18);
}

/* A jack the declarer didn't hold: the "transparent jack" — present so the
   row always reads ♣♠♥♦, faint so held vs missing is one glance. */
.score-story-jack-slot.ghost .result-mini-card {
  opacity: 0.22;
}

/* The consecutive mit/ohne run — amber ring marks the slots that count
   (held ones for "mit", empty ones for "ohne"); where the ring stops is
   where the run breaks. Static glow, never animated (box-shadow rule). */
.score-story-jack-slot.counted {
  border-color: var(--ui-warning);
  box-shadow: 0 0 10px rgba(217, 119, 6, 0.35);
}

.score-story-equation {
  display: inline-flex;
  align-items: center;
  gap: var(--space-sm);
  flex-wrap: wrap;
  justify-content: center;
  font-variant-numeric: tabular-nums;
}

.score-story-chip {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 3px 9px;
  border-radius: var(--radius-sm);
  border: 1px solid rgba(226, 232, 240, 0.14);
  background: rgba(226, 232, 240, 0.05);
  font-size: var(--fs-sm);
  font-weight: 700;
  color: var(--text-primary);
  white-space: nowrap;
}

/* Threshold chips (Schneider/Schwarz) carry the earned-amber accent the
   counted jack run also wears. */
.score-story-chip[data-chip="schneider"],
.score-story-chip[data-chip="schwarz"] {
  border-color: var(--ui-warning);
}

.score-story-chip-icon {
  display: inline-flex;
}

.score-story-op {
  color: var(--text-secondary);
  opacity: 0.7;
  font-weight: 500;
}

.score-story-mult {
  font-size: var(--fs-lg);
  font-weight: 700;
  color: var(--text-primary);
  /* digit-rise count-ups: held digits must not shift when a neighbor changes */
  font-variant-numeric: tabular-nums;
}

.score-story-base {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 3px 9px;
  border-radius: var(--radius-sm);
  border: 1px solid rgba(226, 232, 240, 0.14);
  background: rgba(226, 232, 240, 0.05);
}

.score-story-base-value {
  font-weight: 700;
  color: var(--text-primary);
}

.score-story-value {
  font-size: var(--fs-xl);
  font-weight: 700;
  color: var(--text-primary);
}

/* --- Augen duel bar --- */

.score-story-duel {
  display: flex;
  align-items: center;
  gap: var(--space-md);
  /* headroom for the threshold badges floating above the bar */
  margin-top: var(--space-sm);
}

.score-story-augen {
  display: inline-flex;
  /* center, not baseline: the Zählbogen aligns on its own box — an SVG has no
     text baseline to share with the value beside it. */
  align-items: center;
  gap: 4px;
  min-width: 72px;
  justify-content: center;
}

.score-story-augen-icon {
  color: var(--text-secondary);
}

.score-story-augen-value {
  font-size: var(--fs-lg);
  font-weight: 700;
  color: var(--text-primary);
  /* digit-rise count-ups: held digits must not shift when a neighbor changes */
  font-variant-numeric: tabular-nums;
}

.score-story-worth {
  display: inline-flex;
  align-items: center;
  gap: 6px;
}

.score-story-bar {
  position: relative;
  flex: 1 1 auto;
  height: 14px;
  border-radius: 7px;
  background: rgba(2, 12, 20, 0.45);
  border: 1px solid rgba(226, 232, 240, 0.12);
}

.score-story-fill {
  position: absolute;
  top: 0;
  bottom: 0;
}

.score-story-fill.declarer {
  left: 0;
  border-radius: 7px 0 0 7px;
  transform-origin: left center;
}

.score-story-fill.defenders {
  right: 0;
  border-radius: 0 7px 7px 0;
  transform-origin: right center;
}

/* Friend/enemy alliance tones — the established light-vs-dark axis, reusing
   the opponent identity slates for the enemy side. */
.score-story-fill.friend {
  background: linear-gradient(180deg, #f1f5f9, #cbd5e1);
}

.score-story-fill.enemy {
  background: linear-gradient(180deg, #475569, #334155);
}

/* Marks measure declarer augen left-to-right: 61 = the win line; 90 and 30 =
   the two Schneider thresholds (declarer's and the defenders' own 90). */
.score-story-mark {
  position: absolute;
  top: -3px;
  bottom: -3px;
  width: 2px;
  transform: translateX(-50%);
}

.score-story-mark-win {
  background: rgba(226, 232, 240, 0.7);
}

.score-story-mark-schneider {
  background: rgba(226, 232, 240, 0.18);
}

/* Earned threshold badge floats above the bar at the threshold it marks. */
.score-story-badge {
  position: absolute;
  top: -8px;
  transform: translate(-50%, -100%);
  display: inline-flex;
  padding: 4px;
  border-radius: 50%;
  background: var(--bg-elevated);
  border: 1px solid var(--ui-warning);
  z-index: 1;
}

/* --- Score story reveal state --- */

/* The strip holds hidden while the board show plays in the central band, then
   reveals at settle (result-ui's makeBoardSettle adds `.fresh`). Without
   `.fresh` the reveal-cascade keyframes don't run. */
.result-strip.story-active {
  visibility: hidden;
}

/* Board-panel sizing — big readable cards for the score story playing on the
   board (the panel carries `.score-story-big`; the animation lab reuses the
   class to preview the same sizing). The engine sizes the content elastically
   (result-ui.js#placeResultAssembly → `--result-*`), CSS consumes; the fallbacks
   are today's settled px for the pre-measure frame + the lab preview. */
.score-story-big {
  font-size: var(
    --result-fs,
    25.6px
  ); /* the panel's font root — inner text + icons are em ratios of it (engine-elastic) */
}

.score-story-big .score-story-jacks .result-mini-card {
  /* One size throughout — the matador row sits at the engine's elastic settled px
     for the whole show and at rest. No φ show-swell that shrinks at settle: that
     end-of-story resize snapped (the result-solving freeze suppressed its ease) and
     drew the eye to a size change that earns nothing (Andre, 2026-07-01). */
  height: var(--result-mini-h, 76.6px);
  width: calc(var(--result-mini-h, 76.6px) * var(--card-ratio));
}

/* The engine's fit solve (result-ui.js#placeResultAssembly) drives --result-mini-h + the
   dock and re-measures the assembly synchronously, iterating to a fit. Any transition on a
   measured/moved element makes getBoundingClientRect report the pre-change value mid-tween,
   so the extent looks unchanged — the fit degenerates (Infinity sizes) and the centring shift
   lands short. Under `prefers-reduced-motion` the global `* { transition-duration: 0.01ms
   !important }` puts a transition on EVERY property (the panel dock's margin-bottom, the
   slots'/plates' transforms, the zone), so every measure lags. `#skat-app` carries
   `.result-solving` only for the synchronous solve window (no paint happens inside it),
   freezing all motion so the engine measures its own output truthfully. `!important` is
   required to beat the reduced-motion rule. */
#skat-app.result-solving * {
  transition: none !important;
}

.score-story-big .score-story-flier .result-mini-card {
  height: 96px;
  width: calc(96px * var(--card-ratio));
}

/* Inner text + icons are em ratios of the panel's `--result-fs` root (above), so one
   engine-set multiply scales the whole panel's typography — the em cascade does the rest, no
   per-element property. The ratios below reproduce the former absolute px at the 25.6px
   (fs-xl) reference: fs-md/fs-xl = 0.6875, 2.4rem/fs-xl = 1.5, etc. */
.score-story-big .score-story-chip {
  font-size: 0.6875em;
  padding: 0.2em 0.47em;
}

.score-story-big .score-story-mult,
.score-story-big .score-story-base-value {
  font-size: 1em;
}

.score-story-big .score-story-value {
  font-size: 1.5em;
}

.score-story-big .score-story-flier-tag {
  font-size: 1em;
}

.score-story-big .score-story-bar {
  height: var(--result-bar-h, 22px);
  border-radius: calc(var(--result-bar-h, 22px) / 2);
}

.score-story-big .score-story-fill.declarer {
  border-radius: calc(var(--result-bar-h, 22px) / 2) 0 0
    calc(var(--result-bar-h, 22px) / 2);
}

.score-story-big .score-story-fill.defenders {
  border-radius: 0 calc(var(--result-bar-h, 22px) / 2)
    calc(var(--result-bar-h, 22px) / 2) 0;
}

.score-story-big .score-story-augen-value {
  font-size: 1em;
}

/* CSS wins over the baked SVG width/height attrs, so the fixed-size icons
   scale with the panel's font root (em → `--result-fs`). */
.score-story-big .score-story-base svg {
  width: 1.5625em;
  height: 1.5625em;
}

/* The two number glyphs carry their class on the `<svg>` itself
   (score-story.js#buildAugenCounter / #buildWorth), like the chip icon — matched
   directly, no descendant `svg`. Both ratios were picked on live pixels (Andre,
   2026-07-15) against the duel's digits and the equation's payoff respectively,
   then expressed in em so the mark keeps that ratio at every viewport — the
   number and its mark scale off the same font root. */
.score-story-big .score-story-augen-icon {
  width: 1.538em;
  height: 1.538em;
}

.score-story-big .score-story-worth-icon {
  width: 1.635em;
  height: 1.635em;
}

/* The chip icon carries the class on the `<svg>` itself (score-story.js#buildChainChip),
   not on a wrapper span like base/badge — so it's matched directly, no descendant `svg`.
   The em compounds through `.score-story-chip`'s own `font-size` (0.6875em), so the icon
   scales with its chip's label text: ~16.5px at the settled reference (≈ the former baked 16px). */
.score-story-big .score-story-chip-icon {
  width: 0.9375em;
  height: 0.9375em;
}

.score-story-big .score-story-badge svg {
  width: 1.016em;
  height: 1.016em;
}

/* --- Score story on the board (S3) --- */
/* The board-native show: the score-story panel sits in the central band the
   actors vacate, board visible around it — no covering stage. */

.score-story-board-layer {
  position: absolute;
  inset: 0;
  /* Top of the content tier — a top-level result-phase reveal layer (appended to
     #skat-app, board visible around it). Paints over all board content (hand 70,
     played cards 60). Since slice 4 the result strip is a ROW of the panel inside
     this layer (the panel re-enables pointer-events, so the strip's buttons stay
     clickable); only the no-panel rounds' standalone strip sits above at z 80.
     The modal result overlay (1000) still covers this. Its own children
     (.score-story-flier, badges) are scoped inside this context. */
  z-index: 75;
  display: flex;
  align-items: flex-end; /* the panel docks UP from the board bottom (above the strip) */
  justify-content: center;
  pointer-events: none; /* the board behind stays inert; the panel re-enables */
}

.score-story-board-panel.result-score-story {
  pointer-events: auto;
  /* result-landscape-composition design: --result-span-w (result-ui.js#placeResultAssembly's
     d-regime freeze) is the engine-published edge-anchored span once the gate engages — absent
     (the d-regime off, today's every-other-viewport path) falls back to the natural cap. */
  width: var(--result-span-w, min(880px, 84vw));
  /* Rest the panel's centre mid-screen (the JS-measured + recentred offset from
     placeResultAssembly's single self-contained pass — result-ui.js); the 16vh
     fallback covers the pre-measure frame. */
  margin-bottom: var(--score-story-dock, 16vh);
  /* board-native — no panel chrome; the story lives ON the petrol, not in a box */
  border: none;
  background: none;
}

/* --- Choreography fliers (transient, created/removed by the reveal) --- */

.score-story-flier {
  position: absolute;
  top: 0;
  left: 0;
  display: inline-flex;
  align-items: center;
  gap: 4px;
  z-index: 5;
  pointer-events: none;
}

.score-story-flier .result-mini-card {
  height: 44px;
  width: calc(44px * var(--card-ratio));
}

.score-story-flier-tag {
  font-size: var(--fs-md);
  font-weight: 700;
  color: var(--text-primary);
  text-shadow: 0 2px 8px rgba(2, 12, 20, 0.8);
}

/* === Game Menu === */
/* The context-aware pause menu (game-menu.js), opened from the top-right Menu
   button on every screen: reuses the shared vellum-overlay dialog/dismiss
   (§ Result Overlays), but styled as a video-game pause menu rather than a
   content panel — compact, frameless, a vertical stack of large tappable entry
   rows. Placed after § Result Overlays so .menu-overlay-panel's frameless overrides win
   over the .overlay-panel base by source order. Prototype cut
   (quit-becomes-menu-button); the entrance rides the shared `.overlay-panel` base
   (`overlayPanelIn`) like every other overlay, no bespoke keyframe anymore. */
/* The paused game reads as paused: blur + darken the stage behind the menu. The panel and its
   ::backdrop share one element now (no separate backdrop div to :has() into) — a direct class
   selector on the pseudo-element reaches it. backdrop-filter is one-shot here — the overlay is
   transient, not a per-frame gameplay surface, so the no-looping-filter rule doesn't bite. */
.menu-overlay-panel::backdrop {
  background: rgba(2, 12, 20, 0.66);
  backdrop-filter: blur(7px) saturate(0.9);
}

/* Frameless: the entries float on the blurred stage — no boxed panel (that read
   as a web form). Overrides the .overlay-panel content-panel base — but keeps the
   base's `overflow-y: auto`: with the language section the in-game list outgrows
   the 90vh budget on phone-landscape, and an `overflow: visible` override (the
   pre-language shape) rendered Quit unreachably below the viewport there. */
.menu-overlay-panel {
  width: min(360px, 86vw);
  padding: 0;
  gap: 0;
  background: none;
  border: none;
}

/* The crenellated menu mark crowns the list — a small heritage title, not a box. */
.menu-overlay-heading {
  display: flex;
  justify-content: center;
  margin-bottom: var(--space-lg);
  color: rgba(226, 232, 240, 0.85);
  filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.4));
}

.menu-overlay-heading svg {
  width: 40px;
  height: 40px;
}

.menu-overlay-list {
  display: flex;
  flex-direction: column;
  gap: var(--space-md);
  /* Room for the entries' :hover translateX slide (4px, matching the transform below): the
     panel scrolls (overflow-y: auto → overflow-x hidden), so without this reserve the slid
     row either spawned a horizontal scrollbar or, since the base's overflow-x guard, would
     clip its right border. */
  padding-right: 4px;
}

/* Large glass rows — the material of the button that opened them (DESIGN 18),
   sized up to read as menu entries. The amber left-bar + edge is the "selected"
   tell on hover; Quit swaps it for danger-red (the one leave-the-game entry). */
.menu-overlay-entry {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-md);
  font-size: var(--fs-lg);
  font-weight: 700; /* loaded Satoshi weight (400/500/700); 600 has no face */
  padding: var(--space-lg) var(--space-xl);
  color: rgba(226, 232, 240, 0.92);
  border-radius: var(--radius-lg);
  background: linear-gradient(
    155deg,
    rgba(255, 240, 214, 0.09),
    rgba(255, 235, 200, 0.02)
  );
  border: 1px solid rgba(255, 244, 224, 0.14);
  box-shadow: inset 0 1px 0 rgba(255, 244, 224, 0.22);
  transition:
    transform var(--transition-fast),
    background var(--transition-fast),
    border-color var(--transition-fast);
}

/* Selected-item accent bar (opacity-only transition — cheap, no box-shadow). */
.menu-overlay-entry::before {
  content: "";
  position: absolute;
  left: 6px;
  top: 50%;
  width: 3px;
  height: 58%;
  transform: translateY(-50%);
  border-radius: 3px;
  background: #fcd34d;
  opacity: 0;
  transition: opacity var(--transition-fast);
}

.menu-overlay-entry:hover {
  transform: translateX(4px);
  background: linear-gradient(
    155deg,
    rgba(255, 240, 214, 0.15),
    rgba(255, 235, 200, 0.05)
  );
  border-color: rgba(252, 211, 77, 0.5);
}

.menu-overlay-entry:hover::before {
  opacity: 1;
}

.menu-overlay-entry--danger::before {
  background: #ef4444;
}

.menu-overlay-entry--danger:hover {
  border-color: rgba(239, 68, 68, 0.55);
}

.menu-overlay-entry-icon {
  width: 24px;
  height: 24px;
  flex: none;
  opacity: 0.85;
}

/* Hints/learning-mode toggles (game-menu-hint-learning-toggles design): the start screen's
   .toggle-group/.hints-toggle* vocabulary (§ Game Start), reused verbatim via toggle.js and
   restacked vertically for the menu's narrower column. A quiet divider — not another glass
   row — sets the settings section apart from the nav entries above and the danger Quit entry
   below; no icons (that's the .menu-overlay-entry anatomy, deliberately not forced onto these). */
.menu-overlay-toggle-section {
  /* Grid, not flex: justify-content centers the single column track (sized to the
     widest group) against the centered glass entries above/below, while
     justify-items keeps the two knobs left-aligned with each other inside it —
     centered block, ragged-free knob column. */
  display: grid;
  justify-content: center;
  justify-items: start;
  gap: var(--space-md);
  margin: var(--space-lg) 0;
  padding-top: var(--space-lg);
  border-top: 1px solid rgba(255, 244, 224, 0.14);
}

/* .toggle-group's start-screen margins assume a horizontal row (game-start-toggles-row); the
   menu stacks the two groups vertically instead, so neither margin applies here. */
.menu-overlay-toggle-section .toggle-group {
  margin-right: 0;
}

.menu-overlay-toggle-section .toggle-group:last-child {
  margin-left: 0;
}

/* Language switcher (go-live-impressum-datenschutz: moved out of the footer into the
   menu, so it now also works in-game). Inline flag+autonym options, not the retired
   popover — a popover inside an already-modal overlay was needless nesting. Rendered in
   both contexts (start + in-game) right after the nav entry list, so its position stays
   predictable regardless of which other sections the context adds below it. A quiet
   divider, the same shape as `.menu-overlay-toggle-section` above it. */
.menu-overlay-language-section {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-sm);
  margin: var(--space-lg) 0;
  padding-top: var(--space-lg);
  border-top: 1px solid rgba(255, 244, 224, 0.14);
}

.menu-overlay-language-label {
  display: flex;
  align-items: center;
  gap: var(--space-xs);
  color: var(--text-secondary);
  font-size: var(--fs-sm);
}

.menu-overlay-language-options {
  display: flex;
  gap: var(--space-sm);
}

.menu-overlay-language-option {
  display: flex;
  align-items: center;
  gap: var(--space-xs);
  padding: var(--space-xs) var(--space-md);
  background: none;
  border: 1px solid rgba(255, 244, 224, 0.14);
  border-radius: var(--radius-md);
  color: var(--text-secondary);
  font: inherit;
  cursor: pointer;
  transition:
    color var(--transition-fast),
    border-color var(--transition-fast),
    background-color var(--transition-fast);
}

.menu-overlay-language-option:hover,
.menu-overlay-language-option:focus-visible {
  color: var(--text-primary);
  border-color: rgba(252, 211, 77, 0.5);
}

/* The active language — amber accent, the same "selected" tell the nav entries use on
   hover, but persistent rather than hover-only. */
.menu-overlay-language-option[aria-pressed="true"] {
  color: var(--text-primary);
  border-color: rgba(252, 211, 77, 0.7);
  background: rgba(255, 240, 214, 0.08);
}

/* The flag glyphs (language-selector.js#createFlagDe/createFlagUk) — a rounded-rect frame
   so the flat SVG bands read as a coherent chip rather than a bleeding rectangle. */
.lang-flag {
  display: block;
  flex-shrink: 0;
  border: 1px solid var(--bg-surface);
  border-radius: 3px;
}

/* === Result History === */

.result-history {
  margin-top: var(--space-md);
  padding-top: var(--space-md);
  border-top: 1px solid rgba(226, 232, 240, 0.1);
}

.result-history-table {
  display: grid;
  grid-template-columns: minmax(72px, auto) minmax(160px, 1.4fr) repeat(
      3,
      minmax(64px, 0.55fr)
    );
  font-variant-numeric: tabular-nums;
  border-radius: var(--radius-md);
  overflow: hidden;
  box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.1);
}

.result-history-cell {
  padding: var(--space-sm);
  min-width: 0;
  border-bottom: 1px solid rgba(148, 163, 184, 0.1);
}

.result-history-header-row,
.result-history-entry,
.result-history-total-row,
.result-history-net-row {
  display: contents;
}

.result-history-separator {
  grid-column: 1 / -1;
  height: 2px;
  margin: 0;
  background: rgba(217, 119, 6, 0.25);
}

.result-history-heading {
  font-size: var(--fs-xs);
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-secondary);
  background: rgba(255, 255, 255, 0.056);
}

.result-history-round {
  font-size: var(--fs-xs);
  font-weight: 700;
  color: var(--text-secondary);
}

.result-history-outcome {
  font-size: var(--fs-sm);
  font-weight: 500;
  color: var(--text-primary);
}

.result-history-value,
.result-history-total-value {
  font-size: var(--fs-sm);
  font-weight: 700;
  color: var(--text-primary);
  text-align: center;
}

.result-history-total-label,
.result-history-total-spacer {
  font-size: var(--fs-sm);
  font-weight: 700;
  color: var(--text-primary);
  background: rgba(255, 255, 255, 0.065);
}

.result-history-total-value {
  background: rgba(255, 255, 255, 0.065);
}

.result-history-entry.even .result-history-cell {
  background: rgba(8, 47, 73, 0.25);
}

.result-history-entry.odd .result-history-cell {
  background: rgba(8, 47, 73, 0.45);
}

.result-history-net-label,
.result-history-net-spacer,
.result-history-net-value {
  font-size: var(--fs-sm);
  font-weight: 700;
  color: var(--text-primary);
  background: rgba(255, 255, 255, 0.04);
  /* Amber edge under the standings block (totals + Net lead the table now) —
     separates current standings from the round entries below. */
  border-bottom: 2px solid rgba(217, 119, 6, 0.45);
}

.result-history-net-value {
  text-align: center;
}

/* Tertiary affordance: reveals the rest of the archive in place (renderer
   appends rows + removes the row). Content-sized — deliberately not the
   CTA's 61.8%. */
.result-history-more {
  display: flex;
  justify-content: center;
  margin-top: var(--space-md);
}

.result-history-net-spacer {
  color: transparent;
}

.result-history-net-label {
  text-transform: uppercase;
  letter-spacing: 0.05em;
  white-space: nowrap;
  font-size: var(--fs-xs);
  color: var(--text-secondary);
  /* The balance-scale chip replaces this label's text (number-glyphs-result-summary-overlays)
     — inline-flex fixes the SVG's baseline gap, matching .overlay-bid-tag's precedent. */
  display: inline-flex;
  align-items: center;
}

.result-history-total-spacer {
  color: transparent;
}

.result-history-total-label {
  text-transform: uppercase;
  letter-spacing: 0.05em;
}

.result-history-total-label,
.result-history-round {
  white-space: nowrap;
}

.result-history-outcome {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  gap: var(--space-xs);
}

.result-history-outcome-name {
  overflow: hidden;
  text-overflow: ellipsis;
  min-width: 0;
}

.result-history-icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 18px;
  height: 18px;
  flex-shrink: 0;
}

.result-history-icon svg,
.result-history-icon .mock-suit-pip,
.result-history-icon .mock-chalice,
.result-history-icon .mock-null-zero,
.result-history-icon .mock-fallen-chalice {
  width: 18px;
  height: 18px;
}

.result-history-heading,
.result-history-round,
.result-history-outcome,
.result-history-value,
.result-history-total-label,
.result-history-total-value {
  align-self: stretch;
  display: flex;
  align-items: center;
}

.result-history-heading,
.result-history-total-label {
  justify-content: flex-start;
}

.result-history-value,
.result-history-total-value {
  justify-content: center;
}

.result-history-round,
.result-history-outcome {
  justify-content: flex-start;
}

.result-history-heading {
  color: var(--text-secondary);
}

.score-positive {
  color: var(--score-positive);
}
.score-negative {
  color: var(--score-negative);
}
.score-zero {
  color: var(--text-secondary);
  opacity: 0.55;
}

/* Phone-portrait (≤500 px wide): the result-history table's 424 px min-content
   (sum of `grid-template-columns` minmaxes) exceeds the available container
   width and the rightmost seat column clips off the right edge. Compact mode
   keeps one row per round but merges the Round + Game cells into the outcome
   cell (the round number is injected as a leading "R#" badge via `::before`,
   reading the `data-round` attribute set by the renderer), narrows the outcome
   column, and tightens fonts so the three seat-delta columns always fit.
   (The history overlay panel clips any residual horizontal overflow at its
   own boundary.) */
@media (max-width: 500px) {
  .result-history-table {
    grid-template-columns: minmax(95px, 1.4fr) repeat(3, 1fr);
  }

  /* Hide the "Game" column header — it labels a column that disappears. The
     "Round" header (nth-child 1) stays and now dual-labels the merged
     "R# {icon} {declarer}" column — the leading "R#" badge in each row
     anchors the label semantically without needing a rename. */
  .result-history-table .result-history-heading:nth-child(2) {
    display: none;
  }

  /* Hide the per-entry round cell — the round number is reconstructed as an
     `::before` badge on the outcome cell so each row reads as one merged
     "R# {icon} {declarer}" line. */
  .result-history-table .result-history-round {
    display: none;
  }

  .result-history-outcome::before {
    content: "R" attr(data-round);
    color: var(--text-secondary);
    font-weight: 700;
    margin-right: var(--space-xs);
    /* Fixed-width round badge so the game-type icon column aligns vertically
       across 1- and 2-digit round numbers. 3ch covers R1–R99; rounds above 99
       shift the icon ~1ch right, accepted as graceful degradation — runs are
       tens of rounds in practice, so even the fully revealed archive stays
       within 1- and 2-digit badge widths. */
    min-width: 3ch;
    flex-shrink: 0;
  }

  .result-history-outcome,
  .result-history-value,
  .result-history-heading {
    font-size: var(--fs-xs);
    padding: var(--space-xs);
  }

  .result-history-total-label,
  .result-history-net-label {
    font-size: var(--fs-xs);
    padding: var(--space-xs) var(--space-sm);
  }

  /* Spacer cells were filler for the 5-column wide layout; not needed in 4-column
     compact layout. */
  .result-history-table .result-history-total-spacer,
  .result-history-table .result-history-net-spacer {
    display: none;
  }
}

/* === Result Run Stats === */

.result-run-stats {
  /* Sibling of .result-history; visible by default — no max-height collapse, no aria-hidden.
     Reuses the inner content classes from .leaderboard-detail (sections, game-type strip,
     feats) under a result-screen-scoped wrapper. */
  margin-top: var(--space-lg);
  padding: var(--space-sm) 0;
}

.result-run-stats .leaderboard-detail-type.is-highlight {
  /* The just-played-and-won game-type cell. CTA orange (--ui-warning, Deep Amber) to
     visually rhyme with the Next Round button — see spec § Highlight contract.
     Scoped to .result-run-stats so the leaderboard expansion (which renders the same
     cell class) is unaffected if a future call passes a highlight from a game-start
     context. */
  color: var(--ui-warning);
  font-weight: 700;
}

/* === Game-Start === */

/* Game-start top-anchors its menu and fits it to the viewport, then lets the leaderboard
   flow below — the one phase that scrolls the page (the `html/body:has(.game-start)`
   override up top). The layout engine (game-start-layout.js#placeStartAssembly) fills the
   menu's width up to a comfortable column and caps it by height so the Play CTA always
   stays on screen; the leaderboard flows below at its own size, reached by page scroll.

   ELASTIC SIZING (slice 2): the engine pushes the fit as three unitless group
   multipliers on `.game-start-content` — `--gs-ux` (tappable content, resists),
   `--gs-vis` (the wordmark, middle), `--gs-gap` (gaps/air, collapses first) — plus the
   real-px column width `--gs-menu-width`. Every size below multiplies its base px by
   its group's factor; base px = the design at fit ratio 1 (the 560px column). No
   transform scale — text renders crisp at real px — and no viewport units: the engine
   owns the responsive math (css/CLAUDE.md § Engine-set sizing, CSS consumes).

   `.game-start` is in-flow (`position: relative`) so the stage reserves real document
   height for the scroll: its height is set in JS to the content extent
   (game-start-layout.js), since the absolute content contributes nothing to
   scrollHeight on its own. */
.game-start {
  position: relative;
  width: 100%;
}

/* The engine's fit solve (game-start-layout.js#placeStartAssembly) writes the elastic
   multipliers and re-measures the menu synchronously, iterating to a fit. Any transition
   on a measured element makes the read report the pre-change value mid-tween — and under
   `prefers-reduced-motion` the global `* { transition-duration: 0.01ms !important }`
   puts a transition on EVERY property (the column gap, the menu width, the fonts), so
   every measure lags one write behind and the pressure solve diverges instead of
   converging. `.gs-solving` is carried only for the synchronous solve window (no paint
   happens inside it), freezing all motion so the engine measures its own output
   truthfully. `!important` is required to beat the reduced-motion rule. (The result
   assembly's `.result-solving` is the same fix — see that rule.) */
.game-start.gs-solving * {
  transition: none !important;
}

/* The vertical composition: logo → name → mode cards → toggles → Play → leaderboard,
   one column. Top-anchored (not 50/50-centred) so the leaderboard below can claim the
   band beneath the menu; `top` is the gap from the true viewport top. The translate
   only centres horizontally — sizing is the engine's elastic custom properties, not a
   transform (game-start-layout.js). */
.game-start-content {
  position: absolute;
  left: 50%;
  top: 4vh;
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: calc(var(--space-xl) * var(--gs-gap, 1));
  /* The engine pushes the rendered column width (per-arrangement base × the UX curve) as
     --gs-menu-width (game-start-layout.js#BASE_WIDTH — the single source); the fallback is
     the wide base. The mode-cards row fills this width, golden-split between Skat and
     Ramsch. */
  width: var(--gs-menu-width, 560px);
  transform: translate(-50%, 0);
}

.game-start-logo {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: calc(var(--space-md) * var(--gs-gap, 1));
}

.game-start-logo-pip-group {
  display: flex;
  align-items: center;
  gap: calc(var(--space-md) * var(--gs-gap, 1));
}

.game-start-logo svg {
  opacity: 0.85;
}

/* The logo pips/chalices ride the VISUAL curve. Base 54px mirrors the SVG's own
   width/height attributes (game-start-ui.js#LOGO_PIP_SIZE — the attrs stay as the
   viewBox source; this override does the elastic sizing). */
.game-start-logo-pip-group svg {
  width: calc(54px * var(--gs-vis, 1));
  height: calc(54px * var(--gs-vis, 1));
}

/* The wordmark is glyph-path SVG (so the entrance can etch it), not DOM text.
   Sized off the same --fs-2xl the text used; height drives it, width follows the
   viewBox aspect. overflow:visible so the etch's round-capped stroke isn't
   clipped at the glyph bounds. Color is baked into the paths (resolved
   --text-primary), not a CSS color. */
.game-start-logo-text {
  height: calc(var(--fs-2xl) * 1.3 * var(--gs-vis, 1));
  width: auto;
  overflow: visible;
}

/* Name slot stretches with the column flex parent so the grid below has a full
   width to distribute. Without that width, `1fr | auto | 1fr` would collapse
   the side columns to 0 and the quill would push the name off-center.
   `position: relative` anchors the confirmation line below (identity-action-confirmation
   design): it's absolutely positioned at `top: 100%` so the start assembly's solver never
   sees it — no layout shift when it appears. */
.game-start-name-slot {
  width: 100%;
  position: relative;
}

/* The whole row is one clickable doorway into the identity overlay (DB slice 2c
   identity-overlay rework) — never an editable input. Grid mirrors the old
   display-row: name centered, quill hugging its right shoulder. */
.game-start-identity-trigger {
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  width: 100%;
  gap: calc(var(--space-sm) * var(--gs-gap, 1));
  padding: calc(var(--space-sm) * var(--gs-gap, 1))
    calc(var(--space-md) * var(--gs-gap, 1));
  background: transparent;
  border: none;
  border-bottom: 1px solid transparent; /* reserves the empty-state underline's box height */
  cursor: pointer;
}

.game-start-identity-trigger:focus-visible {
  outline: 2px solid var(--ui-warning);
  outline-offset: 2px;
  border-radius: var(--radius-sm);
}

.game-start-name-display {
  grid-column: 2;
  font-size: calc(var(--fs-xl) * var(--gs-ux, 1));
  font-weight: 700;
  letter-spacing: 0.04em;
  color: var(--text-primary);
  text-align: center;
}

/* Empty state (first visit, no name yet): the placeholder reads like the old
   underlined input even though it's a button now — a doorway, not a field. */
.game-start-identity-trigger--empty {
  border-bottom-color: rgba(255, 255, 255, 0.18);
}

.game-start-identity-trigger--empty .game-start-name-display {
  font-style: italic;
  font-weight: 500;
  opacity: 0.55;
}

/* Logged-in (account) state: the name has no quill (it can't be renamed), so a faint underline —
   the same doorway cue the empty state wears — marks it interactive; the bare bold text otherwise
   read as a dead label. Warms to amber on hover/focus, like the empty state's field underline. */
.game-start-identity-trigger--account {
  border-bottom-color: rgba(255, 255, 255, 0.14);
}

.game-start-identity-trigger--account:hover,
.game-start-identity-trigger--account:focus-visible {
  border-bottom-color: var(--ui-warning);
}

/* The registered row's Knight bust (identity-action-confirmation design) — column 1, mirroring
   the quill's column 3, so it hugs the name's left shoulder. Doubles as the doorway affordance
   the faint underline under-delivered, and as the etch host the confirmation beat finds by
   selector on register/login (game-start-identity-confirmation.js). */
.game-start-name-persona {
  grid-column: 1;
  justify-self: end;
  display: inline-flex;
  align-items: center;
  justify-content: center;
}

/* Rides the UX curve like the quill. Base 28px mirrors the SVG's own width/height attributes
   (game-start-ui.js#NAME_PERSONA_SIZE). */
.game-start-name-persona svg {
  width: calc(28px * var(--gs-ux, 1));
  height: calc(28px * var(--gs-ux, 1));
}

.game-start-name-edit {
  grid-column: 3;
  justify-self: start;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 4px;
  color: var(--text-secondary);
  opacity: 0.55;
  transition:
    opacity var(--transition-fast),
    color var(--transition-fast);
}

/* The edit-hint quill rides the UX curve (it sits in a tap target). Base 24px
   mirrors the SVG's own width/height attributes (game-start-ui.js). */
.game-start-name-edit svg {
  width: calc(24px * var(--gs-ux, 1));
  height: calc(24px * var(--gs-ux, 1));
}

.game-start-identity-trigger:hover .game-start-name-edit,
.game-start-identity-trigger:focus-visible .game-start-name-edit {
  opacity: 1;
  color: var(--text-primary);
}

.session-lost-notice {
  background: var(--ui-warning);
  color: #1a1a2e;
  padding: calc(var(--space-md) * var(--gs-gap, 1))
    calc(var(--space-xl) * var(--gs-gap, 1));
  border-radius: var(--radius-md);
  font-size: calc(var(--fs-sm) * var(--gs-ux, 1));
  font-weight: 700;
  text-align: center;
  max-width: calc(480px * var(--gs-ux, 1));
  animation: fade-in 0.3s ease-out;
}

@keyframes fade-in {
  from {
    opacity: 0;
    transform: translateY(-8px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

/* The confirmation beat's transient line (identity-action-confirmation design) — a child of
   `.game-start-name-slot` (relatively positioned above), absolute at `top: 100%` so the start
   assembly's solver never sees it (no layout shift on arrival). Its lifecycle is JS-timer-owned
   (game-start-identity-confirmation.js): this class's fade-in plays on append; `--leaving` plays
   the fade-out once the JS hold elapses. The hold itself is never an animation duration — see
   the module's file header for why (the global Reduced Motion collapse below would otherwise
   flash it away unread). */
.game-start-identity-confirmation {
  position: absolute;
  top: 100%;
  left: 50%;
  transform: translateX(-50%);
  margin-top: var(--space-xs);
  width: max-content;
  max-width: 90%;
  font-size: calc(var(--fs-sm) * var(--gs-ux, 1));
  color: var(--text-secondary);
  text-align: center;
  /* why: can't reuse the shared `fade-in` — its keyframes tween `transform: translateY`, which
     would replace this line's `translateX(-50%)` centering for the animation's duration, so the
     line would arrive shifted half its width right and snap centered. This dedicated keyframe
     carries the centering through the rise. */
  animation: gameStartIdentityConfirmationIn var(--transition-normal);
}

@keyframes gameStartIdentityConfirmationIn {
  from {
    opacity: 0;
    transform: translateX(-50%) translateY(-8px);
  }
  to {
    opacity: 1;
    transform: translateX(-50%) translateY(0);
  }
}

.game-start-identity-confirmation--leaving {
  animation: gameStartIdentityConfirmationOut var(--transition-normal) forwards;
}

@keyframes gameStartIdentityConfirmationOut {
  from {
    opacity: 1;
  }
  to {
    opacity: 0;
  }
}

.mode-cards {
  display: flex;
  justify-content: center;
  gap: calc(var(--space-md) * var(--gs-gap, 1));
}

/* Asymmetric golden split: Skat (the default) takes φ−1 ≈ 0.618 of the row,
   Ramsch (the outlier) the remainder ≈ 0.382. Widths computed from the row width
   minus the (elastic) gap so the two cards plus the gap fill it exactly —
   deterministic, unlike flex-grow which content-size interactions skew off ratio.
   why: DESIGN principle 15 — golden ratio by default. */
.mode-card {
  flex: 0 0 auto;
  width: calc((100% - var(--space-md) * var(--gs-gap, 1)) * 0.382);
  padding: calc(20px * var(--gs-ux, 1)) calc(28px * var(--gs-ux, 1));
  border-radius: calc(var(--radius-lg) * var(--gs-ux, 1));
  background: var(--gradient-surface-light);
  border: 1px solid rgba(226, 232, 240, 0.12);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: calc(6px * var(--gs-ux, 1));
  cursor: pointer;
  transition:
    border-color var(--transition-normal),
    box-shadow var(--transition-normal);
}

.mode-card[data-mode="SKAT"] {
  width: calc((100% - var(--space-md) * var(--gs-gap, 1)) * 0.618);
}

.mode-card.selected {
  background: linear-gradient(
    180deg,
    rgba(226, 232, 240, 0.12) 0%,
    rgba(226, 232, 240, 0.05) 100%
  );
  border: 2px solid var(--ui-warning);
  box-shadow: 0 0 20px rgba(217, 119, 6, 0.15);
}

.mode-card:hover:not(.selected) {
  background: linear-gradient(
    180deg,
    rgba(226, 232, 240, 0.1) 0%,
    rgba(226, 232, 240, 0.04) 100%
  );
  border-color: rgba(226, 232, 240, 0.2);
}

/* Active-run halo on mode cards — vellum atmosphere ring matching DESIGN.md
   § 13. Three layers: inset glow (card lit on stage) + 1px vellum outer edge +
   soft outer halo. */
.mode-card.has-active-run {
  box-shadow:
    inset 0 0 24px rgba(226, 232, 240, 0.12),
    0 0 0 1px rgba(226, 232, 240, 0.45),
    0 0 24px rgba(226, 232, 240, 0.35);
}

/* Active-run + selected: drop the 1px vellum stroke so the amber selection
   border has a clean edge; keep the inset + outer halo, layer the amber halo on
   top so both states still read at once. */
.mode-card.has-active-run.selected {
  box-shadow:
    inset 0 0 24px rgba(226, 232, 240, 0.12),
    0 0 24px rgba(226, 232, 240, 0.35),
    0 0 20px rgba(217, 119, 6, 0.15);
}

.mode-icon {
  color: var(--text-secondary);
  /* UX curve — the icon is the card's main tap-target content. Base 46px mirrors the
     SVG's own width/height attributes (game-start-ui.js#MODE_ICON_SIZE). */
  width: calc(46px * var(--gs-ux, 1));
  height: calc(46px * var(--gs-ux, 1));
}

.mode-card.selected .mode-icon {
  color: var(--ui-warning);
}

.mode-label {
  font-size: calc(22px * var(--gs-ux, 1));
  font-weight: 500;
  color: var(--text-primary);
}

/* The mode name as glyph-path SVG (so the panel-etch entrance can inscribe it) —
   sized to read like the old text. The viewBox carries font ascender/descender
   room, so the SVG height runs a touch over 1em to land the caps at the text size.
   overflow:visible so the etch's round-capped stroke isn't clipped. */
.mode-label-text {
  display: block;
  height: 1.15em;
  width: auto;
  overflow: visible;
}

/* Run context for a mode with a game in progress — the round about to be played, the dynamic
   text that the Play button's label dropped (now a fixed etched word). Recessive: it annotates
   the card the halo already marks, never shouts. */
.mode-run-badge {
  font-size: calc(13px * var(--gs-vis, 1));
  font-weight: 500;
  color: var(--text-secondary);
  letter-spacing: 0.01em;
  font-variant-numeric: tabular-nums;
}

.game-start-toggles-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
}

.toggle-group {
  display: flex;
  align-items: center;
  gap: calc(var(--space-md) * var(--gs-gap, 1));
  margin-right: calc(var(--space-xl) * var(--gs-gap, 1));
}

.toggle-group:last-child {
  margin-right: 0;
  margin-left: auto;
}

.toggle-group.disabled {
  opacity: 0.35;
  pointer-events: none;
}

.hints-toggle {
  width: calc(46px * var(--gs-ux, 1));
  height: calc(26px * var(--gs-ux, 1));
  border-radius: calc(var(--radius-lg) * var(--gs-ux, 1));
  background: var(--bg-toggle);
  position: relative;
  cursor: pointer;
  transition: background var(--transition-normal);
}

.hints-toggle.active {
  background: var(--ui-warning);
}

.hints-toggle-knob {
  width: calc(20px * var(--gs-ux, 1));
  height: calc(20px * var(--gs-ux, 1));
  border-radius: var(--radius-full);
  background: white;
  position: absolute;
  top: calc(3px * var(--gs-ux, 1));
  left: calc(3px * var(--gs-ux, 1));
  transition: transform var(--transition-normal);
}

.hints-toggle.active .hints-toggle-knob {
  transform: translateX(calc(20px * var(--gs-ux, 1)));
}

.hints-label {
  color: var(--text-secondary);
  font-size: calc(20px * var(--gs-ux, 1));
}

/* Game-start screen CTA — extra padding/sizing on top of .btn-primary colors */
.play-button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.45em;
  padding: calc(20px * var(--gs-ux, 1)) 0;
  /* 1/φ ≈ 0.618 of the content column, centered — the CTA sits narrower than the
     full-width mode-cards row above it. why: DESIGN principle 15, golden ratio. */
  width: 61.8%;
  align-self: center;
  border-radius: calc(var(--radius-lg) * var(--gs-ux, 1));
  font-size: calc(var(--fs-xl) * var(--gs-ux, 1));
  font-weight: 700;
  border: none;
  cursor: pointer;
  position: relative;
  transition:
    transform var(--transition-fast),
    box-shadow var(--transition-fast);
}

/* The Play label as glyph-path SVG (so the entrance can etch it) — sized to read
   like the button's text. The viewBox carries font ascender/descender room, so the
   SVG height runs a touch over 1em to land the caps at the old text size.
   overflow:visible so the etch's round-capped stroke isn't clipped. */
.play-button-label-text {
  height: 1.15em;
  width: auto;
  overflow: visible;
}

/* The enter-the-gate icon after the Play label (the in-game Menu counterpart
   is icon-only). currentColor — both read in the button's text color.
   The `> path` rules fatten only the arrow (the silhouette sits in a <g>):
   at button size the settled 3-unit arrow stroke reads too thin. */
.play-button-icon {
  width: 1.4em;
  height: 1.4em;
  flex: none;
}

.play-button-icon > path,
.menu-icon > path {
  stroke-width: 4.5;
}

/* The RECESSED register of the button material — the quiet counterpart to the raised
   CTA plate. The gate-exit sits ENGRAVED into the surface: an inset well + a light lower
   edge read as the same struck-metal family carved IN rather than raised OUT, so Quit
   belongs to the button system without competing for the eye (leaving is safe
   navigation, not a call to action). No glow, no JS affordance — restraint is the
   point of this register. why: redesign-play-cta-button (buttons as a material system).
   No longer worn by the in-game Quit affordance since quit-becomes-menu-button (that
   button now wears `.icon-tile` glass — it opens a menu, not a struck-metal commit) —
   still mirrored as the button-lab's pure-CSS reference (`dev/button-lab.js`). */
.btn-recessed {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  background: rgba(0, 0, 0, 0.2);
  border-radius: var(--radius-md);
  box-shadow:
    inset 0 1px 2px rgba(0, 0, 0, 0.5),
    inset 0 -1px 0 rgba(255, 255, 255, 0.06);
  transition:
    background var(--transition-fast),
    color var(--transition-fast);
}

.btn-recessed:hover {
  background: rgba(0, 0, 0, 0.3);
  color: var(--text-primary);
}

/* The in-game Menu affordance (placement only — `.icon-tile` supplies the glass
   material, prompt-tile vocabulary, § Icon tiles above). The lone in-game chrome
   control (the info bar was retired): pinned top-right out of flow, engine-sized and
   -placed via `--menu-*` (layout/menu-affordance.js#computeMenuAffordance, written by its
   sibling `applyMenuAffordanceVars` — the board and game-start passes both call it) so it
   scales and insets with the viewport. App-space coords — #skat-app is the positioned
   ancestor. Z-index 80
   (interactive controls band). Square; size overrides the intrinsic btn-small padding box.
   quit-becomes-menu-button: this button used to be Quit (instant exit, recessed/engraved
   register); it now opens the menu overlay, Quit is one entry in it. The old `quit`-prefixed
   class and var names were renamed to their `menu`-prefixed equivalents above in the
   same-named follow-up once the prototype cut settled. */
.menu-button {
  position: absolute;
  top: var(--menu-inset);
  right: var(--menu-inset);
  z-index: 80; /* interactive controls band */
  width: var(--menu-size);
  height: var(--menu-size);
  padding: 0;
}

/* The start screen's own menu-trigger instance (game-start-ui.js#renderGameStart) —
   placement only, positioned identically to `.menu-button` above via the same engine
   vars, but a DIFFERENT class deliberately: it must NOT trip the `body:has(.menu-button)`
   context flip (§ below) that hides `.site-footer` and grows #skat-app to 100dvh — the
   start screen keeps the footer and its normal layout. */
.menu-button-start {
  position: absolute;
  top: var(--menu-inset);
  right: var(--menu-inset);
  z-index: 80; /* interactive controls band */
  width: var(--menu-size);
  height: var(--menu-size);
  padding: 0;
}

/* Both menu triggers are glare-swept glass (`.icon-tile`, animation/glare.js): the
   pointer tilt rewrites --glare-shift per frame while the lerp runs, and each write
   repaints this button's ::before catch-light. Unpinned, that per-frame repaint lands
   its raster work on the button's big shared layer — the board — which regressed the
   declaration reveal 4–5× on the Pixel Tablet when the button joined the sweep
   (#1470; measured 2026-07-16, device-ab.js declare: base 595–1120ms of stalls vs
   149–182ms with this pin — todo/glare-per-tile-write-regresses-declare-beat).
   will-change gives the button its own compositor layer so the catch-light repaint
   stays button-sized — the drop-zone-glow precedent (docs/PERFORMANCE.md § Motion
   rides transform/opacity: one small element on a big unpromoted layer): a single
   persistent element, not the at-multiplicity pinning the card rule avoids. Only one
   of the two instances is ever mounted (in-game vs start screen). */
.menu-button,
.menu-button-start {
  will-change: transform;
}

.menu-icon {
  width: var(--menu-icon-size);
  height: var(--menu-icon-size);
  filter: drop-shadow(0 1px 0 rgba(255, 255, 255, 0.08));
}

/* No-GSAP / reduced-motion fallback — button-plate.js owns hover/press when armed
   (it sets data-plate-gsap), so these only fire when the affordance is off. */
.play-button:not([data-plate-gsap]):hover {
  transform: scale(1.03);
  box-shadow: 0 0 20px rgba(217, 119, 6, 0.4);
}

.play-button:not([data-plate-gsap]):active {
  transform: scale(0.97);
}

/* Arrangement: STACKED (narrow viewports). The layout engine decides when to stack
   (game-start-layout.js#arrangementFor — content-geometry-derived, not a CSS
   breakpoint) and writes `data-arrangement`; these rules only APPLY it. The stacked
   base is narrower so the tall-narrow box fits a portrait phone under the fit-scale.
   Logo + mode cards go vertical; the golden flex-split (height in a column) drops for
   equal-width icon-beside-label cards. */
.game-start[data-arrangement="stacked"] .game-start-logo {
  flex-direction: column;
}

.game-start[data-arrangement="stacked"] .mode-cards {
  flex-direction: column;
  align-items: center;
  gap: calc(var(--space-sm) * var(--gs-gap, 1));
}

.game-start[data-arrangement="stacked"] .mode-card,
.game-start[data-arrangement="stacked"] .mode-card[data-mode="SKAT"] {
  flex: none;
  width: 100%;
  flex-direction: row;
  justify-content: center;
  padding: calc(var(--space-md) * var(--gs-ux, 1))
    calc(var(--space-lg) * var(--gs-ux, 1));
}

/* Cold-Play indicator (lazy-game-graph-boot-seam, functional minimum — shape-live, refine on
   sight): mounted on the emptied `.game-start` stage only when the exit settles before the lazy
   game graph is ready (game-start-ui.js#showColdBootIndicatorIfNeeded). Reuses the boot overlay's
   inkwell language (`.app-loader-track`/`.app-loader-fill` above) at reduced scale rather than a
   new vocabulary — the emptied stage's only other loading treatment is that same overlay. */
.commit-play-indicator {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 20; /* content tier — sits over the emptied, pointer-events:none stage */
}

.commit-play-indicator-track {
  width: min(
    19.1vw,
    160px
  ); /* half the boot overlay's golden-fraction track — reduced scale */
  height: 2px;
  border-radius: var(--radius-full);
  background: rgba(241, 245, 249, 0.12); /* vellum, faint — the unfilled rule */
  overflow: hidden;
}

.commit-play-indicator-fill {
  display: block;
  width: 100%;
  height: 100%;
  border-radius: inherit;
  background: var(--ui-warning);
  transform-origin: left center;
  transform: scaleX(0);
  animation: app-loader-fill 2s cubic-bezier(0.1, 0.6, 0.2, 1) forwards; /* shared keyframes */
}

/* === Identity Overlay === */
/* The identity overlay (identity-overlay single-CTA rework, 2026-07) — game-start's sole doorway
   into optional accounts, opened from the name slot's `.game-start-identity-trigger`. Reuses the
   shared vellum dialog (§ Result Overlays, `.overlay-panel`)
   the same way `.menu-overlay-panel` does: this section's rules layer
   on top by source order (it comes after § Result Overlays, where the base lives). Quiet by design, with one
   deliberate exception: credentials are a secondary reveal below the name (a slim underline
   input, no boxed form chrome) and never a gate, but the one CTA that acts on them earns the same
   orange plate as the anonymous fast path once the user touches a password — DESIGN 18's
   "secondary buttons stay quiet" sub-rule is contextually suspended here; only ever one action
   wears the plate at a time. */
.identity-overlay-panel {
  max-width: 420px;
  width: 90vw;
  align-items: center;
  text-align: center;
}

/* Symmetric close (DESIGN 14 — state changes move, they don't pop): the shell's `exitAnim` path
   adds `--out` to the panel (the dialog itself now — no separate backdrop element) on dismiss and
   waits for this to finish before teardown. The panel mirrors its entrance (recedes the way it
   arrived — `identityOverlayPanelOut` is the same shape as the shared base's `overlayPanelIn`,
   entrance and exit having converged since overlay-shell-header-navigation §5) while its
   ::backdrop dims out with it. The whole exit lives here because this overlay is `exitAnim`'s only
   opt-in caller; a second one would promote the shared backdrop fade to § Result Overlays (rule
   of three).
   `--transition-normal` already bundles the easing (`200ms ease-out`), so the shorthand takes no
   further timing keyword — a second one makes it two timing-functions, which is invalid and drops
   the whole `animation` declaration (the animation then silently never runs). */
.overlay-panel--out::backdrop {
  animation: overlayBackdropOut var(--transition-normal) forwards;
}

.identity-overlay-panel.overlay-panel--out {
  animation: identityOverlayPanelOut var(--transition-normal) forwards;
}

@keyframes overlayBackdropOut {
  to {
    opacity: 0;
  }
}

@keyframes identityOverlayPanelOut {
  from {
    opacity: 1;
    transform: scale(1);
  }
  to {
    opacity: 0;
    transform: scale(0.96);
  }
}

.identity-overlay-headline {
  font-size: var(--fs-xl);
  font-weight: 700;
  color: var(--text-primary);
}

/* Account mode's headline carries the Knight bust beside the fixed name (identity-action-
   confirmation design) — a flex row instead of plain centered text; anonymous mode's headline
   (a bare placeholder string) is untouched by this modifier. */
.identity-overlay-headline--account {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-sm);
}

/* The overlay isn't governed by game-start's --gs-ux elastic engine (the panel is a fixed-width
   vellum shell, not the start assembly), so this rides a static px like the panel's other chrome.
   Base 32px mirrors the SVG's own width/height attributes
   (game-start-identity-overlay.js#ACCOUNT_PERSONA_SIZE). Same IDENTITY_COLOR.human white as the
   name slot — the panel is dark petrol like the stage (`.result-overlay-panel`'s gradient), so it
   reads identically here. */
.identity-overlay-persona {
  display: inline-flex;
}

.identity-overlay-persona svg {
  width: 32px;
  height: 32px;
}

/* The single form — name, credential reveal, hint, CTA, one column top to bottom. Both anonymous
   mode's name-commit form and account mode's change-password form use this one class; only their
   children differ. */
.identity-overlay-form {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-md);
  width: 100%;
}

/* The name field reuses the old name-slot's underline look — the overlay is now its only home. */
.identity-overlay-form .game-start-name-input {
  display: block;
  width: min(280px, 100%);
  margin: 0 auto;
  padding: var(--space-sm) var(--space-md);
  background: transparent;
  border: none;
  border-bottom: 1px solid rgba(255, 255, 255, 0.18);
  font-family: inherit;
  font-size: var(--fs-xl);
  font-weight: 700;
  letter-spacing: 0.04em;
  color: var(--text-primary);
  text-align: center;
  outline: none;
  transition:
    border-color var(--transition-normal),
    box-shadow var(--transition-normal);
}

.identity-overlay-form .game-start-name-input::placeholder {
  color: var(--text-primary);
  font-style: italic;
  font-weight: 500;
  letter-spacing: 0.04em;
  opacity: 0.55;
}

.identity-overlay-form .game-start-name-input:focus {
  border-bottom: 2px solid var(--ui-warning);
  padding-bottom: calc(var(--space-sm) - 1px);
  box-shadow: 0 4px 14px -4px rgba(217, 119, 6, 0.5);
}

/* .btn-primary carries the struck-metal material but no size; without the padding a hero CTA
   gets from its own size class (`.play-button`), the plate is too short for its convex gradient
   and top catch-light to read. This gives the overlay's primary action that vertical room. */
.identity-overlay-continue {
  width: 100%;
  padding: var(--space-md) var(--space-lg);
  border-radius: var(--radius-lg);
}

.identity-overlay-credentials {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-sm);
  width: 100%;
}

/* AVAILABLE/REGISTERED reveal 1-2 password fields; none/UNAVAILABLE leave this empty. Without
   :empty collapsing it, the form's own `gap` still reserves space around a childless div, opening
   a blank beat between the name field and the hint even with nothing to show. */
.identity-overlay-credentials:empty {
  display: none;
}

.identity-overlay-note {
  font-size: var(--fs-sm);
  color: var(--text-secondary);
}

/* Change-password's success note (identity-action-confirmation design) — brightened off the
   quiet default so a confirmation reads as one, without reaching for a new hue the palette
   doesn't otherwise carry (no dedicated "success green" token exists — see TOKENS.md). The
   success handler replaces a prior instance of this class rather than appending a sibling, so a
   second successful submit can't stack duplicate notes (the bug this fixes). */
.identity-overlay-note--success {
  color: var(--text-primary);
  font-weight: 700;
}

/* The always-present hint slot (§ the identity overlay design): invitation, live validation, or a
   failed-request error, by precedence. `min-height` reserves two lines, not one — the invitation
   copy ("Just play — or add a password to…") wraps to two lines at the panel's own width in every
   locale/viewport verified, so a single-line reservation (`.game-start-auth-error`'s 1.2em) still
   let the CTA jump when a validation hint (often one line) swapped in (Andre 2026-07-09, verified
   in-browser). Two lines covers that swap; a narrow-viewport German invitation can still exceed it
   by a third line — a residual gap, not the common-case regression this fixes. */
.identity-overlay-hint {
  min-height: 2.7em;
  font-size: var(--fs-sm);
  color: var(--text-secondary);
}

.identity-overlay-hint--error {
  color: var(--ui-warning);
}

/* Credential inputs reuse the name input's underline look one type-step smaller — the name
   stays the headline, credentials are supporting cast. */
.game-start-auth-input {
  display: block;
  width: min(280px, 100%);
  margin: 0 auto;
  padding: var(--space-sm) var(--space-md);
  background: transparent;
  border: none;
  border-bottom: 1px solid rgba(255, 255, 255, 0.18);
  font-family: inherit;
  font-size: var(--fs-lg);
  font-weight: 500;
  color: var(--text-primary);
  text-align: center;
  outline: none;
  transition: border-color var(--transition-normal);
}

.game-start-auth-input:focus {
  border-bottom: 2px solid var(--ui-warning);
}

.game-start-auth-link {
  background: transparent;
  border: none;
  padding: 4px;
  font-family: inherit;
  font-size: var(--fs-sm);
  letter-spacing: 0.03em;
  color: var(--text-secondary);
  opacity: 0.65;
  cursor: pointer;
  transition:
    opacity var(--transition-fast),
    color var(--transition-fast);
}

.game-start-auth-link:hover,
.game-start-auth-link:focus-visible {
  opacity: 1;
  color: var(--text-primary);
}

/* The logout row's own error surface — orthogonal to the hint-line unification above, since
   sign-out isn't part of either form's CTA grammar. */
.game-start-auth-error {
  min-height: 1.2em;
  font-size: var(--fs-sm);
  color: var(--ui-warning);
}

/* === Leaderboard === */

/* Game-start high-score table — sits beneath the Play button. Reuses the vellum tones
   from the active-run halo vocabulary (DESIGN.md § 13) so the section reads as part
   of the game-start stage rather than a grafted-on admin panel. */
.game-start-leaderboard {
  margin-top: clamp(16px, 2.5vh, 24px);
  padding: clamp(12px, 2vh, 18px);
  border-radius: var(--radius-lg);
  background: rgba(226, 232, 240, 0.04);
  box-shadow: inset 0 0 0 1px rgba(226, 232, 240, 0.12);
}

.leaderboard-heading {
  margin: 0 0 clamp(8px, 1.4vh, 12px) 0;
  text-align: center;
  font-size: var(--fs-md);
  font-weight: 700;
  color: var(--text-secondary);
  letter-spacing: 0.04em;
  text-transform: uppercase;
}

.leaderboard-list,
.leaderboard-list-tail {
  list-style: none;
  margin: 0;
  padding: 0;
}

/* Row + header share the same grid template with FIXED-width metric columns. Without
   fixed widths the two grids size their `auto` tracks independently — the header label
   "RUNDEN" is narrower than the body number "+10.840", so each grid resolves the same
   column to different widths and the header drifts off the row cells (Andre 2026-05-17).
   6em fits the widest realistic body value ("+99.999" or "−99.999" with locale thousand
   sep) plus tabular-num breathing room. */
/* `em` here would resolve against each grid container's own font-size — the row at
   ~1.45rem and the header at ~0.85rem — so `5em` ended up as different pixel widths in
   each grid, mis-aligning the columns (Andre 2026-05-17). `rem` is root-relative and
   stable across both contexts. */
.leaderboard-row,
.leaderboard-header {
  display: grid;
  grid-template-columns: 2.5rem 1fr 6rem 6rem 6rem;
  gap: clamp(8px, 1.5vw, 14px);
  align-items: baseline;
  padding: clamp(6px, 0.9vh, 9px) clamp(8px, 1.5vw, 12px);
}

.leaderboard-row {
  /* Row font-size lands above the expanded-detail clamp (max 1.25rem) so the name reads
     as the largest text in the row rather than smaller than its own expansion. */
  font-size: clamp(1rem, 1.4vw, 1.45rem);
  color: var(--text-primary);
  border-radius: var(--radius-sm);
  cursor: pointer;
}

.leaderboard-row:hover {
  background: rgba(226, 232, 240, 0.05);
}

.leaderboard-row:focus-visible {
  outline: 2px solid rgba(226, 232, 240, 0.4);
  outline-offset: -2px;
}

.leaderboard-row + .leaderboard-row {
  border-top: 1px solid rgba(226, 232, 240, 0.08);
}

/* Column header — small, uppercase, secondary tone. Active column gets primary color +
   bold weight as the sort cue. Body cells stay uniform weight so rows scan as quiet
   tabular data (2026-05-17 polish pass). Earlier draft added a `▼` chevron to the
   active label, but it read as a clickable sort affordance that the table doesn't
   provide — clicking the chevron does nothing, sort lives on the tab strip above. */
.leaderboard-header {
  font-size: var(--fs-sm);
  color: var(--text-secondary);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  border-bottom: 1px solid rgba(226, 232, 240, 0.14);
  padding-bottom: clamp(4px, 0.6vh, 6px);
  margin-bottom: clamp(2px, 0.4vh, 4px);
}

.leaderboard-header > .leaderboard-rounds,
.leaderboard-header > .leaderboard-avg-net,
.leaderboard-header > .leaderboard-score,
.leaderboard-header > .leaderboard-net {
  text-align: right;
}

.leaderboard-header .is-sort {
  color: var(--text-primary);
  font-weight: 700;
}

/* The Bilanz scale in the Net / avg-net column headers (number-glyphs sweep): chip cut,
   currentColor — so the is-sort highlight recolors it like the text labels it replaced.
   Nudged below the baseline so the beam sits level with the neighboring uppercase labels. */
.leaderboard-header-net-icon {
  vertical-align: -0.22em;
}

/* Thin vertical column dividers — ~60% row height, centered between cells. Pseudo-element
   on each cell except the rank (which lives flush left). Keeps the table-feel without the
   heaviness of full-height grid borders. */
.leaderboard-row > span:not(.leaderboard-rank),
.leaderboard-header > span:not(.leaderboard-rank) {
  position: relative;
}

.leaderboard-row > span:not(.leaderboard-rank)::before,
.leaderboard-header > span:not(.leaderboard-rank)::before {
  content: "";
  position: absolute;
  left: calc(clamp(8px, 1.5vw, 14px) / -2);
  top: 20%;
  bottom: 20%;
  width: 1px;
  background: rgba(226, 232, 240, 0.12);
}

/* Tab strip — styled as a settled chip control rather than a grafted-on tab strip
   (DESIGN.md § 12). */
.leaderboard-tabs {
  display: flex;
  justify-content: center;
  gap: var(--space-md);
  margin-bottom: clamp(8px, 1.2vh, 12px);
}

.leaderboard-tab {
  display: flex;
  align-items: center;
  gap: var(--space-sm);
  padding: 10px 20px;
  border-radius: var(--radius-lg);
  background: rgba(255, 255, 255, 0.06);
  border: 1px solid rgba(255, 255, 255, 0.1);
  cursor: pointer;
  transition:
    border-color var(--transition-normal),
    box-shadow var(--transition-normal);
  font-size: var(--fs-sm);
  color: var(--text-secondary);
}

.leaderboard-tab.selected {
  background: rgba(255, 255, 255, 0.1);
  border-color: var(--ui-warning);
  color: var(--text-primary);
  box-shadow: 0 0 16px rgba(217, 119, 6, 0.15);
}

.leaderboard-tab:hover:not(.selected) {
  background: rgba(255, 255, 255, 0.08);
}

/* Per-row detail revealed when a row is click-toggled. Spans the full row width; collapses via
   max-height transition. The expanded cap fits ~4 stacked lines (summary · game-types · averages
   · seats) with narrow-viewport wrap headroom. */
.leaderboard-detail {
  grid-column: 1 / -1;
  font-size: clamp(0.95rem, 1.2vw, 1.25rem);
  color: var(--text-secondary);
  font-variant-numeric: tabular-nums;
  max-height: 0;
  overflow: hidden;
  transition: max-height var(--transition-fast);
}

.leaderboard-row.expanded .leaderboard-detail {
  /* Large enough to fit any plausible expanded-detail height so content never clips.
     Expanding a row grows `.game-start-content`, which the placement pass's
     ResizeObserver catches (game-start-layout.js) and re-runs the fit — re-reserving the
     page-scroll height so the grown board stays reachable by scroll (the menu's own size
     is unaffected). An interpolable value (vs `none`) keeps the base max-height
     transition animating. */
  max-height: 100vh;
  /* Vellum-translucent surface treatment, matching .result-summary so the expanded
     panel reads as a detail surface rather than free-floating text on Midnight Petrol.
     The panel is the expanded row's own surface; the row above (collapsed) stays flush. */
  background: linear-gradient(
    180deg,
    rgba(22, 78, 99, 0.55) 0%,
    rgba(8, 47, 73, 0.55) 100%
  );
  border: 1px solid rgba(226, 232, 240, 0.1);
  border-radius: var(--radius-md);
  padding: var(--space-md);
  margin-top: var(--space-sm);
}

.leaderboard-detail-line {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: clamp(4px, 0.6vw, 8px);
  padding: clamp(2px, 0.3vh, 4px) 0;
}

/* Summary mini-table: the role block's "summary numbers" (Best/Worst Game +
   average) clustered right of the headline. fit-content width makes the column
   exactly as wide as its widest row, so both rows share a left edge; per-row
   space-between pins each trailing number to that shared right edge — a clean
   number column. margin-left: auto floats the group right; it drops below the
   role % as one unit via .leaderboard-detail-line's flex-wrap at narrow widths. */
.leaderboard-detail-summary {
  margin-left: auto;
  display: flex;
  flex-direction: column;
  width: fit-content;
  gap: clamp(2px, 0.3vh, 4px);
  color: var(--text-primary);
}

.leaderboard-detail-summary-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-md);
}

/* Trailing number anchors the shared right edge; tabular figures keep +120 and
   +120.0 aligned digit-for-digit down the column. */
.leaderboard-detail-summary-value {
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

/* Role section: vellum marker on the left, headline + supporting lines on the right.
   The marker is a single role icon (gatehouse) or a paired-banner construction; both
   inherit currentColor so they render in vellum via .role-icon's neutral color rule. */
.leaderboard-detail-section {
  display: grid;
  grid-template-columns: 44px 1fr;
  column-gap: var(--space-md);
  align-items: start;
  padding: var(--space-sm) 0;
  border-top: 1px solid rgba(226, 232, 240, 0.1);
}

.leaderboard-detail-section:first-of-type {
  border-top: none;
}

.leaderboard-detail-section-marker {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 32px;
  color: var(--text-primary);
}

/* No `.role-icon` here — the global class carries `position: absolute` and would
   pull markers out of the grid. The icons paint themselves (icons/svg.js). */
.leaderboard-detail-section-marker svg {
  display: block;
}

/* Paired banners: container holds two absolutely-positioned banners offset ±9px so
   their poles bracket a shared midline. Pattern mirrors `appendPairedBanners` in
   bidding-ui.js. */
.leaderboard-detail-paired-banners {
  position: relative;
  width: 44px;
  height: 32px;
}

.leaderboard-detail-paired-banners > svg {
  position: absolute;
  top: 50%;
  left: 50%;
  margin-top: -16px;
  margin-left: -14px;
}

.leaderboard-detail-section-body {
  display: flex;
  flex-direction: column;
  gap: clamp(2px, 0.3vh, 4px);
  min-width: 0;
}

.leaderboard-detail-headline {
  color: var(--text-primary);
  font-weight: 500;
}

/* Seats line: three equal thirds spanning the panel width, each seat centered in its
   third, with thin vertical dividers between (mirrors the column dividers in the
   unexpanded row header). The seats line specifically overrides .leaderboard-detail-line's
   flex layout to use grid for the equal-thirds geometry. */
.leaderboard-detail-seats {
  display: grid;
  grid-template-columns: 1fr 1fr 1fr;
  align-items: center;
  gap: 0;
}

/* Seat sigil: a small identity-colored banner before each name in the per-seat triple,
   teaching "this color = this player" the same way the bidding ceremony does. Cloth
   fill is inline (identity color); pole + finial draw currentColor and pick up the
   seat's text color. */
.leaderboard-detail-seat {
  display: inline-flex;
  align-items: center;
  gap: var(--space-xs);
  justify-content: center;
  padding: 0 var(--space-sm);
  color: var(--text-primary);
}

.leaderboard-detail-seat + .leaderboard-detail-seat {
  border-left: 1px solid rgba(226, 232, 240, 0.1);
}

/* Phone-portrait: 3-col grid overflows when names + sigils + scores can't fit
   in ~110px columns. Collapse to vertical stack; vertical dividers become
   horizontal hairlines between seats. Preserves the teaching pattern (sigil +
   name + score) at every viewport. */
@media (max-width: 500px) {
  .leaderboard-detail-seats {
    grid-template-columns: 1fr;
    row-gap: var(--space-xs);
  }
  .leaderboard-detail-seat {
    justify-content: flex-start;
  }
  .leaderboard-detail-seat + .leaderboard-detail-seat {
    border-left: none;
    border-top: 1px solid rgba(226, 232, 240, 0.1);
    padding-top: var(--space-xs);
  }
}

.leaderboard-detail-seat-banner {
  flex-shrink: 0;
  display: block;
}

.leaderboard-detail-seat .leaderboard-detail-name {
  color: var(--text-primary);
}

/* Footer: per-seat triple + recency, set apart by a subtle divider and a softer tone. */
.leaderboard-detail-footer {
  margin-top: var(--space-sm);
  padding-top: var(--space-sm);
  border-top: 1px solid rgba(226, 232, 240, 0.1);
  display: flex;
  flex-direction: column;
  gap: clamp(2px, 0.3vh, 4px);
}

.leaderboard-detail-types {
  gap: clamp(8px, 1.5vw, 14px);
}

.leaderboard-detail-type {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  color: var(--text-primary);
}

.leaderboard-detail-type.is-zero {
  opacity: 0.8; /* play-test: muted-cell contrast — trump-purple Ramsch chalice was invisible against Midnight Petrol below this floor */
}

.leaderboard-detail-type-icon,
.leaderboard-detail-best-icon {
  --ld-icon-size: clamp(20px, 2.2vw, 28px);
  display: inline-flex;
  width: var(--ld-icon-size);
  height: var(--ld-icon-size);
}

/* Optical left-alignment: each game-type glyph is centered inside a padded
   viewBox (~15-19% empty on each side depending on the icon), so the visible
   icon would sit ~3-4px right of its box edge — looking indented from the ⌀ that
   leads the average line directly below. Pull it left by that padding fraction
   (tracks --ld-icon-size across viewports) so the icon and the ⌀ start at the
   same x. Within ~0.5px across game-type icons, not pixel-exact (their glyph
   padding varies), but visually flush. Only the summary's leading best/worst
   icon needs this — the game-type strip icons carry no such alignment. */
.leaderboard-detail-best-icon {
  margin-left: calc(var(--ld-icon-size) * -0.17);
}

/* Feat-icon size is owned by JS (FEAT_ICON_SIZE in leaderboard-detail-body.js):
   the factories self-size via SVG width/height attributes, so CSS carries no size,
   only centering. A CSS size here would dual-source the number and silently
   override the SVG-attribute sizing. */
.leaderboard-detail-feat-icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
}

.leaderboard-detail-best {
  display: inline-flex;
  align-items: center;
  gap: 4px;
}

.leaderboard-detail-feats {
  gap: clamp(8px, 1.5vw, 14px);
}

.leaderboard-detail-feat {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  color: var(--text-primary);
}

.leaderboard-detail-defender {
  color: var(--text-primary);
}

/* Averages are load-bearing readouts — promote them out of the muted base color. */
.leaderboard-detail-avg {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  color: var(--text-primary);
}

/* The balance scale leads each average label (it replaced the typographic ⌀). */
.leaderboard-detail-avg-icon {
  width: 1.15em;
  height: 1.15em;
  flex: none;
}

/* W/L standards inline in the declarer/defender headline counts — the icons
   replaced the letter abbreviations; em-sized so they track the line's font. */
.leaderboard-detail-wl {
  display: inline-block;
}
.leaderboard-detail-wl-icon {
  width: 1.2em;
  height: 1.2em;
  vertical-align: -0.25em;
}

/* Averages line: right-aligned "summary number" anchored to the right edge.
   Ramsch-only now — the declarer/defender averages moved into the headline's
   .leaderboard-detail-summary mini-table; Ramsch has a single number with no
   Best/Worst pair to align against, so it keeps the standalone right-aligned row. */
.leaderboard-detail-averages {
  justify-content: flex-end;
}

/* Seats row: names recede (muted), scores stand out (primary). Default left-aligned. */
.leaderboard-detail-name {
  color: var(--text-secondary);
}

.leaderboard-detail-score {
  color: var(--text-primary);
}

/* Recency: bottom-right corner, muted "last updated at" caption (no label —
   round count now stays on the unexpanded row). Smaller + lower contrast so it
   reads as panel metadata, not a stat. */
.leaderboard-detail-recency {
  justify-content: flex-end;
  padding-top: var(--space-xs);
}

.leaderboard-detail-recency-rel {
  color: var(--text-secondary);
  font-size: var(--fs-sm);
  opacity: 0.7;
}

/* Feats line: icons + plain-text cells in the same row create a baseline mismatch
   (an icon-cell's flex baseline is its bottom edge; text cells use their text baseline,
   so the counts next to icons appear above the text counts). Center-align all cells
   in this line so icons and text-pills share a visual midline. */
.leaderboard-detail-feats {
  align-items: center;
}

.leaderboard-rank {
  color: var(--text-secondary);
  font-variant-numeric: tabular-nums;
}

.leaderboard-name {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* Body metric cells share the row's font-size + color; uniform weight per the 2026-05-17
   polish pass (no more muted/bold/normal rotation per row — header carries the sort cue).
   The active-sort cell gets a single weight bump (medium 500) to keep faint emphasis on
   the column the list is ranked by. Track widths are owned by `grid-template-columns`
   above — no per-cell min-width here, otherwise cells push past the track and overflow
   into the next column. */
.leaderboard-row > .leaderboard-rounds,
.leaderboard-row > .leaderboard-avg-net,
.leaderboard-row > .leaderboard-score,
.leaderboard-row > .leaderboard-net {
  font-variant-numeric: tabular-nums;
  text-align: right;
}

.leaderboard-row > .is-sort {
  font-weight: 500;
}

/* Viewer's own row — soft vellum glow, same vocabulary as the active-run halo
   so "this is you" reads through the existing visual language. */
.leaderboard-row.is-viewer {
  background: rgba(226, 232, 240, 0.08);
  box-shadow: inset 0 0 0 1px rgba(226, 232, 240, 0.25);
}

.leaderboard-separator {
  text-align: center;
  color: var(--text-secondary);
  letter-spacing: 0.4em;
  padding: clamp(4px, 0.7vh, 7px) 0;
  font-size: var(--fs-sm);
}

.leaderboard-empty,
.leaderboard-unavailable,
.leaderboard-loading {
  text-align: center;
  font-style: italic;
  color: var(--text-secondary);
  padding: clamp(8px, 1.4vh, 12px) 0;
  font-size: var(--fs-sm);
}

.leaderboard-loading {
  letter-spacing: 0.4em;
}

@media (max-width: 700px) {
  .leaderboard-tabs {
    gap: var(--space-sm);
  }

  .leaderboard-tab {
    padding: var(--space-sm) var(--space-lg);
  }

  /* At ≤700px the rounds/net/score cells crowd the name into ellipsis. Collapse
     the compact view to [rank, name, active-sort] so the name always reads in full. The
     active-sort cell stays via .is-sort; the two secondaries hide. Header mirrors the row. */
  .leaderboard-row,
  .leaderboard-header {
    grid-template-columns: 2.5rem 1fr 6rem;
  }

  .leaderboard-row .is-secondary,
  .leaderboard-header
    > span:not(.is-sort, .leaderboard-rank, .leaderboard-name) {
    display: none;
  }
}

/* === Site Footer === */

/* Landing-page credit + legal links — in document flow after #skat-app (go-live-
   impressum-datenschutz: the fixed bottom-right corner-pin was retired — overlap-prone
   floating chrome that sat on top of the scrolling leaderboard rather than ending the
   page). `#skat-app` grows past its usual fixed 100dvh on the game-start screen to make
   room for this (§ Layout, `body:has(.game-start) #skat-app`), so the footer always lands
   truly below the composition — which on game-start means below the fold, always: short
   content floors #skat-app at 100dvh, so the footer starts at the viewport's bottom edge
   and is reached by a footer-height scroll (deliberate, approved — legal links stay
   quiet); a tall leaderboard pushes it further down.

   Visible by default so static HTML / no-JS crawlers see the links without waiting for
   JS-injected DOM (the footer itself is plain server-rendered markup, no JS mount since
   the language selector moved into the pause-menu overlay — § Game Menu). Hidden during
   gameplay/result via the `body:has(.menu-button)` rule below — `.menu-button` is only
   rendered once game.js has a `state.phase`, so crawlers never trigger that hide rule. */
.site-footer {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: var(--space-xs) var(--space-md);
  padding: var(--space-md) var(--space-lg);
  font-size: var(--fs-sm);
  color: var(--text-secondary);
  user-select: none;
}

/* The shared legal-links fragment (templates/legal-footer.html) — small and muted
   wherever it's included: inline here inside `.site-footer`'s flex row, or standalone at
   the end of help/rules/games' main content (§ Help Page / Rules Page / Games Page —
   each just needs its own top margin around this, the link styling lives here once). A
   middot between the pair mirrors `.rules-toc`'s separator technique. */
.legal-footer-links {
  display: flex;
  gap: var(--space-md);
  font-size: var(--fs-sm);
  color: var(--text-secondary);
}

.legal-footer-links a {
  color: var(--text-primary);
  text-decoration: none;
  border-bottom: 1px solid rgba(148, 163, 184, 0.4);
  transition:
    border-color var(--transition-fast),
    color var(--transition-fast);
}

.legal-footer-links a:hover,
.legal-footer-links a:focus-visible {
  color: var(--ui-warning);
  border-bottom-color: var(--ui-warning);
}

.legal-footer-links a:not(:last-child)::after {
  content: "·";
  margin-left: var(--space-md);
  color: var(--text-secondary);
}

/* The credit text — plain text since go-live-impressum-datenschutz (the andremis.de
   backlink is gone); a middot echoes the legal-links pair's own separator. */
.site-footer-credit::before {
  content: "·";
  margin-right: var(--space-md);
  color: var(--text-secondary);
}

/* Hide when `.menu-button` is rendered — the lone in-game chrome control, present
   during bidding, trick play, and the result screen, but never on the game-start screen
   (which replaces the whole grid with `.game-start` and its own Play CTA). */
body:has(.menu-button) .site-footer {
  display: none;
}

/* === Error Pages === */

.error-page {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  min-height: 100vh;
  min-height: 100dvh;
  text-align: center;
  padding: var(--space-xl);
}

.error-code {
  font-size: clamp(4rem, 12vw, 8rem);
  font-weight: 700;
  color: var(--text-primary);
  line-height: 1;
  margin-bottom: var(--space-sm);
}

.error-message {
  font-size: clamp(1rem, 2.5vw, 1.25rem);
  color: var(--text-secondary);
  margin-bottom: var(--space-lg);
}

.error-path {
  font-size: var(--fs-sm);
  color: var(--text-secondary);
  margin-bottom: var(--space-lg);
  word-break: break-all;
}

.error-back {
  color: var(--text-primary);
  text-decoration: underline;
  text-underline-offset: 4px;
  font-size: var(--fs-base);
}

.error-back:hover {
  color: var(--text-accent);
}

.error-details {
  margin-top: var(--space-xl);
  text-align: left;
  font-size: var(--fs-sm);
  color: var(--text-secondary);
  max-width: 600px;
  width: 100%;
}

.error-details summary {
  cursor: pointer;
  margin-bottom: var(--space-sm);
}

.error-details pre {
  background: var(--bg-surface);
  padding: var(--space-md);
  border-radius: var(--radius-md);
  overflow-x: auto;
  white-space: pre-wrap;
  word-break: break-all;
}

/* === App Loader === */

/* The boot overlay (markup in the shell): paints on the first frame over the same
   ink sea the body carries, so its dissolve reveals an identical background — no
   seam (its fixed inset-0 box equals the viewport the body's fixed attachment
   anchors to, so the shared --ink-sea layers line up). Covers the empty pre-render
   shell (footer + blank #skat-app) until app-loader.js dissolves it into the
   game-start entrance. */
.app-loader {
  position: fixed;
  inset: 0;
  z-index: 1000; /* transient band — modal overlays; covers the whole shell at boot */
  display: flex;
  align-items: center;
  justify-content: center;
  background-color: var(--bg-abyss);
  background-image: var(--ink-sea);
  background-size: var(--ink-sea-size);
}

/* The splash — crest, track, wordmark as one vertical group (variant F,
   todo/initial-load-branded-boot-splash), lifted together to the sea's pole (38.2vh,
   the same lift the track alone used to carry). The lift rides THIS wrapper, never
   `.app-loader` itself — a transform there would shift the ink-sea background it
   shares with body, breaking the dissolve's seamless-background contract (both
   consume --ink-sea/--ink-sea-size so the boot overlay paints identically to what's
   under it). Static/instant on arrival by design, not an oversight: GSAP (the etch
   machinery the game-start entrance uses) isn't loaded yet at first paint, so this
   is a poster, not a performance — don't upgrade it to an etch-in without moving it
   past the font/stylesheet gate (app-loader.js#awaitAppReady). The gap clamp() below
   is a deliberate exception to "engine-set sizing, CSS consumes" (css/CLAUDE.md) —
   it paints before the layout engine exists to size anything for it. */
.boot-splash {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: clamp(14px, 2.6vh, 24px); /* play-test: rhythm between the three marks */
  transform: translateY(calc(38.2vh - 50vh));
}

/* Fortress crest — favicon.svg's gatehouse with the rounded app-icon tile dropped
   (img/boot-splash-crest.svg, inlined as this <img>'s data-URI src by
   scripts/gates/generate-critical-css.js). An <img>, not an inline <svg>: Chrome
   only counts an <img> as an LCP candidate — see todo/initial-load-branded-boot-splash
   § "Why this caps LCP". clamp() here rides the same early-paint exception as
   `.boot-splash`'s gap above. */
.boot-splash-crest {
  height: clamp(96px, 19vmin, 168px);
  width: auto;
}

/* The track: directly under the crest now, so the fill sweeping in reads as the
   ground being laid under the fortress. Its lift moved to `.boot-splash` (the
   group's, above) — it no longer carries its own — and its width now matches the
   wordmark's below so bar and name share edges, replacing the golden-fraction
   viewport width it used when it stood alone. */
.app-loader-track {
  width: clamp(
    180px,
    34vmin,
    300px
  ); /* why: matches .boot-splash-wordmark's width so they share edges */
  height: 3px;
  border-radius: var(--radius-full);
  background: rgba(241, 245, 249, 0.12); /* vellum, faint — the unfilled rule */
  overflow: hidden;
}

/* The fill sweeps left-to-right and decelerates toward a near-full hold — the
   "almost there" anticipation curve — then app-loader.js completes it to 100% via
   .app-loader-ready once the app is truly ready. Amber is the game-start accent,
   so the loader is tinted with the screen it precedes. */
.app-loader-fill {
  display: block;
  width: 100%;
  height: 100%;
  border-radius: inherit;
  background: var(--ui-warning);
  transform-origin: left center;
  transform: scaleX(0);
  animation: app-loader-fill 2s cubic-bezier(0.1, 0.6, 0.2, 1) forwards;
}

@keyframes app-loader-fill {
  from {
    transform: scaleX(0);
  }
  to {
    transform: scaleX(0.88); /* holds shy of full until the ready snap */
  }
}

.app-loader-ready .app-loader-fill {
  animation: none;
  transform: scaleX(1);
  transition: transform 0.2s ease-out; /* the completion snap on ready */
}

/* "Lothar" wordmark — one filled <path> per letter (img/boot-splash-wordmark.svg,
   generated from IDENTITY_NAME_GLYPHS.Lothar), inlined as this <img>'s data-URI src
   the same way as the crest above, and for the same LCP reason. It also appears
   later, etched letter-by-letter in the game-start entrance (game-start-wordmark.js)
   — that's deliberate poster-vs-performance duplication, not redundancy: GSAP isn't
   loaded yet at this first paint. */
.boot-splash-wordmark {
  width: clamp(180px, 34vmin, 300px);
  height: auto;
}

/* === Round Inspection === */

/* The shared round-inspection fragment (templates/round-inspection.html, game-history-htmx-spike
   design § The shared round-inspection module): key-facts strip, auction, per-trick blocks.
   Three consumers render it — the prod /games page, the result-screen round-inspector overlay,
   and the dev ai-lab round inspector — so this section is their one shared style source; a change
   here lands on all three at once. Sized to fit a 390px portrait phone with no horizontal scroll
   on both prod consumers — the /games page (game-history-smoke.spec.js) and the narrower result
   overlay (result-round-inspect.spec.js); the phone-width fit needs the .inspector-body padding
   trim at the section's end plus the inspect panel's full-width treatment (§ Result Overlays). */
.inspector-body {
  padding: var(--space-lg);
  display: flex;
  flex-direction: column;
  gap: var(--space-xl);
}
/* Key-facts strip across the top: a row of prominent stat cards. */
.ri-facts {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-md);
}
.ri-fact {
  background: var(--bg-elevated);
  border: 1px solid rgba(255, 255, 255, 0.12);
  border-radius: var(--radius-md);
  padding: var(--space-sm) var(--space-lg);
  min-width: 130px;
}
.ri-fact-icon svg {
  display: block;
}
/* The outcome standard draws with currentColor — tint it to the verdict, sharing the win/loss
   value color (.ri-fact-win / .ri-fact-loss below) that now rides the signed round-score number
   and the Ramsch outcome names. Global (not inspector-scoped): the stats KPI strip's outcome-free
   today, but declarerWinrate/ramschRate KPI cards share this slot class too (Gap 2, round-
   inspector-show-dont-tell). */
.ri-fact-icon[data-outcome="WIN"] {
  color: var(--ui-warning);
}
.ri-fact-icon[data-outcome="LOSS"] {
  color: var(--text-error);
}
/* Still consumed by the stats KPI strip (games.html's .stats-kpi-strip .ri-fact-label) — the
   round-inspector fragment no longer renders this word (round-inspector-show-dont-tell moved the
   label into a tooltip), but the KPI strip's rule stays visually identical, per Gap 2. */
.ri-fact-label {
  font-size: var(--fs-sm);
  color: var(--text-secondary);
  text-transform: uppercase;
  letter-spacing: 0.05em;
}
.ri-fact-value {
  font-size: var(--fs-2xl);
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  line-height: 1.25;
}
.ri-fact-value.ri-fact-win {
  color: var(--ui-warning);
}
.ri-fact-value.ri-fact-loss {
  color: var(--text-error);
}
.ri-fact-sub {
  font-size: var(--fs-sm);
  color: var(--text-secondary);
  font-variant-numeric: tabular-nums;
}
/* Hero-icon-on-top: the round-inspector's own fact cards go icon-primary
   (round-inspector-show-dont-tell) — the icon sits in normal flow, centered above the value,
   instead of the KPI strip's absolute corner mark (that treatment stays scoped to
   .stats-kpi-strip under § Games Page, Gap 2). The Margin/Bonus card carries only the icon (no
   .ri-fact-value sibling) and still centers cleanly off the same rule. */
.inspector-body .ri-fact {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  gap: var(--space-xs);
}
.inspector-body .ri-fact-icon {
  opacity: 0.9; /* play-test: hero prominence, a touch stronger than the KPI corner mark's 0.85 */
}
/* Icon-only fact cards (game-type, win/loss, Schneider/Schwarz margin — no .ri-fact-value to
   anchor) grow the hero icon into the freed vertical space instead of floating it in a half-empty
   card (round-inspector-show-dont-tell feel-pass). The enhancer draws every fact icon at 56px;
   these cards override up in CSS since only the layout, not the icon factory, distinguishes them. */
.inspector-body .ri-fact:not(:has(.ri-fact-value)) .ri-fact-icon svg {
  width: 88px; /* play-test: icon-only hero fill, up from the 56px data-card icon */
  height: 88px;
}
.ri-section h3 {
  margin: 0 0 var(--space-sm);
  font-size: var(--fs-sm);
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-secondary);
}
.ri-bid {
  display: flex;
  align-items: center;
  gap: var(--space-md);
  padding: 3px 0;
  max-width: 480px; /* keep name → bid → tag a compact readable line on wide pages */
}
/* Flexible label column (was a 205px min-width, which overflowed 390px viewports once the
   declarer tag joined the row); the declarer's own name carries the amber accent, so the tag can
   stay terse ("won at 18"). Persona bust + name + position icon in a row (round-inspector-
   show-dont-tell) — the persona carries no tooltip of its own here (the name text right beside it
   already carries identity); only the position icon's wrapper does. */
.ri-bid .seat-label {
  flex: 1;
  min-width: 0;
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: var(--fs-md);
  color: var(--text-primary);
}
.ri-bid.ri-declarer .seat-label {
  color: var(--ui-warning);
}
.ri-bid-persona,
.ri-bid-position {
  display: inline-flex;
  flex-shrink: 0;
}
.ri-bid-persona svg,
.ri-bid-pos svg {
  display: block;
}
/* Mini card tile: light vellum face + suit stripe, mirroring the game's cards (cards.js #E2E8F0
   face + colour stripe). Fixed, card-ratio dimensions so every card is the same size; the rank and
   the pip render in the suit colour as a top-left corner index (like physical cardstock), so the
   left sliver an overlapped fan shows still identifies the card. --suit is set per ri-suit-<s>
   class below; --rank-color is set only on .ri-trump (trump-purple rank tint, CSS-driven off the
   template's ri-trump class — no inline style, unlike the old JS-built tiles). */
.ri-card {
  display: inline-flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 1px;
  width: 44px;
  height: 62px; /* why: ~width / 0.714, the --card-ratio used across the game */
  padding: 3px 0 0 5px;
  background: #e2e8f0;
  border-radius: var(--radius-sm);
  border: 1px solid rgba(0, 0, 0, 0.25);
  border-left: 4px solid var(--suit);
}
/* Suit colors: mirrors cards.js SUIT_COLORS — desync check: grep -n
   "D97706\|BE123C\|046C4E\|0369A1" -r src/main/resources/static (game-history-htmx-spike design
   § Verification). */
.ri-suit-diamonds {
  --suit: #d97706;
}
.ri-suit-hearts {
  --suit: #be123c;
}
.ri-suit-spades {
  --suit: #046c4e;
}
.ri-suit-clubs {
  --suit: #0369a1;
}
.ri-card.ri-trump {
  --rank-color: #7e22ce; /* Royal Purple — mirrors constants.js TRUMP_COLOR */
}
.ri-card-rank {
  color: var(--rank-color, var(--suit));
  font-weight: 700;
  font-size: var(--fs-md);
  line-height: 1;
  font-variant-numeric: tabular-nums;
}
/* "10" is the one two-glyph rank — condensed so it stays inside the overlap sliver (real decks
   narrow their 10 indices the same way). */
.ri-card[data-rank="10"] .ri-card-rank {
  font-size: var(--fs-sm);
  letter-spacing: -0.06em;
}
/* Unicode-pip fallback (no-JS / pre-enhance content); round-inspection-enhance.js replaces this
   element outright with the real card-art svg, so no post-enhance override is needed here. */
.ri-pip {
  color: var(--suit);
  font-size: var(--fs-md);
  line-height: 1;
}
.ri-card svg {
  display: block;
}
.ri-bid .bid-value {
  min-width: 72px;
  font-variant-numeric: tabular-nums;
  font-size: var(--fs-lg);
  color: var(--text-primary);
}
.ri-bid.ri-declarer .bid-value {
  color: var(--ui-warning);
  font-weight: 700;
}
.ri-bid .declarer-tag {
  font-size: var(--fs-md);
  color: var(--ui-warning);
}
/* --- Trick blocks --- */
/* One bordered block per trick: a header line (trick number, its Augen, the running faction
   score) over three seat rows. Each seat row is the seat's full hand at trick start as an
   overlapping fan; the played card is lifted out of the fan (ri-played) instead of sitting in a
   separate column — one glance gives what they held AND what they chose. The color vocabulary
   carries over from the old table: cyan = led the trick (row inset edge), amber = won it (ring on
   the played card). */
.ri-tricks {
  display: flex;
  flex-direction: column;
  gap: var(--space-sm);
}
.ri-trick {
  border: 1px solid rgba(255, 255, 255, 0.08);
  border-radius: var(--radius-md);
  padding: var(--space-xs) var(--space-md) var(--space-sm);
  background: rgba(255, 255, 255, 0.02);
}
.ri-trick-head {
  display: flex;
  flex-wrap: wrap; /* a Ramsch head carries three named tallies — fold rather than overflow at 390 */
  align-items: baseline;
  gap: var(--space-md);
  padding: 2px 0 var(--space-xs);
}
.ri-trick-no {
  font-size: var(--fs-lg);
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  color: var(--text-secondary);
}
/* Icon + "+N" pair (number-glyphs-game-wide-sweep), mirroring games.html's .round-kpi wrapper:
   the icon leads, the title naming "Augen" rides this visible wrapper rather than the aria-hidden
   icon slot. */
.ri-trick-points {
  display: inline-flex;
  align-items: center;
  gap: 3px;
  font-size: var(--fs-sm);
  font-variant-numeric: tabular-nums;
  color: var(--text-secondary);
}
.ri-trick-points-icon {
  display: inline-flex;
  opacity: 0.65; /* play-test: matches .round-kpi-icon's supporting-mark dimness */
}
.ri-trick-points-icon svg {
  display: block;
}
/* Running score after this trick, right-aligned: one label+total pair per faction, the trick's
   taker tinted amber — the winner accent and the tally accent are the same fact, told once. */
.ri-trick-score {
  margin-left: auto;
  display: flex;
  align-items: baseline;
  gap: var(--space-md);
}
.ri-tally {
  display: inline-flex;
  align-items: baseline;
  gap: 5px;
  color: var(--text-secondary);
  font-size: var(--fs-sm);
}
.ri-tally-total {
  font-size: var(--fs-md);
  font-variant-numeric: tabular-nums;
}
.ri-tally-active {
  color: var(--ui-warning);
}
.ri-tally-active .ri-tally-total {
  font-weight: 700;
}
/* Seat row: the seat marker, then the fan. The lead accent is a cyan inset edge on the row —
   off the cards, so it never fights the winner ring. */
.ri-seat-row {
  display: flex;
  align-items: center;
  gap: var(--space-sm);
  padding: 4px 0 2px 6px;
}
.ri-seat-row.ri-lead {
  box-shadow: inset 3px 0 0 var(--system-hint);
}
/* Persona avatar + position icon, replacing the bare seat number and the FH/MH/RH abbreviation
   (round-inspector-show-dont-tell) — both wrapped in visible spans (.ri-seat-name/.ri-seat-pos)
   that carry the tooltip, since the seat row shows no name text of its own. Position icons inherit
   this color via currentColor; personas carry their own identity color inline (the JS factory
   call), so this color only ever shows through the position icon. */
.ri-seat-mark {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  min-width: 42px;
  color: var(--text-secondary);
}
.ri-seat-name,
.ri-seat-pos {
  display: inline-flex;
}
.ri-seat-persona svg,
.ri-seat-pos-icon svg {
  display: block;
}
.ri-seat-mark-declarer {
  color: var(--ui-warning);
}
/* The fan: overlapped cards (corner indices keep the sliver readable), the played card lifted
   above its neighbours. Static shadows only — nothing here animates. */
.ri-fan {
  display: flex;
  padding-top: 7px; /* headroom for the played card's lift */
}
.ri-fan .ri-card + .ri-card {
  /* Overlap step sized to the corner index: 25px visible = border 1 + stripe 4 + pad 5 + the
     widest index ("10", ~15px condensed below). 10 cards then span 269px — inside a 390 viewport
     with the seat mark beside. */
  margin-left: -19px;
}
/* The played card pulls fully out of the fan: lifted, and the fan PARTS around it (the next
   card's overlap margin resets) so its whole face shows without covering any neighbour's index —
   a z-index lift instead would paint over the card to its right. Costs ~17px row width, still
   inside 390. */
.ri-fan .ri-card.ri-played {
  transform: translateY(-7px);
  box-shadow:
    0 0 0 2px rgba(255, 255, 255, 0.4),
    0 3px 9px rgba(0, 0, 0, 0.5);
}
.ri-fan .ri-card.ri-played + .ri-card {
  margin-left: -2px;
}
/* Wider viewports have the room — relax the overlap so more of each card face shows. */
@media (min-width: 700px) {
  .ri-fan .ri-card + .ri-card {
    margin-left: -12px;
  }
  .ri-fan .ri-card.ri-played + .ri-card {
    margin-left: 0;
  }
}
/* Winner ring: the trick-taker's played card swaps the neutral ring for amber + a soft glow
   (static — DESIGN's no-animated-shadow rule doesn't bite). rgba mirrors --ui-warning #d97706. */
.ri-seat-row.ri-winner .ri-card.ri-played {
  box-shadow:
    0 0 0 2px var(--ui-warning),
    0 0 12px 2px rgba(217, 119, 6, 0.45);
}
/* Phone widths: the 10-card first-trick fan needs ~342px for its widest seat row, and the
   overlap can't tighten (card readability floors it at a 25px sliver). Trim the fragment's own
   side padding so that row fits its content box rather than bleeding past it — on /games the
   bleed lands within the viewport, but the narrower result overlay turns it into a horizontal
   scrollbar. Pairs with the inspect panel's full-width treatment (§ Result Overlays → narrow overlays). */
@media (max-width: 430px) {
  .inspector-body {
    padding: var(--space-lg) var(--space-xs);
  }
}

/* === Games Page === */

/* /games — the player's own warehoused runs (game-history-htmx-spike design § Surface): a run
   section per warehoused mode-file, round rows that navigate into the shared round-inspection
   fragment above via plain server-rendered pages (games-page-ux-pass design § Plumbing). Rows are
   quiet vellum-toned link surfaces — navigation, so neither metal nor glass (DESIGN Principle 18);
   each carries its contract emblem from the game-type icon family (games-list-enhance.js). */
.games-page {
  max-width: 900px;
  margin: 0 auto;
  padding: var(--space-xl) var(--space-lg);
  color: var(--text-primary);
  font-family: "Satoshi", system-ui, sans-serif;
}
.games-empty {
  display: flex;
  min-height: 70vh; /* centre the first-visit state in the viewport rather than pinning it to the top */
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--space-lg);
  text-align: center;
}
.games-empty-emblem {
  opacity: 0.6; /* a quiet mark, not a showpiece (DESIGN Principle 18 — the empty state is chrome) */
  color: #e2e8f0; /* Vellum — the empty-state feel pass's locked option D (games-list-enhance.js) */
}
.games-empty-copy {
  margin: 0;
  font-size: var(--fs-md);
  color: var(--text-secondary);
}
/* Play CTA: struck-amber metal (.btn-primary) without the .btn-plate glow — the glow register is
   reserved for the two screen-defining CTAs (DESIGN Principle 18). */
.games-empty-cta {
  display: inline-block;
  text-decoration: none;
}
.games-list h1 {
  margin: 0 0 var(--space-xs);
  font-size: var(--fs-2xl);
  font-weight: 700;
  letter-spacing: -0.01em;
}
.games-notice {
  margin: 0 0 var(--space-xl);
  color: var(--text-secondary);
  font-size: var(--fs-sm);
}
.games-mode {
  margin-bottom: var(--space-2xl);
}
.games-mode h2 {
  margin: 0 0 2px;
  font-size: var(--fs-xl);
  font-weight: 700;
}
.games-meta {
  margin: 0 0 var(--space-md);
  color: var(--text-secondary);
  font-size: var(--fs-sm);
  font-variant-numeric: tabular-nums;
}

/* --- Round row --- */
/* A quiet vellum-toned link surface (DESIGN Principle 18 — navigation, so neither metal nor glass).
   Icon-forward grid (games-page-ux-pass icon redesign): number, game-type emblem, stakes value,
   declarer name, then feat + outcome icons anchored to the right edge. Icons carry the row — see
   games-list-enhance.js and the .round-* accessible-name (aria-label) attributes in games.html. */
.games-round-row {
  display: grid;
  /* number · type emblem · role icon · declarer avatar · declarer name (1fr) · KPI cluster · verdict */
  grid-template-columns: 30px 46px 38px 30px 1fr auto auto;
  column-gap: 10px;
  align-items: center;
  padding: 6px 14px;
  margin-bottom: var(--space-sm);
  border: 1px solid rgba(255, 255, 255, 0.08);
  border-radius: var(--radius-md);
  background: var(--bg-surface);
  color: var(--text-primary);
  text-decoration: none;
  transition:
    background var(--transition-fast),
    border-color var(--transition-fast),
    transform var(--transition-fast);
}
.games-round-row:hover {
  background: var(--bg-elevated);
  border-color: rgba(255, 255, 255, 0.18);
  transform: translateX(3px);
}
.round-number {
  text-align: center;
  color: var(--text-secondary);
  font-variant-numeric: tabular-nums;
  font-weight: 500;
}
.round-emblem {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 46px; /* why: matches EMBLEM_SIZE in games-list-enhance.js */
  height: 46px;
}
.round-emblem svg {
  display: block;
}
/* The human's role this round (games-list-enhance.js#appendRoleIcon) — the compound defender icon
   is position:absolute inside, so this slot must be a positioned box of a known size. */
.round-role {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 38px; /* why: matches ROLE_ICON_SIZE in games-list-enhance.js */
  height: 38px;
  color: var(--text-secondary);
}
.round-role svg {
  display: block;
}
/* The declarer's court persona in its identity colour, beside the name (games-list-enhance.js). */
.round-avatar {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 30px; /* why: matches AVATAR_SIZE in games-list-enhance.js */
  height: 30px;
}
.round-avatar svg {
  display: block;
}
.round-declarer {
  min-width: 0; /* lets the 1fr track actually shrink below the name's intrinsic width */
  color: var(--text-secondary);
}
/* The row's three KPIs (bid · worth · net), echoing the stats panel's — a right-aligned numeric
   cluster before the verdict. */
.round-kpis {
  display: flex;
  align-items: baseline;
  gap: var(--space-md);
  justify-content: flex-end;
  font-variant-numeric: tabular-nums;
}
/* A KPI (bid/worth): its number-glyph icon before its number (worth-icon task), centered like
   .round-net below rather than the text label's old baseline alignment. */
.round-kpi {
  display: inline-flex;
  align-items: center;
  gap: 4px;
}
.round-kpi-icon {
  display: inline-flex;
  opacity: 0.65; /* play-test: matches the retired text label's dimness — a supporting mark, not the number */
}
.round-kpi-icon svg {
  display: block;
}
.round-bid,
.round-value {
  color: var(--text-secondary);
}
/* The declarer-signed net, carrying the AVG-NET balance scale and coloured by its own sign
   (.round-result-win/-loss on the span) — the icon draws with currentColor, so it tints too. */
.round-net {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  font-weight: 700;
}
.round-net-icon {
  display: inline-flex;
}
.round-net-icon svg {
  display: block;
}
.round-verdict {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: var(--space-md);
}
.round-feats {
  display: flex;
  align-items: center;
  gap: var(--space-sm);
}
.round-feat {
  display: flex;
  align-items: center;
}
/* The four icon feats (Hand/Ouvert/Schwarz/Schneider) — a fixed icon slot. Overbid has no icon
   (.round-feat-overbid below) and carries no data-feat, so it's untouched by this rule. */
.round-feat[data-feat] {
  justify-content: center;
  width: 40px; /* why: matches FEAT_SIZE in games-list-enhance.js */
  height: 40px;
}
.round-feat[data-feat] svg {
  display: block;
}
/* Overbid — a lost declared game, kept as a small modest text mark since it has no icon. */
.round-feat-overbid {
  padding: 2px var(--space-xs);
  border-radius: var(--radius-sm);
  font-size: var(--fs-xs);
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  white-space: nowrap;
  color: var(--score-negative);
  background: rgba(255, 90, 120, 0.13); /* --score-negative, low-alpha fill */
}
.round-outcome {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 46px; /* why: matches OUTCOME_SIZE in games-list-enhance.js */
  height: 46px;
}
.round-outcome svg {
  display: block;
}
.round-result-win {
  color: var(--score-positive);
}
.round-result-loss {
  color: var(--score-negative);
}

/* Narrow viewports: the fixed lead-column widths and wide gaps overflow a phone (DESIGN
   Principle 10). Tighten the grid and shrink the icon slots (CSS-scaling the SVGs to their smaller
   containers — the desktop constants above stay the drawn size, only the display size shrinks);
   the declarer name truncates so the row stays one line. */
@media (max-width: 560px) {
  .games-round-row {
    grid-template-columns: 22px 34px 30px 26px 1fr auto auto;
    column-gap: var(--space-xs);
    padding: var(--space-xs) var(--space-sm);
  }
  .round-emblem {
    width: 34px;
    height: 34px;
  }
  .round-role {
    width: 30px;
    height: 30px;
  }
  .round-avatar {
    width: 26px;
    height: 26px;
  }
  .round-role svg,
  .round-avatar svg {
    width: 100%;
    height: 100%;
  }
  .round-kpis {
    gap: var(--space-sm);
  }
  .round-feat[data-feat] {
    width: 30px;
    height: 30px;
  }
  .round-outcome {
    width: 38px;
    height: 38px;
  }
  .round-emblem svg,
  .round-feat[data-feat] svg,
  .round-outcome svg {
    width: 100%;
    height: 100%;
  }
  .round-verdict {
    gap: var(--space-sm);
  }
  .round-feats {
    gap: var(--space-xs);
  }
  .round-declarer {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
}
/* Back control: the sole direct-child <a> of .games-detail (games.html's plain href="/games"
   link back to the list). */
.games-detail > a {
  display: inline-block;
  margin-bottom: var(--space-lg);
  color: var(--ui-warning);
  text-decoration: none;
}

/* --- Self-analytics panel (games-self-analytics slice 1) --- */
/* Chips are plain <a> links (no JS): a click re-navigates /games with the toggled param, so the
   only styling job here is active/inactive state, not interactivity. The KPI strip reuses the
   round-inspector's .ri-facts/.ri-fact stat-card language directly (§ Rendering reuses the games
   page's visual language) — .stats-kpi-strip only adds the section's top margin. */
.stats-panel {
  margin-bottom: var(--space-2xl);
  padding-bottom: var(--space-xl);
  border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.stats-panel h2 {
  margin: 0 0 var(--space-md);
  font-size: var(--fs-xl);
  font-weight: 700;
}
.stats-panel h3 {
  margin: 0 0 var(--space-xs);
  font-size: var(--fs-sm);
  font-weight: 700;
  color: var(--text-secondary);
  text-transform: uppercase;
  letter-spacing: 0.05em;
}
.stats-chip-rail {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-md);
  margin-bottom: var(--space-lg);
}
.stats-chip-group {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-xs);
}
.stats-chip {
  display: inline-flex;
  align-items: center;
  gap: var(--space-xs);
  padding: var(--space-xs) var(--space-md);
  border: 1px solid rgba(255, 255, 255, 0.14);
  border-radius: var(--radius-md);
  color: var(--text-secondary);
  font-size: var(--fs-md);
  text-decoration: none;
  white-space: nowrap;
  transition:
    background var(--transition-fast),
    border-color var(--transition-fast),
    color var(--transition-fast);
}
.stats-chip:hover {
  border-color: rgba(255, 255, 255, 0.28);
  color: var(--text-primary);
}
.stats-chip-active {
  border-color: var(--ui-warning);
  color: var(--ui-warning);
  background: rgba(255, 193, 87, 0.1); /* --ui-warning, low-alpha fill */
}
/* The matching-round count each chip carries (games-stats-panel-surface-and-styling): a quiet
   tabular number after the label. */
.stats-chip-count {
  font-variant-numeric: tabular-nums;
  font-weight: 700;
  font-size: var(--fs-sm);
  opacity: 0.7;
}
/* Zero matches under the other active filters — the chip greys out and drops its href (a dead-end
   click). The active chip is never disabled (it must stay clickable to clear itself). */
.stats-chip-disabled {
  opacity: 0.5;
  pointer-events: none;
}
.stats-kpi-strip {
  margin-bottom: var(--space-lg);
}
/* The KPI card's corner emblem, scoped here (round-inspector-show-dont-tell, Gap 2): the round
   inspector's own .ri-fact went icon-primary (hero icon on top, § Round Inspection), but this
   panel's KPI cards keep the original small corner mark unchanged — so the positioning rules that
   used to be global live only under this strip now. */
.stats-kpi-strip .ri-fact {
  position: relative; /* anchors the corner .ri-fact-icon */
}
.stats-kpi-strip .ri-fact:has(.ri-fact-icon) {
  padding-right: 46px;
}
.stats-kpi-strip .ri-fact-icon {
  position: absolute;
  top: var(--space-sm);
  right: var(--space-sm);
  opacity: 0.85;
}
.stats-breakdowns {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-xl);
}
.stats-breakdown table {
  border-collapse: collapse;
}
.stats-breakdown th,
.stats-breakdown td {
  padding: var(--space-xs) var(--space-md) var(--space-xs) 0;
  font-size: var(--fs-sm);
  text-align: left;
}
.stats-breakdown th {
  color: var(--text-secondary);
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.05em;
}
.stats-breakdown .stats-num {
  text-align: right;
  font-variant-numeric: tabular-nums;
}
/* The by-round-type panel's donut slot (chart-vocabulary design): games-list-enhance.js fills it
   from the table's own data-round-type/data-rounds attributes. Sits above the table (the panel is
   one of three side-by-side .stats-breakdowns columns, too narrow for the composer's donut-beside-
   table row treatment — an open question the design left for driving); :empty hides the slot when
   JS never runs, matching the .breakdowns/#kpi-health empty-hide precedent above. */
.stats-roundtype-chart {
  display: flex;
  justify-content: center;
  margin-bottom: var(--space-md);
}
.stats-roundtype-chart:empty {
  display: none;
}

/* Chip-rail and breakdown-table icon slots (games-list-enhance.js): the icon draws at the JS
   factory's own explicit size, so these only need a positioning box + block-level svg — no CSS
   sizing (the "let CSS own the size" clamp treatment other icon-shaped chrome uses doesn't fit
   here, since the compound role:DEFENDER icon below needs its offset computed in JS proportional
   to a known size). The chip's gap comes from .stats-chip's flex gap above; a <td> isn't flex, so
   .stats-dim-icon carries its own trailing margin instead. */
.stats-chip-icon,
.stats-dim-icon {
  position: relative; /* anchors a compound role:DEFENDER paired-standard icon, see below */
  display: inline-flex;
  align-items: center;
  justify-content: center;
}
.stats-chip-icon svg,
.stats-dim-icon svg {
  display: block;
}
.stats-dim-icon {
  margin-right: 4px;
}
/* The role:DEFENDER icon composes two battle standards absolutely centered then leaned outward —
   games-list-enhance.js#appendPairedStandards, the leaderboard-detail-body.js
   #makePairedBannerMarker recipe at a different scale (chip rail, breakdown cell, KPI card all
   reuse this one class). */
.stats-paired-standard {
  position: absolute;
  top: 50%;
  left: 50%;
}

/* --- Overlay consumer (games-overlay.js) --- */
/* The menu-mounted /games overlay: the same content fragment the page composes, inside the shared
   vellum shell (overlay-shell.js). Sized to the page's own column width, not the shell's
   full 980px — the content wasn't designed for that width. The content div (not the panel) owns
   the internal scroll, so a long history doesn't carry the close row along with it. */
.games-overlay-panel {
  max-width: 900px; /* matches .games-page's own column width */
}
.games-overlay-content {
  overflow-y: auto;
  max-height: 70vh;
}
.games-overlay-error {
  padding: var(--space-lg);
  color: var(--text-secondary);
  text-align: center;
}

/* Legal links (go-live-impressum-datenschutz), standalone at page end — the overlay
   consumer above never mounts this (it fetches `games :: content` only, not the whole
   page), so it stays reachable exclusively on the standalone /games navigation. */
.games-page > .legal-footer-links {
  justify-content: center;
  margin-top: var(--space-2xl);
}

/* === Help Page === */

/* /help — the app reference (help-page design § Styling): Principle 12's bar is Lothar's
   reference sheet, not a generic docs page, so this holds to the established vellum/petrol/Satoshi
   vocabulary rather than a fresh layout — a 1200px two-column spread (wider than /games' single
   900px column, tuned for the reference's paired entries per Andre's live pixel round). Icons
   render plain — no etch entrance (reference entries label, they don't arrive, the same line
   ICONOGRAPHY draws for feat chips on stats surfaces). */
.help-page {
  max-width: 1200px;
  margin: 0 auto;
  padding: var(--space-xl) var(--space-lg) var(--space-2xl);
  font-family: "Satoshi", system-ui, sans-serif;
}
.help-back {
  display: inline-block;
  margin-bottom: var(--space-lg);
  color: var(--ui-warning);
  text-decoration: none;
}
/* Scoped to `.help-content` (the th:fragment="content" boundary), not `.help-page` — the
   overlay-mounted fragment (reference-overlay.js) never carries the page class (the
   html:has(.help-page) scroll-lock carve-out would otherwise leak into the game page), so basic
   prose styling can't hang off it either; `.help-content` travels with the fragment into both the
   standalone page and the overlay panel. */
/* The fragment's color anchor lives here, not on `.help-page`: inside the overlay the inheritance
   chain runs through the native <dialog>, whose UA style resets color to CanvasText — unclassed
   prose (lists, tables, wells) would silently render browser-palette white instead of the token
   (review finding, confirmed by a computed-style sweep of page vs overlay). */
.help-content {
  color: var(--text-primary);
}
.help-content h1 {
  margin: 0 0 var(--space-xs);
  font-size: var(--fs-2xl);
  font-weight: 700;
  letter-spacing: -0.01em;
}
.help-intro {
  margin: 0 0 var(--space-2xl);
  color: var(--text-secondary);
  font-size: var(--fs-md);
}
/* The /help → /rules doorway (skat-rules-page design § Route, controller, template): a quiet
   prose link, not a second CTA — the reciprocal of .rules-help-link below. */
.help-rules-link {
  margin: 0 0 var(--space-2xl);
  font-size: var(--fs-md);
}
.help-rules-link a {
  color: var(--ui-warning);
}
.help-section {
  margin-bottom: var(--space-2xl);
}
.help-section h2 {
  margin: 0 0 var(--space-md);
  padding-bottom: var(--space-xs);
  border-bottom: 1px solid rgba(255, 255, 255, 0.12);
  font-size: var(--fs-xl);
  font-weight: 700;
  color: var(--text-accent);
}

/* --- Entry rows --- */
/* Entries flow two-up within each section (section h2 spans the full width above them), halving
   the page's vertical run — the reference reads as a spread, not a scroll. Each entry is icon
   well | text (name stacked over one-liner); the icon well's fixed 128px column keeps iconless
   entries indented identically to iconed ones, and is wide enough for two 56px icons (or two
   fanned card renders) to sit side by side before wrapping. Multi-icon entries (up to four,
   SuitColors) wrap 2-up within that column. */
.help-entries {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  column-gap: var(--space-2xl);
}
.help-entry {
  display: grid;
  grid-template-columns: 128px 1fr;
  column-gap: var(--space-lg);
  align-items: start;
  padding: var(--space-md) 0;
  border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.help-entry-icons {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-sm);
  align-content: flex-start;
}
/* Some tokens mount several nodes into their one template span (help-enhance.js): the four flat
   pips of suit-pips-flat, the before/after pair of card-trump, the two backs of card-skat.
   `display: contents` folds those children into the parent's flex row as if they were its own
   direct children, so wrap/nowrap and the card-fan margin apply to the icons themselves. */
[data-help-icon="suit-pips-flat"],
[data-help-icon="card-trump"],
[data-help-icon="card-skat"] {
  display: contents;
}
.help-entry-icons svg {
  display: block;
}
/* Card renders (card-trump, card-skat) mount real `.card` nodes, which size off the layout engine's
   `--card-height` var — absent off the game board, so the help page sets it locally (`--card-ratio`
   already has a `:root` default that applies here — don't re-pin it, or it becomes a third home for
   a value the global comment already flags as desync-prone). Multi-card entries fan with a negative
   margin so a pair still sits inside the icon well rather than wrapping to a second row. Static —
   no engine shadow, calm reference imagery. */
.help-entry-icons .card {
  --card-height: 84px;
  box-shadow: none;
}
/* A card pair fans side by side; without nowrap the flex container breaks them onto two rows. Scoped
   to card wells so the multi-pip entries (suit-pips-flat) keep their 2×2 wrap. */
.help-entry-icons:has(.card) {
  flex-wrap: nowrap;
}
.help-entry-icons .card + .card {
  margin-left: -18px;
}
.help-entry-name {
  font-weight: 700;
  font-size: var(--fs-md);
  margin-bottom: 2px;
}
.help-entry-desc {
  margin: 0;
  color: var(--text-secondary);
  font-size: var(--fs-md);
}
/* Narrow viewports: collapse to a single column of entries (DESIGN Principle 10). The well stays
   wide enough for the fanned card pair (~102px) so it never overflows under the text. */
@media (max-width: 720px) {
  .help-entries {
    grid-template-columns: 1fr;
    column-gap: 0;
  }
  .help-entry {
    grid-template-columns: 116px 1fr;
  }
}

.help-cta {
  margin-top: var(--space-xl);
  text-align: center;
}
.help-play-cta {
  text-decoration: none;
}

/* Legal links (go-live-impressum-datenschutz), standalone at page end. */
.help-page > .legal-footer-links {
  justify-content: center;
  margin-top: var(--space-2xl);
}

/* === Rules Page === */

/* /rules — the game reference (skat-rules-page design § Styling): Principle 12's bar is Lothar's
   rulebook, not a generic docs page, so this holds to the established vellum/petrol/Satoshi
   vocabulary. Long-form prose reads narrow — a single centered column at a prose measure, between
   /games' 900px and /help's 1200px two-column spread (this page has no paired entries to spread
   across). */
.rules-page {
  max-width: 860px;
  margin: 0 auto;
  padding: var(--space-xl) var(--space-lg) var(--space-2xl);
  font-family: "Satoshi", system-ui, sans-serif;
}
.rules-back {
  display: inline-block;
  margin-bottom: var(--space-lg);
  color: var(--ui-warning);
  text-decoration: none;
}
/* Scoped to `.rules-content` (the th:fragment="content" boundary), not `.rules-page` — same
   reasoning as .help-content above: the overlay-mounted fragment never carries the page class
   (would leak the html:has(.rules-page) scroll-lock carve-out into the game page), so this whole
   prose block (headings, paragraphs, lists, the Augen table, the deviations links) is scoped to
   the class that actually travels with the fragment into both the standalone page and the overlay. */
/* Color anchor on the fragment class — same <dialog> CanvasText reasoning as `.help-content`. */
.rules-content {
  color: var(--text-primary);
}
.rules-content h1 {
  margin: 0 0 var(--space-xs);
  font-size: var(--fs-2xl);
  font-weight: 700;
  letter-spacing: -0.01em;
}
.rules-intro {
  margin: 0 0 var(--space-md);
  color: var(--text-secondary);
  font-size: var(--fs-md);
}
/* The /rules → /help doorway — the reciprocal of .help-rules-link above. What icons and buttons
   mean stays /help's job (skat-rules-page design § Route, controller, template); this page only
   points there. */
.rules-help-link {
  margin: 0 0 var(--space-2xl);
  font-size: var(--fs-md);
}
.rules-help-link a {
  color: var(--ui-warning);
}

/* --- Table of contents --- */
/* A plain inline anchor list, not a sidebar — this is a single long page, not a multi-page docs
   site. A middle-dot separator between entries reads as one flowing line rather than a bulleted
   list. Sticks to the viewport top as the page scrolls (the jump index stays reachable through a
   long read); the header above it scrolls away. The solid petrol background masks section prose
   passing underneath, and z-index 80 is the controls band (above content, below transient). */
.rules-toc {
  position: sticky;
  top: 0;
  z-index: 80;
  margin: 0 0 var(--space-2xl);
  padding: var(--space-sm) 0;
  border-bottom: 1px solid rgba(255, 255, 255, 0.12);
  background: var(--bg-outer);
}
.rules-toc ul {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-xs) var(--space-sm);
  margin: 0;
  padding: 0;
  list-style: none;
}
.rules-toc li {
  display: flex;
  align-items: center;
  gap: var(--space-sm);
}
.rules-toc li:not(:last-child)::after {
  content: "·";
  margin-left: var(--space-sm);
  color: var(--text-secondary);
}
.rules-toc a {
  color: var(--text-primary);
  text-decoration: none;
  font-size: var(--fs-sm);
}
.rules-toc a:hover {
  color: var(--ui-warning);
}

/* --- Sections --- */
.rules-content section {
  margin-bottom: var(--space-2xl);
}
.rules-content h2 {
  margin: 0 0 var(--space-md);
  padding-bottom: var(--space-xs);
  border-bottom: 1px solid rgba(255, 255, 255, 0.12);
  font-size: var(--fs-xl);
  font-weight: 700;
  color: var(--text-accent);
}
.rules-content p,
.rules-content ol,
.rules-content ul {
  margin: 0 0 var(--space-md);
  font-size: var(--fs-md);
  line-height: 1.5;
}
.rules-content ol,
.rules-content ul {
  padding-left: var(--space-lg);
}
.rules-content section#abweichungen a {
  color: var(--text-primary);
}
.rules-content section#abweichungen a:hover {
  color: var(--ui-warning);
}

/* --- Augen table --- */
.rules-content table {
  width: 100%;
  margin: 0 0 var(--space-lg);
  border-collapse: collapse;
  font-size: var(--fs-md);
  font-variant-numeric: tabular-nums;
}
.rules-content th,
.rules-content td {
  padding: var(--space-xs) var(--space-sm);
  text-align: left;
  border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.rules-content th {
  color: var(--text-secondary);
  font-weight: 700;
}
.rules-content td:last-child,
.rules-content th:last-child {
  text-align: right;
}

/* --- Illustrations --- */
/* Every `data-rules-icon` placeholder pairs an icon well with a visible one-liner underneath
   (skat-rules-page design § Illustrations) — the mirror of /help's icon-well-plus-text entry, but
   stacked (this page has no two-up grid to sit beside). */
.rules-illustration {
  margin: 0 0 var(--space-xl);
}
.rules-icons {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-sm);
  margin-bottom: var(--space-sm);
}
.rules-icons svg {
  display: block;
}
/* Every builder mounts one or more nodes into its one template span (rules-enhance.js); `display:
   contents` folds those children into the flex row (.rules-icons or .rules-cards) as if they were
   its own direct children — otherwise the span is the row's only flex item and its own children
   fall back to normal block flow (the card tokens stacking vertically instead of fanning), the same
   help-entry-icons trick help.html's suit-pips-flat relies on. */
[data-rules-icon] {
  display: contents;
}
.rules-caption {
  margin: 0;
  color: var(--text-secondary);
  font-size: var(--fs-sm);
}

/* --- Card rows --- */
/* Card-based illustrations (augen-row, jacks-row, null-row, trick-row) fan at a size large enough
   that corner labels and tally arcs read as content, not decoration (play-test: 120px height, ~86px
   wide per card). box-shadow: none keeps them calm reference imagery, not gameplay cards. */
.rules-cards {
  display: flex;
  flex-wrap: nowrap;
  margin-bottom: var(--space-sm);
}
.rules-cards .card {
  --card-height: 120px; /* play-test: large enough that labels/arcs read as content */
  box-shadow: none;
}
.rules-cards .card + .card {
  margin-left: -16px; /* play-test: a fan feel without hiding the tally arcs */
}
/* Narrow viewports: six-card rows (augen-row) can't fit 360px on overlap alone at the base size
   without burying the cards (review finding, 2026-07-07) — both values shrink together. */
@media (max-width: 720px) {
  .rules-cards .card {
    --card-height: 90px;
  }
  .rules-cards .card + .card {
    margin-left: -24px;
  }
}

/* --- Deviation callouts --- */
/* An aside with a left accent border and a small-caps label — distinct from body prose at a
   glance, calm, no warning-triangle theatrics (skat-rules-page design § Page structure). */
.rules-callout {
  margin: 0 0 var(--space-md);
  padding: var(--space-sm) var(--space-md);
  border-left: 3px solid var(--ui-warning);
  background: rgba(255, 255, 255, 0.03);
}
.rules-callout p:last-child {
  margin-bottom: 0;
}
.rules-callout-label {
  margin: 0 0 var(--space-xs);
  font-size: var(--fs-sm);
  font-weight: 700;
  font-variant: small-caps;
  letter-spacing: 0.02em;
  color: var(--ui-warning);
}

.rules-cta {
  margin-top: var(--space-xl);
  text-align: center;
}
.rules-play-cta {
  text-decoration: none;
}

/* Legal links (go-live-impressum-datenschutz), standalone at page end. */
.rules-page > .legal-footer-links {
  justify-content: center;
  margin-top: var(--space-2xl);
}

/* === Reference Overlays === */

/* The menu-mounted /help and /rules overlays (reference-overlay.js, help-rules-menu-overlays task):
   the same content fragments the two standalone pages compose, inside the shared vellum shell
   (overlay-shell.js) — consumers 4 and 5 of docs/adr/2026-07-03-non-game-surface-rendering-model's
   panel pattern. A separate section rather than a trailing sub-block of Help Page or Rules Page
   (the .games-overlay-panel precedent): one JS module serves both pages, so its CSS doesn't belong
   to either page's section alone. Each panel is sized to its own standalone page's column width —
   1200px for /help's two-column spread, 860px for /rules' single prose column (the two disagree, so
   one shared panel-width class would either starve /help's paired entries or leave /rules swimming
   in whitespace) — but the content div's scroll behavior is identical for both (one shared JS
   code path creates it either way), so it stays one shared class. */
.help-overlay-panel {
  max-width: 1200px; /* matches .help-page's own column width */
}
.rules-overlay-panel {
  max-width: 860px; /* matches .rules-page's own column width */
}
.reference-overlay-content {
  overflow-y: auto;
  max-height: 70vh;
}
.reference-overlay-error {
  padding: var(--space-lg);
  color: var(--text-secondary);
  text-align: center;
}

/* === Legal Page === */

/* /impressum + /datenschutz (go-live-impressum-datenschutz design): German-only legal prose,
   no locale ternary and no i18n keys (the text is hardcoded German in the templates — a
   deliberate deviation from every other page shell). Narrower than /rules' 860px prose
   column — legal text has no illustrations or table of contents to spread for, just short
   labeled paragraphs. Speaks the same vellum/petrol/Satoshi vocabulary as every other public
   page (Principle 12), not a bare unstyled legal-notice page. */
.legal-page {
  max-width: 680px;
  margin: 0 auto;
  padding: var(--space-xl) var(--space-lg) var(--space-2xl);
  color: var(--text-primary);
  font-family: "Satoshi", system-ui, sans-serif;
}
.legal-back {
  display: inline-block;
  margin-bottom: var(--space-lg);
  color: var(--ui-warning);
  text-decoration: none;
}
.legal-page h1 {
  margin: 0 0 var(--space-lg);
  font-size: var(--fs-2xl);
  font-weight: 700;
  letter-spacing: -0.01em;
}
.legal-page h2 {
  margin: 0 0 var(--space-sm);
  font-size: var(--fs-lg);
  font-weight: 700;
  color: var(--text-accent);
}
.legal-page section {
  margin-bottom: var(--space-lg);
}
.legal-page p {
  margin: 0;
  color: var(--text-secondary);
  font-size: var(--fs-md);
  line-height: 1.6;
}
.legal-page code {
  font-family: monospace;
  font-size: 0.9em;
  color: var(--text-primary);
}
.legal-stand {
  margin-top: var(--space-xl);
  color: var(--text-secondary);
  font-size: var(--fs-sm);
}

/* === Reduced Motion === */

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    /* Delays must collapse with durations: a delayed `both`-fill one-shot
       (scene topple, reveal beats) otherwise HOLDS its from-frame through
       the delay — reduced-motion users would see losers' standards planted
       for 0.7s, an inverted verdict, not just slower motion. */
    animation-delay: 0s !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    transition-delay: 0s !important;
  }
}
