/* §11 look: near-black ink on paper white, no fills/glow/shading/textures.
   HTML overlay text uses the same palette, hairline rules, compact uppercase
   type — reads like a clean printed box score. Palette-driven via CSS custom
   properties (see modules/palette.js), default = ink. */

/* ---- U-36 (2026-08-04): THE HOUSE CASING RULE ----
   `text-transform: uppercase` is reserved for OUR OWN AUTHORED UI COPY —
   labels, eyebrows, chip/button text, section headers, unit suffixes ("MPH"),
   network/league branding — the genuinely legitimate "sportscast graphics
   package" register a real broadcast box score/scorebug/chyron uses.

   It is NEVER applied to a text node whose CONTENT IS REAL WORLD DATA: a
   person's name, a quote, a stat line, dialogue/prose, a caption, a
   credential value. That data keeps its own authored casing EVERYWHERE it
   appears, so the same name reads identically whether it's in the AT BAT
   panel, a hover label, the ledger, or a story caption.

   THIS WAS ALREADY THE DE FACTO RULE in two places before this comment
   existed — `.matchup-stat-line`'s own "sentence-cased ... override the
   block's uppercase" comment below, and `featureCard.js`'s deliberate split
   of `.feature-card-figure` (a name, untransformed) from `.feature-card-title`
   /`.feature-card-tag` (our own slate copy, uppercase) — this just makes it
   explicit and audits every existing `text-transform: uppercase` rule against
   it. FOUR rules were found force-uppercasing a real name and fixed to match:
   `.matchup-row span:last-child` (AT BAT/PITCHING panel), `.stats-card-name`
   (BOX panel), `.hover-label` (on-field hover ID), `.mind-panel-header
   [data-mind-name]` (MIND VIEW card). Each fix is a narrow override at its
   own site, not a rewrite of the block, so the surrounding chrome (labels,
   eyebrows) keeps its uppercase exactly as before.

   WHY THIS MATTERS BEYOND LOOKS: CLAUDE.md's own history records this exact
   casing split silently defeating a load-time measurement instrument (a
   Title-Case name regex blind to the uppercase AT BAT panel) — a real,
   previously-paid cost, not just a style nit. */

:root {
  --ink: #141414;
  --paper: #fafaf7;
  --field-opacity: 0.35;
  --mono: 'Courier New', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  /* Dither/fine-grain overlay strength (see modules/dither.js's DITHER_OPACITY,
     kept in sync by hand — this is the one dial to tune). Plain alpha
     compositing (no mix-blend-mode) on purpose: blend modes like overlay/
     soft-light are identity at backdrop lightness 0 or 1 and would silently
     kill the effect on the darkest/lightest palettes (see dither.js). */
  --dither-opacity: 0.05;
}

* {
  box-sizing: border-box;
  -webkit-tap-highlight-color: transparent;
}

html, body {
  margin: 0;
  padding: 0;
  width: 100%;
  height: 100vh;
  height: 100dvh;
  background: var(--paper);
  color: var(--ink);
  font-family: var(--mono);
  overflow: hidden;
  overscroll-behavior: none;
}

#scene-container {
  position: fixed;
  inset: 0;
  background: var(--paper);
}
#scene-container canvas { display: block; }

/* Fine-grain dither over the 3D render only (not the HTML text overlay below
   it — see index.html/dev.html: this div is a child of #scene-container, not
   .overlay). Static 4x4 Bayer-ordered tile generated by modules/dither.js's
   ditherTileSVG() (pure, unit-tested) and pasted here by hand — a buildless
   app has no build step to generate it at load, and it never changes unless
   DITHER_AMPLITUDE does. Composited by the browser once as a cached GPU
   texture: zero per-frame JS/GL cost, however long the broadcast runs. A
   plain absolutely-positioned box always paints above the plain (statically
   positioned) canvas sibling, regardless of DOM order, so no z-index dance
   is needed. */
.dither-overlay {
  position: absolute;
  inset: 0;
  pointer-events: none;
  opacity: var(--dither-opacity);
  background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%224%22%20height%3D%224%22%3E%3Crect%20x%3D%220%22%20y%3D%220%22%20width%3D%221%22%20height%3D%221%22%20fill%3D%22%23595959%22%2F%3E%3Crect%20x%3D%221%22%20y%3D%220%22%20width%3D%221%22%20height%3D%221%22%20fill%3D%22%23838383%22%2F%3E%3Crect%20x%3D%222%22%20y%3D%220%22%20width%3D%221%22%20height%3D%221%22%20fill%3D%22%23636363%22%2F%3E%3Crect%20x%3D%223%22%20y%3D%220%22%20width%3D%221%22%20height%3D%221%22%20fill%3D%22%238d8d8d%22%2F%3E%3Crect%20x%3D%220%22%20y%3D%221%22%20width%3D%221%22%20height%3D%221%22%20fill%3D%22%23989898%22%2F%3E%3Crect%20x%3D%221%22%20y%3D%221%22%20width%3D%221%22%20height%3D%221%22%20fill%3D%22%236e6e6e%22%2F%3E%3Crect%20x%3D%222%22%20y%3D%221%22%20width%3D%221%22%20height%3D%221%22%20fill%3D%22%23a2a2a2%22%2F%3E%3Crect%20x%3D%223%22%20y%3D%221%22%20width%3D%221%22%20height%3D%221%22%20fill%3D%22%23787878%22%2F%3E%3Crect%20x%3D%220%22%20y%3D%222%22%20width%3D%221%22%20height%3D%221%22%20fill%3D%22%23686868%22%2F%3E%3Crect%20x%3D%221%22%20y%3D%222%22%20width%3D%221%22%20height%3D%221%22%20fill%3D%22%23929292%22%2F%3E%3Crect%20x%3D%222%22%20y%3D%222%22%20width%3D%221%22%20height%3D%221%22%20fill%3D%22%235e5e5e%22%2F%3E%3Crect%20x%3D%223%22%20y%3D%222%22%20width%3D%221%22%20height%3D%221%22%20fill%3D%22%23888888%22%2F%3E%3Crect%20x%3D%220%22%20y%3D%223%22%20width%3D%221%22%20height%3D%221%22%20fill%3D%22%23a7a7a7%22%2F%3E%3Crect%20x%3D%221%22%20y%3D%223%22%20width%3D%221%22%20height%3D%221%22%20fill%3D%22%237d7d7d%22%2F%3E%3Crect%20x%3D%222%22%20y%3D%223%22%20width%3D%221%22%20height%3D%221%22%20fill%3D%22%239d9d9d%22%2F%3E%3Crect%20x%3D%223%22%20y%3D%223%22%20width%3D%221%22%20height%3D%221%22%20fill%3D%22%23737373%22%2F%3E%3C%2Fsvg%3E");
  background-size: 4px 4px;
}

.overlay {
  position: fixed;
  inset: 0;
  pointer-events: none;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  padding: 14px;
  padding-top: calc(14px + env(safe-area-inset-top));
  padding-bottom: calc(14px + env(safe-area-inset-bottom));
  padding-left: calc(14px + env(safe-area-inset-left));
  padding-right: calc(14px + env(safe-area-inset-right));
}

.hairline { border-color: var(--ink) !important; }

/* U-16 (2026-08-05), GENERALIZED for its own follow-up (2026-08-05, same
   day): a full-viewport window's own loading/error state -- originally
   built only for worldMapEntry.js (see that file's own header comment on
   `stageStatus` for the full before/after: a full-viewport takeover with
   NOTHING here but a 9px corner string against an otherwise blank stage
   while a real 1.2 MB fetch was in flight). ledgerEntry.js has the IDENTICAL
   bare-corner-string pattern in its own index/entity/story fetches -- rather
   than parallel-author a second copy of this block under a `ledger-*` name
   (CLAUDE.md's duplication firewall), the class names were generalized from
   `world-map-stage-status*` to plain `stage-status*` here so BOTH windows
   share one style authority; only each window's own `data-*` attribute
   (kept module-specific, e.g. `data-world-map-stage-status` /
   `data-ledger-stage-status`) still identifies which window's node is which
   for tests. A hairline diamond (the same shape THE ONTOLOGY builds the
   whole world around, §1) rather than a generic spinner -- reuses this
   app's own ink/paper/hairline language instead of importing new chrome. */
.stage-status {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 14px;
  pointer-events: none;
  color: var(--ink);
}
/* U-16 FOLLOW-UP REAL BUG (found live 2026-08-05 verifying THIS follow-up's
   own screenshot, not present in the original U-16 report): the UA
   stylesheet's `[hidden] { display: none }` and this class's own
   `display: flex` above have EQUAL selector specificity (0,1,0 each), and
   author-origin CSS wins a same-specificity tie against the user-agent
   stylesheet -- so `stageStatus.hidden = true` was setting the real DOM
   attribute but NOT actually hiding anything; `getComputedStyle(...).display`
   stayed `flex` the whole time. Confirmed live: `worldMapEntry.js`'s own
   version of this bug was invisible only by accident (its stage-status node
   is appended BEFORE the mounted three.js canvas, so the canvas -- a later
   DOM sibling in the same stacking context -- paints over it and the earlier
   `pointer-events:none` kept it from blocking input either); `ledgerEntry.js`
   appends its stage-status AFTER `body` (so re-renders can wipe body's own
   children without losing the status node, see that file's own header) and
   the same broken hide left the "LOADING…" mark plainly visible, floating
   over real rendered content, in a live capture
   (`client/test/visual/u16_ledger_loading_check.mjs`'s own "loaded" shot).
   Fixed at the ONE shared authority both windows already use: an
   `[hidden]`-qualified selector has specificity (0,2,0), which beats the
   bare class regardless of cascade origin. */
.stage-status[hidden] {
  display: none;
}
.stage-status-mark {
  width: 26px;
  height: 26px;
  border: 1px solid var(--ink);
  transform: rotate(45deg);
  animation: stage-status-pulse 1.6s ease-in-out infinite;
}
.stage-status-text {
  font-family: var(--mono);
  font-size: 10px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  opacity: 0.7;
}
@keyframes stage-status-pulse {
  0%, 100% { opacity: 0.28; transform: rotate(45deg) scale(0.82); }
  50% { opacity: 1; transform: rotate(45deg) scale(1); }
}
/* ERROR: a genuinely different state, not just relabeled text -- the mark
   stops pulsing (nothing is "in progress" any more) and sits at a steady,
   slightly more visible rest state instead. */
.stage-status-error .stage-status-mark {
  animation: none;
  opacity: 0.55;
}
.stage-status-error .stage-status-text { opacity: 0.85; }
@media (prefers-reduced-motion: reduce) {
  /* U-42 (unhandled elsewhere in this app today) -- honored here from the
     start rather than retrofitted: a fixed mid-pulse frame reads clearly
     without motion instead of freezing on either animation extreme. */
  .stage-status-mark { animation: none; opacity: 0.7; transform: rotate(45deg) scale(0.94); }
}

/* ---- top bar: TV broadcast scorebug (White Sox / CHSN LAYOUT) ----
   Uses the compact top-left bar LAYOUT of the Chicago White Sox / Chicago
   Sports Network broadcast scorebug — the arrangement of network badge, stacked
   team rows, big inning, count and diamond — repainted with OUR game's data
   (Meridian Grays / Harborview Pilots, SSN/PPBL branding; see scoreboard.js's
   setTeams()/update(), which reads the same server config/state every prior
   scoreboard build read — nothing here is wired to new data). Replaces the old
   five-hairline-box "ink on paper" scoreboard and the dev-only "SSN ● LIVE"
   corner bug — this is now the single top-left readout.

   MONOCHROME, IN THE HOUSE PALETTE: the original was fixed black/silver TV
   chrome; per the owner it now reads as ink on paper like every other overlay —
   EXACTLY the two house colors (var(--ink)/var(--paper)), the house mono font,
   ink hairlines, secondary text dimmed via opacity (never a gray). The seven
   --sb-* vars below are the whole colour surface and route straight to
   --ink/--paper, so the bug follows the active palette (see modules/palette.js)
   along with the rest of the UI rather than fighting it. */
.scoreboard {
  align-self: flex-start;
  display: flex;
  flex-direction: column;
  margin-top: 34px;
  margin-left: 0;
  /* Banner reserves the stacking context so it always paints ABOVE the
     right-anchored stats panel even if they ever meet — see the
     BANNER-OVERLAP FIX note on .stats-panel. Positioned so z-index applies.
     (Note for future editors: avoid writing a literal "z-index:N" pattern in
     prose here — a test once matched this comment instead of the real
     declaration below, because String.match takes the first hit in source
     order and a comment can appear before its own rule's declaration.) */
  position: relative;
  z-index: 2;
  /* EXACTLY the two house colors, arranged like every other panel: paper
     ground, ink text/rules/lit, ink hairlines — no grays, no accents. (Where
     an element needs to read as secondary it's dimmed with `opacity` in its own
     rule, the same way the rest of the UI does it — that's still the ink color,
     not a third one.) All bug color routes through these seven vars. */
  /* OPAQUE HUD RECOLOR (punchlist 2026-07-19, "#144"): the bug's ground
     tone reads off --hud-paper when present — a continuous dusk/night tint
     of the active palette's paper (see ui.js's applyHudTone/hudPaperHex,
     mirroring scene.js's own duskPaperTint/nightPaperTint composite) — with
     `var(--paper)` as the fallback so nothing breaks before ui.js's first
     tick sets it. */
  --sb-black: var(--hud-paper, var(--paper));      /* panel ground */
  --sb-black-2: var(--hud-paper, var(--paper));    /* strip / matchup ground (divided by hairline, not tone) */
  --sb-silver: var(--ink);       /* rules, team abbr, network badge fill */
  --sb-silver-dim: var(--ink);   /* secondary text (softened via opacity per-rule) */
  --sb-white: var(--ink);        /* primary text, scores, lit indicators */
  --sb-out-lit: var(--ink);      /* lit out dot = filled ink */
  --sb-hair: var(--ink);         /* hairline dividers = ink, like .hairline */
  font-family: var(--mono); /* house monospace, same as the rest of the UI */
  color: var(--sb-white);
  filter: drop-shadow(0 2px 5px rgba(0, 0, 0, 0.4));
}

