:root {
  /* ---- Phase 2 approved palette (07_Design_Tokens.css) ----
     Adopted wholesale so the dashboard matches the client-approved direction. The older
     semantic names below now POINT AT these rather than carrying their own values, so every
     existing rule inherits the new palette without being rewritten. */
  /* ---- Surface ramp: DESATURATED and dropped (measured against the gold standard) ----
     The board and the mockup were captured at the same size and compared. Two findings drove
     this, and both were the opposite of "add more colour":

       - 35% of our board carried saturated colour against the mockup's 9.6%, and in the stage
         band -- where neither has photographs, so it is a fair comparison -- 47% against 11%.
         The cause was not the accents. It was THESE tokens: at low luminance a blue cast reads
         as high saturation, and surface-2 (s=0.46), surface-3 (s=0.43) and the subtle border
         (s=0.40) all crossed the threshold. Our neutral chrome was the colour mass, so the real
         accents had nothing to stand out from.
       - our black point was 10.7 against their 3.9, and the darkest 0.5% of the board 10.5
         against 0.0. Nothing was dark enough for the bloom to fall onto.

     So: near-black ground, a nearly neutral ramp with only a slight cool cast, and WIDER steps
     between the rungs (5.9 / 12.7 / 21.6 / 30.7 / 42.5 against the old 10.7 / 17.5 / 26.4 /
     33.3 / 43.0) so nesting reads as depth -- which is what VIS-301 wanted and did not get,
     because its steps were too small to see.

     This deviates from 07_Design_Tokens.css, which the original comment below adopted wholesale.
     Deliberate: the tokens were the diagnosis. */
  /* TRUE black, not near-black. Measured A/B against the mockup with everything else held
     constant: the board's darkest 0.5% went 3.6 -> 0.0, exactly matching the gold standard, and
     the tonal range widened by 2.8 points. Median, P95 and edge energy were unchanged, so this is
     a free move on the one contrast metric that was still off.
     The hexagons are the only thing between the panels now, which is the point -- the bloom falls
     on silver over black rather than silver over a dark blue-grey. */
  --sc-bg: #000000;
  --sc-surface-0: #0a0d12;
  --sc-surface-1: #12161c;
  --sc-surface-2: #1b1f26;
  --sc-surface-3: #262b33;
  --sc-border-subtle: #39404a;
  --sc-text: #eef3f9;
  --sc-text-muted: #aab6c8;
  --sc-player: #3f8fff;
  --sc-opponent: #ff5b62;
  --sc-defense: #33d2e1;
  --sc-action: #f6b929;
  --sc-action-hover: #ffd15e;
  --sc-success: #55c88d;
  --sc-danger: #ff5b62;
  --sc-radius-sm: 8px;
  --sc-radius-md: 12px;
  --sc-radius-lg: 18px;
  --sc-shadow-panel: 0 12px 30px rgb(0 0 0 / 0.28);
  --sc-glow-action: 0 0 18px rgb(246 185 41 / 0.18);
  --sc-summary-height: 92px;
  --sc-gap: 16px;

  /* ---- D0: four-zone dashboard geometry ----
     Adapted from dashboard_shell_reference.css. Consumed only under  so defining them
     unconditionally costs nothing and keeps every zone dimension in one place for the sign-off
     conversation. */
  /* Raised from the reference's clamp(176px, 22dvh, 218px) after measuring the real panel. The
     minimum had to grow so one full-width metric column fits on a 768px-tall screen; the MAXIMUM
     came down, because at 1080 the reference height left visible dead space under the panels and
     the stage is where that height is wanted. */
  --sc-hud-h: clamp(208px, 21dvh, 224px);
  /* Measured off the approved mockup: Zone 3 spans x=1122..1655 of a 1672px board, i.e. ~32% of
     it, and reads as roughly the width of the Player 2 corner above it. The previous
     clamp(280px, 25vw, 340px) capped at 340px, which is 18% at 1920 — barely half the intended
     share, and the reason the prose history had to scroll so hard. */
  /* G3: 32vw, not 31. acceptance_criteria.json asks for 31-33% and 31vw sat on the exact edge,
     so sub-pixel rounding put the measured share at 30.96% and it failed the band it was aimed
     at. A target set to a boundary is a target that fails half the time. 32vw is the centre of
     the range and also what the mockup measures (Zone 3 spans x=1122..1655 of a 1672px board).
     The bounds are widened so the vw term, not a clamp end, governs across 1024-1920. */
  --sc-rail-w: clamp(300px, 32vw, 640px);
  --sc-command-h: clamp(72px, 9dvh, 94px);
  --sc-edge: clamp(8px, 1.2vw, 18px);
  /* The shared ROLE / TEMPO SOURCE gutter beside the technique pair. */
  --commit-gutter: clamp(88px, 6.5vw, 124px);
  --sc-motion-fast: 160ms;
  --sc-motion-normal: 280ms;
  --sc-motion-emphasis: 460ms;
  --sc-ease-out: cubic-bezier(.2,.8,.2,1);

  /* ---- VIS-301: surface hierarchy ----
     Four steps of depth. The Phase 2 note is that the old UI had "too many equally weighted
     bordered rectangles": every container carried the same 1px border, so nesting read as a pile
     of boxes rather than as depth. Nested surfaces now step in LUMINANCE and reserve borders for
     things that carry meaning — role, ownership, and state. */
  --surface-page: var(--sc-bg);
  --surface-1: var(--sc-surface-0);   /* dashboard panels        */
  --surface-2: var(--sc-surface-1);   /* cards inside panels     */
  --surface-3: var(--sc-surface-2);   /* panels inside cards     */
  --surface-4: var(--sc-surface-3);   /* controls inside those   */

  --bg: var(--sc-bg);
  --panel: var(--sc-surface-1);
  --panel-2: var(--sc-surface-2);
  --panel-3: var(--sc-surface-3);
  --border: var(--sc-border-subtle);
  --text: var(--sc-text);
  --text-dim: var(--sc-text-muted);
  --p1: var(--sc-player);
  --p2: var(--sc-opponent);
  /* applyFighterNeon overwrites these from each deck's style once a bout has fighters. They need a
     default all the same: a `rgb(var(--p1-rgb) / 0.2)` stop with --p1-rgb unset is invalid at
     computed-value time, which drops the WHOLE background-image list it sits in -- so the Range
     panel would lose its honeycomb as well, before setup, for want of a fallback. */
  --p1-rgb: 63 127 209;
  --p2-rgb: 214 74 92;
  --accent: var(--sc-action);
  --good: var(--sc-success);
  --bad: var(--sc-danger);

  /* Single Exchange UX (M1) — persistent shell surfaces. Deeper ground than the old flat --bg so
     the HUD panels and their corner glows read as lit objects against it. */
  --shell-bg: var(--sc-bg);
  --hud-panel: linear-gradient(160deg, var(--sc-surface-2) 0%, var(--sc-surface-0) 100%);
  --hud-border: var(--sc-border-subtle);
  --rail-bg: var(--sc-surface-0);
  --p1-glow: rgba(79, 163, 255, 0.30);
  --p2-glow: rgba(255, 107, 107, 0.30);
  --stam-spent: var(--sc-surface-3);
  --flow-cool: var(--sc-player);
  --fury-hot: #ff7043;
  /* Skew applied to the two fighter panels so they angle toward the centered range track. */
  /* The lean of the fighter corners toward the centre. The mockup slants ~60px across a 1672px
     board (~3.5%); 18px flat read as a nicked corner rather than a shape. */
  --hud-cut: clamp(20px, 2.6vw, 50px);

  /* Command Ribbon v2 — stable visual semantics */
  --role-attack: #ff6b57;
  --role-defense: var(--sc-defense);   /* cyan Defense language (P2 req 1) */
  --dirty: #d4a017;
  --universal: #8a8d9a;
  --family-kinetic: #e0653a;
  --family-torque: #3f7fd1;
  --family-fulcrum: #2fae8a;
  --family-leverage: #9a5be0;
}

* { box-sizing: border-box; }

/* Available to assistive technology, absent from the visual layout. */
.visually-hidden {
  position: absolute !important; width: 1px; height: 1px;
  padding: 0; margin: -1px; overflow: hidden;
  clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; border: 0;
}
/* A heading that receives focus on a phase change must not draw a focus ring as though it were
   a control — but it must still be visible when reached by keyboard. */
[tabindex="-1"]:focus { outline: none; }
[tabindex="-1"]:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }

body {
  margin: 0;
  font-family: 'Segoe UI', system-ui, sans-serif;
  background: var(--bg);
  color: var(--text);
  min-height: 100vh;
}

/* ================= PERSISTENT SHELL (M1) ================= */

/* The brand bar and HUD travel together and stay pinned. Without this, scrolling a tall phase
   screen (Commit is taller than a viewport) hides Range, Stamina, mindset, and the exchange
   number simultaneously — the exact four things spec section 15 requires to be readable at every
   phase without scrolling. Needs its own opaque background so workspace content cannot show
   through, and must stay BELOW #pass-overlay (z-index 50) so the hotseat handoff blackout still
   covers it. */
#shell-top {
  position: sticky;
  top: 0;
  z-index: 30;   /* ladder: HUD */
  background: var(--shell-bg);
  border-bottom: 1px solid var(--hud-border);
}

/* No border here — #shell-top owns the outer edge, so a second one would double up whenever the
   HUD is hidden (the setup screen). */
#brand-bar {
  display: flex;
  justify-content: space-between;
  align-items: center;
  /* The sound toggle sits between the brand and the counter and pushes itself right with
     `margin-left: auto`; the gap keeps it off the counter once it has. */
  gap: 12px;
  padding: 10px 20px;
}
#brand-bar .brand { display: flex; align-items: center; gap: 10px; }
#brand-bar h1 {
  font-size: 1.05rem; margin: 0; letter-spacing: 0.14em;
  text-transform: uppercase; font-weight: 700;
}
.brand-mark { width: 26px; height: 26px; color: var(--accent); flex-shrink: 0; }
#exchange-counter { color: var(--text-dim); font-size: 0.8rem; letter-spacing: 0.08em; }
.xc-label { text-transform: uppercase; }
.xc-value { color: var(--accent); font-weight: 700; font-size: 1rem; }

/* --- Fight HUD --- */
#fight-hud {
  display: grid;
  grid-template-columns: 1fr minmax(240px, 0.85fr) 1fr;
  gap: 10px;
  padding: 10px 16px 14px;
  align-items: stretch;
}
/* `flex: 1` made the track grow to fill the stage, so `justify-content: flex-end` had no free
   space left to push with and the rail stayed at the top with the figures overlapping it. In the
   column the track takes its content height and the slack above it is the figures' band. */
.rp-stage > .rp-track { flex: 0 0 auto; }
#fight-hud.hidden { display: none; }

.fighter-panel {
  position: relative;
  display: flex;
  gap: 12px;
  padding: 12px 16px;
  background: var(--hud-panel);
  border: 1px solid var(--hud-border);
  border-radius: 4px;
  overflow: hidden;
}
/* Angled inner edge so both panels lean toward the centered range track. */
.fighter-panel.p1 { clip-path: polygon(0 0, 100% 0, calc(100% - var(--hud-cut)) 100%, 0 100%); }
/* Content must clear the slant. Without this the cut crops the panel's own text at the leaning
   edge — most visibly p2, whose name and stats start on the side that leans. */
.fighter-panel.p1 { padding-right: calc(16px + var(--hud-cut)); }
.fighter-panel.p2 { padding-left: calc(16px + var(--hud-cut)); }
.fighter-panel.p2 {
  /* MIRRORED, not parallel. This was `polygon(cut 0, 100% 0, 100% 100%, 0 100%)` — top-left inset,
     bottom-left flush — which leans the same way as p1 rather than opposite it. Both corners then
     slanted down-left, so the centre could not be a trapezoid: its right edge and p2's left edge
     ran parallel and left a wedge. p2 must narrow downward exactly as p1 does, so the space
     between them widens downward and IS the trapezoid. */
  clip-path: polygon(0 0, 100% 0, 100% 100%, var(--hud-cut) 100%);
  flex-direction: row-reverse;
  text-align: right;
}
.fighter-panel::before {
  content: ''; position: absolute; top: 0; bottom: 0; width: 3px;
}
.fighter-panel.p1::before { left: 0; background: var(--p1); box-shadow: 0 0 22px 5px var(--p1-glow); }
.fighter-panel.p2::before { right: 0; background: var(--p2); box-shadow: 0 0 22px 5px var(--p2-glow); }

/* VIS-302: the portrait is part of the corner, not a boxed thumbnail. It bleeds to the panel
   edge and dissolves into the surface, so a registered photo and the silhouette fallback both
   sit in the same frame. Negative margins pull it into the panel's own padding. */
