/* measure/fx.css — THE IMPACT LAYER (measure/fx.js). Three effects sharing one mounted root:
   THE ULTIMATE (full-stage, class-coloured), THE DETONATION (originates at the struck body,
   scales with what happened) and THE PERFORMANCE (the ability itself as a wind-up/payload/
   landing, its shape from the verb and its colour from the element — see its own section at
   the bottom of this file). All `pointer-events:none` end to end — nothing in this file is
   ever a click target, matching every other measure/*.css overlay.

   Colour law: every colour below reads `--ms-burn`/`--ms-shock`/`--ms-rupture`/`--ms-kinetic`
   /`--ms-gold` off `.ms-overlay` (measure.css) via inheritance, or `--ms-fx-*-accent`/`-color`
   custom properties fx.js sets per-play — this file defines NO new colour of its own.

   Animated properties throughout: `transform`, `opacity`, `filter` ONLY — never
   `left`/`top`/`width`/`height`/`box-shadow` (those are set once, synchronously, by fx.js at
   spawn time; see its own header). `.ms-fx-shake` is the one rule meant to be applied to an
   EXTERNAL element (`.ms-world`, never a `.ms-world-figure` — those already carry their own
   `translate(-50%,-100%) scale(...)`, and shaking the shared WORLD container composes with
   that instead of fighting it), so it is written to stand alone rather than nested under
   `.ms-fx`. */

.ms-fx {
  position: absolute; inset: 0;
  /* Above THE WORLD (z 0-1) and THE CUT-IN (z 3); below `.ms-pause` (z 5, measure.css) — the
     pause sheet must always win. */
  z-index: 4;
  pointer-events: none;
  overflow: hidden;
}

/* ── THE ULTIMATE ─────────────────────────────────────────────────────────────
   One fixed node (fx.js never spawns a second — Ultimates queue, they never overlap
   themselves). Three layers: a vignette that collapses inward (IN), a bright flash + sweeping
   energy bands at the strike (STRIKE, where onReveal fires), then a fade back to the board
   (OUT). */
.ms-fx-ult {
  position: absolute; inset: 0;
  z-index: 2;                 /* above .ms-fx-det-host, so an ultimate's own chained
                                  detonate pops read as INSIDE it, not competing with it */
  opacity: 0;
  mix-blend-mode: normal;
}
.ms-fx-ult.ms-fx-ult-live { opacity: 1; }

.ms-fx-ult-vignette {
  position: absolute; inset: -6%;
  background: radial-gradient(ellipse at center,
    transparent 0%,
    color-mix(in srgb, var(--ms-fx-ult-accent, var(--ms-gold)) 45%, transparent) 62%,
    color-mix(in srgb, var(--ms-fx-ult-accent, var(--ms-gold)) 70%, black 30%) 130%);
  opacity: 0;
  transform: scale(1.7);
  will-change: transform, opacity;
}
.ms-fx-ult-flash {
  position: absolute; inset: 0;
  background: color-mix(in srgb, var(--ms-fx-ult-accent, var(--ms-gold)) 65%, white 35%);
  opacity: 0;
  will-change: opacity;
}
.ms-fx-ult-bands {
  position: absolute; inset: -12% 0;
  background: repeating-linear-gradient(4deg,
    transparent 0 16px,
    color-mix(in srgb, var(--ms-fx-ult-accent, var(--ms-gold)) 55%, transparent) 16px 19px);
  mix-blend-mode: screen;
  opacity: 0;
  transform: translateY(-8%) scaleY(1.35);
  will-change: transform, opacity;
}

.ms-fx-ult-in .ms-fx-ult-vignette {
  animation: msFxUltVignetteIn var(--ms-fx-ult-in-ms, 200ms) cubic-bezier(.2,.7,.3,1) forwards;
}
.ms-fx-ult-strike .ms-fx-ult-flash {
  animation: msFxUltFlash var(--ms-fx-ult-strike-ms, 160ms) ease-out forwards;
}
.ms-fx-ult-strike .ms-fx-ult-bands {
  animation: msFxUltBands var(--ms-fx-ult-strike-ms, 160ms) linear forwards;
}
.ms-fx-ult-out .ms-fx-ult-vignette,
.ms-fx-ult-out .ms-fx-ult-bands,
.ms-fx-ult-out .ms-fx-ult-flash {
  animation: msFxUltOut var(--ms-fx-ult-out-ms, 260ms) ease-in forwards;
}

/* THE SUPER ON THE STAGE (THE LADDER, LOUDER): `--ms-fx-ult-wash` scales the WASH's peak — 1 on
   the board, config.measure.fx.ULT_STAGE_WASH when fx.js is told a 3D stage is live behind the
   sweep (`ms-fx-ult-stage`), so the room's own Super FX stays the show. The motif and the
   caption never read it — they are the caption half and play at full on both renderers. */
@keyframes msFxUltVignetteIn {
  from { opacity: 0; transform: scale(1.7); }
  to   { opacity: var(--ms-fx-ult-wash, 1); transform: scale(1); }
}
@keyframes msFxUltFlash {
  0%   { opacity: 0; }
  25%  { opacity: calc(0.92 * var(--ms-fx-ult-wash, 1)); }
  100% { opacity: calc(0.38 * var(--ms-fx-ult-wash, 1)); }
}
@keyframes msFxUltBands {
  0%   { opacity: 0; transform: translateY(-8%) scaleY(1.35); }
  35%  { opacity: 0.85; }
  100% { opacity: 0; transform: translateY(8%) scaleY(1.0); }
}
@keyframes msFxUltOut {
  from { opacity: 1; }
  to   { opacity: 0; }
}

/* ── THE DETONATION ──────────────────────────────────────────────────────────
   `.ms-fx-det-host` holds a bounded, short-lived set of `.ms-fx-det` nodes (fx.js: create on
   spawn, remove on timeout — no pool; see fx.js's own perf note). Each node is positioned/
   sized ONCE at spawn (left/top/width/height, synchronous) then only its children animate. */
.ms-fx-det-host {
  position: absolute; inset: 0;
  z-index: 1;
}

.ms-fx-det {
  position: absolute;
  transform: translate(-50%, -50%);
}

.ms-fx-det-ring {
  position: absolute; inset: 0;
  border-radius: 50%;
  border: 3px solid var(--ms-fx-color, var(--ms-gold));
  box-shadow: 0 0 22px 2px color-mix(in srgb, var(--ms-fx-color, var(--ms-gold)) 55%, transparent);
  opacity: 0;
  transform: scale(0.12);
  will-change: transform, opacity, filter;
}
.ms-fx-det:not(.ms-fx-det-static) .ms-fx-det-ring {
  animation: msFxDetRing var(--ms-fx-ring-ms, 460ms) cubic-bezier(.16,.84,.28,1) forwards;
}
/* A second, dimmer ring on the bigger tiers only — the "approaching full-screen" read comes
   from an outer bloom trailing the main ring, not from the ring itself growing alone. */
.ms-fx-mag-md .ms-fx-det-ring,
.ms-fx-mag-lg .ms-fx-det-ring {
  box-shadow:
    0 0 22px 2px color-mix(in srgb, var(--ms-fx-color, var(--ms-gold)) 55%, transparent),
    0 0 46px 14px color-mix(in srgb, var(--ms-fx-color, var(--ms-gold)) 30%, transparent);
}

/* THE REACTION SILHOUETTES — reuses the forked-bolt/drill-spike/hex-cage vocabulary
   reaction-fx-uplift-spec.md already established for the FPS mode's own accent shapes, as a
   `clip-path` on the ring rather than a new sprite, so a meltdown never reads as a cascade. A
   plain SINGLE/SAME pop carries no reaction class and stays a plain ring. */