.scorebug-bar {
  display: flex;
  align-items: stretch;
  background: var(--sb-black);
  border-top: 2px solid var(--sb-silver);
  border-radius: 2px;
  overflow: hidden;
}

.scorebug-net {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  background: var(--sb-silver);
  color: var(--sb-black);
  padding: 4px 8px;
  min-width: 38px;
}
.scorebug-net-abbr { font-size: 13px; font-weight: 800; letter-spacing: 0.02em; line-height: 1.15; }
/* U-43 (2026-08-07): bumped 7px->9px, same anchor size U-44 used for the
   outs dots — this is a broadcast meant to read from across a room, not a
   desk, and 7px was the single smallest text on the whole scorebug. */
.scorebug-net-league { font-size: 9px; font-weight: 700; letter-spacing: 0.08em; opacity: 0.8; line-height: 1.3; }

.scorebug-teams {
  display: flex;
  flex-direction: column;
  justify-content: center;
  padding: 3px 10px;
  border-left: 1px solid var(--sb-hair);
  min-width: 104px;
  /* U-69: this column no longer has a font-shrink escape valve for a long
     score (see .scorebug-team-score above), so it must never be the thing
     that gives when the flex row runs short of space — it grows with its
     own content instead of ceding width to a sibling. */
  flex-shrink: 0;
}
.scorebug-team-row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
  padding: 2px 0;
}
.scorebug-team-row + .scorebug-team-row { border-top: 1px solid var(--sb-hair); }
.scorebug-team-abbr {
  font-size: 13px;
  font-weight: 800;
  letter-spacing: 0.03em;
  text-transform: uppercase;
  color: var(--sb-silver);
}
/* No fixed width on the score itself — a fixed narrow column is exactly how
   you clip a big number. tabular-nums keeps the digits from jittering the
   row width as they change without pinning a max.
   ★ U-69 (2026-08-08, the "9-inning bug on a 9999-inning game" fix — see
   .scorebug-inning-num just below for the fuller rationale, which applies
   here too): this used to shrink a notch per extra digit
   (data-digits="4"/"5"/"6") to stay inside a column sized for a normal box
   score. A shrinking number reads as a value apologizing for outgrowing its
   box — the opposite of an accumulating tally, which should just keep
   counting at full size. Runs never actually reach those digit counts in
   practice (the game is capped at 9999 innings and unreachable by
   construction — see F-119/U-69's own queue entries — so a real run total
   tops out far short of four digits), so the step-down was solving a
   hypothetical at the cost of a real one: it was the same "make the wrong
   form fit" move the inning number made. One constant size now; `flex-shrink:
   0` on .scorebug-teams (below) means the column widens instead if it
   ever needs to. */
.scorebug-team-score {
  font-size: 17px;
  font-weight: 800;
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
  color: var(--sb-white);
}

.scorebug-inning {
  position: relative; /* anchors .scorebug-changeover-bar (ONT-10) */
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 3px 12px;
  border-left: 1px solid var(--sb-hair);
  min-width: 44px;
  /* U-69: the hero number below no longer shrinks to stay inside this box —
     the box grows to keep it instead (see .scorebug-inning-num's comment). */
  flex-shrink: 0;
  /* U-75 (2026-08-08): the arrow and the hero number used to sit with ZERO
     gap between them (line-height:1 on both, no margin) — a live capture
     measured the arrow's own bottom edge exactly touching the digit's top
     edge. 2px is enough to read as two separate elements without loosening
     the block's tight, hero-first read. */
  gap: 2px;
}
.scorebug-half-arrow { font-size: 11px; color: var(--sb-silver); line-height: 1; }
/* U-75 (2026-08-08): small hairline caption under the hero inning number —
   same "big stat, small caption directly under it" idiom .scorebug-net-league
   already uses for "PPBL" under "SSN" (line ~256 above), never a raw literal
   in the markup rendered at full weight. Never competes with the number: at
   9px/opacity 0.55 it reads roughly the same visual weight as the team
   abbreviations, well below the hero digits it labels. */
.scorebug-inning-label {
  font-size: 9px;
  font-weight: 700;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--sb-silver-dim);
  opacity: 0.55;
  line-height: 1;
  margin-top: 1px;
}
/* CHANGEOVER LEGIBILITY (ONT-10, 2026-08-02, see scoreboard.js's setChangeover
   for the full rationale): a real ~90-155s half-inning break is correct and
   should stay that long — this is the FORM answer to "is the broadcast
   frozen or genuinely paused", not a shortcut to make the pause itself
   faster or explain it in words. The bar's width is set every frame directly
   from the changeover's own real progress (playback.js co.plan age/totalS),
   so it fills continuously across the whole real break, never a decorative
   loop. Hidden (opacity:0, scaleX from JS) whenever no break is in
   progress — inert during ordinary play, so this costs nothing to load or
   render outside the ~1-2 breaks per inning it's actually visible for. */
.scorebug-changeover-bar {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 2px;
  background: var(--sb-white);
  transform: scaleX(0);
  transform-origin: left center;
  opacity: 0;
  transition: opacity 0.4s ease;
  pointer-events: none;
}
.scorebug-inning.is-changeover .scorebug-changeover-bar { opacity: 0.7; }
/* The SAME glyph that already flips within ~4s of the real half-inning flip
   (scoreboard.js update()'s halfArrow.textContent) pulses gently for the
   REST of the real break — a second, independent confirmation that this
   cell is still being driven by a live clock, not merely holding a static
   fill. Monochrome (opacity only), matching the house ink palette — no new
   color introduced anywhere in this ruleset. */
@keyframes scorebug-half-pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.4; }
}
.scorebug-inning.is-changeover .scorebug-half-arrow {
  animation: scorebug-half-pulse 2.4s ease-in-out infinite;
}
/* EVER-GROWING COUNTER (2026-07-18): the show's signature — this inning
   number can run to 3, 4, 5+ digits over a long broadcast. No fixed width
   (white-space:nowrap only), so it's never clipped.
   ★ U-69 (2026-08-08): this used to step DOWN a font-size notch per extra
   digit (data-digits="4"/"5"/"6") — a real White Sox/CHSN bug's inning slot
   is sized for one digit (1-9), so growing past that and then apologizing
   for it by shrinking is the bug conceding it borrowed a form that promises
   an ending this game does not have (owner ruling, U-69's queue entry:
   "the inning number is already the honest hero number... the borrowed CHSN
   chrome stops asserting a shape the game does not have"). The fix is not
   more code to fit the old shape better — it's to stop shrinking. This
   number is the one honest, correct thing on the whole bug: it is Perpetual
   Park's real clock, and it should get MORE visually weighty as it climbs,
   never less. One constant size now, at every digit count; `flex-shrink: 0`
   on .scorebug-inning (above) means the bug widens by a few px per digit
   instead — a far smaller, slower, and more honest accommodation than
   shrinking the one number the whole design exists to show off. */
.scorebug-inning-num {
  display: block;
  font-size: 30px;
  font-weight: 800;
  line-height: 1;
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
  letter-spacing: 0;
}

/* SCOREBUG COMPACTION (punchlist 2026-07-18, "#139"): count + baserunner
   diamond now share one bordered section (instead of each having its own),
   with the pitch-type flash sitting beneath both — see index.html's
   .scorebug-count-bases wrapper and scoreboard.js's countBasesBlock. The
   wrapper carries the single divider from the teams column; the row inside
   lays count/bases out side by side exactly as before. */
.scorebug-count-bases {
  display: flex;
  flex-direction: column;
  justify-content: center;
  border-left: 1px solid var(--sb-hair);
}
.scorebug-count-bases-row {
  display: flex;
  align-items: stretch;
}

.scorebug-count {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 3px;
  padding: 3px 10px;
  min-width: 40px;
}
.scorebug-count-bs {
  font-size: 13px;
  font-weight: 800;
  font-variant-numeric: tabular-nums;
}
.scorebug-count-sep { color: var(--sb-silver-dim); margin: 0 1px; opacity: 0.5; }
/* U-44 (2026-08-04, "the smallest, densest info on the whole broadcast reads
   as barely-visible chrome"): outs dots were 7px at opacity 0.45, the base
   diamond 22px with each base an 8px square at opacity 0.4 — the count/bases
   is what a baseball viewer checks constantly and it was the hardest thing
   on screen to actually see. Bigger glyphs + a real ink/opacity floor so the
   UNLIT state reads as "dim but clearly a real shape," not "is that even
   there" — still exactly the house's two-color (ink/paper) discipline this
   panel is built on (.scoreboard's own comment above), just less faint.
   Scaled again for the mobile-narrow rule below in the same proportion. */
.outs-dots { display: flex; gap: 5px; }
.dot {
  width: 9px; height: 9px;
  border: 1.5px solid var(--sb-silver-dim);
  border-radius: 50%;
  background: transparent;
  opacity: 0.6; /* unlit reads dim but legible; .dot.lit restores full ink */
}
.dot.lit { border-color: var(--sb-out-lit); background: var(--sb-out-lit); opacity: 1; }

.scorebug-bases {
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 4px 12px;
  border-left: 1px solid var(--sb-hair);
}
.diamond { position: relative; width: 27px; height: 27px; transform: rotate(45deg); }
.base {
  position: absolute;
  width: 10px; height: 10px;
  opacity: 0.55; /* empty base reads dim but legible; .base.lit restores full ink */
  border: 2px solid var(--sb-silver-dim);
  background: transparent;
}
.base.lit { border-color: var(--sb-white); background: var(--sb-white); opacity: 1; }
/* ALIGNMENT FIX (2026-07-14): a 45deg rotation only turns a square's own
   CORNERS into the diamond's up/right/down/left points — a point sitting on
   an EDGE MIDPOINT pre-rotation (the old top:12/left:12/right:0 values,
   centered along one axis) lands on a DIAGONAL post-rotation instead, so the
   three bases came out skewed relative to one another rather than forming a
   clean triangle around home. Placing each base at a true corner of the
   (unrotated) .diamond box fixes it: top-left -> top point (2B), top-right ->
   right point (1B), bottom-left -> left point (3B); the unused bottom-right
   corner would be home. */
.base-second { top: 0; left: 0; }
.base-first { top: 0; right: 0; }
.base-third { bottom: 0; left: 0; }

.scorebug-strip {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  background: var(--sb-black-2);
  border-top: 1px solid var(--sb-hair);
  padding: 2px 10px;
  font-size: 9px;
  font-weight: 700;
  letter-spacing: 0.12em;
  color: var(--sb-silver-dim);
}

/* F-3 (2026-08-03, "the client presents stale state as live") / F-64 FIX
   (2026-08-04): hidden by default; app.js#updateHudStaleness toggles
   `body.hud-stale` when no real message (sync/play/heartbeat) has reached
   the client OFF THE WIRE in STALE_HUD_MS — see app.js's STALE_HUD_MS doc
   comment for why this is conn.msSinceLastMessage(), not local playback. A
   REAL ~90-155s half-inning break, or an honest content gap, never trips
   this: the server's 15s heartbeat keeps the wire signal fresh regardless
   of game content, so this only ever fires when the CONNECTION has
   genuinely died — exactly the gap the owner named: "a broadcast that has
   nothing to show must say so; silence is allowed, lying is not." Monochrome ink only, no
   new color, same discipline as the rest of the bug. */
.hud-stale-badge { display: none; margin-left: 8px; opacity: 0.85; }
body.hud-stale .hud-stale-badge { display: inline; }
@keyframes hud-stale-pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.4; }
}
body.hud-stale .scoreboard,
body.hud-stale .hud-stale-badge {
  animation: hud-stale-pulse 2.2s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
  body.hud-stale .scoreboard,
  body.hud-stale .hud-stale-badge { animation: none; opacity: 0.7; }
}

/* U-43 (2026-08-07): 11px->12px — the AT BAT/PITCHING label+name row, one of
   the most-read pieces of text on the whole broadcast. */
.matchup-block {
  background: var(--sb-black-2);
  border-top: 1px solid var(--sb-hair);
  padding: 5px 10px 6px;
  font-size: 12px;
}
.matchup-row {
  display: flex;
  justify-content: space-between;
  gap: 10px;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--sb-white);
}
.matchup-row span:first-child { color: var(--sb-silver-dim); font-weight: 700; margin-right: 8px; flex: 0 0 auto; opacity: 0.6; }
/* Batter/pitcher names are arbitrary-length real data, not our copy — bound
   to one line (flex: 1 1 auto + min-width: 0 is what lets an ellipsis apply
   inside a flex item at all; the browser default min-width:auto would keep
   it at its content width and never actually clip). Keeps the whole bug's
   height PREDICTABLE (see the ui.js clearance comment on .stats-panel/
   .feed-panel, which is sized against this never wrapping). */
.matchup-row span:last-child {
  flex: 1 1 auto;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  text-align: right;
  /* U-36: this is the real batter/pitcher NAME, not our copy — see the house
     casing rule at this file's top. `.matchup-row`'s own uppercase (for the
     "AT BAT"/"PITCHING" label in span:first-child) must not bleed onto it. */
  text-transform: none;
}
/* STAT-BOX follow-up (2026-07-15): real battingLineToday/recentForm/
   pitcherLineToday strings (see scoreboard.js update()) under each
   matchup-row — sentence-cased, so override the block's uppercase. Same
   one-line-only bound as the name above, same reason. */
