/* measure/measure.css — THE MEASURE's screens (docs/the-measure-spec.md §9/§10).
 *
 * Framed as a COMIC PANEL, not a spreadsheet: paper ground, ink borders, halftone behind
 * the foe faces, captions in the mission-control voice the rest of the game speaks
 * (docs/ui-design-system.md). Mobile-first — the grid, the party strip and the command
 * rail all stack on a phone, and every hit target clears 44px.
 *
 * It reuses the onboarding chrome (.ob-overlay / .ob-frame / .fx-key / .ob-focused) rather
 * than minting a parallel look; only the board's own furniture is new here.
 *
 * THE MOBILE PASS (playtest 2026-08-28) reworked the footer into THE ACTION BAR (`.ms-bar*`,
 * finding 3) and added the hit-FX layer (`.ms-foe-hit`/`.ms-foe-crit`/`.ms-dmg-float`/…,
 * finding 5). The old standing caption (`.ms-caption*`) is gone — finding 4 — and its
 * vertical budget went to the grid. */

/* ── THE SEAWORTHY HULL (2026-08-29 hull pass) ──────────────────────────────
 * `.ob-overlay` (styles.css) is `position:fixed; inset:0`, which on a dynamic-toolbar
 * mobile browser resolves against the LARGE viewport — at a real iPhone Safari 402x932-large
 * /402x660-visible split the overlay came out 932px tall while only 660px was actually on
 * screen, and the gap is exactly the elastic distance iOS lets a touch drag the document
 * into (the reported "page scrolls" bug). `height:100svh` (the SMALL viewport — what is
 * actually visible right now) wins the CSS 10.6.4 over-constrained resolution against
 * `inset:0`'s `top:0;bottom:0` — with an explicit height also set, `bottom` is the one
 * recomputed, so the box stays pinned to the top at exactly the visible height. `100vh`
 * stays first as the fallback for a browser with no `svh` support (an unsupported value
 * invalidates only that one declaration, leaving the earlier one live). `overscroll-behavior`
 * stops the page-level bounce from ever engaging on this screen; `touch-action` drops the
 * double-tap-to-zoom delay on its buttons. Scoped to `.ms-overlay` only — no other
 * `.ob-overlay` screen's sizing or touch behavior changes. */
.ms-overlay {
  /* THE ZOOM FIX (2026-10-04): under the UI SCALE `zoom` (styles.css `.ob-overlay`) — or any
     zoom up the tree — a viewport-unit length paints at zoom × the viewport, so this box covered
     only zoom² of the screen (measured at 0.75: 322×552 of a 430×736 page, the FPS canvas showing
     through below and to the right — Jeff's "empty black space"). Divide by the zoom in effect:
     `--ms-zoom` is the MEASURED effective zoom board.js `_fitZoom()` writes on a real mount, the
     `--ui-scale` fallback the one zoom this codebase sets itself. The WIDTH is set the same way
     by `_fitZoom()` inline (`calc(100vw / z)`), only on a real fixed mount and only under a zoom,
     so the headless harness, which mounts the board inside a 430px host, keeps its box. */
  height: calc(100vh / var(--ms-zoom, var(--ui-scale, 1)));
  height: calc(100svh / var(--ms-zoom, var(--ui-scale, 1)));
  /* THE FIGURE'S REAL BOX (2026-08-29) — establishes the `cqw` reference `.ms-figure-body`
     below resolves against; see that rule's own comment for why `vw` alone isn't safe to
     measure headlessly. `.ms-overlay` always spans the true device width in real play, so
     this changes nothing on a device — it only stops a nested fake-width test mount from
     silently reading past its own edges. */
  container-type: inline-size;
  overscroll-behavior: none;
  touch-action: manipulation;
}

/* ── THE PALETTE (2026-08-29 hull pass) ─────────────────────────────────────
 * The board's hardcoded hex literals, named once. Pure refactor — every rendered value
 * below is byte-identical to the literal it replaces; only the `var()` indirection is new.
 * `--ms-accent`/`--ms-world-accent`/`--ms-pw-accent`/`--ms-cutin-accent` are a DIFFERENT
 * family (per-instance JS-set accents with their own inline fallback) and are untouched. */
.ms-overlay {
  --ms-gold: #ffd76a;         /* the one warm accent: cursor, CAST/ENTER, ready meters */
  --ms-shock: #5bc8f5;        /* shock element */
  --ms-burn: #ff6b2b;         /* burn element */
  --ms-rupture: #c45ff0;      /* rupture element */
  --ms-kinetic: #d8ecff;      /* kinetic element + the Conduit shield/tether mark */
  --ms-hp-foe: #b83333;       /* enemy HP fill */
  --ms-hp-party: #7fd67f;     /* party HP fill */
  --ms-boss-gold: #f2c350;    /* boss border + the elite tier's HP fill */
  --ms-party-danger: #ff6b6b; /* damage dealt TO the party + the DOWN tag */
  --ms-ink: #f4efe4;          /* the standard off-white text colour */
  --ms-ink-on-gold: #0b0d12;  /* dark text drawn on a gold surface (CAST, tiers, etc.) */
  --ms-white: #fff;           /* pure-white emphasis (crit flash, hover outline) */
}

/* ── The board ───────────────────────────────────────────────────────────── */
.ms-overlay .ms-stage {
  position: absolute; inset: 0;
  display: flex; flex-direction: column; gap: 10px;
  padding: clamp(10px, 2.4vh, 22px) clamp(10px, 2.6vw, 28px);
  /* The rail is the surface a thumb touches every single turn, so it must clear the home
     indicator rather than sit under it (docs/measure-phone-controls.md §3.2). */
  padding-bottom: calc(clamp(10px, 2.4vh, 22px) + env(safe-area-inset-bottom, 0px));
  overflow: hidden;
}

.ms-head {
  display: flex; flex-wrap: wrap; gap: 8px 18px; align-items: center;
  padding-bottom: 8px; border-bottom: 1px solid rgba(255,255,255,0.16);
  flex: 0 0 auto;
}
.ms-kv { display: flex; align-items: center; gap: 6px; font-size: 0.72rem; letter-spacing: 0.06em; }
.ms-kv-v { color: var(--ms-ink); font-variant-numeric: tabular-nums; }
.ms-kv-meter { gap: 8px; min-width: 0; }
/* THE SEAWORTHY HULL — the meter row's label + value are text with no shrink budget to
   give up (a flex item's automatic minimum size is its own content, unless told otherwise),
   so at a narrow effective width the BAR has to be the only thing that gives: `min-width:0`
   removes its own content-based floor so it can shrink toward zero, and the label/value get
   `flex-shrink:0` so they are never the ones asked to wrap or clip off the right edge. */
.ms-kv-meter .fx-key { flex: 0 0 auto; }
.ms-kv-meter .ms-kv-v { flex: 0 0 auto; white-space: nowrap; }
.ms-meter { width: clamp(70px, 12vw, 140px); height: 7px; background: rgba(255,255,255,0.12);
            border: 1px solid rgba(255,255,255,0.22); position: relative; overflow: hidden;
            flex: 1 1 auto; min-width: 0; }
.ms-meter-fill { position: absolute; inset: 0 auto 0 0; background: var(--ms-shock); transition: width 180ms ease; }
.ms-kv-meter.ms-meter-ready .ms-meter-fill { background: var(--ms-gold); box-shadow: 0 0 10px rgba(255,215,106,0.7); }
.ms-kv-meter.ms-meter-broken .ms-meter-fill { background: var(--ms-burn); }
.ms-kv-meter.ms-meter-broken .ms-kv-v { color: var(--ms-burn); font-weight: 700; }

/* ── The 4x3 grid ────────────────────────────────────────────────────────── */
.ms-grid {
  flex: 1 1 auto; min-height: 0;
  display: grid; grid-template-columns: repeat(var(--ms-cols, 4), 1fr);
  gap: clamp(5px, 1vw, 12px);
  /* CENTER, not start: the grid is the flex child that takes the slack, and starting the
     rows left a tall phone with a screen of dead space between the last foe and the party
     strip. The board is a comic PANEL — the wave belongs in the middle of it. */
  align-content: center;
}
.ms-cell {
  position: relative; min-height: 0;
  border: 1px solid rgba(255,255,255,0.14);
  background: rgba(8,10,14,0.5);
  display: flex; flex-direction: column; gap: 4px;
  padding: 5px; overflow: hidden;
}
.ms-cell-empty { border-style: dashed; opacity: 0.28; }
.ms-foe {
  cursor: pointer; text-align: left; font: inherit; color: inherit;
  border-color: color-mix(in srgb, var(--ms-accent) 55%, transparent);
  transition: transform 120ms ease, box-shadow 120ms ease;
}
/* `:not(.ms-world-figure)` on both rules below (found investigating the standing
   'scene/every-figure-and-its-cursor-plate-stay-inside-the-scene' failure, 2026-08-29 — a
   REAL bug, not a harness artifact): `.ms-grid`'s old 4x3 card tray is fully DELETED from
   board.js (THE COCKPIT FRAME) — `.ms-foe` is minted ONLY on `.ms-world-figure` cards now
   ('scene/phone-world-replaces-the-grid' asserts `.ms-grid` no longer exists at all) — but
   these two rules are dead-code leftovers from the tray era, still selecting on `.ms-foe`
   alone. `.ms-foe.ms-cursor{transform:none}` ties `.ms-world-figure.ms-cursor`'s own
   transform rule above at (0,0,2,0) specificity and this rule comes SECOND in cascade
   order, so it was winning outright and cancelling the cursor'd figure's entire placement
   transform (anchor translate + tier scale + THE FIGURE FLOOR's own edge nudge) — not
   overriding one property, erasing all of it. That collapsed the cursor'd figure to its
   untransformed top-left corner, which is exactly why the DEFAULT-cursor'd body in every
   run of that failing test sat outside the scene. */
.ms-foe:not(.ms-world-figure):hover, .ms-foe:not(.ms-world-figure).ob-focused {
  transform: translateY(-2px);
  box-shadow: 0 0 0 2px var(--ms-accent), 0 6px 18px rgba(0,0,0,0.55);
  outline: none;
}
.ms-foe-boss { border-width: 2px; border-color: var(--ms-boss-gold); }
/* THE CURSOR — the "point you can move around". It is deliberately LOUDER than the focus
   ring: focus says "the pad is here", the cursor says "this is what the command will hit",
   and on a phone those are the same thing but on a pad they are not. */
/* A foe that is BOTH the cursor and the pad focus should carry one visual language, not
   two stacked rings — the cursor wins and the focus treatment stands down. */
.ms-foe.ms-cursor:not(.ms-world-figure) { transform: none; }
.ms-cursor { box-shadow: 0 0 0 2px var(--ms-gold), 0 0 16px rgba(255,215,106,0.45) !important; }
.ms-foe.ms-cursor::after {
  content: '▼'; position: absolute; top: -2px; left: 50%; transform: translateX(-50%);
  color: var(--ms-gold); font-size: 0.8rem; line-height: 1; text-shadow: 0 0 6px rgba(255,215,106,0.8);
}
/* The ally cursor is drawn whenever an ally-facing cast is armed — BOTH cursors at once, one
   commit. `-live` is the one the ◀/▶ are currently steering. */
.ms-cursor-live { border-color: var(--ms-gold) !important; background: rgba(255,215,106,0.10); }

/* ── THE REACH IS LIT (THE SUPPORT PASS's presentation half, 2026-08-30) ──────────────────
 * A rider that lands on the whole party or the whole enemy row lights EVERY body it lands
 * on while it is armed. Deliberately one step QUIETER than `.ms-cursor` (1px ring, no glow,
 * no ▼ caret): the cursor still says "this is the one you are steering", the reach mark says
 * "and these come with it" — one visual family (the same gold), two weights, never two
 * competing rings. The cursor'd card carries both classes and `.ms-cursor`'s own
 * `!important` box-shadow wins outright, which is the intended order.
 * NOTE the deliberate `:not(.ms-world-figure)` shape, matching `.ms-cursor`'s own split
 * below: a free-standing world figure gets its mark on the PLATE (a gold rectangle drawn
 * around a standing robot reads as a card tray — the 2026-08-29 finding), never on the card. */
.ms-reach:not(.ms-world-figure) {
  box-shadow: 0 0 0 1.5px rgba(255,215,106,0.7);
  background: rgba(255,215,106,0.055);
}
.ms-world-figure.ms-reach .ms-plate {
  box-shadow: 0 0 0 1.5px rgba(255,215,106,0.72), 0 0 10px rgba(255,215,106,0.22);
  border-color: color-mix(in srgb, var(--ms-gold) 55%, transparent);
}
/* The standing hero figure has no plate to mark — a soft gold cast on the body itself, the
   same weight the foe plate's ring carries and clearly under `.ms-cursor`'s bracket. */
.ms-world-hero.ms-reach .ms-figure-body { filter: drop-shadow(0 0 6px rgba(255,215,106,0.5)); }
/* A Conduit-tethered body cannot be damaged at all — the mark has to read as a HARD gate,
   because that is exactly what it is in the resolver (§7: kill the Conduit to strip it).
   THE COCKPIT PASS (defect 2, 2026-08-29): scoped to `.ms-figure-body` only, both the
   saturation AND the hatch overlay — the old rule painted the WHOLE card (plate included),
   which among free-standing world figures read as a leftover card-tray box wrapped around
   the entire figure. `.ms-shield-mark` is now appended as a CHILD of `.ms-figure-body`
   (board.js `_worldFigureCard`) so its `inset:0` sizes to that box alone; this is shared
   code for both platforms (`_worldFigureCard` has no desktop/phone fork), so the desktop
   world's shield read changes identically — the one permitted desktop visual change here. */
.ms-foe-shielded .ms-figure-body { filter: saturate(0.6); }
/* THE TETHER IS A BUBBLE (2026-10-04): the hatch was a RECTANGLE around the body, which on the
   phone read as one more grey card behind the robot (Jeff: "better handling of the enemy
   cards"). Same element, same kinetic stroke and hatch, drawn as the field it is. */
.ms-shield-mark {
  position: absolute; inset: 2% 4%; pointer-events: none; border-radius: 50%;
  border: 2px solid color-mix(in srgb, var(--ms-kinetic) 80%, transparent);
  background: radial-gradient(ellipse at 50% 45%, transparent 55%, rgba(216,236,255,0.16) 100%),
              repeating-linear-gradient(45deg, rgba(216,236,255,0.07) 0 6px, transparent 6px 12px);
  box-shadow: 0 0 10px rgba(160,210,255,0.35), inset 0 0 12px rgba(160,210,255,0.25);
}
.ms-face { position: relative; flex: 1 1 auto; min-height: 42px; display: flex;
           align-items: center; justify-content: center; overflow: hidden; }
.ms-face img, .ms-face .ms-plate { max-width: 100%; max-height: 100%; object-fit: contain; }
/* Tier 2's halftone backdrop — what keeps the offline fallback inside the comic frame
   rather than looking like a debug view (§9 THE HONEST COST). */