.ms-fx-det-plasma .ms-fx-det-ring {           /* forked bolt */
  clip-path: polygon(50% 0%, 61% 36%, 100% 40%, 66% 60%, 76% 100%,
                      50% 74%, 24% 100%, 34% 60%, 0% 40%, 39% 36%);
}
.ms-fx-det-meltdown .ms-fx-det-ring {          /* drill spike / drip */
  clip-path: polygon(50% 0%, 64% 28%, 57% 28%, 80% 68%, 50% 100%,
                      20% 68%, 43% 28%, 36% 28%);
}
.ms-fx-det-cascade .ms-fx-det-ring {           /* hex cage */
  clip-path: polygon(25% 4%, 75% 4%, 98% 50%, 75% 96%, 25% 96%, 2% 50%);
}

@keyframes msFxDetRing {
  0%   { opacity: 0;                                transform: scale(0.12); filter: brightness(1.7); }
  /* §13 THE LEAN READS (action-language-spec.md §13) — CORE brightness/opacity channel:
     fx.js sets --ms-fx-ring-peak from directMod (0.85 spread / 1.30 clamped-to-1 focused /
     0.95 default when the property is unset — e.g. THE ULTIMATE's own layers never set it
     and never read this keyframe either, so the fallback is decorative, not load-bearing).
     "Focused hits harder in a smaller place" reads here as a brighter peak. */
  16%  { opacity: var(--ms-fx-ring-peak, 0.95);      transform: scale(0.42); }
  100% { opacity: 0;                                transform: scale(1);    filter: brightness(1); }
}

.ms-fx-det-static .ms-fx-det-ring {
  opacity: 0.55;
  transform: scale(0.62);
}

.ms-fx-det-spark {
  position: absolute; left: 50%; top: 50%;
  width: 3px; height: 34%;
  background: linear-gradient(to bottom, var(--ms-fx-color, var(--ms-gold)), transparent);
  transform-origin: 50% 0%;
  transform: translateX(-50%) rotate(var(--ms-fx-spark-rot, 0deg)) scaleY(0);
  opacity: 0;
  will-change: transform, opacity;
}
.ms-fx-det:not(.ms-fx-det-static) .ms-fx-det-spark {
  animation: msFxDetSpark var(--ms-fx-spark-ms, 380ms) ease-out forwards;
}
@keyframes msFxDetSpark {
  0%   { opacity: 0; transform: translateX(-50%) rotate(var(--ms-fx-spark-rot, 0deg)) scaleY(0); }
  22%  { opacity: 1; }
  100% { opacity: 0; transform: translateX(-50%) rotate(var(--ms-fx-spark-rot, 0deg)) scaleY(1); }
}

/* ── THE CORE · THE CHAIN ARC · THE TALLY (THE BLAST LANDS, docs/playtest-r13.md §14) ──
   Three children fx.js adds to the same detonate node (the arc is its own sibling node in
   the det host). Same laws as everything above: positioned once, animated on
   transform/opacity/filter only, coloured by `--ms-fx-color`, no new colour of its own. */
.ms-fx-det-core {                 /* the filled flash — the pop is an explosion, not an outline */
  position: absolute; inset: 12%;
  border-radius: 50%;
  background: radial-gradient(circle at center,
    #fff 0%,
    color-mix(in srgb, var(--ms-fx-color, var(--ms-gold)) 80%, white 20%) 34%,
    color-mix(in srgb, var(--ms-fx-color, var(--ms-gold)) 55%, transparent) 62%,
    transparent 78%);
  opacity: 0;
  transform: scale(0.2);
  will-change: transform, opacity;
}
.ms-fx-det:not(.ms-fx-det-static) .ms-fx-det-core {
  animation: msFxDetCore var(--ms-fx-core-ms, 200ms) cubic-bezier(.1,.9,.3,1) forwards;
}
.ms-fx-det-static .ms-fx-det-core { opacity: 0.4; transform: scale(0.5); }
@keyframes msFxDetCore {
  0%   { opacity: 0;    transform: scale(0.2);  filter: brightness(2); }
  25%  { opacity: 1;    transform: scale(0.72); }
  100% { opacity: 0;    transform: scale(1.05); filter: brightness(1); }
}