/* U-43 (2026-08-07): 9px/opacity .65 -> 11px/opacity .75 — this was the
   worst legibility offender measured in this pass's own live capture (real
   stat line under each AT BAT/PITCHING name, e.g. "0-for-5 today"): small
   AND low-contrast at once. Both bumped together, same restrained approach
   U-44 used for the outs dots. */
.matchup-stat-line {
  color: var(--sb-silver-dim);
  opacity: 0.75;
  font-size: 11px;
  text-transform: none;
  letter-spacing: 0.02em;
  margin: 1px 0 4px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.matchup-stat-line:last-child { margin-bottom: 0; }

/* ---- bottom: captions ---- (the ticker that used to live here was
   consolidated into the single feed.js play-by-play panel — see its CSS
   block below — so there's exactly one log-like element on screen) */
.bottom-block {
  align-self: stretch;
  display: flex;
  justify-content: center;
  align-items: flex-end;
  gap: 14px;
}

.captions {
  flex: 1;
  text-align: center;
  /* U-43 (2026-08-07): 13px->15px — this is the actual spoken-dialogue
     transcript, arguably the single most-read text in the whole broadcast
     ("voice follows the camera"), and it was smaller than the AT BAT panel
     labels sitting right above it. max-height bumped 96px->110px in lockstep
     so roughly the same number of lines stays visible before scrolling. */
  font-size: 15px;
  min-height: 40px;
  /* ANNOUNCER TRANSCRIPT: a running history of the booth that can be scrolled
     back through (see scoreboard.js setCaptions). Height-bounded so it still
     reads as subtitles sitting at the bottom-center of frame rather than a
     wall of text; scroll up for everything Dutch and Sal have said. The
     overlay sets pointer-events:none, so re-enable them here to allow the
     scroll. */
  max-height: 110px;
  overflow-y: auto;
  overscroll-behavior: contain;
  pointer-events: auto;
  scrollbar-width: thin;
}
/* LEGIBILITY FIX (2026-08-01, "the world's line-art strikes through the
   caption text"): captured live, the wireframe ballpark's own geometry drew
   straight through Dutch/Sal's subtitles with no scrim at all — read as
   strikethrough at real viewing distance, and .caption-color's italic/
   lighter-weight lines were worst hit.
   TREATMENT CHOSEN: a soft, tightly-FITTED plate per line (fit-content width,
   centered, low-opacity paper + a light blur) rather than either a hard
   opaque box (fights the spare black-on-cream line-art house style — see the
   MONOCHROME banner comment above .scoreboard) or a full-bleed bar across
   the frame (reads as "UI panel bolted on", the one thing the brief for this
   fix explicitly ruled out — captions must stay subtitles floating IN the
   frame). A plate that hugs its own text, one per line, is the same idiom
   real subtitle/lower-third treatments use: it fully blocks the line art
   behind the glyphs while leaving the gaps between plates open to the scene,
   so the transcript still reads as sitting IN the world rather than over a
   panel. Each JS-driven `style.opacity` age-fade (scoreboard.js's
   refreshCaptionFade/captionOpacity) already applies to this whole element,
   so an older line's plate recedes right along with its text — no separate
   fade to keep in sync. backdrop-filter degrades gracefully: browsers
   without it still get the semi-opaque background alone, which is already
   enough contrast on its own. */
.caption-line {
  display: block;
  width: fit-content;
  max-width: 100%;
  margin: 2px auto;
  padding: 2px 10px;
  background: color-mix(in srgb, var(--paper) 90%, transparent);
  backdrop-filter: blur(3px);
  -webkit-backdrop-filter: blur(3px);
  border-radius: 4px;
}
.caption-pbp { font-weight: bold; }
/* LEGIBILITY FIX (2026-08-01): the "SAL reads a touch softer than Dutch"
   register used to be plain element `opacity` here — exactly the same
   scrim-multiplying problem the age-fade had (see .caption-line's own
   comment above and refreshCaptionFade's in scoreboard.js): it dimmed the
   PLATE behind the text along with the text. Folded into
   refreshCaptionFade's own ink-percentage color-mix instead (it reads this
   class off the line to apply the same 0.75 factor to the TEXT COLOR only),
   so `.caption-color` no longer touches opacity at all — italic is the only
   thing left here that CSS alone still owns. */
.caption-color { font-style: italic; }
/* Sideline reporter "Dot" (2026-07-18): a distinct caption register from the
   two booth voices — upright (not italic like Sal) but lighter than Dutch's
   bold, so a between-innings interview beat reads as its own thing. Same
   LEGIBILITY FIX as .caption-color just above: the 0.9 dimming moved into
   refreshCaptionFade's text-color mix, out of element opacity. */
.caption-sideline { font-weight: 600; }

/* U-4 (2026-08-04) — ARRIVAL ORIENTATION: "arriving at a story gives no
   sense of having arrived: no title, no who-is-here, no orientation."
   Small, transient, top-center caption (app.js's showStoryArrivalCaption) —
   deliberately NOT bottom-center, which .captions above already owns, and
   NOT a full-screen takeover like .ident-overlay below (this is an
   orientation beat, not a network stinger). Same "soft plate hugging its
   own text" idiom as .caption-line above (paper-tinted translucent
   background + blur, not a hard opaque box) so it reads as a subtitle
   floating in frame, not a UI panel bolted on.
   Sits at top:132px — clear of BOTH caption positions this overlay can be
   in: normally `.captions` sits bottom-center (see that rule above), but a
   story-channel arrival is always free-roam, and free-roam hides
   `.scoreboard` entirely — `.overlay`'s flex column (`justify-content:
   space-between`) then has only ONE child left (`.bottom-block`), which
   collapses to the CONTAINER'S TOP under `space-between` with a single
   item. Measured live (u4_story_arrival_caption.mjs's own capture): the
   two-line announcer transcript lands at top:24-68px in exactly that
   state. 132px clears its 96px max-height plus a real margin, confirmed
   against a live capture, not assumed. Also clear of .controls (top:14px,
   top-right) and .scoreboard (margin-top:34px, top-left, when present). */
.story-arrival-caption {
  position: fixed;
  top: 132px;
  left: 50%;
  transform: translateX(-50%) translateY(-6px);
  text-align: center;
  pointer-events: none;
  z-index: 12;
  opacity: 0;
  transition: opacity 0.5s ease, transform 0.5s ease;
  background: color-mix(in srgb, var(--paper) 88%, transparent);
  backdrop-filter: blur(3px);
  -webkit-backdrop-filter: blur(3px);
  border-radius: 4px;
  padding: 6px 16px;
  max-width: min(80vw, 520px);
}
.story-arrival-caption.visible {
  opacity: 1;
  transform: translateX(-50%) translateY(0);
}
.story-arrival-title {
  font-size: 14px;
  font-weight: 600;
  color: var(--ink);
  letter-spacing: 0.01em;
}
.story-arrival-presence {
  margin-top: 2px;
  font-size: 11px;
  font-style: italic;
  color: color-mix(in srgb, var(--ink) 70%, var(--paper) 30%);
}

/* U-6/U-7 (2026-08-04) — "the channel list does not say what is happening
   on each channel" / "no indication of which channels are LIVE vs idle."
   The channel picker dropdown's own per-row rule (`.channel-menu-item`:
   display:block; white-space:nowrap; overflow:hidden; text-overflow:
   ellipsis — one truncating text line) lives in ui.js's JS-injected
   stylesheet, not this file (ui.js isn't in this task's declared paths).
   The two-part row this needs — a truncating label plus an always-visible
   LIVE/idle badge — is added here as a higher-specificity override
   (`.channel-menu .channel-menu-item`, two classes/one attribute selector,
   beats the injected rule's single class) rather than editing that file,
   so it wins the cascade regardless of injection order and ownership stays
   where it already was.
   Monochrome by design, same reasoning as the world-map story glyph above
   (`mapPalette`'s doc — every palette theme is one ink/paper hue pair, so a
   hardcoded accent color would clash with half of them): LIVE is bold ink
   with a solid border, idle is dim italic with no border — weight and
   opacity carry the distinction, not hue. */
.channel-menu .channel-menu-item {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 8px;
  white-space: nowrap;
}
.channel-menu-item-label {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  min-width: 0;
  flex: 1 1 auto;
}
.channel-menu-item-status {
  flex: 0 0 auto;
  font-size: 9px;
  letter-spacing: 0.06em;
  padding: 1px 4px;
  border-radius: 2px;
}
.channel-menu-item-status-live {
  font-weight: bold;
  color: var(--ink);
  border: 1px solid var(--ink);
}
.channel-menu-item-status-idle {
  font-style: italic;
  opacity: 0.55;
}

/* ---- controls (interactive, pointer-events re-enabled) ----
   Moved to top-right (punchlist 2026-07-17) to avoid overlaying captions.
   UI CLEANLINESS (punchlist 2026-07-18, "#128/#118/#100"): no longer wrapped
   in its own outer hairline box — that read as one big rectangle sitting
   over the field. Each chip already carries its own hairline border/paper
   fill (see .chip below), so the group still reads as a cluster of controls
   without a second bounding box around all of them. */
.controls {
  position: absolute;
  top: calc(14px + env(safe-area-inset-top));
  right: calc(14px + env(safe-area-inset-right));
  display: flex;
  flex-direction: column;
  gap: 5px;
  align-items: flex-end;
  pointer-events: auto;
  /* U-89 (2026-08-09): a FIXED width, not max-width -- see this file's own
     header comment on .chip-row below for why a determinate width is what
     actually fixes the ragged-left-edge defect (a shrink-to-fit/max-width
     box's rendered width isn't guaranteed constant row to row the way a
     flex-wrap "pack until it doesn't fit" layout computes it; a real
     CSS-declared width is). ui.js#injectChromeStyle overrides this with its
     own !important value at runtime -- kept in sync, see that rule's comment. */
  width: 168px;
  padding: 0;
}
/* U-89 (2026-08-09): "The top-right controls cluster has no column grid, no
   visible toggle affordance, and two tooltips that are instruction manuals."
   THE GRID FIX for problem 1 (ragged left edge, orphan trailing row): each
   function group (channel identity / windows that open an overlay / view
   toggles / info) is now its own .chip-row, width:100% of .controls' own
   FIXED width above -- every row therefore shares the exact same left AND
   right edge (a percentage of a determinate parent width is itself
   determinate, unlike the old single flex-wrap blob whose per-row width was
   "however much content that row happened to pack," which is what produced
   five different left edges at x=1157/1170/1175/1165/1243, measured live).
   flex-wrap stays as a safety net (a row that genuinely can't fit on one
   line at 168px still wraps rather than overflowing/clipping), but every
   row's GROUPING is now deliberate (see index.html's own U-89 comment), so
   wrapping inside a row should be rare in practice, not the normal case. */
.chip-row {
  width: 100%;
  display: flex;
  flex-wrap: wrap;
  gap: 3px;
  justify-content: flex-start;
}
/* The return-to-broadcast chip (U-5) sits OUTSIDE any .chip-row -- a rare,
   transient, full-sentence call-to-action ("RETURN TO CH X"), not a grouped
   standing control. Without this, flex-direction:column's default
   align-items:stretch would force it to the FULL 168px row width (a
   visually odd wide bar for a short label); align-self keeps its original
   auto-width, right-aligned behavior unchanged. */
.controls > [data-return-broadcast-btn] {
  align-self: flex-end;
}
.chip {
  /* OPAQUE HUD RECOLOR (2026-07-19, "#144"): see .scoreboard's --sb-black
     comment above — same --hud-paper-with-fallback pattern. */
  background: var(--hud-paper, var(--paper));
  color: var(--ink);
  border: 1px solid var(--ink);
  font-family: var(--mono);
  font-size: 9px;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  padding: 4px 8px;
  cursor: pointer;
  touch-action: manipulation;
  text-decoration: none;
  /* U-89 (2026-08-09): anchors the off-state toggle marker (::after below),
     which needs an absolutely-positioned corner dot relative to the CHIP
     itself, not the whole .controls cluster. */
  position: relative;
}
.chip:hover { background: color-mix(in srgb, var(--ink) 8%, var(--paper)); }
/* U-89 (2026-08-09) problem 2: "off-state toggles are visually identical to
   non-toggles." A chip that CAN toggle (carries aria-pressed at all) but is
   currently off looked pixel-identical to a plain button that never
   toggles -- a viewer could not tell which of the thirteen chips had a
   state until they pressed one and watched it invert. `.chip.active`
   already marks the ON state correctly (full ink/paper inversion); this
   marks the OFF state of anything that CAN be on: a small persistent ink
   dot in the corner, deliberately faint (0.55 opacity) so it reads as
   "this remembers a state" ambient chrome, not another competing control.
   Scoped to [aria-pressed="false"] specifically (not aria-pressed="true",
   which already gets the full active-state inversion via JS toggling the
   .active class, and not elements with no aria-pressed attribute at all --
   a plain button like "?" or "Main" must NOT get this mark, it has no
   state to remember). */
.chip[aria-pressed="false"]::after {
  content: '';
  position: absolute;
  top: 2px;
  right: 2px;
  width: 3px;
  height: 3px;
  border-radius: 50%;
  background: var(--ink);
  opacity: 0.55;
  pointer-events: none;
}
.chip.active { background: var(--ink); color: var(--paper); }

/* U-5 (2026-08-05): the "return to broadcast" affordance. text-transform:none
   because its label ends in a real channel name (U-36 -- never force-case a
   real name/label; the button's own "RETURN TO" chrome carries its caps
   literally in the string instead, see app.js#syncReturnAffordance). Inverted
   ink/paper (matching .chip.active's own language for "the thing that wants
   your attention right now") rather than a new color, and bold, so it reads
   as the one time-sensitive control in a row of standing ones -- it is only
   ever visible when there is somewhere real to go back to.

   FOUND LIVE (a plain, non-!important rule here was silently overridden):
   ui.js#injectChromeStyle appends its OWN `.controls .chip { ...
   text-transform: uppercase !important ... }` to <head> at runtime, AFTER
   this stylesheet has already loaded — same specificity (two selectors) as
   a bare `.chip[data-return-broadcast-btn]` here, and !important always
   wins over a normal declaration regardless of source order, so the plain
   version never took effect (confirmed via a live getComputedStyle +
   matched-CSSOM-rules probe, not by reading the diff). `.controls` is a
   STATIC selector any real DOM ancestor of this button already satisfies
   (see index.html — `data-return-broadcast-btn` only ever lives inside
   `.controls`), so adding it here does not change WHICH elements this rule
   targets, only its specificity: three selectors beats ui.js's two, so
   !important vs !important now resolves on specificity, not injection
   order, and this wins unconditionally. Same fix shape U-36 already used
   for [data-mind-hint] (see ui.js's own comment there) — this keeps it in
   style.css rather than duplicating the override into ui.js too. */
.controls .chip[data-return-broadcast-btn] {
  text-transform: none !important;
  font-weight: 700;
  background: var(--ink);
  color: var(--paper);
  border-color: var(--ink);
}
.controls .chip[data-return-broadcast-btn]:hover {
  background: color-mix(in srgb, var(--paper) 12%, var(--ink));
}

/* Mind-view discoverability hint (PLAYER-INTERIORITY Wave 3) — sits among the
   real chip buttons in .controls but isn't one (no click handler): a plain
   <span>, so it needs its own look-alike rule rather than button.chip above
   (which is scoped to actual buttons). Same hairline card language, just
   non-interactive (default cursor, no hover state, slightly dimmed so it
   reads as ambient chrome rather than another control competing for taps). */
.mind-hint {
  /* Same --hud-paper-with-fallback tint as the real chip buttons beside it
     (2026-07-19, "#144") — otherwise it would sit visually static while its
     neighbors shift with the day/night clock. */
  background: var(--hud-paper, var(--paper));
  color: var(--ink);
  border: 1px solid var(--ink);
  font-family: var(--mono);
  font-size: 9px;
  text-transform: none;
  letter-spacing: 0.06em;
  padding: 4px 8px;
  cursor: default;
  opacity: 0.7;
}

/* Icon-only chip buttons (compact, centered) */
.chip-icon {
  font-size: 13px;
  text-transform: none;
  letter-spacing: 0;
  padding: 4px 6px;
  display: flex;
  align-items: center;
  justify-content: center;
  min-width: 24px;
  min-height: 24px;
}

/* ---- hover-to-identify (punchlist 2026-07-15) ---- */
/* A small printed-caption-style label, not a generic browser tooltip: same
   ink-on-paper hairline box every other readout on this broadcast uses (see
   .stats-card/.matchup-row above), positioned by modules/hover.js's
   mountHoverLabel via left/top (CSS px, matching scene.js's pickHover
   screen-space output). pointer-events stays off (inherited from .overlay)
   — it must never itself become the thing under the cursor, or hovering the
   label would fight the actor it's naming. The fade is the ONLY concession
   to the "for a bit" dwell already gating when this class gets added: the
   label itself has no timer of its own. */
.hover-label {
  position: absolute;
  left: 0;
  top: 0;
  transform: translate(-50%, calc(-100% - 10px));
  background: var(--paper);
  color: var(--ink);
  border: 1px solid var(--ink);
  font-family: var(--mono);
  font-size: 11px;
  /* U-36: this label's ENTIRE text content is the hovered actor's real name
     (hover.js#mountHoverLabel — no compound label, just the name) — see the
     house casing rule at this file's top. Was uppercase; that forced the
     same real name to print differently here than in the ledger/captions. */
  text-transform: none;
  letter-spacing: 0.06em;
  padding: 3px 7px;
  white-space: nowrap;
  opacity: 0;
  transition: opacity 0.15s ease;
}
.hover-label.visible { opacity: 1; }

/* ---- pitch-location marker velo readout (punchlist 2026-07-16) ---- */
/* Same ink-on-paper hairline-card family as .hover-label just above (see
   modules/strikezone.js's mountPitchMarkerLabel — identical show/hide
   contract, driven every frame by playback.js's `this.pitchMarker`). A
   smaller vertical lift than .hover-label's -10px: this sits just off a
   small in-scene dot, not above a full figure's head. No timer of its own —
   the fade is purely the opacity transition; visibility is driven entirely
   by whether playback.pitchMarker is set that frame. */
.pitch-marker-label {
  position: absolute;
  left: 0;
  top: 0;
  transform: translate(-50%, calc(-100% - 6px));
  /* OPAQUE HUD RECOLOR (2026-07-19, "#144"): same --hud-paper pattern as
     .scoreboard/.chip above — the velo readout is one of the three opaque
     displays the punchlist item names explicitly. */
  background: var(--hud-paper, var(--paper));
  color: var(--ink);
  border: 1px solid var(--ink);
  font-family: var(--mono);
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  padding: 3px 7px;
  white-space: nowrap;
  opacity: 0;
  transition: opacity 0.15s ease;
}
.pitch-marker-label.visible { opacity: 1; }

/* ---- suggestion box ('~' key, punchlist 2026-07-16 — see modules/
   suggestBox.js) ---- Deliberately NOT a full-screen blocking treatment
   like .join-overlay/.ident-overlay below: this is a lightweight "hit ~,
   jot a note, hit enter" utility, not a gate the viewer must clear, so the
   broadcast keeps running behind it. Same ink-on-paper hairline-card family
   as .hover-label/.stats-card/.pitch-marker-label above — a real HUD
   element, not a generic browser prompt(). The wrapper itself has
   pointer-events:none (so it never blocks clicks on the scene behind it
   when hidden or between interactions); only .suggest-card re-enables them,
   matching how .overlay's own pointer-events:none / .controls' auto
   split already works elsewhere on this page. Anchored bottom-center, above
   .controls' chip row so the two never overlap. */
.suggest-overlay {
  position: fixed;
  left: 50%;
  bottom: calc(64px + env(safe-area-inset-bottom));
  transform: translateX(-50%);
  z-index: 25;
  pointer-events: none;
}
.suggest-overlay.hidden { display: none; }
.suggest-card {
  pointer-events: auto;
  background: var(--paper);
  color: var(--ink);
  border: 1px solid var(--ink);
  padding: 10px 12px;
  width: min(360px, 86vw);
}
.suggest-label {
  font-family: var(--mono);
  font-size: 10px;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  opacity: 0.7;
  margin-bottom: 6px;
}
.suggest-input {
  display: block;
  width: 100%;
  background: var(--paper);
  color: var(--ink);
  border: 1px solid var(--ink);
  font-family: var(--mono);
  font-size: 13px;
  padding: 6px 8px;
  outline: none;
}
.suggest-input:disabled { opacity: 0.5; }
.suggest-status {
  margin-top: 6px;
  font-family: var(--mono);
  font-size: 10px;
  letter-spacing: 0.06em;
  min-height: 12px;
  opacity: 0.8;
}

/* ---- initial-load splash (owner ask 2026-07-21: "a new initial-load
   splash screen with a SPINNING version of the logo while the broadcast
   loads") ----
   Visible by DEFAULT in the static markup (no `.hidden` class in
   index.html) so it covers the very first paint, before any JS has run —
   the exact same "static markup, JS just drives it" precedent as
   .join-overlay just below (see modules/ui.js's header comment: the join
   overlay itself is never hidden by startup code). Highest z-index on the
   page (see the .mind-overlay comment further down for why 40 beats its
   own 30) since this is the very first and, while it's up, ONLY thing that
   should be visible — .join-overlay/.ident-overlay sit underneath, already
   in their normal state, so the instant modules/splash.js clears this the
   broadcast is exactly as reachable as it would've been with no splash at
   all. Cleared once the broadcast is actually ready (sync applied + first
   rendered frame — see app.js) or a short safety timeout elapses (see
   splash.js's SPLASH_MAX_WAIT_MS — the graceful-degrade path so a stuck
   connect can never leave the join button unreachable). */
.splash-overlay {
  position: fixed;
  inset: 0;
  z-index: 40;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--paper);
  color: var(--ink);
  pointer-events: auto;
  opacity: 1;
  transition: opacity 0.6s ease;
}
.splash-overlay.hidden {
  opacity: 0;
  pointer-events: none;
}
.splash-card {
  position: absolute;
  inset: 0;
  text-align: center;
}
.splash-bloom {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  width: 100%;
  height: 100%;
}
.splash-bloom > * { grid-area: 1 / 1; }
.splash-bloom-canvas,
.splash-bloom-fallback {
  display: none;
  width: 100%;
  height: 100%;
}
.splash-bloom.bloom-mounted .splash-bloom-canvas,
.splash-bloom.bloom-mounted .splash-bloom-fallback { display: block; }
.splash-bloom.webgl-ready .splash-bloom-fallback { display: none; }
.splash-bloom-canvas {
  cursor: grab;
  touch-action: none;
}
.splash-bloom-canvas:active { cursor: grabbing; }
.splash-copy {
  position: absolute;
  z-index: 2;
  left: 50%;
  bottom: clamp(30px, 8vh, 86px);
  width: min(92vw, 560px);
  transform: translateX(-50%);
  pointer-events: none;
}
.splash-label {
  font-size: 13px;
  letter-spacing: 0.18em;
  text-transform: uppercase;
}
.splash-sub {
  max-height: 20px;
  margin-top: 4px;
  font-size: 10px;
  text-align: center;
  opacity: 0.6;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  transition: opacity 0.25s ease, max-height 0.25s ease, margin 0.25s ease;
}
.splash-ready-controls {
  display: flex;
  flex-direction: column;
  align-items: center;
  max-height: 0;
  margin-top: 0;
  opacity: 0;
  overflow: hidden;
  pointer-events: none;
  visibility: hidden;
  transition: opacity 0.4s ease, max-height 0.4s ease, margin 0.4s ease;
}
.splash-overlay.ready .splash-sub {
  max-height: 0;
  margin: 0;
  opacity: 0;
}
.splash-overlay.ready .splash-ready-controls {
  max-height: 110px;
  margin-top: 20px;
  opacity: 1;
  pointer-events: auto;
  visibility: visible;
}