.ms-face-t2::before {
  content: ''; position: absolute; inset: 0;
  background: radial-gradient(circle at 50% 42%,
              color-mix(in srgb, var(--ms-accent) 34%, transparent) 0%, transparent 68%),
              repeating-radial-gradient(circle at 0 0, rgba(255,255,255,0.07) 0 1px, transparent 1px 4px);
}
.ms-foe-name { font-size: 0.6rem; letter-spacing: 0.08em; color: var(--ms-ink);
               white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ms-hp { height: 5px; background: rgba(255,255,255,0.12); position: relative; overflow: hidden; }
/* THE HIT LANDS (item 2b, THE COCKPIT PASS · PASS 2) — the bar TWEENS to its new width
 * rather than snapping; the numeral overlay (`.ms-hp-nums`) still snaps, since the exact
 * number is the information and the bar is only the glance. Shared by every `.ms-hp-fill`
 * user — foe plates, the party window's bars, AND the strip chips' own bars
 * (`.ms-chip-hp .ms-hp-fill`) — one rule, three surfaces. */
.ms-hp-fill { position: absolute; inset: 0 auto 0 0; background: var(--ms-hp-foe);
              transition: width var(--ms-hp-tween-ms, 240ms) ease-out; }
.ms-hp-party { background: var(--ms-hp-party); }
.ms-shield-fill { position: absolute; inset: 0 auto 0 0; height: 2px; background: var(--ms-shock); }
/* THE WARD (docs/the-measure-spec.md §9) — a party member's PROTECTION painted inside their
 * own HP bar, right-aligned, so no card/chip/strip grows by a pixel. Two segments, and they
 * deliberately do not look alike: a cast ABSORB is solid (it stands until spent), a BANKED
 * overheal is HATCHED (§6.1 — it decays, three rounds, and the desktop chip says by when).
 * Sits under `.ms-hp-nums`, which is appended after it, so the numerals still win. */
/* Spans the WHOLE bar (inset:0) — the segments' widths are percentages of the member's
 * maxHp, and a shrink-to-fit box would resolve them against its own zero content width. The
 * segments sit at the right end via `justify-content`. */
.ms-ward { position: absolute; inset: 0; display: flex; justify-content: flex-end;
           pointer-events: none; }
.ms-ward-seg { height: 100%; }
/* Both segments are deliberately dimmer than the HP fill under them: the numerals
 * (`.ms-hp-nums`, cream on a dark shadow) are drawn OVER the ward and must stay the most
 * legible thing on the bar — a full-strength shock fill washed them out at chip size. */
.ms-ward-absorb { background: var(--ms-shock); opacity: 0.55; }
.ms-ward-banked { background: repeating-linear-gradient(135deg,
  rgba(91,200,245,0.55) 0 2px, rgba(91,200,245,0.12) 2px 5px); }
.ms-tier-line   .ms-hp-fill { background: #e89020; }
.ms-tier-specialist .ms-hp-fill { background: #9a5cff; }
.ms-tier-elite  .ms-hp-fill { background: var(--ms-boss-gold); }

/* The two prime dots ARE the composition readout (§5.2). */
.ms-primes { display: flex; align-items: center; gap: 3px; }
/* THE ROW IS CENTRED AND NOTHING TOUCHES (Jeff 2026-10-09: "center the prime dots, and i don't
 * like it when they collide with each other"). On a plate the dots sit centred under the HP
 * bar, the composition word riding after them as one centred group. The gap is sized off the
 * largest scale either stack ever reaches — a landing pulse (1.45) beside a FOCUSED strain
 * (1.28) grows 13 px bubbles by (0.45 + 0.28) × 13 / 2 ≈ 4.7 px into the gap — so the 6 px gap
 * never closes, at rest or mid-animation. */
.ms-plate .ms-primes { justify-content: center; gap: 6px; }

/* ── THE BUBBLE (docs/loaded-body-spec.md §2/§2.1) ────────────────────────────────────────
 * A prime is stored pressure, not a status pip — a rim + translucent interior + one
 * catch-light reads as a VOLUME, and a slow resting breathe says "loaded" between casts
 * (today's dot has no life at rest at all). Lives entirely on `.ms-dot`, a LEAF inside the
 * figure card — figures.css's strategy 1 (a different element from anything the card/body
 * already animates), so it composes with the hit-shake/idle-bob/critical-pulse layers for
 * free; see the guard `figure-state/idle-and-critical-compose-with-the-attack-tell` in
 * measure-board.test.js for the proof-of-technique this file reuses. NEVER write `transform`
 * on `.ms-world-figure` here (B5).
 *
 * B1 — THE ELEMENT OWNS HUE, THE BUBBLE OWNS FORM: every colour below is one of the four
 * `--ms-burn/-shock/-rupture/-kinetic` tokens (via `--ms-dot-hue`) or an achromatic white/
 * transparent highlight — no new hue, no new token.
 * B3 — ONLY A LOADED STACK ANIMATES: `.ms-dot-empty` carries no `animation` declaration
 * anywhere in this file and never matches an element-hue class, so it is a static disc —
 * no keyframe, no promoted layer, no `will-change`. That is also the budget: 12 bodies x 2
 * stacks = 24 possible bubbles, well inside the ~10-20 concurrent-animation ceiling this
 * spec measures against, because most slots are empty most of the time (§2.2).
 * §2.2's three rules: transform/opacity/filter ONLY (no animated box-shadow, no
 * filter:blur()); breathe on `transform: scale()`; rim+interior+catch-light are ONE static
 * `background` set once per element class, never recomputed per render. */
.ms-dot {
  width: 10px; height: 10px; border-radius: 50%;
  border: 1px solid rgba(255,255,255,0.4);
  background: transparent;
  position: relative;
  transform-origin: 50% 50%;
}
.ms-dot-empty { background: transparent; border-color: rgba(255,255,255,0.22); opacity: 0.4; }

.ms-dot-burn, .ms-dot-shock, .ms-dot-rupture, .ms-dot-kinetic {
  border-color: var(--ms-dot-hue);
  /* rim = the border above; interior + catch-light = this one static gradient stack */
  background:
    radial-gradient(circle at 30% 26%, rgba(255,255,255,0.85) 0%, rgba(255,255,255,0.12) 20%, transparent 36%),
    radial-gradient(circle at 54% 60%, var(--ms-dot-hue) 0%, transparent 74%),
    var(--ms-dot-hue);
  /* MONO's resting breathe (§2.1) — FOCUSED reuses this exact `animation-name` at a
   * strained intensity below (B2); MIXED replaces it entirely (the categorical break). */
  animation: ms-dot-breathe var(--ms-dot-breathe-ms, 2400ms) ease-in-out infinite;
  animation-delay: calc(var(--ms-fig-phase, 0) * -1 * var(--ms-dot-breathe-ms, 2400ms));
}
.ms-dot-burn    { --ms-dot-hue: var(--ms-burn); }
.ms-dot-shock   { --ms-dot-hue: var(--ms-shock); }
.ms-dot-rupture { --ms-dot-hue: var(--ms-rupture); }
.ms-dot-kinetic { --ms-dot-hue: var(--ms-kinetic); }

@keyframes ms-dot-breathe {
  0%, 100% { transform: scale(var(--ms-dot-breathe-lo, 1)); }
  50%      { transform: scale(var(--ms-dot-breathe-hi, 1.12)); filter: brightness(var(--ms-dot-breathe-bright, 1.08)); }
}

/* A body's SECOND stack breathes out of phase with its first (§2 "desynchronised per
 * stack") — the same phase-offset idiom `--ms-fig-phase` already uses one level up
 * (figures.css), applied again at the stack level as a fixed structural offset (never
 * randomised, so it stays deterministic for the guard). */
.ms-primes > .ms-dot:nth-child(2) {
  animation-delay: calc(var(--ms-fig-phase, 0) * -1 * var(--ms-dot-breathe-ms, 2400ms) + 260ms);
}

/* FOCUSED (2 stacks, SAME element) — B2/§2.1: the SAME bubble language, doubled and
 * STRAINING. Only the custom properties the keyframe reads change; `animation-name` stays
 * `ms-dot-breathe` on both stacks, so this is intensity, never a third shape. */
.ms-primes-focused .ms-dot:not(.ms-dot-empty) {
  --ms-dot-breathe-lo: 1.04;
  --ms-dot-breathe-hi: 1.28;
  --ms-dot-breathe-bright: 1.32;
  --ms-dot-breathe-ms: 1200ms;
}

/* MIXED (2 stacks, DIFFERENT elements) — B2's categorical break, §2.1's loudest state: two
 * bubbles that lean toward each other — a STATIC facing-edge deformation via
 * `border-radius` that survives reduced motion untouched (B6), plus a genuinely different
 * keyframe (`ms-dot-mixed-throb`, not `ms-dot-breathe`) — a different STRUCTURE, per B2,
 * not just different numbers on the same one. */
.ms-primes-mixed > .ms-dot:nth-child(1):not(.ms-dot-empty) {
  border-radius: 62% 38% 55% 45% / 58% 42% 62% 38%;
}
.ms-primes-mixed > .ms-dot:nth-child(2):not(.ms-dot-empty) {
  border-radius: 38% 62% 45% 55% / 42% 58% 38% 62%;
}
@keyframes ms-dot-mixed-throb {
  0%, 100% { transform: scale(1) rotate(0deg); }
  50%      { transform: scale(1.2) rotate(-3deg); filter: brightness(1.24); }
}
.ms-primes-mixed > .ms-dot:not(.ms-dot-empty) {
  --ms-dot-breathe-ms: 1100ms;
  animation-name: ms-dot-mixed-throb;
  animation-duration: var(--ms-dot-breathe-ms);
  animation-timing-function: ease-in-out;
  animation-iteration-count: infinite;
}
.ms-primes-mixed > .ms-dot:nth-child(2):not(.ms-dot-empty) {
  animation-delay: calc(var(--ms-fig-phase, 0) * -1 * var(--ms-dot-breathe-ms, 1100ms) + 200ms);
}

.ms-comp { font-size: 0.5rem; letter-spacing: 0.1em; color: var(--ms-gold); }

/* ── THE STATUS MARKS (docs/status-marks-spec.md) ─────────────────────────────────────────
 * A status is a VISUAL, never a label (§3c, 2026-10-10): the body burns in the element(s) that
 * put it there (THE SCORCH READ, below), the phone strip's portrait wears a ring, and the LOG
 * names it. The badge chips this block once drew are retired with the label. */
/* THE AURA, on the phone strip — the portrait wears a ring in the element(s) the member is
   afflicted by (an even split for a mix). A visual only (§3c).
   Everything here is absolutely positioned: the strip's fixed height does not move. */
.ms-chip-portrait-wrap { position: relative; flex: 0 0 auto; display: flex; }
.ms-chip-portrait-wrap.ms-aura::before {
  content: ''; position: absolute; inset: -2px; z-index: 0; pointer-events: none;
  background: var(--ms-aura); border-radius: 2px;
}
.ms-chip-portrait-wrap > .ms-chip-portrait { position: relative; z-index: 1; }
/* The portrait's own fill is translucent; under an aura it goes opaque so the ring reads as a
   RING, not a tint washed through the whole portrait. */
.ms-chip-portrait-wrap.ms-aura > .ms-chip-portrait-img { background: #0b0e14; }

/* THE SCORCH READ (status-marks-spec §3a, 2026-10-09 — "on their body, like scorch in
   destiny. this way it doesn't compete with prime"). A world figure's marks live INSIDE
   `.ms-figure-body` (board.js), so they sit on the drawn body on the board and on the projected
   3D body on the live stage (which sizes that box per frame). Two layers, none of them on the
   plate — the plate is the prime's row — and NO LABEL (Jeff, same day: "remove the labels on the
   status effects. it will just be a visual, and they will look at the log to know what
   happened"; the flank badges are gone, the log names each status and each tick):
     1. THE GLOW — the drawn image's own alpha rimmed in the afflicting element (drop-shadow
        follows the silhouette, never the box). It lives on the IMG, which only THE ATTACKER RIM
        animates — an animation outranks a static declaration, so the strike flare plays over it
        and the glow returns when it ends. A mix: the inner rim one element, the bloom the other.
     2. THE FLARE — a rising wash per afflicting element over the lower body; a mix interleaves
        them on staggered phases, two fires on one body. transform/opacity only. */
.ms-body-glow .ms-figure-body :is(img, .ms-hero-badge) {
  filter: drop-shadow(0 0 1px var(--ms-glow-a)) drop-shadow(0 0 1px var(--ms-glow-a))
          drop-shadow(0 0 7px color-mix(in srgb, var(--ms-glow-b) 70%, transparent));
}
.ms-body-status { position: absolute; inset: 0; pointer-events: none; z-index: 2; }
.ms-body-flares {
  position: absolute; left: 4%; right: 4%; top: 8%; bottom: 0;
  mix-blend-mode: screen; overflow: hidden;
  border-radius: 45% 45% 30% 30% / 60% 60% 20% 20%;
}
.ms-body-flare {
  position: absolute; inset: 0; transform-origin: 50% 100%;
  background:
    radial-gradient(ellipse 22% 46% at 34% 96%, var(--ms-flare) 0%, transparent 70%),
    radial-gradient(ellipse 20% 54% at 64% 96%, var(--ms-flare) 0%, transparent 70%),
    radial-gradient(ellipse 52% 70% at 50% 100%,
              color-mix(in srgb, var(--ms-flare) 85%, transparent) 0%,
              color-mix(in srgb, var(--ms-flare) 35%, transparent) 48%, transparent 76%);
  opacity: 0.5;
  animation: ms-body-flare 1500ms ease-in-out infinite;
  animation-delay: calc((var(--ms-fig-phase, 0) * -1500ms) - var(--ms-flare-i, 0) * 1500ms / var(--ms-flare-n, 1));
}
@keyframes ms-body-flare {
  0%, 100% { opacity: 0.35; transform: translateY(6%) scaleY(0.78) skewX(2deg); }
  45%      { opacity: 0.9;  transform: translateY(-4%) scaleY(1.1) skewX(-3deg); }
}
/* the embers — two motes per element, lifting off the body and fading */
.ms-body-flare::before, .ms-body-flare::after {
  content: ''; position: absolute; left: 36%; bottom: 18%; width: 4px; height: 4px; border-radius: 50%;
  background: #fff; box-shadow: 0 0 3px 1px var(--ms-flare), 0 0 7px var(--ms-flare);
  animation: ms-body-ember 1900ms linear infinite;
  animation-delay: calc(var(--ms-flare-i, 0) * -700ms);
}
.ms-body-flare::after { left: 60%; animation-delay: calc(var(--ms-flare-i, 0) * -700ms - 950ms); }
@keyframes ms-body-ember {
  0%   { opacity: 0; transform: translate(0, 0); }
  15%  { opacity: 1; }
  100% { opacity: 0; transform: translate(4px, -46px); }
}
/* a dying body stops burning — the collapse owns it */
.ms-foe-dying .ms-body-status { display: none; }
@media (prefers-reduced-motion: reduce) {
  .ms-body-flare, .ms-body-flare::before, .ms-body-flare::after { animation: none; }
  .ms-body-flare { opacity: 0.6; }
  .ms-body-flare::before, .ms-body-flare::after { display: none; }
}

/* THE PRIME SIGIL (status-marks-spec §3b) — the loaded bubble stamps its element's glyph in
   comic ink. The bubble grows a size to hold it (a 10px disc was a colour; 13px holds a
   shape); the empty slot stays the small hollow ring it was. The tiny forecast/aim dots
   hide the glyph — at 6-7px it would be noise. */
.ms-dot { display: inline-flex; align-items: center; justify-content: center; }
.ms-plate .ms-dot:not(.ms-dot-empty), .ms-boss-banner .ms-dot:not(.ms-dot-empty) { width: 13px; height: 13px; }
.ms-dot-sigil { width: 82%; height: 82%; pointer-events: none; opacity: 0.9; }
.ms-aim-info-target .ms-dot-sigil, .ms-preview-dots .ms-dot-sigil { display: none; }

/* The landing word wears the element that inflicted it. */
.ms-status-pop-burn    { background: color-mix(in srgb, var(--ms-burn) 88%, black); }
.ms-status-pop-shock   { background: color-mix(in srgb, var(--ms-shock) 80%, black); color: #06121a; }
.ms-status-pop-rupture { background: color-mix(in srgb, var(--ms-rupture) 85%, black); }
.ms-telegraph { font-size: 0.55rem; letter-spacing: 0.08em; color: var(--ms-burn); font-weight: 700; }
/* THE NAMED SHOT (playtest-r15 §3) — the plate names its target; LETHAL is the loudest read on
 * the board because it is the one a heal or a shield can still undo this turn. */
.ms-telegraph-lethal { color: #fff; background: var(--ms-burn); padding: 0 3px; }
/* The party half — the targeted card wears a burn outline and a corner ▼ (the phone chip has no
 * room for the words; the desktop card adds a `.ms-rider-marked` chip). */
.ms-member-marked { box-shadow: inset 0 0 0 1px var(--ms-burn); position: relative; }
.ms-member-marked::before {
  content: '▼'; position: absolute; top: 2px; left: 3px; line-height: 1;
  font-size: 0.5rem; color: var(--ms-burn); pointer-events: none;
}
.ms-member-marked-lethal { box-shadow: inset 0 0 0 2px var(--ms-burn); }
.ms-member-marked-lethal::before { animation: ms-telegraph-pulse 900ms ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) { .ms-member-marked-lethal::before { animation: none; } }
.ms-rider-marked { color: var(--ms-burn); border-color: rgba(255,107,43,0.6); }
.ms-rider-lethal { color: #fff; background: var(--ms-burn); }

/* ── THE HIT LAYER (finding 5, §B4/§B5) ──────────────────────────────────────
 * Player hits and the enemy phase's playback both drive the SAME small vocabulary: a shake
 * on the acting/hurt card, a floating number, a pulsed dot. Every animation here has a
 * `prefers-reduced-motion` twin at the bottom of this file that removes the motion and
 * leaves the number in place — the shake is decoration, the number is the information. */
@keyframes ms-shake {
  0%, 100% { transform: translateX(0); }
  20% { transform: translateX(-4px) rotate(-1deg); }
  40% { transform: translateX(4px) rotate(1deg); }
  60% { transform: translateX(-3px); }
  80% { transform: translateX(3px); }
}
/* `.ms-member-hit`'s own party-strip chip carries no positioning transform to lose (it is a
   plain flow card), so `ms-shake`'s direct `transform:` keyframes are harmless there and
   stay exactly as they were. `.ms-foe-hit`/`.ms-foe-crit` are a WORLD FIGURE's own card,
   which does — THE FX CHANNEL above (near `.ms-world-figure`'s base rule) is what fixes
   that, so this shake writes `--ms-fx-x`/`--ms-fx-rot` instead of `transform` directly. */
@keyframes ms-fig-shake {
  0%, 100% { --ms-fx-x: 0px; --ms-fx-rot: 0deg; }
  20% { --ms-fx-x: -4px; --ms-fx-rot: -1deg; }
  40% { --ms-fx-x: 4px; --ms-fx-rot: 1deg; }
  60% { --ms-fx-x: -3px; --ms-fx-rot: 0deg; }
  80% { --ms-fx-x: 3px; --ms-fx-rot: 0deg; }
}
/* THE HURT (2026-09-26, docs/playtest-r13.md §7 — "animations for taking damage"). A hit is
   a BLOW FROM SOMEWHERE, so the struck body recoils AWAY from it and squashes at the feet
   before it settles — not a symmetric side-to-side wobble. The blow's direction is the
   board's own composition: on the phone the party stands DOWNSTAGE, so a struck foe is
   knocked UP the stage (−y) and a struck Fateforged knocked DOWN toward the camera (+y); on
   desktop the party stands stage-LEFT, so a foe goes RIGHT (+x) and a hero LEFT (−x).
   `--ms-hurt-amp` scales the whole curve: 1 for a hit, bigger for a crit — one keyframe set,
   two weights, never two hand-tuned copies that drift. Everything writes THE FX CHANNEL
   (`--ms-fx-*`), never `transform`, so the anchor translate + tier scale survive the beat.
   (`ms-fig-shake` above is kept for the layers that still name it.) */
@keyframes ms-fig-hurt-up {
  0%   { --ms-fx-x: 0px; --ms-fx-y: 0px; --ms-fx-rot: 0deg; --ms-fx-scale: 1; }
  18%  { --ms-fx-x: calc(var(--ms-hurt-amp, 1) * -3px); --ms-fx-y: calc(var(--ms-hurt-amp, 1) * -7px);
         --ms-fx-rot: calc(var(--ms-hurt-amp, 1) * -3deg); --ms-fx-scale: calc(1 - var(--ms-hurt-amp, 1) * 0.06); }
  45%  { --ms-fx-x: calc(var(--ms-hurt-amp, 1) * 4px); --ms-fx-y: calc(var(--ms-hurt-amp, 1) * -2px);
         --ms-fx-rot: calc(var(--ms-hurt-amp, 1) * 2deg); --ms-fx-scale: calc(1 + var(--ms-hurt-amp, 1) * 0.04); }
  72%  { --ms-fx-x: calc(var(--ms-hurt-amp, 1) * -2px); --ms-fx-y: 1px; --ms-fx-rot: -1deg; --ms-fx-scale: 0.99; }
  100% { --ms-fx-x: 0px; --ms-fx-y: 0px; --ms-fx-rot: 0deg; --ms-fx-scale: 1; }
}
@keyframes ms-fig-hurt-down {
  0%   { --ms-fx-x: 0px; --ms-fx-y: 0px; --ms-fx-rot: 0deg; --ms-fx-scale: 1; }
  18%  { --ms-fx-x: calc(var(--ms-hurt-amp, 1) * 3px); --ms-fx-y: calc(var(--ms-hurt-amp, 1) * 6px);
         --ms-fx-rot: calc(var(--ms-hurt-amp, 1) * 3deg); --ms-fx-scale: calc(1 - var(--ms-hurt-amp, 1) * 0.06); }
  45%  { --ms-fx-x: calc(var(--ms-hurt-amp, 1) * -4px); --ms-fx-y: calc(var(--ms-hurt-amp, 1) * 2px);
         --ms-fx-rot: calc(var(--ms-hurt-amp, 1) * -2deg); --ms-fx-scale: calc(1 + var(--ms-hurt-amp, 1) * 0.04); }
  72%  { --ms-fx-x: calc(var(--ms-hurt-amp, 1) * 2px); --ms-fx-y: -1px; --ms-fx-rot: 1deg; --ms-fx-scale: 0.99; }
  100% { --ms-fx-x: 0px; --ms-fx-y: 0px; --ms-fx-rot: 0deg; --ms-fx-scale: 1; }
}
@keyframes ms-fig-hurt-right {
  0%   { --ms-fx-x: 0px; --ms-fx-y: 0px; --ms-fx-rot: 0deg; --ms-fx-scale: 1; }
  18%  { --ms-fx-x: calc(var(--ms-hurt-amp, 1) * 8px); --ms-fx-y: calc(var(--ms-hurt-amp, 1) * -2px);
         --ms-fx-rot: calc(var(--ms-hurt-amp, 1) * 4deg); --ms-fx-scale: calc(1 - var(--ms-hurt-amp, 1) * 0.06); }
  45%  { --ms-fx-x: calc(var(--ms-hurt-amp, 1) * -3px); --ms-fx-y: 0px;
         --ms-fx-rot: calc(var(--ms-hurt-amp, 1) * -2deg); --ms-fx-scale: calc(1 + var(--ms-hurt-amp, 1) * 0.04); }
  72%  { --ms-fx-x: calc(var(--ms-hurt-amp, 1) * 2px); --ms-fx-y: 0px; --ms-fx-rot: 1deg; --ms-fx-scale: 0.99; }
  100% { --ms-fx-x: 0px; --ms-fx-y: 0px; --ms-fx-rot: 0deg; --ms-fx-scale: 1; }
}
@keyframes ms-fig-hurt-left {
  0%   { --ms-fx-x: 0px; --ms-fx-y: 0px; --ms-fx-rot: 0deg; --ms-fx-scale: 1; }
  18%  { --ms-fx-x: calc(var(--ms-hurt-amp, 1) * -8px); --ms-fx-y: calc(var(--ms-hurt-amp, 1) * -2px);
         --ms-fx-rot: calc(var(--ms-hurt-amp, 1) * -4deg); --ms-fx-scale: calc(1 - var(--ms-hurt-amp, 1) * 0.06); }
  45%  { --ms-fx-x: calc(var(--ms-hurt-amp, 1) * 3px); --ms-fx-y: 0px;
         --ms-fx-rot: calc(var(--ms-hurt-amp, 1) * 2deg); --ms-fx-scale: calc(1 + var(--ms-hurt-amp, 1) * 0.04); }
  72%  { --ms-fx-x: calc(var(--ms-hurt-amp, 1) * -2px); --ms-fx-y: 0px; --ms-fx-rot: -1deg; --ms-fx-scale: 0.99; }
  100% { --ms-fx-x: 0px; --ms-fx-y: 0px; --ms-fx-rot: 0deg; --ms-fx-scale: 1; }
}
/* The GAUGE's flinch — the strip chip / party-window row is a plain flow card with no
   positioning transform to lose, so `ms-shake`'s direct `transform:` stays correct THERE. */
.ms-member-hit { animation: ms-shake var(--ms-hit-shake-ms, 320ms) ease-in-out; }
/* …but a STANDING FIGURE is a `.ms-world-figure`, whose one `transform` declaration (THE FX
   CHANNEL, below near its base rule) is the anchor translate + tier scale. Before this pass
   `_hitMember` landed `.ms-member-hit` on the hero figure too, and `ms-shake` REPLACED that
   transform for 320 ms — the body jumped a full box down-right and un-scaled, then snapped
   back (the exact clobber THE FX CHANNEL's own comment describes, on the one class it did not
   yet cover). The figure now takes the same recoil grammar the foes do. */
.ms-world-figure.ms-member-hit { --ms-hurt-amp: 1; animation: ms-fig-hurt-down var(--ms-hit-shake-ms, 320ms) ease-out; }
.ms-stage.ms-desktop .ms-world-figure.ms-member-hit { animation-name: ms-fig-hurt-left; }
.ms-foe-hit { --ms-hurt-amp: 1; animation: ms-fig-hurt-up var(--ms-hit-shake-ms, 320ms) ease-out; }
.ms-stage.ms-desktop .ms-foe-hit { animation-name: ms-fig-hurt-right; }
.ms-foe-crit {
  --ms-hurt-amp: 1.7;
  animation: ms-fig-hurt-up var(--ms-hit-shake-ms, 320ms) ease-out;
  box-shadow: 0 0 0 3px var(--ms-white), 0 0 22px rgba(255,255,255,0.65) !important;
}
.ms-stage.ms-desktop .ms-foe-crit { animation-name: ms-fig-hurt-right; }
/* The gauge takes a hurt WASH with its flinch — the number that drops on the strip drops on a
   card that visibly took the blow. `::before` (the chip's `::after` is THE ACTED glyph). */
.ms-chip.ms-member-hit::before, .ms-pw-row.ms-member-hit::before {
  content: ''; position: absolute; inset: 0; pointer-events: none; z-index: 2; border-radius: inherit;
  background: rgba(255,84,64,0.42); mix-blend-mode: screen;
  animation: ms-impact-flash var(--ms-hit-shake-ms, 320ms) ease-out forwards;
}
.ms-member { position: relative; }
/* The future damage-state art hook (playtest note: "different states for them taking
 * damage") — a plain white flash today, a real hurt-material swap later without touching
 * the JS that toggles the class. */
.ms-face-hurt::after {
  content: ''; position: absolute; inset: 0; background: rgba(255,255,255,0.35);
  mix-blend-mode: screen; pointer-events: none;
}

/* THE IMPACT FLASH (item 2a, THE COCKPIT PASS · PASS 2) — a brief white/accent flash on the
 * struck FIGURE'S BODY, layered with (never replacing) the hit-shake above: `.ms-foe-hit`
 * shakes the whole card, `.ms-face-hurt` washes the face art, and this washes the whole
 * standing figure (foe or hero) — three effects, one hit, each on a different element so
 * none clobbers another's class. `.ms-figure-body` is already `position:relative`. */
@keyframes ms-impact-flash {
  0%   { opacity: 0.85; }
  100% { opacity: 0; }
}
.ms-impact-flash::after {
  content: ''; position: absolute; inset: 0; pointer-events: none; z-index: 2;
  background: rgba(255,255,255,0.6); mix-blend-mode: screen;
  animation: ms-impact-flash var(--ms-impact-flash-ms, 90ms) ease-out forwards;
}
/* THE HURT — a Fateforged's flash is RED and lingers a touch longer: "we got hit" must never
   read as the same white pop "we hit them" does (the same colour law the damage floats use —
   `.ms-dmg-float-party` is the party's own tint). */
.ms-world-hero .ms-impact-flash::after {
  background: rgba(255,88,66,0.62);
  animation-duration: calc(var(--ms-impact-flash-ms, 90ms) * 2);
}

/* THE HITSTOP (item 2c) — a KILLING blow only (board.js `_releaseDying`/`_hitstopUids` — since THE BLAST LANDS the kill is released at the pop/reveal, never at commit): every
 * animation on the struck figure (and its own descendants — the hit-shake, the impact
 * flash, the damage float) freezes in place for HITSTOP_MS before the kill-collapse is
 * allowed to actually advance. Scoped to the one figure carrying the class — never global,
 * per the pass's own hard rule. `!important` because it has to outrank whichever OTHER
 * pulse class (`.ms-foe-crit`/`.ms-foe-hit`) is riding the same element at the same moment.
 * THE HITSTOP SCALES WITH THE POP (THE LADDER, LOUDER, 2026-10-03): when the killing blow was a
 * detonation the SAME class is held longer — `fx.js hitstopMsFor()` = HITSTOP_MS + magnitude ×
 * fx.DET_HITSTOP_MAG_MS — so the biggest pop freezes longest. This is THE ONE freeze primitive in
 * the mode (`measure-fx.test.js hitstop/*` asserts this is the only `paused !important` rule in the
 * measure stylesheets and that fx.css declares none — figures.css's `.ms-walk-strike` pause is THE
 * PACE's gait, not a freeze). */
.ms-hitstop, .ms-hitstop * { animation-play-state: paused !important; }

@keyframes ms-dmg-float {
  0%   { opacity: 0; transform: translate(-50%, 0); }
  15%  { opacity: 1; }
  100% { opacity: 0; transform: translate(-50%, -26px); }
}
.ms-dmg-float {
  position: absolute; left: 50%; top: 28%; transform: translate(-50%, 0);
  font-size: 0.72rem; font-weight: 700; color: #ff8a5c; font-variant-numeric: tabular-nums;
  text-shadow: 0 1px 3px rgba(0,0,0,0.7); pointer-events: none; z-index: 2; white-space: nowrap;
  animation: ms-dmg-float var(--ms-dmg-float-ms, 750ms) ease-out forwards;
}
/* THE BIG HIT (item 2d) — a non-crit hit whose OWN magnitude (dealt / target maxHp) clears
 * DMG_FLOAT_BIG_HP_FRAC gets a size bump without borrowing the crit's gold colour (that
 * colour means "the resolver flagged this a crit", which a big ability hit is not). Ordered
 * BEFORE `.ms-dmg-float-crit` so a hit carrying BOTH classes (board.js never sets `big` when
 * `crit` is already true, but the cascade is kept correct regardless) still reads as a crit
 * — crit's font-size/colour win. */
.ms-dmg-float-big { font-size: 0.86rem; text-shadow: 0 0 6px rgba(255,255,255,0.55), 0 1px 3px rgba(0,0,0,0.75); }
.ms-dmg-float-crit { font-size: 0.9rem; color: var(--ms-gold); }
.ms-dmg-float-party { color: var(--ms-party-danger); }
/* THE REACH IS LIT's landing beat — a class rider's own float, on EVERY body it reached.
   Gold (the reach family's colour, not damage's), a size down from a damage number and
   letter-spaced like the rider chips it echoes, so three of them at PARTY reach read as one
   event rather than three hits. */
.ms-dmg-float-rider {
  color: var(--ms-gold); font-size: 0.54rem; font-weight: 700; letter-spacing: 0.06em;
}
.ms-dmg-float-burn { color: var(--ms-burn); }
.ms-dmg-float-break { color: var(--ms-burn); font-weight: 700; letter-spacing: 0.08em; }
/* THE COURIER (playtest-r18 §2) — a foe's primes are being / were wiped. The wave's own mark
   colour, never an element token: it is the enemy talking, not your build. */
.ms-dmg-float-scrub { color: var(--ms-wave-mark, #e8e2d0); font-weight: 700; letter-spacing: 0.08em; }
/* A status chip riding the same float animation (finding 5: "telegraphs read out"). */
.ms-status-pop {
  background: rgba(196,95,240,0.85); color: var(--ms-white); padding: 2px 6px; top: 6px;
  font-size: 0.5rem; letter-spacing: 0.08em;
}

/* THE KILL COLLAPSE — a just-killed card stays on screen, collapsing, for one beat rather
 * than vanishing on the very next render (see board.js's _dyingUids/_holdDying/_releaseDying). Writes
 * THE FX CHANNEL's `--ms-fx-scale`/`--ms-fx-y` (opacity stays a plain, direct property —
 * it was never part of the clobber, nothing else on this card animates it). */
@keyframes ms-kill-collapse {
  0%   { opacity: 1; --ms-fx-scale: 1; --ms-fx-y: 0px; }
  100% { opacity: 0; --ms-fx-scale: 0.7; --ms-fx-y: 6px; }
}
.ms-foe-dying { animation: ms-kill-collapse var(--ms-kill-fade-ms, 420ms) ease-in forwards; pointer-events: none; }

/* ── THE BLAST SHOVES (docs/playtest-r13.md §14, 2026-09-28 — Jeff: "Enemies should get rocked
 * by the explosion, and have a detonated animation"). Three beats, all on THE FX CHANNEL
 * (`--ms-fx-*`, never `transform`), all fed by board.js's blast plan through four custom
 * properties: `--ms-blast-x`/`--ms-blast-y` (a UNIT vector, epicentre → body, on screen),
 * `--ms-blast-amp` (a length — the shared `RUNG_MULT[rung].knock` already multiplied in) and
 * `--ms-blast-color` (the reaction's own colour, fx.js `_colorFor`).
 *   .ms-foe-blasted    — a SPLASHED body: thrown along the vector, squashed on the way out,
 *                        overshoots back and settles. It is what makes the explosion a thing
 *                        with a CENTRE rather than a ring drawn over a shake.
 *   .ms-foe-detonated  — the POPPED body: rocked in place — a hard scale punch, a hop, a
 *                        tumble either way, no net throw (the blast is centred on it).
 *   .ms-foe-dying-blast — a body a pop KILLED dies BLOWN: it flies along the vector, tumbles
 *                        and shrinks as it fades, instead of `ms-kill-collapse`'s sink. Same
 *                        `--ms-kill-fade-ms` clock, so the hand-off math is untouched.
 * All three are state-timed by board.js (`_pulseFoe` records the tell, `_worldFigureCard`
 * re-applies it on a re-render), so a mid-beat rebuild of the card no longer erases the shove. */
@keyframes ms-fig-blast {
  0%   { --ms-fx-x: 0px; --ms-fx-y: 0px; --ms-fx-rot: 0deg; --ms-fx-scale: 1; }
  22%  { --ms-fx-x: calc(var(--ms-blast-x, 0) * var(--ms-blast-amp, 12px));
         --ms-fx-y: calc(var(--ms-blast-y, -1) * var(--ms-blast-amp, 12px));
         --ms-fx-rot: calc(var(--ms-blast-x, 0) * 7deg); --ms-fx-scale: 0.93; }
  52%  { --ms-fx-x: calc(var(--ms-blast-x, 0) * var(--ms-blast-amp, 12px) * 0.5);
         --ms-fx-y: calc(var(--ms-blast-y, -1) * var(--ms-blast-amp, 12px) * 0.5);
         --ms-fx-rot: calc(var(--ms-blast-x, 0) * -3deg); --ms-fx-scale: 1.04; }
  80%  { --ms-fx-x: calc(var(--ms-blast-x, 0) * var(--ms-blast-amp, 12px) * 0.12);
         --ms-fx-y: calc(var(--ms-blast-y, -1) * var(--ms-blast-amp, 12px) * 0.12);
         --ms-fx-rot: calc(var(--ms-blast-x, 0) * 1deg); --ms-fx-scale: 0.99; }
  100% { --ms-fx-x: 0px; --ms-fx-y: 0px; --ms-fx-rot: 0deg; --ms-fx-scale: 1; }
}
.ms-foe-blasted { animation: ms-fig-blast var(--ms-blast-ms, 520ms) cubic-bezier(.15,.8,.3,1); }
@keyframes ms-fig-detonated {
  0%   { --ms-fx-y: 0px; --ms-fx-rot: 0deg; --ms-fx-scale: 1; }
  9%   { --ms-fx-y: calc(var(--ms-blast-amp, 10px) * -1); --ms-fx-rot: 0deg; --ms-fx-scale: 1.24; }
  28%  { --ms-fx-y: calc(var(--ms-blast-amp, 10px) * 0.35); --ms-fx-rot: 6deg; --ms-fx-scale: 0.88; }
  52%  { --ms-fx-y: calc(var(--ms-blast-amp, 10px) * -0.4); --ms-fx-rot: -5deg; --ms-fx-scale: 1.07; }
  78%  { --ms-fx-y: 1px; --ms-fx-rot: 2deg; --ms-fx-scale: 0.98; }
  100% { --ms-fx-y: 0px; --ms-fx-rot: 0deg; --ms-fx-scale: 1; }
}
.ms-foe-detonated { animation: ms-fig-detonated var(--ms-blast-ms, 520ms) ease-out; }
@keyframes ms-kill-blast {
  0%   { opacity: 1; --ms-fx-x: 0px; --ms-fx-y: 0px; --ms-fx-rot: 0deg; --ms-fx-scale: 1; }
  14%  { opacity: 1; --ms-fx-scale: 1.16; }
  100% { opacity: 0;
         --ms-fx-x: calc(var(--ms-blast-x, 0) * var(--ms-kill-blast-px, 46px));
         --ms-fx-y: calc(var(--ms-blast-y, -1) * var(--ms-kill-blast-px, 46px));
         --ms-fx-rot: calc(28deg + var(--ms-blast-x, 0) * 34deg); --ms-fx-scale: 0.55; }
}
.ms-foe-dying.ms-foe-dying-blast {
  animation: ms-kill-blast var(--ms-kill-fade-ms, 420ms) cubic-bezier(.2,.7,.4,1) forwards;
}
/* The popped body's white-out wears the REACTION's colour and holds twice as long as a plain
 * hit's flash — the detonated state is a different event from being shot, and reads as one. */
.ms-impact-flash.ms-impact-flash-blast::after {
  background: color-mix(in srgb, var(--ms-blast-color, #fff) 55%, white 45%);
  animation-duration: var(--ms-blast-flash-ms, 180ms);
}

/* THE ANSWERED WAVE's own beats — an attacker "lunges" toward the party strip, a telegraph
 * pulses instead of shaking (it is a warning, not a hit). Writes THE FX CHANNEL's
 * `--ms-fx-y`/`--ms-fx-scale` — same reasoning as `ms-fig-shake` above. */
@keyframes ms-foe-acting {
  0%, 100% { --ms-fx-y: 0px; --ms-fx-scale: 1; }
  50%      { --ms-fx-y: 6px; --ms-fx-scale: 1.03; }
}
/* THE ANSWERED WAVE reads as attacks (item 4, THE COCKPIT PASS · PASS 2) — timed off its OWN
 * ATTACK_STEP_MS now (was HIT_SHAKE_MS) so the forward step reads as a committed lunge
 * rather than a hit reaction; the beat SCHEDULING (PLAYBACK_STEP_MS) is untouched. */
.ms-foe-acting { animation: ms-foe-acting var(--ms-attack-step-ms, 200ms) ease-in-out; }
/* On desktop the party stands stage-LEFT, so a wave body's committed step goes left (−x),
   not down — the lunge points at what it hits on both compositions. */
@keyframes ms-foe-acting-left {
  0%, 100% { --ms-fx-x: 0px; --ms-fx-scale: 1; }
  50%      { --ms-fx-x: -7px; --ms-fx-scale: 1.03; }
}
.ms-stage.ms-desktop .ms-foe-acting { animation-name: ms-foe-acting-left; }
/* THE BLOW (2026-09-26, docs/playtest-r13.md §7 — "animations for … dealing damage"). The
   acting Fateforged never moved on its own strike: the walk wrapper carried it to the berth
   and `.ms-walk-strike` leaned it 3px, and that was the whole "I hit you". This is the
   party's twin of `.ms-foe-acting` — a committed lunge TOWARD THE WAVE (up the stage on the
   phone, stage-right on desktop), a little bigger and a shade quicker than the wave's own
   step, paired by board.js with the same `.ms-attacker-rim` ring in the class's accent.
   THE FX CHANNEL only; the wrapper's travel and the body's ambient bob compose under it. */
@keyframes ms-hero-strike-up {
  0%, 100% { --ms-fx-y: 0px; --ms-fx-x: 0px; --ms-fx-rot: 0deg; --ms-fx-scale: 1; }
  30%, 60% { --ms-fx-y: -9px; --ms-fx-x: 2px; --ms-fx-rot: -2deg; --ms-fx-scale: 1.06; }
}
@keyframes ms-hero-strike-right {
  0%, 100% { --ms-fx-x: 0px; --ms-fx-y: 0px; --ms-fx-rot: 0deg; --ms-fx-scale: 1; }
  30%, 60% { --ms-fx-x: 10px; --ms-fx-y: -2px; --ms-fx-rot: 3deg; --ms-fx-scale: 1.06; }
}
.ms-world-hero.ms-hero-strike { animation: ms-hero-strike-up var(--ms-attack-step-ms, 200ms) ease-out; }
.ms-stage.ms-desktop .ms-world-hero.ms-hero-strike { animation-name: ms-hero-strike-right; }
@keyframes ms-telegraph-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
.ms-telegraph-pulse { animation: ms-telegraph-pulse var(--ms-hit-shake-ms, 320ms) ease-in-out; }

/* THE ATTACKER RIM (item 4) — "THIS one is striking", paired with `.ms-foe-acting`'s forward
 * step (and the party's `.ms-hero-strike` lunge). `--ms-accent` is set inline per-figure
 * (board.js `_worldFigureCard`).
 * THE SILHOUETTE, NOT THE BOX (2026-10-04 — "when enemies take action they are surrounded by
 * a box which is not very cool looking"). The tell used to be a `box-shadow` ring on
 * `.ms-figure-body` — and a box-shadow draws the element's RECTANGLE, never its pixels, so
 * every strike framed the body in a glowing square (THE INK BOX sized the box to the drawn
 * figure's height, so the square hugged it closely enough to read as deliberate). It is two
 * shape-true marks now:
 *   1. THE RIM LIGHT — a `filter: drop-shadow()` stack on the drawn image itself. drop-shadow
 *      follows ALPHA, so a hard 1-2px accent outline + a wide bloom trace the figure's own
 *      silhouette, and the body flares bright for the plateau. It lives on the IMG (or the
 *      hero badge), never `.ms-figure-body`, so it never contends with idle/critical/broken's
 *      `animation`/`filter` there — a child's filter composes UNDER its parent's for free
 *      (figures.css's rule 1: different element), and no compound selector is owed.
 *   2. THE STAMP — an accent ellipse that blooms out under the feet and fades, so the strike
 *      lands on the floor as well as the body. It rides the walk wrapper's own `::after`
 *      (not the contact shadow, whose idle breathe would dim it), so it travels with an
 *      arriving walker and contends with no animation of anyone else's.
 * The beat is still exactly `--ms-attack-step-ms` long (THE ANSWERED WAVE's scheduling is
 * untouched); only what it paints changed. */
@keyframes ms-attacker-rim {
  0%, 100% { filter: none; }
  20%, 60% { filter: brightness(1.22)
                     drop-shadow(0 0 1px var(--ms-accent, var(--ms-gold)))
                     drop-shadow(0 0 1px var(--ms-accent, var(--ms-gold)))
                     drop-shadow(0 0 14px color-mix(in srgb, var(--ms-accent, var(--ms-gold)) 75%, transparent)); }
}
.ms-attacker-rim .ms-figure-body :is(img, .ms-hero-badge) {
  animation: ms-attacker-rim var(--ms-attack-step-ms, 200ms) ease-out;
}
@keyframes ms-attacker-stamp {
  0%   { opacity: 0;    transform: translate(-50%, 50%) scale(0.45); }
  25%  { opacity: 0.95; }
  100% { opacity: 0;    transform: translate(-50%, 50%) scale(1.35); }
}
.ms-attacker-rim .ms-figure-walk { position: relative; }
.ms-attacker-rim .ms-figure-walk::after {
  content: ''; position: absolute; left: 50%; bottom: 0; width: 115%; height: 20px;
  border-radius: 50%; pointer-events: none; opacity: 0; z-index: -1;
  border: 2px solid var(--ms-accent, var(--ms-gold));
  background: radial-gradient(ellipse, color-mix(in srgb, var(--ms-accent, var(--ms-gold)) 45%, transparent) 0%, transparent 70%);
  box-shadow: 0 0 12px color-mix(in srgb, var(--ms-accent, var(--ms-gold)) 70%, transparent);
  animation: ms-attacker-stamp var(--ms-attack-step-ms, 200ms) ease-out;
}
/* THE LIVE STAGE stands real 3D bodies where these DOM figures stand hidden — no floor stamp
   floating over a body it does not belong to. */
.ms-overlay.ms-live .ms-attacker-rim .ms-figure-walk::after { display: none; }

/* A newly-applied prime stack pulses its dot rather than just silently appearing. Specificity
 * note: `.ms-dot.ms-dot-new` (2 classes) already outranks the resting-breathe base rule
 * `.ms-dot-burn`/etc (1 class), so the pulse plays over MONO/FOCUSED's breathe automatically.
 * MIXED's throb selector (`.ms-primes-mixed > .ms-dot:not(.ms-dot-empty)`, 3 classes) outranks
 * a plain 2-class `.ms-dot-new`, so it gets an explicit, order-independent override below via
 * one extra ancestor class (`.ms-stage`, always present) rather than relying on source order. */
/* 1.45, not 1.7: at 1.7 a landing stack reached into its neighbour (THE ROW IS CENTRED AND
 * NOTHING TOUCHES, above) — the flash carries the rest of the beat. */
@keyframes ms-dot-pulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.45); filter: brightness(1.75); } }
.ms-dot.ms-dot-new { animation: ms-dot-pulse var(--ms-hit-shake-ms, 320ms) ease-in-out; }
.ms-stage .ms-primes-mixed > .ms-dot.ms-dot-new {
  animation-name: ms-dot-pulse;
  animation-duration: var(--ms-hit-shake-ms, 320ms);
  animation-timing-function: ease-in-out;
  animation-iteration-count: 1;
}

/* ── The party strip ─────────────────────────────────────────────────────── */
.ms-party { flex: 0 0 auto; display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.ms-member { border: 1px solid rgba(255,255,255,0.16); padding: 6px 8px;
             display: flex; flex-direction: column; gap: 4px; background: rgba(8,10,14,0.45); }
.ms-member-active { border-color: var(--ms-gold); box-shadow: inset 0 0 0 1px rgba(255,215,106,0.35); }
.ms-member-down { opacity: 0.35; }
/* THE OTHERS FADE (docs/playtest-r10.md §3 — "in addition to highlighting the player's turn,
   let's fade out the other players"). While the party's turn is live (`.ms-turn-live`, set by
   board.js render() for COMMAND / AIM / the actor's own beat — never THE ANSWERED WAVE, where
   nobody is acting) every living member who is NOT the actor steps back, on all three party
   surfaces (phone chip · desktop row · the world figure's plate). A card that is itself a
   live control stays bright: a charged Ultimate trigger (THE SUPER IS AN INTERRUPT), the
   armed CAST card, and the ally cursor during an ally-facing aim. Opacity only — never a
   `filter`, which `.ms-hero-acted` and live-stage.css already own on these nodes.
   THE TURN IS YOURS (docs/measure-turn-select-spec.md §4): a SELECTABLE card fades its BODY
   (the children) and keeps its own frame at full, so the class-colour ring below stays a
   legible "you may pick me" tell under the fade — the card's own opacity would take the ring
   down with it. */
.ms-overlay { --ms-others-fade: 0.5; }
.ms-member { transition: opacity 160ms ease; }
.ms-turn-live .ms-member:not(.ms-member-active):not(.ms-member-down):not(.ms-member-charged):not(.ms-member-cast):not(.ms-cursor):not(.ms-member-select) {
  opacity: var(--ms-others-fade); }
.ms-turn-live .ms-member-select > * { opacity: var(--ms-others-fade); transition: opacity 160ms ease; }
/* THE TAP LADDER's rung one — the "you may pick me" tell on every card a tap would SELECT.
   THE SELECTED ONE IS GOLD (playtest-r13 §10, 2026-09-27 — Jeff: "for the non selected
   players, the colored outlines are making it seem like it's selected"): the first cut drew
   this ring in the member's own CLASS colour at full strength, which on the phone read as
   two more selected cards beside the gold one. Now: exactly ONE card on the strip carries a
   solid coloured frame — the actor's gold `.ms-member-active` — and a selectable card gets a
   NEUTRAL, DASHED hairline at low alpha (a frame you can see is a door, not a frame that is
   lit), brightening solid-white on hover/focus so the pointer still reads. The class colour
   stays on the portrait badge and the HP bar where it names the class, never the frame. An
   acted, uncharged card is not selectable and gets no ring — only its ACTED ✓. */
.ms-member-select { cursor: pointer;
  border-color: rgba(244,239,228,0.30); border-style: dashed;
  box-shadow: none; }
.ms-member-select:hover, .ms-member-select.ob-focused {
  border-color: rgba(244,239,228,0.75); border-style: solid;
  box-shadow: 0 0 0 1px rgba(244,239,228,0.35); }
/* The actor's own frame always outranks the selectable tell (an acted+charged card can be
   both `-active` and a ladder rung in the same render). */
.ms-member-active.ms-member-select { border-color: var(--ms-gold); border-style: solid;
  box-shadow: inset 0 0 0 1px rgba(255,215,106,0.35); }
.ms-turn-live .ms-world-hero:not(.ms-hero-acting):not(.ms-hero-down):not(.ms-cursor) {
  opacity: var(--ms-others-fade); transition: opacity 160ms ease; }
.ms-member-name { font-size: 0.68rem; letter-spacing: 0.12em; color: var(--ms-ink); }
.ms-member-hp { font-size: 0.6rem; color: rgba(244,239,228,0.7); font-variant-numeric: tabular-nums; }
.ms-statuses { display: flex; flex-wrap: wrap; gap: 3px; }
.ms-status { font-size: 0.5rem; letter-spacing: 0.06em; padding: 1px 4px; border: 1px solid currentColor; }
.ms-status-burn { color: var(--ms-burn); }
.ms-status-slow { color: var(--ms-shock); }
.ms-status-delay { color: var(--ms-rupture); }
.ms-acted { font-size: 0.5rem; letter-spacing: 0.14em; color: rgba(244,239,228,0.4); }
/* §6.2 riders — the only reason to read an ally's card mid-fight. */
.ms-riders { display: flex; flex-wrap: wrap; gap: 3px; }
.ms-rider { font-size: 0.5rem; letter-spacing: 0.06em; padding: 1px 4px;
            color: var(--ms-gold); border: 1px solid rgba(255,215,106,0.55); }
/* THE WARD's two pools in words (desktop party card only — the phone strip has the bar
 * segments and no room for chips). Shock-hued to match the bar segments they name. */
.ms-rider-ward   { color: var(--ms-shock); border-color: rgba(91,200,245,0.55); }
.ms-rider-banked { color: var(--ms-shock); border-color: rgba(91,200,245,0.35);
                   border-style: dashed; }
/* While the ally pane is up, the party strip is a target list, so it has to LOOK pickable. */
.ms-member-pick { cursor: pointer; }
.ms-member-pick:hover { border-color: var(--ms-gold); }
.ms-cmd-class { border-color: rgba(91,200,245,0.55); }
.ms-cmd-class .ms-cmd-label { color: var(--ms-shock); }

/* ── The command rail ────────────────────────────────────────────────────── */
/* A GRID, not `flex-wrap`. Wrapping means the buttons move between turns as commands become
   legal and illegal — which breaks muscle memory AND THE SAME SPOT RULE, since CAST is placed
   by grid auto-placement at the armed command's own position in the slot list. */
.ms-rail { flex: 0 0 auto; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr));
           /* THE AIM INFO PANEL spans rows: the rows keep the cells' own floor so a card
              shorter than the ghosts it replaced never pulls the CAST row up (THE SAME SPOT
              RULE, measured by aim/cast-inherits-the-armed-cell). */
           grid-auto-rows: minmax(48px, auto);
           gap: 6px; align-items: stretch;
           border-top: 1px solid rgba(255,255,255,0.16); padding-top: 8px; }
/* NO LAYOUT SHIFT (2026-10-03): while a command is armed the rows are PINNED to the cell
   height the command pane uses (48 here; the phone's `--ms-rail-cell-min-h` in the two phone
   blocks below; 38 on desktop's action rail) — the rail is the same height in COMMAND and
   AIM, CAST inherits its cell's whole rect, the card and the headline are clipped to the rows
   they take. Guard: `aim/no-layout-shift-*`. */
.ms-rail.ms-rail-aim { grid-auto-rows: 48px; }
.ms-cmd {
  /* box-sizing:border-box FIXES THE SAME SPOT RULE (docs/measure-phone-controls.md §1.3,
     2026-08-29 investigation): a real command cell is a `<button>` (UA-default border-box,
     so `min-height` already means the rendered box), but `_renderAimRail`'s ghost cells are
     plain `<div>`s — content-box by default with no site-wide reset in this stylesheet — so
     the SAME `min-height` set the CONTENT box on a ghost and left its padding+border (14px
     at the phone tier) to inflate the cell ON TOP of that, growing every row in the rail
     (not just the armed one) the instant a command armed and pulling the whole rail — CAST
     included — up off the pixel the finger had just pressed. One declaration makes every
     `.ms-cmd`-classed element (button or div) measure `min-height` the same way. */
  box-sizing: border-box;
  min-width: 0; min-height: 48px;                       /* 44px is the floor; 48 is the aim */
  display: flex; flex-direction: column; justify-content: center; gap: 2px;
  padding: 6px 10px; font: inherit; color: var(--ms-ink); text-align: left;
  background: rgba(8,10,14,0.6); border: 1px solid rgba(255,255,255,0.24); cursor: pointer;
}
.ms-cmd:hover, .ms-cmd.ob-focused { border-color: var(--ms-gold); box-shadow: 0 0 0 1px var(--ms-gold); outline: none; }
.ms-cmd-cold { opacity: 0.4; cursor: default; }
/* THE TRAY HOLDS (playtest 2026-08-29) — the whole rail during THE ANSWERED WAVE, cold.
   Reads DIFFERENT from an ordinary `.ms-cmd-cold` (a command merely off cooldown, still
   yours to plan around): dimmer still, desaturated, and the border drops to a hairline —
   "not your turn" instead of "not ready yet". `pointer-events:none` backs up `disabled` —
   nothing in the tray is tappable while the wave is being watched. */
.ms-cmd-inert { opacity: 0.22; filter: grayscale(0.6); border-style: dashed;
                border-color: rgba(255,255,255,0.14); pointer-events: none; cursor: default; }
.ms-cmd-label { font-size: 0.72rem; letter-spacing: 0.1em; }
.ms-cmd-sub { font-size: 0.55rem; letter-spacing: 0.06em; color: rgba(244,239,228,0.62); }
/* THE LIVE CELL (ruled 2026-10-03, docs/pitches/gun-speaks-pitch.html — "for light and heavy:
   name of gun, perk; the perk lights up when active" + "take care on line wrapping in these
   boxes"). A cell's two lines NEVER wrap: a wrapped cell re-flows the rail (THE SAME SPOT RULE)
   and reads as a paragraph under a thumb. Both ellipsize; a long gun name trades tracking and
   a size notch (`-long`, set by board.js on >13 chars) before it trades letters. */
.ms-cmd-label, .ms-cmd-sub { display: block; max-width: 100%; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ms-cmd-label-long { font-size: 0.62rem; letter-spacing: 0.04em; }
/* The one cell that MAY wrap: the gold CAST/commit cell carries a whole sentence (FIRE · THE GUN
   — THE PERK 50% TO DET) and the gun's name must never be cut there — it is the thing you fire. */
.ms-cast .ms-cmd-label { white-space: normal; overflow: visible; text-overflow: clip; }
.ms-cmd-perk { color: rgba(244,239,228,0.62); }
.ms-cmd-perk-live { color: var(--ms-gold); text-shadow: 0 0 6px rgba(255,215,106,0.55); font-weight: 700; }
.ms-cmd-live:not(.ms-cmd-cold) { border-color: rgba(255,215,106,0.55); }
.ms-cmd-left { color: rgba(244,239,228,0.5); }
/* THE EFFECT IS STATED WHERE IT IS CHOSEN (docs/the-measure-spec.md §9.1) — the class
   ability's blurb / the sustain's desc, in the author's own sentence case. Deliberately
   NOT given a phone twin: it is sized so the longest authored string (the 54-char
   KINETIC BARRIER desc) still fits inside `.ms-cmd`'s existing min-height at phone width,
   so stating the effect costs the rail — and therefore the thumb arc below it — nothing. */
.ms-cmd-blurb { font-size: 0.5rem; letter-spacing: 0.01em; line-height: 1.05;
                margin-top: -1px;      /* eats `.ms-cmd`'s own 2px gap back */
                color: rgba(244,239,228,0.46); text-transform: none; }
/* `.ms-cmd-ultimate` is RETIRED — THE SUPER SITS ON THE CARD (docs/measure-phone-controls.md
   §7.7) deletes the ultimate's rail cell outright, so `cmd.kind` never renders that class
   any more; its gold treatment lives on `.ms-member-charged`/`.ms-chip-super-name` below. */
.ms-rail-hint { font-size: 0.7rem; letter-spacing: 0.12em; color: var(--ms-gold); padding: 12px 4px;
                grid-column: 1 / -1; }

/* ── THE AIM RAIL (docs/measure-phone-controls.md §1.3) ───────────────────────
 * While a command is armed the rail does not re-flow: every cell becomes a dead ghost of the
 * command it was, so the menu you came from is still readable, and CAST lands on the exact
 * cell you just pressed. */
/* THE AIM INFO PANEL (docs/playtest-r10.md §8) — the card that takes the ghosts' rows while
   a command is armed. Explicitly grid-placed by board.js (`grid-column: 1/-1` + the rows the
   CAST row does not own), so CAST still lands on its own pixel (THE SAME SPOT RULE). It is
   read-only furniture: no pointer target, no focus stop, and `overflow:hidden` inside rows
   whose height the ghosts already set — the rail never grows. */
/* THE FORECAST CARD (2026-10-03, Jeff: "a list of different fonts ... share a language ...
   icons ... use the full space"): a two-column GRID — the SLOT WORD gutter (ARMED · TARGET ·
   LANDS · WHY · NEXT, one language for every command kind) and the facts beside it. */
.ms-aim-info { box-sizing: border-box; min-width: 0; min-height: 0; overflow: hidden;
  display: grid; grid-template-columns: max-content minmax(0, 1fr); column-gap: 8px; row-gap: 2px;
  align-items: start; align-content: start; padding: 5px 10px;
  background: rgba(8,10,14,0.55); border: 1px solid rgba(255,215,106,0.35); color: var(--ms-ink);
  font-size: 0.58rem; letter-spacing: 0.04em; line-height: 1.2; pointer-events: none; }
.ms-aim-slot { font-size: 0.46rem; letter-spacing: 0.14em; font-weight: 700; color: rgba(255,215,106,0.62);
  min-width: 3.4em; text-align: right; padding-top: 3px; white-space: nowrap; }
.ms-aim-slot-warn { display: flex; justify-content: flex-end; padding-top: 1px; }
.ms-aim-slot-warn .ms-aim-ico { color: var(--ms-shock, #8fd6ff); }
/* A slot change is a hairline above its WORD, never a divider row — the card's height is the
   phone deck's two-cell budget, and a divider row costs a gap on both sides. */
.ms-aim-slot-first { border-top: 1px solid rgba(255,255,255,0.12); margin-top: 1px; padding-top: 4px; }
.ms-aim-slot-first + .ms-aim-row { margin-top: 1px; }
.ms-aim-row { min-width: 0; display: flex; flex-wrap: wrap; align-items: center; gap: 3px 6px; }
.ms-aim-ico { width: 13px; height: 13px; flex: 0 0 auto; }
.ms-aim-text { flex: 1 1 0; min-width: 0; }   /* wraps BESIDE its glyph, never under it */
.ms-aim-chips { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 3px 4px; min-width: 0; }
.ms-aim-chip { display: inline-flex; align-items: center; gap: 3px; padding: 1px 5px; border-radius: 2px;
  border: 1px solid rgba(244,239,228,0.22); color: rgba(244,239,228,0.85);
  font-size: 0.52rem; letter-spacing: 0.05em; white-space: nowrap; line-height: 1.3; }
.ms-aim-chip .ms-aim-ico, .ms-aim-chip svg { width: 10px; height: 10px; }
.ms-aim-chip-live { border-color: var(--ms-gold); color: var(--ms-gold); text-shadow: 0 0 6px rgba(255,215,106,0.5); }
.ms-aim-chip-tag { font-size: 0.42rem; letter-spacing: 0.12em; opacity: 0.85; }
.ms-aim-chip-cold { border-style: dashed; color: rgba(244,239,228,0.6); }
.ms-aim-chip-primed { border-color: rgba(255,215,106,0.5); }
/* THE WHY as chips: a tax cold, a bonus gold — the sign reads before the word. */
.ms-aim-chip.ms-aim-delta-pos { border-color: rgba(255,215,106,0.65); }
.ms-aim-chip.ms-aim-delta-neg { border-color: rgba(143,214,255,0.6); }
/* THE TARGET: the plate's dots, the name, an HP bar carrying THIS cast's cut, the numbers. */
.ms-aim-info-target .ms-preview-dots { gap: 2px; }
.ms-aim-info-target .ms-dot { width: 7px; height: 7px; min-width: 0; border-width: 1px; box-shadow: none; animation: none; }
.ms-aim-target-name { font-weight: 700; letter-spacing: 0.08em; }
.ms-aim-hpbar { flex: 1 1 56px; min-width: 48px; max-width: 160px; height: 7px; display: flex; overflow: hidden;
  border: 1px solid rgba(255,255,255,0.3); background: rgba(0,0,0,0.45); }
.ms-aim-hpbar-left { height: 100%; background: var(--ms-hp-foe); }
.ms-aim-hpbar-cut { height: 100%; background: var(--ms-gold); animation: ms-aim-cut 1.1s ease-in-out infinite; }
@keyframes ms-aim-cut { 50% { opacity: 0.5; } }
.ms-aim-hpnum { font-variant-numeric: tabular-nums; white-space: nowrap; }
.ms-aim-hpnum b { color: var(--ms-gold); font-weight: 700; }
.ms-aim-target-state { flex-basis: 100%; }
/* THE HEADLINE — the LANDS slot beside CAST: the number big, the rest of the sentence small. */
.ms-aim-headline { box-sizing: border-box; min-width: 0; min-height: 0; overflow: hidden; display: flex; flex-direction: column;
  justify-content: center; padding: 4px 10px; pointer-events: none;
  background: rgba(8,10,14,0.6); border: 1px solid rgba(255,215,106,0.55); color: var(--ms-gold); }
.ms-aim-headline .ms-aim-info-outcome { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0 5px;
  font-size: 0.58rem; letter-spacing: 0.05em; line-height: 1.15; }
.ms-aim-headline .ms-aim-ico { width: 16px; height: 16px; align-self: center; }
.ms-aim-headline-big { font-size: 1.3rem; line-height: 1; font-weight: 800; letter-spacing: 0.02em; font-variant-numeric: tabular-nums; }
.ms-aim-headline-word { font-size: 0.74rem; letter-spacing: 0.06em; white-space: normal; }
.ms-aim-headline-unit { font-size: 0.5rem; letter-spacing: 0.14em; color: rgba(255,215,106,0.75); font-weight: 700; }
.ms-aim-headline-sub { font-size: 0.62rem; font-weight: 700; color: var(--ms-ink); }
.ms-aim-headline-rest { flex-basis: 100%; font-size: 0.52rem; letter-spacing: 0.05em; color: rgba(244,239,228,0.8); font-weight: 400; margin-top: 2px; }
.ms-aim-headline-empty { border-style: dashed; color: rgba(244,239,228,0.5); }
.ms-stage:not(.ms-desktop) .ms-aim-headline-big { font-size: 1.15rem; }
.ms-stage:not(.ms-desktop) .ms-aim-headline-word { font-size: 0.66rem; }
.ms-stage:not(.ms-desktop) .ms-aim-headline { padding: 4px 8px; }
/* THE PANEL IS A FORECAST, NOT A RULEBOOK (docs/playtest-r10.md §13): one slot per question,
   weighted by the order a player asks them. THE EYEBROW (what is armed) is dim — the CAST cell
   and the bar already say it big; THE OUTCOME (what lands) is the one gold, bold, larger row;
   THE WHY's deltas carry their sign as a colour (a tax cold, a bonus gold) so good/bad reads
   before the words do; THE NEXT is ink, the perks quieter still. */
.ms-aim-info-head { font-size: 0.54rem; letter-spacing: 0.08em; color: rgba(244,239,228,0.55); font-weight: 400; }
.ms-aim-info-outcome { font-size: 0.7rem; letter-spacing: 0.06em; color: var(--ms-gold); font-weight: 700; line-height: 1.15; }
.ms-aim-info-why { color: rgba(244,239,228,0.72); }
.ms-aim-delta { white-space: nowrap; }
.ms-aim-delta-pos { color: var(--ms-gold); font-weight: 700; }
/* §5.2c SINGULARITY — the top of the ladder reads louder than any other delta in the panel. */
.ms-aim-delta-rainbow { letter-spacing: 0.06em; text-shadow: 0 0 6px rgba(230,216,255,0.75), 0 0 2px var(--ms-gold); }
.ms-aim-delta-neg { color: var(--ms-shock, #8fd6ff); font-weight: 700; }
.ms-aim-sep { color: rgba(244,239,228,0.35); }
.ms-aim-info-next { color: var(--ms-gold); }
.ms-aim-info-fact { color: rgba(244,239,228,0.85); }
.ms-aim-info-blurb { color: rgba(244,239,228,0.6); font-style: italic; letter-spacing: 0.01em; }
.ms-aim-info-target { color: var(--ms-ink); border-top: 1px solid rgba(255,255,255,0.12); padding-top: 3px; margin-top: 1px; }
.ms-aim-info-warn { color: var(--ms-shock, #8fd6ff); }
.ms-aim-info-rider { padding-left: 8px; position: relative; }
.ms-aim-info-rider::before { content: '▸'; position: absolute; left: 0; }
.ms-aim-info-rider-live { color: var(--ms-gold); }
.ms-aim-info-rider-idle { color: rgba(244,239,228,0.5); }
.ms-aim-info-rider-silent { color: rgba(244,239,228,0.32); }
/* THE PERK READS — a gun perk's NAME floats where the hit landed. */
.ms-dmg-float-perk { color: var(--ms-gold); letter-spacing: 0.08em; }
.ms-cmd-ghost { opacity: 0.26; cursor: default; pointer-events: none;
                border-style: dashed; background: transparent; }
 /* Same height as the cell it replaces, deliberately: a TALLER cast button grows its grid
    row, and a bottom-anchored rail then shifts every row above it — moving CAST off the
    pixel the finger is already on. It is louder in colour, never in size. */
.ms-cast { background: var(--ms-gold); border-color: var(--ms-gold); color: var(--ms-ink-on-gold);
           text-align: center; align-items: center;
           box-shadow: 0 0 18px rgba(255,215,106,0.35); }
.ms-cast .ms-cmd-label { font-size: 0.78rem; letter-spacing: 0.14em; font-weight: 700; }
/* NO LAYOUT SHIFT: the one wrapping label lives in a PINNED row, so it is clipped rather than
   allowed to grow the row; the phone row (64px) holds three lines of it. */
.ms-cast { overflow: hidden; }
.ms-cast .ms-cmd-label { line-height: 1.15; }
.ms-cast:hover, .ms-cast.ob-focused { filter: brightness(1.08); outline: none; }

/* ── THE ACTION BAR (finding 3) ────────────────────────────────────────────
 * One persistent [✕][ALLY?][◀][▶][ENTER] row, drawn in EVERY pane at the same height — the
 * one constant touch surface, the whole mode is playable from it alone (board.js's
 * _renderFooter). `.ms-footer` stays the grid; `.ms-bar` is the shared button base, with
 * `.ms-bar-back/-ally/-prev/-next/-enter` as the per-cell modifiers (mirroring the old
 * `.ms-aim`/`.ms-aim-*` convention this replaces). `.ms-cmd-leave` rides alongside
 * `.ms-bar-back` in COMMAND so a leave affordance is still one grep away. */
.ms-footer { flex: 0 0 auto; display: grid; gap: 6px; margin-top: 6px; min-height: 52px;
              /* THE BAR HOLDS ITS HEIGHT (2026-10-09, Jeff on the phone: "a small amount of layout
                 shift still"): the bar is pinned, not floored. An ally-facing cast's ENTER carries
                 the pair statement (`.ms-bar-sub`) and five columns instead of four, and a floored
                 row grew to fit it — 56 → 65 px at 430 wide — sliding the deck, the strip and THE
                 WORLD up the moment Bulwark Wall armed. The cell fits its words instead (below). */
              height: 52px; grid-template-rows: minmax(0, 1fr); }
.ms-footer > .ms-bar { min-height: 0; overflow: hidden; }
/* The ENTER that carries the pair statement trades a type notch for its two lines. */
.ms-bar-enter:has(.ms-bar-sub) { gap: 1px; padding-top: 3px; padding-bottom: 3px; }
.ms-bar-enter:has(.ms-bar-sub) .ms-bar-label { font-size: 0.5rem; letter-spacing: 0.04em; line-height: 1.1; }
.ms-bar-enter .ms-bar-sub { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; }
/* BACK is always the narrow column; ENTER is always the last column. The ALLY cell (5 cols)
   only exists while an ally-facing class ability is armed — everywhere else it is 4. */
.ms-footer[data-cols="4"] { grid-template-columns: minmax(56px, 0.55fr) repeat(3, minmax(0, 1fr)); }
.ms-footer[data-cols="5"] { grid-template-columns: minmax(56px, 0.55fr) repeat(4, minmax(0, 1fr)); }
.ms-bar {
  min-height: 52px; padding: 8px 6px; font: inherit; font-size: 0.62rem;
  letter-spacing: 0.1em; color: var(--ms-ink); background: rgba(8,10,14,0.6);
  border: 1px solid rgba(255,255,255,0.3); cursor: pointer;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 2px; text-align: center;
}
.ms-bar:hover { border-color: var(--ms-gold); }
.ms-bar:active { background: rgba(255,215,106,0.18); }
.ms-bar-disabled, .ms-bar:disabled { opacity: 0.32; cursor: default; }
.ms-bar-label { font-size: inherit; letter-spacing: inherit; }
/* The ally-facing pair statement (board.js `_renderFooter`) — now carrying the rider's
   magnitude and duration as well as its label, so it runs to three short lines. Sized
   and leaded so those three lines occupy what two used to. The bar's height is PINNED now
   (THE BAR HOLDS ITS HEIGHT, above): the sub is clamped to two lines inside it. */
.ms-bar-sub { font-size: 0.45rem; letter-spacing: 0.04em; line-height: 1.08; opacity: 0.78; }
.ms-bar-back { color: rgba(244,239,228,0.75); }
.ms-bar-ally { color: var(--ms-shock); border-color: rgba(91,200,245,0.5); }
.ms-cmd-leave { color: rgba(244,239,228,0.5); border-color: rgba(255,255,255,0.14); }
.ms-bar-enter {
  background: var(--ms-gold); border-color: var(--ms-gold); color: var(--ms-ink-on-gold); font-weight: 700;
}
.ms-bar-enter:hover, .ms-bar-enter.ob-focused { filter: brightness(1.08); outline: none; }
.ms-bar-enter:disabled { opacity: 0.4; background: rgba(255,215,106,0.35); border-color: rgba(255,215,106,0.35); }

/* ── The beat banner ─────────────────────────────────────────────────────── */
.ms-banner {
  position: absolute; inset: 0; display: none;
  align-items: center; justify-content: center; flex-direction: column; gap: 14px;
  background: rgba(6,8,12,0.86); text-align: center; padding: 24px; z-index: 4;
}
.ms-banner-on { display: flex; }
.ms-banner-text { font-size: clamp(0.9rem, 2.4vw, 1.4rem); letter-spacing: 0.14em;
                  color: var(--ms-ink); white-space: pre-line; max-width: 46ch; line-height: 1.6; }
.ms-banner-hint { font-size: 0.58rem; letter-spacing: 0.2em; color: rgba(244,239,228,0.45); }
.ms-banner:not(:has(.ms-banner-btn)) { cursor: pointer; }
.ms-banner-btn { min-height: 44px; padding: 10px 26px; font: inherit;
                 letter-spacing: 0.16em; color: var(--ms-ink-on-gold); background: var(--ms-gold);
                 border: none; cursor: pointer; }
/* THE RESTART OFFER's "no" — a quiet outline beside the gold "yes" (board.js _offerRestartWithParty). */
.ms-banner-btn-alt { color: var(--ms-ink); background: transparent; border: 1px solid rgba(244,239,228,0.45); }

/* ── The front door ──────────────────────────────────────────────────────── */
.ms-menu-cols { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
                gap: clamp(10px, 2vw, 24px); align-items: start; width: 100%; }
.ms-menu-list { display: flex; flex-direction: column; gap: 6px; max-height: 46vh; overflow-y: auto; }
.ms-mission { display: grid; grid-template-columns: auto 1fr; grid-template-rows: auto auto;
              gap: 2px 10px; min-height: 44px; padding: 8px 12px; text-align: left;
              font: inherit; color: var(--ms-ink); background: rgba(8,10,14,0.5);
              border: 1px solid rgba(255,255,255,0.18); cursor: pointer; }
.ms-mission:hover, .ms-mission.ob-focused { border-color: var(--ms-gold); outline: none; }
.ms-mission-sel { border-color: var(--ms-gold); background: rgba(255,215,106,0.08); }
.ms-mission-n { grid-row: 1 / span 2; align-self: center; font-size: 1.1rem; color: var(--ms-gold);
                font-variant-numeric: tabular-nums; }
.ms-mission-name { font-size: 0.8rem; letter-spacing: 0.1em; }
.ms-mission-act { font-size: 0.55rem; letter-spacing: 0.08em; color: rgba(244,239,228,0.5); }
/* THE UNTRIED (progression/untried.js) — this party would pay a bonus drop here */
.ms-mission-untried { color: #7fe8d0; }
.ms-menu-side { display: flex; flex-direction: column; gap: 12px; }
.ms-knob, .ms-payout { border: 1px solid rgba(255,255,255,0.18); padding: 10px 12px;
                       display: flex; flex-direction: column; gap: 6px; background: rgba(8,10,14,0.45); }
.ms-knob-row { display: flex; flex-wrap: wrap; gap: 5px; }
.ms-tier { min-height: 36px; padding: 6px 10px; font: inherit; font-size: 0.6rem;
           letter-spacing: 0.1em; color: var(--ms-ink); background: transparent;
           border: 1px solid rgba(255,255,255,0.24); cursor: pointer; }
.ms-tier:hover, .ms-tier.ob-focused { border-color: var(--ms-gold); outline: none; }
.ms-tier-sel { background: var(--ms-gold); color: var(--ms-ink-on-gold); border-color: var(--ms-gold); }
.ms-payout-note { font-size: 0.55rem; letter-spacing: 0.04em; color: rgba(244,239,228,0.55); line-height: 1.5; }
.ms-logline { font-size: 0.66rem; line-height: 1.6; color: rgba(244,239,228,0.72); font-style: italic; }
.ms-launch { min-height: 52px; display: flex; flex-direction: column; justify-content: center;
             gap: 2px; padding: 10px 16px; font: inherit; color: var(--ms-ink-on-gold); background: var(--ms-gold);
             border: none; cursor: pointer; text-align: left; }
.ms-launch:hover, .ms-launch.ob-focused { filter: brightness(1.1); outline: 2px solid var(--ms-white); }
.ms-launch-label { font-size: 0.8rem; letter-spacing: 0.16em; }
.ms-launch-sub { font-size: 0.58rem; letter-spacing: 0.08em; opacity: 0.72; }
.ms-empty { display: flex; flex-direction: column; gap: 8px; padding: 18px;
            border: 1px dashed rgba(255,255,255,0.24); }
.ms-empty-title { font-size: 0.8rem; letter-spacing: 0.14em; color: var(--ms-gold); }
.ms-empty-body { font-size: 0.66rem; line-height: 1.6; color: rgba(244,239,228,0.7); }

/* ── THE PINNED ACTION ZONE (measure-phone-controls.md §7.11) ────────────────
 * PARTY (secondary) + ENTER MEASURED TIME (primary) — the two things this screen
 * exists to let you do, kept together always. Fixes the 2026-08-29 playtest
 * report: both used to live at the bottom of `sideCol`, which scrolls out of
 * view at phone width (PARTY, appended after ◯ BACK, scrolled off furthest).
 * Desktop: an ordinary row directly under the two columns — the screen already
 * fits without scrolling, so nothing here needs to fight it. The phone block
 * below (+ its zoom twin) re-pins this same element to the viewport bottom;
 * no separate markup, one element, two presentations. */
/* THE SCROLL REGION's wrapper (measure-menu.js) is transparent to the stage's flex flow on
   desktop; the phone block below turns it into the ONE scroll box. */
.ms-menu-scroll { display: contents; }
.ms-menu-actions { display: flex; align-items: stretch; gap: 10px; margin-top: 4px; }
.ms-menu-actions .ms-launch { flex: 1 1 auto; }
/* `.ms-party-btn-standalone` (party.css) had no rule of its own before this pass — it
   inherited `.ms-party-btn`'s 32px min-height, under Fate's own 44px touch floor. A
   real rule, on every platform; the phone block below raises it further still. */
.ms-party-btn.ms-party-btn-standalone { flex: 0 0 auto; min-height: 44px; padding: 8px 14px; font-size: 0.6rem; }

/* THE PARTY SUMMARY — item 5's second, discoverable home for the party inside
   `sideCol`, above THE KNOB: a read-only line naming the three fielded classes;
   tapping it opens the same THE PARTY screen as the action-zone PARTY button.
   Mouse/touch only by design (measure-menu.js comments why it claims no second
   focus-hub stop — PARTY already has one, right in the action zone). */
.ms-party-summary { display: flex; align-items: center; justify-content: space-between;
                     gap: 10px; min-height: 44px; padding: 7px 12px; font: inherit;
                     color: var(--ms-ink); background: rgba(8,10,14,0.4);
                     border: 1px solid rgba(255,255,255,0.16); cursor: pointer; text-align: left; }
.ms-party-summary:hover, .ms-party-summary.ob-focused { border-color: var(--ms-gold); outline: none; }
.ms-party-summary-names { font-size: 0.62rem; letter-spacing: 0.06em; color: rgba(244,239,228,0.8);
                           text-align: right; }

/* ── Phone (§10 phone-first) ─────────────────────────────────────────────── */
@media (max-width: 760px) {
  /* The shared .ob-furniture-top chrome is absolutely positioned, and this screen's rule
     label wraps to three lines under it at phone width — the two collided. Drop the label
     (it is flavour, and the heading directly under it already says THE MEASURE) and give
     the stage room for the furniture. */
  .ms-menu .ob-rule-label { display: none; }
  /* THE HEADER HOLDS ONE LINE (2026-09-26). At 10px/2px-tracked Courier the two readouts
     need ~360px and a 390px phone offers ~334px inside the shared 28px gutters, so each
     wrapped to two lines and the second landed ON the THE MEASURE heading. One line each
     at a tighter track, and the strip sits under the notch rather than beside it (the
     furniture is absolute inside the overlay's padding box, so it never saw the inset). */
  .ms-menu .ob-furniture-top {
    top: calc(10px + env(safe-area-inset-top, 0px));
    padding: 0 14px; gap: 8px;
  }
  .ms-menu .ob-furniture-top .fx-telemetry { white-space: nowrap; font-size: 9px; letter-spacing: 1px; }
  /* THE SCROLL REGION (2026-09-26). The stage used to BE the scroll box with THE PINNED
     ACTION ZONE `position:sticky` at its bottom — which paints the row OVER whatever is
     scrolled beneath it (the payout card, the logline) until the page hits the end; the
     2026-09-26 playtest read that as a layout shift. Now the stage is a fixed column
     (heading · rule · the scroll box · the action row) and the scrolling happens INSIDE
     `.ms-menu-scroll`, so the action row is a real footer below the content, never on it.
     The overlay's own padding already carries `safe-area-inset-bottom`; the row no longer
     adds it a second time. */
  .ms-menu .ob-submenu-stage {
    justify-content: flex-start;
    flex: 1 1 auto; min-height: 0; align-self: stretch;
    overflow: hidden;
    padding-top: 40px;
    padding-bottom: 0;
  }
  .ms-menu-scroll {
    display: flex; flex-direction: column; align-items: center; gap: 18px;
    flex: 1 1 auto; min-height: 0; width: 100%;
    overflow-y: auto;
    -webkit-overflow-scrolling: touch;
    padding-bottom: 12px;
  }
  .ms-menu-cols { grid-template-columns: 1fr; }
  .ms-menu-list { max-height: 30vh; }
  /* THE PINNED ACTION ZONE (§7.11) at phone width — the same element as the desktop rule
     above, now a non-scrolling flex sibling under the scroll box: on screen with zero
     scrolling (the §7.11 promise) AND never over the content (the 2026-09-26 fix). */
  .ms-menu-actions {
    flex: 0 0 auto; width: 100%; margin-top: 0;
    padding-top: 10px;
    padding-bottom: 6px;
    border-top: 1px solid rgba(255,255,255,0.18);
    background: var(--void);
  }
  .ms-party-btn.ms-party-btn-standalone { min-height: 48px; font-size: 0.66rem; }
  .ms-menu-actions .ms-launch { min-height: 52px; }
  .ms-overlay .ms-stage {
    gap: 6px; padding: 8px;
    padding-bottom: calc(8px + env(safe-area-inset-bottom, 0px));
  }
  /* THE THUMB ZONE. Two columns, taller cells, and the action bar bigger still — every
     mandatory touch of a turn lands in the bottom third of the screen. THE COCKPIT FRAME
     (Pass 1) reskins these tiles as instruments and adds THE SLUG/SCENE/STRIP layout, all
     under `.ms-stage:not(.ms-desktop)` (a platform class, not a width query) rather than
     here — this geometry (columns, min-heights) is Pass 0's and stays untouched. */
  /* THE COCKPIT PASS · PASS 3 (2026-08-29) — the ultimate's rail cell is gone (6 cells, a
     clean 2x3 on phone), and the freed space buys a taller floor here: `--ms-rail-cell-min-h`
     (board.js, off `config.measure.ui.RAIL_CELL_MIN_H_PHONE`) with the old 54px kept as the
     var()'s own fallback for a mount that never ran that JS. */
  .ms-rail { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 5px; grid-auto-rows: minmax(var(--ms-rail-cell-min-h, 54px), auto); }
  .ms-rail.ms-rail-aim { grid-auto-rows: var(--ms-rail-cell-min-h, 54px); }
  .ms-cmd { min-height: var(--ms-rail-cell-min-h, 54px); }
  .ms-cmd-label { font-size: 0.76rem; }
  .ms-cmd-label-long { font-size: 0.64rem; }
  .ms-cmd-sub { font-size: 0.6rem; }
  .ms-bar { min-height: 56px; font-size: 0.58rem; padding: 6px 4px; }
  .ms-footer { min-height: 56px; height: 56px; gap: 5px; }
}

/* ── THE ZOOM TWIN (docs/ui-scale-vault-view.md, BINDING) ─────────────────────
 * `.ms-overlay` is an `.ob-overlay`, so styles.css applies `zoom: var(--ui-scale)`
 * to it. A raw `@media (max-width: 760px)` reads the RAW viewport, which the zoom
 * does not change — so on a phone at LARGE or EXTRA LARGE ui-scale the block above
 * would never fire and the board would keep its desktop layout at the exact scale
 * a player chose because they need things bigger. The contract's fix is a
 * `:root.eff-*` twin of every raw block (main.js stamps eff-w-le-600/720/900 and
 * eff-h-le-480/860 from `innerWidth / --ui-scale`); the raw block stays for the
 * scale-1.0 path. 720 is the nearest stamped breakpoint under this file's 760.
 * Generated from the block above rather than hand-copied — the two must not drift.
 * ─────────────────────────────────────────────────────────────────────────── */

  /* The shared .ob-furniture-top chrome is absolutely positioned, and this screen's rule
     label wraps to three lines under it at phone width — the two collided. Drop the label
     (it is flavour, and the heading directly under it already says THE MEASURE) and give
     the stage room for the furniture. */
  :root.eff-w-le-720 .ms-menu .ob-rule-label { display: none; }
  /* THE HEADER HOLDS ONE LINE (2026-09-26). At 10px/2px-tracked Courier the two readouts
     need ~360px and a 390px phone offers ~334px inside the shared 28px gutters, so each
     wrapped to two lines and the second landed ON the THE MEASURE heading. One line each
     at a tighter track, and the strip sits under the notch rather than beside it (the
     furniture is absolute inside the overlay's padding box, so it never saw the inset). */
  :root.eff-w-le-720 .ms-menu .ob-furniture-top {
    top: calc(10px + env(safe-area-inset-top, 0px));
    padding: 0 14px; gap: 8px;
  }
  :root.eff-w-le-720 .ms-menu .ob-furniture-top .fx-telemetry { white-space: nowrap; font-size: 9px; letter-spacing: 1px; }
  /* THE SCROLL REGION (2026-09-26). The stage used to BE the scroll box with THE PINNED
     ACTION ZONE `position:sticky` at its bottom — which paints the row OVER whatever is
     scrolled beneath it (the payout card, the logline) until the page hits the end; the
     2026-09-26 playtest read that as a layout shift. Now the stage is a fixed column
     (heading · rule · the scroll box · the action row) and the scrolling happens INSIDE
     `.ms-menu-scroll`, so the action row is a real footer below the content, never on it.
     The overlay's own padding already carries `safe-area-inset-bottom`; the row no longer
     adds it a second time. */
  :root.eff-w-le-720 .ms-menu .ob-submenu-stage {
    justify-content: flex-start;
    flex: 1 1 auto; min-height: 0; align-self: stretch;
    overflow: hidden;
    padding-top: 40px;
    padding-bottom: 0;
  }
  :root.eff-w-le-720 .ms-menu-scroll {
    display: flex; flex-direction: column; align-items: center; gap: 18px;
    flex: 1 1 auto; min-height: 0; width: 100%;
    overflow-y: auto;
    -webkit-overflow-scrolling: touch;
    padding-bottom: 12px;
  }
  :root.eff-w-le-720 .ms-menu-cols { grid-template-columns: 1fr; }
  :root.eff-w-le-720 .ms-menu-list { max-height: 30vh; }
  /* THE PINNED ACTION ZONE (§7.11) at phone width — the same element as the desktop rule
     above, now a non-scrolling flex sibling under the scroll box: on screen with zero
     scrolling (the §7.11 promise) AND never over the content (the 2026-09-26 fix). */
  :root.eff-w-le-720 .ms-menu-actions {
    flex: 0 0 auto; width: 100%; margin-top: 0;
    padding-top: 10px;
    padding-bottom: 6px;
    border-top: 1px solid rgba(255,255,255,0.18);
    background: var(--void);
  }
  :root.eff-w-le-720 .ms-party-btn.ms-party-btn-standalone { min-height: 48px; font-size: 0.66rem; }
  :root.eff-w-le-720 .ms-menu-actions .ms-launch { min-height: 52px; }
  :root.eff-w-le-720 .ms-overlay .ms-stage {
    gap: 6px; padding: 8px;
    padding-bottom: calc(8px + env(safe-area-inset-bottom, 0px));
  }
  /* THE THUMB ZONE. Two columns, taller cells, and the action bar bigger still — every
     mandatory touch of a turn lands in the bottom third of the screen. THE COCKPIT FRAME
     (Pass 1) reskins these tiles as instruments and adds THE SLUG/SCENE/STRIP layout, all
     under `.ms-stage:not(.ms-desktop)` (a platform class, not a width query) rather than
     here — this geometry (columns, min-heights) is Pass 0's and stays untouched. */
  :root.eff-w-le-720 .ms-rail { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 5px; grid-auto-rows: minmax(var(--ms-rail-cell-min-h, 54px), auto); }
  :root.eff-w-le-720 .ms-rail.ms-rail-aim { grid-auto-rows: var(--ms-rail-cell-min-h, 54px); }
  :root.eff-w-le-720 .ms-cmd { min-height: var(--ms-rail-cell-min-h, 54px); }
  :root.eff-w-le-720 .ms-cmd-label { font-size: 0.76rem; }
  :root.eff-w-le-720 .ms-cmd-label-long { font-size: 0.64rem; }
  :root.eff-w-le-720 .ms-cmd-sub { font-size: 0.6rem; }
  :root.eff-w-le-720 .ms-bar { min-height: 56px; font-size: 0.58rem; padding: 6px 4px; }
  :root.eff-w-le-720 .ms-footer { min-height: 56px; height: 56px; gap: 5px; }

/* ── THE SEAWORTHY HULL's intra-grid overlap fix (2026-08-29 hull pass) ─────────
 * `min-height` above was a FLOOR, never a ceiling — the portrait <img> carries no width/
 * height attribute, so with every ancestor's height auto (grid row auto -> cell auto ->
 * face flex-item auto) `.ms-face img`'s `max-height:100%` resolves against an indeterminate
 * parent height and is a no-op (the CSS %-height-of-auto rule), so the image rendered at its
 * full aspect-correct height (~110px) regardless of how little room the grid actually had.
 * Two cells in the same row inflating by different amounts is what let a shorter neighbour's
 * row start above a taller row's real bottom — the front-row MOTE cards painting over
 * SEXTANT/THEODOLITE at a real 402x660 iPhone viewport (`measure-rig.mjs`).
 *
 * The fix is a real max-height, but it may NEVER be a plain width media query: `.ms-face` is
 * also the class the DESKTOP world-figure card reuses (`.ms-world-figure .ms-face`, capped to
 * `min-height:clamp(50px,7vw,110px)` there, not this rule), and a Steam-Deck-class short
 * LANDSCAPE window (main.js's own `eff-h-le-860` case, 1280x800) is wide enough to stay in
 * THE MEASURED STAGE — capping every `.ms-face` there by height alone would clip a boss's
 * portrait for no reason. Gating on BOTH `eff-w-le-720` (phone-narrow) AND `eff-h-le-860`
 * (short) together, with no bare `@media` counterpart, is what keeps this rule off a wide-but-
 * short desktop window while still catching a narrow-but-short phone one — and needs no THE
 * TWINS pairing of its own (below) since it carries no raw form to drift out of sync with
 * (`main.js` stamps both classes off real `innerWidth`/`innerHeight` regardless of zoom, so
 * there is no separate scale-1.0 raw path to keep synced against). Verified byte-identical to
 * the pre-pass render at 430x932 (`eff-h-le-860` never engages there) and overlap-free at a
 * real 402x660 (`measure-rig.mjs shots --vp=402x660`); the automated guard below stamps both
 * classes directly since a single fixed-viewport headless run cannot itself shrink the real
 * window to prove the gate. */
:root.eff-w-le-720.eff-h-le-860 .ms-face { max-height: 26px; }
/* THE SPECK (2026-10-04, found reproducing Jeff's phone frame at 430×741): the 26px cap above was
   written for the old TRAY grid's faces, but it matched THE WORLD's standing figures too — and
   `eff-h-le-860` is EVERY real phone in a browser with its bars up (an iPhone's visible page is
   ~660–740 px), while the 430×932 emulation every pass was measured at never stamps it. So on a
   device every foe body was capped at 26 px tall whatever FIGURE_H_FRAC said — r13 §3/§11's three
   "bigger figures" steps never reached the phone. A world figure is sized by THE INK BOX, never by
   this cap. */
:root.eff-w-le-720.eff-h-le-860 .ms-world-figure .ms-face { max-height: none; }

/* ══════════════════════════════════════════════════════════════════════════
 * THE MEASURED STAGE — desktop presentation (docs/measure-stage-spec.md).
 * Engages above effective width 900 via a JS-toggled `.ms-desktop` class on
 * `.ms-stage` (board.js `_isDesktop()`), not a raw `@media` query — the class
 * itself already resolves through the SAME `eff-w-le-900`/raw-viewport gate
 * the ui-scale contract defines, so there is no separate raw/eff pair to keep
 * in lockstep for THE GATE itself (unlike the phone tier above, which has no
 * such JS-side mirror and needs the dual write). Everything below fires
 * purely off the class; the phone rules above are completely untouched.
 * ══════════════════════════════════════════════════════════════════════════ */

/* THE FRAME — three grid rows: slug / world / band (§1). The band row's own
   minmax guarantees the party window a full height no matter how tall the
   action window's command list gets — "nothing may ever paint over the world
   or clip" is a fact about this one rule. */
.ms-stage.ms-desktop {
  display: grid;
  /* 30% (was 36%) — the band lost height to THE WORLD in the 2026-08-28 grounding pass;
     the compact party vitals column is what made the smaller budget fit. */
  grid-template-rows: auto minmax(0, 1fr) minmax(250px, 30%);
  grid-template-columns: 1fr;
  gap: 10px;
}
.ms-stage.ms-desktop > .ms-slug  { grid-row: 1; min-height: 0; }
.ms-stage.ms-desktop > .ms-world { grid-row: 2; min-height: 0; }
.ms-stage.ms-desktop > .ms-band  { grid-row: 3; min-height: 0; }
/* The phone structure renders nothing useful here — hide it outright rather
   than leaving dead DOM in the layout. THE COCKPIT FRAME (Pass 1) deleted the
   old `.ms-head`/`.ms-grid` phone tray outright (no DOM to hide any more —
   `.ms-slug`/`.ms-world` are now SHARED furniture, see below); `.ms-party` /
   `.ms-rail` / `.ms-footer` are the phone's STRIP + DECK and stay hidden here. */
.ms-stage.ms-desktop > .ms-party,
.ms-stage.ms-desktop > .ms-rail,
.ms-stage.ms-desktop > .ms-footer { display: none; }
/* THE TICKER now lives INSIDE `.ms-world` (an overlay, not a flow row — see THE COCKPIT
   FRAME below) and stays empty on desktop (`_renderTicker` no-ops there) via `:empty`, but
   this stays as an explicit belt-and-braces hide. */
.ms-stage.ms-desktop .ms-ticker { display: none; }
/* THE LOG LEAVES THE WORLD (THE SHOULDER A4, 2026-10-04): the corner log is desktop's; on the phone
   `_renderWorldLog` leaves it EMPTY, but its gradient panel still painted a dark 54%-wide box in
   the world's bottom-right corner (the screenshot's empty rectangle). THE TICKER is the phone's log. */
.ms-stage:not(.ms-desktop) .ms-world-log { display: none; }
/* …and the reverse: THE MEASURED STAGE's own band (action window + party window) is
   desktop-only — `.ms-slug`/`.ms-world` are shared and get their OWN phone-shaped sizing
   under THE COCKPIT FRAME below rather than being hidden outright. */
.ms-stage:not(.ms-desktop) > .ms-band { display: none; }

/* ── THE SLUG (§1) — identity + clock only, no meters. ─────────────────────── */
.ms-slug { display: flex; align-items: center; justify-content: space-between; gap: 14px;
           padding-bottom: 8px; border-bottom: 1px solid rgba(255,255,255,0.16); flex: 0 0 auto; }
.ms-slug-left { display: flex; align-items: center; gap: 10px; font-size: 0.76rem;
                letter-spacing: 0.08em; color: var(--ms-ink); }
.ms-slug-sep { opacity: 0.4; }
.ms-slug-round { color: var(--ms-gold); }
.ms-slug-pause { min-height: 34px; padding: 6px 16px; font: inherit; font-size: 0.68rem;
                 letter-spacing: 0.12em; color: var(--ms-ink); background: rgba(8,10,14,0.6);
                 border: 1px solid rgba(255,255,255,0.28); cursor: pointer; }
.ms-slug-pause:hover, .ms-slug-pause.ob-focused { border-color: var(--ms-gold); outline: none; }

/* ── THE WORLD (§2) — the diorama. ──────────────────────────────────────────── */
.ms-world { position: relative; overflow: hidden; border: 1px solid rgba(255,255,255,0.14);
            background: #0a0c12;
            /* THE INK BOX (2026-09-26): a size container, so a figure's height is a fraction of
               THE WORLD's own height (`cqh` in `.ms-figure-body` below) on every mount — the
               fraction the deployment solver reasons in, instead of an absolute px floor the
               solver had to be told about per platform. `.ms-world` never sizes from its
               content (a flex/grid child with min-height:0 on both platforms), so containment
               costs it nothing. */
            container-type: size; }
/* `z-index:1` here is deliberate, not decorative: it gives this layer its OWN stacking
   context so each figure's per-body z-index (scene.js `placeFigure`'s near/far ordering,
   ~100-150) only ever competes against its SIBLING figures — without it those inline
   values would out-rank THE LOG (z-index 2) and THE CUT-IN (z-index 3) below, since a
   `position:absolute` box with `z-index:auto` does not itself form a stacking context. */
.ms-world-figures { position: absolute; inset: 0; z-index: 1; }

/* THE BACKDROP (scene.js `buildBackdrop`) — cheap layered DOM, built once per battle.
   The scene is split at a HORIZON (`--ms-horizon`, set by scene.js from
   config.measure.stage.HORIZON_FRAC): sky + skyline above, a perspective graticule
   floor below — the floor is what makes the figures STAND somewhere instead of
   hanging in fog (the 2026-08-28 "enemies are floating" report). */
.ms-backdrop { position: absolute; inset: 0; overflow: hidden; z-index: 0; }
/* THE TINT (THE FOUR SKIES, 2026-10-03): `--ms-bd-horizon` / `--ms-bd-mid` are the map's OWN
   sky-row colours (config.skybox.byMap[id].sky.horizon/mid), set on the wrap by scene.js
   `buildBackdrop({ tint })`. Every theme mixes them into its sky FOOT and its floor's near
   edge, so two rungs on one theme still read as two places (Phobos is near black, a
   Saturnian moon is rust, Enceladus is sea-blue). Unset, each theme keeps its own paint —
   the fallback in each `var()` is the colour the rule had before the tint existed. */
.ms-backdrop-sky { position: absolute; left: 0; right: 0; top: 0; height: var(--ms-horizon, 34%);
  background: linear-gradient(180deg, #090c16 0%,
    color-mix(in srgb, #0e1424 60%, var(--ms-bd-mid, #0e1424) 40%) 62%,
    color-mix(in srgb, #16192a 45%, var(--ms-bd-horizon, #16192a) 55%) 100%); }
/* A warm haze band hugging the horizon from above — atmosphere, behind the skyline. */
.ms-backdrop-haze { position: absolute; left: 0; right: 0;
  top: calc(var(--ms-horizon, 34%) - 10%); height: 10%;
  background: linear-gradient(180deg, transparent,
    color-mix(in srgb, var(--ms-bd-horizon, var(--ms-world-accent, #e89020)) 22%, transparent)); }
/* The skyline stands ON the horizon — silhouettes against the sky, never over the floor. */
.ms-backdrop-skyline { position: absolute; left: 0; right: 0;
  bottom: calc(100% - var(--ms-horizon, 34%)); height: 30%; }
.ms-backdrop-slab { position: absolute; bottom: 0; background: #0c1018;
                    border-top: 1px solid rgba(255,255,255,0.06); }
/* Lit slabs carry a dot-grid of WINDOWS (the old horizontal stripes read as venetian
   blinds floating in the dark, not architecture). */
.ms-backdrop-slab-lit { background-color: #0e1220;
  background-image: radial-gradient(rgba(232,144,32,0.28) 1px, transparent 1.6px);
  background-size: 7px 11px; background-position: 2px 3px; }
/* THE FLOOR — a graticule receding to the horizon (THE MEASURE's own vocabulary: the
   wave stands on a surveyed field). The grid is a flat repeating gradient tipped back
   with a perspective transform; masked so it fades out at the horizon seam. */
.ms-backdrop-floor { position: absolute; left: 0; right: 0;
  top: var(--ms-horizon, 34%); bottom: 0; overflow: hidden;
  border-top: 1px solid color-mix(in srgb, var(--ms-world-accent, #e89020) 26%, transparent);
  background: linear-gradient(180deg,
    color-mix(in srgb, #1a1e2c 70%, var(--ms-bd-horizon, #1a1e2c) 30%) 0%, #141826 30%, #0c0f18 100%); }
.ms-backdrop-floor::before { content: ''; position: absolute; left: -60%; right: -60%;
  top: 0; height: 340%; transform-origin: 50% 0;
  transform: perspective(320px) rotateX(64deg);
  background-image:
    repeating-linear-gradient(90deg, rgba(232,144,32,0.10) 0 1px, transparent 1px 64px),
    repeating-linear-gradient(0deg,  rgba(232,144,32,0.08) 0 1px, transparent 1px 64px);
  -webkit-mask-image: linear-gradient(180deg, transparent 0, black 12%);
          mask-image: linear-gradient(180deg, transparent 0, black 12%); }
/* WORK LIGHTS — a mast + lamp head standing on the floor, throwing a flattened pool at
   its base (scene.js sets left/top/height inline; the pool hangs off the box's bottom). */
.ms-backdrop-lamp { position: absolute; width: 0; }
.ms-lamp-mast { position: absolute; left: -1px; top: 0; bottom: 0; width: 2px;
  background: linear-gradient(180deg, rgba(216,226,240,0.5), rgba(216,226,240,0.12)); }
.ms-lamp-head { position: absolute; left: 50%; top: 0; width: 10px; height: 10px;
  transform: translate(-50%,-50%); border-radius: 50%;
  background: color-mix(in srgb, var(--ms-world-accent, #e89020) 85%, var(--ms-white) 15%);
  box-shadow: 0 0 14px 4px color-mix(in srgb, var(--ms-world-accent, #e89020) 55%, transparent); }
.ms-lamp-pool { position: absolute; left: 50%; bottom: 0; width: 30vw; height: 9vw;
  max-width: 380px; max-height: 110px; transform: translate(-50%, 50%); border-radius: 50%;
  background: radial-gradient(ellipse,
  color-mix(in srgb, var(--ms-world-accent, #e89020) 30%, transparent) 0%, transparent 68%); }
.ms-backdrop-ground { position: absolute; left: 0; right: 0; bottom: 0; height: 14%;
  background: linear-gradient(180deg, transparent, rgba(0,0,0,0.55)); }

/* ── THE BASE (scene.js `backdropThemeFor`, 2026-09-26) — an airless body's yard. ────────────
   Same layer order as the skyline theme (sky · skyline · floor · lamps · ground), the
   pieces swapped: a hard black starfield + the map's hero body (`--ms-bd-hero`, the
   skybox row's own planet colour) over regolith hills; domes / habs / a mast standing on
   them; a pale regolith floor with craters and a cooler, fainter graticule. Everything is
   gradients and borders — no image, no canvas, zero per-frame work. */
.ms-bd-base .ms-backdrop-sky { background: linear-gradient(180deg, #000 0%,
    color-mix(in srgb, #04050a 70%, var(--ms-bd-mid, #04050a) 30%) 70%,
    color-mix(in srgb, #0a0b12 50%, var(--ms-bd-horizon, #0a0b12) 50%) 100%); }
.ms-bd-base .ms-backdrop-stars { position: absolute; left: 0; right: 0; top: 0;
  height: var(--ms-horizon, 34%); background-repeat: no-repeat; opacity: 0.9; }
.ms-bd-base .ms-backdrop-body { position: absolute; width: var(--ms-bd-body-w, 26%);
  aspect-ratio: 1; transform: translate(-50%, -50%); border-radius: 50%;
  background:
    radial-gradient(circle at 36% 34%, rgba(255,255,255,0.28) 0%, transparent 38%),
    radial-gradient(circle at 60% 60%, color-mix(in srgb, var(--ms-bd-hero, #b5522a) 88%, #fff 12%) 0%,
      var(--ms-bd-hero, #b5522a) 45%, color-mix(in srgb, var(--ms-bd-hero, #b5522a) 45%, #000 55%) 100%);
  /* the terminator: the dark limb on the side away from the sun */
  box-shadow: inset -0.9em -0.5em 1.4em 0.2em rgba(0,0,0,0.78),
              0 0 22px 2px color-mix(in srgb, var(--ms-bd-hero, #b5522a) 35%, transparent);
  font-size: clamp(6px, 2.2cqw, 14px); }
.ms-bd-base .ms-backdrop-hill { position: absolute; bottom: 0; background: #12141b;
  border-radius: 50% 50% 0 0 / 100% 100% 0 0; }
.ms-bd-base .ms-backdrop-dome { position: absolute; bottom: 0; background-color: #171b26;
  border-radius: 50% 50% 0 0 / 100% 100% 0 0; border-top: 1px solid rgba(200,214,240,0.22);
  background-image: radial-gradient(rgba(255,214,120,0.55) 1px, transparent 1.7px);
  background-size: 8px 9px; background-position: 3px 5px; }
.ms-bd-base .ms-backdrop-hab { position: absolute; bottom: 0; background: #14171f;
  border-top: 1px solid rgba(200,214,240,0.18); border-left: 1px solid rgba(0,0,0,0.5); }
.ms-bd-base .ms-backdrop-hab::after { content: ''; position: absolute; left: 8%; right: 8%;
  top: 34%; height: 3px; background: repeating-linear-gradient(90deg, rgba(255,214,120,0.7) 0 4px, transparent 4px 7px); }
.ms-bd-base .ms-backdrop-hab-tall { background: #11141c; }
.ms-bd-base .ms-backdrop-mast { position: absolute; bottom: 0; width: 2px;
  background: linear-gradient(180deg, rgba(200,214,240,0.55), rgba(200,214,240,0.12)); }
.ms-bd-base .ms-backdrop-mast::after { content: ''; position: absolute; left: 50%; top: 0;
  width: 5px; height: 5px; transform: translate(-50%, -50%); border-radius: 50%;
  background: #ff5a3c; box-shadow: 0 0 8px 2px rgba(255,90,60,0.6); }
/* The yard: pale regolith lit from the hero body's side, a cool faint survey grid. */
.ms-bd-base .ms-backdrop-floor { border-top-color: rgba(200,214,240,0.22);
  background: linear-gradient(100deg,
    color-mix(in srgb, #3a3e4c 72%, var(--ms-bd-horizon, #3a3e4c) 28%) 0%,
    color-mix(in srgb, #2c303c 78%, var(--ms-bd-horizon, #2c303c) 22%) 35%, #1d2029 70%, #14161e 100%); }
.ms-bd-base .ms-backdrop-floor::before {
  background-image:
    repeating-linear-gradient(90deg, rgba(200,214,240,0.07) 0 1px, transparent 1px 64px),
    repeating-linear-gradient(0deg,  rgba(200,214,240,0.06) 0 1px, transparent 1px 64px); }
.ms-bd-base .ms-backdrop-crater { position: absolute; transform: translate(-50%, -50%);
  border-radius: 50%;
  background: radial-gradient(ellipse at 50% 60%, rgba(0,0,0,0.42) 0%, rgba(0,0,0,0.22) 55%, transparent 72%);
  box-shadow: inset 2px 1px 0 0 rgba(220,228,245,0.22), inset -2px -1px 0 0 rgba(0,0,0,0.45); }
.ms-bd-base .ms-lamp-mast { background: linear-gradient(180deg, rgba(230,236,250,0.6), rgba(230,236,250,0.15)); }

/* ── THE QUARRY (scene.js `backdropThemeFor` → 'quarry', THE FOUR SKIES 2026-10-03) — a belt
   rock's cut face. Rung 7 is Vesta and THE RIFT is its span: the same layer order (sky ·
   skyline · floor · lamps · ground), the pieces swapped — the base's starfield with a few
   tumbling belt CHIPS instead of one hero body; a rock wall in STRATA (horizontal bands, a
   shade apart, a hair off level) behind two PIER stubs reaching over a gap, the gap lit from
   BELOW; a dark ledge with FISSURES running toward the lens. Gradients and borders only. */
.ms-bd-quarry .ms-backdrop-sky { background: linear-gradient(180deg, #000 0%,
    color-mix(in srgb, #06040a 70%, var(--ms-bd-mid, #06040a) 30%) 70%,
    color-mix(in srgb, #0d0a16 50%, var(--ms-bd-horizon, #0d0a16) 50%) 100%); }
.ms-bd-quarry .ms-backdrop-stars { position: absolute; left: 0; right: 0; top: 0;
  height: var(--ms-horizon, 34%); background-repeat: no-repeat; opacity: 0.8; }
.ms-bd-quarry .ms-backdrop-chip { position: absolute; border-radius: 46% 54% 52% 48% / 58% 44% 56% 42%;
  background: linear-gradient(115deg, #6a6258 0%, #3d3831 45%, #15130f 100%);
  box-shadow: inset -3px -2px 6px rgba(0,0,0,0.7), 0 0 10px 1px rgba(0,0,0,0.5); }
.ms-bd-quarry .ms-backdrop-skyline { height: 38%; }
.ms-bd-quarry .ms-backdrop-stratum { position: absolute; left: -2%; right: -2%;
  background: linear-gradient(180deg, #2a2620 0%, #1c1915 55%, #131110 100%);
  border-top: 1px solid rgba(214,200,176,0.16); }
.ms-bd-quarry .ms-backdrop-stratum:nth-child(even) { background: linear-gradient(180deg, #221f1a 0%, #171412 60%, #0f0d0c 100%); }
.ms-bd-quarry .ms-backdrop-pier { position: absolute; bottom: 0; background: #0b0a0c;
  border-top: 2px solid rgba(232,144,32,0.35);
  background-image: repeating-linear-gradient(90deg, rgba(255,255,255,0.05) 0 1px, transparent 1px 18px); }
.ms-bd-quarry .ms-backdrop-pier::after { content: ''; position: absolute; right: -6px; top: 0; width: 6px; bottom: 30%;
  background: linear-gradient(180deg, #0b0a0c, transparent); }
.ms-bd-quarry .ms-backdrop-pier-east::after { right: auto; left: -6px; }
.ms-bd-quarry .ms-backdrop-underlight { position: absolute; bottom: -2%; height: 46%;
  transform: translateX(-50%); border-radius: 50% 50% 0 0 / 100% 100% 0 0;
  background: radial-gradient(ellipse at 50% 100%,
    color-mix(in srgb, var(--ms-world-accent, #e89020) 48%, transparent) 0%,
    color-mix(in srgb, var(--ms-world-accent, #e89020) 16%, transparent) 45%, transparent 75%); }
.ms-bd-quarry .ms-backdrop-floor { border-top-color: rgba(232,144,32,0.30);
  background: linear-gradient(180deg,
    color-mix(in srgb, #2b2822 70%, var(--ms-bd-horizon, #2b2822) 30%) 0%, #1c1a17 32%, #0e0d0b 100%); }
.ms-bd-quarry .ms-backdrop-floor::before {
  background-image:
    repeating-linear-gradient(90deg, rgba(214,200,176,0.06) 0 1px, transparent 1px 64px),
    repeating-linear-gradient(0deg,  rgba(214,200,176,0.05) 0 1px, transparent 1px 64px); }
.ms-bd-quarry .ms-backdrop-fissure { position: absolute; height: 2px; transform-origin: 0 50%;
  background: linear-gradient(90deg, transparent, rgba(0,0,0,0.85) 20%, rgba(0,0,0,0.85) 80%, transparent);
  box-shadow: 0 1px 0 rgba(214,200,176,0.10); }

/* ── THE DECK ('deck') — a hull interior. The skyline box grows to the whole sky so the RIBS
   (the ship's frames) stand floor-to-ceiling across a LONG WINDOW on the starfield, divided by
   MULLIONS and closed by a SILL; the floor is deck plating with a lit SPINE and HATCHES set in. */
.ms-bd-deck .ms-backdrop-sky { background: linear-gradient(180deg, #02030a 0%,
    color-mix(in srgb, #070a1a 60%, var(--ms-bd-mid, #070a1a) 40%) 60%,
    color-mix(in srgb, #0a0e2a 50%, var(--ms-bd-horizon, #0a0e2a) 50%) 100%); }
.ms-bd-deck .ms-backdrop-stars { position: absolute; left: 0; right: 0; top: 0;
  height: var(--ms-horizon, 34%); background-repeat: no-repeat; opacity: 0.95; }
.ms-bd-deck .ms-backdrop-mullion { position: absolute; top: 0; height: var(--ms-horizon, 34%); width: 3px;
  transform: translateX(-50%); background: linear-gradient(180deg, #1a1e2c, #0c0f18);
  box-shadow: 1px 0 0 rgba(255,255,255,0.05); }
.ms-bd-deck .ms-backdrop-sill { position: absolute; left: 0; right: 0;
  top: calc(var(--ms-horizon, 34%) - 3%); height: 3%;
  background: linear-gradient(180deg, #232838, #12151f); border-top: 1px solid rgba(200,214,240,0.25); }
.ms-bd-deck .ms-backdrop-skyline { height: var(--ms-horizon, 34%); }
.ms-bd-deck .ms-backdrop-rib { position: absolute; top: 0; bottom: 0; transform: translateX(-50%);
  background: linear-gradient(90deg, #2a2f40 0%, #171b28 55%, #0b0d14 100%);
  border-left: 1px solid rgba(200,214,240,0.22); border-radius: 40% 40% 0 0 / 6% 6% 0 0; }
.ms-bd-deck .ms-backdrop-rib::after { content: ''; position: absolute; left: 50%; top: 18%; width: 3px; height: 3px;
  transform: translateX(-50%); border-radius: 50%;
  background: color-mix(in srgb, var(--ms-world-accent, #e89020) 85%, #fff 15%);
  box-shadow: 0 0 6px 2px color-mix(in srgb, var(--ms-world-accent, #e89020) 50%, transparent); }
.ms-bd-deck .ms-backdrop-floor { border-top-color: rgba(200,214,240,0.26);
  background: linear-gradient(180deg,
    color-mix(in srgb, #262b3a 70%, var(--ms-bd-horizon, #262b3a) 30%) 0%, #181c28 30%, #0c0f18 100%); }
.ms-bd-deck .ms-backdrop-floor::before {
  background-image:
    repeating-linear-gradient(90deg, rgba(200,214,240,0.09) 0 1px, transparent 1px 40px),
    repeating-linear-gradient(0deg,  rgba(200,214,240,0.07) 0 1px, transparent 1px 40px); }
.ms-bd-deck .ms-backdrop-spine { position: absolute; left: 50%; top: var(--ms-horizon, 34%); bottom: 0; width: 3px;
  transform: translateX(-50%);
  background: linear-gradient(180deg, transparent 0%,
    color-mix(in srgb, var(--ms-world-accent, #e89020) 55%, transparent) 20%,
    color-mix(in srgb, var(--ms-world-accent, #e89020) 35%, transparent) 100%);
  box-shadow: 0 0 10px 2px color-mix(in srgb, var(--ms-world-accent, #e89020) 25%, transparent); }
.ms-bd-deck .ms-backdrop-hatch { position: absolute; transform: translate(-50%, -50%); border-radius: 3px;
  background: linear-gradient(180deg, #1f2434, #0f1220); border: 1px solid rgba(200,214,240,0.18);
  box-shadow: inset 0 2px 0 rgba(255,255,255,0.05), 0 2px 6px rgba(0,0,0,0.5); }
.ms-bd-deck .ms-lamp-mast { background: linear-gradient(180deg, rgba(200,214,240,0.6), rgba(200,214,240,0.15)); }

/* THE FIGURES — tier-scaled, positioned by scene.js `placeFigure`. Reuses `.ms-foe`'s base
   rules (cursor ring, hit/crit/dying FX) wholesale; only layout is new here. The base
   `.ms-foe` rules set `transform` on hover/focus/cursor for the phone card's small lift —
   those all have to be RE-asserted here or a world figure's own position+scale transform
   gets clobbered the moment it becomes the cursor (which is most of the time). This
   element's OWN `scale(var(--ms-fig-scale))` is UNCHANGED from before THE COCKPIT PASS —
   see THE PLATE FLOOR below for why (the hull's figure-overlap tuning is built on THIS
   element's rendered box scaling with tier, and only the box the CARD itself generates —
   never a descendant's own separate transform — feeds that geometry). */
/* `--ms-fig-nudge-x` is board.js's `_clampFiguresToWorld()` — the figure twin of THE PLATE
   FLOOR's own `--ms-plate-nudge-x` below. Folded into the SAME translate() as the anchor's
   own `-50%` rather than a second translate() in the list: both are listed before `scale()`,
   so both are applied to the ALREADY-scaled result (CSS composes right-to-left) — the nudge
   lands as a flat, already-on-screen px shift regardless of tier, exactly like the anchor
   percentage itself already is. Defaults to `0px` (no-op) on every figure that never needed
   a nudge this render. */
/* THE FX CHANNEL (found investigating Task 3, 2026-08-29 — the enemy-phase attack tell at
   the new, bigger figure size). `.ms-foe-hit`/`.ms-foe-crit`/`.ms-foe-acting`/`.ms-foe-dying`
   below each set the CARD's `transform` directly inside their own keyframes — and a CSS
   `animation` overrides the WHOLE property for its duration, not just the offset the
   keyframe author had in mind, so each of those beats was silently REPLACING (not adding
   to) the anchor translate + tier scale below with whatever the keyframe alone specified —
   the figure visibly "jumped" to its keyframe's own tiny local offset for that beat, then
   snapped back. Registered as real animatable types (`@property`, not a bare custom
   property) so the browser actually TWEENS between keyframe stops instead of hard-cutting
   between them — a shake needs the smooth interpolation as much as the base transform does.
   `--ms-fx-x`/`--ms-fx-y` are folded into the SAME translate() as the anchor `-50%`/nudge —
   the same reasoning as `--ms-fig-nudge-x` right above: a shake/lunge should read as the
   SAME few screen px on a chaff figure and a boss, not scaled up with tier, so it has to sit
   OUTSIDE `scale(var(--ms-fig-scale))` in the list, never nested inside it. `--ms-fx-rot`/
   `--ms-fx-scale` stay AFTER the tier scale on purpose — a wobble/grow pulse IS relative to
   whatever size the figure already is. */
@property --ms-fx-x { syntax: '<length>'; inherits: false; initial-value: 0px; }
@property --ms-fx-y { syntax: '<length>'; inherits: false; initial-value: 0px; }
@property --ms-fx-rot { syntax: '<angle>'; inherits: false; initial-value: 0deg; }
@property --ms-fx-scale { syntax: '<number>'; inherits: false; initial-value: 1; }
.ms-world-figure {
  position: absolute; transform-origin: bottom center;
  /* The anchor translate + tier scale (unchanged) PLUS the FX channel above, always present
     in the list at its no-op defaults — every hit/attack/death beat below writes ONLY
     `--ms-fx-*`, never `transform` itself, so this one declaration is the single source of
     the card's on-screen transform at every instant, composed instead of replaced. */
  transform: translate(calc(-50% + var(--ms-fig-nudge-x, 0px) + var(--ms-fx-x, 0px)),
                        calc(-100% + var(--ms-fig-nudge-y, 0px) + var(--ms-fx-y, 0px)))
             scale(var(--ms-fig-scale, 1))
             rotate(var(--ms-fx-rot, 0deg)) scale(var(--ms-fx-scale, 1));
  display: flex; flex-direction: column; align-items: center; gap: 4px;
  background: none; border: none; padding: 0; cursor: pointer;
  /* Found alongside the specificity clobber above: `.ms-foe`'s own `transition: transform
     120ms ease, …` (dead-grid-era, meant for the old tray's hover lift) still applies here
     too, and `_clampFiguresToWorld()` mutates `--ms-fig-nudge-x` on an ALREADY-rendered card
     (after the wave's first paint) — exactly the case a CSS transition DOES pick up. Every
     `getBoundingClientRect()`/`getComputedStyle()` read taken in the same synchronous task as
     that mutation (board.js's own clamp math, and every test that measures right after a
     render()) then read the ANIMATING mid-transition value, not the target — which is to say
     the clamp silently failed to ever take visible effect (`transform` here is 0/none-effective
     for a whole 120ms window every render). Positioning must be exact and synchronous, same as
     THE PLATE FLOOR's own nudge (no transition on `.ms-plate` at all) — so it never animates. */
  transition: none;
}
.ms-world-figure:hover,
.ms-world-figure.ob-focused,
.ms-world-figure.ms-cursor {
  transform: translate(calc(-50% + var(--ms-fig-nudge-x, 0px) + var(--ms-fx-x, 0px)),
                        calc(-100% + var(--ms-fig-nudge-y, 0px) + var(--ms-fx-y, 0px)))
             scale(var(--ms-fig-scale, 1))
             rotate(var(--ms-fx-rot, 0deg)) scale(var(--ms-fx-scale, 1));
}
.ms-world-figure:hover, .ms-world-figure.ob-focused { filter: brightness(1.12); }
.ms-world-figure.ms-cursor::after { top: -6px; }   /* clear the plate's own top edge */

/* THE PLATE FLOOR (THE COCKPIT PASS, defect 1, 2026-08-29) — `.ms-plate` COUNTER-scales
   against the ambient `--ms-fig-scale` its own `.ms-world-figure` ancestor still carries
   (above): `scale(calc(1 / var(--ms-fig-scale)))` exactly cancels the parent's own factor
   for RENDERED SIZE (ambient S times the plate's own 1/S nets to 1), so the plate renders
   at ONE constant CSS size for every tier — the old bug was a cursor'd boss (TIER_SCALE up
   to ~1.7 on phone) rendering its plate enormous, clipped off the scene's edge and burying
   its own stagger bar. `transform-origin: bottom center` anchors the counter-scale at the
   plate's OWN bottom edge — the edge nearest the figure it labels — so it grows/shrinks in
   place there rather than around its own middle. Deliberately does NOT touch the CARD's own
   box (a child's transform never inflates or shrinks its parent's generated box/bounding
   rect), so the hull's figure-overlap spacing math — tuned against the CARD's rendered
   size — is untouched; only the plate's own paint changes.
   *** CORRECTION 2026-08-31 — the sentence above was HALF WRONG, and it hid a real defect. ***
   A child's TRANSFORM indeed never inflates its parent's box. But this plate also carried
   `min-width: 118px` (148px for a boss) while sitting IN `.ms-world-figure`'s flex COLUMN, so
   its PRE-TRANSFORM LAYOUT width sized the card — and the card's own scale(tierScale) then
   multiplied that. The plate painted at a constant size and MEASURED at 118px x tierScale, so
   every figure's bounding box was far wider than anything drawn, and the hull's spacing math
   (tuned against "the CARD's rendered size") was in fact tuned against a phantom. Found while
   building the desktop overlap guard (docs/loaded-body-spec.md §5): reverting the scale table
   to pre-pass values reproduced the same overlaps, proving it pre-existing rather than caused
   by the +15%. FIXED by taking the plate OUT OF FLOW (`position:absolute`, anchored at
   `bottom:100%` of the body it labels, centred by a -50% translate composed into the existing
   transform so the counter-scale and both nudges still apply unchanged). Measured effect on
   the real-wave overlap count: 8 pairs -> 4. `--ms-plate-nudge-x` is
   board.js's `_clampPlatesToWorld()` horizontal edge clamp (defect 1's third part) — listed
   OUTSIDE the scale so it moves the plate by a flat, already-on-screen px amount regardless
   of tier, never itself scaled. A boss earns a MODEST plate bump through its own explicit
   `.ms-foe-boss .ms-plate` rule below (bigger NATURAL size, so it survives the same
   counter-scale) — never through figure scale. `--ms-plate-nudge-y` is the same clamp's
   VERTICAL half: a near-rank body's plate can otherwise poke out THE WORLD's own top edge
   at a squeezed short viewport (measured at 320x560) — same flat, unscaled px nudge.
   THE PLATE FOLLOWS THE BODY (THE CLOSER FRAME II, docs/pitches/measure-spectacle-pitch.html
   p.7, 2026-10-03): the counter-scale stays — it is what makes the plate's size a DECLARED
   number instead of the tier's accident — and a second factor, `--ms-plate-scale`, rides in
   the same scale(): board.js sets it per card from the figure's own tier×rank scale clamped to
   [`stage.PLATES.SCALE_MIN`, 1], live-stage.js `_projectPlates` from the projected body height
   (`liveStage.PLATES`). So a far body wears a far plate, and the floor the clamp protects is the
   numeral's 7 px. The plate's own transform is the ONE place a plate scale may go (THE FX
   CHANNEL law stands for `.ms-world-figure` itself). The cursor's and the boss's plates pin it
   back to 1 below — they are the two that keep the NAME (THE NUMERAL IS THE PLATE). */
.ms-world-figure .ms-plate {
  position: absolute; left: 50%; bottom: 100%; margin-bottom: 3px;
  min-width: 118px; max-width: 200px; display: flex; flex-direction: column; gap: 3px;
  background: rgba(6,8,12,0.72);
  border: 1px solid color-mix(in srgb, var(--ms-accent, #e89020) 55%, transparent);
  padding: 5px 7px;
  transform-origin: bottom center;
  transform: translate(calc(-50% + var(--ms-plate-nudge-x, 0px)), var(--ms-plate-nudge-y, 0px))
             scale(calc(var(--ms-plate-scale, 1) / var(--ms-fig-scale, 1)));
}
.ms-world-figure.ms-foe-boss .ms-plate { min-width: 148px; padding: 6px 9px; }
.ms-world-figure.ms-cursor .ms-plate,
.ms-world-figure.ms-foe-boss .ms-plate { --ms-plate-scale: 1; }   /* the plates that keep the name keep their size */
.ms-plate-name-row { display: flex; align-items: center; justify-content: space-between; gap: 6px; }
.ms-world-figure .ms-foe-name { font-size: 0.62rem; white-space: normal; }
.ms-world-figure .ms-telegraph { white-space: nowrap; }

/* FRAMELESS FIGURES (playtest 2026-08-29): in the world the tier-2 card backdrop (halftone
   panel + accent radial) read as a floating tile around every robot — the world's figures
   stand free, so the panel treatment stays on CARDS only. */
.ms-world-figure .ms-face-t2::before { display: none; }
.ms-world-figure .ms-face { background: transparent; border: 0; }
.ms-world-figure .ms-face img { width: 100%; height: auto; max-height: none; }
/* The cursor ring hugs the PLATE, not the whole card — a gold rectangle around a
   free-standing figure read as a box again. The figure itself glows instead. */
.ms-world-figure.ms-cursor { box-shadow: none !important; }
.ms-world-figure.ms-cursor .ms-plate,
.ms-world-figure.ms-cursor > .ms-world-plate {
  box-shadow: 0 0 0 2px var(--ms-gold), 0 0 16px rgba(255,215,106,0.45); }
/* THE RETICLE (item 3, THE COCKPIT PASS · PASS 2) — REPLACES the old soft gold drop-shadow
 * halo on the figure body (a UI-ring language) with corner brackets: the world cursor now
 * reads as a targeting-computer lock, not a UI ring — four corner brackets drawn on the
 * figure BODY via one `::before` (the classic 8-gradient corner trick — two strokes per
 * corner, each a `background-size`-boxed L). `.ms-cursor` stays the STATE class (tests
 * select it; the party strip's own `.ms-member.ms-cursor` list-row treatment above is
 * UNTOUCHED — this rule only fires inside `.ms-world-figure`). Distinct from `.ob-focused`
 * (pad focus): that rule stays a plain brightness bump on the whole figure (below), never
 * this bracket paint, so a co-present cursor+focus still reads as one mark with the pad's
 * own hint layered on top, not two competing rings. Slow pulse (`ms-reticle-pulse`,
 * 1.2-1.6s); static under `prefers-reduced-motion` (bottom of file).
 *
 * `inset:-4px` is deliberately SHY of THE PLATE FLOOR's own edge-clamp margin — the bracket
 * rides `.ms-figure-body`, which (unlike `.ms-plate`, nudged inward every render by
 * `_clampPlatesToWorld`) has no edge-clamp of its own, and `.ms-world` clips overflow. A
 * body standing very close to THE WORLD's own boundary (measured: a boss on a squeezed short
 * viewport) can still lose a sliver of one corner; a named follow-up if that reads badly
 * on-device is extending the plate's own nudge to this pseudo-element too. */
.ms-world-figure.ms-cursor .ms-figure-body::before {
  content: ''; position: absolute; inset: -4px; pointer-events: none; z-index: 3;
  background:
    linear-gradient(to right,  var(--ms-gold) 2px, transparent 2px) 0 0 / 9px 9px no-repeat,
    linear-gradient(to bottom, var(--ms-gold) 2px, transparent 2px) 0 0 / 9px 9px no-repeat,
    linear-gradient(to left,   var(--ms-gold) 2px, transparent 2px) 100% 0 / 9px 9px no-repeat,
    linear-gradient(to bottom, var(--ms-gold) 2px, transparent 2px) 100% 0 / 9px 9px no-repeat,
    linear-gradient(to right,  var(--ms-gold) 2px, transparent 2px) 0 100% / 9px 9px no-repeat,
    linear-gradient(to top,    var(--ms-gold) 2px, transparent 2px) 0 100% / 9px 9px no-repeat,
    linear-gradient(to left,   var(--ms-gold) 2px, transparent 2px) 100% 100% / 9px 9px no-repeat,
    linear-gradient(to top,    var(--ms-gold) 2px, transparent 2px) 100% 100% / 9px 9px no-repeat;
  filter: drop-shadow(0 0 3px rgba(255,215,106,0.7));
  animation: ms-reticle-pulse var(--ms-reticle-pulse-ms, 1400ms) ease-in-out infinite;
}
@keyframes ms-reticle-pulse {
  0%, 100% { opacity: 0.65; transform: scale(1); }
  50%      { opacity: 1;    transform: scale(1.05); }
}
/* `cqw`, not `vw` (found investigating the standing hull/overlap failures, 2026-08-29 —
   see measure-stage-spec.md's build record). `.ms-overlay` (below) is `position:fixed;
   inset:0` in real play, so its own width always equals the true device viewport and `vw`
   was numerically identical to a container-query width in every REAL case — but in this
   suite's headless harness, `mount()`/`mountShort()` fake a 430px-wide box on elements
   NESTED inside a real (wide, desktop test-runner) browser window; `vw` reads that real,
   uncontrolled outer window regardless, so a figure's box rendered ~1.5x its intended
   phone size ONLY in that harness — never on a device — and two ranks that never actually
   touch on a real 430px phone (proven by re-running this exact mount at a real 430x932
   viewport: zero overlap) crowded into each other here. `container-type: inline-size` on
   `.ms-overlay` below makes `cqw` track whatever box ACTUALLY contains the figure — the
   real viewport in play, the fake 430px box in this harness — so both read the true
   number. No visual change on a real device (identical value at every phone/desktop width
   this shipped at). */
.ms-figure-body { position: relative; width: clamp(64px, 9cqw, 150px);
                   display: flex; align-items: flex-end; justify-content: center; }
.ms-figure-body .ms-face { width: 100%; min-height: clamp(50px, 7cqw, 110px); }
/* THE INK BOX (2026-09-26). The rules above sized a figure by WIDTH (a 64px floor that every
   phone hit) and let the height follow the portrait canvas's 4:5 — but the body inside that
   canvas was mostly transparent margin (a rank-0 line body drew 16×37 px of ink in its 65×81
   box, measured), so three passes of "make the figures bigger" scaled the margin. The image
   is trimmed to its ink now (portrait.js), and a world figure is sized by HEIGHT — `--ms-fig-h`
   / `--ms-hero-h` (board.js, off `config.measure.stage.*_H_FRAC`) of THE WORLD's own height,
   times the card's tier scale — with the width following the ink's own aspect. The two
   selectors below out-rank the base rules above (two classes); THE LIVE STAGE's own
   `.ms-overlay.ms-live .ms-figure-body` (three) still wins over these, as it must. */
.ms-world-figure .ms-figure-body {
  width: auto; min-width: 0; height: calc(var(--ms-fig-h, 0.32) * 100cqh);
}
.ms-world-figure.ms-world-hero .ms-figure-body { height: calc(var(--ms-hero-h, var(--ms-fig-h, 0.32)) * 100cqh); }
.ms-world-figure .ms-figure-body .ms-face { width: auto; height: 100%; min-height: 0; flex: 0 0 auto; }
.ms-world-figure .ms-figure-body img { display: block; width: auto; height: 100%; max-width: none; max-height: none; }
.ms-world-figure .ms-hero-badge { width: auto; height: 100%; aspect-ratio: 3 / 4; }
/* The contact shadow is what welds a figure to the floor — wide, dark at the core, and
   slightly BELOW the feet so hover-faction bodies still read as standing over a spot. */
.ms-figure-shadow { position: absolute; bottom: -7px; left: 50%; width: 112%; height: 16px;
  transform: translateX(-50%);
  background: radial-gradient(ellipse, rgba(0,0,0,0.7) 0%, rgba(0,0,0,0.35) 40%, transparent 68%); }

/* HP WITH NUMERALS — a numeral overlay atop the same `.ms-hp`/`.ms-hp-fill` bar. */
.ms-hp-numeral { position: relative; height: 8px; }
.ms-hp-nums { position: absolute; inset: 0; display: flex; align-items: center;
  justify-content: center; font-size: 0.5rem; color: #fff; font-variant-numeric: tabular-nums;
  /* a halo, not a drop: cream on the party's bright green fill was near-invisible (measured on
     the desktop party window's "267/267"); a dark halo all round keeps it on red and green alike */
  text-shadow: 0 0 2px rgba(0,0,0,0.95), 0 0 3px rgba(0,0,0,0.9), 0 1px 2px rgba(0,0,0,0.95);
  pointer-events: none; }

/* THE BOSS REELS (2026-09-26, docs/playtest-r13.md §7 — "a new look for the boss being
   staggered"). The BREAK is a knockout beat on the BODY, not a bar turning orange: the blow
   lifts it, it comes down hard and buckles at the feet, over-corrects, and only then finds
   its footing — one swing on THE FX CHANNEL, `BREAK_REEL_MS` long (config.measure.ui).
   `.ms-foe.ms-foe-break` (two classes) OUTRANKS the same reveal's own `.ms-foe-hit`/
   `.ms-foe-crit` on the card, so the detonation that broke the bar does not also fight this
   for the one `animation` slot — the reel is the hit, at boss weight. The standing
   `ms-fig-broken` reel (figures.css) takes over at the next render. */
@keyframes ms-foe-break {
  0%   { --ms-fx-x: 0px;  --ms-fx-y: 0px;   --ms-fx-rot: 0deg;  --ms-fx-scale: 1; }
  14%  { --ms-fx-x: 6px;  --ms-fx-y: -12px; --ms-fx-rot: 7deg;  --ms-fx-scale: 1.08; }
  38%  { --ms-fx-x: -9px; --ms-fx-y: 7px;   --ms-fx-rot: -9deg; --ms-fx-scale: 0.88; }
  60%  { --ms-fx-x: 5px;  --ms-fx-y: 2px;   --ms-fx-rot: 4deg;  --ms-fx-scale: 1.03; }
  80%  { --ms-fx-x: -2px; --ms-fx-y: 3px;   --ms-fx-rot: -2deg; --ms-fx-scale: 0.98; }
  100% { --ms-fx-x: 0px;  --ms-fx-y: 0px;   --ms-fx-rot: 0deg;  --ms-fx-scale: 1; }
}
.ms-foe.ms-foe-break { animation: ms-foe-break var(--ms-break-reel-ms, 720ms) cubic-bezier(.3,.7,.3,1); }

/* THE BROKEN BOSS — the card-level half (the body's reel lives in figures.css as the
   `ms-fig-broken` state). THE STUN HALO: three sparks orbiting the reeling head on a
   squashed circle — the comic's own stagger mark — its OWN element inside `.ms-figure-body`,
   so its spin never contends for the body's one `animation` slot. The plate's BROKEN label
   pulses so the read survives on a phone where the halo is a few px. */
.ms-broken-halo {
  position: absolute; left: 50%; top: 0; width: 66%; aspect-ratio: 1 / 1; pointer-events: none; z-index: 3;
  transform: translate(-50%, -58%) scaleY(0.38) rotate(0deg);
  animation: ms-broken-orbit var(--ms-fig-broken-halo-ms, 1300ms) linear infinite;
}
@keyframes ms-broken-orbit {
  from { transform: translate(-50%, -58%) scaleY(0.38) rotate(0deg); }
  to   { transform: translate(-50%, -58%) scaleY(0.38) rotate(360deg); }
}
.ms-broken-spark {
  position: absolute; width: 7px; height: 7px; margin: -3.5px; border-radius: 50%;
  background: var(--ms-gold); box-shadow: 0 0 6px 2px rgba(255,215,106,0.75);
}
.ms-broken-spark:nth-child(1) { left: 100%; top: 50%; }
.ms-broken-spark:nth-child(2) { left: 25%;  top: 6.7%; }
.ms-broken-spark:nth-child(3) { left: 25%;  top: 93.3%; }
.ms-foe-broken .ms-plate { border-color: var(--ms-burn); box-shadow: 0 0 10px rgba(255,138,64,0.35); }
.ms-foe-broken .ms-stagger-label { color: var(--ms-burn); animation: ms-telegraph-pulse 900ms ease-in-out infinite; }
.ms-foe-broken .ms-stagger-bar { box-shadow: 0 0 6px rgba(255,138,64,0.6); }
/* THE LAST STAND (docs/playtest-r14.md §1) — a lone Major/boss rages: its plate burns red and
 * says RAGING, so the change in its attacks (party-wide, twice every other round) is on the body. */
.ms-foe-raging .ms-plate { border-color: #ff4d3d; box-shadow: 0 0 12px rgba(255,77,61,0.55); }
.ms-foe-raging .ms-plate::after { content: 'RAGING'; display: block; font-size: 0.5rem;
  letter-spacing: 0.16em; color: #ff8a7a; text-align: center; animation: ms-telegraph-pulse 900ms ease-in-out infinite; }

/* THE STAGGER — only ever drawn on the body carrying boss-stagger state (§3). */
.ms-stagger-bar { position: relative; height: 5px; margin-top: 2px;
  background: rgba(255,255,255,0.12); overflow: hidden; }
.ms-stagger-fill { position: absolute; inset: 0 auto 0 0; background: var(--ms-rupture); transition: width 180ms ease; }
.ms-stagger-broken .ms-stagger-fill { background: var(--ms-burn); }
.ms-stagger-label { font-size: 0.48rem; letter-spacing: 0.08em; color: rgba(244,239,228,0.8); }

/* THE TETHER — a Conduit-shielded body's hard-gate look, desaturated + hatched (reuses
   `.ms-foe-shielded .ms-figure-body`'s `filter: saturate()` and `.ms-shield-mark`'s hatch
   overlay, both now scoped to the figure body — see THE COCKPIT PASS defect 2 above). The
   tag itself is now a PLATE row (board.js appends it into `.ms-plate`, alongside the name/
   HP/stagger rows) rather than a sibling under the whole figure, so it reads near the
   vitals it belongs to instead of pinned under the body. */
.ms-world-figure.ms-tether .ms-tether-tag { font-size: 0.5rem; letter-spacing: 0.12em;
  color: var(--ms-kinetic); text-align: center; }

/* ── THE PARTY STANDS (playtest 2026-08-29, amends §5) ──────────────────────────
 * "the party appears as a CUT-IN … never as standing furniture" — the cut-in stays, the
 * party now ALSO stands in the world, stage-left of the wave and facing it (scene.js
 * `placePartyFigure` mirrors the wave band's stage-right reshape). `.ms-world-hero` shares
 * `.ms-world-figure`'s base wholesale (frameless, transform-origin, the contact shadow) via
 * the shared class on the element — no border/panel is added here (FRAMELESS FIGURES
 * stands); the transform re-assertion below is the same clobber-trap insurance
 * `.ms-world-figure`'s own hover/cursor rules already carry. NO floating plate, NO HP bar —
 * the band's party window already carries vitals, and repeating them on the figure is
 * exactly the clutter the 2026-08-28 grounding pass removed. Desktop-only via the JS-toggled
 * `.ms-desktop` gate, same as every other world-figure rule above — no `eff-*` twin needed.
 * THE PLATE FLOOR (defect 1) does not touch this element — a hero carries no plate, so
 * there is nothing here for `--ms-fig-scale` to over-inflate; the ambient card-level scale
 * stays exactly as it always was. */
.ms-world-hero:hover,
.ms-world-hero.ob-focused,
.ms-world-hero.ms-cursor {
  transform: translate(-50%, -100%) scale(var(--ms-fig-scale, 1));
}
/* heroFigureImg's <img> is a bare child of `.ms-figure-body` (foes wrap theirs in `.ms-face`,
   which already carries this sizing) — same full-width-fit rule, one level shallower. */
.ms-figure-body > img { width: auto; height: 100%; max-height: none; display: block; }

/* THE UNDER-NAME TAG — always present, visually quiet; brightens on the acting member.
   `margin-top` (review round 2, THE FLOATING FEET FIX) seats it just under the contact
   shadow now that the figure's own alpha crop lands the feet AT the image's bottom edge —
   without it the tag sat flush against the shadow. */
/* THE TAG READS (2026-09-26): 0.58rem at 65% cream over a lit floor was the least legible
   text on either platform (measured on the plates: unreadable at 430px, faint at 1280). A
   dark pill under it and a full-strength ink; the cursor variant below still repaints it gold. */
.ms-hero-tag { font-size: 0.62rem; letter-spacing: 0.1em; color: rgba(244,239,228,0.92);
  text-shadow: 0 1px 2px rgba(0,0,0,0.85); text-align: center; margin-top: 3px;
  padding: 1px 6px; background: rgba(6,8,12,0.58); border-radius: 2px; white-space: nowrap; }
.ms-world-hero.ms-hero-acting .ms-hero-tag { color: var(--ms-gold); }
/* Acting reads as a quiet gold under-glow on the FIGURE — "whose turn it is", distinct from
   THE CURSOR's ring/caret language above ("what you're aiming at"), which stays the target
   read and applies here too via the shared `.ms-world-figure.ms-cursor` rules. */
.ms-world-hero.ms-hero-acting .ms-figure-body { filter: drop-shadow(0 0 9px rgba(255,215,106,0.55)); }
/* Acted: dimmed, still present — a body that has spent its turn, not one that left the stage. */
.ms-world-hero.ms-hero-acted { filter: brightness(0.72) saturate(0.8); }
/* Down: desaturated and faded; the DOWN marker takes the tag's spot rather than stacking
   under it. */
.ms-world-hero.ms-hero-down { filter: grayscale(1); opacity: 0.45; }
.ms-world-hero.ms-hero-down .ms-hero-tag { display: none; }
.ms-hero-down-tag { font-size: 0.58rem; letter-spacing: 0.14em; font-weight: 700;
  color: var(--ms-party-danger); text-shadow: 0 1px 2px rgba(0,0,0,0.85); text-align: center; }

/* THE ALLY CURSOR ON A HERO — a hero carries no plate, so the foes' cursor ring had nothing
   to land on and the cursor read collapsed into the acting under-glow: "who am I about to
   heal" had no answer. The cursor lands on the TAG instead (gold plate, dark text — the same
   selected-row language the action window speaks) plus the foes' own ▼ caret over the head. */
.ms-world-hero.ms-cursor::after {
  content: '▼'; position: absolute; top: -14px; left: 50%; transform: translateX(-50%);
  color: var(--ms-gold); font-size: 0.8rem; line-height: 1; text-shadow: 0 0 6px rgba(255,215,106,0.8);
}
.ms-world-hero.ms-cursor .ms-hero-tag {
  color: #0b0e14; background: var(--ms-gold); padding: 1px 6px; text-shadow: none;
  box-shadow: 0 0 12px rgba(255,215,106,0.6);
}

/* The no-WebGL fallback — a class-accent bordered box with the initial, the same read as
   the band's own portrait fallback (`.ms-pw-portrait`) but sized to the figure's own frame. */
.ms-hero-badge { width: 100%; aspect-ratio: 3 / 4; display: flex; align-items: center;
  justify-content: center; font-size: 1.4rem; font-weight: 700; color: var(--ms-accent, var(--ms-gold));
  border: 1px solid var(--ms-accent, var(--ms-gold));
  background: color-mix(in srgb, var(--ms-accent, var(--ms-gold)) 14%, rgba(8,10,14,0.6)); }

/* ── THE CUT-IN (§5, cutin.js) ──────────────────────────────────────────────── */
.ms-cutin { position: absolute; inset: 0; pointer-events: none; z-index: 3;
            overflow: hidden; display: none; }
.ms-cutin.ms-cutin-live { display: block; }
.ms-cutin-panel {
  position: absolute; inset: 0; display: flex; align-items: center; gap: 5%;
  padding: 0 7%;
  background: linear-gradient(100deg, rgba(6,8,12,0.97) 0%, rgba(6,8,12,0.9) 55%, transparent 100%);
  transform: translateX(-105%);
}
.ms-cutin-in .ms-cutin-panel {
  transform: translateX(0);
  transition: transform var(--ms-cutin-in-ms, 220ms) cubic-bezier(.2,.8,.2,1);
}
.ms-cutin-hold .ms-cutin-panel { transform: translateX(0); }
.ms-cutin-out .ms-cutin-panel {
  transform: translateX(105%);
  transition: transform var(--ms-cutin-out-ms, 250ms) cubic-bezier(.4,0,.8,.2);
}
.ms-cutin-lines { position: absolute; inset: 0; opacity: 0.5;
  background: repeating-linear-gradient(100deg, var(--ms-cutin-accent, var(--ms-gold)) 0 2px, transparent 2px 26px);
  -webkit-mask-image: linear-gradient(90deg, black, transparent 70%);
          mask-image: linear-gradient(90deg, black, transparent 70%); }
.ms-cutin-silhouette { width: 20%; min-width: 64px; aspect-ratio: 1 / 1.5; flex: 0 0 auto;
  background: var(--ms-cutin-accent, var(--ms-gold)); opacity: 0.85; }
/* One silhouette per class — a shape only, never a rendered mesh (v1, §5's own AI-plate
   follow-up is the named next step). */
.ms-cutin-sil-jupiter { clip-path: polygon(50% 0%, 90% 30%, 90% 78%, 50% 100%, 10% 78%, 10% 30%); }
.ms-cutin-sil-mars    { clip-path: polygon(50% 0%, 100% 50%, 78% 100%, 22% 100%, 0% 50%); }
.ms-cutin-sil-saturn  { clip-path: circle(46% at 50% 46%); }
.ms-cutin-text { display: flex; flex-direction: column; gap: 2px; z-index: 1; }
.ms-cutin-name { font-size: 1.05rem; letter-spacing: 0.14em; color: var(--ms-ink); font-weight: 700; }
.ms-cutin-cmd { font-size: 0.8rem; letter-spacing: 0.12em; color: var(--ms-cutin-accent, var(--ms-gold)); }

/* ── THE PHONE CUT-IN — the 'ribbon' variant (item 1, THE COCKPIT PASS · PASS 2) ─────────
 * cutin.js toggles `.ms-cutin-ribbon` on the SAME `.ms-cutin` host the desktop panel already
 * uses (mounted in `.ms-world`, shared furniture) — this rule only restyles `.ms-cutin-panel`
 * when that class is present, so a plain (desktop) play() is completely untouched. The
 * sweep/hold/out CLASS choreography (`.ms-cutin-in/-hold/-out`) and its transform-based
 * transition are the SAME mechanism as the panel — only the footprint + two geometry vars
 * (set once in cutin.js from CUTIN_RIBBON_HEIGHT_FRAC/TOP_FRAC) differ. */
.ms-cutin-ribbon .ms-cutin-panel {
  top: var(--ms-cutin-ribbon-top, 7%); bottom: auto;
  height: var(--ms-cutin-ribbon-height, 18%);
  padding: 0 6%; gap: 4%;
  background: linear-gradient(100deg,
    color-mix(in srgb, var(--ms-cutin-accent, var(--ms-gold)) 30%, rgba(6,8,12,0.97)) 0%,
    color-mix(in srgb, var(--ms-cutin-accent, var(--ms-gold)) 18%, rgba(6,8,12,0.92)) 55%,
    transparent 100%);
  border-top: 2px solid rgba(255,255,255,0.55);
  border-bottom: 2px solid rgba(255,255,255,0.55);
  box-shadow: 0 4px 18px rgba(0,0,0,0.5);
}
.ms-cutin-ribbon .ms-cutin-silhouette { width: 13%; min-width: 40px; }
.ms-cutin-ribbon .ms-cutin-name { font-size: 0.78rem; }
.ms-cutin-ribbon .ms-cutin-cmd { font-size: 0.6rem; }

/* ── THE CUT-IN SWEPT THE OTHER WAY (docs/playtest-r9.md §5 ruling 7; THE LADDER, LOUDER
 * 2026-10-03) — cutin.js toggles `.ms-cutin-reverse` for a WAVE-side event (a boss signature
 * landing, a Major's death). Same host, same in/hold/out class choreography, same timings;
 * only the direction mirrors (in from the right, out to the left), the gradient and the
 * speed-line mask flip with it, the silhouette sits on the right, and `--ms-cutin-accent` is
 * the foe's own accent (set per play by cutin.js). Composes with `.ms-cutin-ribbon` — the
 * stage's slim register — because it only ever restyles transform/direction, never geometry. */
.ms-cutin-reverse .ms-cutin-panel {
  transform: translateX(105%);
  flex-direction: row-reverse;
  text-align: right;
  background: linear-gradient(260deg, rgba(6,8,12,0.97) 0%, rgba(6,8,12,0.9) 55%, transparent 100%);
}
.ms-cutin-reverse.ms-cutin-in .ms-cutin-panel,
.ms-cutin-reverse.ms-cutin-hold .ms-cutin-panel { transform: translateX(0); }
.ms-cutin-reverse.ms-cutin-out .ms-cutin-panel { transform: translateX(-105%); }
.ms-cutin-reverse .ms-cutin-lines {
  background: repeating-linear-gradient(260deg, var(--ms-cutin-accent, var(--ms-gold)) 0 2px, transparent 2px 26px);
  -webkit-mask-image: linear-gradient(270deg, black, transparent 70%);
          mask-image: linear-gradient(270deg, black, transparent 70%);
}
.ms-cutin-reverse .ms-cutin-text { align-items: flex-end; }
.ms-cutin-reverse.ms-cutin-ribbon .ms-cutin-panel {
  background: linear-gradient(260deg,
    color-mix(in srgb, var(--ms-cutin-accent, var(--ms-gold)) 30%, rgba(6,8,12,0.97)) 0%,
    color-mix(in srgb, var(--ms-cutin-accent, var(--ms-gold)) 18%, rgba(6,8,12,0.92)) 55%,
    transparent 100%);
}
/* the wave has no class — its silhouette is the foe mark (a tilted plate, the plate every
 * foe card already wears), never one of the three class shapes */
.ms-cutin-sil-foe { clip-path: polygon(50% 0%, 100% 22%, 86% 100%, 14% 100%, 0% 22%); }

/* ── THE LOG — desktop's corner overlay + the shared `.ms-log-line` vocabulary
 * (log-lines.js). No border, no panel, pointer-events:none — a readout, never a surface. */
/* THE LOG SPEAKS UP (docs/playtest-r13.md §11, 2026-09-27): six lines, 0.6 → 0.7rem, a wider
 * and darker gradient, wrapping instead of clipping — and the NEWEST line is the loud one
 * (full ink, the rest step back), so the eye lands on what just happened. */
.ms-world-log { position: absolute; right: 10px; bottom: 10px; width: min(54%, 520px);
  display: flex; flex-direction: column; gap: 3px; pointer-events: none; z-index: 2;
  background: linear-gradient(0deg, rgba(6,8,12,0.86) 0%, rgba(6,8,12,0.42) 66%, transparent 100%);
  padding: 26px 12px 8px; }
.ms-log-line { font-size: 0.7rem; letter-spacing: 0.03em; color: rgba(244,239,228,0.85);
  text-shadow: 0 1px 2px rgba(0,0,0,0.8); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ms-world-log .ms-log-line { white-space: normal; opacity: 0.72; }
.ms-world-log .ms-log-line:last-child { opacity: 1; font-size: 0.76rem; color: rgba(244,239,228,0.98); }
.ms-log-detonate, .ms-log-ultimate, .ms-log-crit { color: var(--ms-gold); }
.ms-log-kill, .ms-log-break { color: var(--ms-burn); }
.ms-log-prime { color: var(--ms-shock); }
.ms-log-round { color: rgba(244,239,228,0.5); letter-spacing: 0.14em; text-align: center; }

/* THE LOG READS (docs/playtest-r14.md §7, 2026-09-28 — "easier to read and scrollable"). Both live
 * faces are now SCROLL REGIONS (board.js `_fillLiveLog`) sized to `--ms-log-window` lines; each line
 * carries its SPEAKER on its left edge — the class's own accent for a Fateforged, red for the wave —
 * numbers are bold, and a big beat (a Super, a last stand) prints a plain-words second row. */
.ms-world-log { pointer-events: auto; overflow-y: auto; overscroll-behavior: contain;
  justify-content: flex-start; scrollbar-width: thin;
  max-height: calc(var(--ms-log-window, 7) * 1.2rem + 34px); }
.ms-log-line { border-left: 2px solid transparent; padding-left: 6px; flex: 0 0 auto; }
.ms-log-line b { font-weight: 700; color: rgba(255,250,238,1); }
.ms-log-side-enemy { border-left-color: rgba(255,107,107,0.75); color: #ffc9c0; }
.ms-log-cls-jupiter { border-left-color: #5bc8f5; }
.ms-log-cls-mars    { border-left-color: #ff6b2b; }
.ms-log-cls-saturn  { border-left-color: #c45ff0; }
.ms-log-ultimate { font-weight: 700; letter-spacing: 0.06em; }
.ms-log-stand { color: #ff8a7a; font-weight: 700; letter-spacing: 0.06em; border-left-color: #ff4d3d; }
/* THE VOICE IN THE RUN (docs/lore-uplift-spec.md W3) — a faction's or the Loom's line, in the
   speaker's own accent (data/barks.js BARK_SPEAKERS = data/cast.js CAST_FACTIONS' accents). */
.ms-log-bark { font-style: italic; letter-spacing: 0.04em; }
.ms-log-bark.ms-log-cls-architects { color: #aef4ff; border-left-color: #aef4ff; }
.ms-log-bark.ms-log-cls-atlas      { color: #c9a557; border-left-color: #c9a557; }
.ms-log-bark.ms-log-cls-rust-kin   { color: #ff5a1e; border-left-color: #ff5a1e; }
.ms-log-bark.ms-log-cls-loom       { color: #ffd34d; border-left-color: #ffd34d; }
.ms-log-sub { font-size: 0.92em; font-weight: 400; font-style: italic; letter-spacing: 0.01em;
  color: rgba(244,239,228,0.82); white-space: normal; margin-top: 1px; }

/* ── THE BAND (§1) — action window 42% | party window 1fr. `min-height:0` on every zone
   (band itself + both children below) is what makes the grid row's `minmax(280px,36%)`
   guarantee real: without it a flex/grid child defaults to its CONTENT's min-height, which
   is exactly what would let a tall command list push the party window's height budget away
   from it — "nothing may ever paint over the world or clip" is a fact about this chain. */
.ms-band { display: grid; grid-template-columns: 42% 1fr; gap: 12px; min-height: 0; }

/* ── THE ACTION WINDOW (§4, 42% of the band) ───────────────────────────────── */
.ms-action { min-height: 0; display: flex; flex-direction: column; gap: 8px;
  border: 1px solid rgba(255,255,255,0.14); background: rgba(8,10,14,0.5); padding: 10px; overflow: hidden; }
.ms-action-head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px;
  font-size: 0.7rem; letter-spacing: 0.08em; padding-bottom: 6px;
  border-bottom: 1px solid rgba(255,255,255,0.14); flex: 0 0 auto; }
.ms-action-who { color: var(--ms-gold); }
.ms-action-state { color: rgba(244,239,228,0.7); }
/* THE DESKTOP BACKS OUT (docs/playtest-r10.md §13) — the head's ✕ BACK while a command is armed. */
.ms-action-back { flex: 0 0 auto; margin-left: auto; padding: 4px 10px; font: inherit; font-size: 0.62rem;
  letter-spacing: 0.1em; color: var(--ms-gold); background: transparent; border: 1px solid rgba(255,215,106,0.55);
  border-radius: 3px; cursor: pointer; }
.ms-action-back:hover, .ms-action-back:focus-visible { background: rgba(255,215,106,0.14); outline: none; }
/* NO LAYOUT SHIFT: outside AIM the button keeps its box and loses its paint. */
.ms-action-back-idle { visibility: hidden; pointer-events: none; }
/* THE CARD GROWS ON DESKTOP (r10 §13) — amended by NO LAYOUT SHIFT (2026-10-03): the card is
   no longer a grid row at all on desktop. It is the rail's SIBLING inside .ms-action-scroll,
   free height, nothing positioned under it; the rail's rows stay pinned at 38px while armed so
   CAST never moves. The headline beside CAST is one line here (the row is 38px). */
.ms-action-rail.ms-rail-aim { grid-auto-rows: 38px; }
.ms-action-scroll > .ms-aim-info { overflow: visible; margin-top: 6px; }
.ms-action-rail .ms-cast .ms-cmd-label { font-size: 0.64rem; letter-spacing: 0.08em; }
.ms-action-rail .ms-aim-headline { padding: 2px 8px; }
.ms-action-rail .ms-aim-headline .ms-aim-info-outcome { flex-wrap: nowrap; white-space: nowrap; overflow: hidden; }
.ms-action-rail .ms-aim-headline-big { font-size: 0.95rem; }
.ms-action-rail .ms-aim-headline-word { font-size: 0.66rem; }
.ms-action-rail .ms-aim-headline-rest { flex-basis: auto; flex: 1 1 0; min-width: 0; overflow: hidden; text-overflow: ellipsis; margin-top: 0; }
.ms-action-rail .ms-aim-headline .ms-aim-ico { width: 13px; height: 13px; }
/* Two columns of three (playtest 2026-08-29) — the six commands pair up cleanly now that
   THE SUPER SITS ON THE CARD deleted the ultimate's own cell; CAST (and the no-action hint)
   still span both columns when they render. */
.ms-action-scroll { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
.ms-action-rail { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; grid-auto-rows: minmax(38px, auto);
  height: auto; }
.ms-action-rail .ms-cmd { min-height: 38px; }
.ms-action-rail .ms-cast,
.ms-action-rail .ms-rail-hint { grid-column: 1 / -1; }
/* NO LAYOUT SHIFT (2026-10-03): one line, always the same height — empty in COMMAND, a sentence
   in AIM — so the scroller above it never gains or loses 5 px when a command arms. */
.ms-forecast { flex: 0 0 auto; padding-top: 6px; border-top: 1px solid rgba(255,255,255,0.14);
  font-size: 0.64rem; letter-spacing: 0.05em; color: rgba(244,239,228,0.78);
  line-height: 1.9em; height: 1.9em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }

/* ── THE PARTY WINDOW (§4, the band's remaining column) ────────────────────────
   A row is a GRID: portrait | identity (name + chips, takes the slack) | vitals
   (HP + ESC at a capped width). The bars stopped spanning the whole window on the
   2026-08-28 report "the party health bars are most of the screen" — the readout
   is the numerals; the bar is a glance, not a wall. */
.ms-party-window { min-height: 0; display: flex; flex-direction: column; gap: 6px;
  border: 1px solid rgba(255,255,255,0.14); background: rgba(8,10,14,0.5); padding: 10px; overflow: hidden; }
/* display:grid is load-bearing: the base .ms-member is a COLUMN (the phone card),
   and inheriting it centered the badge over stub bars (playtest 2026-08-29). */
.ms-pw-row { flex: 0.85 0 0; min-height: 0; display: grid;
  grid-template-columns: auto minmax(0, 1fr) clamp(170px, 34%, 260px);
  gap: 12px; align-items: center;
  padding: 6px 10px; transition: flex 160ms ease, opacity 160ms ease; overflow: hidden; }
.ms-pw-acting { flex: 1.3 0 0; border-color: var(--ms-gold); box-shadow: inset 0 0 0 1px rgba(255,215,106,0.35); }
.ms-pw-acted { opacity: 0.55; }
.ms-pw-portrait-img { border-radius: 0; width: 46px; height: 56px; overflow: hidden;
  border: 1px solid rgba(240,230,200,0.35); background: rgba(8,10,14,0.55); }
.ms-pw-portrait-img img { width: 100%; height: 100%; object-fit: cover; object-position: top; }
.ms-pw-acting .ms-pw-portrait-img { width: 56px; height: 68px; border-color: var(--ms-gold); }
.ms-pw-row .ms-hp { height: 9px; }
.ms-pw-acting .ms-hp { height: 11px; }
.ms-pw-portrait { width: 36px; height: 36px; border-radius: 50%; flex: 0 0 auto;
  display: flex; align-items: center; justify-content: center; font-size: 0.88rem; font-weight: 700;
  color: var(--ms-ink-on-gold); background: var(--ms-pw-accent, var(--ms-gold)); }
.ms-pw-body { min-width: 0; display: flex; flex-direction: column; gap: 4px; }
.ms-pw-vitals { min-width: 0; display: flex; flex-direction: column; gap: 3px; }
.ms-pw-head { display: flex; align-items: center; gap: 8px; }
/* THE SHIELD SAYS WHAT IT IS (docs/playtest-r9.md §2 R9-C, 2026-09-04 — Jeff: "what is the
 * blue bar above my health... shield?"). It was a 3px anonymous sliver: the ONE vital on the
 * card with no name, sitting directly above an HP bar whose `ms-ward-absorb` segment is the
 * same shock blue. It now reads in the ESCAPEMENT's own idiom — identical height, identical
 * centered-label treatment — so the vitals column is three NAMED rows. */
.ms-pw-shield { height: 8px; background: rgba(255,255,255,0.1); position: relative; overflow: hidden; }
/* No border, deliberately: `.ms-pw-esc` carries one and it eats 2 of its 8px, which is why its
 * own label sits tight. The HP bar above has none and reads best of the three, so the shield
 * matches HP — 8px of real content for the label, and a stack of three even rows. */
.ms-pw-shield-label { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
  font-size: 0.46rem; letter-spacing: 0.08em; color: var(--ms-ink); font-variant-numeric: tabular-nums;
  text-shadow: 0 1px 2px rgba(0,0,0,0.85); pointer-events: none; }
/* Dimmer than a full-strength shock fill, for exactly the reason `.ms-ward-absorb` above is:
 * the LABEL has to stay the most legible thing on the bar, and a shield sits at FULL almost
 * always (it starts full and regens 20% a round), so cream-on-bright-cyan would be the normal
 * case rather than the edge one. */
.ms-pw-shield .ms-shield-fill { height: 100%; opacity: 0.5; }
.ms-pw-esc { position: relative; height: 8px; background: rgba(255,255,255,0.12); overflow: hidden;
  border: 1px solid rgba(255,215,106,0.3); }
.ms-pw-esc-fill { position: absolute; inset: 0 auto 0 0; background: #c9a227; transition: width 200ms ease; }
.ms-pw-esc-ready { background: var(--ms-gold); box-shadow: 0 0 10px rgba(255,215,106,0.7); }
.ms-pw-esc-label { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
  font-size: 0.46rem; letter-spacing: 0.08em; color: var(--ms-ink); text-shadow: 0 1px 2px rgba(0,0,0,0.75); }

/* ── THE TICKER (§7, phone-only — hidden on desktop above) ────────────────── */
.ms-ticker { flex: 0 0 auto; display: flex; flex-direction: column; gap: 1px;
  padding: 3px 7px; border: 1px solid rgba(255,255,255,0.12); background: rgba(8,10,14,0.4); cursor: pointer; }
.ms-ticker:empty { display: none; }
/* THE LOG SPEAKS UP (r13 §11): 0.56 → 0.64rem, three lines, the newest at full ink. */
.ms-ticker .ms-log-line { font-size: 0.64rem; white-space: normal; opacity: 0.72; }
/* THE LOG READS (r14 §7) — the ticker scrolls too: a window of --ms-log-window lines over the
 * recent history, following the newest unless you scroll up. A tap still opens THE FULL LOG. */
.ms-ticker { overflow-y: auto; overscroll-behavior: contain; scrollbar-width: none;
  max-height: calc(var(--ms-log-window, 4) * 1.15rem + 14px); }
.ms-ticker .ms-log-sub { font-size: 0.6rem; }
.ms-ticker .ms-log-line:last-child { opacity: 1; font-size: 0.7rem; color: rgba(244,239,228,0.98); }

/* ── THE PAUSE CARD (§7 PAUSE / FULL LOG; §7.1 amended THE COCKPIT PASS · PASS 3b) ─────────
 * `.ms-pause` is now JUST the backdrop (position/inset/z-index/dim/centring); its actual
 * content lives in `.ms-pause-sheet`, a new inner wrapper (board.js `_renderPauseCard`) that
 * carries the old content rules (flex column, gap, centring) so DESKTOP renders byte-
 * identical to before the split. Phone's own block below turns `.ms-pause-sheet` into a
 * bottom sheet and `.ms-pause` into its dimmed scrim — scoped via THE COCKPIT FRAME's own
 * `.ms-stage:not(.ms-desktop)` platform class, so it needs no eff-w-le-720 twin (that guard
 * only polices the OLDER raw-media-query block above it in this file — see its own comment). */
.ms-pause { position: absolute; inset: 0; display: none; z-index: 5;
  align-items: center; justify-content: center;
  background: rgba(6,8,12,0.92); padding: 24px; text-align: center; }
.ms-pause-on { display: flex; }
.ms-pause-sheet { display: flex; flex-direction: column; gap: 14px; align-items: center; }
.ms-pause-title { font-size: 1rem; letter-spacing: 0.18em; color: var(--ms-ink); }
.ms-pause-btns { display: flex; flex-direction: column; gap: 8px; width: min(320px, 80%); }
.ms-pause-btn { min-height: 44px; padding: 8px 16px; font: inherit; font-size: 0.7rem;
  letter-spacing: 0.12em; color: var(--ms-ink); background: rgba(8,10,14,0.6);
  border: 1px solid rgba(255,255,255,0.28); cursor: pointer; }
.ms-pause-btn:hover, .ms-pause-btn.ob-focused { border-color: var(--ms-gold); outline: none; }
/* ABANDON — the one row in the sheet that actually leaves (via _confirmLeave). A quieter
   warm-red cast, never shouting: the confirm banner it opens is the real gate. */
.ms-pause-btn-danger { border-color: rgba(255,107,107,0.4); color: #ffb0b0; }
.ms-pause-btn-danger:hover, .ms-pause-btn-danger.ob-focused { border-color: #ff6b6b; }
.ms-pause-log { max-height: 54vh; overflow-y: auto; width: min(560px, 92%);
  display: flex; flex-direction: column; gap: 3px; text-align: left; padding: 10px;
  border: 1px solid rgba(255,255,255,0.16); background: rgba(0,0,0,0.32); }
.ms-pause-log .ms-log-line { white-space: normal; font-size: 0.68rem; }

/* THE PAUSE, phone bottom sheet — slides up over a dimmed, frozen scene; RESUME/PARTY/FULL
 * LOG/ABANDON stack full-width in the thumb arc, safe-area padded at the bottom edge. */
.ms-stage:not(.ms-desktop) .ms-pause {
  align-items: flex-end; justify-content: flex-end; padding: 0;
  background: rgba(4,5,8,0.62);
}
.ms-stage:not(.ms-desktop) .ms-pause-sheet {
  width: 100%; max-height: 78vh; overflow-y: auto;
  align-items: stretch; text-align: left;
  background: linear-gradient(180deg, rgba(18,20,27,0.98), rgba(6,8,12,0.98));
  border-top: 1px solid rgba(255,215,106,0.32);
  border-radius: 16px 16px 0 0;
  padding: 16px 14px calc(16px + env(safe-area-inset-bottom, 0px));
  box-shadow: 0 -10px 34px rgba(0,0,0,0.6);
  animation: ms-pause-sheet-in var(--ms-pause-sheet-ms, 240ms) ease-out;
}
.ms-stage:not(.ms-desktop) .ms-pause-title { text-align: center; font-size: 0.88rem; }
.ms-stage:not(.ms-desktop) .ms-pause-btns { width: 100%; gap: 10px; }
.ms-stage:not(.ms-desktop) .ms-pause-btn { min-height: 52px; font-size: 0.72rem; }
@keyframes ms-pause-sheet-in {
  from { transform: translateY(100%); }
  to   { transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
  .ms-stage:not(.ms-desktop) .ms-pause-sheet { animation: none !important; }
  .ms-aim-hpbar-cut { animation: none !important; }
}

/* ── THE DRY RUN (docs/measure-phone-controls.md §1.5, Q6=(a) BUILT 2026-08-29) ────────────
 * The toggle is a small corner badge on `.ms-world` (shared furniture — one rule set covers
 * both platforms; `.ms-world` is already `position:relative`, see THE FIGURES above). The
 * chips themselves are absolutely positioned inside whatever they describe, so painting or
 * clearing one costs ZERO layout — `.ms-plate` gets an explicit `position:relative` for this
 * (harmless: it already carries its own `transform`, which needs no positioning context to
 * work); `.ms-member` already had one (the hit-shake/dmg-float layer above uses it too). */
.ms-dryrun-toggle {
  position: absolute; top: 8px; right: 8px; z-index: 5;
  padding: 3px 8px; font: inherit; font-size: 0.52rem; letter-spacing: 0.08em;
  color: rgba(244,239,228,0.68); background: rgba(6,8,12,0.65);
  border: 1px solid rgba(255,255,255,0.28); cursor: pointer;
}
.ms-dryrun-toggle[hidden] { display: none; }
.ms-dryrun-toggle.ms-dryrun-on { color: var(--ms-gold); border-color: var(--ms-gold); }

.ms-plate { position: relative; }
/* A REAL foe plate on phone measures as small as 64x33px (measured on the rig) — there is no
   corner of a box that tiny an overlay can sit in without burying the name, the HP numeral,
   or the prime dots (all three tried and all three measured badly). The chip instead hangs
   just BELOW the plate, in the small gap `.ms-world-figure`'s own flex column already leaves
   before the figure body starts — that territory is guaranteed on-screen (the figure body
   that normally occupies it is, by construction: `placeFigure` + `_clampPlatesToWorld`), so
   this is what actually keeps THE FIGURES' `.ms-world { overflow:hidden }` from ever clipping
   it (a `bottom:100%`/above-the-plate first cut clipped clean off-screen on a near-top rank —
   measured on the rig, not just reasoned about). A very minor overlap onto the figure's own
   head is the trade paid for never covering the vitals the plate exists to show. */
.ms-preview-chip {
  position: absolute; left: 50%; top: 100%; transform: translateX(-50%);
  margin-top: 2px; z-index: 4;
  display: flex; flex-direction: column; align-items: center; gap: 0;
  padding: 2px 4px; width: 54px; pointer-events: none;   /* 62 -> 54 (THE PLATES SLIM DOWN, 2026-09-26) */
  background: rgba(10,8,4,0.92); border: 1px solid var(--ms-gold); border-radius: 3px;
  box-shadow: 0 1px 4px rgba(0,0,0,0.6);
}
/* A party card's own preview lands the top-right corner instead — `.ms-member`/`.ms-pw-row`
   are wide, flat ROWS (not a small floating plate), so a small corner badge over the free
   space beside the portrait/name never touches the HP/ESCAPEMENT bars underneath it. */
.ms-member > .ms-preview-chip {
  position: absolute; left: auto; top: 2px; right: 2px; transform: none; margin-top: 0;
  align-items: flex-end; width: auto; max-width: 58%;
}
.ms-preview-word {
  font-size: 0.44rem; font-weight: 700; letter-spacing: 0.02em; color: var(--ms-gold);
  white-space: normal; text-align: center; line-height: 1.15; word-break: break-word;
}
.ms-member > .ms-preview-chip .ms-preview-word { text-align: right; }
.ms-preview-num { font-size: 0.4rem; font-weight: 400; color: rgba(244,239,228,0.8); }
/* THE FORECAST READS THE POP (docs/playtest-r13.md §11, 2026-09-27): the primary chip grows to
   its content for the slots-before → slots-after row, the sigil and the multiplier; splash
   chips keep the fixed width and drop the BEFORE half (board.js `_paintPreview`). */
.ms-preview-chip-primary { width: auto; min-width: 54px; max-width: 118px; }
.ms-preview-pop { display: flex; align-items: center; justify-content: center; gap: 3px; margin-top: 1px; }
.ms-preview-dots { display: inline-flex; gap: 2px; align-items: center; }
.ms-preview-dots .ms-dot { width: 6px; height: 6px; min-width: 0; border-width: 1px; box-shadow: none; }
.ms-preview-arrow { font-size: 0.42rem; line-height: 1; color: rgba(244,239,228,0.7); }
.ms-preview-pop svg { width: 11px; height: 11px; flex: 0 0 auto; }
.ms-preview-mult { font-size: 0.48rem; font-weight: 700; color: var(--ms-gold); font-variant-numeric: tabular-nums; letter-spacing: 0.01em; }
.ms-preview-clean { font-size: 0.4rem; font-weight: 700; letter-spacing: 0.03em; color: var(--ms-burn); white-space: nowrap; }
.ms-preview-rung { font-size: 0.4rem; font-weight: 800; letter-spacing: 0.04em; color: var(--ms-gold); white-space: nowrap; opacity: 0.92; } /* THE DETONATION LADDER — MIX · PRIMED MIX · SINGULARITY */
/* A splash/party-effect chip is the same shape, quieter — the primary target's own chip is
   the one that should read first (Q6's own "the sentence, not the spreadsheet"). */
.ms-preview-chip-splash {
  background: rgba(10,10,12,0.8); border-color: rgba(255,255,255,0.34);
}
.ms-preview-chip-splash .ms-preview-word { color: var(--ms-ink); font-size: 0.46rem; }
/* THE NUMBERS ARE THE FOOTNOTE (Q6) — dropped first at the narrowest real viewports, keeping
   the outcome WORD (the sentence) on screen everywhere. NOT `eff-w-le-600`: every phone this
   mode targets (360-430px) already sits under 600, so that class does not distinguish
   "phone" from "genuinely cramped" — board.js's `_renderDryRun` reads `window.innerWidth`
   directly instead (`.ms-preview-num` is simply never appended below the threshold, so
   there is no CSS rule to keep in sync with one). */

/* ══════════════════════════════════════════════════════════════════════════
 * THE COCKPIT FRAME (docs/measure-phone-controls.md §3, THE COCKPIT PASS · PASS 1,
 * 2026-08-29) — the phone screen's three-zone recomposition: THE SLUG (identity+clock,
 * shared with desktop) / THE SCENE (THE WORLD, adopted from desktop, phone-tuned) / THE
 * STRIP (three party chips + HEAVY) / THE DECK (Pass 0's rail+footer, reskinned only).
 * Everything below is scoped by the JS-toggled `.ms-stage:not(.ms-desktop)` PLATFORM class
 * (board.js `_isDesktop()`), never by a width media query — it is already keyed to the same
 * `eff-w-le-900` gate the ui-scale contract defines (measure-stage-spec.md §8), so unlike
 * the raw-760px phone block above, THESE rules need no `:root.eff-*` twin: there is no
 * separate scale-1.0 raw path to drift out of sync with (the class already IS scale-aware).
 * DESKTOP IS UNTOUCHED — every selector below either lives under `:not(.ms-desktop)` or, for
 * the two exceptions that must reach INTO shared DOM `.ms-cast`/`.ms-cmd`/`.ms-bar`'s own
 * per-cell `::before`), is itself `:not(.ms-desktop)`-scoped so the desktop action window's
 * identical classes are never touched.
 * ══════════════════════════════════════════════════════════════════════════ */

/* ── THE SLUG, phone-sized (§1: "one hairline line, ~24-30px") ─────────────── */
.ms-stage:not(.ms-desktop) > .ms-slug { padding-bottom: 4px; }
.ms-stage:not(.ms-desktop) .ms-slug-left {
  font-size: 0.6rem; gap: 5px; min-width: 0; overflow: hidden;
}
.ms-stage:not(.ms-desktop) .ms-slug-place,
.ms-stage:not(.ms-desktop) .ms-slug-battle,
.ms-stage:not(.ms-desktop) .ms-slug-round {
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.ms-stage:not(.ms-desktop) .ms-slug-place { flex: 1 1 auto; min-width: 0; }

/* ── THE SCENE — THE WORLD adopted wholesale, taking every pixel of slack
 * (the FIT RULE: the deck/strip/slug hold their own height, the scene absorbs the rest). ─ */
.ms-stage:not(.ms-desktop) > .ms-world { flex: 1 1 auto; min-height: 0; }

/* ── THE TICKER, moved OFF the flow and INTO the scene as a translucent overlay anchored
 * to its bottom edge (§3 — "like desktop's corner world-log gradient, pointer-events on")
 * instead of its own row; its flow height goes back to the scene. Still tap-to-open (§7). */
.ms-stage:not(.ms-desktop) .ms-ticker {
  position: absolute; left: 6px; right: 6px; bottom: 6px; z-index: 2;
  padding: 10px 8px 4px; border: none; gap: 2px;
  background: linear-gradient(0deg, rgba(6,8,12,0.88) 0%, rgba(6,8,12,0.5) 74%, transparent 100%);
  pointer-events: auto;
}

/* ── THE BANNER's transient round-transition sweep (§3 THE BANNER) — a full-width,
 * pointer-events:none gold-ruled ribbon that auto-dismisses; never blocks input, never
 * shifts layout (it is an absolute overlay inside the scene). Desktop never adds
 * `.ms-round-sweep-on` (board.js `_flashRoundSweep` gates itself on `_isDesktop()`); the
 * `display:none` below is belt-and-braces so a stray class can never paint there either. */
.ms-round-sweep {
  position: absolute; left: 4%; right: 4%; top: 14%; z-index: 2;
  transform: translateY(-50%) scaleX(0.5);
  text-align: center; pointer-events: none; opacity: 0;
  padding: 6px 10px;
  font-size: 0.68rem; font-weight: 700; letter-spacing: 0.2em;
  color: var(--ms-ink-on-gold);
  background: linear-gradient(90deg, transparent, var(--ms-gold) 14%, var(--ms-gold) 86%, transparent);
  border-top: 1px solid rgba(255,255,255,0.55);
  border-bottom: 1px solid rgba(255,255,255,0.55);
}
.ms-stage.ms-desktop .ms-round-sweep { display: none; }
.ms-round-sweep-on { animation: ms-round-sweep var(--ms-round-sweep-ms, 1100ms) ease-out forwards; }
@keyframes ms-round-sweep {
  0%   { opacity: 0; transform: translateY(-50%) scaleX(0.35); }
  14%  { opacity: 1; transform: translateY(-50%) scaleX(1); }
  76%  { opacity: 1; transform: translateY(-50%) scaleX(1); }
  100% { opacity: 0; transform: translateY(-50%) scaleX(1); }
}

/* ── THE STRIP — three party CHIPS + HEAVY, fixed height (§3: "~54-64px"). Replaces the
 * old vertical `.ms-member` rows; `.ms-member`/`.ms-member-active`/`.ms-member-down`/
 * `.ms-cursor`/`.ms-cursor-live`/`.ms-member-pick` keep every rule they already have — only
 * the CHIP's own internal layout (`.ms-chip*`) is new. */
.ms-stage:not(.ms-desktop) > .ms-party {
  display: flex; align-items: stretch; gap: 6px; height: 58px; flex: 0 0 auto;
}
.ms-member.ms-chip {
  display: flex; flex-direction: row; align-items: center; gap: 6px;
  padding: 5px 7px; flex: 1 1 0; min-width: 0;
}
.ms-chip-portrait {
  width: 28px; height: 28px; border-radius: 50%; flex: 0 0 auto;
  display: flex; align-items: center; justify-content: center;
  font-size: 0.68rem; font-weight: 700; color: var(--ms-ink-on-gold);
  background: var(--ms-pw-accent, var(--ms-gold));
}
.ms-chip-portrait-img {
  border-radius: 0; width: 26px; height: 32px; overflow: hidden;
  border: 1px solid rgba(240,230,200,0.35); background: rgba(8,10,14,0.55);
}
.ms-chip-portrait-img img { width: 100%; height: 100%; object-fit: cover; object-position: top; }
.ms-chip-body { min-width: 0; display: flex; flex-direction: column; gap: 2px; flex: 1 1 auto; }
.ms-chip-name-row { display: flex; align-items: baseline; gap: 4px; min-width: 0; }
.ms-chip-name {
  font-size: 0.56rem; letter-spacing: 0.06em; color: var(--ms-ink);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  /* THE SUPER SITS ON THE CARD (§7.7) — the old inline "ACTED" text used to share this row
     and squeeze the name into an ellipsis at 402/320 wide ("JUPI…", playtest 2026-08-29);
     ACTED is now a corner glyph on the card (`.ms-chip-acted::after` below). The name gets a
     PROTECTED, non-shrinking flex basis (`flex: 0 0 auto`) rather than sharing shrink budget
     with `.ms-chip-super-name` beside it — a first pass gave both `flex-shrink`, and at a
     charged chip's real width ("JUPITER" + the ultimate's own name) BOTH ends up clipped
     ("JUPI…"/"LIGH…", measured on the rig). The member's own name must never be the one
     that gives; the ultimate-name label absorbs all the shrinking instead (below). */
  flex: 0 0 auto; min-width: 0;
}
.ms-chip-hp { height: 7px; }
.ms-chip-hp .ms-hp-nums { font-size: 0.42rem; }
/* The phone's half of THE SHIELD SAYS WHAT IT IS — its own 2px track ABOVE the HP bar,
 * mirroring the desktop card's stack order (shield / hp / esc). Not a segment inside the HP
 * bar: `ms-ward-absorb` already lives there in the same shock blue, so that home would trade
 * an absent readout for an ambiguous one. No label (the chip has no room, its own rule) — the
 * `title` carries the numbers and the desktop card carries the words. +3px to the chip. */
.ms-chip-shield {
  position: relative; height: 2px; margin-bottom: 1px;
  background: rgba(255,255,255,0.14); overflow: hidden;
}
.ms-chip-esc {
  position: relative; height: 3px; margin-top: 1px;
  background: rgba(255,255,255,0.14); overflow: hidden;
}
.ms-chip-esc-fill { position: absolute; inset: 0 auto 0 0; background: #c9a227; transition: width 200ms ease; }
.ms-chip-esc-ready .ms-chip-esc-fill { background: var(--ms-gold); box-shadow: 0 0 6px rgba(255,215,106,0.7); }

/* ── THE SUPER SITS ON THE CARD (docs/measure-phone-controls.md §7.7, Jeff's ruling
 * 2026-08-29) — the ultimate has no rail cell; the acting member's own full-bar chip/card is
 * its trigger, and the same chip becomes the commit control once armed. Three states, shared
 * by BOTH the phone chip (`.ms-chip`) and desktop's party-window row (`.ms-pw-row`) via the
 * same modifier classes the focus hub already keys `.ms-member*` off:
 *   `.ms-member-super-ready` — a quiet, dim badge: SOMEONE's bar is full, but not now/not you
 *   `.ms-member-charged`     — the ACTING member's own full bar: bright, pulsing, tappable
 *   `.ms-member-cast`        — armed: the card itself is now the CAST control
 * The corner ACTED glyph and the ultimate-name label are chip-specific layout (`.ms-chip-*`)
 * and live further down; these three classes are only the frame/glow treatment. */
.ms-member-super-ready { border-color: rgba(255,215,106,0.4); }
/* The ACTING member already wears a plain gold border+inset shadow (`.ms-member-active`,
 * above) whether or not their bar is full — a review pass measured the two states as nearly
 * indistinguishable in a still frame (the rig's own screenshots). CHARGED needs to read as a
 * clearly DIFFERENT, louder thing even at the animation's dimmest instant: a warm background
 * tint (no other card state carries one) plus a border/glow floor well above the plain
 * active look, never depending on catching the pulse mid-peak. */
.ms-member-charged {
  border-color: var(--ms-gold); border-width: 2px; cursor: pointer;
  background: rgba(255,215,106,0.10);
  box-shadow: 0 0 0 2px var(--ms-gold), 0 0 16px rgba(255,215,106,0.55);
  animation: ms-member-charged-pulse 1.6s ease-in-out infinite;
}
.ms-member-cast {
  border-color: var(--ms-gold); border-width: 2px; cursor: pointer;
  background: rgba(255,215,106,0.16);
  box-shadow: 0 0 0 2px var(--ms-gold), 0 0 20px rgba(255,215,106,0.65);
}
@keyframes ms-member-charged-pulse {
  0%, 100% { box-shadow: 0 0 0 2px var(--ms-gold), 0 0 14px rgba(255,215,106,0.5); }
  50%      { box-shadow: 0 0 0 3px var(--ms-gold), 0 0 28px rgba(255,215,106,0.85); }
}
/* The chip's own ultimate-name label — sits where THE ACTED tag used to, now that the tag is
 * a corner glyph. Takes ALL of the row's shrink budget (`.ms-chip-name` above is now
 * `flex: 0 0 auto`, protected) so it — never the member's own name — is the one that clips. */
.ms-chip-super-name {
  flex: 1 1 auto; min-width: 0;
  font-size: 0.44rem; letter-spacing: 0.04em; color: var(--ms-gold);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* AN OWED ACTION IS A CHIP (2026-08-30) — RALLY's token, in the same corner the ACTED ✓
   occupies and mutually exclusive with it (a member holding a token is not ACTED). Absolute,
   so the strip's fixed chip height does not move by a pixel. */
.ms-chip-owed {
  position: absolute; top: 3px; right: 4px; z-index: 3; line-height: 1;
  font-size: 0.42rem; letter-spacing: 0.06em; font-weight: 700; color: var(--ms-gold);
  text-shadow: 0 1px 2px rgba(0,0,0,0.8);
}
/* The corner ACTED glyph — replaces the old name-row text so the name never competes with it
 * for width (the fix for the truncation above). Paints on the SELECTED card too since THE
 * TURN IS YOURS: an acted, charged member may be the actor (rail cold, card lit) and reads
 * ACTED ✓ + charged at once. */
.ms-chip-acted { position: relative; }
.ms-chip-acted::after {
  content: '✓'; position: absolute; top: 3px; right: 4px; line-height: 1;
  font-size: 0.42rem; color: rgba(244,239,228,0.4);
}
/* The escapement thread's own brighter, pulsing treatment for the charged/armed cases —
 * layered on top of the plain `-ready` glow above, never replacing it. */
.ms-chip-esc-charged .ms-chip-esc-fill,
.ms-pw-esc-charged .ms-pw-esc-fill {
  animation: ms-member-esc-pulse 1.6s ease-in-out infinite;
}
@keyframes ms-member-esc-pulse {
  0%, 100% { filter: brightness(1); }
  50%      { filter: brightness(1.6); }
}
@media (prefers-reduced-motion: reduce) {
  .ms-member-charged,
  .ms-chip-esc-charged .ms-chip-esc-fill,
  .ms-pw-esc-charged .ms-pw-esc-fill { animation: none !important; }
}

.ms-strip-heavy {
  flex: 0 0 auto; display: flex; flex-direction: column;
  align-items: flex-end; justify-content: center; gap: 2px; padding: 0 3px;
  /* THE STRIP FITS (2026-09-26): the pips' block glyphs advance wider than the mono font's
     own cell, so a `flex:0 0 auto` column measured 36px and painted ~42 — the fourth pip
     clipped against THE WORLD's right edge on a 430px mount (measured on the plates). A
     floor wide enough for four pips, and the glyphs a touch smaller, so the column's paint
     is its box. */
  min-width: 42px;
}
.ms-strip-heavy-label { font-size: 0.42rem; letter-spacing: 0.1em; color: rgba(244,239,228,0.55); }
.ms-strip-heavy-pips { font-size: 0.64rem; letter-spacing: 0.02em; color: var(--ms-ink); white-space: nowrap; }

/* ── THE DECK — Pass 0's rail+footer geometry is UNTOUCHED (columns, min-heights, the
 * SAME SPOT RULE's cell math); this is a CSS-only instrument reskin: a subtle bezel, an
 * arming LED dot per cell (lit gold on CAST / a ready ENTER), and a restrained per-command
 * element accent stripe (`_cmdElementClass`, board.js) via the shared `--ms-burn/-shock/
 * -rupture/-kinetic` tokens. Scoped so the desktop action window — which renders the exact
 * same `.ms-cmd` cells via the same `_renderRail` — never sees any of it. */
/* `:not(.ms-cast)`/`:not(.ms-bar-enter)` matter: those two classes ALSO carry `.ms-cmd`/
 * `.ms-bar` (they are the SAME cell, just armed/committing), and a scoped selector this
 * specific would otherwise outrank their own `background: var(--ms-gold)` rule and paint
 * the commit button dark instead of gold. */
.ms-stage:not(.ms-desktop) .ms-cmd:not(.ms-cast),
.ms-stage:not(.ms-desktop) .ms-bar:not(.ms-bar-enter) {
  position: relative;
  background: linear-gradient(180deg, rgba(22,26,36,0.75), rgba(6,8,12,0.75));
  box-shadow: inset 0 1px 0 rgba(255,255,255,0.09), inset 0 -2px 3px rgba(0,0,0,0.5);
}
.ms-stage:not(.ms-desktop) .ms-cmd::before,
.ms-stage:not(.ms-desktop) .ms-bar::before {
  content: ''; position: absolute; top: 5px; right: 6px;
  width: 5px; height: 5px; border-radius: 50%;
  background: rgba(255,255,255,0.16); box-shadow: inset 0 0 2px rgba(0,0,0,0.7);
}
.ms-stage:not(.ms-desktop) .ms-cast,
.ms-stage:not(.ms-desktop) .ms-bar-enter:not(:disabled) {
  position: relative;
  box-shadow: inset 0 1px 0 rgba(255,255,255,0.35), inset 0 -2px 3px rgba(0,0,0,0.2),
              0 0 18px rgba(255,215,106,0.35);
}
.ms-stage:not(.ms-desktop) .ms-cmd-cold {
  background: linear-gradient(180deg, rgba(10,12,17,0.65), rgba(3,4,7,0.65));
  box-shadow: inset 0 2px 5px rgba(0,0,0,0.65);
}
.ms-stage:not(.ms-desktop) .ms-cast::before,
.ms-stage:not(.ms-desktop) .ms-bar-enter:not(:disabled)::before {
  background: var(--ms-gold); box-shadow: 0 0 6px rgba(255,215,106,0.9);
}
.ms-stage:not(.ms-desktop) .ms-cmd-elem-burn    { border-left: 3px solid var(--ms-burn); }
.ms-stage:not(.ms-desktop) .ms-cmd-elem-shock   { border-left: 3px solid var(--ms-shock); }
.ms-stage:not(.ms-desktop) .ms-cmd-elem-rupture { border-left: 3px solid var(--ms-rupture); }
.ms-stage:not(.ms-desktop) .ms-cmd-elem-kinetic { border-left: 3px solid var(--ms-kinetic); }

/* ── THE SCENE's plates slim down on phone (§3): every figure keeps name + HP bar + prime
 * dots (prime→detonate targeting needs them); the composition tag shows only on the cursor'd
 * figure and the boss. Same DOM as desktop (`_worldFigureCard` is shared) — hidden here, not
 * omitted, so every test/helper that finds these nodes keeps working.
 *
 * EVERY FOE READS ITS HP (playtest 2026-08-29: "all enemies should have visible hp bars, not
 * just majors"). The numerals were never missing from the DOM — this rule used to hide them
 * on every non-cursor, non-boss figure, which left chaff and line bodies with a 6px
 * low-contrast fill and no number, i.e. a health bar the player could not read. The numeral
 * now shows on every FOE plate; only the party heroes (`.ms-world-hero`, whose exact vitals
 * are already the party strip's job) keep the slim treatment, so the phone's plate budget
 * pays for the readout the player actually asked for. */
.ms-stage:not(.ms-desktop) .ms-world-hero:not(.ms-cursor) .ms-hp-nums,
.ms-stage:not(.ms-desktop) .ms-world-figure:not(.ms-cursor):not(.ms-foe-boss) .ms-comp {
  display: none;
}
/* A non-cursor'd foe's numeral WAS tighter than the cursor's own so it fit beside a name.
   THE NUMERAL IS THE PLATE (THE CLOSER FRAME II, 2026-10-03): the name is gone from this plate
   (below), so the numeral is its headline — and it is drawn at 0.61rem (9.76 px) so that at
   THE PLATE FOLLOWS THE BODY's `SCALE_MIN` 0.72 it still RENDERS the 7 px floor r9 §5.1 set
   (0.44rem was 7.04 px, which left the clamp no room at all). Shared with desktop's rule. */
.ms-stage:not(.ms-desktop) .ms-foe:not(.ms-cursor):not(.ms-foe-boss) .ms-hp-nums {
  font-size: 0.61rem; letter-spacing: 0;   /* 0.38rem was 6px — under the floor (2026-09-26); 0.44 → 0.61 with the body-scale clamp (2026-10-03) */
}
/* And the numeral may not BUY WIDTH with that legibility. A plain foe's plate is capped
 * tighter than the shared 128px ceiling above, because the numeral is centred inside the bar
 * (absolutely positioned, `.ms-hp-nums`) and therefore never needs the plate to grow to hold
 * it — the name is what sizes the plate, exactly as before this rule existed. Measured: at
 * THE SEAWORTHY HULL's squeezed 430x660 (Safari chrome eating an iPhone viewport), against
 * THE FIGURES GET BIGGER's own retuned PHONE scale, the uncapped plate let two foe plates
 * touch — guard `hull/board-fits-a-squeezed-viewport-with-no-figure-overlap`. */
.ms-stage:not(.ms-desktop) .ms-foe:not(.ms-cursor):not(.ms-foe-boss) .ms-plate {
  max-width: 76px;   /* 96 -> 84 (THE PLATES SLIM DOWN, 2026-09-26) -> 76 (r13 §11, 2026-09-27): the figures grew again, the plates pay again */
  /* 52 -> 56 (2026-10-03): with the name gone the numeral sizes the plate — "1140/1140" at 0.61rem
     needs the four px, and the plate then renders ×0.72–1 with its body, so it is smaller still */
  min-width: 56px;
  padding: 1px 3px; gap: 1px;
}
.ms-stage:not(.ms-desktop) .ms-world-figure .ms-plate { min-width: 52px; max-width: 128px; padding: 2px 4px; gap: 2px; }
.ms-stage:not(.ms-desktop) .ms-world-figure.ms-cursor .ms-plate,
.ms-stage:not(.ms-desktop) .ms-world-figure.ms-foe-boss .ms-plate { max-width: 150px; }
/* A name never buys the plate a second LINE on phone (the world override lets names wrap):
   one line, clipped with an ellipsis — the fonts stay on the 7px legibility floor THE INK
   BOX set, the plate just stops growing to fit them. */
.ms-stage:not(.ms-desktop) .ms-world-figure .ms-foe-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
/* THE MODEST BOSS BUMP's phone equivalent (defect 1) — this selector's own specificity has
   to match/beat the generic min-width rule right above it (four class-level selectors vs
   the base `.ms-world-figure.ms-foe-boss .ms-plate` rule's three), or the boss would fall
   right back to the plain 64px floor on phone regardless of the desktop-context bump. */
.ms-stage:not(.ms-desktop) .ms-world-figure.ms-foe-boss .ms-plate { min-width: 96px; }
/* THE COCKPIT PASS · PASS 3 (review nit) — the cursor's own plate widens to 168px above to
   fit its HP numerals + composition tag, and at that width in a tight formation it can bury
   a neighbouring plate. Capped back down (never for the boss's own already-tuned bump) with
   a tighter numeral font on any cursor'd plate — still fully readable, never wide enough to
   reach a neighbour. */
.ms-stage:not(.ms-desktop) .ms-world-figure.ms-cursor:not(.ms-foe-boss) .ms-plate { min-width: 56px; max-width: 124px; }   /* 148 -> 124 (2026-09-26); min 52 -> 56 (2026-10-03) holds "1140/1140" at the cursor's 0.56rem */
.ms-stage:not(.ms-desktop) .ms-world-figure.ms-cursor .ms-hp-nums { font-size: 0.56rem; letter-spacing: 0.01em; }   /* 0.46 -> 0.56 (2026-10-03): the plain plates' numeral grew, the cursor's stays the louder plate through its name + ring */
.ms-stage:not(.ms-desktop) .ms-world-figure .ms-foe-name { font-size: 0.52rem; }
.ms-stage:not(.ms-desktop) .ms-world-figure .ms-telegraph { font-size: 0.46rem; }
.ms-stage:not(.ms-desktop) .ms-world-figure .ms-hp-numeral { height: 6px; }

/* ── THE SHOULDER (2026-10-04, docs/pitches/measure-phone-stage-pitch.html, pitch A — the phone's
 * composition; `config.measure.stage.PHONE.SHOULDER` toggles `.ms-shoulder` on the stage) ──────
 * A1 BACKS TO THE CAMERA: the party stands at the bottom edge, cropped at the waist by THE WORLD's
 *    own overflow (PARTY_DEPTH > 1, PARTY_FLOOR_MAX), facing the wave (portrait.js `facing`). The
 *    name tags go (THE STRIP names them) except on the ally cursor, which lands on the tag.
 * A3 THE PLATE SITS ON THE FEET — RETIRED the same day by THE PLATES KEEP CLEAR (below): under the
 *    feet a plate was covered by every nearer body and sank under THE TICKER (Jeff's boss frame). */
.ms-stage.ms-shoulder .ms-world-hero:not(.ms-cursor) .ms-hero-tag { display: none; }
.ms-stage.ms-shoulder .ms-world-hero .ms-figure-shadow { display: none; }
/* The sky is a strip (HORIZON_FRAC 0.14): the haze thins with it. */
.ms-stage.ms-shoulder .ms-backdrop-haze { opacity: 0.6; }
/* A foe's bar carries a number now, so it gets a darker track: a nearly-dead chaff body
 * still reads as a bar rather than a smudge, and the numeral has something to sit on.
 * HEIGHT DELIBERATELY UNCHANGED at the shared 6px. It was briefly 8px "to hold the number",
 * which cost 2px on every plate and — against THE FIGURES GET BIGGER's retuned PHONE scale —
 * made two foe plates overlap at THE SEAWORTHY HULL's squeezed 430x660 (guard
 * `hull/board-fits-a-squeezed-viewport-with-no-figure-overlap`). No height was needed: the
 * numeral is an absolutely-positioned overlay (`.ms-hp-nums`, inset 0), and the cursor'd
 * plate has always drawn a LARGER 0.42rem numeral in this same 6px bar. */
.ms-stage:not(.ms-desktop) .ms-foe .ms-hp-numeral { background: rgba(0,0,0,0.55); }
/* Raised off 6px (finding 5: "the primary platform gets the least legible version of the
 * most important state"). Desktop's rim went 8px->10px (+25%); a flat ratio-hold would put
 * phone at 7.5px, but phone is the one platform where the bubble is also a touch/attention
 * target read at arm's length, so it gets a deliberately larger absolute jump to 9px —
 * bigger than the ratio-preserving number, still a full pixel under desktop's 10px so the
 * two platforms stay visibly proportioned to their own figure scale (measure-stage-spec §2). */
.ms-stage:not(.ms-desktop) .ms-world-figure .ms-dot { width: 9px; height: 9px; }
.ms-stage:not(.ms-desktop) .ms-world-figure .ms-comp { font-size: 0.46rem; }

/* ── prefers-reduced-motion (finding 5's own accessibility note) ─────────────
 * The shake/lunge/pulse/collapse animations are decoration; the numbers are the
 * information. Every keyframe above gets neutralised here — none of them are redefined,
 * only turned off — and the floating number switches from an absolutely-positioned riser
 * to a plain static inline label so it still reads without ever animating. */
@media (prefers-reduced-motion: reduce) {
  .ms-foe-hit, .ms-member-hit, .ms-foe-crit, .ms-foe-dying, .ms-foe-acting,
  .ms-foe-blasted, .ms-foe-detonated, .ms-foe-dying-blast,
  .ms-telegraph-pulse, .ms-dot-new,
  /* THE HURT / THE BLOW / THE BOSS REELS (2026-09-26) — motion off; the impact flash and
     the plate's BROKEN label (static, burn-coloured) carry the read. */
  .ms-hero-strike, .ms-foe-break, .ms-broken-halo, .ms-foe-broken .ms-stagger-label,
  .ms-chip.ms-member-hit::before, .ms-pw-row.ms-member-hit::before {
    animation: none !important;
  }
  .ms-chip.ms-member-hit::before, .ms-pw-row.ms-member-hit::before { opacity: 0 !important; }
  .ms-foe-crit { box-shadow: 0 0 0 3px var(--ms-white) !important; }
  .ms-foe-dying { opacity: 0.35; }
  /* THE BUBBLE (B6) — the breathe/throb stops; the rim, catch-light, hue (all static parts
   * of the one background gradient above) and MIXED's pressed-together border-radius
   * deformation are plain non-animated properties already and need no rule here to survive. */
  .ms-dot-burn, .ms-dot-shock, .ms-dot-rupture, .ms-dot-kinetic,
  .ms-primes-mixed > .ms-dot {
    animation: none !important;
  }
  .ms-dmg-float {
    animation: none !important; opacity: 1; position: static; display: inline-block;
    transform: none; margin: 2px 0 0; text-shadow: none;
  }
  /* THE CUT-IN (§5) — cutin.js itself skips the whole sweep and fires its reveal callback
     on the next tick when this media query matches, so `.ms-cutin-live` never even gets
     added; these two rules are only the belt-and-braces CSS twin in case a panel is
     already mid-transition when the OS setting flips mid-session. Covers BOTH the desktop
     panel and the phone ribbon variant — the selector is shared. */
  .ms-cutin-panel, .ms-cutin-in .ms-cutin-panel, .ms-cutin-out .ms-cutin-panel { transition: none !important; }
  .ms-pw-row { transition: none !important; }
  /* THE BANNER's round-sweep (THE COCKPIT FRAME, Pass 1) — board.js's `_flashRoundSweep`
     already refuses to add `.ms-round-sweep-on` under this media query, so this is only the
     belt-and-braces CSS twin in case the OS setting flips mid-sweep. */
  .ms-round-sweep-on { animation: none !important; opacity: 0 !important; }

  /* ── THE COCKPIT PASS · PASS 2 THE JUICE — every new effect collapses to
   * instant-with-static-result, never to nothing-happened-at-all where a static result
   * exists to show. ────────────────────────────────────────────────────────────────── */
  /* THE IMPACT FLASH (2a) — a flash has no meaningful "static" end-state (its resting state
     IS invisible), so it is simply suppressed, exactly like THE CUT-IN above. */
  .ms-impact-flash::after { animation: none !important; opacity: 0 !important; }
  /* THE HP TWEEN (2b) — the bar snaps; the numeral overlay was always static. */
  .ms-hp-fill { transition: none !important; }
  /* THE ATTACKER RIM (4) — the silhouette outline held static at the animation's own
     brightest frame, rather than vanishing outright, so the "who is acting" read still holds
     under reduced motion; the stamp under the feet is motion only and goes. */
  .ms-attacker-rim .ms-figure-body :is(img, .ms-hero-badge) {
    animation: none !important;
    filter: drop-shadow(0 0 1px var(--ms-accent, var(--ms-gold)))
            drop-shadow(0 0 1px var(--ms-accent, var(--ms-gold)))
            drop-shadow(0 0 10px color-mix(in srgb, var(--ms-accent, var(--ms-gold)) 70%, transparent)) !important;
  }
  .ms-attacker-rim .ms-figure-walk::after { animation: none !important; opacity: 0 !important; }
  /* THE RETICLE (3) — static brackets, per the pass's own brief ("reduced-motion = static
     brackets"), not a vanished mark: the corner-bracket paint itself is not motion, only its
     breathing pulse is. */
  .ms-world-figure.ms-cursor .ms-figure-body::before {
    animation: none !important; opacity: 0.85; transform: none;
  }
}

/* THE FPS HUD STANDS DOWN (board.js `_setFpsHudHidden`, 2026-09-04) — the crosshair and the
   motion tracker used to be hidden from the board by OCCLUSION (the DOM diorama painted an
   opaque overlay); THE LIVE STAGE's mask cuts a hole to THE WORLD's rect and they show
   through it. Nothing under `#hud` is a readout the turn board wants. */
#hud.measure-hidden { display: none !important; }

/* ── THE PLATES STEP BACK (docs/playtest-r9.md §4.4/§5, 2026-09-04) ───────────────────────
 * Jeff, on THE CLOSER FRAME: "when we zoom in we can make the enemy name plates smaller so we
 * can see all of them" — and, on how: "make sure their hp values aren't hidden."
 *
 * That second clause is not a preference, it is a DECISION OF RECORD being re-asserted. The
 * phone ran this exact experiment and lost: hiding the numeral on every non-cursor, non-boss
 * figure left chaff with "a 6px low-contrast fill and no number, i.e. a health bar the player
 * could not read", reported on 2026-08-29 and reversed as EVERY FOE READS ITS HP
 * (the-measure-spec.md, guarded by `stage/every-foe-shows-its-hp-numeral-on-phone`). So the
 * reduction lever here is the NAME, the COMPOSITION TAG and the plate's own WIDTH — never HP.
 *
 * Desktop was never given the phone's off-cursor ladder because at BOOM 13 the plates had all
 * the room they wanted. THE CLOSER FRAME spends that room, so desktop inherits the ladder now:
 * the cursor'd body and the boss keep the full plate, everything else steps back. The party's
 * own hero plates drop their numerals off-cursor for the same reason the phone's do — their
 * exact vitals are the party WINDOW's job, and it now draws three named rows of them. */
.ms-stage.ms-desktop .ms-world-figure:not(.ms-cursor):not(.ms-foe-boss) .ms-comp { display: none; }
/* min-width is the NUMERAL's, not zero: `.ms-hp-nums` is an absolutely-positioned overlay
   inside an `overflow:hidden` bar, so a plate that shrank to a four-letter name ("MOTE") clipped
   its own "167/167" to "67/16" — measured on THE LINE READS' first desktop plate (r9 §10).
   60px holds "1140/1140" at 0.4rem with the padding; EVERY FOE READS ITS HP is the rule. */
.ms-stage.ms-desktop .ms-foe:not(.ms-cursor):not(.ms-foe-boss) .ms-plate {
  min-width: 66px; max-width: 88px; padding: 1px 4px; gap: 1px;   /* 96 -> 88, 2px -> 1px (r13 §11, 2026-09-27) */
}
/* 0.44rem (7px) was under the legibility floor at 1280×800 — a plate you cannot read is not
   a smaller plate, it is a missing one. 0.5rem, and the numeral to match (2026-09-26). */
.ms-stage.ms-desktop .ms-foe:not(.ms-cursor):not(.ms-foe-boss) .ms-foe-name {
  font-size: 0.5rem; letter-spacing: 0.02em; white-space: nowrap;
  overflow: hidden; text-overflow: ellipsis;
}
/* The numeral is an absolutely-positioned overlay, so sizing it never widens the plate past its
   min-width. 0.46 -> 0.61rem (THE NUMERAL IS THE PLATE, 2026-10-03): the name is off this plate,
   the numeral is its headline, and 9.76 px × SCALE_MIN 0.72 renders the 7 px floor. HP stays, always. */
.ms-stage.ms-desktop .ms-foe:not(.ms-cursor):not(.ms-foe-boss) .ms-hp-nums {
  font-size: 0.61rem; letter-spacing: 0;
}
.ms-stage.ms-desktop .ms-foe.ms-cursor .ms-hp-nums { font-size: 0.56rem; }   /* 0.5 -> 0.56 (2026-10-03), the phone's own bump */
.ms-stage.ms-desktop .ms-world-hero:not(.ms-cursor) .ms-hp-nums { display: none; }
.ms-stage.ms-desktop .ms-world-hero:not(.ms-cursor) .ms-plate {
  min-width: 0; max-width: 104px; padding: 2px 5px;
}
/* And the CURSOR'D plate sizes to its content too. Measured after the rules above landed: the
 * six foe plates fell from an average 118px to 57px and four overlapping pairs remained —
 * every one of them involving the cursor'd plate, which the base rule pins to `min-width:118px`
 * however short its name is (MOTE was 118px beside a 33px MOTE). It should read LOUDER than its
 * neighbours, which it does through the full name + numeral + composition tag it alone keeps —
 * not through 60px of empty box. The phone capped this same plate for the same reason ("at that
 * width in a tight formation it can bury a neighbouring plate"). The BOSS keeps its own bump. */
.ms-stage.ms-desktop .ms-foe.ms-cursor:not(.ms-foe-boss) .ms-plate {
  min-width: 66px; max-width: 150px;   /* 0 -> 66 (2026-10-03): a name-sized MOTE plate clipped its own
                                          "167/167" to "67/16" once the cursor's numeral grew — the plain
                                          plate's own floor, for the same reason */
}

/* ── THE NUMERAL IS THE PLATE (THE CLOSER FRAME II, docs/pitches/measure-spectacle-pitch.html
 * p.7, 2026-10-03 — both renderers, both platforms) ───────────────────────────────────────
 * A foe plate that is NOT the cursor, NOT the boss and NOT the acting foe collapses to the HP
 * numeral + the HP bar (+ the prime dots, which are state, and the telegraph, which outranks a
 * hit — E4). The NAME is what the ladder takes: it moves to the moment you look — the cursor'd
 * plate, the boss and the foe striking you (`.ms-foe-acting` / `.ms-attacker-rim`, the blow's
 * own 200 ms) keep it. EVERY FOE READS ITS HP stands untouched (`stage/every-foe-*` guards).
 * The HP fill is already the tier mark (`.ms-tier-* .ms-hp-fill`), so nothing new is drawn.
 * The name row leaves layout entirely when it has nothing left to say (`:has`), so a plain
 * plate is the bar's height + the dots — and when the acting foe's name comes back for the
 * blow it FLOATS above the plate rather than re-flowing it: THE LIVE STAGE caches each plate's
 * layout size once per render (`_plateDirty`) and solves the stack on those numbers, so a
 * 200 ms pulse must not change the box those numbers describe. */
.ms-world-figure.ms-foe:not(.ms-cursor):not(.ms-foe-boss) .ms-foe-name { display: none; }
.ms-world-figure.ms-foe:not(.ms-cursor):not(.ms-foe-boss) .ms-plate-name-row:not(:has(.ms-telegraph)) { display: none; }
/* The bar is the numeral's own box (`.ms-hp` clips, `overflow:hidden`): a 0.61rem numeral's
   digits stand ~7 px tall, so the plain plate's bar grows 6/8 → 10 px to hold them whole — at
   SCALE_MIN it still renders 7.2 px, over the 6 px the phone guard asks of a bar. The name's
   row it replaces was taller than the 2–4 px this adds, so the plate is still shorter. */
.ms-stage .ms-world-figure.ms-foe:not(.ms-cursor):not(.ms-foe-boss) .ms-hp-numeral { height: 10px; }
.ms-world-figure.ms-foe:not(.ms-cursor):not(.ms-foe-boss).ms-foe-acting .ms-plate-name-row,
.ms-world-figure.ms-foe:not(.ms-cursor):not(.ms-foe-boss).ms-attacker-rim .ms-plate-name-row {
  display: flex; position: absolute; left: 50%; bottom: 100%; transform: translateX(-50%);
  width: max-content; max-width: 160px; padding: 1px 4px; justify-content: center;
  background: rgba(6,8,12,0.78); border: 1px solid color-mix(in srgb, var(--ms-accent, #e89020) 55%, transparent);
  pointer-events: none;
}
.ms-world-figure.ms-foe:not(.ms-cursor):not(.ms-foe-boss).ms-foe-acting .ms-foe-name,
.ms-world-figure.ms-foe:not(.ms-cursor):not(.ms-foe-boss).ms-attacker-rim .ms-foe-name {
  display: block; font-size: 0.61rem; white-space: nowrap;   /* the same floor-aware size as the numeral */
}

/* ── THE SUPER TAKES THE STAGE (docs/playtest-r14.md §9, 2026-09-28) ───────────────────────
 * Jeff: "when a super is selected, we should show a cool graphic where the ability buttons used
 * to be along with what will happen. This will explain what it does and make it special."
 * board.js `_superPanel` paints this over the whole rail while a Super is armed: spinning
 * class-accent rays under the class's own motif (the storm's bolts, the wall's flames, the
 * collapsing star — the SAME three shapes the cast plays full-stage, fx.css), then the name,
 * the tag, the plain-words sentence, the aimed foe, THE FORECAST and its own CAST button.
 * transform/opacity only; frozen under reduced motion. */
.ms-super-panel { position: relative; box-sizing: border-box; min-width: 0; min-height: 0;
  overflow: hidden; display: flex; align-items: stretch;
  border: 1px solid var(--ms-super-accent, var(--ms-gold));
  box-shadow: 0 0 18px color-mix(in srgb, var(--ms-super-accent, var(--ms-gold)) 45%, transparent),
              inset 0 0 28px color-mix(in srgb, var(--ms-super-accent, var(--ms-gold)) 22%, transparent);
  background: radial-gradient(ellipse at 18% 50%,
      color-mix(in srgb, var(--ms-super-accent, var(--ms-gold)) 34%, #06080c) 0%, #06080c 72%);
  animation: msSuperIn 280ms cubic-bezier(.2,.8,.3,1) both; }
.ms-super-art { position: relative; flex: 0 0 34%; min-width: 96px; overflow: hidden; }
.ms-super-rays { position: absolute; left: 50%; top: 50%; width: 260%; aspect-ratio: 1;
  transform: translate(-50%, -50%);
  background: repeating-conic-gradient(from 0deg,
      color-mix(in srgb, var(--ms-super-accent, var(--ms-gold)) 55%, transparent) 0deg 7deg,
      transparent 7deg 22deg);
  -webkit-mask-image: radial-gradient(circle, #000 12%, transparent 58%);
          mask-image: radial-gradient(circle, #000 12%, transparent 58%);
  animation: msSuperSpin 9s linear infinite; opacity: 0.8; }
.ms-super-motif { position: absolute; inset: 0; }
.ms-super-m { position: absolute; opacity: 0.95; }
/* THE PANEL FITS THE RAIL (2026-10-04, Jeff on the phone: "fix super cropping"). The body is a
 * column that never overflows: every row is `flex: 0 0 auto` except `.ms-super-text`, the one
 * box that SHRINKS (min-height 0) and scrolls, so the name above it and the foe / FORECAST /
 * UNLEASH rows below it are always inside the panel. `justify-content: center` on an
 * overflowing scroller is what clipped the name off the top — it is safe again only because
 * the body no longer overflows. */
.ms-super-body { position: relative; flex: 1 1 auto; min-width: 0; min-height: 0; display: flex; flex-direction: column;
  justify-content: center; gap: 3px; padding: 6px 10px 6px 4px; text-align: left; overflow: hidden; }
.ms-super-body > * { flex: 0 0 auto; }
.ms-super-text { flex: 0 1 auto; min-height: 0; overflow-y: auto; scrollbar-width: thin;
  overscroll-behavior: contain; -webkit-overflow-scrolling: touch; display: flex; flex-direction: column; }
.ms-super-kicker { font-size: 0.56rem; letter-spacing: 0.18em; color: rgba(244,239,228,0.7); }
.ms-super-name { font-size: 1.2rem; font-weight: 800; letter-spacing: 0.16em; line-height: 1.05;
  color: var(--ms-super-accent, var(--ms-gold));
  text-shadow: 0 0 10px var(--ms-super-accent, var(--ms-gold)), 0 2px 2px #000; }
.ms-super-explain { font-size: 0.66rem; line-height: 1.3; color: rgba(244,239,228,0.9); }
.ms-super-target { font-size: 0.62rem; letter-spacing: 0.06em; color: var(--ms-gold); }
.ms-super-forecast { font-size: 0.62rem; letter-spacing: 0.08em; font-weight: 700; color: #fff; }
.ms-super-cast { align-self: flex-start; margin-top: 3px; min-height: 36px; padding: 6px 14px;
  font: inherit; font-size: 0.66rem; font-weight: 800; letter-spacing: 0.14em; cursor: pointer;
  color: #06080c; background: var(--ms-super-accent, var(--ms-gold)); border: none;
  box-shadow: 0 0 14px var(--ms-super-accent, var(--ms-gold));
  animation: msSuperPulse 1.1s ease-in-out infinite; }
.ms-super-cast:hover, .ms-super-cast:focus-visible { outline: 2px solid #fff; }
/* THE REWEAVE (playtest-r17 §2) — the Super's second use, beside UNLEASH: an outline, never the
 * filled pulse, so the board's loudest button stays the one that hits the wave. */
.ms-super-actions { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.ms-super-actions .ms-super-cast { align-self: auto; }
.ms-super-reweave { margin-top: 3px; min-height: 36px; padding: 6px 12px; font: inherit; font-size: 0.62rem;
  font-weight: 800; letter-spacing: 0.12em; cursor: pointer; color: var(--ms-super-accent, var(--ms-gold));
  background: rgba(6, 8, 12, 0.72); border: 2px solid var(--ms-super-accent, var(--ms-gold)); }
.ms-super-reweave:hover, .ms-super-reweave:focus-visible { outline: 2px solid #fff; }
.ms-stage.ms-desktop .ms-super-reweave { min-height: 30px; padding: 4px 10px; }
/* A fallen card a Super can raise, while that Super is armed — the pick reads as a target. */
.ms-member-down.ms-member-pick { filter: none; opacity: 1; outline: 2px dashed var(--ms-gold); outline-offset: -2px; }

/* JUPITER — the storm: five bolts dropping through the rays. */
.ms-super-panel[data-class="jupiter"] .ms-super-m { top: 6%; height: 80%; width: 12%;
  background: linear-gradient(180deg, #fff 0%, var(--ms-super-accent) 70%, transparent 100%);
  clip-path: polygon(45% 0, 70% 0, 52% 28%, 78% 28%, 40% 62%, 58% 62%, 22% 100%, 36% 58%, 16% 58%, 38% 26%, 20% 26%);
  filter: drop-shadow(0 0 5px var(--ms-super-accent));
  animation: msSuperBolt 1.6s steps(1, end) infinite; }
.ms-super-panel[data-class="jupiter"] .ms-super-m0 { left: 8%;  animation-delay: 0s; }
.ms-super-panel[data-class="jupiter"] .ms-super-m1 { left: 27%; animation-delay: .35s; }
.ms-super-panel[data-class="jupiter"] .ms-super-m2 { left: 46%; animation-delay: .8s; }
.ms-super-panel[data-class="jupiter"] .ms-super-m3 { left: 64%; animation-delay: .2s; }
.ms-super-panel[data-class="jupiter"] .ms-super-m4 { left: 82%; animation-delay: 1.1s; }
@keyframes msSuperBolt { 0% { opacity: 1; } 12% { opacity: 0.15; } 20% { opacity: 1; } 32%, 100% { opacity: 0; } }

/* MARS — the wall: five flame tongues shoulder to shoulder in one band, flickering. */
.ms-super-panel[data-class="mars"] .ms-super-m { bottom: 8%; height: 62%; width: 26%;
  background: linear-gradient(0deg, #fff1c4 0%, #ffb347 24%, var(--ms-super-accent) 58%, transparent 100%);
  clip-path: polygon(0 100%, 8% 40%, 22% 62%, 34% 12%, 50% 48%, 64% 0, 78% 50%, 90% 30%, 100% 100%);
  transform-origin: bottom center; mix-blend-mode: screen;
  animation: msSuperFlame 0.9s ease-in-out infinite alternate; }
.ms-super-panel[data-class="mars"] .ms-super-m0 { left: -4%; }
.ms-super-panel[data-class="mars"] .ms-super-m1 { left: 16%; height: 74%; animation-delay: .2s; }
.ms-super-panel[data-class="mars"] .ms-super-m2 { left: 37%; height: 80%; animation-delay: .45s; }
.ms-super-panel[data-class="mars"] .ms-super-m3 { left: 58%; height: 70%; animation-delay: .1s; }
.ms-super-panel[data-class="mars"] .ms-super-m4 { left: 78%; animation-delay: .3s; }
@keyframes msSuperFlame { from { transform: scaleY(0.82) scaleX(1.02); } to { transform: scaleY(1.06) scaleX(0.96); } }

/* SATURN — the star: a black core with a violet rim, a ring breathing out of it, motes pulled in. */
.ms-super-panel[data-class="saturn"] .ms-super-m0 { left: 50%; top: 50%; width: 46%; aspect-ratio: 1;
  transform: translate(-50%, -50%); border-radius: 50%;
  background: radial-gradient(circle, #000 0%, #140022 40%, var(--ms-super-accent) 64%, transparent 72%);
  filter: drop-shadow(0 0 10px var(--ms-super-accent));
  animation: msSuperCore 2.2s ease-in-out infinite; }
.ms-super-panel[data-class="saturn"] .ms-super-m1 { left: 50%; top: 50%; width: 56%; aspect-ratio: 1;
  border-radius: 50%; border: 3px solid var(--ms-super-accent);
  animation: msSuperRing 2.2s ease-out infinite; }
.ms-super-panel[data-class="saturn"] .ms-super-m2,
.ms-super-panel[data-class="saturn"] .ms-super-m3,
.ms-super-panel[data-class="saturn"] .ms-super-m4 { left: 50%; top: 50%; width: 6px; height: 6px;
  margin: -3px 0 0 -3px; border-radius: 50%; background: #f4e2ff; box-shadow: 0 0 6px var(--ms-super-accent);
  animation: msSuperPull 2.2s ease-in infinite; }
.ms-super-panel[data-class="saturn"] .ms-super-m2 { --ms-pull-x: -48px; --ms-pull-y: -30px; }
.ms-super-panel[data-class="saturn"] .ms-super-m3 { --ms-pull-x: 46px; --ms-pull-y: -22px; animation-delay: .7s; }
.ms-super-panel[data-class="saturn"] .ms-super-m4 { --ms-pull-x: -10px; --ms-pull-y: 40px; animation-delay: 1.4s; }
@keyframes msSuperCore { 0%, 100% { transform: translate(-50%, -50%) scale(1); } 50% { transform: translate(-50%, -50%) scale(0.82); } }
@keyframes msSuperRing { 0% { opacity: 0.9; transform: translate(-50%, -50%) scale(0.4); } 100% { opacity: 0; transform: translate(-50%, -50%) scale(1.6); } }
@keyframes msSuperPull { 0% { opacity: 0; transform: translate(var(--ms-pull-x), var(--ms-pull-y)); } 20% { opacity: 1; } 80%, 100% { opacity: 0; transform: translate(0, 0) scale(0.3); } }

@keyframes msSuperIn { from { opacity: 0; transform: scale(0.97); } to { opacity: 1; transform: scale(1); } }
@keyframes msSuperSpin { to { transform: translate(-50%, -50%) rotate(360deg); } }
@keyframes msSuperPulse { 0%, 100% { box-shadow: 0 0 8px var(--ms-super-accent, var(--ms-gold)); } 50% { box-shadow: 0 0 20px var(--ms-super-accent, var(--ms-gold)); } }

@media (prefers-reduced-motion: reduce) {
  .ms-super-panel, .ms-super-rays, .ms-super-m, .ms-super-cast { animation: none !important; }
}
/* Desktop's action window is shorter than the phone's rail: the art narrows further, the name
 * and the sentence step down a size, and CAST is a shorter button. (The tag that repeated the
 * sentence is gone on both platforms — THE PANEL FITS THE RAIL, above.) */
.ms-stage.ms-desktop .ms-super-art { flex-basis: 26%; min-width: 80px; }
.ms-stage.ms-desktop .ms-super-name { font-size: 1.05rem; }
.ms-stage.ms-desktop .ms-super-explain { font-size: 0.62rem; }
.ms-stage.ms-desktop .ms-super-cast { min-height: 30px; padding: 4px 12px; }
/* The phone's panel is three 64px rows (`--ms-rail-cell-min-h`): the art gives the words a
 * wider column so the sentence wraps to fewer lines, and the aimed-foe line stays one line. */
.ms-stage:not(.ms-desktop) .ms-super-art { flex-basis: 27%; min-width: 84px; }
/* The kicker (SUPER · JUPITER · SENTINEL OF JUPITER) is the acting card's own gold chip on the
 * phone — its row goes to the sentence instead. */
.ms-stage:not(.ms-desktop) .ms-super-kicker { display: none; }

/* ── THE PLATES KEEP CLEAR + THE BOSS BANNER (2026-10-04, Jeff on the phone, a boss frame: "we need
 * better handling of the enemy cards") — the phone's foe plates, three defects measured on his
 * frame (430×720 page, UI SCALE 0.75):
 *  1. THE BOSS PLATE WAS UNDER THE BOSS. A boss stands deepest and tallest, so its plate (the one
 *     carrying THE STAGGER) was clamped down off the top edge onto the boss's own body, which then
 *     painted over it. It now docks as THE BOSS BANNER, full width across the top of THE WORLD
 *     (board.js moves the same `.ms-plate` into `.ms-boss-banner`; the banner wears the card's
 *     state classes, so the cursor ring / BROKEN / raging rules still find it).
 *  2. THE PLATES WERE UNDER THE BODIES AND THE LOG. A3's plate-on-the-feet was covered by every
 *     nearer body and sank under THE TICKER. The plates stand above the head again, paint over
 *     their own body (z-index inside the card), and board.js `_clampPlatesToWorld` keeps them
 *     between the banner and the ticker and steps a colliding plate past its neighbour.
 *  3. THE NUMERAL WAS 5 PX. Every plate size was rem/px under the UI SCALE's zoom, then ×0.72 by
 *     THE PLATE FOLLOWS THE BODY: the 0.61rem numeral rendered ≈5.3 px on his phone. The phone's
 *     plate text is now a RENDERED-px floor — divided back by the measured `--ms-zoom` (board.js
 *     `_fitZoom`) and by the plate's own `--ms-plate-scale` — so the bar still shrinks with
 *     distance but the number you read never does. */
.ms-stage:not(.ms-desktop) .ms-world .ms-world-figure .ms-plate { z-index: 6; }
.ms-stage:not(.ms-desktop) .ms-world .ms-world-figure.ms-foe:not(.ms-cursor):not(.ms-foe-boss) .ms-hp-numeral {
  height: calc(11px / var(--ms-zoom, 1) / var(--ms-plate-scale, 1));
  border: 1px solid rgba(6,8,12,0.9); background: rgba(0,0,0,0.7);
}
.ms-stage:not(.ms-desktop) .ms-world .ms-world-figure.ms-foe:not(.ms-foe-boss) .ms-hp-nums {
  font-size: calc(9px / var(--ms-zoom, 1) / var(--ms-plate-scale, 1)); font-weight: 700;
  color: #fff; letter-spacing: 0.02em;
  text-shadow: 0 0 2px #000, 0 1px 1px #000;
}
.ms-stage:not(.ms-desktop) .ms-world .ms-world-figure.ms-cursor .ms-hp-numeral { height: calc(12px / var(--ms-zoom, 1)); }
.ms-stage:not(.ms-desktop) .ms-world .ms-world-figure.ms-cursor .ms-hp-nums { font-size: calc(10px / var(--ms-zoom, 1)); }
.ms-stage:not(.ms-desktop) .ms-world .ms-world-figure.ms-foe .ms-foe-name { font-size: calc(9px / var(--ms-zoom, 1)); font-weight: 700; }
.ms-stage:not(.ms-desktop) .ms-world .ms-world-figure.ms-foe .ms-telegraph { font-size: calc(8.5px / var(--ms-zoom, 1)); }
.ms-stage:not(.ms-desktop) .ms-world .ms-world-figure.ms-foe:not(.ms-cursor):not(.ms-foe-boss) .ms-plate {
  /* The numeral is an overlay on the bar (`.ms-hp-nums`, inset 0), so it never sizes its plate:
     the floor must hold the widest plain numeral, "1976/1976" at the 9 px floor ≈ 56 px. */
  width: max-content;   /* an absolute plate shrink-wraps to HALF its card (left:50%) */
  min-width: calc(60px / var(--ms-zoom, 1) / var(--ms-plate-scale, 1));
  max-width: calc(76px / var(--ms-zoom, 1) / var(--ms-plate-scale, 1));
}
.ms-stage:not(.ms-desktop) .ms-world .ms-world-figure.ms-cursor:not(.ms-foe-boss) .ms-plate {
  min-width: calc(64px / var(--ms-zoom, 1)); max-width: calc(132px / var(--ms-zoom, 1));
}
.ms-stage:not(.ms-desktop) .ms-world .ms-world-figure.ms-foe .ms-primes .ms-dot {
  width: calc(8px / var(--ms-zoom, 1) / var(--ms-plate-scale, 1));
  height: calc(8px / var(--ms-zoom, 1) / var(--ms-plate-scale, 1)); min-width: 0;
}
/* THE PRIME SIGIL on the phone: a loaded bubble a size up so its glyph holds (11 px at the
   plate's own scale); the empty slot keeps the 8 px ring. The gap scales with them, so the
   no-touch margin (above) holds at every zoom. */
.ms-stage:not(.ms-desktop) .ms-world .ms-world-figure.ms-foe .ms-primes {
  gap: calc(5px / var(--ms-zoom, 1) / var(--ms-plate-scale, 1));
}
.ms-stage:not(.ms-desktop) .ms-world .ms-world-figure.ms-foe .ms-primes .ms-dot:not(.ms-dot-empty) {
  width: calc(11px / var(--ms-zoom, 1) / var(--ms-plate-scale, 1));
  height: calc(11px / var(--ms-zoom, 1) / var(--ms-plate-scale, 1));
}

/* THE BOSS BANNER — the docked boss plate. Sits over the sky strip; the boss's head may stand
 * behind it (the banner is translucent). A tap targets the boss, like tapping its body.
 * THE SLIM BANNER (2026-10-10, Jeff on the phone, a veteran boss frame: "fix the boss screen,
 * its health covers everything"). The 10-04 banner stacked FOUR rows — name, HP, primes, a
 * labelled STAGGER — into a ~55 px slab (rendered, 430×741 @ UI SCALE 0.75) that hid the boss's
 * whole upper body and shoved every other plate down onto the heads. It is ONE strip now, three
 * columns × two thin rows: the name (+ the telegraph under it) | the HP bar with THE STAGGER
 * fused under it | the primes. Same DOM, same state classes — layout only. ~30 px rendered. */
.ms-boss-banner {
  position: absolute; left: 6px; right: 6px; top: 4px; z-index: 3;
  cursor: pointer; pointer-events: auto;
}
.ms-boss-banner[hidden] { display: none; }
.ms-boss-banner .ms-plate {
  position: relative; display: grid; grid-template-columns: auto 1fr auto;
  grid-template-rows: auto auto; align-items: center;
  column-gap: 6px; row-gap: 2px; padding: 3px 6px; min-width: 0; max-width: none; width: auto;
  background: linear-gradient(180deg, rgba(10,8,6,0.82), rgba(10,8,6,0.62));
  border: 1px solid color-mix(in srgb, var(--ms-accent, #e89020) 70%, transparent);
  box-shadow: 0 2px 8px rgba(0,0,0,0.45);
}
/* col 1 — the name, the telegraph stacked under it (a boss about to act says so, in place). */
.ms-boss-banner .ms-plate-name-row {
  grid-column: 1; grid-row: 1 / span 2; min-width: 0;
  max-width: calc(132px / var(--ms-zoom, 1));
  display: flex; flex-direction: column; align-items: flex-start; justify-content: center; gap: 1px;
}
.ms-boss-banner .ms-foe-name {
  font-size: calc(10px / var(--ms-zoom, 1)); font-weight: 800; letter-spacing: 0.06em; line-height: 1.05;
  color: var(--ms-gold); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; max-width: 100%;
}
.ms-boss-banner .ms-telegraph {
  font-size: calc(8px / var(--ms-zoom, 1)); line-height: 1.1; max-width: 100%;
  white-space: normal; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
}
/* col 2 — the HP bar, the numeral read inside it; THE STAGGER is a thin bar fused under it. */
.ms-boss-banner .ms-hp-numeral {
  grid-column: 2; grid-row: 1; height: calc(12px / var(--ms-zoom, 1)); margin: 0;
  background: rgba(0,0,0,0.7); border: 1px solid rgba(6,8,12,0.9);
}
.ms-boss-banner .ms-hp-nums {
  font-size: calc(9.5px / var(--ms-zoom, 1)); font-weight: 800; color: #fff;
  text-shadow: 0 0 2px #000, 0 1px 1px #000;
}
.ms-boss-banner .ms-stagger-bar {
  grid-column: 2; grid-row: 2; height: calc(9px / var(--ms-zoom, 1)); margin-top: 0;
  background: rgba(0,0,0,0.6);
}
/* THE STAGGER reads inside its own bar, like the HP numeral. */
.ms-boss-banner .ms-stagger-label {
  position: absolute; inset: 0; z-index: 1; display: flex; align-items: center; justify-content: center;
  font-size: calc(7.5px / var(--ms-zoom, 1)); letter-spacing: 0.1em;
  font-weight: 700; color: #fff; text-shadow: 0 0 2px #000, 0 1px 1px #000; line-height: 1;
}
/* col 3 — the primes (+ the composition word), spanning both rows. */
.ms-boss-banner .ms-primes {
  grid-column: 3; grid-row: 1 / span 2; gap: calc(4px / var(--ms-zoom, 1)); margin: 0;
  min-width: calc(22px / var(--ms-zoom, 1)); justify-content: flex-end;
}
.ms-boss-banner .ms-primes .ms-dot { width: calc(9px / var(--ms-zoom, 1)); height: calc(9px / var(--ms-zoom, 1)); min-width: 0; }
.ms-boss-banner .ms-primes .ms-dot:not(.ms-dot-empty) { width: calc(12px / var(--ms-zoom, 1)); height: calc(12px / var(--ms-zoom, 1)); }
.ms-boss-banner .ms-comp { font-size: calc(8px / var(--ms-zoom, 1)); }
/* A shielded boss: the tether tag rides a third, tiny row under the bars. */
.ms-boss-banner .ms-tether-tag { grid-column: 1 / -1; grid-row: 3; font-size: calc(7.5px / var(--ms-zoom, 1)); line-height: 1; letter-spacing: 0.12em; color: var(--ms-kinetic); }
.ms-boss-banner.ms-cursor .ms-plate { box-shadow: 0 0 0 2px var(--ms-gold), 0 0 16px rgba(255,215,106,0.45); }
.ms-boss-banner.ms-foe-broken .ms-plate { border-color: var(--ms-burn); box-shadow: 0 0 10px rgba(255,138,64,0.35); }
.ms-boss-banner.ms-foe-raging .ms-plate { border-color: #ff4d3d; box-shadow: 0 0 12px rgba(255,77,61,0.55); }
.ms-boss-banner.ms-foe-dying { opacity: 0; transition: opacity 0.5s ease; }
/* The FORECAST badge steps under the banner (board.js writes `--ms-banner-h`). */
.ms-boss-banner:not([hidden]) ~ .ms-dryrun-toggle { top: calc(var(--ms-banner-h, 40px) + 4px); }
/* A plain plate carries a telegraph only while that body is about to act — it wraps inside the
   plate's own width rather than running across the neighbour's plate. */
.ms-stage:not(.ms-desktop) .ms-world .ms-world-figure.ms-foe:not(.ms-foe-boss) .ms-plate-name-row { flex-wrap: wrap; justify-content: center; row-gap: 0; }
.ms-stage:not(.ms-desktop) .ms-world .ms-world-figure.ms-foe:not(.ms-foe-boss) .ms-telegraph {
  white-space: normal; text-align: center; line-height: 1.1; max-width: 100%;
}
.ms-stage:not(.ms-desktop) .ms-world .ms-world-figure.ms-foe.ms-foe-telegraphing:not(.ms-cursor):not(.ms-foe-boss) .ms-plate {
  max-width: calc(112px / var(--ms-zoom, 1) / var(--ms-plate-scale, 1));
}
/* The forecast chip under the banner reads on the left, under the bar it forecasts. */
.ms-boss-banner .ms-preview-chip { left: 8px; transform: none; }

/* ── THE LOG STEPS BACK (2026-10-04, Jeff: "get the log out of the way to show the party sometimes
 * … when you touch it or scroll you can read it") — config.measure.stage.PHONE.TICKER. Folded, THE
 * TICKER is one line tall with no backdrop and sits at low ink over the party's backs; a fresh line
 * reads at full ink for FRESH_MS (`.ms-ticker-fresh`), then fades back. A touch / scroll opens the
 * whole window with its backdrop (`.ms-ticker-open`, board.js `_openTicker`) until IDLE_MS pass. */
.ms-stage:not(.ms-desktop) .ms-ticker.ms-ticker-folds {
  transition: max-height 220ms ease, background-color 220ms ease, opacity 400ms ease;
}
.ms-stage:not(.ms-desktop) .ms-ticker.ms-ticker-folds:not(.ms-ticker-open) {
  max-height: calc(0.7rem * 1.35 + 8px); overflow: hidden; padding-top: 4px;
  background: linear-gradient(0deg, rgba(6,8,12,0.55) 0%, transparent 100%);
  opacity: 0.5;
}
.ms-stage:not(.ms-desktop) .ms-ticker.ms-ticker-folds.ms-ticker-fresh:not(.ms-ticker-open) { opacity: 1; transition-duration: 120ms; }
.ms-stage:not(.ms-desktop) .ms-ticker.ms-ticker-folds:not(.ms-ticker-open) .ms-log-line:not(:last-child) { visibility: hidden; }
.ms-stage:not(.ms-desktop) .ms-ticker.ms-ticker-folds:not(.ms-ticker-open) .ms-log-line:last-child {
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.ms-stage:not(.ms-desktop) .ms-ticker.ms-ticker-folds:not(.ms-ticker-open) .ms-log-sub { display: none; }
.ms-stage:not(.ms-desktop) .ms-ticker.ms-ticker-open { opacity: 1; z-index: 7; }

/* ═══ THE GLANCE — PREVIEW (docs/measure-glance-spec.md; board.js `_glance`, `?glance=1`) ═══
   Everything below is scoped to `.ms-glance`, which only exists when the preview flag is on. */
.ms-glance .ms-cast-glance { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1px; }
.ms-glance .ms-cast-verb { font-size: 0.48rem; letter-spacing: 0.18em; opacity: 0.75; font-weight: 700; }
.ms-glance .ms-cast-name { font-size: 0.78rem; letter-spacing: 0.04em; font-weight: 800; line-height: 1.05; text-align: center; }
.ms-glance .ms-glance-head .msg-head-line { display: flex; flex-wrap: wrap; align-items: center; gap: 2px 5px; }
.ms-glance .ms-glance-head .msg-word { font-size: 0.92rem; letter-spacing: 0.04em; }
.ms-glance .msg-rx { font-size: 0.66rem; font-weight: 700; letter-spacing: 0.06em; color: var(--ms-ink); }
.ms-glance .msg-chip-pos { font-size: 0.5rem; letter-spacing: 0.06em; padding: 1px 4px; border: 1px solid rgba(127,214,127,0.55); color: #8fe08f; border-radius: 2px; }
.ms-glance .ms-glance-head .ms-preview-dots { display: inline-flex; gap: 2px; }
.ms-glance .ms-glance-head .ms-dot { width: 9px; height: 9px; min-width: 0; animation: none; }
.ms-glance .ms-aim-info.ms-glance-card { display: flex; flex-direction: column; gap: 3px; padding: 5px 9px; font-size: 0.56rem; }
.ms-glance .msg-perk { border: 1px solid rgba(244,239,228,0.18); border-radius: 3px; padding: 2px 6px; display: flex; flex-direction: column; gap: 1px; }
.ms-glance .msg-perk-live { border-color: rgba(255,215,106,0.55); background: rgba(255,215,106,0.07); }
.ms-glance .msg-perk-top { display: flex; align-items: center; justify-content: space-between; gap: 6px; }
.ms-glance .msg-perk-name { font-size: 0.72rem; font-weight: 800; letter-spacing: 0.08em; }
.ms-glance .msg-perk-live .msg-perk-name { color: var(--ms-gold); }
.ms-glance .msg-perk-text { color: rgba(244,239,228,0.85); }
.ms-glance .msg-ladder { display: inline-flex; gap: 2px; }
.ms-glance .msg-step { font-size: 0.46rem; padding: 0 3px; border: 1px solid rgba(244,239,228,0.2); border-radius: 2px; color: rgba(244,239,228,0.45); }
.ms-glance .msg-step-done { color: rgba(244,239,228,0.75); border-color: rgba(255,215,106,0.35); }
.ms-glance .msg-step-on { background: var(--ms-gold); color: var(--ms-ink-on-gold); border-color: var(--ms-gold); font-weight: 800; }
.ms-glance .msg-blast { display: grid; grid-template-columns: max-content minmax(0, 1fr) max-content; gap: 2px 7px; align-items: center; }
.ms-glance .msg-blast-label { grid-column: 1 / -1; font-size: 0.46rem; letter-spacing: 0.14em; color: rgba(244,239,228,0.5); }
.ms-glance .msg-k { font-size: 0.46rem; letter-spacing: 0.12em; color: rgba(255,215,106,0.62); font-weight: 700; }
.ms-glance .msg-v { font-weight: 700; font-variant-numeric: tabular-nums; text-align: right; }
.ms-glance .msg-bar { height: 5px; background: rgba(255,255,255,0.08); border-radius: 2px; overflow: hidden; }
.ms-glance .msg-bar-fill { height: 100%; border-radius: 2px; }
.ms-glance .msg-bar-t .msg-bar-fill { background: var(--ms-ink); }
.ms-glance .msg-bar-s .msg-bar-fill { background: var(--ms-gold); }
.ms-glance .msg-eff { grid-column: 2 / 4; color: rgba(244,239,228,0.85); }
.ms-glance .msg-none { color: rgba(244,239,228,0.4); }
.ms-glance .msg-warn { display: flex; gap: 5px; align-items: center; color: var(--ms-shock, #8fd6ff); }
.ms-glance .msg-row { display: flex; gap: 5px; align-items: center; color: rgba(244,239,228,0.85); }
.ms-glance .msg-charge { align-self: flex-start; display: inline-flex; align-items: center; gap: 5px; padding: 1px 6px; border: 1px solid rgba(244,239,228,0.22); border-radius: 3px; letter-spacing: 0.06em; }
.ms-glance .msg-charge-ready { border-color: rgba(255,215,106,0.6); color: var(--ms-gold); }
.ms-glance .msg-pips { display: inline-flex; gap: 2px; }
.ms-glance .msg-pip { width: 6px; height: 6px; border-radius: 50%; border: 1px solid rgba(244,239,228,0.55); }
.ms-glance .msg-pip-on { background: currentColor; border-color: currentColor; }
.ms-glance .msg-fate { display: grid; grid-template-columns: auto auto minmax(0, 1fr) auto; gap: 5px; align-items: center; }
.ms-glance .msg-fate-amt { color: #e8c66a; font-weight: 800; letter-spacing: 0.06em; }
.ms-glance .msg-fbar { height: 4px; display: flex; background: rgba(255,255,255,0.08); border-radius: 2px; overflow: hidden; }
.ms-glance .msg-fbar-now { background: rgba(232,198,106,0.5); }
.ms-glance .msg-fbar-gain { background: #e8c66a; box-shadow: 0 0 5px #e8c66a; }
.ms-glance .msg-fate-of { color: rgba(244,239,228,0.5); font-variant-numeric: tabular-nums; }
.ms-glance .msg-sub-ico { width: 10px; height: 10px; vertical-align: -1px; margin-right: 3px; }

/* THE FULL PRIME pill (plate only): the two slots merge; FOCUS pulses, a MIX moves like its status.
   THE SIGIL STAYS ON TOP (2026-10-10 — "the effect behind the icons" must never out-shout them):
   each sigil keeps its OWN flat element disc inside the pill, so its comic ink always sits on its
   own hue, whatever the effect behind is doing. The effect lives in the pill's FRAME — the 2px
   rim and the gap between the discs — never under the glyph. (The first build stripped the discs
   and asked for a white sigil, but the icon's inline `color` outranks a stylesheet, so the ink
   stayed navy on the dark mixes: cascade's hatch and plasma's dark arcs swallowed it.) */
.ms-glance .ms-pill { position: relative; display: inline-flex; align-items: center; gap: 2px; padding: 2px 3px; border-radius: 999px; overflow: hidden; isolation: isolate; }
.ms-glance .ms-pill .ms-dot {
  background: radial-gradient(circle at 42% 38%, color-mix(in srgb, var(--ms-dot-hue) 55%, #fff) 0%, var(--ms-dot-hue) 72%) !important;
  border-color: transparent !important; box-shadow: 0 0 0 1px rgba(10,14,42,0.65) !important;
  animation: none !important; transform: none !important; filter: none !important; position: relative; z-index: 2;
}
.ms-glance .ms-pill .ms-dot-sigil { opacity: 1; }
.ms-glance .ms-pill-focus { animation: msg-pulse 2.6s ease-in-out infinite; }
.ms-glance .ms-pill-burn { background: radial-gradient(circle, #ff8a4f, #b8401a); --msg-glow: var(--ms-burn); }
.ms-glance .ms-pill-shock { background: radial-gradient(circle, #8fd8ff, #1f6f95); --msg-glow: var(--ms-shock); }
.ms-glance .ms-pill-rupture { background: radial-gradient(circle, #d98af7, #6a2a8a); --msg-glow: var(--ms-rupture); }
@keyframes msg-pulse { 0%, 100% { box-shadow: 0 0 5px var(--msg-glow); } 50% { box-shadow: 0 0 13px var(--msg-glow); filter: brightness(1.2); } }
.ms-glance .ms-pill-mix::before { content: ""; position: absolute; inset: -14px; z-index: 0; }
.ms-glance .ms-pill-plasma::before { background: conic-gradient(var(--ms-burn), #7a2a10, var(--ms-shock), #164a66, var(--ms-burn)); animation: msg-swirl 7s linear infinite; }
.ms-glance .ms-pill-plasma::after { content: ""; position: absolute; inset: 0; z-index: 1; background: linear-gradient(100deg, transparent 40%, rgba(255,255,255,0.7) 47%, transparent 54%); animation: msg-arc 3.2s steps(1) infinite; }
.ms-glance .ms-pill-cascade::before { background: repeating-linear-gradient(60deg, rgba(255,255,255,0.28) 0 1px, transparent 1px 5px), repeating-linear-gradient(-60deg, rgba(255,255,255,0.28) 0 1px, transparent 1px 5px), linear-gradient(90deg, #2a7aa0, #7a3aa0); animation: msg-cage 4.5s ease-in-out infinite; }
.ms-glance .ms-pill-meltdown::before { background: linear-gradient(180deg, var(--ms-burn), #7a2a5a, var(--ms-rupture), var(--ms-burn)); background-size: 100% 300%; animation: msg-melt 5.5s linear infinite; }
.ms-glance .ms-pill-singularity::before { background: conic-gradient(var(--ms-burn), var(--ms-shock), var(--ms-rupture), var(--ms-burn)); animation: msg-swirl 9s linear infinite; }
@keyframes msg-swirl { to { transform: rotate(360deg); } }
@keyframes msg-arc { 0%, 86% { opacity: 0; } 88% { opacity: 1; } 91% { opacity: 0.2; } 93% { opacity: 1; } 96%, 100% { opacity: 0; } }
@keyframes msg-cage { 0%, 100% { opacity: 0.6; transform: scale(1.1); } 50% { opacity: 1; transform: scale(1); } }
@keyframes msg-melt { to { background-position: 0 100%; } }
@media (prefers-reduced-motion: reduce) { .ms-glance .ms-pill, .ms-glance .ms-pill::before, .ms-glance .ms-pill::after { animation: none !important; } }
.ms-glance .ms-aim-info.ms-glance-card { align-items: stretch; }
.ms-glance .msg-head-warn { font-size: 0.52rem; letter-spacing: 0.05em; margin-top: 3px; }
.ms-glance .msg-head-warn .ms-aim-ico { width: 11px; height: 11px; }
.ms-glance .ms-dryrun-toggle { display: none; }   /* §7 Q4 — the forecast chips are always on */

/* ── THE GLANCE §0e (the UX review pass) ───────────────────────────────────────────── */
/* G5 — three fixed lines: eyebrow · sigil + BIG · the small line */
.ms-glance .ms-glance-head { justify-content: center; gap: 2px; }
.ms-glance .msg-eyebrow { font-size: 0.5rem; letter-spacing: 0.16em; color: rgba(255,215,106,0.7); font-weight: 700; }
.ms-glance .msg-head-line { gap: 6px !important; }
.ms-glance .msg-head-ico { width: 18px !important; height: 18px !important; }
.ms-glance .ms-glance-head .msg-word { font-size: 1.3rem !important; letter-spacing: 0.03em; line-height: 1; }
.ms-glance .ms-glance-head .msg-num { font-size: 1.55rem !important; }
.ms-glance .msg-head-sub { font-size: 0.6rem; letter-spacing: 0.06em; color: rgba(244,239,228,0.75); }
.ms-glance .msg-head-warn { font-size: 0.55rem; }
/* G2 — the bar is an HP track; a lethal chunk turns red and says KILL; primers get a ×1 tick */
.ms-glance .msg-bar { position: relative; height: 7px; overflow: visible; }
.ms-glance .msg-bar-fill { border-radius: 2px; }
.ms-glance .msg-blast-abs .msg-bar { background: rgba(184,51,51,0.28); border: 1px solid rgba(255,255,255,0.12); }
.ms-glance .msg-blast-abs .msg-bar-t .msg-bar-fill, .ms-glance .msg-blast-abs .msg-bar .msg-bar-fill { background: var(--ms-gold); }
.ms-glance .msg-bar-kill .msg-bar-fill { background: #ff5a4a !important; box-shadow: 0 0 6px rgba(255,90,74,0.7); }
.ms-glance .msg-kill { position: absolute; right: 2px; top: -1px; font-size: 0.42rem; font-weight: 800; letter-spacing: 0.1em; color: #fff; line-height: 9px; text-shadow: 0 0 3px #000; }
.ms-glance .msg-blast-mult .msg-bar-fill { background: var(--ms-ink); }
.ms-glance .msg-blast-mult .msg-bar + .msg-v + .msg-k + .msg-bar .msg-bar-fill { background: var(--ms-gold); }
.ms-glance .msg-bar-tick { position: absolute; top: -2px; bottom: -2px; width: 1px; background: rgba(244,239,228,0.6); }
.ms-glance .msg-blast { gap: 3px 8px; }
.ms-glance .msg-blast .msg-v { font-size: 0.66rem; }
/* G6 — a ghost blast: what it WOULD pay */
.ms-glance .msg-blast-ghost { opacity: 0.45; }
.ms-glance .msg-blast-ghost .msg-bar { border-style: dashed; }
.ms-glance .msg-blast-label { font-size: 0.46rem; letter-spacing: 0.14em; color: rgba(244,239,228,0.6); grid-column: 1 / -1; }
/* G4 — cooldown · charge · Fate in one strip pinned to the bottom */
.ms-glance .ms-aim-info.ms-glance-card { justify-content: flex-start; }
.ms-glance .msg-strip { margin-top: auto; display: flex; align-items: center; gap: 10px; min-height: 18px; font-size: 0.56rem; letter-spacing: 0.05em; }
.ms-glance .msg-strip-cd { display: inline-flex; align-items: center; gap: 4px; color: rgba(244,239,228,0.8); white-space: nowrap; }
.ms-glance .msg-strip-cd .ms-aim-ico { width: 11px; height: 11px; }
.ms-glance .msg-strip .msg-charge { padding: 0 5px; }
.ms-glance .msg-strip .msg-fate { flex: 1 1 auto; min-width: 0; display: grid; grid-template-columns: auto auto minmax(30px, 1fr) auto; gap: 5px; align-items: center; }
/* G8 — the perk box: name left, ladder right */
.ms-glance .msg-perk-name { font-size: 0.8rem; }
.ms-glance .msg-step { font-size: 0.52rem; padding: 0 4px; }
/* G3 — the bar's confirm names the target */
.ms-glance .msg-enter { display: flex; flex-direction: column; align-items: center; justify-content: center; }
.ms-glance .msg-enter .ms-bar-sub { font-size: 0.55rem; opacity: 0.75; }
/* G9 — a party member the rider lands on: a gold top edge, no chip */
.ms-glance .msg-buffed { box-shadow: inset 0 3px 0 var(--ms-gold) !important; }
.ms-glance .msg-card-warn { font-size: 0.58rem; letter-spacing: 0.05em; }
.ms-glance .msg-card-warn .ms-aim-ico { width: 12px; height: 12px; }
.ms-glance .msg-bar-none { height: 1px; border-top: 1px dashed rgba(244,239,228,0.18); }
/* THE SHEET (measure-glance-spec §0g) — the card is ONE grid: a fixed label column, a bar/pips
   column, a right-aligned value column. Rows spread to fill the card (space-evenly), so a short
   card and a tall card both read full, and the eye scans one column of labels and one of values. */
.ms-glance .ms-aim-info.ms-glance-card { display: flex; flex-direction: column; padding: 4px 10px; }
.ms-glance .msg-sheet { flex: 1 1 auto; min-height: 0; display: grid; grid-template-columns: 62px minmax(0, 1fr) minmax(44px, max-content); column-gap: 9px; row-gap: 2px; align-items: center; align-content: space-evenly; }
.ms-glance .msg-sheet > .msg-full { grid-column: 1 / -1; }
.ms-glance .msg-sheet .msg-k { font-size: 0.5rem; letter-spacing: 0.12em; color: rgba(255,215,106,0.7); font-weight: 700; white-space: nowrap; }
.ms-glance .msg-sheet .msg-v { font-size: 0.72rem; font-weight: 800; text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }
.ms-glance .msg-sheet .msg-v.msg-none { font-size: 0.56rem; font-weight: 700; }
.ms-glance .msg-sheet .msg-eff { grid-column: 2 / 4; font-size: 0.6rem; letter-spacing: 0.04em; color: rgba(244,239,228,0.9); line-height: 1.2; }
.ms-glance .msg-sheet .msg-ghost { opacity: 0.45; }
.ms-glance .msg-sheet .msg-bar.msg-ghost { border-style: dashed; }
.ms-glance .msg-sheet .msg-blast-label { font-size: 0.48rem; letter-spacing: 0.14em; color: rgba(244,239,228,0.65); }
.ms-glance .msg-sheet .msg-perk { margin-bottom: 1px; }
.ms-glance .msg-sheet .msg-card-warn { font-size: 0.6rem; }
.ms-glance .msg-mid { display: flex; align-items: center; gap: 6px; }
.ms-glance .msg-mid .ms-aim-ico { width: 12px; height: 12px; }
.ms-glance .msg-pips-row { display: flex; align-items: center; gap: 3px; }
.ms-glance .msg-pips-row .msg-pip { width: 8px; height: 8px; border-radius: 50%; border: 1px solid rgba(244,239,228,0.55); }
.ms-glance .msg-pips-row .msg-pip-on { background: rgba(244,239,228,0.85); border-color: rgba(244,239,228,0.85); }
.ms-glance .msg-pips-charge .msg-pip-on { background: var(--ms-gold); border-color: var(--ms-gold); }
.ms-glance .msg-pips-cd .msg-pip { border-radius: 1px; width: 10px; height: 6px; }
.ms-glance .msg-v-gold { color: var(--ms-gold); }
.ms-glance .msg-sheet .msg-fbar { height: 6px; }
.ms-glance .msg-k-fate, .ms-glance .msg-v-fate { color: #e8c66a !important; }
.ms-glance .msg-v-fate .msg-fate-of { font-size: 0.52rem; font-weight: 600; color: rgba(244,239,228,0.5); }