.fp-portrait {
  position: relative; flex-shrink: 0;
  width: 84px; align-self: stretch; min-height: 96px;
  margin: -12px 0 -12px -16px;
  background: linear-gradient(180deg, var(--sc-surface-2), var(--sc-surface-0));
  display: flex; align-items: flex-end; justify-content: center; overflow: hidden;
  -webkit-mask-image: linear-gradient(90deg, #000 62%, transparent 100%);
          mask-image: linear-gradient(90deg, #000 62%, transparent 100%);
}
.p2 .fp-portrait {
  margin: -12px -16px -12px 0;
  -webkit-mask-image: linear-gradient(270deg, #000 62%, transparent 100%);
          mask-image: linear-gradient(270deg, #000 62%, transparent 100%);
}
.portrait-silhouette { width: 100%; height: 100%; opacity: 0.38; object-fit: cover; }
.p1 .portrait-silhouette { color: var(--p1); }
.p2 .portrait-silhouette { color: var(--p2); }
/* Initials sit over the silhouette; a real portrait image would simply cover both. */
.portrait-initials {
  position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
  font-size: 1.5rem; font-weight: 800; letter-spacing: 0.06em; color: var(--text);
  opacity: 0.9;
}
.fp-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 7px; }
.fp-name { font-size: 1.15rem; font-weight: 700; letter-spacing: 0.03em; line-height: 1.1; }
.p1 .fp-name { color: #dcebff; }
.p2 .fp-name { color: #ffe0e0; }
.fp-style {
  font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.12em; font-weight: 600;
}
.p1 .fp-style { color: var(--p1); }
.p2 .fp-style { color: var(--p2); }

.fp-metric-head { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
.p2 .fp-metric-head { flex-direction: row-reverse; }
.fp-label {
  font-size: 0.6rem; text-transform: uppercase; letter-spacing: 0.1em; color: var(--text-dim);
}
.fp-arrow { opacity: 0.6; }
.fp-readout { font-size: 0.8rem; font-weight: 700; font-variant-numeric: tabular-nums; }

/* Segmented Stamina: exact value AND urgency, per spec section 03 note 2. Damage animates from
   this same bar in M3. */
.stam-track { display: flex; gap: 2px; margin-top: 3px; }
.p2 .stam-track { flex-direction: row-reverse; }
.stam-seg {
  flex: 1; height: 9px; border-radius: 1px; background: var(--p1);
  transition: background-color var(--sc-motion-normal) var(--sc-ease-out),
              opacity var(--sc-motion-normal) var(--sc-ease-out);
}
.p2 .stam-seg { background: var(--p2); }
/* `:not(.spent)` is load-bearing, not decoration. `.stam-track.low .stam-seg` is specificity
   0-3-0 and `.stam-seg.spent` is 0-2-0, so the urgency rules USED TO OUTRANK the spent rule and
   repaint every segment — the bar rendered completely full at 1/15, and only ever misrendered when
   Stamina was low, which is precisely when the reading matters. Excluding spent segments from the
   urgency colour fixes it by matching, not by tie-breaking on source order. */
.stam-track.low .stam-seg:not(.spent) { background: var(--accent); }
.stam-track.critical .stam-seg:not(.spent) { background: var(--bad); }
.stam-seg.spent { background: var(--stam-spent); }

/* Flow-to-Fury: ONE axis, ONE marker (spec section 03 note 3). */
.ff-axis { display: flex; align-items: center; gap: 7px; margin-top: 3px; }
/* FE-211: the panel mirrors, but the mindset axis must NOT — both fighters read Calm on the
   left and Fury on the right, or the two meters mean opposite things at a glance. */
.p2 .ff-axis { flex-direction: row; }
.ff-end {
  font-size: 0.55rem; text-transform: uppercase; letter-spacing: 0.08em;
  color: var(--text-dim); flex-shrink: 0;
}
.ff-axis.is-flow .ff-end.calm { color: var(--flow-cool); }
.ff-axis.is-fury .ff-end.fury { color: var(--fury-hot); }
.ff-rail {
  position: relative; flex: 1; height: 4px; border-radius: 2px;
  background: linear-gradient(90deg, var(--flow-cool) 0%, #6b7280 50%, var(--fury-hot) 100%);
  opacity: 0.75;
}

.ff-marker {
  position: absolute; top: 50%; width: 11px; height: 11px; border-radius: 50%;
  background: var(--text); border: 2px solid var(--bg);
  transform: translate(-50%, -50%);
  transition: left var(--sc-motion-normal) var(--sc-ease-out);
}
.fp-track-label {
  font-size: 0.62rem; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.07em;
}

.portrait-img { width: 100%; height: 100%; object-fit: cover; display: block; }

/* Expandable detail (M4). Above 900px the summary is hidden and the panel renders open, so this
   reads as a plain row; below it, the summary becomes the control that reclaims the space. */
.fp-detail > summary {
  display: none;
  font-size: 0.58rem; text-transform: uppercase; letter-spacing: 0.09em;
  color: var(--text-dim); cursor: pointer; padding: 2px 0;
}
.fp-detail > summary::-webkit-details-marker { display: none; }
.fp-detail > summary::before { content: '▸ '; display: inline-block; transition: transform 180ms ease; }
.fp-detail[open] > summary::before { content: '▾ '; }

.fp-counts {
  display: flex; gap: 12px; font-size: 0.62rem; color: var(--text-dim);
  text-transform: uppercase; letter-spacing: 0.07em;
}
.p2 .fp-counts { justify-content: flex-end; }
.fp-counts b { color: var(--text); font-size: 0.78rem; font-weight: 700; }

.fp-chips { display: flex; gap: 5px; flex-wrap: wrap; min-height: 0; }
.p2 .fp-chips { justify-content: flex-end; }
.chip {
  font-size: 0.6rem; padding: 2px 7px; border-radius: 9px;
  border: 1px solid var(--border); color: var(--text-dim); white-space: nowrap;
}
.chip.off-balance { border-color: var(--bad); color: #ff9d9d; }
.chip.overcommitted { border-color: var(--accent); color: var(--accent); }
.chip.opening { border-color: var(--good); color: #86e0b6; }

/* --- Centered range track: the dominant shared tactical state --- */
/* The centre of Zone 1 is a TRAPEZOID, not a rectangle sitting in a gap. The two fighter corners
   narrow downward, so the space between them widens downward — and in the approved Zone 1 that
   space is the shape, cut to interlock with both. Rendering it as a bordered rectangle was what
   made the three panels read as three boxes rather than one leaning band.
   No border: a clip-path cuts through one, leaving hairlines on the slants. The fill and the shape
   carry it instead. */
.range-panel {
  position: relative;
  display: flex; flex-direction: column;
  padding: 10px 18px 12px;
  background: var(--hud-panel);
  border: none;
  border-radius: 0;
  clip-path: polygon(var(--hud-cut) 0, calc(100% - var(--hud-cut)) 0, 100% 100%, 0 100%);
  /* The clip insets this panel by --hud-cut on each side, and the fighter corners are cut by the
     same amount in the opposite direction — so the VISIBLE gap between them was the grid gap PLUS
     the cut on both edges (10 + 50 = 60px at 1920), a wide diagonal moat rather than an interlock.
     Growing the box outward by exactly the cut makes the visible gap the grid gap again, at both
     the top and the bottom, because the two slants stay parallel. */
  margin-left: calc(-1 * var(--hud-cut));
  margin-right: calc(-1 * var(--hud-cut));
}
.rp-head {
  text-align: center; font-size: 0.62rem; text-transform: uppercase;
  letter-spacing: 0.18em; color: var(--text-dim); margin-bottom: 6px;
}
.rp-stage { position: relative; flex: 1; display: flex; align-items: center; }
.rp-figure { position: absolute; bottom: 16px; width: 30px; opacity: 0.13; pointer-events: none; }
.rp-figure svg { width: 100%; display: block; }
.rp-figure.p1 { left: 6%; color: var(--p1); }
.rp-figure.p2 { right: 6%; color: var(--p2); transform: scaleX(-1); }

.rp-track { position: relative; flex: 1; display: flex; justify-content: space-between; }
/* A platform, not a hairline -- and a HOLLOW one. Read the pixels of goldrange.png across the
   track: a bright edge line, then a black interior, then a second dimmer edge line. It is drawn
   the same way as the node dots beside it, which have always been rings with a black fill, and the
   same way as every other window on this board: outline lit, core cut through to the ground.

   Filling it with grey made it the brightest solid mass in Zone 1 -- brighter than the fighters
   standing on it, which is backwards for a panel whose subject is the fighters.

   The top edge is the heavier of the two, because the light in this panel comes from above and
   from the sides; the bottom edge is dimmer and there is a contact shadow under it so the bar
   reads as sitting above the ground rather than cut into it.
   `top` is 4 so the bar's centre stays on the 16px dot's centre at 8px. */
.rp-rail {
  position: absolute; left: 8%; right: 8%; top: 4px; height: 8px;
  border-radius: 4px;
  background: #05070a;
  border: 1px solid rgba(150, 162, 178, 0.55);
  border-top: 2px solid rgba(214, 224, 238, 0.9);
  box-shadow: 0 2px 5px rgba(0, 0, 0, 0.75);
}
.rp-node {
  position: relative; z-index: 1;
  display: flex; flex-direction: column; align-items: center; gap: 11px;
  flex: 1;
}
/* `position: relative` so the marker rings can hang off it, and the gap above is 11px rather than
   5px because the outer ring extends 8px past the dot and would otherwise sit on the label. Six
   pixels, against the 44-54px the reserved marker column used to cost. */
.rp-dot {
  position: relative;
  width: 16px; height: 16px; border-radius: 50%;
  background: var(--bg); border: 2px solid var(--border);
  transition: border-color var(--sc-motion-normal) var(--sc-ease-out),
              box-shadow var(--sc-motion-normal) var(--sc-ease-out),
              background-color var(--sc-motion-normal) var(--sc-ease-out);
}
.rp-node.active .rp-dot {
  border-color: var(--accent); background: var(--accent);
  box-shadow: 0 0 12px 3px rgba(240, 180, 41, 0.45);
}
.rp-name {
  font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.09em; color: var(--text-dim);
}
.rp-node.active .rp-name { color: var(--text); font-weight: 700; }
/* FE-210 / AC-009: all three markers can land on one node (Start=Clash=End). Concentric rings at
   fixed radii, so every combination reads at a glance and none of them can collide -- which is what
   the old fixed-height stacked column was solving, at 54px of panel height.

   Radius encodes WHICH marker, not any ordering: Start is always the inner ring and End always the
   outer, whether or not the other is present. Clash is the dot's own amber fill (see the markup).
   Each ring carries a matching bloom so it reads as lit rather than drawn, and so it survives on
   top of a figure's glow. */
.rp-mark {
  position: absolute; border-radius: 50%; pointer-events: none;
  border: 2px solid transparent;
  transition: opacity var(--sc-motion-normal) var(--sc-ease-out);
}
.rp-mark.start {
  inset: -4px;
  border-color: var(--good);
  box-shadow: 0 0 5px rgba(74, 200, 122, 0.55);
}
/* The End marker and the before-confirm badge borrowed the player-blue token as a generic
   accent. Now that --p1 follows p1's DECK STYLE, they would have drifted with it -- an End ring
   turning green because someone picked Pencak Silat. Pinned to the palette token instead. */
.rp-mark.end {
  inset: -8px;
  border-color: var(--sc-player);
  box-shadow: 0 0 6px rgba(90, 150, 230, 0.45);
}
/* Hidden by default and revealed by the node's marker list, so the eight combinations need no
   per-combination rules and nothing has to be cleared between exchanges. */
.rp-mark { opacity: 0; }
.rp-node[data-marks~="start"] .rp-mark.start { opacity: 1; }
.rp-node[data-marks~="end"] .rp-mark.end { opacity: 1; }

/* --- Body: workspace is the only region a phase render may replace --- */
#shell-body { display: flex; gap: 16px; align-items: flex-start; padding: 0 16px; }
/* Safe-area rule: the sticky tactical bar overlays the end of the scroll region, so the
   workspace reserves its height rather than trusting z-index (09_Responsive spec). */
#workspace {
  flex: 1; min-width: 0; max-width: 1100px; margin: 0 auto;
  padding: 14px 4px 24px;
}
#rail {
  width: 320px; flex-shrink: 0; margin-top: 14px; padding: 12px;
  background: var(--rail-bg); border: 1px solid var(--hud-border); border-radius: 4px;
}
#rail.hidden { display: none; }
/* Drawer handle: hidden on a roomy layout where the rail is simply always open. */
#rail-toggle { display: none; }
#rail.collapsed #rail-body { display: none; }

/* (.opening-badge / .overcommitted-badge removed in M1 — Openings and Overcommitted are fighter
   state and now render as .chip on the persistent HUD panel instead of inside a phase screen.) */

.screen h2 { margin-top: 0; }

/* VIS-301: the workspace announces itself once, quietly, instead of a heading plus a paragraph
   of instructions competing with the cards for weight. */
.workspace-head { margin-bottom: 14px; }
.workspace-head h2 {
  font-size: 0.86rem; text-transform: uppercase; letter-spacing: 0.14em;
  color: var(--text); margin: 0 0 3px;
}
.workspace-sub { color: var(--text-dim); font-size: 0.82rem; margin: 0; }
.hint { color: var(--text-dim); font-size: 0.9rem; margin-bottom: 16px; }

button {
  background: var(--panel-2);
  color: var(--text);
  border: 1px solid var(--border);
  border-radius: 6px;
  padding: 8px 16px;
  cursor: pointer;
  font-size: 0.95rem;
  font-family: inherit;
}
button:hover:not(:disabled) { border-color: var(--accent); }
button:disabled { opacity: 0.4; cursor: not-allowed; }
button.primary { background: var(--accent); color: #241d05; border-color: var(--accent); font-weight: 700; }
button.primary:hover:not(:disabled) { filter: brightness(1.1); }
button:focus-visible, .ribbon:focus-visible, input:focus-visible, [tabindex]:focus-visible,
.intent-card:focus-visible, .tempo-choice:focus-within, .fp-detail > summary:focus-visible,
#rail-toggle:focus-visible, .unresolved-list button:focus-visible, .link-btn:focus-visible {
  outline: 2px solid var(--accent); outline-offset: 2px;
}

.option-row { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 6px; }
label.inline { display: inline-flex; align-items: center; gap: 6px; color: var(--text); font-size: 0.82rem; margin-bottom: 4px; }

.role-toggle { margin-top: 8px; display: flex; gap: 6px; flex-wrap: wrap; }
.role-toggle label.inline { flex: 1; justify-content: center; border: 1px solid var(--border); border-radius: 6px; padding: 4px 6px; }

/* Privacy handoff shield — top of the ladder so nothing can sit above it (FE-206). */
#pass-overlay {
  position: fixed; inset: 0;
  background: #06070a;
  display: flex; align-items: center; justify-content: center;
  z-index: 60;
}
#pass-overlay.hidden { display: none; }
.pass-box { text-align: center; }
.pass-box p { font-size: 1.3rem; margin-bottom: 20px; }

.log-lines {
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 14px 16px;
  font-size: 0.9rem;
  line-height: 1.6;
  /* Already bounded before D4 -- the pre-existing 420px cap was doing the job, and a duplicate
     declaration added above it was simply dead. Consolidated, and made viewport-relative as well:
     inside a 439px stage at 768px tall, a flat 420px cap is the whole stage. */
  margin-bottom: 20px;
  max-height: min(420px, 42vh);
  overflow-y: auto;
  overscroll-behavior: contain;
}

.narrative-block {
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 16px 18px;
  font-size: 1.05rem;
  line-height: 1.5;
  margin-bottom: 12px;
  cursor: pointer;
}
.narrative-block p { margin: 0 0 10px; }
.narrative-block p:last-of-type { margin-bottom: 0; }
.narrative-block .reveal-hint {
  font-size: 0.8rem;
  color: var(--muted, #888);
  margin-top: 10px;
  font-style: italic;
}
#mechanical-log.hidden { display: none; }

.narrative-block.final-scene {
  cursor: default;
  border-color: var(--accent, #b8860b);
  font-style: italic;
}

.result-banner {
  text-align: center;
  padding: 30px;
  border-radius: 10px;
  background: var(--panel);
  border: 1px solid var(--border);
}
.result-banner h2 { font-size: 1.8rem; margin-bottom: 8px; }

.setup-cols { display: flex; gap: 24px; flex-wrap: wrap; }
.setup-col { flex: 1; min-width: 260px; }
.setup-col input, .setup-col select {
  width: 100%; padding: 8px; margin-bottom: 12px;
  background: var(--panel-2); color: var(--text); border: 1px solid var(--border); border-radius: 6px;
}

/* ================= INTENT STRIP =================
   M1: the range track moved to the persistent HUD (.range-panel above). What stays in the
   workspace is only the Commit-phase Intent control, which is a per-player secret choice.
   M2: Intent becomes an explicit choice with the relationship cycle shown in place. */
.range-strip {
  background: var(--surface-1);
  border: 1px solid var(--sc-border-subtle);
  border-radius: var(--sc-radius-md);
  padding: 10px 16px;
  margin-bottom: 20px;
}
.intent-group { display: flex; flex-direction: column; }
.intent-group-label { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-dim); }
/* One row: heading+lede, the relationship triangle, then the three choices — the arrangement the
   approved mockup draws. The heading used to sit on its own full-width line above the row, which
   cost a text line of band height AND left the space beside the triangle empty. Putting it in the
   row spends that space on the copy it was already carrying. */
.intent-layout { display: flex; gap: 16px; align-items: center; flex-wrap: nowrap; }
.intent-lede { flex: 0 1 clamp(132px, 12vw, 208px); min-width: 0; }
.intent-relationship { display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
/* Scales with the board instead of staying a thumbnail on a wide screen — but the vw term is
   chosen so it only exceeds the old 104px above ~1490px viewport width. The triangle column drives
   this band's height, and below ~1500px Commit has no height to spare (see
   reference/d4-measurements.md); growth is spent only where there is slack. */
.intent-triangle { width: clamp(104px, 8.5vw, 168px); height: auto; color: var(--text-dim); }
.intent-triangle .itri-edges path {
  opacity: 0.4;
  transition: opacity var(--sc-motion-normal) var(--sc-ease-out),
              stroke var(--sc-motion-normal) var(--sc-ease-out);
}
.intent-triangle .itri-edges path.on { opacity: 1; stroke: var(--accent); color: var(--accent); }
.intent-triangle .itri-nodes circle {
  fill: var(--surface-3); stroke: var(--border); stroke-width: 1.6;
  transition: fill var(--sc-motion-normal) var(--sc-ease-out),
              stroke var(--sc-motion-normal) var(--sc-ease-out);
}
.intent-triangle .itri-nodes circle.on { fill: var(--accent); stroke: var(--accent); }
.intent-triangle .itri-nodes text {
  fill: var(--text); font-size: 11px; font-weight: 700; font-family: inherit;
}

/* The three choices used to be `flex: 1` inside a `flex: 1` row, so they stretched to whatever
   width was going — at 1920 each card was ~340px wide around ~200px of content, which reads as
   three mostly-empty panels rather than three buttons. Capped and centred instead: the slack
   becomes margin at the edges of the band, where it looks deliberate. */
.intent-options {
  display: flex; gap: 10px; flex: 1; justify-content: center;
  /* NO WRAP. Three choices are one row; a wrapped third card nearly doubles the band's height for
     no extra content, which is the "lots of empty space" this band kept showing. They shrink
     together instead, down to the deliberate stack below 900px. */
  flex-wrap: nowrap;
}
.intent-card {
  flex: 1 1 0; min-width: 0; max-width: 290px;
  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px;
  border: 2px solid var(--border); border-radius: 8px; padding: 7px 8px;
  background: var(--panel-2); color: var(--text); cursor: pointer; text-align: center;
}
.intent-card:hover:not(:disabled) { border-color: var(--accent); }
.intent-card[aria-pressed="true"] {
  border-color: var(--accent); background: rgba(240, 180, 41, 0.10);
}
.intent-card:disabled { opacity: 0.3; cursor: not-allowed; }
.intent-name {
  font-size: 0.8rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em;
}
.intent-card[aria-pressed="true"] .intent-name { color: var(--accent); }
.intent-icon { width: 20px; height: 20px; color: var(--text-dim); }
.intent-card[aria-pressed="true"] .intent-icon { color: var(--accent); }
.intent-icon svg { width: 100%; height: 100%; display: block; }
.intent-blurb { font-size: 0.68rem; color: var(--text-dim); line-height: 1.25; }
.intent-beats {
  font-size: 0.6rem; text-transform: uppercase; letter-spacing: 0.07em;
  color: var(--good); font-weight: 700;
}

/* ================= TEMPO CHOICE (exclusive across the pair) ================= */
.tempo-choice {
  display: inline-flex; align-items: center; gap: 6px; cursor: pointer;
  border: 1px solid var(--border); border-radius: 20px; padding: 4px 10px;
  background: var(--panel-2); font-size: 0.75rem;
}
.tempo-choice.active { border-color: var(--accent); background: rgba(240, 180, 41, 0.12); }
.tempo-choice input { margin: 0; }
.tempo-label { text-transform: uppercase; letter-spacing: 0.07em; color: var(--text-dim); font-size: 0.62rem; }
.tempo-choice.active .tempo-label { color: var(--accent); }
/* Printed value shown struck through beside the effective one, so an adjustment is visible
   rather than silently replacing the number printed on the card. */
.tempo-printed { text-decoration: line-through; color: var(--text-dim); font-size: 0.68rem; }
.tempo-eff { font-weight: 700; font-variant-numeric: tabular-nums; }
.tempo-eff:not(.plain) { color: var(--accent); }

/* The sticky .tactical-bar is gone (D1). It was `position: sticky; bottom: 0` over the stage with
   #workspace reserving --sc-summary-height of padding underneath it -- the construction section 18
   lists under "Do not". Its two jobs, the preflight sentence and Confirm, are Zone 4's now, in a
   row of its own that cannot cover anything. */

.tactical-sentence { display: flex; flex-wrap: wrap; gap: 8px 20px; align-items: center; }
.ts-bit { display: inline-flex; align-items: center; gap: 7px; }
.ts-icon { width: 15px; height: 15px; flex-shrink: 0; opacity: 0.9; }
.ts-icon svg { width: 100%; height: 100%; display: block; }
.ts-bit.intent .ts-icon { color: var(--accent); }
.ts-bit.attack .ts-icon { color: var(--role-attack); }
.ts-bit.defense .ts-icon { color: var(--role-defense); }
.ts-bit.tempo .ts-icon { color: var(--text-dim); }
.ts-label {
  font-size: 0.6rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-dim);
}
.ts-value { font-size: 0.88rem; font-weight: 700; }
.ts-bit.intent .ts-value { color: var(--accent); }
.ts-bit.attack .ts-value { color: var(--role-attack); }
.ts-bit.defense .ts-value { color: var(--role-defense); }

/* Every outstanding requirement is listed, not just the first — see renderReadyStrip(). */
.unresolved-head {
  font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.08em;
  color: var(--bad); font-weight: 700; margin-bottom: 5px;
}
.unresolved-list { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px; }
.unresolved-list button {
  font-size: 0.74rem; padding: 3px 10px; border-radius: 12px;
  background: rgba(224, 86, 86, 0.12); border: 1px solid var(--bad); color: #ffb3b3;
}
.unresolved-list button:hover { background: rgba(224, 86, 86, 0.24); border-color: var(--bad); }

/* ================= NETWORK STATUS STRIP (Stage 4) ================= */
/* Chrome, not a screen: it reports connection state without displacing the dashboard. */
.ns-dot {
  width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
  background: var(--text-dim);
}
.ns-dot.live {
  background: var(--sc-success);
  animation: ns-pulse 1.8s var(--sc-ease-out) infinite;
}
.ns-dot.error { background: var(--bad); }
@keyframes ns-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
.ns-text { min-width: 0; }

/* ================= ONLINE ENTRY (Stage 1) ================= */
.setup-online-cta {
  display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
  margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--sc-border-subtle);
  font-size: 0.85rem; color: var(--text-dim);
}
.setup-online-cta .sep { opacity: 0.5; }

.online-entry { display: flex; justify-content: center; padding-top: 20px; }
.entry-card {
  width: 100%; max-width: 440px;
  background: var(--surface-1); border: 1px solid var(--sc-border-subtle);
  border-top: 2px solid var(--accent); border-radius: var(--sc-radius-md);
  padding: 26px 28px; box-shadow: var(--sc-shadow-panel);
}
.entry-card.wide { max-width: 720px; }
.entry-card h2 { margin: 0 0 4px; font-size: 1.15rem; }
.entry-sub { color: var(--text-dim); font-size: 0.86rem; margin: 0 0 18px; }
.entry-error {
  background: rgba(255, 91, 98, 0.12); border: 1px solid var(--bad);
  border-radius: var(--sc-radius-sm); padding: 9px 12px;
  color: #ffb3b3; font-size: 0.84rem; margin: 0 0 16px;
}
.entry-card label {
  display: block; font-size: 0.62rem; text-transform: uppercase;
  letter-spacing: 0.1em; color: var(--text-dim); margin-bottom: 5px;
}
.entry-card input, .entry-card select {
  width: 100%; padding: 10px 12px; margin-bottom: 14px;
  background: var(--surface-3); color: var(--text);
  border: 1px solid var(--sc-border-subtle); border-radius: var(--sc-radius-sm);
  font-family: inherit; font-size: 0.92rem;
}
/* A room code is read aloud and retyped: give it room to breathe. */
.room-input {
  font-size: 1.15rem !important; letter-spacing: 0.18em; text-transform: uppercase;
  font-weight: 700; text-align: center;
}
#invite-link { font-size: 0.78rem !important; letter-spacing: 0; text-transform: none; font-weight: 400; }
.entry-card button.primary { width: 100%; padding: 11px; margin-bottom: 10px; }
.entry-card .link-btn { display: inline-block; margin-top: 6px; }

.room-code-display {
  display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
  background: var(--surface-3); border: 1px solid var(--sc-border-subtle);
  border-radius: var(--sc-radius-sm); padding: 12px 16px; margin-bottom: 18px;
}
.rcd-label {
  font-size: 0.6rem; text-transform: uppercase; letter-spacing: 0.1em; color: var(--text-dim);
}
.rcd-code {
  font-size: 1.45rem; font-weight: 800; letter-spacing: 0.22em; color: var(--accent);
  flex: 1; min-width: 0;
}
.entry-advanced { margin-bottom: 12px; }
.entry-advanced > summary {
  font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.09em;
  color: var(--text-dim); cursor: pointer; padding: 6px 0;
}
.entry-note { font-size: 0.74rem; color: var(--text-dim); margin: 0 0 8px; line-height: 1.45; }

/* ---- Stage 5 lobby ---- */
/* The opponent column is a read-only mirror of somebody else's choice, so it is deliberately not
   styled like the editable column beside it -- a field you cannot type in should not look like one. */
.lobby .setup-col > label { display: block; }
.lobby-them-name { font-weight: 700; font-size: 1rem; margin: 0 0 2px; color: var(--text); }
.lobby-ready, .lobby-waiting {
  display: inline-block; font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.1em;
  padding: 3px 9px; border-radius: 999px; border: 1px solid currentColor;
}
.lobby-ready { color: var(--good, #57d98a); }
.lobby-waiting { color: var(--text-dim); }
/* A disabled Start is the whole gate ("block until the guest is ready"), so it has to read as
   deliberately unavailable rather than as a button that failed to draw. */
.lobby button.primary[disabled] { opacity: 0.4; cursor: not-allowed; }
/* Being ready is a settled state, so it stops competing with the host's Start for attention. The
   gold primary is reserved for the thing still waiting to be done. */
.lobby button.lobby-unready { width: 100%; padding: 11px; margin-bottom: 10px; }
#lobby-status:empty { display: none; }

/* ================= PHASE 2: SELECT PAIR TRAY (FE-203) ================= */
/* Compact ribbons, not full-height duplicates of cards already visible in the hand above. */
.pair-tray {
  background: var(--surface-1); border: 1px solid var(--sc-border-subtle);
  border-radius: var(--sc-radius-md); padding: 12px 14px; margin-bottom: 14px;
}
.tray-label {
  font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.1em;
  color: var(--text-dim); margin-bottom: 8px;
}
.tray-slots { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
.tray-slot {
  --frame: var(--border);
  display: flex; align-items: center; gap: 10px;
  padding: 10px 12px; border-radius: 8px; min-height: 46px;
  font-family: inherit; text-align: left; width: 100%;
}
.tray-slot.empty {
  border: 1px dashed var(--border); background: transparent;
  color: var(--text-dim); font-size: 0.75rem; justify-content: center;
}
.tray-slot.filled {
  border: 1px solid var(--frame); border-left-width: 3px;
  background: var(--panel-2); color: var(--text); cursor: pointer;
}
.tray-slot.filled:hover { border-color: var(--accent); }
.tray-slot.inspecting { background: var(--panel-3); border-color: var(--accent); }
.tray-tempo {
  font-size: 0.68rem; font-weight: 700; color: var(--text-dim);
  font-variant-numeric: tabular-nums; flex-shrink: 0;
}
.tray-name { font-weight: 700; font-size: 0.9rem; flex: 1; min-width: 0; }
.tray-hint { font-size: 0.62rem; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.07em; }
.tray-detail { margin-bottom: 14px; }

/* ================= PHASE 2: LOCK / WAIT (FE-205) ================= */
.locked-wait { display: flex; justify-content: center; padding-top: 24px; }
.lock-card {
  max-width: 640px; width: 100%;
  background: var(--panel); border: 1px solid var(--accent); border-radius: 12px;
  padding: 22px 24px; text-align: center;
  box-shadow: 0 0 26px rgba(240, 180, 41, 0.10);
}
.lock-head { display: flex; flex-direction: column; gap: 6px; margin-bottom: 16px; }
.lock-badge {
  font-size: 0.72rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.16em;
  color: var(--accent);
}
.lock-sub { font-size: 0.8rem; color: var(--text-dim); }
.tactical-sentence.locked { justify-content: center; gap: 10px 22px; }
.lock-status {
  margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--border);
  font-size: 0.85rem; color: var(--text-dim);
}

/* Neutral placeholder left behind when the handoff shield clears the outgoing player's plan. */
.handoff-blank { min-height: 40vh; }

.log-gate-note {
  margin-top: 8px; padding-top: 6px; border-top: 1px dashed var(--border);
  font-size: 0.68rem; color: var(--text-dim); font-style: italic;
}

/* ================= RESOLUTION BEATS (M3) ================= */
.beat-stepper {
  display: flex; gap: 6px; margin: 0 0 14px; padding: 0;
  flex-wrap: wrap; list-style: none;
}
.step {
  font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.09em;
  padding: 4px 12px; border-radius: 12px;
  border: 1px solid var(--border); color: var(--text-dim); background: var(--panel);
}
.step.done { border-color: var(--good); color: var(--good); }
.step.on { border-color: var(--accent); color: var(--accent); background: rgba(240, 180, 41, 0.10); font-weight: 700; }

/* VIS-305: the beat is the subject of the screen during resolution, not a small card in a
   large empty canvas. It gets real presence and a floor so short beats do not leave a void. */
.beat-panel {
  background:
    linear-gradient(180deg, rgb(255 255 255 / 0.03) 0%, transparent 38%),
    var(--surface-1);
  border: 1px solid var(--sc-border-subtle);
  border-top: 2px solid var(--accent);
  border-radius: var(--sc-radius-md); padding: 24px 26px;
  min-height: 340px; display: flex; flex-direction: column;
  box-shadow: var(--sc-shadow-panel);
}
.beat-panel .beat-body { flex: 1; }
.beat-head { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
.beat-head h2 { margin: 0; font-size: 1.05rem; letter-spacing: 0.03em; }
.fast-resolve {
  display: inline-flex; align-items: center; gap: 6px;
  font-size: 0.68rem; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.07em;
  cursor: pointer;
}
.fr-hint { font-size: 0.58rem; opacity: 0.7; text-transform: none; letter-spacing: 0; }
.beat-body { margin-top: 14px; display: flex; flex-direction: column; gap: 12px; }

.beat-stats { display: flex; gap: 18px; flex-wrap: wrap; }
.beat-stat { display: inline-flex; align-items: baseline; gap: 6px; }
.bs-label { font-size: 0.58rem; text-transform: uppercase; letter-spacing: 0.09em; color: var(--text-dim); }
.bs-value { font-size: 0.92rem; font-weight: 700; font-variant-numeric: tabular-nums; }
.beat-stat.changed .bs-value { color: var(--accent); }

.beat-pill {
  font-size: 0.66rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em;
  padding: 3px 10px; border-radius: 12px; border: 1px solid var(--border); color: var(--text-dim);
}
.beat-pill.optimal { border-color: var(--good); color: var(--good); }
.beat-pill.compromised { border-color: var(--accent); color: var(--accent); }
.beat-pill.whiff { border-color: var(--text-dim); }
.beat-pill.clean { background: var(--bad); border-color: var(--bad); color: #fff; }
.beat-pill.partial { border-color: var(--bad); color: #ff9d9d; }
.beat-pill.block { border-color: var(--role-defense); color: var(--role-defense); }
.beat-pill.guarded { border-color: var(--role-defense); color: var(--role-defense); }
.beat-pill.damage { background: rgba(224, 86, 86, 0.18); border-color: var(--bad); color: #ffb3b3; }
.beat-pill.nodamage { color: var(--text-dim); }

.beat-row {
  display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
  padding: 10px 12px; border-radius: 6px; background: var(--panel-2);
  border-left: 3px solid var(--border);
}
.beat-row.attacker { border-left-color: var(--role-attack); }
.beat-row.defender { border-left-color: var(--role-defense); }
.br-card { font-weight: 700; font-size: 0.95rem; }
.br-meta { color: var(--text-dim); font-size: 0.78rem; flex: 1; }
.br-pills { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }

.beat-verdict { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.beat-detail { margin: 0; padding-left: 18px; color: var(--text-dim); font-size: 0.82rem; }
.beat-detail li { margin: 2px 0; }
.beat-note { font-size: 0.85rem; color: var(--text-dim); line-height: 1.45; }
.beat-note.stoppage { color: var(--accent); }

/* Footwork contest */
.fw-contest { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
.fw-side {
  flex: 1; min-width: 140px; padding: 10px 12px; border-radius: 6px;
  background: var(--panel-2); border: 1px solid var(--border);
  display: flex; flex-direction: column; gap: 2px;
}
.fw-side.p1 { border-left: 3px solid var(--p1); }
.fw-side.p2 { border-left: 3px solid var(--p2); }
.fw-side.won { border-color: var(--accent); background: rgba(240, 180, 41, 0.08); }
.fw-name { font-size: 0.7rem; color: var(--text-dim); }
.fw-intent { font-size: 1rem; font-weight: 700; }
.fw-side.won .fw-intent { color: var(--accent); }
.fw-recast { font-size: 0.64rem; color: var(--accent); font-style: italic; }
.fw-verdict {
  font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.09em;
  font-weight: 700; color: var(--accent); text-align: center; flex-shrink: 0;
}

/* Cleanup recap */
.recap { display: flex; flex-direction: column; gap: 6px; }
.recap-row {
  display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
  padding: 8px 12px; border-radius: 6px; background: var(--panel-2);
}
.recap-row.p1 { border-left: 3px solid var(--p1); }
.recap-row.p2 { border-left: 3px solid var(--p2); }
.rr-name { font-weight: 700; min-width: 88px; }
.rr-card { color: var(--text-dim); flex: 1; font-size: 0.85rem; }
.rr-damage { font-size: 0.8rem; font-variant-numeric: tabular-nums; }

/* Prose arrives after the mechanical result, never before it. */
.beat-prose-slot { min-height: 1.4em; }
.beat-prose {
  font-size: 0.98rem; line-height: 1.55; font-style: italic; color: var(--text);
  padding-top: 12px; margin-top: 4px; border-top: 1px solid var(--sc-border-subtle);
  animation: beat-prose-in var(--sc-motion-normal) var(--sc-ease-out) both;
}
@keyframes beat-prose-in { from { opacity: 0; transform: translateY(3px); } to { opacity: 1; transform: none; } }

.beat-controls { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-top: 16px; }
.link-btn {
  background: none; border: none; color: var(--text-dim); text-decoration: underline;
  cursor: pointer; font-size: 0.8rem; padding: 4px 0;
}
.link-btn:hover { color: var(--text); }

/* Rail: beats already read this exchange */
.last-exchange { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.last-exchange li {
  display: flex; justify-content: space-between; align-items: center; gap: 8px;
  padding: 7px 10px; border-radius: 5px; background: var(--panel);
  border-left: 3px solid var(--border);
}
.last-exchange li.on { border-left-color: var(--accent); background: var(--panel-2); }
.le-label { font-size: 0.76rem; }
.le-pills { display: flex; gap: 4px; flex-wrap: wrap; }

/* ================= PHASE 2: INLINE TELEGRAPH (FE-207) ================= */
/* The .telegraph-inline / .telegraph-note rules went with the screen they styled (D3). The reveal
   itself did not: the opponent's pair arriving in the context panel IS the telegraph now, and it
   fires once per exchange from renderRailContext rather than from a stage that owns the board. */
.rail-panel.entering { animation: rail-enter 380ms var(--ease-out, cubic-bezier(.2,.8,.2,1)) both; }
@keyframes rail-enter {
  from { opacity: 0; transform: translateX(14px); }
  to   { opacity: 1; transform: none; }
}

/* ================= RAIL: pinned opponent telegraph ================= */
.rail-panel { display: flex; flex-direction: column; gap: 10px; }
.rail-head { display: flex; flex-direction: column; gap: 2px; }
.rail-title {
  font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.11em; font-weight: 700;
  color: var(--text);
}
.rail-sub { font-size: 0.68rem; color: var(--text-dim); }
.rail-ribbons { display: flex; flex-direction: column; gap: 8px; }
.rail-note {
  font-size: 0.64rem; color: var(--text-dim); font-style: italic;
  border-top: 1px solid var(--hud-border); padding-top: 8px;
}
.ribbon.readonly { cursor: default; }
.ribbon.readonly:hover { border-color: var(--border); }

/* A placeholder is not an empty box: Zone 3 keeps its shape before the pair arrives so nothing
   reflows when it does, and the player is told when to expect it. */
.rail-panel.is-placeholder .rail-title { color: var(--text-dim); }
.rail-panel.is-placeholder .rail-note { border-top: none; padding-top: 0; }

/* ================= RAIL, lower row: cumulative history (D2) ================= */
/* Prose with outcome pills, as in the approved mockup, not the terse label rows the per-exchange
   timeline used to emit. Grouped by exchange because this now spans the whole bout. */
.rail-panel.history { gap: 0; }
.rh-group {
  position: sticky; top: 0; z-index: 1;
  background: var(--rail-bg);
  font-size: 0.6rem; text-transform: uppercase; letter-spacing: 0.11em; font-weight: 700;
  color: var(--text-dim);
  padding: 8px 0 5px;
  border-bottom: 1px solid var(--hud-border);
}
.rh-row {
  display: grid;
  grid-template-columns: 12px minmax(0, 1fr) auto;
  align-items: start;
  gap: 8px;
  padding: 7px 0;
  border-bottom: 1px solid rgba(255, 255, 255, 0.04);
}
/* A chevron in the acting fighter's colour, so who did what is readable without reading. */
.rh-mark {
  width: 8px; height: 8px; margin-top: 5px;
  border-right: 2px solid var(--text-dim); border-bottom: 2px solid var(--text-dim);
  transform: rotate(45deg);
  opacity: 0.75;
}
.rh-row.rh-p1 .rh-mark { border-color: var(--p1); }
.rh-row.rh-p2 .rh-mark { border-color: var(--p2); }
.rh-row.rh-footwork .rh-mark, .rh-row.rh-cleanup .rh-mark { border-color: var(--text-dim); }
.rh-prose { font-size: 0.76rem; line-height: 1.45; color: var(--text); }
.rh-label {
  font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-dim);
}
.rh-pills { display: flex; flex-direction: column; gap: 3px; align-items: flex-end; }
.rh-pills .beat-pill { white-space: nowrap; }

/* The opponent's full printed text, expanded in place under the pair. Sized down from the
   commit-workspace drawer so a 320px rail can carry both halves of the card without scrolling
   the panel out from under the player. */
.rail-detail {
  margin: 10px 0 0;
  padding: 12px 14px;
  font-size: 0.92em;
}
.rail-detail .drawer-panels { flex-direction: column; gap: 10px; margin-top: 10px; }
.rail-detail .drawer-panel { min-width: 0; }
.rail-detail .drawer-title-row h3 { font-size: 0.95rem; }
.rail-detail .effect-text { font-size: 0.78rem; }
.rail-detail .stat-circle { width: 34px; height: 34px; font-size: 0.9rem; }
.rail-ribbons .ribbon { cursor: pointer; }

.range-lean { font-size: 0.72rem; line-height: 1.3; color: var(--text-dim); margin-top: 6px; }
.range-lean b { color: var(--text); }

/* ================= COMPACT RIBBON ================= */
.ribbon-list { display: flex; flex-direction: column; gap: 10px; margin-bottom: 16px; }
.ribbon {
  --frame: var(--universal);
  display: flex; flex-direction: column; align-items: stretch; gap: 3px;
  width: 100%; text-align: left; font-family: inherit;
  background:
    linear-gradient(180deg, rgb(255 255 255 / 0.03) 0%, transparent 50%),
    var(--surface-2);
  /* One meaningful edge (ownership) instead of a full 2px box on every ribbon in the hand. */
  border: 1px solid var(--sc-border-subtle);
  border-left: 3px solid var(--frame);
  border-radius: var(--sc-radius-sm);
  padding: 9px 14px; cursor: pointer; color: var(--text);
  transition: border-color var(--sc-motion-fast) var(--sc-ease-out),
              background-color var(--sc-motion-fast) var(--sc-ease-out),
              transform var(--sc-motion-fast) var(--sc-ease-out);
}
.ribbon-top { display: flex; align-items: center; gap: 10px; }
.ribbon:hover:not(:disabled) { filter: brightness(1.08); }
.ribbon.pinned { box-shadow: 0 0 0 2px var(--accent) inset; }
.ribbon.selected { background: #2c2513; }
.ribbon:disabled, .ribbon.disabled { opacity: 0.4; cursor: not-allowed; }
.ribbon-tempo { font-size: 0.75rem; font-weight: 700; color: var(--accent); min-width: 34px; }
.ribbon-name { font-weight: 700; flex: 1; }
.ribbon-tag {
  font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.04em; font-weight: 700;
  padding: 3px 7px; border-radius: 5px; border: 1px solid var(--frame); color: var(--frame);
}
.ribbon-tag.dirty { border-color: var(--dirty); color: var(--dirty); }
.ribbon-state { font-size: 0.68rem; font-weight: 700; text-transform: uppercase; padding: 3px 8px; border-radius: 10px; }
.ribbon-state.configured { background: rgba(240,180,41,0.18); color: var(--accent); }
.ribbon-state.neutral { background: var(--panel-2); color: var(--text-dim); }

/* Visual language: style sigil (Layer 2) + move gloss (Layer 4) — see design doc. Sigil is a
   small abstract glyph tinted to the card's own ownership color; gloss is a physical one-line
   description shown under the name on every plank, not just on hover. */
.card-sigil { width: 18px; height: 18px; flex-shrink: 0; display: inline-flex; }
.card-sigil svg { width: 100%; height: 100%; }
.ribbon-gloss { font-size: 0.76rem; color: var(--text-dim); font-style: italic; line-height: 1.3; }

/* ================= SELECT SLOTS (Select phase, both picks visible) ================= */
/* FE-204 / AC-004: the two techniques must be comparable at a glance, not stacked so the
   second sits below the fold. Grid collapses to one column below the tablet breakpoint. */
.commit-drawers {
  display: grid;
  grid-template-columns: var(--commit-gutter) repeat(2, minmax(0, 1fr));
  gap: 14px; margin-bottom: 16px; align-items: start;
}
.select-slots { display: flex; flex-direction: column; gap: 14px; margin-bottom: 16px; }
.select-slot.placeholder {
  border: 2px dashed var(--border); border-radius: 12px; padding: 24px 18px;
  text-align: center; color: var(--text-dim); font-size: 0.9rem;
}

/* ================= PINNED DECISION DRAWER ================= */
/* VIS-303: a subtle top-lit gradient gives the card material without lowering text contrast —
   the texture lives in the surface, never behind the rules text. The ownership frame stays,
   because it is the one border here that carries meaning. */
.drawer {
  --frame: var(--universal);
  position: relative; overflow: hidden;
  background:
    linear-gradient(180deg, rgb(255 255 255 / 0.035) 0%, transparent 42%),
    var(--surface-2);
  border: 1px solid var(--frame);
  border-top: 2px solid var(--frame);
  border-radius: var(--sc-radius-md);
  padding: 16px 18px; margin-bottom: 20px;
  box-shadow: var(--sc-shadow-panel);
}
/* Layer 3 (motion field, per-family) and the Dirty-card hazard alternative — decorative-only
   background traces confined to the card shell. Never above the Attack/Defense text: kept at
   z-index 0 with pointer-events disabled, everything else in the drawer stacks above it. */
.motion-field { position: absolute; inset: 0; z-index: 0; opacity: 0.10; pointer-events: none; transition: opacity 0.15s; }
.drawer:hover .motion-field { opacity: 0.22; }
.dirty-hazard {
  position: absolute; inset: 0; z-index: 0; pointer-events: none; opacity: 0.08; transition: opacity 0.15s;
  background: repeating-linear-gradient(45deg, var(--dirty) 0 10px, transparent 10px 20px);
}
.drawer:hover .dirty-hazard { opacity: 0.16; }
.drawer-header, .drawer-panels, .req-fail, .shelf { position: relative; z-index: 1; }
/* D4 density. This was a wrapping ROW: title-row and the controls competed for ~437px of card
   width, so the controls wrapped under the title AND the title itself wrapped around the family
   tag -- 106px for what the approved Commit draws as a single 26px line plus a control row.
   Making it a column lets each get its full width and stop wrapping. */
.drawer-header { display: flex; flex-direction: column; gap: 6px; margin-bottom: 4px; }
.drawer-title-row { flex: 1; min-width: 0; }
.drawer-title-row h3 { overflow-wrap: anywhere; }
.drawer-title-row { display: flex; align-items: baseline; gap: 10px; }
.drawer-title-row h3 { margin: 0; font-size: 1.15rem; }
.drawer-tempo { font-size: 0.8rem; font-weight: 700; color: var(--accent); }
.drawer-ownership { display: flex; gap: 6px; align-items: center; margin-top: 4px; }
.drawer-controls { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; row-gap: 6px; }
.drawer-controls .drawer-ownership { margin-top: 0; margin-right: auto; }
.drawer-gloss { font-size: 0.82rem; color: var(--text-dim); font-style: italic; margin: 2px 0 8px; position: relative; z-index: 1; }
.seg-toggle { display: flex; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
.seg-toggle label { margin: 0; padding: 6px 14px; font-size: 0.8rem; font-weight: 700; cursor: pointer; }
.seg-toggle label.attack.active { background: var(--role-attack); color: #2b0d07; }
.seg-toggle label.defense.active { background: var(--role-defense); color: #04252a; }
.seg-toggle input { position: absolute; opacity: 0; pointer-events: none; }
.use-tempo-check { display: flex; align-items: center; gap: 6px; font-size: 0.82rem; font-weight: 600; }

/* VIS-301: inner panels read as depth, not as another box in a stack. */
.drawer-panels { display: flex; gap: 14px; flex-wrap: wrap; margin-top: 12px; }
/* D4 density. min-width was 240px, and at 1366x768 each technique card has only ~437px of inner
   width -- so the two panels wrapped into a STACK, doubling the card's height. That single value
   was the largest contributor to Commit overflowing its stage. The approved Commit shows Attack
   and Defense side by side inside each card; 196px lets them do that from ~1300px up, and still
   wrap deliberately below it where there genuinely is not room. */
.drawer-panel {
  flex: 1; min-width: 196px; border-radius: var(--sc-radius-sm);
  padding: 12px 14px;
  /* Role is carried by a tinted surface plus one accent edge, rather than a full border box. */
  background:
    linear-gradient(180deg, color-mix(in srgb, var(--panel-role) 10%, transparent) 0%, transparent 58%),
    var(--surface-3);
  /* 22% -> 48%. With the surface ramp desaturated there is far less competing colour, so the
     edges that DO carry meaning can be read at a glance instead of being inferred. */
  border: 1px solid color-mix(in srgb, var(--panel-role) 48%, transparent);
  border-top: 2px solid var(--panel-role);
  transition: opacity var(--sc-motion-normal) var(--sc-ease-out),
              box-shadow var(--sc-motion-normal) var(--sc-ease-out);
}
.drawer-panel.role-attack { --panel-role: var(--role-attack); }
.drawer-panel.role-defense { --panel-role: var(--role-defense); }
.drawer-panel.active-role {
  box-shadow: 0 0 0 1px var(--panel-role) inset, 0 0 18px color-mix(in srgb, var(--panel-role) 22%, transparent);
}
.drawer-panels:has(.active-role) .drawer-panel:not(.active-role) { opacity: 0.72; }
.panel-label {
  font-size: 0.72rem; font-weight: 800; letter-spacing: 0.06em; margin-bottom: 8px;
  color: var(--panel-role);
}
.panel-stats { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; margin-bottom: 10px; }
.badge-row { display: flex; gap: 4px; }
/* North Star chip grammar: small square glyphs, off state receding, on state carrying the
   role colour so range and level read at a glance without a legend. */
.badge {
  width: 20px; height: 20px; border-radius: 4px; border: 1px solid var(--border); background: transparent;
  color: #6a6e80; font-size: 0.64rem; font-weight: 700; display: flex; align-items: center; justify-content: center;
  letter-spacing: 0.02em;
}
.badge.on {
  border-color: var(--panel-role); color: #fff;
  background: color-mix(in srgb, var(--panel-role) 32%, transparent);
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--panel-role) 40%, transparent);
}
.badge.choosable { border-style: dashed; }
.stat-circle {
  width: 40px; height: 40px; border-radius: 50%; border: 2px solid var(--panel-role);
  background: color-mix(in srgb, var(--panel-role) 12%, transparent);
  display: flex; align-items: center; justify-content: center; flex-direction: column;
  font-weight: 800; font-size: 1.05rem; line-height: 1; flex-shrink: 0;
}
.stat-circle span { font-size: 0.5rem; font-weight: 700; color: var(--text-dim); letter-spacing: 0.03em; }
.stamina-pips { display: flex; gap: 3px; }
.pip { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); }
.pip.free { background: var(--border); }
.effect-text { font-size: 0.82rem; line-height: 1.4; color: var(--text); }
.req-fail { color: var(--bad); font-size: 0.75rem; margin-top: 6px; font-weight: 600; }
.req-fail.override { color: var(--accent); }

/* ================= CONDITIONAL SHELF ================= */
.shelf { margin-top: 12px; border-top: 1px dashed var(--border); padding-top: 10px; }
.shelf-item { position: relative; margin-bottom: 10px; }
.shelf-item.child { position: relative; padding-left: 18px; }
.shelf-item.child::before {
  content: ''; position: absolute; left: 0; top: 0; bottom: 8px; width: 10px;
  border-left: 2px solid var(--border); border-bottom: 2px solid var(--border); border-radius: 0 0 0 6px;
}
.timing-badge {
  display: inline-block; font-size: 0.6rem; font-weight: 800; letter-spacing: 0.04em; text-transform: uppercase;
  padding: 2px 7px; border-radius: 8px; margin-bottom: 5px; background: var(--panel-3); color: var(--text-dim);
  border: 1px solid var(--border);
}
.timing-badge.before-confirm { color: var(--sc-player); border-color: var(--sc-player); }
.timing-badge.on-guard, .timing-badge.on-hit { color: var(--accent); border-color: var(--accent); }
.timing-badge.triggered { color: var(--bad); border-color: var(--bad); }
.shelf-item-label { font-size: 0.82rem; display: block; margin-bottom: 4px; }

/* ================= READY STRIP ================= */
.ready-strip {
  display: flex; align-items: center; justify-content: space-between; gap: 14px; flex-wrap: wrap;
  padding: 10px 16px; border-radius: 8px; margin-bottom: 16px; font-size: 0.85rem; font-weight: 600;
}
.ready-strip.ready { background: rgba(76,175,125,0.14); border: 1px solid var(--good); color: var(--good); }
.ready-strip.blocked { background: rgba(224,86,86,0.14); border: 1px solid var(--bad); color: var(--bad); }

/* ================= COMBO TRACK (multi-strike) ================= */
.combo-track { display: flex; align-items: center; gap: 6px; margin: 10px 0; }
.combo-beat {
  width: 62px; height: 62px; border-radius: 50%; border: 2px solid var(--panel-role); background: var(--panel-3);
  display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; flex-shrink: 0;
}
.combo-beat-idx { font-size: 0.55rem; color: var(--text-dim); text-transform: uppercase; }
.combo-beat-level { font-weight: 800; font-size: 0.95rem; }
.combo-beat-power { font-size: 0.62rem; color: var(--text-dim); }
.combo-arrow { color: var(--panel-role); font-size: 1.2rem; flex-shrink: 0; }
.combo-beat-choices { display: flex; gap: 4px; margin-top: 2px; flex-wrap: wrap; }

/* ================= THROW MODULE ================= */
.throw-module { display: flex; align-items: center; gap: 12px; margin: 8px 0 10px; }
.throw-module svg { flex-shrink: 0; }

/* ================= NOTE ROWS ================= */
.notice-row { font-size: 0.78rem; color: var(--text-dim); margin-top: 6px; }

/* ================= NARRATIVE 2.4 PILLS ================= */
:root {
  --narrative-pill-bg: #252936;
  --narrative-pill-border: #454b5d;
  --narrative-pill-text: #f5f7ff;
  --narrative-damage: #ff655d;
  --narrative-defense: #34d6e7;
  --narrative-status: #b567ff;
  --narrative-opening: #f4bd32;
  --narrative-recovery: #54d69a;
  --narrative-fury: #ff5a45;
  --narrative-flow: #4da3ff;
}

.narrative-line__pills {
  display: inline-flex;
  flex-wrap: wrap;
  gap: 0.28rem;
  margin-left: 0.5rem;
  vertical-align: 0.08em;
}

.narrative-summary-pills {
  display: flex;
  flex-wrap: wrap;
  gap: 0.35rem;
  margin-top: 0.65rem;
  padding-top: 0.55rem;
  border-top: 1px dashed #3c4252;
}

.narrative-pill {
  display: inline-flex;
  align-items: center;
  min-height: 1.18rem;
  padding: 0.08rem 0.42rem 0.09rem;
  border: 1px solid var(--narrative-pill-border);
  border-radius: 999px;
  background: var(--narrative-pill-bg);
  color: var(--narrative-pill-text);
  font-family: inherit;
  font-size: 0.66rem;
  font-weight: 800;
  line-height: 1;
  letter-spacing: 0.045em;
  text-transform: uppercase;
  white-space: nowrap;
  box-shadow: 0 1px 0 rgb(255 255 255 / 4%) inset;
}

.narrative-pill--damage {
  border-color: color-mix(in srgb, var(--narrative-damage) 74%, #ffffff 5%);
  color: #ffd3cf;
  background: color-mix(in srgb, var(--narrative-damage) 15%, #1d202a);
}

.narrative-pill--defense,
.narrative-pill--block,
.narrative-pill--evade {
  border-color: color-mix(in srgb, var(--narrative-defense) 74%, #ffffff 5%);
  color: #bdf8ff;
  background: color-mix(in srgb, var(--narrative-defense) 13%, #1d202a);
}

.narrative-pill--status,
.narrative-pill--danger {
  border-color: var(--narrative-status);
  color: #e6c6ff;
  background: #2b2035;
}

.narrative-pill--opening,
.narrative-pill--opening-used {
  border-color: var(--narrative-opening);
  color: #ffe8a0;
  background: #302819;
}

.narrative-pill--recovery {
  border-color: var(--narrative-recovery);
  color: #bdf7d9;
  background: #1c3028;
}

.narrative-pill--fury {
  border-color: var(--narrative-fury);
  color: #ffd0c9;
  background: #37211e;
}

.narrative-pill--flow {
  border-color: var(--narrative-flow);
  color: #cbe3ff;
  background: #1b2939;
}

.narrative-pill--muted {
  opacity: 0.78;
  border-style: dashed;
}

.narrative-pill--brutal,
.narrative-pill--catastrophic {
  border-width: 2px;
  color: #fff3f1;
  background: linear-gradient(90deg, #4a1f20, #2a2028);
}

.narrative-pill--dirty-critical {
  border: 2px solid #ffd33d;
  color: #17130a;
  background: linear-gradient(90deg, #ffd33d, #ff9e32);
  box-shadow: 0 0 0.55rem rgb(255 179 48 / 38%);
}

/* ---- Category-flavored critical-hit pills (5+ damage, non-groin) ---- */

.narrative-pill--ko-flash {
  border: 2px solid #ffe066;
  color: #241d05;
  background: linear-gradient(90deg, #fff6d0, #ffcf3d);
  box-shadow: 0 0 0.6rem rgb(255 224 102 / 45%);
  animation: narrative-pill-flash 420ms ease-out both;
}

.narrative-pill--bone-crusher {
  border: 2px solid #ff7b3d;
  color: #fff0e6;
  background: linear-gradient(90deg, #5c2410, #331409);
  animation: narrative-pill-shake 420ms ease-out both;
}

.narrative-pill--knee-crit {
  border: 2px solid #8fa3c8;
  color: #eaf1ff;
  background: linear-gradient(90deg, #2a3550, #1a2135);
  animation: narrative-pill-buckle 460ms ease-out both;
}

.narrative-pill--elbow-crit {
  border: 2px solid #e0293e;
  color: #ffe6e9;
  background: linear-gradient(90deg, #5c0f18, #33090f);
  animation: narrative-pill-throb 480ms ease-out both;
}

.narrative-pill--takedown-crit {
  border: 2px solid #b8793d;
  color: #fff2e0;
  background: linear-gradient(90deg, #4a3216, #2c1e0d);
  animation: narrative-pill-thud 440ms ease-out both;
}

.narrative-pill--throw-crit {
  border: 2px solid #8b5cf6;
  color: #f1eaff;
  background: linear-gradient(90deg, #34215c, #201435);
  animation: narrative-pill-spin 460ms ease-out both;
}

.narrative-pill--sweep-crit {
  border: 2px solid #4caf6d;
  color: #e8ffef;
  background: linear-gradient(90deg, #163c28, #0d2418);
  animation: narrative-pill-topple 480ms ease-out both;
}

.narrative-pill--devastating {
  border-width: 2px;
  border-color: #ff7a6b;
  color: #fff3f1;
  background: linear-gradient(90deg, #4a1f20, #2a2028);
  animation: narrative-pill-pop 260ms ease-out both;
}

@keyframes narrative-pill-flash {
  0% { transform: scale(0.85); filter: brightness(2.4); }
  35% { transform: scale(1.1); filter: brightness(2); }
  100% { transform: scale(1); filter: brightness(1); }
}

@keyframes narrative-pill-shake {
  0% { transform: translateX(0) scale(0.9); }
  20% { transform: translateX(-3px) scale(1.04); }
  40% { transform: translateX(3px); }
  60% { transform: translateX(-2px); }
  80% { transform: translateX(1px); }
  100% { transform: translateX(0) scale(1); }
}

@keyframes narrative-pill-buckle {
  0% { transform: translateY(0) scale(0.9); }
  40% { transform: translateY(0.14rem) scale(1.03) rotate(-2deg); }
  70% { transform: translateY(0.03rem) rotate(1deg); }
  100% { transform: translateY(0) scale(1) rotate(0); }
}

@keyframes narrative-pill-throb {
  0% { transform: scale(0.88); }
  30% { transform: scale(1.08); }
  55% { transform: scale(0.97); }
  80% { transform: scale(1.03); }
  100% { transform: scale(1); }
}

@keyframes narrative-pill-thud {
  0% { transform: translateY(-0.35rem) scale(0.92); }
  55% { transform: translateY(0.06rem) scale(1.05); }
  80% { transform: translateY(-0.02rem); }
  100% { transform: translateY(0) scale(1); }
}

@keyframes narrative-pill-spin {
  0% { transform: rotate(-22deg) scale(0.85); filter: brightness(1.4); }
  100% { transform: rotate(0) scale(1); filter: brightness(1); }
}

@keyframes narrative-pill-topple {
  0% { transform: rotate(0) scale(0.9); }
  35% { transform: rotate(-9deg) scale(1.05); }
  65% { transform: rotate(4deg); }
  100% { transform: rotate(0) scale(1); }
}

.narrative-pill--high {
  transform: translateY(-0.02rem);
}

.narrative-pill--max {
  font-size: 0.7rem;
  letter-spacing: 0.07em;
  animation: narrative-pill-pop 260ms ease-out both;
}

@keyframes narrative-pill-pop {
  0% {
    transform: scale(0.86);
    filter: brightness(1.5);
  }
  72% {
    transform: scale(1.06);
  }
  100% {
    transform: scale(1);
    filter: brightness(1);
  }
}

@media (prefers-reduced-motion: reduce) {
  .narrative-pill--max,
  .narrative-pill--ko-flash,
  .narrative-pill--bone-crusher,
  .narrative-pill--knee-crit,
  .narrative-pill--elbow-crit,
  .narrative-pill--takedown-crit,
  .narrative-pill--throw-crit,
  .narrative-pill--sweep-crit,
  .narrative-pill--devastating {
    animation: none;
  }
  /* Shell (M1): the HUD's only motion is the Fury marker slide and Stamina segment fades. */
  .ff-marker, .stam-seg, .rp-dot { transition: none; }
  /* Beats (M3): prose still appears, it just does not animate in. The delay itself is skipped
     in js/ui-resolution.js so information is never withheld from a reduced-motion user. */
  .beat-prose, .rail-panel.entering { animation: none; }
  .ns-dot.live { animation: none; }
  .fp-detail > summary::before { transition: none; }
  .intent-triangle .itri-edges path,
  .intent-triangle .itri-nodes circle { transition: none; }
}

/* Spec section 03: below 900 px, stack fighter summaries above the range track. Stamina,
   mindset, and Range all stay visible; portraits and deck counts are the first things to go.
   (M4 completes this by moving deck counts into an expandable detail.) */
/* The 1099px stacking rule for the technique pair is gone (G4). Section 5 requires both cards
   visible side by side -- "do not make the player scroll to compare Attack, Defense, Tempo, target
   and conditions" -- and stacking DOUBLED the pair's height at exactly the viewports with the
   least room for it. It was the largest single contributor to the 613px stage overflow measured at
   the 1024x576 reference. The pair now stacks only where the whole board does, at 900px. */

@media (max-width: 900px) {
  .intent-layout { flex-wrap: wrap; justify-content: center; }
  .intent-lede { flex-basis: 100%; }
  .intent-options { flex-wrap: wrap; }
  .intent-card { max-width: none; }
  #fight-hud { grid-template-columns: 1fr 1fr; gap: 8px; padding: 8px 10px 10px; }
  .fighter-panel { padding: 10px; }
  /* Drop the angled inner edge when the panels no longer flank a centered track. */
  .fighter-panel.p1, .fighter-panel.p2 { clip-path: none; }
  .range-panel { grid-column: 1 / -1; order: 3; }
  .fp-portrait { display: none; }
  /* Deck counts move into expandable detail rather than vanishing (spec section 03). */
  .fp-detail > summary { display: block; }
  .p2 .fp-detail > summary { text-align: right; }
  #shell-body { flex-direction: column-reverse; padding: 0 10px; }
  #rail { width: 100%; margin-top: 0; margin-bottom: 12px; }
  /* The rail becomes a drawer: a labelled handle the player pulls open. */
  #rail-toggle {
    display: block; width: 100%; text-align: left;
    background: none; border: none; color: var(--text); cursor: pointer;
    font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.09em; padding: 4px 2px;
  }
  #rail-toggle::before { content: '▾ '; }
  #rail.collapsed #rail-toggle::before { content: '▸ '; }
}

@media (max-width: 680px) {
  /* 09_Responsive spec: primary actions stay reachable with one thumb at a 44px minimum. */
  button.primary, #beat-advance, #confirm-commit-btn, #rail-toggle, .intent-card, .tray-slot {
    min-height: 44px;
  }
  .narrative-line__pills {
    display: flex;
    margin: 0.3rem 0 0;
  }
  /* Deliberately still two columns. Stacking all three blocks would make the sticky header eat
     roughly half a phone viewport, which costs more than the cramped panels save. Compact the
     type instead, and drop the p2 mirroring since there is no centered track to lean toward. */
  #fight-hud { grid-template-columns: 1fr 1fr; gap: 6px; padding: 6px 8px 8px; }
  #brand-bar { padding: 6px 10px; }
  #brand-bar h1 { font-size: 0.9rem; letter-spacing: 0.1em; }
  .fighter-panel { padding: 8px; }
  .fighter-panel.p2 { flex-direction: row; text-align: left; }
  .p2 .fp-metric-head, .p2 .ff-axis { flex-direction: row; }
  .p2 .stam-track { flex-direction: row; }
  .p2 .ff-rail { transform: none; }
  .p2 .fp-chips, .p2 .fp-counts { justify-content: flex-start; }
  .fp-name { font-size: 0.95rem; }
  .fp-track-label { display: none; }
  .ff-end { display: none; }
  .range-panel { padding: 6px 10px 8px; }
}

/* ================================================================================
   D0 — FOUR-ZONE DASHBOARD GRID  (persistent_dashboard_handoff/MILESTONE_CHECKLIST.md)

   Everything below is scoped to on <html>, set by the `?shell=grid` flag. The legacy
   scrolling-document layout above is left byte-identical so the two can be screenshotted side by
   side for the proportion sign-off. This whole section becomes unconditional in D6 and the flag,
   along with the rules it overrides, is deleted.

   Adapted from dashboard_shell_reference.css. The two details that section 11 calls critical —
   `minmax(0, 1fr)` on flexible tracks and `min-height: 0` on their children — are load-bearing,
   not defensive: without them a grid child's content forces its row taller than the viewport and
   the whole "the board does not scroll" contract silently fails.
   ================================================================================ */


/* ---- Zone 4 contents, layout-independent ---- */
#command-bar {
  display: flex;
  align-items: center;
  gap: var(--sc-gap);
}
#command-bar.hidden { display: none; }
#command-bar .cmd-body { flex: 1; min-width: 0; }
#command-bar .cmd-line { display: flex; align-items: baseline; gap: 10px; }
#command-bar .cmd-instruction { font-size: 0.86rem; font-weight: 600; }
#command-bar .cmd-status {
  flex-shrink: 0;
  font-size: 0.6rem; text-transform: uppercase; letter-spacing: 0.12em; font-weight: 700;
  color: var(--accent); border: 1px solid var(--accent);
  border-radius: 3px; padding: 2px 7px;
}
#command-bar .cmd-status.hidden,
#command-bar .cmd-detail.hidden,
#command-bar .primary.hidden { display: none; }
#command-bar .cmd-detail { margin-top: 3px; min-width: 0; }
#command-bar .cmd-actions { display: flex; align-items: center; gap: 12px; flex-shrink: 0; }
#command-bar .cmd-secondary { display: flex; gap: 12px; }
/* Same signal the old bar carried: the plan reads as a sentence and the action is live. */
#command-bar.ready { border-color: var(--accent); }
/* A wait or a dropped connection. Both carry a text badge as well as the colour — status is never
   colour alone (section 17). */
#command-bar.is-waiting { border-color: var(--sc-border-subtle); }
#command-bar.is-error { border-color: var(--bad); }
#command-bar.is-error .cmd-status { color: var(--bad); border-color: var(--bad); }

/* Zone 3's connection panel: who you are waiting for, above the telegraph. */
.rail-panel.connection { gap: 6px; padding-bottom: 10px; border-bottom: 1px solid var(--hud-border); }
.rail-panel.connection .rail-title { display: flex; align-items: center; gap: 7px; }
.rail-panel.connection .rail-note { border-top: none; padding-top: 0; }
.rail-panel.connection.is-error .rail-title { color: var(--bad); }

/* The viewport lock belongs to the BOARD, not to the stylesheet. `html, body { height: 100% }` plus
   `body { overflow: hidden }` is what makes the four zones a fixed frame that never scrolls as a
   document -- correct for index.html and wrong for every other page that links this file. It was
   applied to all of them, so simulate.html and train.html could not scroll at all: their content
   ran past the bottom of the window with no way to reach it, which on the simulator meant the Save
   log control was unreachable.

   Scoped to `body.board`, which index.html carries and the other pages do not. :has(#fight-shell)
   was tried first and is worse in two ways: jsdom does not implement :has(), so the suite that
   asserts "the document body is not the scroll container" could not see the rule at all and went
   red; and opting IN is the safer default anyway -- a page added without the marker scrolls like a
   document, which is the behaviour a page wants unless it is a fixed frame.

   `html` keeps its 100% everywhere. On its own that does not lock anything: content taller than
   the viewport still overflows and the document still scrolls. It is `overflow: hidden` on a
   full-height body that makes the frame. */
html { height: 100%; }
body.board { height: 100%; overflow: hidden; min-height: 0; }

#fight-shell {
  height: 100dvh;
  min-height: 680px;
  padding: var(--sc-edge);
  display: grid;
  grid-template-columns: minmax(0, 1fr) var(--sc-rail-w);
  grid-template-rows: var(--sc-hud-h) minmax(0, 1fr) var(--sc-command-h);
  grid-template-areas:
    "hud     hud"
    "stage   rail"
    "command rail";
  gap: var(--sc-gap);
  overflow: hidden;
}

/* #sr-announce is also a child of the shell, but .visually-hidden makes it position:absolute, so
   it is out of flow and never auto-placed into a grid cell. */

/* ---- Zone 1 ---- */
/* A reserved grid row does not need to be sticky. The M1 sticky header existed only because the
   document scrolled underneath it; under the grid nothing scrolls underneath it. */
#shell-top {
  grid-area: hud;
  position: static;
  min-width: 0; min-height: 0;
  overflow: hidden;
  border-bottom: none;
  border: 1px solid var(--hud-border);
  border-radius: var(--sc-radius-sm);
  display: grid;
  grid-template-rows: auto minmax(0, 1fr) auto;
}
#fight-hud { min-height: 0; padding: 6px 16px 10px; }
#brand-bar { padding: 6px 16px; }

/* Zone 1 fits its reserved row by TIGHTENING the vertical rhythm, not by narrowing the metrics.
   The measured problem was height (the panel needed 182-205px against 134-160px, clipping the deck
   counts and the whole status chip row). Pairing the two metrics into two columns solved that
   arithmetic and produced the wrong board: at 1024 each column is ~110px, so the Stamina segments
   and the mindset axis — the two things Zone 1 exists to make readable at a glance — became
   half-length stubs with dead space beside them.
   The approved Zone 1 keeps ONE full-width column. So the height comes from the rhythm instead:
   tighter gaps, tighter panel padding, no redundant mindset word (the axis and the signed readout
   both already carry it), and a reserved row height that actually fits the result. */
.fp-body { gap: 3px; }
.fighter-panel { padding: 9px 16px; }
/* The portrait bleeds to the panel edge with a negative margin that has to MATCH the panel's
   padding. It did not: -12px against 9px overflowed the panel by exactly 3px top and bottom. That
   read as "Zone 1 needs a taller row" and very nearly bought 18px of stage height to pay for a
   margin. Measure before you widen a clamp. */
.fp-portrait { margin: -9px 0 -9px -16px; }
.p2 .fp-portrait { margin: -9px -16px -9px 0; }
/* The approved Zone 1 puts each readout BESIDE its bar, not above it on the label line: the number
   and the thing it measures read as one object, and it costs no extra row.
   `display: contents` on the head lets its label and readout place themselves directly into the
   metric's grid, so this needs no markup change — the head is a plain div with no semantics to
   lose. */
.fp-metric {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  column-gap: 10px;
  align-items: center;
}
.fp-metric-head { display: contents; }
.fp-label { grid-column: 1 / -1; line-height: 1.15; }
.fp-readout { grid-column: 2; grid-row: 2; }
.stam-track,
.ff-axis { grid-column: 1; grid-row: 2; margin-top: 2px; }

/* The approved Zone 1 mirrors only the PORTRAIT side. Both fighters' stats read left-to-right in
   the same order, which is what makes them comparable at a glance — the same reasoning FE-211
   already applied to the mindset axis alone, carried through the rest of the panel. */
.p2 .fp-metric-head,
.p2 .stam-track,
.p2 .fp-counts,
.p2 .fp-chips { flex-direction: row; justify-content: flex-start; }
.p2 .fp-identity,
.p2 .fp-body { text-align: left; }
/* Restates the marker position and the signed readout beside it. Dropping it is ~14px per panel. */
.fp-track-label { display: none; }
.fp-counts { font-size: 0.6rem; gap: 14px; }

/* Status chips are the one genuinely unbounded thing in Zone 1 — Openings are an array — so they
   get a fixed-height single-track strip that scrolls rather than a wrapping block that grows. This
   is what keeps a mid-bout Opening from resizing the zone whose height is the contract. D6 owns
   the nicer answer (overflow affordance, priority ordering); this one is merely honest: nothing is
   hidden and nothing moves. */
.fp-chips {
  flex-wrap: nowrap;
  height: 21px;
  /* flex-shrink is NOT decoration here. .fp-body is a flex column, so without this the browser
     compresses the strip to make the panel fit and `overflow-y: hidden` then clips the chips
     silently — invisible to getBoundingClientRect AND to the probe's scrollHeight check, because
     a hidden overflow inside a child is not an overflow of the parent. That combination made the
     panel measure clean while visibly cutting the status row in half. Anything with a fixed height
     and hidden overflow inside a flex container needs this, or it lies to its own instruments. */
  flex-shrink: 0;
  overflow-x: auto;
  overflow-y: hidden;
  scrollbar-width: none;
  align-items: center;
}
.fp-identity,
.fp-metric,
.fp-detail { flex-shrink: 0; }
.fp-chips::-webkit-scrollbar { display: none; }
.fp-chips > .chip { flex-shrink: 0; }
.p2 .fp-chips { justify-content: flex-end; }

/* ---- Zones 2 and 3 are grid children of the shell, not of this wrapper ---- */
#shell-body { display: contents; }

/* ---- Zone 2 ---- */
/* The stage frame never scrolls; exactly one node inside it does. Legacy's max-width cap, centring
   margin and tactical-bar padding reservation are all cleared — the cap is a proportion question
   for D0 sign-off, and the padding compensated for a Zone 4 that now has its own row. */
#workspace {
  grid-area: stage;
  min-width: 0; min-height: 0;
  max-width: none; margin: 0; padding: 0;
  overflow: hidden;
  display: grid;
  grid-template-rows: minmax(0, 1fr);
  background: var(--surface-1);
  border: 1px solid var(--hud-border);
  border-radius: var(--sc-radius-sm);
}
#workspace-scroll {
  min-width: 0; min-height: 0;
  overflow: auto;
  overscroll-behavior: contain;
  padding: 14px 16px;
}

/* ---- Zone 3 ---- */
#rail {
  grid-area: rail;
  width: auto; margin: 0;
  min-width: 0; min-height: 0;
  overflow: hidden;
  display: grid;
  grid-template-rows: auto minmax(0, 1fr);
}
/* EXPLICIT rows, not auto-placement. `#rail-toggle` is `display: none` above 900px, and a
   display:none child is not a grid item — so #rail-body was the only item, auto-placed into row 1
   (`auto`), and sized to its content instead of to the rail. That is the whole of "Zone 3 is
   correct but too visually empty" (section 6): the history panel never received the rail's height,
   so it could not fill it, and everything below its content was dead space. Same failure as the
   Commit stage's arbitration box in G4 — a conditional child silently re-mapping grid rows. */
#rail-toggle { grid-row: 1; }
#rail-body { grid-row: 2; }
#rail-body {
  min-width: 0; min-height: 0;
  display: grid;
  grid-template-rows: auto minmax(0, 1fr);
  gap: 10px;
}
#rail-context { min-height: 0; }
/* The only other region permitted to scroll internally. */
#rail-history {
  min-height: 0;
  overflow: auto;
  overscroll-behavior: contain;
}

/* ---- Zone 4 ---- */
/* Deliberately NOT sticky and NOT fixed. It occupies a reserved row, which is the difference
   between a command bar that persists and one that covers the stage (section 18, "Do not"). */
#command-bar {
  grid-area: command;
  min-width: 0; min-height: 0;
  overflow: hidden;
  display: flex;
  align-items: center;
  gap: var(--sc-gap);
  padding: 0 18px;
  background: var(--rail-bg);
  border: 1px solid var(--hud-border);
  border-radius: var(--sc-radius-sm);
}
/* The detail slot holds either a four-part tactical sentence or a list of every outstanding
   requirement, and the list is the taller of the two. It scrolls inside the reserved row rather
   than pushing the CTA out of it -- the anchor not moving is the whole point of the zone. */
#command-bar .cmd-body { min-height: 0; max-height: 100%; overflow-y: auto; }

/* --- Range panel: reclaim the reserved marker band --- */
/* This band was once a 54px column reserved under every node so Start/Clash/End could stack
   without wrapping into a neighbour's label (FE-210 / AC-009), and the figures were sized to fill
   it. The markers are rings on the node dots now and reserve nothing, so the band is simply the
   space above the track and the figures take all of it. */
/* Centred on the track and sized to fill the band, as in the approved Zone 1, rather than sitting
   as 30px ornaments in a corner of it. */
.rp-figure {
  top: 50%; bottom: auto;
  width: clamp(38px, 4.6vw, 62px);
  opacity: 0.17;
  transform: translateY(-52%);
}
.rp-figure.p1 { left: 3%; }
.rp-figure.p2 { right: 3%; transform: translateY(-52%) scaleX(-1); }
.range-panel { padding: 8px 14px 10px; }
.rp-head { margin-bottom: 4px; }
.rp-stage { align-items: center; }

/* Short viewports. --sc-hud-h bottoms out at its clamp minimum on a 768px-tall screen. The plan's
   guidance for the compact band is explicit — "Reduce rail width and portrait detail before
   abandoning the four-zone structure" — so this trims chrome and secondary detail rather than
   taking height from the stage. Keyed on HEIGHT, not width: 1920x1080 and 1024x768 differ here
   only in height. */
@media (max-height: 820px) {
  #brand-bar { padding: 3px 16px; }
  #brand-bar h1 { font-size: 0.92rem; }
  .brand-mark { width: 20px; height: 20px; }
  #fight-hud { padding: 4px 14px 8px; gap: 8px; }
  .fighter-panel { padding: 7px 14px; }
  .fp-portrait { width: 66px; min-height: 72px; margin: -7px 0 -7px -14px; }
  .p2 .fp-portrait { margin: -7px -14px -7px 0; }
  .fp-name { font-size: 1rem; }
  .fp-counts { font-size: 0.57rem; }
  /* The flat `--sc-hud-h: 192px` pin that used to live here is gone (G3): the proportional
     clamp passes through 192px at exactly 768, so the pin restated the clamp at one point and
     contradicted it at every other. The compaction above is still what lets the zone hold its
     content at these heights. */
}

/* No compact-dashboard BAND any more. Its job was "reduce rail width before abandoning the four
   zones", and --sc-rail-w is now clamp(300px, 31vw, 620px) — it narrows continuously with the
   viewport instead of stepping at a breakpoint, and holds the mockup's ~32% share all the way
   down to 1024. A band that pinned the rail to a flat 270px made the compact range the one place
   the proportion was wrong. Zone 1's compaction is height-keyed and lives in the max-height band
   above. */

/* Deliberate fallback at and below the existing 900px breakpoint: the zones stack in reading order
   and the document scrolls again. The desktop board is not compromised to serve this. */
@media (max-width: 900px) {
  body { overflow: auto; }
  #fight-shell {
    height: auto; min-height: 100dvh;
    grid-template-columns: minmax(0, 1fr);
    grid-template-rows: auto auto auto auto;
    grid-template-areas: "hud" "stage" "rail" "command";
    overflow: visible;
  }
  #shell-top { grid-template-rows: none; display: block; }
  #workspace, #rail { overflow: visible; }
  #workspace-scroll, #rail-history { overflow: visible; }
  #workspace { display: block; }
  #command-bar { min-height: 64px; }
}

/* ================= D0 proof content (?shell=grid&proof=1) ================= */
/* Static mock filling all four zones so proportions can be reviewed before any game logic moves.
   Deleted with the flag in D6. */
.proof-note {
  font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.1em;
  color: var(--text-dim); margin: 0 0 10px;
}
.proof-pair { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--sc-gap); }
.proof-card {
  border: 1px solid var(--hud-border); border-radius: var(--sc-radius-sm);
  background: var(--surface-2); padding: 12px 14px; min-height: 190px;
}
.proof-card h3 { margin: 0 0 4px; font-size: 0.95rem; }
.proof-card .proof-role {
  font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.09em; color: var(--sc-action);
}
.proof-card p { color: var(--text-dim); font-size: 0.82rem; line-height: 1.5; }
.proof-history { list-style: none; margin: 0; padding: 0; }
.proof-history li {
  display: flex; justify-content: space-between; gap: 8px;
  padding: 7px 4px; border-bottom: 1px solid var(--hud-border); font-size: 0.78rem;
}
.proof-command-text { flex: 1; min-width: 0; }
.proof-command-text b { display: block; font-size: 0.86rem; }
.proof-command-text span { color: var(--text-dim); font-size: 0.76rem; }

/* ================= SETUP AS A SHELL STATE (D5) ================= */
/* Zone 1 carries editable fighter identity; Zone 2 browses the chosen deck; Zone 3 holds match
   context. Setup stops being "a separate page outside the game board" (the plan's root-cause
   table) and becomes another state of the same board. */
#fight-hud.setup-mode .fp-body.setup {
  display: flex; flex-direction: column; gap: 8px; justify-content: center;
  min-width: 0;
}
.setup-field { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.setup-field .fp-label { font-size: 0.58rem; }
.setup-name, #fight-hud.setup-mode select {
  background: var(--panel-2); color: var(--text);
  border: 1px solid var(--border); border-radius: 5px;
  padding: 5px 8px; font-family: inherit; font-size: 0.86rem; min-width: 0; width: 100%;
}
.fp-deck-blurb {
  margin: 0; font-size: 0.66rem; color: var(--text-dim); line-height: 1.3;
  overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
  /* Without this the flex column shrinks the box below its own clamp and renders HALF a line of
     text -- visible at 1024x576 since G3 brought Zone 1 down to 150px there. Nothing spilled and
     nothing overflowed, so every instrument read clean; only the screenshot showed it. A clamp
     bounds the number of lines, not the height of the box it is in. */
  flex-shrink: 0;
}
@media (max-height: 700px) {
  /* One line where two do not fit. The same sentence is the Zone 2 deck heading's subtitle, so
     nothing becomes unavailable -- it is restated in full a few hundred pixels below. */
  .fp-deck-blurb { -webkit-line-clamp: 1; }
}
#fight-hud.setup-mode .fp-portrait { opacity: 0.45; }
.setup-mode-box { display: flex; flex-direction: column; gap: 6px; align-items: center; padding-top: 6px; }
.setup-mode-box .hint { margin: 0; font-size: 0.68rem; text-align: center; line-height: 1.35; }

/* Zone 2: the deck browser. */
.deck-switch { display: flex; gap: 8px; margin-bottom: 12px; }
.deck-switch-btn {
  flex: 1; padding: 7px 10px; font-size: 0.74rem; text-transform: uppercase;
  letter-spacing: 0.08em; border-radius: 6px;
}
.deck-switch-btn.on { border-color: var(--accent); color: var(--accent); }
.deck-list { display: flex; flex-direction: column; gap: 6px; }
.deck-row {
  --frame: var(--universal);
  display: grid; grid-template-columns: 40px minmax(0, 1fr) auto auto;
  align-items: center; gap: 10px; text-align: left;
  padding: 8px 12px; border-radius: 6px;
  border: 1px solid var(--border); border-left: 3px solid var(--frame);
  background: var(--surface-2); color: var(--text); cursor: pointer;
}
.deck-row:hover { border-color: var(--accent); }
.deck-tempo { color: var(--accent); font-weight: 700; font-size: 0.76rem; }
.deck-name { font-size: 0.9rem; font-weight: 600; }
.deck-count { font-size: 0.7rem; color: var(--text-dim); }
.deck-tags { display: flex; gap: 5px; }
.recap-line { font-size: 0.95rem; }
.recap-line b { color: var(--accent); }

/* ================= COMMIT: the shared control gutter ================= */
/* The approved Commit puts ROLE and TEMPO SOURCE in a narrow column beside the pair rather than
   repeating both inside both cards. They are single choices -- the draft holds one attackIndex and
   one tempoIndex -- so drawing them twice was duplicating a decision and charging the card row for
   a control strip it did not need. */
.commit-gutter { display: flex; flex-direction: column; gap: 16px; padding-top: 4px; min-width: 0; }
.gutter-group { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
.gutter-label {
  font-size: 0.58rem; text-transform: uppercase; letter-spacing: 0.1em; color: var(--text-dim);
  font-weight: 700;
}
.seg-toggle.vertical { flex-direction: column; gap: 4px; margin-top: 0; }
.seg-toggle.vertical label.inline,
.seg-toggle.vertical label {
  display: flex; flex-direction: column; align-items: flex-start; gap: 1px;
  border: 1px solid var(--border); border-radius: 6px; padding: 5px 7px;
  cursor: pointer; min-width: 0; width: 100%;
}
.seg-toggle.vertical input { position: absolute; opacity: 0; pointer-events: none; }
.gutter-card {
  font-size: 0.66rem; line-height: 1.15; color: var(--text);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%;
}
.gutter-hint { font-size: 0.58rem; color: var(--text-dim); line-height: 1.25; }
.gutter-role {
  font-size: 0.55rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-dim);
}
/* CONTRAST. The horizontal seg-toggle fills its active option with a solid role colour and dark
   text (`.seg-toggle label.attack.active`), which works when the label is one short word. The
   gutter's options are a stacked card name plus a role word, each setting its own light colour —
   so on a solid red fill the name went light-on-red and the role word red-on-red. A tinted fill
   with explicit child colours instead.

   The `.active` selectors are spelled out rather than left to source order: the inherited rule and
   a `.seg-toggle.vertical label.attack` rule have IDENTICAL specificity (0,3,1), so which one wins
   would depend on file position — the same trap that made the Stamina bar render full. */
.seg-toggle.vertical label { background: var(--panel-2); color: var(--text); }
.seg-toggle.vertical label.attack,
.seg-toggle.vertical label.attack.active {
  border-color: var(--role-attack);
  background: color-mix(in srgb, var(--role-attack) 15%, var(--panel-2));
  color: var(--text);
}
.seg-toggle.vertical label.defense,
.seg-toggle.vertical label.defense.active {
  border-color: var(--role-defense);
  background: color-mix(in srgb, var(--role-defense) 15%, var(--panel-2));
  color: var(--text);
}
.seg-toggle.vertical label .gutter-card { color: var(--text); }
.seg-toggle.vertical label.attack .gutter-role { color: var(--role-attack); }
.seg-toggle.vertical label.defense .gutter-role { color: var(--role-defense); }
.commit-gutter .tempo-choice { width: 100%; justify-content: flex-start; }

/* The card still says which role it holds — the gutter sets it, the card shows it. */
.drawer-role-tag {
  font-size: 0.58rem; text-transform: uppercase; letter-spacing: 0.09em; font-weight: 800;
  border: 1px solid currentColor; border-radius: 4px; padding: 2px 7px;
}
.drawer-role-tag.attack { color: var(--role-attack); }
.drawer-role-tag.defense { color: var(--role-defense); }

@media (max-width: 900px) {
  /* Stacked pair: the gutter goes back above the cards rather than stealing their width. Moved
     from 1099px to 900px with the rule above -- these two must agree, or the gutter goes
     horizontal while the cards are still side by side. */
  .commit-drawers { grid-template-columns: minmax(0, 1fr); }
  .commit-gutter { flex-direction: row; flex-wrap: wrap; gap: 12px 20px; }
}

/* ================================================================================
   G1 — NEON ILLUMINATION PRIMITIVES
   Sparring_Cards_Gold_Standard_Convergence_Package, sections 8 and 9.

   Built and verified in isolation (tests/browser/neon-probe.html + neon-check.js).
   NOTHING IN THE APP USES THESE YET — applying them to the four zones is G2. They are
   here rather than in the probe page so that what G2 adopts is exactly what G1 verified.

   The effect is an illuminated EDGE casting colour onto a dark surface. It is not a
   bright border and it is not one big blurry shadow; it needs several blur and opacity
   scales at once:

     1. core edge     1-2px    85-100% accent    keeps the shape sharp
     2. tight bloom   4-8px    35-65%            makes the edge look emissive
     3. ambient bleed 16-28px  10-24%            spills colour into the surrounding dark
     4. interior wash          4-8%              connects the frame to the panel surface
     5. active boost           +15-25% on 2      SELECTED / CURRENT ONLY

   Two structural rules, both of which are why flat borders happened in the first place:

   - THE GLOW OWNER MUST NOT CLIP. One element cannot both `overflow: hidden` its content
     and cast its own bleed — the bleed is outside the box it is clipping to. So the glow
     lives on an overflow-visible wrapper (.neon-frame) and clipping lives on an inner
     surface (.neon-surface).
   - `box-shadow` IS RECTANGULAR. It ignores clip-path entirely, so a trapezoid panel with
     a box-shadow gets a rectangular glow floating around a slanted shape. Clipped panels
     use `filter: drop-shadow`, which follows the RENDERED silhouette. That is .neon-clip.

   Accent colours are the approved Phase 2 palette, not the package's starting values —
   section 8 says to tune opacity before hue, and our hues are already signed off. The
   triplets exist because `rgb(R G B / a)` needs the channels unbundled. */
:root {
  --neon-blue: 63 143 255;    /* --sc-player   #3f8fff */
  --neon-red:  255 91 98;     /* --sc-opponent #ff5b62 */
  --neon-cyan: 51 210 225;    /* --sc-defense  #33d2e1 */
  --neon-gold: 246 185 41;    /* --sc-action   #f6b929 */
}

.neon-frame {
  --neon-rgb: var(--neon-blue);
  position: relative;
  isolation: isolate;     /* keeps the negative-z glow inside this element's stacking context */
  overflow: visible;      /* layer 2 and 3 live OUTSIDE the box; clipping here erases them */
  border-radius: var(--sc-radius-md);
}
/* Layers 1-3. Separate from the surface so the surface stays free to clip. */
.neon-frame::before {
  content: "";
  position: absolute;
  inset: -1px;
  z-index: -1;
  pointer-events: none;
  border: 1px solid rgb(var(--neon-rgb) / 0.92);
  border-radius: inherit;
  /* MEASURED, not copied. The package's starting values (3/9/22px) put the ambient band at
     ZERO by 12px out — see the first neon-check run. The reason is a units mismatch that is easy
     to miss: a CSS box-shadow blur radius B is roughly 2 standard deviations, so its visible
     reach is about B/2, while `drop-shadow(0 0 R)` uses R AS the deviation and reaches about
     twice as far for the same number. The reference file's clipped-panel stack is written in
     drop-shadow and its rectangular stack in box-shadow, so only the rectangles came out short.
     Blur radii below are the box-shadow numbers that deliver section 8's 4-8px and 16-28px
     bands as measured on screen. */
  box-shadow:
    0 0 4px  rgb(var(--neon-rgb) / 0.72),   /* layer 2: tight bloom   */
    0 0 14px rgb(var(--neon-rgb) / 0.40),
    0 0 44px rgb(var(--neon-rgb) / 0.22);   /* layer 3: ambient bleed */
}
/* Layer 4, plus the clipping the frame gave up. */
.neon-surface {
  position: relative;
  height: 100%;
  overflow: hidden;
  border-radius: inherit;
  background:
    linear-gradient(180deg, rgb(var(--neon-rgb) / 0.06), transparent 36%),
    var(--surface-1);
  box-shadow:
    inset 0 0 0 1px rgb(var(--neon-rgb) / 0.18),
    inset 0 0 18px  rgb(var(--neon-rgb) / 0.06);
}

.neon-blue { --neon-rgb: var(--neon-blue); }
.neon-red  { --neon-rgb: var(--neon-red); }
.neon-cyan { --neon-rgb: var(--neon-cyan); }
.neon-gold { --neon-rgb: var(--neon-gold); }

/* Layer 5. Restraint is the point: if everything is boosted the hierarchy is gone and the
   screen reads as a neon sign (section 9). Only the selected or current element gets this. */
.neon-frame.is-active::before,
.neon-frame[aria-selected="true"]::before,
.neon-frame[aria-pressed="true"]::before {
  border-color: rgb(var(--neon-rgb) / 1);
  /* Brighter, NOT bigger: the same three blur radii at ~+20% opacity. Growing the radii here is
     the "neon-sign wall" anti-pattern — neon-check asserts the ambient band does not widen. */
  box-shadow:
    0 0 4px  rgb(var(--neon-rgb) / 0.95),
    0 0 14px rgb(var(--neon-rgb) / 0.58),
    0 0 44px rgb(var(--neon-rgb) / 0.30);
}

/* ---- Clipped silhouettes (fighter corners, the Range trapezoid) ----
   --neon-shape is the polygon; set it per panel. The wrapper carries the drop-shadow stack
   and stays overflow-visible; ::before is an accent-coloured underlay outset by 1px, which
   is what shows through as the crisp core edge because the surface sits on top of it. */
.neon-clip {
  --neon-rgb: var(--neon-blue);
  --neon-shape: polygon(0 0, 100% 0, 100% 100%, 0 100%);
  position: relative;
  overflow: visible;
  /* Same light as .neon-frame, restated in drop-shadow units: drop-shadow's radius IS the
     deviation where box-shadow's blur is about twice it, so these radii are half the ones above
     at the same opacities. The fighter corners sit directly beside rectangular panels in Zone 1,
     and two shadow mechanisms in two unit conventions is how they end up reading as different
     materials. Measured difference from the package's own values is modest (~1.26x at 4px vs
     ~1.12x here) — neon-check's clip-vs-rect assertion is a guard against a gross mismatch, not
     a discriminator between these two stacks. Matched here because it is derivable, not because
     a test forced it. */
  filter:
    drop-shadow(0 0 2px  rgb(var(--neon-rgb) / 0.72))
    drop-shadow(0 0 7px  rgb(var(--neon-rgb) / 0.40))
    drop-shadow(0 0 22px rgb(var(--neon-rgb) / 0.22));
}
.neon-clip::before {
  content: "";
  position: absolute;
  inset: -1px;
  z-index: 0;
  pointer-events: none;
  clip-path: var(--neon-shape);
  background: rgb(var(--neon-rgb) / 0.92);
}
.neon-clip > .neon-clip-surface {
  position: relative;
  z-index: 1;
  height: 100%;
  clip-path: var(--neon-shape);
  background:
    linear-gradient(160deg, rgb(var(--neon-rgb) / 0.10), transparent 52%),
    var(--surface-1);
}

/* Section 7: the primary action is a lit control, not a flat yellow rectangle. */
.neon-action {
  color: #090b0f;
  background: linear-gradient(180deg, #ffd05a 0%, var(--sc-action) 58%, #d99712 100%);
  border: 1px solid #ffe08a;
  box-shadow:
    0 0 3px  rgb(var(--neon-gold) / 0.78),
    0 0 12px rgb(var(--neon-gold) / 0.28),
    inset 0 1px 0  rgb(255 255 255 / 0.42),
    inset 0 -2px 0 rgb(111 65 0 / 0.28);
}

/* Section 9: glow the container edge, never its contents — a text-shadow here would blur the
   text, which reads as an out-of-focus interface. Nothing above touches text rendering. */

/* ================================================================================
   G2 — THE FOUR ZONES ADOPT THE ILLUMINATION
   Gold-standard package sections 4, 7, 8, 9. The primitives are verified in G1
   (persistent_dashboard_handoff/reference/g1-neon-measurements.md); this section wires
   the real panels to them.

   MECHANISM. G1 built two: a ::before glow layer (`.neon-frame`) and a drop-shadow stack
   (`.neon-clip`), measured to emit the same light. G2 uses the drop-shadow one almost
   everywhere, because of what the existing DOM is:

     - `#shell-top`, `#workspace`, `#rail` and `#command-bar` all carry `overflow: hidden`,
       and a ::before glow is a CHILD — it gets clipped. That is section 9's number-three
       anti-pattern and the reason these panels look flat today.
     - `filter` output is NOT clipped by the element's own overflow. The filter applies to
       the element's already-rendered (and already-clipped) result, so the light lands
       outside the border box regardless. It also follows border-radius and clip-path
       silhouettes, which is the same reason section 8 mandates it for trapezoids.

   So this needs no wrapper element and no markup change, and one mechanism covers rounded
   rectangles and clipped corners alike. An ANCESTOR's overflow still clips, which is why
   `#shell-top` gives up its own `overflow: hidden` below — otherwise it would cut the
   fighter corners' glow off at Zone 1's boundary.

   RESTRAINT. Section 9: "every panel equally bright destroys hierarchy and creates a neon-
   sign wall." Persistent zone frames run at --neon-k 0.5 in a neutral chrome; the fighter
   corners, the role halves and the current action run at full strength in their semantic
   colours. Only the current primary action is boosted. */
:root {
  /* Neutral lit chrome for the persistent zone frames. Deliberately not one of the four
     semantic accents: those have to stay distinguishable (checklist, illumination §4). */
  --neon-chrome: 104 134 176;
}

/* ---- Zone 1: the fighter corners are the lit objects ---- */
/* Zone 1's own boundary stays nearly dark — in the approved mockup the blue and red corners
   ARE the frame. It only has to stop clipping them. */
#shell-top {
  overflow: visible;
  border-color: rgb(var(--neon-chrome) / 0.22);
}

/* CLIP-PATH ON THE GLOW OWNER CLIPS THE GLOW. This is section 9's overflow trap wearing a
   different hat, and it cost a full measurement cycle: in the rendering model `filter` is applied
   to the element FIRST and `clip-path` clips the filtered result, so an element carrying both
   produces a drop-shadow and then throws away every pixel of it that lies outside the shape —
   which is all of it. Measured 0.00 accent alpha from the first pixel outside the panel.

   So the silhouette moves off the glow owner and onto its pseudo-elements, exactly as the
   reference CSS splits `.neon-clip` (filter, unclipped) from `.neon-clip-surface` (clip-path).
   The panel element keeps the filter and no shape of its own; ::before draws the accent
   silhouette and ::after the surface inset 1px inside it, so the accent reads as the crisp core
   edge all the way round including the slant. The drop-shadow then follows the rendered alpha,
   which is the ::before shape. */
.fighter-panel {
  --neon-k: 1;
  border: none;
  background: none;
  clip-path: none;
  filter:
    drop-shadow(0 0 2px  rgb(var(--neon-rgb) / calc(0.72 * var(--neon-k))))
    drop-shadow(0 0 7px  rgb(var(--neon-rgb) / calc(0.40 * var(--neon-k))))
    drop-shadow(0 0 22px rgb(var(--neon-rgb) / calc(0.22 * var(--neon-k))));
}
/* Layer 1, the core edge. Replaces the 3px accent bar these panels used to carry: the whole
   silhouette is lit now, and a bar plus a lit edge is two treatments for one signal. */
.fighter-panel::before,
.fighter-panel.p1::before,
.fighter-panel.p2::before {
  content: ''; position: absolute; inset: 0; left: 0; right: 0; width: auto; z-index: -2;
  background: rgb(var(--neon-rgb) / 0.92);
  box-shadow: none;
  pointer-events: none;
}
/* The surface. Polygon percentages resolve against this smaller box, so the accent behind it
   stays a ~1px rim on every edge, slants included. */
.fighter-panel::after {
  content: ''; position: absolute; inset: 1px; z-index: -1;
  background: var(--hud-panel);
  pointer-events: none;
}
/* The two layers sit in the negative z-band of the panel's own stacking context (which the
   filter already creates), so they paint above its background and below every child without any
   child having to opt in. The obvious alternative -- .fighter-panel > * { position: relative } --
   is a trap: '.range-panel > *' is specificity (0,1,0), identical to '.rp-figure', so on the Range
   panel it would have come last and turned the two absolutely-positioned silhouettes into
   in-flow boxes. */

/* Spelled out per side, not left to `.fighter-panel { clip-path: none }` above: the original
   shape rules are `.fighter-panel.p1` / `.p2` at specificity (0,2,0), so a bare `.fighter-panel`
   reset at (0,1,0) loses and the clip stays on the glow owner. Silently — the panels look
   correct and cast no light. Third time this codebase has been bitten by two rules whose
   specificity ordering is not what the source order suggests. */
.fighter-panel.p1,
.fighter-panel.p2 { clip-path: none; }
/* Style, not side. --p1-rgb / --p2-rgb are set on the root from the deck's style by
   UIShell.applyFighterNeon; the literals remain as the fallback for a board with no bout yet. */
.fighter-panel.p1 { --neon-rgb: var(--p1-rgb, var(--neon-blue)); }
.fighter-panel.p2 { --neon-rgb: var(--p2-rgb, var(--neon-red)); }
.fighter-panel.p1::before,
.fighter-panel.p1::after { clip-path: polygon(0 0, 100% 0, calc(100% - var(--hud-cut)) 100%, 0 100%); }
.fighter-panel.p2::before,
.fighter-panel.p2::after { clip-path: polygon(0 0, 100% 0, 100% 100%, var(--hud-cut) 100%); }

/* The Range trapezoid is shared ground between the two corners, so it is lit in neutral chrome
   and at half strength — bright enough to read as the same material, quiet enough that the
   fighters still own the zone. Same split: shape on the pseudo-elements, filter on the element. */
.range-panel {
  --neon-rgb: var(--neon-chrome);
  --neon-k: 0.55;
  background: none;
  clip-path: none;
  filter:
    drop-shadow(0 0 2px  rgb(var(--neon-rgb) / calc(0.72 * var(--neon-k))))
    drop-shadow(0 0 7px  rgb(var(--neon-rgb) / calc(0.40 * var(--neon-k))))
    drop-shadow(0 0 22px rgb(var(--neon-rgb) / calc(0.22 * var(--neon-k))));
}
.range-panel::before,
.range-panel::after {
  content: ''; position: absolute; pointer-events: none;
  clip-path: polygon(var(--hud-cut) 0, calc(100% - var(--hud-cut)) 0, 100% 100%, 0 100%);
}
.range-panel::before { inset: 0; z-index: -2; background: rgb(var(--neon-rgb) / 0.6); }
.range-panel::after  { inset: 1px; z-index: -1; background: var(--hud-panel); }

/* ---- Zones 2, 3 and 4: restrained lit chrome ---- */
#workspace, #rail, #command-bar {
  --neon-rgb: var(--neon-chrome);
  --neon-k: 0.5;
  border-color: rgb(var(--neon-rgb) / 0.55);
  filter:
    drop-shadow(0 0 2px  rgb(var(--neon-rgb) / calc(0.72 * var(--neon-k))))
    drop-shadow(0 0 7px  rgb(var(--neon-rgb) / calc(0.40 * var(--neon-k))))
    drop-shadow(0 0 22px rgb(var(--neon-rgb) / calc(0.22 * var(--neon-k))));
}
/* Layer 4: the interior wash that ties the frame to the surface. */
/* Halved: this is a tint over the two largest surfaces on the board, and it was part of the
   35% colour coverage. The frame's own light is what should read, not a wash across the middle. */
#workspace, #rail { box-shadow: inset 0 0 24px rgb(var(--neon-rgb) / 0.028); }

/* Zone 4 is the decision-confidence strip, so it takes the accent when the action is live and
   drops back to chrome while waiting. Same three radii, so the bar brightens without growing. */
#command-bar.ready {
  --neon-rgb: var(--neon-gold);
  --neon-k: 0.8;
  border-color: rgb(var(--neon-gold) / 0.75);
}
#command-bar.is-error { --neon-rgb: var(--neon-red); --neon-k: 0.7; }

/* ---- Role halves and the current action ---- */
/* The Attack/Defense halves are where red and cyan carry meaning, so they get real light — but
   only the half currently holding a role. An unassigned pair stays quiet. */
.drawer-panel.active-role.role-attack  { --neon-rgb: var(--neon-red);  }
.drawer-panel.active-role.role-defense { --neon-rgb: var(--neon-cyan); }
.drawer-panel.active-role {
  --neon-k: 0.65;
  box-shadow: inset 0 0 0 1px var(--panel-role), inset 0 0 20px rgb(var(--neon-rgb) / 0.07);
  filter:
    drop-shadow(0 0 2px  rgb(var(--neon-rgb) / calc(0.72 * var(--neon-k))))
    drop-shadow(0 0 7px  rgb(var(--neon-rgb) / calc(0.40 * var(--neon-k))));
}

/* Section 8 layer 5. The chosen Intent is the one selected thing on the stage. */
.intent-card[aria-pressed="true"] {
  --neon-rgb: var(--neon-gold);
  --neon-k: 0.9;
  filter:
    drop-shadow(0 0 2px  rgb(var(--neon-rgb) / calc(0.72 * var(--neon-k))))
    drop-shadow(0 0 7px  rgb(var(--neon-rgb) / calc(0.40 * var(--neon-k))))
    drop-shadow(0 0 22px rgb(var(--neon-rgb) / calc(0.22 * var(--neon-k))));
}

/* Section 7: the primary action is a lit bevelled control, not a flat yellow rectangle. */
button.primary:not(:disabled) {
  color: #090b0f;
  background: linear-gradient(180deg, #ffd05a 0%, var(--sc-action) 58%, #d99712 100%);
  border: 1px solid #ffe08a;
  box-shadow:
    0 0 3px  rgb(var(--neon-gold) / 0.78),
    0 0 12px rgb(var(--neon-gold) / 0.28),
    inset 0 1px 0  rgb(255 255 255 / 0.42),
    inset 0 -2px 0 rgb(111 65 0 / 0.28);
}
/* `filter: brightness()` on hover would REPLACE the drop-shadow stack on anything that owns
   one; the CTA's light is box-shadow, so this one is safe — but it is the reason nothing else
   here uses a filter for a hover state. */
button.primary:hover:not(:disabled) { filter: brightness(1.08); }

/* Section 9 / checklist: reduced motion keeps the illumination and drops movement only. None
   of the light above animates, so there is nothing here to disable — recorded so that the next
   person does not add a pulse. */

/* ================================================================================
   G3 — THE SHELL FITS THE 1024x576 REFERENCE
   Gold-standard package section 3 and acceptance_criteria.json.

   The package moves the reference viewport from 1024x768 to 1024x576 and asserts no body
   scroll there. We failed that outright: the document measured 680px against a 576px
   viewport. Not a content problem — `#fight-shell { min-height: 680px }` was forcing the
   board taller than the screen, and `height: 100dvh` never got a chance to bind. The
   reference CSS uses 560px; this uses the same, which does not bind at 576.

   The three zone dimensions are re-clamped to the criteria file's targets. All three were
   authored against a 768px-tall floor and none of them scaled down to 576.
   ================================================================================ */
:root {
  /* Zone 1: PROPORTIONAL, per the approved reading of section 3 (~24-28% of viewport
     height), not the flat 150-170px the same paragraph also states. Those two agree only
     at the reference height. The mockup's own Zone 1 spans ~230px of a 941px board — 24% —
     so the proportion is what the design actually encodes.
       576 -> 150   768 -> 192   900 -> 225   1080 -> 270
     This GROWS Zone 1 at 1080, where it had shrunk to 21% and read as a header rather than
     section 4's stage-set. The stage pays for it; G4 is what makes that affordable. */
  --sc-hud-h: clamp(150px, 25dvh, 300px);

  /* Zone 4: acceptance_criteria.json says 56-70px. Was clamp(72px, 9dvh, 94px) — over the
     ceiling at every viewport, and 94px at 1080 was a fifth of the difference between
     Commit fitting and not. */
  --sc-command-h: clamp(56px, 6.5dvh, 70px);
}

#fight-shell {
  /* 680px was taller than the reference viewport itself, so the board could not fit by
     construction and `height: 100dvh` was dead code below 680. */
  min-height: 560px;
}

/* The old height band existed to pin Zone 1 to a flat 192px below 820px tall. The clamp above
   passes through 192px at exactly 768, so the pin is redundant — but everything else in that
   band (a tighter brand bar, a smaller portrait, tighter panel padding) is what lets the zone
   hold its content at those heights, and it is still needed. Only the pin is gone; see the
   `:root` deletion in the band itself. */

/* ---- Below ~660px tall: Zone 1 has ~150px and must still carry every element section 4
   requires. Portrait DETAIL is what gives way, not portrait mass and not a whole metric —
   the plan's own guidance is to reduce detail before abandoning the structure. ---- */
@media (max-height: 660px) {
  #brand-bar { padding: 2px 14px; }
  #brand-bar h1 { font-size: 0.82rem; }
  .brand-mark { width: 17px; height: 17px; }
  #fight-hud { padding: 3px 12px 5px; gap: 6px; }
  .fighter-panel { padding: 5px 12px; }
  .fp-portrait { width: 54px; min-height: 58px; margin: -5px 0 -5px -12px; }
  .p2 .fp-portrait { margin: -5px -12px -5px 0; }
  .fp-name { font-size: 0.9rem; }
  .fp-body { gap: 1px; }
  .fp-label { font-size: 0.52rem; }
  .fp-counts { font-size: 0.52rem; gap: 10px; }
  .stam-track, .ff-axis { margin-top: 0; }
  .fp-chips { height: 15px; }
  /* Measured, not guessed: at 150px the panel was 8px over. These four lines are where the 8px
     was — the rhythm between the metrics, not any element's content. Same lesson as D0, where a
     3px portrait margin mismatch nearly bought 18px of stage height to pay for it. */
  .fp-metric { column-gap: 8px; }
  .fp-label { line-height: 1.05; }
  .fp-identity { line-height: 1.1; }
  .fp-style { font-size: 0.58rem; }
  .range-panel { padding: 4px 12px 6px; }
  .rp-head { margin-bottom: 2px; }
}

/* ---- Positioning Intent: a compact secondary band ----
   Section 5 puts the three choices in "roughly 86-100 px". Measured 151px at the reference,
   125 at 1366 and 163 at 1920 — and the 1920 figure is the tell: the band was getting TALLER
   on a bigger screen because the relationship triangle was sized off viewport width and had
   grown past the cards it stands beside.

   Two changes. The triangle now takes its size from the row instead of from the viewport, so
   it can never drive the band's height — it fills whatever the choices need and stops. And the
   icon moves onto the name's line: it was a 20px row plus a gap, for a glyph that reads
   perfectly well beside the word it belongs to. */
.range-strip { padding: 7px 14px; margin-bottom: 14px; }
.intent-layout { align-items: stretch; }
.intent-lede { align-self: center; }
.intent-relationship { align-self: stretch; }
.intent-triangle {
  width: auto; height: 100%;
  max-width: clamp(96px, 9vw, 168px);
  /* Fallback for any context where the percentage has no definite parent height to resolve
     against: without it the SVG would collapse to zero rather than to something too big. */
  min-height: 72px;
}
.intent-card {
  display: grid;
  grid-template-columns: auto auto;
  grid-template-rows: auto auto auto;
  justify-content: center;
  align-content: center;
  column-gap: 7px; row-gap: 2px;
  padding: 6px 8px;
}
.intent-name { grid-column: 1; grid-row: 1; }
.intent-icon { grid-column: 2; grid-row: 1; width: 15px; height: 15px; align-self: center; }
.intent-blurb { grid-column: 1 / -1; grid-row: 2; }
.intent-beats { grid-column: 1 / -1; grid-row: 3; }

/* ---- Zone 4 at 56px ----
   The bar now has 56-70px instead of 72-94, and at the reference viewport the four-part tactical
   sentence wrapped to a second line that the bar then clipped. `.cmd-body { overflow-y: auto }`
   turned a clipped line into a scrollbar inside the command bar, which is worse: the one strip
   that must always read at a glance became something you scroll.

   So the sentence stops wrapping and scrolls sideways instead, exactly as Zone 1's status chips
   already do (`.fp-chips`) — the one element on the board that is genuinely unbounded gets a
   fixed-height single track rather than a block that grows. Nothing is hidden and nothing moves. */
.tactical-sentence {
  flex-wrap: nowrap;
  overflow-x: auto;
  overflow-y: hidden;
  scrollbar-width: none;
  padding-bottom: 1px;
}
.tactical-sentence::-webkit-scrollbar { display: none; }
.tactical-sentence > .ts-bit { flex-shrink: 0; }
/* Same for the unresolved-requirement list, which is the taller of the two things this slot
   holds — four pills at Commit before any choice is made. */
#command-bar .unresolved-list { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; }
#command-bar .unresolved-list::-webkit-scrollbar { display: none; }
#command-bar .unresolved-list > * { flex-shrink: 0; }
/* With both tracks bounded there is nothing left to scroll vertically, and a vertical scrollbar
   here would only ever mean something has silently outgrown the zone. */
#command-bar .cmd-body { overflow-y: hidden; }

@media (max-height: 660px) {
  #command-bar { padding: 0 12px; }
  #command-bar .cmd-instruction { font-size: 0.76rem; }
  #command-bar .cmd-detail { margin-top: 1px; }
  .tactical-sentence { gap: 4px 14px; font-size: 0.78rem; }
}

@media (max-height: 660px) {
  /* The blurb is the compressible part of this band — the name, the icon and BEATS X carry the
     mechanic; the blurb explains it. Held to one line here rather than dropped, so nothing
     becomes unavailable: the full text stays in the title attribute and the card's own detail. */
  .intent-blurb {
    display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical;
    overflow: hidden;
  }
  .range-strip { padding: 5px 12px; margin-bottom: 10px; }
}

/* ================================================================================
   G4 — THE STAGE SIZES THE CARDS, NOT THE OTHER WAY ROUND
   Gold-standard package section 5.

   D0-D4 sized Zone 2 from its content: each card was as tall as its text, and the stage
   grew or scrolled to suit. Every lever that made the CONTENT shorter was measured and
   spent (d4-measurements.md), and the conclusion was that no-scroll only held at
   ~1600x1080. That conclusion was true of a content-sized layout and only of one.

   Section 5 inverts it. The stage gets a fixed share of the viewport, the pair fills the
   flexible row, and the cards fit into what they are given. The lever D4 never tried was
   not another way to shorten the text — it was to stop the text deciding.
   ================================================================================ */

/* The Commit stage is a fixed three-row grid inside the workspace, not a column that grows.
   `:has()` scopes it to Commit without a phase class on the scroller, which is shared with
   Select and Resolution and must keep scrolling for both. */
#workspace-scroll:has(.commit-stage) {
  overflow: hidden;
  display: grid;
  grid-template-rows: minmax(0, 1fr);
}
.screen.commit-stage {
  min-height: 0;
  display: grid;
  /* heading block · THE PAIR · intent band. Three rows, three children -- see the comment on the
     markup: conditional direct children shifted this mapping and put the band in the flexible row. */
  grid-template-rows: auto minmax(0, 1fr) auto;
  gap: 0;
}
.commit-stage > .commit-top { min-width: 0; }
.commit-stage #arbitration-box:empty { display: none; }

/* The pair is the dominant object (section 5), so it takes the flexible row and its cards
   take its full height. `min-height: 0` on every level is load-bearing, not defensive: a
   grid child's default `min-height: auto` is its content height, which is exactly the
   "content decides" behaviour this section exists to stop. */
.commit-stage .commit-drawers {
  min-height: 0; align-items: stretch;
  /* The pair is itself a grid, and its own row was AUTO — sized to the tallest card. So the pair
     box obeyed the stage row (173px) while its row inside was 217px, and the cards painted 44px
     past the bottom of their own container, straight over the Intent band. Nothing reported an
     overflow: the boxes each measured consistently, they were simply laid out on top of each
     other. `minmax(0, 1fr)` makes the inner row take its size from the box rather than the cards. */
  grid-template-rows: minmax(0, 1fr);
}
.commit-stage .drawer {
  min-height: 0; height: 100%; margin-bottom: 0;
  display: flex; flex-direction: column;
}
.commit-stage .drawer-panels { min-height: 0; flex: 1; align-items: stretch; }
/* NOT `min-height: 0`. A flex item's default `min-height: auto` is its min-content height, and
   that floor is the only thing stopping a squeezed column from crushing panels INTO each other:
   with the floor removed the panels measured 27px shorter than their content and the text
   overlapped the panel below, while the drawer reported only 5px over. Every other level of this
   stage needs `min-height: 0` to stop content dictating size; this one must not have it, because
   here content dictating size is the correct answer and the drawer is what absorbs the rest. */
.commit-stage .drawer-panel { min-height: auto; }
.commit-stage #range-strip-box { flex-shrink: 0; }
.commit-stage .range-strip { margin-bottom: 0; }

/* ---- The rules-text disclosure ----
   Approved: each role panel keeps its label, target grid, Power/Guard value and gloss always
   visible; the conditional rules text collapses behind a per-panel control. Keyed on viewport
   HEIGHT, because that is the constraint — a wide short screen has the same problem as a
   narrow one, and D0 and D4 both mis-diagnosed this as a width problem first. */
.panel-rules { min-height: 0; }
.rules-toggle {
  display: none;               /* roomy screens: no control, text simply shows */
  align-items: center; gap: 5px;
  margin-top: 6px; padding: 2px 8px;
  font-size: 0.62rem; font-weight: 700; letter-spacing: 0.07em; text-transform: uppercase;
  color: var(--panel-role, var(--text-dim));
  background: none; border: 1px solid currentColor; border-radius: 999px;
  cursor: pointer;
}
.rules-toggle::before { content: '\25B8'; font-size: 0.7em; }
.panel-rules.is-open .rules-toggle::before { content: '\25BE'; }
.rules-toggle:hover { background: color-mix(in srgb, currentColor 12%, transparent); }

@media (max-height: 1000px) {
  /* The setup deck preview gets the same treatment, because it is the same technique drawer under
     a tighter constraint: it shares Zone 2 with a 28-row list, so it has LESS room than Commit,
     and it was the one place where the disclosure did not apply. Scoping the rule to
     `.commit-stage` made one component behave two ways depending on where it was mounted, and the
     place it stayed expanded was the place that could least afford it -- the preview pane still
     scrolled 73-171px to reach the stats, which is the complaint this fix exists to answer.
     Zone 3's card inspector is deliberately NOT included: the rail is tall and narrow and has the
     height to show the text outright. */
  .commit-stage .rules-toggle,
  .setup-detail .rules-toggle { display: inline-flex; }
  .commit-stage .panel-rules:not(.is-open) > .effect-text,
  .setup-detail .panel-rules:not(.is-open) > .effect-text { display: none; }
}

/* ---- The card must actually FIT the row it is given ----
   Making the stage a fixed grid does not by itself make the content fit — it moves the overflow
   from the stage into the card, where `.drawer { overflow: hidden }` hides it. Measured 195px of
   card content clipped at the reference viewport while `#workspace-scroll` reported 0 over. That
   is the D0 chip-row bug exactly: a hidden overflow inside a child is not an overflow of the
   parent, so the instrument reads clean while the screen is cut in half.

   Two things follow. The card scrolls rather than clips, so the failure mode is recoverable
   instead of silent — and because that is a failure mode and not a design, geometry-check asserts
   it never engages at any required viewport. And the content is compacted, by height band, until
   it does not. */
.commit-stage .drawer { overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; }

/* Under ~1000px tall: the rules text is already behind its toggle. Trim the frame around it. */
@media (max-height: 1000px) {
  #workspace-scroll:has(.commit-stage) { padding: 10px 12px; }
  .commit-stage .workspace-head { margin-bottom: 6px; }
  .commit-stage .workspace-head h2 { font-size: 1rem; }
  .commit-stage .drawer { padding: 10px 12px; }
  .commit-stage .drawer-header { gap: 4px; margin-bottom: 2px; }
  .commit-stage .drawer-title-row h3 { font-size: 1rem; }
  .commit-stage .drawer-gloss { margin: 1px 0 5px; }
  .commit-stage .drawer-panels { gap: 10px; margin-top: 7px; }
  .commit-stage .drawer-panel {
    padding: 8px 10px;
    /* 196px was tuned in D4 against a card that had the whole stage width. Under the two-column
       pair it forces Attack and Defense to STACK, which doubles the panel block's height — and
       stacking cost more than the wrapped stats row that a narrower floor causes. At 1366 the card
       has ~349px of inner width, so 150 lets them sit side by side and 196 did not. */
    min-width: 150px;
  }
  .commit-stage .panel-label { margin-bottom: 5px; }
}

/* Under ~740px tall: 1280x720 sits between the two bands and was 17px over. The move gloss is
   the cheapest thing to drop — it is flavour prose the card's own name carries, and it is still in
   the rail telegraph and the card detail. Set at 740 rather than 768 deliberately: 1366x768 fits
   with the gloss, and taking it there would be a loss for no gain. */
@media (max-height: 740px) {
  .commit-stage .drawer-gloss { display: none; }
}

/* Under ~660px tall (the 1024x576 reference): the pair has ~200px for two cards. What goes is
   DECORATION and RESTATEMENT, in that order — never a number you compare cards on.
     - the sub-heading, which repeats what the ROLE gutter beside it already says;
     - the move gloss, which is flavour the card's name carries;
     - the family/style ownership tags, still present in the rail's telegraph and the card detail.
   Target grids, Power, Guard, Tempo and the role tag all stay. */
@media (max-height: 660px) {
  #workspace-scroll:has(.commit-stage) { padding: 7px 9px; }
  .commit-stage .workspace-sub { display: none; }
  .commit-stage .workspace-head { margin-bottom: 4px; }
  .commit-stage .workspace-head h2 { font-size: 0.9rem; }
  .commit-stage .drawer { padding: 5px 8px; }
  /* The gutter was 44px over its own column and painting the second Tempo radio into the Intent
     band. It did not spill — its box is stretched to the row, so the box measured clean while the
     content inside it did not fit. Only `scrollHeight` on the gutter itself showed it, which is
     why the probe now measures both. */
  .commit-gutter { gap: 6px; padding-top: 0; }
  .commit-gutter .gutter-group { gap: 2px; }
  .commit-gutter .gutter-label { font-size: 0.54rem; }
  /* NOT hidden. D4 added "One attacks, one defends." precisely so both role names are present in
     text before either is chosen -- role must never be carried by colour alone. It shrinks; it
     does not go. The 12px it would have saved came out of the gutter's own gaps instead. */
  .commit-gutter .gutter-hint { font-size: 0.52rem; line-height: 1.1; }
  .commit-stage .seg-toggle.vertical label { padding: 3px 6px; }
  .commit-stage .gutter-card { font-size: 0.66rem; }
  .commit-stage .gutter-role { font-size: 0.52rem; }
  .commit-stage .commit-gutter .tempo-choice { padding: 2px 7px; font-size: 0.66rem; }
  .commit-stage .drawer-gloss { display: none; }
  .commit-stage .drawer-ownership { display: none; }
  /* Back to a ROW, reversing D4's column. D4 made this a column because the title and the
     controls competed for the same width and both wrapped — but the controls at this height are
     one small role tag, the ownership tags having gone above. A whole 21px row for one tag. */
  .commit-stage .drawer-header {
    flex-direction: row; align-items: baseline; justify-content: space-between;
    gap: 8px; margin-bottom: 0;
  }
  .commit-stage .drawer-controls { row-gap: 0; flex-shrink: 0; }
  .commit-stage .drawer-title-row h3 { font-size: 0.92rem; }
  .commit-stage .drawer-panels { gap: 6px; margin-top: 4px; }
  .commit-stage .drawer-panel { padding: 5px 7px; }
  .commit-stage .panel-label { font-size: 0.66rem; margin-bottom: 3px; }
  .commit-stage .panel-stats { gap: 4px 6px; margin-bottom: 3px; }
  .commit-stage .stat-circle { width: 27px; height: 27px; font-size: 0.8rem; border-width: 1.5px; }
  .commit-stage .stat-circle span { font-size: 0.42rem; }
  /* 100px, not the 150px used from 660-1000. The pair has ~223px of inner card width here, so two
     100px panels sit SIDE BY SIDE where two 150px panels stack — and stacking is what the 87px of
     hidden card scroll at this viewport was made of. The stats row wraps to two lines inside a
     100px panel, which costs ~14px; stacking cost ~88. Measured both ways rather than reasoned. */
  .commit-stage .drawer-panel { min-width: 100px; }
  /* The last 8px at the reference viewport. Both panels are stacked here, so each of these is
     paid twice — which is also why they were worth finding rather than widening a clamp. */
  .commit-stage .rules-toggle { margin-top: 2px; padding: 1px 7px; }
  .commit-stage .drawer-panels { gap: 5px; margin-top: 3px; }
  /* Sized against the FILLED draft, not the empty one. With a role assigned the Attack panel's
     label becomes "ATTACK · Punch" and wraps inside a 100px panel, so the state the player
     actually sits in was 9px taller than the state the measurement was taken in. */
  .commit-stage .panel-label { line-height: 1.15; }
  .commit-stage .panel-stats { margin-bottom: 3px; }
  /* Attack and Defense cannot sit side by side inside a 223px-wide card — the stats row alone
     needs more than 104px. They stack here, and that is a real departure from section 5's
     "Attack and Defense side by side INSIDE each card", recorded in g4-content-fit.md. Section 5's
     other requirement, both CARDS side by side, is the one that is kept: it is the comparison the
     player is actually making.

     The stacking is left to `flex-wrap` in the default ROW direction, which is what it already
     does when two 150px panels will not fit in 223px. An explicit `flex-direction: column` looks
     equivalent and is not: `.drawer-panels` also carries `flex-wrap: wrap`, and a wrapping COLUMN
     with a bounded height wraps into a second COLUMN — the Defense panel left the card entirely,
     sideways, and every overflow measurement still read 0 because nothing had overflowed; it had
     been laid out somewhere else. Only the screenshot showed it. */
}

/* ================================================================================
   G5 — ZONE 3 DENSITY
   Gold-standard package section 6: "the current rail structure is close, but too visually
   empty. Use the available vertical space for compact event lines and result pills."

   The structural half of that is the `#rail-body { grid-row: 2 }` fix above — the history
   panel was never given the rail's height, so it could not fill it. The rest is the rows.
   ================================================================================ */

/* One line, not two stacked. */
.rail-head-row {
  flex-direction: row; align-items: baseline; justify-content: space-between; gap: 10px;
}
/* Tighter event rows. Section 6 also says "avoid tiny narrow text columns" and "technique names
   and key results should remain readable" — so the prose keeps its size and its line-height, and
   what comes out is the padding between rows and the separator's weight. */
.rail-panel.history .rh-row { padding: 5px 0; gap: 7px; }
.rail-panel.history .rh-prose { line-height: 1.35; }
.rail-panel.history .rh-group { padding: 6px 0 4px; }
.rail-panel.history .rh-pills { gap: 2px; }

/* ================================================================================
   SETUP DECK BROWSER — the preview is pinned, the list scrolls

   The card detail was appended after the deck list, so it rendered a full list-length below
   the fold: at 1024x576 the preview started 1061px past the bottom of Zone 2 and reaching it
   meant scrolling 1447px past all 28 rows to read the stats of the card you had just clicked.

   Same shape of fix as G4's Commit stage: Zone 2 becomes a fixed grid, the variable-length
   region scrolls inside its own row, and the thing you are reading is pinned where you can
   see it.
   ================================================================================ */
#workspace-scroll:has(.setup-preview) {
  overflow: hidden;
  display: grid;
  grid-template-rows: minmax(0, 1fr);
}
.screen.setup-preview {
  min-height: 0;
  display: grid;
  /* heading · deck switch · THE LIST · pinned preview.
     The preview track is `fit-content(45%)`, not `auto` with a `max-height: 50%` on the pane:
     a percentage max-height resolves against the grid AREA, whose height comes from the content
     it is trying to cap, so it never bound anything and the detail took the whole stage -- the
     list measured 0px tall at 1024x576 and 1366x768. `fit-content()` caps the TRACK against the
     container, and still collapses to zero when the pane is empty.

     Stated accurately: at the viewports we support the 45% limit is NOT usually the binding term,
     because `fit-content()` still respects the track's automatic minimum and a technique drawer's
     min-content height (209px compacted, 350px full) is larger. It is a ceiling for a
     pathologically tall card, not the thing shaping this layout. What shapes it is the 96px floor
     on the list below. */
  grid-template-rows: auto auto minmax(96px, 1fr) fit-content(55%);
}
.setup-preview .deck-list {
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
  align-content: start;
}
/* The pane never takes more than half the stage, so the list it belongs to stays usable. Its own
   scroll is the release valve -- the detail card is a full technique drawer and is taller than
   half a 576px-tall stage on its own. */
.setup-detail {
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
}
.setup-detail:empty { display: none; }
.setup-detail .rail-detail { margin-bottom: 0; margin-top: 12px; }
/* `.rail-detail` stacks its Attack and Defense panels, which is right in Zone 3 -- the rail is
   ~430px wide and two panels side by side there would be a pair of slits. The setup preview reuses
   the same component in Zone 2, where it has ~800px, and inherited the narrow-column assumption
   with it: both panels rendered full width at the same x, doubling the card's height in the one
   place that had a scrolling list to share the space with. Put back in a row here. */
.setup-detail .drawer-panels { flex-direction: row; flex-wrap: wrap; }
.setup-detail .drawer-panel { min-width: 180px; }
/* The pane sits directly under the row you clicked, and that row already carries the family and
   style tags. Repeating them costs a line of the one thing this pane is short of. */
.setup-detail .drawer-ownership { display: none; }
.setup-detail .drawer-header { margin-bottom: 2px; }

/* Which row you are previewing, in the list as well as in the pane. */
.deck-row.on {
  border-color: var(--accent);
  background: rgb(var(--neon-gold) / 0.10);
}

/* Short viewports: the preview pane is the same technique drawer Commit uses, so it gets the
   same compaction G4 measured for it rather than a second, differently-tuned copy. */
@media (max-height: 820px) {
  /* The one-line move gloss is flavour the card's own name carries, and the pane is the tightest
     place it appears. 820 rather than 740 because 1366x768 needed it too. */
  .setup-detail .drawer-gloss { display: none; }
}
@media (max-height: 660px) {
  .setup-preview .workspace-head { margin-bottom: 4px; }
  .setup-preview .workspace-head h2 { font-size: 0.95rem; }
  .setup-preview .workspace-sub { display: none; }
  /* A lower floor for the list here. At 1024x576 there are ~209px to split, and holding the list
     at 96 left the 131px card 44px short -- which is the original complaint in miniature. The
     card you asked to see wins; the list is scrollable either way. */
  .screen.setup-preview { grid-template-rows: auto auto minmax(72px, 1fr) fit-content(64%); }
  .setup-detail .drawer { padding: 7px 9px; }
  .setup-detail .drawer-ownership { display: none; }
  .setup-detail .drawer-panels { gap: 8px; margin-top: 5px; }
  .setup-detail .drawer-panel { padding: 6px 8px; min-width: 120px; }
  .setup-detail .panel-label { font-size: 0.66rem; margin-bottom: 3px; }
  .setup-detail .panel-stats { gap: 5px 7px; margin-bottom: 4px; }
  .setup-detail .stat-circle { width: 28px; height: 28px; font-size: 0.8rem; }
  .setup-detail .effect-text { font-size: 0.74rem; line-height: 1.3; }
}

/* ================================================================================
   H — ZONE 1: PORTRAIT MASS, RANGE ORDER, STYLE COLOUR, HEX GROUND

   Four related corrections, all measured against the approved mockup:

   1. The portrait was 13-15% of the fighter panel's width (84px of 642 at 1920). In the
      mockup it runs x=14..195 in a panel spanning x=14..540 -- about 34%. Section 4 is
      explicit that this mass is what makes Zone 1 read as a stage-set rather than a header,
      and that the geometry should be reserved even while the art is a silhouette.
   2. The range track rendered ABOVE the two flanking figures; the mockup puts the figures in
      the upper band with LONG / POCKET / CLINCH along the bottom.
   3. The figures sat at the panel's edges (left/right 6%) rather than over the LONG and
      CLINCH nodes -- about 28px outward of each.
   4. Colour comes from the deck's STYLE (approved), including the portrait's corner bloom
      and the Stamina track. The tokens are set on the root by UIShell.applyFighterNeon.
   ================================================================================ */

/* ---- 1. Portrait mass ---- */
.fp-portrait {
  /* Proportional, not a fixed 84px: the panel scales with the viewport and the portrait has to
     scale with it or the share drifts. Floor keeps it recognisable on a short board; the ceiling
     stops it eating the metrics on a very wide one. */
  width: clamp(104px, 34%, 300px);
  min-height: 0;
  /* No opaque plate behind the portrait. Deck art is a cut-out on transparency, and a filled
     rectangle here would hide both the hex ground and the style-coloured corner bloom that are
     meant to show THROUGH it -- the whole point of the treatment. The placeholder silhouette
     reads fine on the panel's own surface. */
  background: none;
}
/* The bloom section 4 asks for: "a low-opacity radial wash behind the portrait; do not brighten
   the entire panel." Anchored to the OUTER upper corner -- the one away from the centre -- so the
   two corners light from opposite sides and frame the Range between them. Colour is the fighter's
   own --neon-rgb, which is their deck's style. */
.fp-portrait::after {
  content: ''; position: absolute; inset: 0; pointer-events: none;
  background: radial-gradient(120% 90% at 0% 0%,
    rgb(var(--neon-rgb) / 0.30) 0%, rgb(var(--neon-rgb) / 0.10) 38%, transparent 72%);
}
.p2 .fp-portrait::after {
  background: radial-gradient(120% 90% at 100% 0%,
    rgb(var(--neon-rgb) / 0.30) 0%, rgb(var(--neon-rgb) / 0.10) 38%, transparent 72%);
}
/* The BOX is the reserved geometry section 4 asks for; the silhouette is a placeholder and is
   deliberately not scaled to fill it. Stretched to 300px it stopped reading as a figure and became
   a coloured blob -- which would misrepresent how the zone looks once real art lands. Real art
   (.portrait-img) does fill the box, because that is the composition being reserved for. */
.portrait-silhouette {
  width: auto; height: min(100%, 132px);
  opacity: 0.42;
  margin: 0 auto;
}
/* Sits under the reserved box's centre, not over the placeholder's head. */
.portrait-initials { align-items: flex-end; padding-bottom: 8%; font-size: 1.15rem; opacity: 0.75; }

/* ---- 2 and 3. The Range panel ---- */
.rp-stage {
  position: relative;
  flex: 1; min-height: 0;
  display: flex; flex-direction: column; justify-content: flex-end;
  /* `align-items: center` was correct while this was a ROW -- it centred the track vertically
     between the two figures. In column direction it shrink-wraps the track horizontally instead,
     and the three range nodes collapsed into a 76px huddle in the middle of the panel. */
  align-items: stretch;
}
/* Pinned to the top of the band with the track, the only in-flow child, pushed to the bottom by
   `justify-content: flex-end`. */
/* The band above the track. Whatever is left after the track takes its content height -- but with
   a floor: "whatever is left" was 0px at 1024x576, where the range panel is ~118px tall and the
   track's reserved marker column alone is 54px of it. The figures vanished entirely and the
   geometry check still passed, because a zero-size box positioned at the node's centre is exactly
   over the node. `flex-shrink: 0` on the floor means the TRACK gives way instead. */
/* Shrinkable, with a small floor. `flex-shrink: 0` here forced the Range panel taller than its
   row and Zone 1 went 8-27px over between 660 and 900px tall -- the band must give way before the
   zone does. What actually frees the space is the marker reserve below, not this floor. */
.rp-figures { position: relative; flex: 1 1 auto; min-height: 24px; }
/* AFTER the rule it overrides, not in the earlier height band with the rest of Zone 1's
   compaction. A media query adds no specificity, so an override written up there loses to a plain
   rule written down here -- the band read as applied and did nothing, and Zone 1 went 20px over
   while the figures kept their full-height floor. Third time source order has beaten intent in
   this file. */
/* The reserved marker column is gone -- Start/Clash/End are rings on the node dots now, costing
   no layout height at all. That reserve was 44-54px, ~45% of the Range panel at 1024x576, and it
   was the single reason the track sat mid-panel and the figures had nothing left. The band below
   is what claims the freed space. */
@media (max-height: 660px) {
  .rp-figures { min-height: 20px; }
  /* The HUD row needs a floor of its own now, and this is the one piece of fallout from removing
     the marker column that is not obvious.

     .fighter-panel is `overflow: hidden`, which makes its automatic minimum size 0 -- as a grid
     item it contributes NOTHING to the row's height. So the row was never sized by the fighter
     panels at all: it was sized by the Range panel, whose reserved marker column happened to make
     it the tallest item at 118px. The fighter panels need 117 here and were living on that
     accident. Take the reserve away and the row collapses to the zone's own 115, and the panels
     that never asked for anything start clipping -- which is exactly what happened, 2px, at
     1024x576 only.

     118px is measured, not chosen: it is the height the row already had, and the height the
     fighter panels were already relying on. Stating it here makes it a floor those panels own,
     rather than a side effect of how much room a different panel happened to reserve. */
  #fight-hud { grid-template-rows: minmax(118px, auto); }
}
/* 1280x720 is not one of the four required viewports but it is a very common laptop, and the
   fighter panel was 3px over there -- between the 820px band's compaction and the 660px one. */
/* BOUNDED at both ends. Written open-ended it sat after the 660px band and raised that band's
   1px gap back to 2px, putting 1024x576 three pixels over -- the same source-order trap as the
   .rp-figures override above, in the opposite direction. Two height bands that both touch a
   property have to be disjoint or ordered deliberately; these are disjoint. */
@media (max-height: 760px) and (min-height: 661px) {
  .fp-body { gap: 2px; }
}

.rp-figure {
  position: absolute; top: 0; bottom: 0;
  width: auto; height: 100%;
  opacity: 0.22;
  transform: none;
}
.rp-figure svg, .rp-figure img { height: 100%; width: auto; display: block; }
/* Deck-supplied figure: the file is a shape, the colour is the fighter's. */
.rp-figure.is-art {
  background-color: currentColor;
  -webkit-mask-size: contain; mask-size: contain;
  -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat;
  -webkit-mask-position: center; mask-position: center;
}
/* A fixed-proportion slot. The height is already definite (top:0 / bottom:0) and the ratio makes
   the width computable; `mask-size: contain` above fits the art inside without distorting it,
   whatever shape the file is. */
.rp-figure.is-art { aspect-ratio: 48 / 56; }
/* Over the nodes, not the panel edges. The three nodes are equal flex children of a track that
   spans the stage, so their centres sit at 1/6, 3/6 and 5/6 of it. */
.rp-figure.p1 { left: 16.667%; transform: translateX(-50%); color: var(--p1); }
.rp-figure.p2 { left: 83.333%; right: auto; transform: translateX(-50%) scaleX(-1); color: var(--p2); }

/* ---- 4. Stamina takes the style colour ---- */
/* Already inherited via --p1/--p2, which applyFighterNeon now sets from the deck's style. The
   urgency states keep their own semantic colours: a Stamina bar turning amber at 5 and red at 2
   is a warning, and a warning that changes hue with your deck is not a warning. */

/* ================================================================================
   HEX GROUND — the surface the neon reflects off

   Approved: the whole board, not Zone 1 alone. The bloom layers paint OVER the background,
   so a faint silver honeycomb underneath genuinely picks up their colour -- no reflection
   trick needed, the glow is simply semi-transparent and the texture shows through it. That
   is the contrast the mockup has and a flat black ground cannot produce: on flat black a
   16% blue wash is 16% of nothing.

   Zone 1 alone was the alternative and was rejected: every lit panel in all four zones sits
   on this, and texturing one zone would have made the other three read as a different
   material.

   The tile is a true honeycomb: three hexagons at R=16 in a 2w x 3R cell, the third being
   the wrapped cap that makes the vertical repeat seamless. Verified by rendering it at two
   scales and checking for seams rather than by trusting the arithmetic.

   Opacity is carried by `stroke-opacity` on the layer, not baked into the stroke colour, so
   the strength is tunable in one place. It is deliberately far below text contrast: this has
   to be felt rather than seen, and neon-app-check asserts glyph edges stay hard over it. */
:root {
  --hex-tile: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='55.426' height='48' viewBox='0 0 55.426 48'%3E%3Cg fill='none' stroke='%23a8b8cc' stroke-opacity='0.062' stroke-width='1'%3E%3Cpath d='M13.856 0L27.713 8L27.713 24L13.856 32L0 24L0 8Z'/%3E%3Cpath d='M41.569 24L55.426 32L55.426 48L41.569 56L27.713 48L27.713 32Z'/%3E%3Cpath d='M41.569 -24L55.426 -16L55.426 0L41.569 8L27.713 0L27.713 -16Z'/%3E%3C/g%3E%3C/svg%3E");
  --hex-scale: 92px 80px;
}

body {
  background-color: var(--shell-bg);
  background-image: var(--hex-tile);
  background-size: var(--hex-scale);
  opacity: 1;
}
#fight-shell {
  background-image: var(--hex-tile);
  background-size: var(--hex-scale);
}
/* The two corner surfaces carry it as well, so a fighter's own bloom has something to fall on
   inside their panel and not only in the gaps around it. Layered UNDER the panel gradient's
   transparent stops rather than over them. */
.fighter-panel::after,
.range-panel::after {
  background-image: var(--hex-tile), var(--hud-panel);
  background-size: var(--hex-scale), auto;
}

/* ================================================================================
   CARD DETAIL PANELS ARE WINDOWS ONTO THE BOARD GROUND

   The nesting used to step UP in luminance as you went inward -- stage 21.6, card 30.7,
   detail panel 42.5 -- so the innermost surface, the one carrying the target grids, the
   Power and Guard values and the rules text, was the brightest thing under them. That is
   backwards for a board whose language is light on darkness: the detail had the least
   contrast available to it exactly where the most is wanted.

   Inverted here. The detail panel is a hole through the card and the stage onto the black
   ground, and its neon border is the window frame. Measured inside the panel:

                 P05   median   P95    range   edge
     before      27.6   31.2    66.0    38.4   3.18
     windows      0.0    9.3    63.2    63.2   3.93     (gold standard: 87.7 / 7.98)

   Local tonal range +65% for the content that needed it most.

   `background-attachment: fixed` is what makes them read as WINDOWS rather than as tiles of
   wallpaper: the hexagons line up across every panel, so they look onto one continuous wall.
   Verified rather than assumed -- the tile phase in three separate panels measures 46 / 47 /
   45 (a 2px spread, i.e. antialiasing), against 75 / 91 / 36 with `scroll`.

   Note the mechanism: `fixed` normally resolves against the viewport, but a `filter` on an
   ancestor makes it resolve against that ancestor instead, and #workspace carries the neon
   filter. Every panel in the stage therefore shares the stage's origin, which is what is
   wanted here -- it survives the board being repositioned and does not depend on where the
   window happens to be.
   ================================================================================ */
.drawer-panel {
  background-color: #000;
  background-image:
    linear-gradient(180deg, color-mix(in srgb, var(--panel-role) 16%, transparent) 0%, transparent 62%),
    var(--hex-tile);
  background-size: auto, var(--hex-scale);
  background-attachment: scroll, fixed;
}

/* The card body is a FRAME, not a slab.
   Measured after the windows landed: board ground 8.1, stage 20.1, card body 35.5, card header
   38.6, window 0.0. The card was the brightest large surface on the board -- brighter than the
   stage it sits in and 35 points brighter than its own contents -- so the name and style region
   read as a stray sheet of grey laid over everything.

   Two causes, both removed. `linear-gradient(180deg, rgb(255 255 255 / .035), transparent 42%)`
   was a raised-surface highlight from the old stacked-panel language; against windows it is just
   a smudge across the header. And the body sat on --surface-2, a rung ABOVE the stage.

   The nesting now descends the whole way in -- stage 20.1, card 12.7, window 0 -- so the card
   reads as a recess in the stage with holes cut through it, which is what the window treatment
   means. */
.drawer {
  background: var(--surface-0);
}

/* The decorative layers were the stray grey sheet.
   Isolated by rendering with and without them: `.motion-field`, the per-family background trace,
   was adding 10.5 luminance across the ENTIRE card -- 12.7 to 23.2 -- which is half again the
   whole stage-to-card step. It reads as a semi-transparent sheet laid over the name and style
   region because that is exactly what it is, and the window treatment made it obvious: the motif
   lifts the frame but not the panels, so the discontinuity it creates got much larger.

   Reduced rather than deleted. It is a deliberate element and it does give each family a
   character on hover; it just cannot cost a third of the board's tonal range to do it. The family
   is in any case already stated in text on the ownership tag and by the card's border colour, so
   nothing depends on it being legible. Same treatment for the Dirty hazard hatch, which carries
   real meaning but was lifting the card the same way. */
.motion-field { opacity: 0.045; }
.drawer:hover .motion-field { opacity: 0.12; }
.dirty-hazard { opacity: 0.05; }
.drawer:hover .dirty-hazard { opacity: 0.11; }

/* ---- The family motif is a lit trace, not a grey wash ----
   Two problems, one cause. It sat at z-index 0, BEHIND the detail panels, so once those became
   opaque black windows most of it was hidden -- and unevenly, because every motif's dense end is
   on the right of its viewBox (TorquePressure's circles are centred at cx=185 of 240, Kinetic's
   lines all terminate at x=205), which is exactly where the Defense panel sits. So the sparse left
   end showed around Attack and the dense right end was covered by Defense. It looked like one
   section blocked it and the other did not; in fact the motif was densest precisely where it was
   hidden.

   And what did show was a flat translucent wash, which is why it needed a hover to be legible:
   raising a grey veil is the only way to make a grey veil brighter.

   Now it is a lit trace over the whole card -- one layer, no section blocking another -- with the
   same core-plus-bloom the frames use. A thin bright line reads at rest without needing hover,
   and costs far less of the card's tonal range than a broad dim one: it is bright where the stroke
   is and absent everywhere else, instead of slightly-not-black everywhere. */
.motion-field {
  z-index: 2;
  opacity: 0.3;
  filter: drop-shadow(0 0 2px) drop-shadow(0 0 6px);
}
.drawer:hover .motion-field { opacity: 0.42; }
/* The hazard hatch stays behind: it is a warning about the card, not a graphic on it, and a
   diagonal grid over the rules text would be noise rather than signal. */
.dirty-hazard { z-index: 0; }

/* ---- Zone 1's corners are windows too ----
   Same film, same cause. The fighter panels and the Range trapezoid painted `--hud-panel`, a
   surface-2 to surface-0 gradient, so they measured 18.5 / 20.8 / 24.6 against a 0.0 card window
   and an 8.5 board ground -- the brightest large surfaces left on the board, and a grey sheet
   under the portraits and the stats. (p1 and p2 differ because the gradient runs at 160deg and p2
   is mirrored, so its bright end lands in a different corner. Two panels, two greys, one of them
   noticeably heavier.)

   Now the same cutout the technique cards use: black ground, the honeycomb through it, and only
   the accent rim, the portrait and the readouts on top. The clip-path silhouettes are unchanged --
   these are still the leaning corners, just no longer filled with grey. */
.fighter-panel::after,
.range-panel::after {
  background-color: #000;
  background-image: var(--hex-tile);
  background-size: var(--hex-scale);
  background-attachment: fixed;
}

/* ---- The Range figures have to survive being small and dark ----
   22% was tuned when the Range panel was a grey surface (L~20): a figure at 22% of a mid colour
   landed around L 23, three points off its own background, and the built-in placeholder got away
   with it only because it is a fat solid blob. A real silhouette is slimmer and has gaps, so at
   the same alpha it carries far less ink and reads as nothing at 50x58.

   Now that the panel is black there is room to give them real presence, and the same core-and-
   bloom the rest of the board uses so they hold together at that size. They are still framing
   rather than information -- the track and its markers stay well above them. */
/* Deck art carries its own soft alpha edges; the built-in placeholder is flat vector fill and is
   opaque to its outline, so at one opacity the placeholder reads considerably hotter than any real
   silhouette next to it. Two values for the same apparent weight. */
.rp-figure:not(.is-art) { opacity: 0.6; }
.rp-figure {
  opacity: 0.78;
  /* TIGHT. The previous version put most of its energy in two very wide layers (18px and 34px) on
     the theory that a light source should throw a lot of light. It does -- but the throw belongs on
     the surfaces the light lands on, not on the source, and `drop-shadow(0 0 R)` takes R as a
     standard deviation, so 34px reaches roughly twice as far as a 34px box-shadow blur would. The
     result was a figure with no edge: a fuzzy blob where a silhouette should be.

     A real source is the sharpest thing in the frame. So the figure keeps a core and one close
     bloom and nothing else, and its opacity goes up to compensate -- the shape reads, and every
     wide layer that used to smear it now lives on the panel fill and the walls below. */
  filter:
    drop-shadow(0 0 1px)
    drop-shadow(0 0 5px);
}

/* ---- The figures light the trapezoid they stand in ----
   The glow above travels a few dozen pixels and stops, which reads as an outline rather than as
   illumination. What sells a light source is what happens to everything AROUND it: the ground
   brightening under it, and the frame catching its colour on the near side. Two additions, both
   on layers that already exist:

     the ground   -- two radial pools on ::after, over the honeycomb rather than under it, centred
                     on the figures' own 1/6 and 5/6 columns so the light lands where the figure is.
     the frame    -- ::before is the 1px rim. It was one flat chrome colour; it now runs the p1
                     colour at the left edge, through that chrome across the middle, to p2 at the
                     right, so each end of the window is lit by the fighter standing nearest it.

   The middle stays neutral deliberately. Running colour the whole way turns the shared trapezoid
   into a two-tone bar and loses the "neutral ground between two corners" the panel is for. */
.range-panel::after {
  background-image:
    /* THE HONEYCOMB SITS ON TOP AND SCREENS. Everything below it is light; the tile's silver
       strokes are the only solid thing in the panel, so blending them over the light with `screen`
       makes them pick it up -- brighter where a fighter is standing, unchanged out in the black.
       That is the "silver hexagons reflecting it back" reading, and it is also why the light no
       longer needs to be strong: the texture carries it instead of the empty space between. */
    var(--hex-tile),
    /* CONTACT. A wide, very flat ellipse right on the rail under each figure. This is the thing
       goldrange.png has that a plain radial pool cannot fake: the light gathers where they meet the
       floor, which is what makes them look like they are standing on it rather than floating in
       front of it. */
    radial-gradient(30% 7% at 16.667% 74%, rgb(var(--p1-rgb) / 0.42), rgb(var(--p1-rgb) / 0.12) 45%, transparent 78%),
    radial-gradient(30% 7% at 83.333% 74%, rgb(var(--p2-rgb) / 0.42), rgb(var(--p2-rgb) / 0.12) 45%, transparent 78%),
    /* BODY WASH, with a long tail. Four stops rather than two: a two-stop gradient falls off
       linearly and its end is a visible edge, which is exactly the "fog with a shape" the previous
       version had. Stepping the alpha down 0.16 / 0.09 / 0.035 / 0 approximates a real falloff, so
       the light runs out instead of stopping. */
    radial-gradient(52% 60% at 16.667% 52%,
      rgb(var(--p1-rgb) / 0.16) 0%, rgb(var(--p1-rgb) / 0.09) 30%,
      rgb(var(--p1-rgb) / 0.035) 56%, transparent 84%),
    radial-gradient(52% 60% at 83.333% 52%,
      rgb(var(--p2-rgb) / 0.16) 0%, rgb(var(--p2-rgb) / 0.09) 30%,
      rgb(var(--p2-rgb) / 0.035) 56%, transparent 84%),
    /* The walls, now a good deal quieter -- they were doing the job the contact light and the
       screened texture do better, and at 0.62 they read as painted rather than lit. */
    linear-gradient(90deg,
      rgb(var(--p1-rgb) / 0.38) 0%, rgb(var(--p1-rgb) / 0.14) 3%,
      rgb(var(--p1-rgb) / 0.05) 12%, transparent 32%),
    linear-gradient(270deg,
      rgb(var(--p2-rgb) / 0.38) 0%, rgb(var(--p2-rgb) / 0.14) 3%,
      rgb(var(--p2-rgb) / 0.05) 12%, transparent 32%);
  background-size: var(--hex-scale), auto, auto, auto, auto, auto, auto;
  /* The honeycomb is the only one pinned to the viewport; the light belongs to the panel and moves
     with it. A single `fixed` here would have anchored all seven. */
  background-attachment: fixed, scroll, scroll, scroll, scroll, scroll, scroll;
  background-blend-mode: screen, normal, normal, normal, normal, normal, normal;
}
/* The frame itself. Full strength at each end and falling off fast, so the lit part of the wall is
   the part nearest its fighter rather than a wash across the whole rim. */
.range-panel::before {
  background: linear-gradient(90deg,
    rgb(var(--p1-rgb) / 1) 0%,
    rgb(var(--p1-rgb) / 0.62) 15%,
    rgb(var(--neon-rgb) / 0.6) 38%,
    rgb(var(--neon-rgb) / 0.6) 62%,
    rgb(var(--p2-rgb) / 0.62) 85%,
    rgb(var(--p2-rgb) / 1) 100%);
}
/* Deliberately NOT a pair of offset drop-shadows on the panel. That was tried: it throws each
   fighter's colour outward past the nearest wall, which sounds right and is wrong twice over. The
   light lands OUTSIDE the trapezoid, where the board's black ground is supposed to be, and a 6px
   offset under a 10px blur overlaps its opposite number badly enough that the two colours mix back
   to a grey fog around the whole panel. The wall is a surface being lit, so the light belongs on
   its inner face, which is what the bands on ::after above do. */

/* Deck-supplied Range figure: an <img> tinted by an inline SVG filter. Replaces a CSS mask over
   `background: currentColor`, which does not paint at all from file:// -- see #fig-tints. */
.rp-figure.is-art {
  background-color: transparent;
  -webkit-mask-image: none; mask-image: none;
}
.rp-figure.is-art img { width: 100%; height: 100%; object-fit: contain; display: block; }
.rp-figure.p1.is-art img { filter: url(#fig-tint-p1); }
.rp-figure.p2.is-art img { filter: url(#fig-tint-p2); }

/* ---- Scrollbars ---------------------------------------------------------------------------- */
/* Windows paints a classic (non-overlay) scrollbar: ~15px wide, near-white, and permanently
   visible -- classic scrollbars do not fade on blur the way overlay ones do, which is why it stayed
   lit even when the window was not focused. Measured on this board: the bar's column averages 217
   of 255 luminance against a panel at 13. The single brightest element on the screen was chrome.

   Only `.fp-chips` had ever been styled, because it was the one strip where the bar visibly broke
   layout. Everything else -- the stage, the rail history, the command detail, the setup deck list,
   and the document itself below 900px -- was left on the platform default.

   STANDARD PROPERTIES, NOT ::-webkit-scrollbar. Setting `scrollbar-width` makes Chrome ignore the
   ::-webkit-scrollbar pseudo-elements outright. The first version of this block set both, so its
   whole pseudo-element half -- including the hover reveal -- was dead: measured 13 luminance where
   the thumb was supposed to be, identical to no thumb at all. These two properties are also the
   only ones Firefox implements, so doing it this way covers both engines instead of neither.

   At rest both track and thumb are transparent, so there is no scrollbar to see. Hovering a
   scrolling region brings its thumb up as a dim hairline, so the affordance is there for the
   pointer that could use it. `scrollbar-width: thin` reserves its gutter either way, so the reveal
   never reflows the content underneath. */
:root {
  --sc-scroll-thumb: rgba(150, 162, 178, 0.34);
}
* {
  scrollbar-width: thin;
  scrollbar-color: transparent transparent;
}
/* `*:hover` rather than a list of the scrolling regions, so a region added later is covered by
   default instead of silently reverting to a white bar. The property only has a visible effect on
   an element that actually has a scrollbar, and the pointer is inside exactly one scrolling chain
   at a time, so in practice this reveals the bar under the pointer and nothing else. The one
   exception is the sub-900px fallback where the document itself scrolls: there `body:hover` is true
   whenever the pointer is in the window, so that bar shows while the pointer is anywhere on the
   page. Correct for a layout the user is scrolling as a document. */
*:hover {
  scrollbar-color: var(--sc-scroll-thumb) transparent;
}

/* ---- Sound toggle -------------------------------------------------------------------------- */
/* In the brand bar because it has to be reachable in every phase, and the brand bar is the only
   chrome that never re-renders. Muted state lives on <html data-sfx> so the icon can be driven by
   CSS rather than by swapping markup on every toggle. */
.sfx-toggle {
  margin-left: auto;
  display: inline-flex; align-items: center; justify-content: center;
  /* Sized to the brand mark, and compacted wherever the brand mark is -- it is the brand mark that
     sets this bar's content height, and the bar is inside Zone 1's budget. At 30px the button
     became the tallest thing in the bar and Zone 1 went over at two viewports; at a flat 26px it
     still did at 1024x576, where the compact band takes the mark down to 17px and the bar with it.
     A sound control that fails a geometry check is a sound control that grew the chrome. */
  width: 26px; height: 26px; padding: 0;
  background: none; border: 1px solid transparent; border-radius: 6px;
  color: var(--text-dim); cursor: pointer;
  transition: color var(--sc-motion-fast) var(--sc-ease-out),
              border-color var(--sc-motion-fast) var(--sc-ease-out);
}
.sfx-toggle svg { width: 16px; height: 16px; display: block; }
.sfx-toggle:hover { color: var(--text); border-color: var(--border); }
.sfx-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* One icon, two states: the waves and the cross are never both shown. */
.sfx-cross { display: none; }
:root[data-sfx="off"] .sfx-wave { display: none; }
:root[data-sfx="off"] .sfx-cross { display: block; }
:root[data-sfx="off"] .sfx-toggle { color: var(--sc-text-muted); opacity: 0.75; }
/* Sound on is the lit state, so it takes the accent the rest of the board uses for "live". */
:root[data-sfx="on"] .sfx-toggle { color: rgb(var(--neon-chrome) / 0.95); }

/* The toggle compacts with the brand mark, in the same two height bands the mark uses (820px and
   660px). These live at the END of the file, after the base .sfx-toggle rule above, NOT up beside
   the .brand-mark declarations they mirror. A media query carries no extra specificity, so written
   next to their siblings they would lose to a plain rule further down and read as applied while
   doing nothing -- which is exactly what happened on the first attempt, and is the fourth time this
   file has been caught by it. */
@media (max-height: 820px) {
  .sfx-toggle { width: 20px; height: 20px; }
  .sfx-toggle svg { width: 13px; height: 13px; }
}
@media (max-height: 660px) {
  .sfx-toggle { width: 17px; height: 17px; }
  .sfx-toggle svg { width: 12px; height: 12px; }
}

/* ---- Commit shows its Conditions ----------------------------------------------------------- */
/* The disclosure below 1000px tall exists because the rules text is the tallest block in a role
   panel. Collapsed by default meant that on Commit -- the one screen where the decision is being
   made -- a player never saw what special rules or use requirements a card carried unless they
   thought to go looking.

   Opening it costs height the card does not have: 74px past its own box at 1024x576. The card
   scrolls rather than clips so nothing is lost, but geometry-check asserts that scroll never
   engages at a required viewport, because it is a failure mode and not a design.

   The fix is not a magic number. Capping the text at a fixed height chased the overflow from 74px
   down to 2-4px and stuck there, because a cap still says "reserve exactly this much" -- it was
   just a smaller wrong amount, and by then the cap was under one line.

   Instead the text becomes the FLEXIBLE child of the panel. `.drawer-panel` keeps `min-height:
   auto` -- that floor is what stops a squeezed column crushing the panels into each other, and it
   stays -- but a flex child with `min-height: 0` contributes nothing to it. So the panel's
   min-content is now its label, its stats and its controls, all of which are fixed, and the rules
   text takes whatever height is left over and scrolls inside it. Nothing reserves, nothing
   overflows, and the number of lines visible is simply however many fit: every line on a tall
   screen, two or three at 1366x768, one at 1024x576 -- with the toggle still there to collapse it.

   AFTER the media block it modifies, not inside it: a media query carries no extra specificity.
   And it sets only properties no tighter band touches -- an earlier attempt set `padding` here and
   reverted the 700px band's tighter 5px back up to 8px, making 1024x576 worse than before. */
@media (max-height: 1000px) {
  .commit-stage .drawer-panel { display: flex; flex-direction: column; }
  .commit-stage .panel-rules {
    flex: 1 1 0; min-height: 0;
    display: flex; flex-direction: column;
  }
  .commit-stage .panel-rules > .effect-text {
    flex: 1 1 0; min-height: 0;
    overflow-y: auto; overscroll-behavior: contain;
  }
}

/* ---- Setup: no light without a source ------------------------------------------------------ */
/* The Range trapezoid's illumination is light cast BY the two figures standing in it. At Setup
   there are no figures and no track yet -- the panel carries match mode and readiness instead --
   so the pools and the coloured walls were lighting an empty box from nothing. Disembodied, and it
   read as a rendering artefact rather than as atmosphere.

   The panel keeps its black ground, its honeycomb and its neutral chrome rim; only the two
   fighters' contributions go, because only they are absent. They come back with the fighters.

   Written after the rules it overrides -- the light lives on the same two pseudo-elements. */
#fight-hud.setup-mode .range-panel::after {
  background-color: #000;
  background-image: var(--hex-tile);
  background-size: var(--hex-scale);
  background-attachment: fixed;
  background-blend-mode: normal;
}
#fight-hud.setup-mode .range-panel::before {
  background: rgb(var(--neon-rgb) / 0.6);
}

/* ---- Positioning Intent: the selected one is lit, not fogged -------------------------------- */
/* Two things were stacked on the chosen Intent and they fought each other. The card kept the grey
   `--panel-2` fill every button has, and a 10% gold wash was laid over it -- so the "selected"
   state was grey seen through gold rather than gold, and it read as washed out. Then a 22px
   drop-shadow bloomed on top of that, which is the same mistake the Range figures had: a wide glow
   on a small object erases its own edges. The one selected thing on the stage was the fuzziest.

   Same fix as everywhere else on this board. The core goes black, so the gold has something to be
   bright against instead of something to tint, and the glow tightens to a core and one close
   bloom. The unselected two go black as well -- they were the grey layer the selected one was
   sitting on, and leaving them grey would just move the mismatch. */
.intent-card {
  background: #05070a;
}
.intent-card[aria-pressed="true"] {
  /* A gold breath from the top edge, not a flat wash across the whole face. */
  background:
    linear-gradient(180deg, rgb(240 180 41 / 0.14) 0%, rgb(240 180 41 / 0.04) 42%, transparent 78%),
    #05070a;
  --neon-k: 0.9;
  filter:
    drop-shadow(0 0 1px rgb(var(--neon-rgb) / calc(0.8 * var(--neon-k))))
    drop-shadow(0 0 5px rgb(var(--neon-rgb) / calc(0.42 * var(--neon-k))));
}
.intent-card:hover:not(:disabled):not([aria-pressed="true"]) { background: #0a0d12; }

/* ---- Deck art faces right; Player 2 faces left ---------------------------------------------- */
/* Every supplied portrait and Range silhouette is drawn facing right, so the two fighters both
   faced the same way and p2 looked like they were walking off the board rather than squaring up.
   The Range figures already mirrored -- `.rp-figure.p2` has carried `scaleX(-1)` since the flanking
   silhouettes were introduced -- but the portraits never did, so one half of a fighter's identity
   faced the fight and the other half faced away from it.

   Applied to the CONTENT, not to `.fp-portrait` itself: the container carries a side-specific
   edge mask and a corner bloom anchored to the outer corner, both already written per side, and
   flipping the box would undo both. */
.p2 .portrait-img,
.p2 .portrait-silhouette { transform: scaleX(-1); }

/* ---- The hand is a rack of tiles, not a stack of planks ------------------------------------- */
/* `.ribbon-list` is a vertical flex column, which is right for the rail -- a narrow pane where a
   full-width row IS the efficient shape. In the Select stage it put a five-card hand down the
   middle of a 900px-wide workspace: five rows of mostly empty space, and the selected pair pushed
   below the fold, so choosing a second card meant scrolling away from the first to see it.

   On the Select stage the hand becomes a rack instead. `auto-fit` with a 160px floor puts a
   five-card hand in one row on a wide screen and reflows to two or three columns as the workspace
   narrows, with no breakpoint to maintain -- the hand can also grow or shrink without this needing
   to know. The vertical space that buys is what puts the selected pair on screen.

   Scoped to #hand-grid, not to .ribbon-list: the rail's ribbons and the read-only lists are the
   case this shape is wrong for. */
#hand-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
  align-items: stretch;
  gap: 10px;
}
#hand-grid .ribbon {
  /* Equal height whatever the name wraps to, so the rack reads as a row of cards rather than a
     ragged edge. */
  height: 100%;
  padding: 8px 10px;
  gap: 4px;
}
/* In a tile the header cannot stay one line: the name, the ownership tags and the SELECTED badge
   together are wider than the tile. Wrapping lets the name take the first line and the tags fall
   under it, which is also the reading order. */
#hand-grid .ribbon-top { flex-wrap: wrap; gap: 4px 6px; }
#hand-grid .ribbon-name { flex: 1 1 100%; order: 3; font-size: 0.88rem; line-height: 1.2; }
#hand-grid .ribbon-tempo { order: 1; min-width: 0; }
#hand-grid .card-sigil { order: 2; }
#hand-grid .ribbon-tag { order: 4; }
#hand-grid .ribbon-state { order: 5; margin-left: auto; }
/* Two lines of gloss, then ellipsis. Unbounded it is the one thing in the tile that can set an
   arbitrary height, and a single long gloss would drag the whole rack down with it. */
#hand-grid .ribbon-gloss {
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
  overflow: hidden; font-size: 0.72rem;
}

/* The rack and the pair strip both have to pay for the expanded card below them, or defaulting it
   open just moves the scroll rather than removing it. Two places where height was going into
   nothing that carries meaning:

   - The ownership tag in a tile wrapped to two lines ("FAMILY · TORQUE & PRESSURE" does not fit
     160px), which cost every tile in the rack ~18px whether or not its own tag wrapped, because
     the tiles are equal height. One line with an ellipsis; the tag is a category the player already
     knows from the colour of the card's edge, not something they read letter by letter.
   - The pair strip repeats what the two SELECTED tiles above it already say. It stays, because it
     is the control for which card is open, but it does not need a card's worth of padding. */
#hand-grid .ribbon-tag {
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  max-width: 100%; font-size: 0.6rem;
}
.select-slots .tray-slot { padding: 6px 10px; }
.select-slots .tray-name { font-size: 0.82rem; }
.select-slots { margin-bottom: 10px; }
.tray-detail { margin-bottom: 8px; }
/* The Select header was two lines of hint over a full-size heading, ~90px of a ~430px stage spent
   on instructions the player reads once. Trimmed so the height goes to the card they are choosing. */
#workspace-scroll .screen > h2 + .hint { margin-top: 2px; margin-bottom: 10px; }

/* In the Select tray the expanded card repeats its own identity a third time: the tile in the rack
   names it, the pair strip immediately above names it and is highlighted while it is open, and then
   the drawer header names it again with the same ownership tag and the same gloss underneath. That
   duplicate cost ~45px at the exact point where the stats -- the only thing on this screen that is
   NOT already visible elsewhere -- were falling below the fold. The title row stays so the panel is
   never anonymous; the tag row and the gloss go. */
.tray-detail .drawer-ownership,
.tray-detail > .drawer-gloss { display: none; }
.tray-detail .drawer-header { margin-bottom: 4px; }
.tray-detail { padding-top: 10px; padding-bottom: 10px; }

/* ---- The stamina cost says what it is ------------------------------------------------------- */
/* Four dots with no legend is not a price tag. Power and Guard both name themselves inside their
   circle; the cost pips named nothing, so a new player had no way to learn that the dots are what
   the card costs to use. The word goes under them, in the same small-caps as POWER and GUARD.
   A free side reads FREE rather than showing three empty dots that look like an unfilled meter. */
.stamina-cost {
  display: flex; flex-direction: column; align-items: center; gap: 3px;
  flex-shrink: 0;
}
.stamina-label {
  font-size: 0.5rem; letter-spacing: 0.1em; font-weight: 700;
  color: var(--accent); text-transform: uppercase; white-space: nowrap;
}
.stamina-cost.is-free .stamina-label { color: var(--text-dim); opacity: 0.75; }
/* A free side should not draw the eye at all -- most sides are free, and the pips are only news
   when there is something to pay. */
.stamina-cost.is-free .stamina-pips { opacity: 0.45; }