/* Tune In keeps the same live canvas. Its renderer moves the camera to a
   top-down close-up; this animation only fades the surrounding chrome after
   the dive has filled the frame. */
.splash-overlay.joining {
  animation: lotus-dive-fade 1.4s ease-in forwards;
  pointer-events: none;
}
.splash-overlay.joining .splash-label,
.splash-overlay.joining .splash-ready-controls {
  opacity: 0;
  transition: opacity 0.18s ease;
}
@keyframes lotus-dive-fade {
  0%, 70% { opacity: 1; }
  100% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
  .splash-overlay.joining { animation: lotus-dive-fade 0.4s linear forwards; }
}
.join-btn {
  font-family: var(--mono);
  font-size: 16px;
  letter-spacing: 0.15em;
  text-transform: uppercase;
  background: var(--paper);
  color: var(--ink);
  border: 2px solid var(--ink);
  padding: 16px 28px;
  cursor: pointer;
  touch-action: manipulation;
  min-height: 44px;
}
.join-btn:hover { background: color-mix(in srgb, var(--ink) 8%, var(--paper)); }
/* ---- full-screen network ident/bumper (punchlist 2026-07-15, camera/
   broadcast production: "full-screen network idents/bumpers ... in the same
   stylized monochrome style, used both as pure branding beats..." — pure-
   branding-beat half only, see modules/ident.js's header comment) ----
   Same ink-on-paper full-screen treatment as .join-overlay (opaque, covers
   everything) but non-interactive (pointer-events stays off — it's a
   transition beat, not a gate the viewer has to act on) and driven purely by
   an opacity fade rather than display:none, so IDENT_DISPLAY_MS reads as a
   real stinger transition instead of a hard cut. Carries its own
   .dither-overlay child so the card gets the exact same fine-grain dither
   grain #scene-container does (see dither.js) — the two never share one
   overlay element since the ident sits in front of the scene, not behind. */
.ident-overlay {
  position: fixed;
  inset: 0;
  background: var(--paper);
  color: var(--ink);
  display: flex;
  align-items: center;
  justify-content: center;
  pointer-events: none;
  z-index: 15;
  opacity: 0;
  transition: opacity 0.35s ease;
}
.ident-overlay.visible { opacity: 1; }
/* Deliberately heavier grain than the world's 0.05 (docs/overlay-layering-design.md:
   L1 "Produced Insert" vs L0 "World" — a network ident is a different piece of tape
   spliced into the broadcast, not a window into the game, so it should read as
   visibly graded differently, the way a real network bumper looks like a distinct
   videotape generation from the live feed around it. Reasoned starting value, not
   verified against a rendered frame — no headless browser on this box; tune by eye
   if it reads wrong live. */
.ident-overlay .dither-overlay { opacity: 0.11; }
.ident-card {
  position: relative;
  text-align: center;
  padding: 20px 32px;
  /* No border here on purpose (punchlist 2026-07-16 marquee redux) — the
     drawn-on SVG corner brackets below ARE the frame now; a plain box
     border around them would read as a second, redundant, boring frame
     competing with the theatrical one. */
}
/* ---- marquee wordmark (punchlist 2026-07-16 "huge redux" — see
   modules/identMarquee.js's header comment for the full design: bold
   single-stroke corner brackets + letterforms + bracketing rules that fly
   in from the four screen corners and stroke-draw on). `overflow: visible`
   is load-bearing: every piece starts translated well outside the SVG's own
   viewBox (that's the "fly in" part) and the default SVG clip would hide it
   there until it crossed back inside — see identMarquee.js's cornerOffset.
   `vector-effect: non-scaling-stroke` keeps the line weight a constant
   ON-SCREEN pixel width regardless of how much the responsive viewBox ends
   up scaled up/down, the same "hairline stays a hairline" discipline as the
   rest of this project's line art. */