.ms-fx-det-arc {                  /* the link between two hops — anchored at the previous body */
  position: absolute;
  height: 5px; min-width: 12px;
  translate: 0 -50%;              /* centred on its own start point, never `transform` */
  transform-origin: 0% 50%;
  transform: rotate(var(--ms-fx-arc-angle, 0deg)) scaleX(0.02);
  border-radius: 3px;
  background: linear-gradient(90deg,
    transparent 0%,
    color-mix(in srgb, var(--ms-fx-color, var(--ms-gold)) 70%, transparent) 30%,
    var(--ms-fx-color, var(--ms-gold)) 78%,
    #fff 100%);
  filter: drop-shadow(0 0 7px var(--ms-fx-color, var(--ms-gold)));
  opacity: 0;
  will-change: transform, opacity;
  animation: msFxDetArc var(--ms-fx-arc-ms, 170ms) cubic-bezier(.2,.75,.3,1) forwards;
}
/* A plasma link forks (thicker, brighter), a meltdown link drips heavy, a cascade link is
   thin and hard — the same three silhouettes the ring's clip-paths speak, at line scale. */
.ms-fx-det-arc-plasma   { height: 7px; }
.ms-fx-det-arc-meltdown { height: 8px; border-radius: 6px 2px 2px 6px; }
.ms-fx-det-arc-cascade  { height: 3px; }
@keyframes msFxDetArc {
  0%   { opacity: 0;   transform: rotate(var(--ms-fx-arc-angle, 0deg)) scaleX(0.02); }
  18%  { opacity: 1; }
  70%  { opacity: 1;   transform: rotate(var(--ms-fx-arc-angle, 0deg)) scaleX(1); }
  100% { opacity: 0;   transform: rotate(var(--ms-fx-arc-angle, 0deg)) scaleX(1); }
}

.ms-fx-det-tally {                /* the word at the body: ×2 · ×3 · MIX · PRIMED MIX … */
  position: absolute; left: 50%; top: 50%;
  translate: -50% -50%;
  font-weight: 800; letter-spacing: 0.08em; white-space: nowrap;
  color: #fff;
  text-shadow: 0 0 10px var(--ms-fx-color, var(--ms-gold)), 0 0 3px var(--ms-fx-color, var(--ms-gold)),
               0 1px 0 rgba(0,0,0,0.6);
  opacity: 0;
  will-change: transform, opacity;
  animation: msFxDetTally var(--ms-fx-tally-ms, 900ms) cubic-bezier(.16,.84,.28,1) forwards;
}
.ms-fx-det-tally-chain { font-size: 1.35rem; }
.ms-fx-det-tally-rung  { font-size: 0.72rem; color: var(--ms-gold); }
.ms-fx-mag-lg .ms-fx-det-tally-chain { font-size: 1.7rem; }
@keyframes msFxDetTally {
  0%   { opacity: 0; transform: translateY(6px) scale(0.6); }
  14%  { opacity: 1; transform: translateY(-4px) scale(1.18); }
  30%  { transform: translateY(-8px) scale(1); }
  100% { opacity: 0; transform: translateY(-34px) scale(0.92); }
}
.ms-fx-det-static .ms-fx-det-tally { animation: none; opacity: 1; }

/* ── THE IMPACT FRAME ─────────────────────────────────────────────────────────
   One fixed, reused node (fx.js never spawns a second — see its own `_flashImpact()` note).
   Full-stage, above BOTH the ultimate (z 2) and the detonate ring (z 1) — the hold has to
   cover everything before the ring itself becomes visibly expanding. Deliberately NOT a
   `@keyframes` animation: a "flat hold" is the absence of a fade, so fx.js turns it on/off
   with a plain class toggle (still `opacity` only, per this file's own animated-properties
   law) rather than animating anything. Colour mixes the same `--ms-fx-impact-color` the
   detonate ring already reads (via `_colorFor()`) with white, the identical contrast move
   `.ms-fx-ult-flash` above already makes — no new colour invented. */
.ms-fx-impact {
  position: absolute; inset: 0;
  z-index: 3;
  background: color-mix(in srgb, var(--ms-fx-impact-color, var(--ms-gold)) 70%, white 30%);
  opacity: 0;
  pointer-events: none;
}
.ms-fx-impact-live { opacity: 0.85; }

/* ── THE WIPE LIST ────────────────────────────────────────────────────────────
   One fixed layer (fx.js `wipe()`), full-stage, above the ultimate/impact (a death is the
   loudest fact on the board at that instant) and below THE SUPER CAPTION. The PANEL is a
   `transform`-only sweep in the fallen body's own accent (`--ms-fx-wipe-accent`), left→right
   for the party's dead, right→left (`ms-fx-wipe-reverse`) for the wave's — one device, two
   directions, the same grammar as THE CUT-IN swept the other way. The CAPTION outlives the
   sweep (`--ms-fx-wipe-cap-ms`). `ms-fx-wipe-quiet` is THE FLASH COUNTER GUARD's degraded
   form: no sweep, caption only — and it is what reduced motion always gets. */
.ms-fx-wipe {
  position: absolute; inset: 0;
  z-index: 3;
  opacity: 0;
  pointer-events: none;
  overflow: hidden;
}
.ms-fx-wipe.ms-fx-wipe-live { opacity: 1; }
.ms-fx-wipe-panel {
  position: absolute; inset: 0;
  background: linear-gradient(100deg,
    color-mix(in srgb, var(--ms-fx-wipe-accent, var(--ms-gold)) 86%, black 14%) 0%,
    var(--ms-fx-wipe-accent, var(--ms-gold)) 45%,
    color-mix(in srgb, var(--ms-fx-wipe-accent, var(--ms-gold)) 70%, white 30%) 100%);
  opacity: 0.94;
  transform: translateX(-104%);
  will-change: transform;
}
.ms-fx-wipe-reverse .ms-fx-wipe-panel { transform: translateX(104%); }
.ms-fx-wipe-in .ms-fx-wipe-panel {
  transform: translateX(0);
  transition: transform var(--ms-fx-wipe-in-ms, 240ms) cubic-bezier(.6,0,.4,1);
}
.ms-fx-wipe-hold .ms-fx-wipe-panel { transform: translateX(0); }
.ms-fx-wipe-out .ms-fx-wipe-panel {
  transform: translateX(104%);
  transition: transform var(--ms-fx-wipe-out-ms, 270ms) cubic-bezier(.6,0,.4,1);
}
.ms-fx-wipe-reverse.ms-fx-wipe-out .ms-fx-wipe-panel { transform: translateX(-104%); }
.ms-fx-wipe-quiet .ms-fx-wipe-panel { display: none; }
.ms-fx-wipe-cap {
  position: absolute; left: 50%; top: 42%;
  transform: translate(-50%, -50%) scale(0.9);
  text-align: center; opacity: 0; pointer-events: none;
  padding: 8px 22px;
  background: linear-gradient(90deg, transparent 0%, rgba(6,8,12,0.82) 14%, rgba(6,8,12,0.82) 86%, transparent 100%);
  border-top: 2px solid var(--ms-fx-wipe-accent, var(--ms-gold));
  border-bottom: 2px solid var(--ms-fx-wipe-accent, var(--ms-gold));
  will-change: transform, opacity;
}
.ms-fx-wipe-cap-on .ms-fx-wipe-cap { animation: msFxWipeCapIn 220ms cubic-bezier(.2,.8,.3,1) forwards; }
.ms-fx-wipe-cap-off .ms-fx-wipe-cap { animation: msFxWipeCapOut 260ms ease-in forwards; }
.ms-fx-wipe-name { font-size: 1.6rem; font-weight: 800; letter-spacing: 0.22em;
  color: #fff; text-shadow: 0 0 14px var(--ms-fx-wipe-accent, var(--ms-gold)), 0 2px 3px #000; }
.ms-fx-wipe-tag { margin-top: 2px; font-size: 0.78rem; letter-spacing: 0.3em;
  color: var(--ms-fx-wipe-accent, var(--ms-gold)); }
.ms-fx-wipe-phone .ms-fx-wipe-cap { top: 36%; padding: 6px 14px; }
.ms-fx-wipe-phone .ms-fx-wipe-name { font-size: 1.15rem; }
.ms-fx-wipe-phone .ms-fx-wipe-tag { font-size: 0.62rem; }
@keyframes msFxWipeCapIn {
  from { opacity: 0; transform: translate(-50%, -50%) scale(0.86); }
  to   { opacity: 1; transform: translate(-50%, -50%) scale(1); }
}
@keyframes msFxWipeCapOut {
  from { opacity: 1; transform: translate(-50%, -50%) scale(1); }
  to   { opacity: 0; transform: translate(-50%, -54%) scale(1.02); }
}

/* ── THE ACCENTS ──────────────────────────────────────────────────────────────
   action-language-spec.md §5's three `—` rows at the struck body: CRIT a halftone STARBURST
   (beyond `.ms-foe-crit`'s own white ring), BLOCK an angled BOUNCE-MARK, MISS an OVERSHOOT
   streak past the body. Leaf nodes in `.ms-fx-acc-host`, positioned ONCE by fx.js, animated on
   transform/opacity/filter only, dropped past ACCENT_MAX_CONCURRENT. Colour rides
   `--ms-fx-color` (the element, or white). */
.ms-fx-acc-host { position: absolute; inset: 0; z-index: 1; pointer-events: none; }
.ms-fx-acc {
  position: absolute; pointer-events: none;
  translate: -50% -50%;
  opacity: 0;
  will-change: transform, opacity;
  animation-duration: var(--ms-fx-acc-ms, 160ms);
  animation-timing-function: ease-out;
  animation-fill-mode: forwards;
}
.ms-fx-acc-crit {
  clip-path: polygon(50% 0%, 58% 34%, 86% 14%, 66% 42%, 100% 50%, 66% 58%, 86% 86%, 58% 66%,
                     50% 100%, 42% 66%, 14% 86%, 34% 58%, 0% 50%, 34% 42%, 14% 14%, 42% 34%);
  background:
    radial-gradient(circle, #ffffff 0 18%, transparent 19%),
    repeating-radial-gradient(circle, color-mix(in srgb, var(--ms-fx-color, #fff) 80%, white 20%) 0 2px, transparent 2px 6px);
  filter: drop-shadow(0 0 8px var(--ms-fx-color, #fff));
  animation-name: msFxAccCrit;
}
@keyframes msFxAccCrit {
  0%   { opacity: 0; transform: scale(0.3) rotate(0deg); }
  30%  { opacity: 1; transform: scale(1.05) rotate(10deg); }
  100% { opacity: 0; transform: scale(1.25) rotate(22deg); }
}
.ms-fx-acc-block {
  height: 14% !important;
  border-radius: 3px;
  background: linear-gradient(90deg, transparent 0%, var(--ms-fx-color, #fff) 30%, #ffffff 100%);
  filter: drop-shadow(0 0 6px var(--ms-fx-color, #fff));
  animation-name: msFxAccBlock;
}
@keyframes msFxAccBlock {   /* an angled bounce-mark — the blow arrives, glances off upward */
  0%   { opacity: 0; transform: rotate(-35deg) translateX(-30%) scaleX(0.6); }
  35%  { opacity: 1; transform: rotate(-35deg) translateX(0) scaleX(1); }
  100% { opacity: 0; transform: rotate(-35deg) translate(40%, -60%) scaleX(0.7); }
}
.ms-fx-acc-miss {
  height: 8% !important;
  border-radius: 4px;
  background: linear-gradient(90deg, transparent 0%, var(--ms-fx-color, #fff) 60%, transparent 100%);
  animation-name: msFxAccMiss;
}
@keyframes msFxAccMiss {    /* travel only, overshoots PAST the body */
  0%   { opacity: 0; transform: translateX(-60%) scaleX(0.5); }
  30%  { opacity: 0.9; transform: translateX(0) scaleX(1.2); }
  100% { opacity: 0; transform: translateX(140%) scaleX(0.6); }
}

/* ── THE SHAKE CHANNEL ────────────────────────────────────────────────────────
   Applied by fx.js to a CALLER-SUPPLIED element (board.js should pass `.ms-world`) — this
   rule is intentionally NOT scoped under `.ms-fx` so it can be toggled on that external node.
   `transform` only; `.ms-world` carries no transform of its own (measure.css), so this never
   fights an existing rule, and its own `overflow:hidden` keeps the brief translate from ever
   revealing an edge. */
.ms-fx-shake {
  animation: msFxShake var(--ms-fx-shake-ms, 260ms) ease-in-out;
}
@keyframes msFxShake {
  0%   { transform: translate(0, 0); }
  15%  { transform: translate(calc(var(--ms-fx-shake-amp, 4px) * -1),
                               calc(var(--ms-fx-shake-amp, 4px) * 0.6)); }
  32%  { transform: translate(var(--ms-fx-shake-amp, 4px),
                               calc(var(--ms-fx-shake-amp, 4px) * -0.55)); }
  50%  { transform: translate(calc(var(--ms-fx-shake-amp, 4px) * -0.65),
                               var(--ms-fx-shake-amp, 4px)); }
  70%  { transform: translate(calc(var(--ms-fx-shake-amp, 4px) * 0.4),
                               calc(var(--ms-fx-shake-amp, 4px) * -0.3)); }
  100% { transform: translate(0, 0); }
}


/* ── THE PERFORMANCE ─────────────────────────────────────────────────────────
   (measure/fx.js `perform()`, playtest 2026-09-01: "we should also show abilities as
   animations... if we can play the fate game on iphone we should have cool animations
   available.")

   Three beats, one group node per cast: `.ms-fx-perf-wind` over the CASTER's rect, one
   `.ms-fx-perf-payload` (a shape family from THE COMMON TONGUE's verb tables) travelling or
   blooming between the two, and one `.ms-fx-perf-land` over the target UNION at the impact
   mark. Never one node per target — a row/column/party-wide cast draws ONE landing spanning
   the union (enemy-turn-spec.md E3).

   THE TWO COMPOSITION LAWS THIS SECTION IS BOUND BY:
   1. Centring rides the standalone `translate` property, NEVER `transform` — so the keyframes
      below (which own `transform`) layer on top of the placement instead of erasing it.
      measure/ink.css's `.ms-ink-fx` established this idiom; fx.js's `_place()` is its writer.
   2. Every node here is THIS LAYER'S OWN. Nothing in this file ever writes an `animation` on
      `.ms-world-figure` (measure.css's hit/act tells), `.ms-figure-body` (figures.css's
      ambients) or `.ms-figure-walk` (the travel engine) — the `animation`-shorthand
      single-winner trap CLAUDE.md records means a fourth writer on any of those silently
      erases one of the three that already live there.

   Animated properties, as everywhere else in this file: `transform`/`opacity`/`filter` only.
   `left`/`top`/`width`/`height` are written ONCE by fx.js at spawn. Durations and delays come
   in as `--ms-fx-perf-ms`/`--ms-fx-perf-delay` (the shorthand reads duration first, delay
   second), and every animation is `both`-filled so a delayed node holds its from-state
   (invisible) rather than flashing at full opacity while it waits. */
.ms-fx-perf-host {
  position: absolute; inset: 0;
  z-index: 0;                 /* below the detonate ring (1): the POP a cast causes reads on
                                 top of the cast that caused it */
  pointer-events: none;
}
.ms-fx-perf { position: absolute; inset: 0; pointer-events: none; }

.ms-fx-perf-wind,
.ms-fx-perf-payload,
.ms-fx-perf-mote,
.ms-fx-perf-land {
  position: absolute;
  translate: -50% -50%;       /* law 1 — never `transform` */
  pointer-events: none;
  opacity: 0;
  will-change: transform, opacity;
}

/* 1. THE WIND-UP — a gathered halo collapsing onto the caster: the anticipation the caster's
   own body reads, without ever touching the caster's card. */
.ms-fx-perf-wind {
  border-radius: 50%;
  border: 2px solid var(--ms-fx-perf-color, var(--ms-gold));
  background: radial-gradient(circle at center,
    color-mix(in srgb, var(--ms-fx-perf-color, var(--ms-gold)) 35%, transparent) 0%,
    transparent 68%);
  animation: msFxPerfWind var(--ms-fx-perf-ms, 150ms) var(--ms-fx-perf-delay, 0ms) cubic-bezier(.25,.75,.25,1) both;
}
/* A planted verb gathers DOWNWARD into a flat plate rather than a halo — the same "this is
   going in the ground" read `groundRing` carries as a payload. */
.ms-fx-perf-wind-groundRing { border-radius: 50%; transform-origin: 50% 100%; }
@keyframes msFxPerfWind {
  0%   { opacity: 0;    transform: scale(1.55) rotate(-7deg); filter: brightness(1); }
  45%  { opacity: 0.85; transform: scale(0.8)  rotate(0deg);  filter: brightness(1.45); }
  100% { opacity: 0;    transform: scale(0.52) rotate(5deg);  filter: brightness(1); }
}

/* 2. THE PAYLOAD — one silhouette per ARCHETYPE_LANGUAGE `shape` token (lob / slash /
   groundRing / beam), plus `jab`, this file's own MINIMAL rung for a cast that carries no
   archetype row at all. The silhouettes deliberately echo measure/ink.css's marks of the same
   verbs — the same sentence spoken louder, never a second vocabulary. */
.ms-fx-perf-lob {
  border-radius: 50%;
  background: radial-gradient(circle at 40% 38%, #fff 0%,
    var(--ms-fx-perf-color, var(--ms-gold)) 45%, transparent 72%);
  filter: drop-shadow(0 0 10px var(--ms-fx-perf-color, var(--ms-gold)));
  animation: msFxPerfLob var(--ms-fx-perf-ms, 260ms) var(--ms-fx-perf-delay, 0ms) cubic-bezier(.3,.6,.4,1) both;
}
.ms-fx-perf-slash, .ms-fx-perf-jab {
  height: 12%;
  min-height: 6px;
  border-radius: 4px;
  background: linear-gradient(90deg, transparent, var(--ms-fx-perf-color, var(--ms-gold)) 45%, #fff 88%, transparent);
  filter: drop-shadow(0 0 8px var(--ms-fx-perf-color, var(--ms-gold)));
  animation: msFxPerfSlash var(--ms-fx-perf-ms, 260ms) var(--ms-fx-perf-delay, 0ms) cubic-bezier(.2,.8,.3,1) both;
}
.ms-fx-perf-jab { animation-name: msFxPerfJab; }
.ms-fx-perf-groundRing {
  border-radius: 50%;
  border: 3px solid var(--ms-fx-perf-color, var(--ms-gold));
  background: radial-gradient(circle at center,
    color-mix(in srgb, var(--ms-fx-perf-color, var(--ms-gold)) 22%, transparent) 0%, transparent 70%);
  animation: msFxPerfRing var(--ms-fx-perf-ms, 260ms) var(--ms-fx-perf-delay, 0ms) ease-out both;
}
.ms-fx-perf-beam {
  translate: 0 -50%;          /* anchored AT the caster, not centred on it */
  transform-origin: 0% 50%;
  border-radius: 3px;
  background: linear-gradient(90deg,
    color-mix(in srgb, var(--ms-fx-perf-color, var(--ms-gold)) 85%, white 15%),
    var(--ms-fx-perf-color, var(--ms-gold)));
  filter: drop-shadow(0 0 9px var(--ms-fx-perf-color, var(--ms-gold)));
  animation: msFxPerfBeam var(--ms-fx-perf-ms, 260ms) var(--ms-fx-perf-delay, 0ms) ease-out both;
}
.ms-fx-perf-mote {
  border-radius: 50%;
  background: radial-gradient(circle at 45% 40%, #fff 0%,
    var(--ms-fx-perf-color, var(--ms-gold)) 50%, transparent 76%);
  animation: msFxPerfLob var(--ms-fx-perf-ms, 260ms) var(--ms-fx-perf-delay, 0ms) ease-out both;
}

@keyframes msFxPerfLob {
  0%   { opacity: 0; transform: translate(var(--ms-fx-perf-from-x, 0px), var(--ms-fx-perf-from-y, 0px)) scale(0.35); }
  20%  { opacity: 1; }
  55%  { transform: translate(calc(var(--ms-fx-perf-from-x, 0px) * 0.45),
                              calc(var(--ms-fx-perf-from-y, 0px) * 0.45 - 38px)) scale(1); }
  100% { opacity: 0; transform: translate(0, 0) scale(0.85); }
}
@keyframes msFxPerfSlash {
  0%   { opacity: 0; transform: translate(var(--ms-fx-perf-from-x, 0px), var(--ms-fx-perf-from-y, 0px))
                                rotate(var(--ms-fx-perf-angle, 0deg)) scaleX(0.2); }
  55%  { opacity: 1; transform: translate(calc(var(--ms-fx-perf-from-x, 0px) * 0.18),
                                          calc(var(--ms-fx-perf-from-y, 0px) * 0.18))
                                rotate(var(--ms-fx-perf-angle, 0deg)) scaleX(1.15); }
  100% { opacity: 0; transform: translate(0, 0) rotate(var(--ms-fx-perf-angle, 0deg)) scaleX(0.85); }
}
@keyframes msFxPerfJab {   /* the MINIMAL rung — short, flat, over almost before it started */
  0%   { opacity: 0; transform: translate(var(--ms-fx-perf-from-x, 0px), var(--ms-fx-perf-from-y, 0px))
                                rotate(var(--ms-fx-perf-angle, 0deg)) scaleX(0.35); }
  45%  { opacity: 0.9; transform: translate(0, 0) rotate(var(--ms-fx-perf-angle, 0deg)) scaleX(0.9); }
  100% { opacity: 0; transform: translate(0, 0) rotate(var(--ms-fx-perf-angle, 0deg)) scaleX(0.7); }
}
@keyframes msFxPerfRing {
  0%   { opacity: 0.2; transform: scale(0.18); }
  45%  { opacity: 0.9; transform: scale(0.9); }
  100% { opacity: 0.55; transform: scale(1.1); }
}
@keyframes msFxPerfBeam {
  0%   { opacity: 0; transform: rotate(var(--ms-fx-perf-angle, 0deg)) scaleX(0.05) scaleY(0.4); }
  18%  { opacity: 1; transform: rotate(var(--ms-fx-perf-angle, 0deg)) scaleX(1) scaleY(1); }
  78%  { opacity: 1; transform: rotate(var(--ms-fx-perf-angle, 0deg)) scaleX(1) scaleY(1); }
  100% { opacity: 0; transform: rotate(var(--ms-fx-perf-angle, 0deg)) scaleX(1) scaleY(0.5); }
}

/* THE BEHAVIOR VERBS, as motion overrides — one rule per BEHAVIOR_LANGUAGE `motion` token,
   keyed by TOKEN (never by ability id, L3). BINDING: each rule overrides `animation-name`
   ONLY, so the SILHOUETTE stays the archetype's — the verb modulates the shape, it never
   replaces it (measure/ink.js's own law, restated at performance scale). Placed AFTER the
   shape rules on purpose: same specificity, later wins. */
.ms-fx-perf-motion-popAndSpray {          /* scatter — the wrapper itself paints nothing */
  background: none; filter: none; animation: none; opacity: 1;
}
.ms-fx-perf-motion-homingTrail   { animation-name: msFxPerfHoming; }
.ms-fx-perf-motion-orbitInward   { animation-name: msFxPerfOrbit; }
.ms-fx-perf-motion-breatheRing   { animation-name: msFxPerfBreathe; }
.ms-fx-perf-motion-expandRing    { animation-name: msFxPerfSlam; }
.ms-fx-perf-motion-lanceLine     { animation-name: msFxPerfLance; }
.ms-fx-perf-motion-trailingArc   { animation-name: msFxPerfLunge; }
.ms-fx-perf-motion-sidestepFlicker { animation-name: msFxPerfFlicker; }
.ms-fx-perf-motion-riseBarrier   { animation-name: msFxPerfBarrier; transform-origin: 50% 100%; }

@keyframes msFxPerfHoming {   /* seeker — drifts in off-axis and CORRECTS onto the target */
  0%   { opacity: 0; transform: translate(calc(var(--ms-fx-perf-from-x, 0px) + 26px),
                                          calc(var(--ms-fx-perf-from-y, 0px) - 30px)) scale(0.3); }
  60%  { opacity: 1; transform: translate(-12px, 9px) scale(1); }
  100% { opacity: 0; transform: translate(0, 0) scale(0.8); }
}
@keyframes msFxPerfOrbit {    /* vortex — orbits, then draws INWARD */
  0%   { opacity: 0.3; transform: rotate(0deg) translateX(26px) rotate(0deg) scale(0.55); }
  50%  { opacity: 0.95; transform: rotate(200deg) translateX(14px) rotate(-200deg) scale(0.95); }
  100% { opacity: 0.5; transform: rotate(400deg) translateX(0px) rotate(-400deg) scale(1.05); }
}
@keyframes msFxPerfBreathe {  /* trap/rift — a placed circle that breathes where it was set */
  0%   { opacity: 0.4; transform: scale(0.5); }
  45%  { opacity: 0.95; transform: scale(0.95); }
  70%  { opacity: 0.7; transform: scale(0.8); }
  100% { opacity: 0.6; transform: scale(1.05); }
}
@keyframes msFxPerfSlam {     /* slam — one hard overshooting punch, no curve */
  0%   { opacity: 1; transform: scale(0.15); filter: brightness(1); }
  40%  { opacity: 0.7; transform: scale(1.4); filter: brightness(1.8); }
  100% { opacity: 0; transform: scale(1.75); filter: brightness(1); }
}
@keyframes msFxPerfLance {    /* tag — a thin fast dart out and back */
  0%   { opacity: 0; transform: translate(var(--ms-fx-perf-from-x, 0px), var(--ms-fx-perf-from-y, 0px))
                                rotate(var(--ms-fx-perf-angle, 0deg)) scaleX(0.06) scaleY(0.4); }
  40%  { opacity: 1; transform: translate(0, 0) rotate(var(--ms-fx-perf-angle, 0deg)) scaleX(1.5) scaleY(1); }
  100% { opacity: 0; transform: translate(0, 0) rotate(var(--ms-fx-perf-angle, 0deg)) scaleX(0.3) scaleY(0.6); }
}
@keyframes msFxPerfLunge {    /* lunge — momentum carried THROUGH the hit */
  0%   { opacity: 0; transform: translate(var(--ms-fx-perf-from-x, 0px), var(--ms-fx-perf-from-y, 0px))
                                rotate(calc(var(--ms-fx-perf-angle, 0deg) - 20deg)) scaleX(0.4); }
  45%  { opacity: 1; transform: translate(calc(var(--ms-fx-perf-from-x, 0px) * 0.12),
                                          calc(var(--ms-fx-perf-from-y, 0px) * 0.12))
                                rotate(calc(var(--ms-fx-perf-angle, 0deg) - 6deg)) scaleX(1.4); }
  100% { opacity: 0; transform: translate(0, 0) rotate(var(--ms-fx-perf-angle, 0deg)) scaleX(0.9); }
}
@keyframes msFxPerfFlicker {  /* dodge — an EVASIVE excursion, never a hit */
  0%   { opacity: 0.9; transform: translateX(0); }
  25%  { opacity: 0.5; transform: translateX(-12px); }
  60%  { opacity: 0.5; transform: translateX(11px); }
  100% { opacity: 0; transform: translateX(0); }
}
@keyframes msFxPerfBarrier {  /* wall — a planted barrier rising from the ground up */
  0%   { opacity: 0.35; transform: scaleY(0.05); }
  55%  { opacity: 1; transform: scaleY(1.2); }
  100% { opacity: 0.8; transform: scaleY(1); }
}

/* 3. THE LANDING — one node over the targets' UNION, fired AT the impact mark, tinted by the
   element and sized off the RENDERED rect (fx.js `_place`). One rule per ARCHETYPE_LANGUAGE
   `accent` family; `accent:'none'` (channeled) gets NO node at all — its beam is still held
   at that instant and already carries the read, which is what the shared table says. */
.ms-fx-perf-land {
  border-radius: 50%;
  animation: msFxPerfLand var(--ms-fx-perf-ms, 300ms) var(--ms-fx-perf-delay, 0ms) cubic-bezier(.16,.84,.28,1) both;
}
.ms-fx-perf-land-burstFlash {  /* thrown — a bright burst that flashes wide */
  background: radial-gradient(circle at center, #fff 0%,
    var(--ms-fx-perf-color, var(--ms-gold)) 32%, transparent 70%);
}
.ms-fx-perf-land-verbFamily {  /* strike — a struck, edged mark rather than a soft bloom */
  border: 3px solid var(--ms-fx-perf-color, var(--ms-gold));
  background: radial-gradient(circle at center,
    color-mix(in srgb, var(--ms-fx-perf-color, var(--ms-gold)) 30%, transparent) 0%, transparent 62%);
  clip-path: polygon(50% 0%, 63% 34%, 100% 50%, 63% 66%, 50% 100%, 37% 66%, 0% 50%, 37% 34%);
}
.ms-fx-perf-land-plantGlyph {  /* deployed — a plate stamped where the zone was planted */
  border: 3px dashed var(--ms-fx-perf-color, var(--ms-gold));
  background: radial-gradient(circle at center,
    color-mix(in srgb, var(--ms-fx-perf-color, var(--ms-gold)) 18%, transparent) 0%, transparent 66%);
  animation-name: msFxPerfLandPlant;
}
@keyframes msFxPerfLand {
  0%   { opacity: 0; transform: scale(0.2); filter: brightness(1.8); }
  22%  { opacity: 0.95; transform: scale(0.85); }
  100% { opacity: 0; transform: scale(1.2); filter: brightness(1); }
}
@keyframes msFxPerfLandPlant {  /* a planted glyph settles instead of flashing away */
  0%   { opacity: 0; transform: scale(0.35) rotate(-6deg); }
  30%  { opacity: 0.9; transform: scale(1.02) rotate(0deg); }
  100% { opacity: 0; transform: scale(0.95) rotate(3deg); }
}

/* ── THE SUPER IS A SHOW (docs/playtest-r14.md §5, 2026-09-28) ───────────────────────────
   Jeff: "They need on screen animations and also explanations of what is happening. How do the
   supers differ? That should be understood by playing." Each class's Ultimate now draws its OWN
   motif under the shared vignette/flash — the shape says the rule the resolver plays:
     JUPITER · LIGHTNING STORM — bolts strike ACROSS THE WHOLE WIDTH (it hits every foe)
     MARS    · PLASMA WALL     — one wall of fire rises in a single band (it hits ONE row, hard)
     SATURN  · NOVA BOMB       — a star collapses to a point, then the ring blows out (every foe,
                                 and the pull lingers)
   Seven fixed leaf nodes (`.ms-fx-ult-m0..6`), transform/opacity/filter only, timed off the same
   in/strike/out custom properties fx.js already sets — no new timers. */
.ms-fx-ult-motif { position: absolute; inset: 0; overflow: hidden; pointer-events: none; }
.ms-fx-ult-m { position: absolute; opacity: 0; will-change: transform, opacity; }
.ms-fx-ult-live .ms-fx-ult-m {
  animation-delay: calc(var(--ms-fx-ult-in-ms, 200ms) * 0.6);
  animation-duration: calc(var(--ms-fx-ult-strike-ms, 160ms) + var(--ms-fx-ult-out-ms, 900ms));
  animation-fill-mode: both; animation-timing-function: ease-out;
}

/* JUPITER — the storm: seven jagged bolts, staggered, the full width of the stage. */
.ms-fx-ult[data-class="jupiter"] .ms-fx-ult-m {
  top: -4%; height: 78%; width: 5%;
  background: linear-gradient(180deg, #ffffff 0%, var(--ms-fx-ult-accent, #5bc8f5) 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 6px var(--ms-fx-ult-accent, #5bc8f5)) drop-shadow(0 0 14px #ffffff);
  transform-origin: top center;
}
.ms-fx-ult-live[data-class="jupiter"] .ms-fx-ult-m { animation-name: msFxUltBolt; }
.ms-fx-ult[data-class="jupiter"] .ms-fx-ult-m0 { left: 6%; }
.ms-fx-ult[data-class="jupiter"] .ms-fx-ult-m1 { left: 20%; top: 2%; }
.ms-fx-ult[data-class="jupiter"] .ms-fx-ult-m2 { left: 34%; }
.ms-fx-ult[data-class="jupiter"] .ms-fx-ult-m3 { left: 48%; top: 6%; }
.ms-fx-ult[data-class="jupiter"] .ms-fx-ult-m4 { left: 62%; }
.ms-fx-ult[data-class="jupiter"] .ms-fx-ult-m5 { left: 76%; top: 3%; }
.ms-fx-ult[data-class="jupiter"] .ms-fx-ult-m6 { left: 89%; }
.ms-fx-ult-live[data-class="jupiter"] .ms-fx-ult-m1 { animation-delay: calc(var(--ms-fx-ult-in-ms, 200ms) * 0.6 + 70ms); }
.ms-fx-ult-live[data-class="jupiter"] .ms-fx-ult-m2 { animation-delay: calc(var(--ms-fx-ult-in-ms, 200ms) * 0.6 + 150ms); }
.ms-fx-ult-live[data-class="jupiter"] .ms-fx-ult-m3 { animation-delay: calc(var(--ms-fx-ult-in-ms, 200ms) * 0.6 + 30ms); }
.ms-fx-ult-live[data-class="jupiter"] .ms-fx-ult-m4 { animation-delay: calc(var(--ms-fx-ult-in-ms, 200ms) * 0.6 + 210ms); }
.ms-fx-ult-live[data-class="jupiter"] .ms-fx-ult-m5 { animation-delay: calc(var(--ms-fx-ult-in-ms, 200ms) * 0.6 + 110ms); }
.ms-fx-ult-live[data-class="jupiter"] .ms-fx-ult-m6 { animation-delay: calc(var(--ms-fx-ult-in-ms, 200ms) * 0.6 + 180ms); }
@keyframes msFxUltBolt {
  0%   { opacity: 0; transform: scaleY(0.1); }
  6%   { opacity: 1; transform: scaleY(1); }
  14%  { opacity: 0.2; }
  20%  { opacity: 1; }
  30%  { opacity: 0.35; }
  38%  { opacity: 0.9; }
  100% { opacity: 0; transform: scaleY(1); }
}

/* MARS — the wall: seven flame tongues rise shoulder to shoulder in ONE band across the stage,
   hold, and burn down. One row, all at once — the wall is where it lands. */
.ms-fx-ult[data-class="mars"] .ms-fx-ult-m {
  bottom: 18%; height: 46%; width: 16%;
  background: linear-gradient(0deg, #fff1c4 0%, #ffb347 22%, var(--ms-fx-ult-accent, #ff6b2b) 55%, transparent 100%);
  clip-path: polygon(0 100%, 8% 40%, 22% 62%, 34% 12%, 50% 48%, 64% 0, 78% 50%, 90% 30%, 100% 100%);
  filter: drop-shadow(0 0 10px var(--ms-fx-ult-accent, #ff6b2b));
  transform-origin: bottom center; mix-blend-mode: screen;
}
.ms-fx-ult-live[data-class="mars"] .ms-fx-ult-m { animation-name: msFxUltWall; }
.ms-fx-ult[data-class="mars"] .ms-fx-ult-m0 { left: -2%; }
.ms-fx-ult[data-class="mars"] .ms-fx-ult-m1 { left: 12%; height: 52%; }
.ms-fx-ult[data-class="mars"] .ms-fx-ult-m2 { left: 26%; }
.ms-fx-ult[data-class="mars"] .ms-fx-ult-m3 { left: 41%; height: 58%; }
.ms-fx-ult[data-class="mars"] .ms-fx-ult-m4 { left: 56%; }
.ms-fx-ult[data-class="mars"] .ms-fx-ult-m5 { left: 71%; height: 50%; }
.ms-fx-ult[data-class="mars"] .ms-fx-ult-m6 { left: 86%; }
.ms-fx-ult-live[data-class="mars"] .ms-fx-ult-m1,
.ms-fx-ult-live[data-class="mars"] .ms-fx-ult-m5 { animation-delay: calc(var(--ms-fx-ult-in-ms, 200ms) * 0.6 + 60ms); }
.ms-fx-ult-live[data-class="mars"] .ms-fx-ult-m3 { animation-delay: calc(var(--ms-fx-ult-in-ms, 200ms) * 0.6 + 30ms); }
@keyframes msFxUltWall {
  0%   { opacity: 0; transform: scaleY(0) scaleX(0.8); }
  16%  { opacity: 1; transform: scaleY(1.08) scaleX(1); }
  24%  { transform: scaleY(0.94) scaleX(1.02); }
  34%  { transform: scaleY(1.04) scaleX(0.98); }
  70%  { opacity: 0.85; transform: scaleY(0.9) scaleX(1); }
  100% { opacity: 0; transform: scaleY(0.2) scaleX(1.1); }
}

/* SATURN — the star: m0 the collapsing star, m1 the blast ring, m2..m6 debris pulled INTO the
   crater (the pull that lingers is the re-prime rider). */
.ms-fx-ult[data-class="saturn"] .ms-fx-ult-m { left: 50%; top: 44%; width: 0; height: 0; }
.ms-fx-ult[data-class="saturn"] .ms-fx-ult-m0 {
  width: 34vmin; height: 34vmin; margin: -17vmin 0 0 -17vmin; border-radius: 50%;
  background: radial-gradient(circle, #000 0%, #140022 38%, var(--ms-fx-ult-accent, #c45ff0) 62%, transparent 72%);
  filter: drop-shadow(0 0 18px var(--ms-fx-ult-accent, #c45ff0));
}
.ms-fx-ult[data-class="saturn"] .ms-fx-ult-m1 {
  width: 40vmin; height: 40vmin; margin: -20vmin 0 0 -20vmin; border-radius: 50%;
  border: 6px solid var(--ms-fx-ult-accent, #c45ff0);
  box-shadow: 0 0 24px var(--ms-fx-ult-accent, #c45ff0), inset 0 0 24px #ffffff;
}
.ms-fx-ult[data-class="saturn"] .ms-fx-ult-m2,
.ms-fx-ult[data-class="saturn"] .ms-fx-ult-m3,
.ms-fx-ult[data-class="saturn"] .ms-fx-ult-m4,
.ms-fx-ult[data-class="saturn"] .ms-fx-ult-m5,
.ms-fx-ult[data-class="saturn"] .ms-fx-ult-m6 {
  width: 10px; height: 10px; margin: -5px 0 0 -5px; border-radius: 50%;
  background: #f4e2ff; box-shadow: 0 0 10px var(--ms-fx-ult-accent, #c45ff0);
}
.ms-fx-ult-live[data-class="saturn"] .ms-fx-ult-m0 { animation-name: msFxUltStar; }
.ms-fx-ult-live[data-class="saturn"] .ms-fx-ult-m1 { animation-name: msFxUltRing; }
.ms-fx-ult-live[data-class="saturn"] .ms-fx-ult-m2 { animation-name: msFxUltPull; --ms-pull-x: -42vw; --ms-pull-y: -24vh; }
.ms-fx-ult-live[data-class="saturn"] .ms-fx-ult-m3 { animation-name: msFxUltPull; --ms-pull-x: 40vw;  --ms-pull-y: -20vh; }
.ms-fx-ult-live[data-class="saturn"] .ms-fx-ult-m4 { animation-name: msFxUltPull; --ms-pull-x: -36vw; --ms-pull-y: 26vh; }
.ms-fx-ult-live[data-class="saturn"] .ms-fx-ult-m5 { animation-name: msFxUltPull; --ms-pull-x: 44vw;  --ms-pull-y: 22vh; }
.ms-fx-ult-live[data-class="saturn"] .ms-fx-ult-m6 { animation-name: msFxUltPull; --ms-pull-x: 4vw;   --ms-pull-y: -34vh; }
@keyframes msFxUltStar {
  0%   { opacity: 0; transform: scale(1.8); }
  18%  { opacity: 1; transform: scale(1); }
  34%  { opacity: 1; transform: scale(0.08); }
  40%  { opacity: 0; transform: scale(0.02); }
  100% { opacity: 0; transform: scale(0.02); }
}
@keyframes msFxUltRing {
  0%, 34% { opacity: 0; transform: scale(0.05); }
  40%  { opacity: 1; transform: scale(0.3); }
  100% { opacity: 0; transform: scale(3.2); }
}
@keyframes msFxUltPull {
  0%   { opacity: 0; transform: translate(var(--ms-pull-x), var(--ms-pull-y)) scale(1.4); }
  12%  { opacity: 1; }
  60%  { opacity: 1; transform: translate(0, 0) scale(0.4); }
  70%, 100% { opacity: 0; transform: translate(0, 0) scale(0.2); }
}

/* THE SUPER CAPTION — the name, the tag (what it hits · what it leaves), and one line of plain
   words with the result. Its own node on the fx root (not inside .ms-fx-ult, whose opacity drops
   the moment the sweep ends) so it can be READ; pointer-events:none like the whole layer. */
.ms-fx-ult-cap {
  position: absolute; left: 50%; top: 9%; z-index: 4; width: min(640px, 88%);
  transform: translateX(-50%); text-align: center; pointer-events: none;
  padding: 10px 16px 11px; opacity: 0;
  background: linear-gradient(90deg, transparent 0%, rgba(6,8,12,0.88) 12%, rgba(6,8,12,0.88) 88%, transparent 100%);
  border-top: 2px solid var(--ms-fx-ult-accent, var(--ms-gold));
  border-bottom: 1px solid color-mix(in srgb, var(--ms-fx-ult-accent, var(--ms-gold)) 50%, transparent);
}
.ms-fx-ult-cap.ms-fx-ult-cap-on { animation: msFxUltCapIn 260ms cubic-bezier(.2,.8,.3,1) forwards; }
.ms-fx-ult-cap.ms-fx-ult-cap-off { animation: msFxUltCapOut 320ms ease-in forwards; }
.ms-fx-ult-cap-name { font-size: 1.35rem; font-weight: 800; letter-spacing: 0.2em;
  color: var(--ms-fx-ult-accent, var(--ms-gold)); text-shadow: 0 0 12px var(--ms-fx-ult-accent, var(--ms-gold)), 0 2px 3px #000; }
.ms-fx-ult-cap-tag { margin-top: 3px; font-size: 0.66rem; letter-spacing: 0.12em; color: #fff4d8; }
.ms-fx-ult-cap-what { margin-top: 5px; font-size: 0.74rem; line-height: 1.35; color: rgba(244,239,228,0.92); }
.ms-fx-ult-cap-phone { top: 5%; width: 94%; padding: 7px 10px 8px; }

/* ── THE POP SAYS ITS NAME (2026-10-09) ───────────────────────────────────────
   The board's twin of the FPS's #hud-reaction callout: one banner per command, off the seed
   pop — sigil · NAME · rung/×mult · how it was made. Upper third, BELOW the Super caption
   (top 9%) so a Super that detonates shows both. A SINGLE reads quieter than a reaction: it
   is the rung the banner is teaching you to climb off. opacity/transform only. */
.ms-fx-det-call {
  position: absolute; left: 50%; top: 21%; z-index: 3; pointer-events: none;
  translate: -50% 0; display: none; text-align: center; white-space: nowrap;
  padding: 6px 18px 7px; color: var(--ms-fx-color, var(--ms-gold));
  background: radial-gradient(ellipse at center, rgba(6,8,12,0.78) 0%, rgba(6,8,12,0.55) 55%, transparent 78%);
  opacity: 0; will-change: transform, opacity;
}
.ms-fx-det-call-on, .ms-fx-det-call-static { display: block; }
.ms-fx-det-call-on { animation: msFxDetCallIn 240ms cubic-bezier(.2,.9,.3,1.2) forwards; }
.ms-fx-det-call-static { opacity: 1; }
.ms-fx-det-call-on.ms-fx-det-call-off { animation: msFxDetCallOut 300ms ease-in forwards; }
.ms-fx-det-call-icon { display: block; width: 30px; height: 30px; margin: 0 auto 2px;
  filter: drop-shadow(0 0 6px var(--ms-fx-color, var(--ms-gold))); }
.ms-fx-det-call-head { display: flex; align-items: baseline; justify-content: center; gap: 8px; }
.ms-fx-det-call-name { font-size: 1.6rem; font-weight: 800; letter-spacing: 0.16em;
  text-shadow: 0 0 12px var(--ms-fx-color, var(--ms-gold)), 0 2px 3px #000; }
.ms-fx-det-call-tag { font-size: 0.72rem; font-weight: 700; letter-spacing: 0.1em; color: var(--ms-gold);
  text-shadow: 0 1px 2px #000; }
.ms-fx-det-call-how { margin-top: 2px; font-size: 0.7rem; letter-spacing: 0.12em; color: rgba(244,239,228,0.9);
  text-shadow: 0 1px 2px #000; }
.ms-fx-det-call-single .ms-fx-det-call-name { font-size: 1.1rem; }
.ms-fx-det-call-single .ms-fx-det-call-icon { width: 22px; height: 22px; }
@keyframes msFxDetCallIn {
  0%   { opacity: 0; transform: translateY(8px) scale(0.82); }
  60%  { opacity: 1; transform: translateY(-2px) scale(1.06); }
  100% { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes msFxDetCallOut { from { opacity: 1; } to { opacity: 0; transform: translateY(-8px); } }
@media (max-width: 640px) {
  .ms-fx-det-call { top: 15%; padding: 4px 10px 5px; }
  .ms-fx-det-call-name { font-size: 1.25rem; letter-spacing: 0.12em; }
  .ms-fx-det-call-single .ms-fx-det-call-name { font-size: 0.95rem; }
  .ms-fx-det-call-how { font-size: 0.62rem; letter-spacing: 0.08em; }
  .ms-fx-det-call-icon { width: 24px; height: 24px; }
}
.ms-fx-ult-cap-phone .ms-fx-ult-cap-name { font-size: 1.05rem; }
.ms-fx-ult-cap-phone .ms-fx-ult-cap-tag { font-size: 0.58rem; }
.ms-fx-ult-cap-phone .ms-fx-ult-cap-what { font-size: 0.64rem; }
@keyframes msFxUltCapIn {
  from { opacity: 0; transform: translateX(-50%) translateY(-10px) scale(0.96); }
  to   { opacity: 1; transform: translateX(-50%) translateY(0) scale(1); }
}
@keyframes msFxUltCapOut {
  from { opacity: 1; transform: translateX(-50%); }
  to   { opacity: 0; transform: translateX(-50%) translateY(-6px); }
}

/* ── prefers-reduced-motion ──────────────────────────────────────────────────
   Defense in depth — fx.js already branches to the static/no-splash path in JS (it never
   adds these animation classes when reduced motion is live), but a node created just before
   an OS-level toggle mid-session should still never animate. */
@media (prefers-reduced-motion: reduce) {
  .ms-fx-ult-vignette, .ms-fx-ult-flash, .ms-fx-ult-bands, .ms-fx-ult-m,
  .ms-fx-det-ring, .ms-fx-det-spark, .ms-fx-det-core, .ms-fx-det-arc, .ms-fx-shake {
    animation: none !important;
    transition: none !important;
  }
  /* an arc is a MOTION with no static meaning — hidden outright; the tally is information,
     so it stays, still */
  .ms-fx-det-arc { opacity: 0 !important; }
  .ms-fx-det-tally { animation: none !important; opacity: 1 !important; }
  /* THE SUPER CAPTION is information, not spectacle — it stays, still, for its hold. */
  .ms-fx-ult-cap.ms-fx-ult-cap-on { animation: none !important; opacity: 1 !important; }
  .ms-fx-ult-cap.ms-fx-ult-cap-off { opacity: 0 !important; }
  /* THE PERFORMANCE is fully NEUTRALISED, not merely stilled: fx.js's `perform()` already
     mounts nothing at all under reduced motion (it fires `onImpact` next tick and returns —
     the same path `ultimate()` takes), so this is defense in depth for a node created just
     before an OS-level toggle mid-session. Both halves matter — killing the animation on a
     `both`-filled node would otherwise leave its from-state painted on screen forever, so
     the opacity is forced to 0 as well. */
  .ms-fx-perf-wind, .ms-fx-perf-payload, .ms-fx-perf-mote, .ms-fx-perf-land {
    animation: none !important;
    transition: none !important;
    opacity: 0 !important;
  }
  /* fx.js already never adds `ms-fx-impact-live` when reduced motion is live (`detonate()`'s
     `reduced` branch), so this is defense in depth only — the same posture as the block
     above — for a node created just before an OS-level toggle mid-session: the flash never
     shows even if the class somehow lands on it. */
  .ms-fx-impact-live { opacity: 0 !important; }
  /* THE WIPE LIST under reduced motion is the CAPTION (fx.js never adds the sweep classes
     there — `ms-fx-wipe-quiet` is forced); defense in depth for a mid-session toggle. */
  .ms-fx-wipe-panel { transition: none !important; display: none !important; }
  .ms-fx-wipe-cap-on .ms-fx-wipe-cap { animation: none !important; opacity: 1 !important; transform: translate(-50%, -50%) !important; }
  .ms-fx-wipe-cap-off .ms-fx-wipe-cap { opacity: 0 !important; }
  /* THE ACCENTS are cosmetic — fx.js mounts none under reduced motion; a node created just
     before a toggle never shows. */
  .ms-fx-acc { animation: none !important; opacity: 0 !important; }
}