.ident-marquee {
  display: block;
  width: min(480px, 82vw);
  height: auto;
  overflow: visible;
  margin: 0 auto;
}
.ident-marquee-line,
.ident-marquee-glyph,
.ident-marquee-mark {
  fill: none;
  stroke: var(--ink);
  stroke-linecap: square;
  stroke-linejoin: miter;
  vector-effect: non-scaling-stroke;
}
.ident-marquee-line { stroke-width: 3; }
/* Letters read bolder than the frame/rule accents — the wordmark is the
   point, the frame is trim around it. Currently unused in index.html's
   markup (the center glyph is the samsara wheel below, not S/N letters —
   see identMarquee.js's 2026-07-20 header comment) but kept for any future
   piece that reuses modules/identMarquee.js's letterPathD. */
.ident-marquee-glyph { stroke-width: 7; }
/* The samsara wheel mark (2026-07-20, modules/samsaraMark.js) — several
   close concentric rings in a small box, so this reads as a hairline like
   the frame accents (.ident-marquee-line) rather than the bold letter
   weight above: a thick stroke here would fuse the rings together. */
.ident-marquee-mark { stroke-width: 1.6; }
/* Visually-hidden (not display:none — screen readers should still see the
   real, config-driven abbreviation) accessible fallback for the decorative
   marquee above; see index.html's comment just above this element. Standard
   clip-based hidden-but-announced pattern. */
.ident-network-abbr {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}
.ident-network {
  margin-top: 6px;
  font-size: 13px;
  letter-spacing: 0.18em;
  text-transform: uppercase;
}
.ident-rule { margin: 16px auto; width: 64px; height: 0; border-top: 1px solid var(--ink); }
.ident-league {
  font-size: 11px;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  opacity: 0.75;
}

/* Full-screen commercial break: the same single live bloom iframe is moved
   here from the splash/ident host, so its animation and viewer-controlled
   orbit continue without a renderer restart. */

/* ---- MIND VIEW (punchlist #54, modules/mindPanel.js) ----
   Double-click a hovered on-field figure to open this panel over a dim
   backdrop (unlike .suggest-overlay's non-blocking chrome, this IS a real
   modal — a deliberate, on-demand deep-dive the viewer opened, not an
   always-present HUD element) showing that figure's imagined-thought
   history. Closed via the header's ✕, clicking the backdrop, or Escape.
   Highest z-index of the INTERACTIVE overlay family (30) since it's the
   most recently and deliberately opened thing on screen — .splash-overlay
   (40) sits above even this, but only ever during initial load, long
   before a viewer could have anything hovered to double-click. */
.mind-overlay {
  position: fixed;
  inset: 0;
  z-index: 30;
  display: flex;
  align-items: center;
  justify-content: center;
  background: color-mix(in srgb, var(--ink) 40%, transparent);
  pointer-events: auto;
}
.mind-overlay.hidden { display: none; }
.mind-panel {
  width: min(420px, 90vw);
  max-height: min(70vh, 560px);
  display: flex;
  flex-direction: column;
  background: var(--paper);
  color: var(--ink);
  border: 1px solid var(--ink);
  font-size: 11px;
}
/* H9-2 (2026-08-01) — "a silly swivelling animation [and] an actual
   baseball-card-style card flies up" (VISION.md §6, quoting the owner
   directly). modules/mindPanel.js's playSwivelIn removes then re-adds this
   class on every open (a class that's already present doesn't restart a CSS
   animation on re-add alone, hence the remove+reflow+add dance there) — so
   it replays each time a different figure is clicked, not just the first.
   Applies to every open, card or not: this is panel-chrome flourish, not a
   claim about what's inside. Pivots from the BOTTOM edge (transform-origin)
   so it genuinely reads as "flies up" rather than spinning in place. */
.mind-panel.mind-swivel-in {
  animation: mind-swivel-in 480ms cubic-bezier(0.22, 1.15, 0.3, 1) both;
  transform-origin: 50% 100%;
}
@keyframes mind-swivel-in {
  0%   { transform: perspective(800px) rotateY(78deg) translateY(46px) scale(0.92); opacity: 0; }
  55%  { transform: perspective(800px) rotateY(-10deg) translateY(-6px) scale(1.01); opacity: 1; }
  100% { transform: perspective(800px) rotateY(0deg) translateY(0) scale(1); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
  .mind-panel.mind-swivel-in { animation: none; }
}
/* ★ H9-2 — THE CARD LAYOUT ITSELF. VISION.md §6, verbatim: click a player,
   the card flies up with "their picture... then their stats... below that
   mood, values and the rest... and their log runs down the right-hand
   side." `.mind-card` is added by mindPanel.js's setCardMode ONLY for the
   ordinary player/booth/umpire path — never for a crew CREDENTIAL (ONT-6:
   issuing crew a card would wrongly assert they're the same kind of thing
   as a player) and never for a fictional spectator/civilian (openForSpectator
   — no baseball stats to put on one). Those two keep the plain vertical
   `.mind-panel` flex layout below, completely untouched by this rule.
   Placement is by NAMED GRID AREA, not DOM order: mindPanel.js splices
   .mind-portrait/.mind-stats in with insertAdjacentElement, whose insertion
   order is an implementation detail (see that file's own comment on this),
   so layout must never depend on it. A hidden slot (e.g. no relationships
   folded yet) collapses to zero height automatically — each area is exactly
   as tall as its own (possibly display:none, i.e. absent) content, so there
   is never a gap left behind. */
.mind-panel.mind-card {
  width: min(560px, 92vw);
  max-height: min(74vh, 620px);
  display: grid;
  grid-template-columns: 128px 1fr;
  grid-template-areas:
    "head          head"
    "portrait      log"
    "stats         log"
    "mood          log"
    "traits        log"
    "relationships log"
    "interests     log";
  align-content: start;
  /* H9-2 SCROLL FIX (2026-08-02) — "scrolling on cards not working" (owner
     report, verbatim). The plain .mind-panel path scrolls via flex:1 on
     .mind-panel-body (below), but that panel is display:flex; this one is
     display:grid, so flex:1 there is inert, align-content:start lets the row
     stack grow past max-height with nothing to reach it, and the left
     column (.mind-portrait/.mind-stats/.mind-mood/.mind-traits/
     .mind-relationships/.mind-interests — each its own grid row) had NO
     scroll container at all. Rather than add a second, competing scroller
     inside .mind-panel-body (two scrollers on one small card is exactly what
     produces "scrolling doesn't work" — the wheel hits whichever one is
     already at its end and nothing moves), the CARD ITSELF is the one scroll
     surface; the header pins on top of it via position:sticky below. */
  overflow-y: auto;
}
.mind-card > .mind-panel-header {
  grid-area: head;
  /* Pinned so it stays visible while the card scrolls underneath it —
     background must stay opaque (not transparent) or scrolled content shows
     through. Sits above .mind-panel-body's own z-index:0 default. */
  position: sticky;
  top: 0;
  z-index: 1;
  background: var(--paper);
}
.mind-card > .mind-portrait { grid-area: portrait; }
.mind-card > .mind-stats { grid-area: stats; }
.mind-card > .mind-mood { grid-area: mood; }
.mind-card > .mind-traits { grid-area: traits; }
.mind-card > .mind-relationships { grid-area: relationships; }
.mind-card > .mind-interests { grid-area: interests; }
/* THE LOG, DOWN THE RIGHT-HAND SIDE — the ontology's own phrase. Same
   element, same scroll/status/list children as the plain-panel path
   (.mind-panel-body); only its grid placement + a left hairline (in place of
   the plain layout's implicit top-to-bottom flow) change for the card.
   overflow:visible (not the plain path's overflow-y:auto) so the log grows
   into the CARD's single scroll surface above instead of forming its own,
   second, competing scroller. */
.mind-card > .mind-panel-body {
  grid-area: log;
  border-left: 1px solid var(--ink);
  overflow: visible;
  flex: initial;
  min-height: initial;
}
/* Header carries a small standing "MIND VIEW" eyebrow (pure ::before, no
   markup change — index.html is outside this feature's owned files) above
   the name/close row: flex-wrap + a full-width ::before forces it onto its
   own line, so the panel reads as a distinct broadcast surface ("this is an
   interiority readout") rather than just another name-and-a-close-button
   card (presentation pass, 2026-07-18). */
.mind-panel-header {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  align-items: center;
  gap: 2px 8px;
  padding: 8px 10px;
  border-bottom: 1px solid var(--ink);
  text-transform: uppercase;
  letter-spacing: 0.08em;
  font-weight: bold;
}
/* U-36: `[data-mind-name]` is the card's real subject NAME, not the
   "MIND VIEW" eyebrow/chrome the rest of this header is — see the house
   casing rule at this file's top. The close button (`.chip.chip-icon`,
   already `text-transform: none`) was never affected; only the bare name
   span was inheriting the header's uppercase with no override. */
.mind-panel-header [data-mind-name] { text-transform: none; }
/* MOOD (PLAYER-INTERIORITY Wave 3, additive; 2-axis meter added in the
   2026-07-18 presentation pass): the selected figure's current affect
   (server/src/playerAffect.ts's descriptor over valence/arousal), surfaced
   as a standing line under the header rather than buried in a thought row —
   a viewer should read "how this figure feels right now" at a glance, not
   have to infer it from prose. Hidden via .hidden whenever mountMindPanel has
   no reading for the open figure (see renderMood). Flex row so the meter
   widget and the text label sit side by side; renderMood's plain-text
   fallback (unit tests / a container with no appendChild) still renders fine
   as a single flex item here. */
.mind-mood {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 3px;
  padding: 6px 10px;
  border-bottom: 1px solid var(--ink);
  font-size: 10px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  opacity: 0.8;
}
.mind-mood.hidden { display: none; }
/* Meter + label stay a row (unchanged pairing) within the now-column
   .mind-mood standing line, so the legend below (next rule) can sit on its
   own line without widening the row itself. */
.mind-mood-row {
  display: flex;
  align-items: center;
  gap: 8px;
}
/* AXIS LEGEND (additive readability pass, 2026-07-18): a one-line, plain-
   English key for what the dot's position on the meter actually means —
   without it, "the dot sits up and to the right" only reads as intentional
   to someone who already knows the valence/arousal convention. Faint/small
   so it reads as a caption, not a second label competing with "Mood: X". */
.mind-mood-legend {
  font-size: 8px;
  letter-spacing: 0.04em;
  text-transform: none;
  opacity: 0.5;
}
/* The valence(x)/arousal(y) meter itself: a small square with a faint
   crosshair (::before/::after) and a single solid dot positioned by
   mindPanel.js's buildMoodMeter (inline left/top %) — right = more pleasant,
   up = more activated. Ink-only (opacity, not hue) so it stays correct under
   any palette swap (modules/palette.js). */
.mind-mood-meter {
  position: relative;
  flex: none;
  width: 24px;
  height: 24px;
  border: 1px solid color-mix(in srgb, var(--ink) 45%, transparent);
}
.mind-mood-meter::before,
.mind-mood-meter::after {
  content: '';
  position: absolute;
  background: color-mix(in srgb, var(--ink) 18%, transparent);
}
.mind-mood-meter::before { left: 0; right: 0; top: 50%; height: 1px; }
.mind-mood-meter::after { top: 0; bottom: 0; left: 50%; width: 1px; }
.mind-mood-dot {
  position: absolute;
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--ink);
  transform: translate(-50%, -50%);
}
.mind-mood-label { white-space: nowrap; }
/* RELATIONSHIPS (PLAYER-INTERIORITY Wave 4, additive; per-row list added in
   the 2026-07-18 presentation pass): the selected figure's most-notable
   relationships (server/src/playerRelationships.ts), under mood — same
   standing-line treatment as .mind-mood just above, its own rule only so
   mood and relationships can be independently hidden. The plain italic
   sentence styling here is renderRelationships' fallback (unit tests /
   pre-appendChild container); the rich path below renders one .mind-rel-row
   per relationship instead, each overriding font-style locally. */
.mind-relationships {
  padding: 6px 10px;
  border-bottom: 1px solid var(--ink);
  font-size: 10px;
  letter-spacing: 0.02em;
  opacity: 0.8;
  font-style: italic;
  line-height: 1.4;
}
.mind-relationships.hidden { display: none; }
.mind-rel-row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 8px;
  margin-bottom: 3px;
}
.mind-rel-row:last-child { margin-bottom: 0; }
.mind-rel-name { font-style: italic; }
.mind-rel-tag {
  flex: none;
  font-style: normal;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  font-size: 9px;
  opacity: 0.7;
  white-space: nowrap;
}
/* fond/friendly lean bold+opaque, wary/resentful lean faint — direction read
   entirely off weight/opacity (never hue), same "safe under any palette"
   discipline as the mood meter above. */
.mind-rel-tag--fond, .mind-rel-tag--friendly { font-weight: bold; opacity: 0.9; }
.mind-rel-tag--wary, .mind-rel-tag--resentful { opacity: 0.5; }
/* Warmth bar (readability pass, 2026-07-18): a short track with a visible
   zero-line at its CENTER, filling outward from that midpoint — right for
   pos, left for neg (::before fills to half of --pct, set inline by
   renderRelationships from |affinity|, since each half of the track
   represents the full 0-100% intensity range in its own direction). This
   replaces the old left-aligned fill that told pos/neg apart only by a
   subtle opacity difference (0.85 vs 0.4), which read as "shorter bar" more
   than "opposite direction" at a glance, especially for small |affinity|.
   Direction is now unambiguous from geometry alone, and the always-visible
   track + center tick give a short bar a baseline to read "a little"
   against instead of floating in blank space. */
.mind-rel-bar {
  position: relative;
  flex: none;
  display: inline-block;
  width: 26px;
  height: 4px;
  background: color-mix(in srgb, var(--ink) 12%, transparent);
}
.mind-rel-bar::after {
  content: '';
  position: absolute;
  left: 50%;
  top: -1px;
  bottom: -1px;
  width: 1px;
  background: color-mix(in srgb, var(--ink) 35%, transparent);
}
.mind-rel-bar::before {
  content: '';
  position: absolute;
  top: 0;
  bottom: 0;
  width: calc(var(--pct, 0%) / 2);
  background: var(--ink);
  opacity: 0.85;
}
.mind-rel-bar--pos::before { left: 50%; }
.mind-rel-bar--neg::before { right: 50%; }
/* VALUES/DRIVES/ANXIETIES (H9-2 follow-up, 2026-08-01 — THE ONTOLOGY §6's
   "values · drives · anxieties · interests · relationships · stats · mood",
   the one leg with no card presence until this task). Deliberately NOT chip
   rows like .mind-interests just below — values/drives/anxieties are a
   different register (what a person IS, not what they're into), and a card
   back has room for a stat block and one dry line (VISION.md §1's own form
   constraint), so this renders as exactly that: one plain caption sentence
   ("Values honor and patience, driven by mastery and legacy, fears
   isolation."), never a tag cloud. modules/mindPanel.js's renderTraits
   populates it; traitsSummaryLine is the pure sentence-builder. Same "own
   rule so it can hide independently" pattern as .mind-mood/.mind-
   relationships/.mind-interests, and the same "spliced in by JS, no static
   markup hook" idiom as .mind-interests uses — but after [data-mind-mood],
   not .mind-relationships, so DOM order matches VISION.md §6's own stated
   sequence ("stats... mood, values and the rest"). */
.mind-traits {
  padding: 6px 10px;
  border-bottom: 1px solid var(--ink);
  font-size: 10px;
  line-height: 1.4;
  font-style: italic;
  opacity: 0.75;
}
.mind-traits.hidden { display: none; }
/* INTERESTS/CONCERNS (reader vertical slice, additive, 2026-07-18):
   server/src/playerInterests.ts's topic-id sets, surfaced as a standing line
   under relationships — same "own rule so it can hide independently" pattern
   as .mind-mood/.mind-relationships. There is no static markup hook for this
   in index.html (out of this feature's owned files); modules/mindPanel.js
   creates the container itself and splices it in after .mind-relationships,
   so it only ever exists as a real DOM node — no plain-text fallback rule
   needed here the way .mind-mood/.mind-relationships carry for their own
   caller-supplied containers. */
.mind-interests {
  display: flex;
  flex-direction: column;
  gap: 4px;
  padding: 6px 10px;
  border-bottom: 1px solid var(--ink);
  font-size: 10px;
  opacity: 0.8;
}
.mind-interests.hidden { display: none; }
.mind-interests-row {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 5px;
}
.mind-interests-label {
  flex: none;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  font-size: 9px;
  opacity: 0.6;
  white-space: nowrap;
}
.mind-tag {
  flex: none;
  border: 1px solid color-mix(in srgb, var(--ink) 35%, transparent);
  border-radius: 2px;
  padding: 1px 5px;
  font-size: 9px;
  line-height: 1.5;
  white-space: nowrap;
}
/* Interests read plain/opaque; concerns lean italic + a touch fainter — same
   ink-only, weight/opacity-driven direction cue the relationship tags use,
   never a new hue. */
.mind-tag--interest { opacity: 0.9; }
.mind-tag--concern { opacity: 0.65; font-style: italic; }
/* CREW CREDENTIAL, NOT A CARD (ONT-6, 2026-08-01 — VISION.md §6's owner
   ruling): "a player's card encodes a person as stats; crew have none, and
   issuing them one would quietly assert they are the same kind of thing."
   modules/mindPanel.js's renderCredential populates this in place of (never
   alongside) .mind-mood/.mind-relationships/.mind-interests whenever the
   fetched figure is crew — deliberately a DIFFERENT shape (an eyebrow +
   plain label/value rows, no meter, no tag chips, no bars) so the form
   itself reads as "not like the others" the way VISION.md's ruling asks for,
   with nothing on screen explaining why. Same "container created by JS,
   spliced in after .mind-relationships, no static markup hook" idiom as
   .mind-interests just above. */
.mind-credential {
  display: flex;
  flex-direction: column;
  gap: 3px;
  padding: 6px 10px;
  border-bottom: 1px solid var(--ink);
  font-size: 10px;
}
.mind-credential.hidden { display: none; }
.mind-credential-eyebrow {
  text-transform: uppercase;
  letter-spacing: 0.12em;
  font-size: 8px;
  opacity: 0.55;
  margin-bottom: 2px;
}
.mind-credential-row {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: 8px;
}
.mind-credential-label {
  flex: none;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  font-size: 9px;
  opacity: 0.6;
  white-space: nowrap;
}
.mind-credential-value {
  text-align: right;
}
/* PORTRAIT (H9-2, likeness cue I-93/I-71 2026-08-06) — the ontology's own
   first card element: "their picture, as they are modelled in game". A REAL
   in-game render is investigated and NOT wired, on purpose (see modules/
   mindPanel.js's renderPortrait for the full reasoning): the client's only
   figure renderer draws every on-field actor together in one shared NPR
   pass with no per-actor camera/render target, and the WebGLRenderer isn't
   even configured with `preserveDrawingBuffer` — there is no surface to
   capture a real render FROM today. Rather than fake a photo, this slot now
   holds a DETERMINISTIC, PROCEDURALLY GENERATED drawn face
   (modules/portraitAvatar.js — see that file's header for the full
   rationale): same seed always produces the same face, distinct figures
   read as visibly distinct. Same "container created by JS, spliced in after
   .mind-relationships, no static markup hook" idiom as .mind-interests/
   .mind-credential above — always shown while a card is open (this is a
   FIXED card slot per the ontology, not an optional signal like mood/
   relationships/interests, which is why it has no accompanying "hide when
   empty" case the way those three do). */
.mind-portrait {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 4px;
  padding: 8px;
  border-bottom: 1px solid var(--ink);
}
.mind-portrait.hidden { display: none; }
.mind-portrait-frame {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  aspect-ratio: 3 / 4;
  border: 1px solid var(--ink);
  background: color-mix(in srgb, var(--ink) 4%, var(--paper));
}
/* The generated avatar SVG (portraitAvatar.js's buildAvatarSvg) — same
   "sparse vector-line, no fills, follows the active palette" idiom as
   .ident-marquee-line/.ident-marquee-mark above: stroke picked up from
   `color` via currentColor rather than a hardcoded hex, so a palette switch
   (modules/palette.js) recolors every open card portrait along with
   everything else, with zero portrait-specific code. */
.mind-portrait-avatar {
  width: 82%;
  height: 82%;
  color: var(--ink);
  overflow: visible;
}
.mind-portrait-avatar-line {
  fill: none;
  stroke: currentColor;
  stroke-width: 1.1;
  stroke-linecap: round;
  stroke-linejoin: round;
  vector-effect: non-scaling-stroke;
  opacity: 0.82;
}
/* STATS (H9-2) — the ontology's second card element: "then their stats".
   Real numbers only: modules/mindPanel.js reads these straight off the
   client's own live per-broadcast StatsTracker (modules/stats.js, the same
   tally the stats sidebar already renders from) — there is no season/career
   stat store reachable from an arbitrary clicked mlbam, hence the eyebrow
   saying so plainly rather than implying a fuller line than the data
   supports. Also a FIXED slot (see .mind-portrait's comment on the
   distinction) — "No stats yet this broadcast" is itself this slot's honest
   content for a player who hasn't played yet, not a reason to hide it. */
.mind-stats {
  display: flex;
  flex-direction: column;
  gap: 3px;
  padding: 6px 10px;
  border-bottom: 1px solid var(--ink);
  font-size: 10px;
}
.mind-stats.hidden { display: none; }
.mind-stats-eyebrow {
  text-transform: uppercase;
  letter-spacing: 0.1em;
  font-size: 8px;
  opacity: 0.55;
}
.mind-stats-empty { opacity: 0.55; font-style: italic; }
.mind-stats-row {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 6px;
}
.mind-stats-row-label {
  flex: none;
  font-weight: bold;
  letter-spacing: 0.06em;
  opacity: 0.6;
  font-size: 9px;
}
.mind-stats-cell {
  flex: none;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
/* LIFE-LOG DENSITY (readability pass, 2026-07-18): this is already the
   panel's sole scrolling surface (.mind-panel itself is height-capped above,
   flex-column, so this is the only element that grows instead of it) — the
   inset shadow just makes that fact legible: a permanent, subtle top/bottom
   vignette that reads as "this area scrolls" whether or not the log is
   currently long enough to overflow, so a long history never needs to push
   the panel itself off-screen and older entries fade toward the edge rather
   than crowd flush against the header/close button. */
.mind-panel-body {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  padding: 10px 12px;
  box-shadow:
    inset 0 8px 6px -6px color-mix(in srgb, var(--ink) 14%, transparent),
    inset 0 -8px 6px -6px color-mix(in srgb, var(--ink) 14%, transparent);
}
.mind-status {
  opacity: 0.6;
  font-style: italic;
  padding: 12px 0;
  text-align: center;
}
.mind-status.hidden { display: none; }
/* STATUS KINDS (readability pass, 2026-07-18): loading/empty/error used to
   share this one undifferentiated faint-italic look — a stalled fetch and
   "nothing to see here" read identically. --loading pulses gently (still
   in progress, not stuck); --error drops the italic/faint treatment
   entirely and gets a small marker so a real failure doesn't read as just
   another quiet ambient state. --empty is the plain base look above,
   unchanged (that's the closest fit to "nothing here yet" as-is). Ink-only
   throughout — no new color, matches the house palette. */
.mind-status--loading { animation: mind-status-pulse 1.4s ease-in-out infinite; }
@keyframes mind-status-pulse {
  0%, 100% { opacity: 0.35; }
  50% { opacity: 0.75; }
}
.mind-status--error {
  font-style: normal;
  font-weight: bold;
  opacity: 0.85;
}
.mind-status--error::before { content: '⚠ '; opacity: 0.7; }
/* LIFE-LOG (PLAYER-INTERIORITY Wave 4): a chronological stream of every
   thought/social/interstitial entry on file for the open figure. FORMATTING
   HIERARCHY (the whole point of this wave): .mind-thought (thought + social
   — the meaningful moments) stays BOLD/prominent, at the base panel scale;
   .mind-interstitial (a cough, an adjusted cap — quiet texture between them)
   is small/faint and carries no border/meta row of its own, so the log reads
   as a life stream where the small human ticks recede rather than compete
   for attention. A tighter gap than before (8px, was 10px) since
   interstitial rows are meant to sit close to their neighbors.

   A left "spine" (border-left on .mind-list) plus a per-entry ::before dot
   (2026-07-18 presentation pass) turns the stream into a readable timeline:
   .mind-thought gets a solid dot, .mind-thought--social a hollow ring (the
   same bold tier, visually marked as an exchange rather than private prose),
   .mind-interstitial a tiny faint dot. Pure CSS — no new DOM per entry, so
   this costs nothing structurally and can't drift from renderList's classes. */
.mind-list {
  display: flex;
  flex-direction: column;
  gap: 8px;
  margin-left: 4px;
  padding-left: 12px;
  border-left: 1px dotted color-mix(in srgb, var(--ink) 22%, transparent);
}
/* LOG VOICE NOTICE (H9-2 follow-up §3, 2026-08-01): server/src/thoughtsApi.
   ts's `logPredatesVoiceFix` — true when every visible entry in this
   figure's log predates the register/grounding fix (see that file's
   THOUGHT_VOICE_FIX_TS comment for the exact commits/measurements this
   dates itself to). THE DECISION this task made: leave the historical log
   completely alone — a log is a record, rewriting it to look better than it
   was is its own dishonesty — and say so plainly instead, as the first row
   of the log itself (renderList), never a hidden or rewritten entry. No dot/
   border (unlike .mind-thought) so it reads as a caption ABOUT the log, not
   an entry IN it. Self-expiring: the moment a fresher thought lands for this
   figure it becomes the newest entry, the server flips the flag false, and
   this row stops appearing on the next render — no further code needed. */
.mind-log-notice {
  padding-bottom: 4px;
  font-size: 9px;
  letter-spacing: 0.02em;
  font-style: italic;
  opacity: 0.5;
}
.mind-thought {
  position: relative;
  border-bottom: 1px dotted color-mix(in srgb, var(--ink) 25%, transparent);
  padding-bottom: 8px;
}
.mind-thought:last-child { border-bottom: none; padding-bottom: 0; }
.mind-thought::before {
  content: '';
  position: absolute;
  left: -15.5px;
  top: 3px;
  width: 5px;
  height: 5px;
  border-radius: 50%;
  background: var(--ink);
}
.mind-thought--social::before {
  background: var(--paper);
  border: 1px solid var(--ink);
}
/* U-47 (2026-08-05): WORK/PLAY get their own shape marks, same ink-only
   discipline as thought (solid dot) / social (hollow ring) above — a square
   for work, a diamond (rotated square) for play. No new colour, no icon
   font — pure CSS shape, so it can't drift from renderList's kind classes. */
.mind-thought--work::before {
  border-radius: 0;
}
.mind-thought--play::before {
  border-radius: 0;
  transform: rotate(45deg);
}
.mind-thought-meta {
  font-size: 9px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  opacity: 0.5;
  margin-bottom: 3px;
}
.mind-thought-text { line-height: 1.4; font-style: italic; font-weight: 600; }
/* A social entry ("traded words with X") is a plain grounded fact, not
   interior prose — same bold/prominent tier as a thought, just not
   italicized (italics stay reserved for the figure's own imagined voice).
   U-47 (2026-08-05): work/play daily-action lines (harness/thoughtWriter.ts
   fillDailySlot's 'work'/'play' categories, e.g. "keeps to the small
   business of the game") are the same kind of grounded fact/action report,
   not imagined interior voice -- same non-italic treatment. */
.mind-thought--social .mind-thought-text,
.mind-thought--work .mind-thought-text,
.mind-thought--play .mind-thought-text { font-style: normal; }
/* The small/faint tier — deliberately NOT a peer of .mind-thought (no
   border, no uppercase meta row, no bold): a single quiet line so a run of
   several in a row reads as background texture, not a list of equals. Now a
   flex row (text + a trailing faint relative-time span) rather than one bare
   text node, so recency stays visible without a bold meta row. */
.mind-interstitial {
  position: relative;
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: 8px;
  padding: 0 0 0 2px;
  font-size: 9px;
  font-style: italic;
  line-height: 1.5;
  opacity: 0.42;
  letter-spacing: 0.01em;
}
.mind-interstitial::before {
  content: '';
  position: absolute;
  left: -14.5px;
  top: 6px;
  width: 3px;
  height: 3px;
  border-radius: 50%;
  background: var(--ink);
}
.mind-interstitial-text { flex: 1 1 auto; }
.mind-interstitial-time {
  flex: none;
  font-style: normal;
  text-transform: uppercase;
  letter-spacing: 0.04em;
}

/* ---- side panels: compact + collapsible (UI cleanup 2026-07-14) ----
   Both .stats-panel and .feed-panel carry a click-to-collapse header
   (`[data-collapse-header]` — see ui.js wireCollapsible, state persisted per
   panel in localStorage) so the field view can be kept clean without losing
   either panel outright. Panels stay FULLY OPAQUE at all times — the earlier
   idle auto-hide/gray-out (faded panels to ~12% after a few idle seconds) was
   removed per user feedback; there is no longer any opacity fade.

   Collapsed = fully hidden except a small, clearly-clickable re-open TAB: the
   panel shrinks to just its header bar (body hidden, width/height shrink to fit
   the label, nav hidden), rather than leaving the full-size box behind. Click
   the tab to re-open. */
[data-collapse-header] { cursor: pointer; user-select: none; }
.panel-caret { display: inline-block; margin-right: 5px; transition: transform 0.15s ease; }
.collapsed .panel-caret { transform: rotate(-90deg); }

/* Collapsed: hide the body, shrink the panel to a compact header-only tab. */
.stats-panel.collapsed .stats-panel-body,
.feed-panel.collapsed .feed-list { display: none; }
.stats-panel.collapsed,
.feed-panel.collapsed { width: auto; }
/* U-68 (2026-08-08): the feed panel now sizes to its own content up to
   max-height rather than stretching to a fixed bottom anchor (see
   .feed-panel's own comment), so collapsing it no longer needs to override
   a `bottom` anchor — `max-height` alone already lets it shrink to just the
   header once `.feed-list` is hidden below. Kept as an explicit 0 rather
   than deleted outright: it documents the collapsed state's intent and
   costs nothing now that `max-height` does the real work. */
.feed-panel.collapsed { max-height: none; }
/* Nothing but the label + caret in the tab (hide the stats prev/next nav). */
.stats-panel.collapsed .stats-nav { display: none; }
/* No dangling divider under the header once the body is gone. */
.stats-panel.collapsed .stats-panel-header,
.feed-panel.collapsed .feed-panel-header { border-bottom: none; }

/* ---- stats pane (STATS & BANTER track) ----
   Anchored top-right, independent of the .overlay flex flow (like
   .controls) so it never displaces the scoreboard (top-left) or the
   bottom-left play-by-play log (feed.js) another track owns.
   Interactive (roster click, prev/next, collapse), so pointer-events
   re-enabled locally. Compact by default (narrow, small type).

   BANNER-OVERLAP FIX (2026-07-14, remeasured 2026-07-18 for the White-Sox-
   style scorebug rebuild): the panel is right-anchored, so as the window
   NARROWS its left edge slides leftward across the top-right of the
   scoreboard — it used to cover part of the banner. It's anchored BELOW the
   banner's full height so it can never overlap it at any width above the
   480px phone breakpoint (where it's hidden entirely). The scorebug rebuild
   made the banner noticeably taller than the old single-row 5-box layout —
   it now stacks a main bar + a day/league strip + a batter/pitcher sub-panel
   (.matchup-block, held to a single line per side via the ellipsis rules on
   .matchup-row/.matchup-stat-line so this height stays a known quantity) —
   measured ~148px tall at its realistic worst case (5-figure score/inning,
   3-digit day). top:180px clears that with margin. NOTE: in the live app
   modules/ui.js's injected "compact controls" stylesheet OVERRIDES this
   top/max-height with !important (see its own matching comment) — this base
   rule is what a bare index.html without that script would still get, kept
   in sync by hand. The scoreboard also gets a higher z-index (below) as
   belt-and-suspenders. */
/* U-43 (2026-08-07): 10px->11px — the BOX panel (roster + last-pitch stat
   card), scroll-bounded (max-height above is a viewport calc, .stats-roster
   is independently overflow-y:auto), so this only ever trims visible rows,
   never overflows the panel. */
.stats-panel {
  position: absolute;
  top: calc(180px + env(safe-area-inset-top));
  right: calc(14px + env(safe-area-inset-right));
  z-index: 1;
  width: 200px;
  max-height: calc(100vh - 194px - env(safe-area-inset-top) - env(safe-area-inset-bottom));
  display: flex;
  flex-direction: column;
  border: 1px solid var(--ink);
  background: var(--paper);
  font-size: 11px;
  pointer-events: auto;
  overflow: hidden;
}
.stats-panel-body { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-height: 0; }
.stats-panel-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 5px 7px;
  border-bottom: 1px solid var(--ink);
  text-transform: uppercase;
  letter-spacing: 0.1em;
  /* U-43 (2026-08-07): 10px->11px, matching .feed-panel-header's own bump
     just below (same eyebrow-label register). */
  font-size: 11px;
  opacity: 0.85;
  flex: 0 0 auto;
}
.stats-nav { display: flex; align-items: center; gap: 6px; }
.stats-nav span { min-width: 3.5ch; text-align: center; opacity: 0.7; }
button.stats-chip {
  padding: 1px 7px;
  font-size: 12px;
  line-height: 1.4;
  min-height: 0;
}
.stats-live { padding: 6px 8px; border-bottom: 1px dotted color-mix(in srgb, var(--ink) 35%, transparent); }
.stats-live-row {
  display: flex;
  justify-content: space-between;
  gap: 8px;
  padding: 1px 0;
  opacity: 0.85;
}
.stats-live-row span:first-child { opacity: 0.55; letter-spacing: 0.06em; }
.stats-live-row span:last-child { text-align: right; word-break: break-word; }

.stats-card { padding: 6px 8px; border-bottom: 1px solid var(--ink); }
/* U-36: this is the real selected player's NAME — see the house casing rule
   at this file's top. `.stats-card-meta` below stays uppercase (team
   abbreviation/position/status, our own formatting, not a proper name). */
.stats-card-name { font-weight: bold; text-transform: none; letter-spacing: 0.04em; }
/* U-43 (2026-08-07): 10px/.55 -> 11px/.62, same size+contrast pairing as
   .matchup-stat-line above. */
.stats-card-meta { opacity: 0.62; font-size: 11px; margin-top: 1px; text-transform: uppercase; letter-spacing: 0.06em; }
.stats-card-line { margin-top: 4px; opacity: 0.9; line-height: 1.35; }

.stats-roster {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  padding: 2px 0;
}
.stats-roster-group {
  padding: 4px 8px 2px;
  font-size: 9px;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  opacity: 0.5;
}
.stats-roster-row {
  padding: 3px 8px;
  cursor: pointer;
  opacity: 0.8;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.stats-roster-row:hover { background: color-mix(in srgb, var(--ink) 8%, var(--paper)); }
.stats-roster-row.active { background: var(--ink); color: var(--paper); opacity: 1; }
.stats-roster-row.inactive { opacity: 0.4; font-style: italic; }

/* ---- play-by-play feed (the single, consolidated game log) ----
   Bottom-left. This used to be additive to a separate one-line
   `.ticker[data-ticker]` (owned by scoreboard.js/playback.js) stacked below
   it — the two were redundant (per user feedback), so the ticker is gone and
   this panel is now the ONLY log surface, decomposing every event into
   several fine-grained lines, fed in sync with on-screen play (see
   playback.js onSettle). Anchored with both `top` and `bottom` (rather than
   a fixed height) so its height is whatever room is left between the
   scoreboard above and the captions row below. Compact by default (narrower
   than before, small type); pointer-events re-enabled locally so the line
   list can be scrolled back through by hand (like .stats-roster). */
/* U-43 (2026-08-07): 10px->11px, both this panel and its header below — the
   panel is height-computed from viewport (top+bottom calc, not a fixed
   height) and already internally scrollable (.feed-list), so a font bump
   only ever shows fewer lines before scrolling, never clips. */
.feed-panel {
  /* U-68 (2026-08-08): this used to run `top` to `bottom` unconditionally —
     a fixed-height frame drawn around whatever few lines had actually
     logged, which on a fresh half-inning (or right after WATCH LIVE) meant
     a border enclosing ~70% dead void. A container that draws a frame
     around nothing asserts something belongs there. Sized to content now
     (no `bottom`, `height: auto` is the default for an absolutely-positioned
     flex column with neither `top`+`bottom` nor an explicit height) and
     capped with `max-height` at the SAME footprint the old fixed frame used,
     so a long-running game's real backlog (feed.js's MAX_LINES=1200) still
     scrolls inside a bounded panel rather than growing off the screen — the
     panel's own natural bottom edge (its most recent line) is the new
     bottom, until it's grown enough to hit the cap. */
  position: absolute;
  left: calc(14px + env(safe-area-inset-left));
  top: calc(200px + env(safe-area-inset-top));
  max-height: calc(100vh - 200px - 64px - env(safe-area-inset-top) - env(safe-area-inset-bottom));
  width: 260px;
  display: flex;
  flex-direction: column;
  border: 1px solid var(--ink);
  background: var(--paper);
  font-size: 11px;
  pointer-events: auto;
  overflow: hidden;
}
.feed-panel-header {
  padding: 5px 7px;
  border-bottom: 1px solid var(--ink);
  text-transform: uppercase;
  letter-spacing: 0.1em;
  font-size: 11px;
  opacity: 0.85;
  flex: 0 0 auto;
}
.feed-list {
  /* U-68: was `flex: 1`, which forces this to fill the panel's OLD fixed
     height regardless of content — the direct cause of the void. `flex: 0 1
     auto` sizes to its own content (line-by-line, as they arrive) and only
     shrinks (never grows past) the panel's `max-height` once there's enough
     backlog to fill it, at which point overflow-y:auto takes over exactly
     as before. */
  flex: 0 1 auto;
  overflow-y: auto;
  padding: 4px 8px;
  display: flex;
  flex-direction: column;
  gap: 1px;
}
/* Newest line is appended at the BOTTOM (documented ordering choice — reads
   like a scrolling console) and panel auto-scrolls there unless the reader
   has scrolled up (see feed.js mountFeedPanel). */
.feed-line {
  padding: 2px 0;
  border-bottom: 1px dotted color-mix(in srgb, var(--ink) 20%, transparent);
  line-height: 1.3;
  opacity: 0.75;
}
.feed-line:last-child { opacity: 1; border-bottom: none; }

/* FIGURES+UMPS+LIFE: imagined between-play conversations (see dialogue.js /
   feed.js logConversation). Set clearly apart from the mechanical play-by-play
   — italic, indented, with a small chat glyph — so the log reads as overheard
   dugout/field chatter interleaved with the calls. */
.feed-line.feed-chat {
  font-style: italic;
  padding-left: 16px;
  position: relative;
  opacity: 0.62;
  border-bottom: none;
}
.feed-line.feed-chat::before {
  content: "\201C"; /* left double quote as a small chat glyph */
  position: absolute;
  left: 3px;
  font-style: normal;
  opacity: 0.7;
}
.feed-line.feed-chat:last-child { opacity: 0.85; }

@media (max-width: 480px) {
  /* Same call as .stats-panel just above: no room on a compact phone layout
     for a secondary detail panel. */
  .feed-panel { display: none; }
}

/* ---- dev.html scrub bar ---- */
.scrub-bar {
  position: fixed;
  left: 0; right: 0; bottom: 0;
  background: var(--paper);
  border-top: 1px solid var(--ink);
  padding: 8px 14px;
  display: flex;
  align-items: center;
  gap: 10px;
  pointer-events: auto;
  z-index: 15;
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: 0.06em;
}
.scrub-bar input[type="range"] { flex: 1; accent-color: var(--ink); }
.scrub-bar .label { min-width: 90px; }

/* ---- touch tap targets (real pointing devices, any viewport size) ---- */
@media (pointer: coarse) {
  .chip {
    min-width: 40px;
    min-height: 32px;
    padding: 6px 10px;
  }
  .chip-icon {
    min-width: 40px;
    min-height: 32px;
  }
}

/* ---- portrait phone layout (360-430px-class widths) ----
   Full-bleed canvas is already achieved by #scene-container (position:fixed;
   inset:0). Here: shrink the scorebug bar to a compact single row of team
   abbrs/scores/inning/outs/count, drop the day/league strip and the
   batter/pitcher sub-panel (secondary info that doesn't fit), and keep
   captions readable. */
@media (max-width: 480px) {
  .overlay { padding: 10px; }
  .overlay {
    padding-top: calc(10px + env(safe-area-inset-top));
    padding-bottom: calc(10px + env(safe-area-inset-bottom));
    padding-left: calc(10px + env(safe-area-inset-left));
    padding-right: calc(10px + env(safe-area-inset-right));
  }

  .scoreboard {
    width: 100%;
    margin-top: 28px;
  }

  .scorebug-bar { flex-wrap: nowrap; }

  .scorebug-net { min-width: 0; padding: 3px 5px; }
  .scorebug-net-abbr { font-size: 10px; }
  .scorebug-net-league { display: none; }

  /* flex: 0 0 auto (NOT 1 1 auto) — a shrinkable neighbor next to fixed-width
     siblings is exactly how a big score got crushed down to a few unreadable
     pixels here before (2026-07-18 fix): the team abbr is always <=3 chars
     (see deriveAbbr) and the score has its own data-digits step-down above,
     so this column's content width is already bounded — it doesn't need to
     (and must never be allowed to) shrink below it. */
  .scorebug-teams { min-width: 0; flex: 0 0 auto; padding: 2px 6px; }
  .scorebug-team-row { gap: 6px; }
  .scorebug-team-abbr { font-size: 10px; }
  .scorebug-team-score { font-size: 13px; }
  .scorebug-team-score[data-digits="4"] { font-size: 12px; }
  .scorebug-team-score[data-digits="5"] { font-size: 11px; }
  .scorebug-team-score[data-digits="6"] { font-size: 9px; }

  .scorebug-inning { flex: 0 0 auto; padding: 2px 8px; min-width: 0; }
  .scorebug-inning-num { font-size: 22px; }
  .scorebug-inning-num[data-digits="4"] { font-size: 17px; }
  .scorebug-inning-num[data-digits="5"] { font-size: 13px; }
  .scorebug-inning-num[data-digits="6"] { font-size: 10px; }
  /* U-75: same "drop the small caption first" call as .scorebug-net-league
     above -- mobile space is already tight enough to shrink the hero digits
     themselves at 4+ innings' worth of length. */
  .scorebug-inning-label { display: none; }

  .scorebug-count-bases { flex: 0 0 auto; }
  .scorebug-count { flex: 0 0 auto; min-width: 0; padding: 3px 6px; }
  .scorebug-count-bs { font-size: 10px; }
  .outs-dots { gap: 4px; }
  /* U-44: scaled up from the mobile rule's old 6px in the same proportion as
     the desktop bump above (7->9px) — still smaller than desktop, still
     legible, still fits the compact single-row phone layout. */
  .dot { width: 8px; height: 8px; border-width: 1.2px; }

  .scorebug-bases { flex: 0 0 auto; padding: 3px 6px; }
  .diamond { width: 21px; height: 21px; }
  .base { width: 9px; height: 9px; border-width: 1.5px; }
  /* same corner-placement fix as the desktop rule above */
  .base-second { top: 0; left: 0; }
  .base-first { top: 0; right: 0; }
  .base-third { bottom: 0; left: 0; }

  /* Day/league strip and the At-bat/pitching sub-panel aren't in the
     required compact row and the latter is the biggest overflow risk
     (arbitrary-length player names) — drop both. */
  .scorebug-strip { display: none; }
  .matchup-block { display: none; }

  .bottom-block { gap: 6px; }

  /* U-43 (2026-08-07): 13px->15px, matching the desktop bump above — same
     legibility bar, phone or not. Also caught, live, a real pre-existing
     collision this bump made worse: a wrapped 3-line caption is centered and
     can grow wide enough on a narrow viewport for its own left edge to slide
     under dayNightHud.js's always-on bottom-left badge (own inline styles:
     bottom:14px/left:14px, ~48-60px wide) — confirmed with a real capture
     (a genuine "SAL: Around the horn..." caption overlapping the DAY badge).
     Same idiom this file already uses for the freeroam D-pad/compass
     (.freeroam-hint's own mobile override above): reserve clearance rather
     than let two independent bottom-anchored elements fight for the same
     pixels. 84px comfortably clears the day/night badge's own worst-case
     width ("DAWN"/"DUSK") plus its 14px offset. */
  .captions { font-size: 15px; min-height: 32px; padding: 0 84px; }
  .caption-line { line-height: 1.3; }

  /* Stats pane is a secondary/detail view — no room on a compact phone
     layout (same call as matchup-block above); still reachable on wider
     viewports where it doesn't compete with the core scoreboard. */
  .stats-panel { display: none; }
}

/* ---- free-roam TOUCH controls (2026-07-28, owner: "I'm on my phone right
   now... a quick arrow pad") ----
   Built/shown only on a touch-capable device (modules/freeRoam.js's
   isTouchCapable) and only while free-roam is active (JS toggles
   [hidden] via syncTouchUi) — invisible chrome on desktop otherwise. Same
   --hud-paper/--ink hairline-chip language as .chip, just bigger tap
   targets (pointer:coarse's own 40x32 floor, doubled — these are held and
   dragged, not tapped once). */
.freeroam-touch {
  position: fixed;
  left: calc(14px + env(safe-area-inset-left));
  /* Clears .captions' own max-height (96px, centered at the bottom) plus
     margin — a 2-line caption otherwise sits directly under the D-pad. */
  bottom: calc(110px + env(safe-area-inset-bottom));
  z-index: 45;
  /* display:none by default, NOT relying on the [hidden] attribute alone —
     author CSS origin beats the UA [hidden] default, so an unconditional
     `display:flex` rule below would otherwise win and keep this visible
     (and click-blocking, including over the splash's own TUNE IN button)
     even while [hidden] is set. JS (syncTouchUi) toggles [hidden]; this
     rule is what actually makes that toggle take effect. */
  display: none;
  align-items: flex-end;
  gap: 10px;
  pointer-events: auto;
}
.freeroam-touch:not([hidden]) { display: flex; }
.freeroam-dpad {
  display: grid;
  grid-template-columns: repeat(3, 44px);
  grid-template-rows: repeat(3, 44px);
  gap: 2px;
}
.freeroam-dpad-fwd { grid-column: 2; grid-row: 1; }
.freeroam-dpad-left { grid-column: 1; grid-row: 2; }
.freeroam-dpad-back { grid-column: 2; grid-row: 3; }
.freeroam-dpad-right { grid-column: 3; grid-row: 2; }
.freeroam-vert {
  display: flex;
  flex-direction: column;
  gap: 2px;
}
.freeroam-dpad button, .freeroam-vert button {
  background: var(--hud-paper, var(--paper));
  color: var(--ink);
  border: 1px solid var(--ink);
  font-family: var(--mono);
  font-size: 16px;
  line-height: 1;
  cursor: pointer;
  touch-action: none; /* these are held/dragged, not scrolled through */
  user-select: none;
  -webkit-user-select: none;
  opacity: 0.72;
}
.freeroam-dpad button { width: 44px; height: 44px; }
.freeroam-vert button { width: 36px; height: 36px; }
.freeroam-dpad button:active, .freeroam-vert button:active { background: var(--ink); color: var(--paper); opacity: 1; }

/* U-30 (2026-08-04, "no affordance tells a first-time viewer they can move
   the camera at all"): freeRoam.js's own control-scheme text (WASD/mouse-
   look/etc.) already existed but only ever reached the DOM as a hover
   `title` on the ✈ toggle chip — undiscoverable without already knowing to
   hover, and entirely absent on touch (no hover at all). freeRoam.js's
   showEntryHint mounts this element and toggles `.visible` the first time
   free-roam is actually entered (ONE TIME EVER, localStorage-gated — see
   FREE_ROAM_HINT_STORAGE_KEY), dismissing itself on a timer or the moment
   the viewer's first real move/look input arrives.
   Same "soft plate hugging its own text" idiom as .story-arrival-caption
   above (U-4) — reused deliberately, not reinvented — but BOTTOM-center
   rather than top-center: entering free-roam always hides .scoreboard,
   which collapses .overlay's flex column (`justify-content: space-between`)
   down to its one remaining child (.bottom-block, holding .captions) at the
   TOP (see .story-arrival-caption's own comment for the measured proof),
   leaving the bottom of the frame genuinely clear the whole time free-roam
   is active — including clear of .freeroam-touch's D-pad, which is
   left-anchored (bottom: 110px+) well above this caption's own bottom:24px. */
.freeroam-hint {
  position: fixed;
  bottom: calc(24px + env(safe-area-inset-bottom));
  left: 50%;
  transform: translateX(-50%) translateY(6px);
  text-align: center;
  pointer-events: none;
  z-index: 12;
  opacity: 0;
  transition: opacity 0.5s ease, transform 0.5s ease;
  background: color-mix(in srgb, var(--paper) 88%, transparent);
  backdrop-filter: blur(3px);
  -webkit-backdrop-filter: blur(3px);
  border-radius: 4px;
  padding: 7px 16px;
  max-width: min(86vw, 480px);
  font-size: 12px;
  font-family: var(--mono);
  color: var(--ink);
  letter-spacing: 0.01em;
}
.freeroam-hint.visible {
  opacity: 1;
  transform: translateX(-50%) translateY(0);
}
@media (max-width: 480px) {
  /* Bottom-center collides on a real narrow/touch viewport — confirmed live
     (390x844 capture): .freeroam-touch's D-pad (bottom: 110px+, ~136px tall
     -> its own top edge lands ~246px up) and wayfinding.js's COMPASS panel
     (bottom:10px, right:10px, ~100-110px tall, own inline styles — not this
     file, so it can't be resized from here) together already claim nearly
     the ENTIRE bottom strip on a touch device; bottom:18px (the desktop-
     proportioned value) put the hint's text directly under/behind the
     COMPASS panel's own text. Moved above BOTH: 266px clears the D-pad's
     ~246px top edge with margin, and is far above the COMPASS panel's much
     shorter reach. Still visually "bottom half," clear of top-anchored
     .captions too (own comment above: captions collapse to the TOP the
     instant .scoreboard hides, i.e. exactly while free-roam is active). */
  .freeroam-hint { font-size: 11px; padding: 6px 12px; bottom: calc(266px + env(safe-area-inset-bottom)); }
}

/* ---- SSN FEATURE CARD (2026-08-01, H9-7/H9-4) ----
   The surface a STORY appears on. See client/modules/featureCard.js's header for the editorial
   reasoning; this block is only the paint, and it deliberately introduces nothing new to paint
   with. One hairline border, var(--ink) on var(--paper), the house mono face, an uppercase eyebrow
   over a rule — the identical construction as .feed-panel and .stats-panel above, because a story
   should read as another thing the production truck put on screen, not as a different application.

   PLACEMENT: lower third, left-aligned, starting at 294px so it clears the 260px .feed-panel column
   (14px gutter + 260px + 20px), and bottom-anchored at 118px so it sits ABOVE the .bottom-block
   caption transcript rather than over it. This is the one large empty region of the broadcast
   layout, and it is the region a real broadcast reserves for exactly this graphic. */
.feature-card {
  position: absolute;
  left: calc(294px + env(safe-area-inset-left));
  bottom: calc(118px + env(safe-area-inset-bottom));
  width: min(520px, calc(100vw - 320px - env(safe-area-inset-left) - env(safe-area-inset-right)));
  border: 1px solid var(--ink);
  background: var(--paper);
  color: var(--ink);
  font-family: var(--mono);
  padding: 10px 14px 11px;
  /* A broadcast graphic is never a control — it must not eat a click meant for the field. */
  pointer-events: none;
  opacity: 1;
  transition: opacity 600ms ease; /* == featureCard.js FADE_MS */
}
.feature-card.hidden { display: none; }
.feature-card.fading { opacity: 0; }

.feature-card-head {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  font-size: 9px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  opacity: 0.72;
  border-bottom: 1px solid var(--ink);
  padding-bottom: 5px;
  margin-bottom: 7px;
}
/* NEW / CONTINUED — the one bit of the card that tells a viewer whether they are watching a story
   begin or a story carry on. Reversed out, the way a broadcast flags a live/new bug. */
.feature-card-kicker {
  background: var(--ink);
  color: var(--paper);
  padding: 1px 5px;
  letter-spacing: 0.1em;
}
.feature-card-kicker:empty { display: none; }

.feature-card-figure {
  font-size: 15px;
  font-weight: bold;
  letter-spacing: 0.06em;
  line-height: 1.15;
}
.feature-card-title {
  font-size: 10px;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  opacity: 0.78;
  margin-top: 2px;
}
.feature-card-figure.hidden, .feature-card-title.hidden,
.feature-card-quote.hidden, .feature-card-tag.hidden { display: none; }

/* THE WRITING. 12px/1.5 is the largest comfortable body size that keeps four sentences inside the
   lower third without the card growing into the field — and it is deliberately larger than the
   10px log panels, because this is the one text on screen a viewer is meant to stop and read. */
.feature-card-body {
  margin-top: 9px;
  font-size: 12px;
  line-height: 1.5;
}
.feature-card-line { margin: 0 0 4px; }
.feature-card-line:last-child { margin-bottom: 0; }

/* The realized line, when H2-B's cache has one for this story. Italic and indented behind a rule,
   the way a feature ends on the player's own words. */
.feature-card-quote {
  margin-top: 8px;
  padding-left: 9px;
  border-left: 1px solid var(--ink);
  font-size: 12px;
  font-style: italic;
  line-height: 1.45;
  opacity: 0.88;
}

/* The arc's own coordinates, as a broadcast stat tag. */
.feature-card-tag {
  margin-top: 9px;
  padding-top: 6px;
  border-top: 1px solid var(--ink);
  font-size: 8.5px;
  letter-spacing: 0.13em;
  text-transform: uppercase;
  opacity: 0.6;
}

/* Narrow viewports: the feed panel is already hidden well before this, so the card takes the full
   lower third rather than being squeezed to unreadability. */
@media (max-width: 820px) {
  .feature-card {
    left: calc(14px + env(safe-area-inset-left));
    width: min(520px, calc(100vw - 28px - env(safe-area-inset-left) - env(safe-area-inset-right)));
  }
}
