:root {
  --light: #e9dcc3;
  --dark: #a98865;
  --destroyed: #2b2b31;
  --warned: #c9543a;
  --selected: #3f7fbf;
  --legal: #3fbf7f;
}

* { box-sizing: border-box; }

body {
  margin: 0;
  font-family: system-ui, sans-serif;
  background: #1d1f24;
  color: #e8e8ea;
}

header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 0.5rem 1.5rem;
  padding: 0.6rem 1rem;
  background: #26282f;
}

h1 { font-size: 1.2rem; margin: 0; }
.sub { font-size: 0.8rem; font-weight: normal; color: #9aa0ab; margin-left: 0.5rem; }

.controls { display: flex; align-items: center; gap: 1rem; font-size: 0.9rem; }
.controls[hidden] { display: none; } /* the hidden attr must beat .controls{display:flex} (exp-controls row) */
.controls.testbed { gap: 0.5rem; padding-left: 1rem; border-left: 1px solid #3a3d46; }
.controls.testbed select, .controls.testbed input { font-size: 0.82rem; padding: 0.25rem; }
button { cursor: pointer; padding: 0.3rem 0.8rem; }
button:disabled { cursor: not-allowed; opacity: 0.45; }

main {
  display: flex;
  gap: 1.5rem;
  padding: 1rem;
  align-items: flex-start;
  flex-wrap: wrap;
}

/* #board is a positioned wrapper holding two layers: the .cells grid (rebuilt
   every render — backgrounds, overlays, ground-fx, highlight classes, click
   targets) and a persistent .piece-layer (reconciled by piece id, never wiped)
   so a moved piece TWEENS via its transform transition instead of snapping
   (plan/cascade-playback #4). The square size (--cell) is the RESPONSIVE calc on
   .dk-app (dk-ui.css) — it fits both axes (min of width-fit and height-fit) and
   scales/clamps per viewport; #board inherits it. Do NOT re-set --cell here (a
   fixed value shadows the responsive one for the whole board subtree → wide
   boards like the 8×8 overflow the viewport horizontally). */
#board {
  position: relative;
  border: 4px solid #44464e;
  user-select: none;
}
#board .cells { display: grid; gap: 0; }
.piece-layer { position: absolute; inset: 0; z-index: 3; pointer-events: none; }
.piece-layer .pc {
  position: absolute; left: 0; top: 0;
  width: var(--cell); height: var(--cell);
  display: flex; align-items: center; justify-content: center;
  font-size: 46px;
  transition: transform 180ms ease;
  will-change: transform;
}
/* Trip-up motion (plan/ground-transit § Visual): while a piece runs a trapped lane it
   travels each hop with an EASE-IN — it re-accelerates out of one trap and hits the
   next at full speed, then STOPS abruptly (clipped), rather than the default `ease`
   easing gently to a halt. Toggled on the mover during transit steps, off on landing. */
.piece-layer .pc.tripping { transition: transform 150ms cubic-bezier(.6, 0, 1, 1); }
/* Portal wormhole hop (plan/portal § Animation): at the P1→P2 discontinuity the mover
   is repositioned INSTANTLY (transition-duration zeroed inline) under this swallow
   dip — it vanishes into the near mouth and brightens back in out of the far one.
   Opacity/filter only: transform stays owned by the positioning tween. */
.piece-layer .pc.port-hop { animation: portHop 260ms ease-out; }
@keyframes portHop {
  0% { opacity: 0; filter: brightness(2); }
  55% { opacity: 0; filter: brightness(1.6); }
  100% { opacity: 1; filter: none; }
}
/* Teleport flush (Maxx 2026-07-31). A by-id relocate has NO path between its
   endpoints, so gliding the piece across the squares in between is a lie — it never
   touched them. It spins down the drain at the entry pad and unspools out of the
   exit, the second animation being the first played backwards (Maxx: "spinning and
   shrinking (flushing) and then unflushing in the reverse motion on the other
   side"). Deliberately NOT used for a PORTAL traversal: a portal is seamless
   passage you walk through (it keeps its two-segment travel), a teleporter is a
   booth. Rides .glyph so the .pc's own square-positioning transform is untouched. */
.piece-layer .pc.tp-flush .glyph { animation: tpFlush 210ms cubic-bezier(.55,.06,.9,.35) forwards; }
.piece-layer .pc.tp-unflush .glyph { animation: tpUnflush 260ms cubic-bezier(.14,.7,.35,1) both; }
@keyframes tpFlush {
  0%   { transform: rotate(0) scale(1); opacity: 1; filter: none; }
  100% { transform: rotate(540deg) scale(0.04); opacity: 0; filter: brightness(1.7); }
}
@keyframes tpUnflush { /* tpFlush reversed — same spin, unwound */
  0%   { transform: rotate(540deg) scale(0.04); opacity: 0; filter: brightness(1.7); }
  100% { transform: rotate(0) scale(1); opacity: 1; filter: none; }
}

/* THE THROW's arc (plan/throw, backlog #137) — the other half of the pathless
   family, and the reason `throw` is a separate PieceMoved cause from `relocate`.
   A booth flushes a piece out of existence and unspools it elsewhere; a hurl is a
   travelling object, so it flies. Driven by scheduleThrowArc, which holds the .pc
   at the launch square and sets the offsets as custom properties.

   The parabola is spelled out in stops rather than eased, because easing can only
   bend ONE axis and an arc needs x linear while y rises and falls. Each stop is
   y = t·dy − lift·4t(1−t) — the 4t(1−t) term peaks at exactly 1 at the apex, so
   --throw-lift reads directly as "apex height in cells above the chord".
   Timing is linear for the same reason: the stops ARE the easing.

   Rides .glyph, like the flush — renderPieceLayer owns the .pc's own transform. */
/* ⚠️ The two RULES that drive these keyframes do NOT live here — they are in
   dk-ui.css, deliberately. Both .pc.shake .glyph and .pc.lifted .glyph are declared
   there at the SAME specificity, and dk-ui.css is loaded AFTER style.css, so a rule
   written here loses the `animation` property to whichever of those fired last. It
   lost to the launcher's slam on the first live run: the class was applied, the
   glyph reported animationName `dk-card-shake`, and the piece never flew. */
@keyframes throwArc {
  0% {
    transform: translate(0, 0) rotate(0) scale(1);
  }
  20% {
    transform: translate(calc(var(--cell) * var(--throw-dx) * 0.2),
                         calc(var(--cell) * (var(--throw-dy) * 0.2 - var(--throw-lift) * 0.64)))
               rotate(calc(var(--throw-spin) * 72deg)) scale(1.05);
  }
  40% {
    transform: translate(calc(var(--cell) * var(--throw-dx) * 0.4),
                         calc(var(--cell) * (var(--throw-dy) * 0.4 - var(--throw-lift) * 0.96)))
               rotate(calc(var(--throw-spin) * 144deg)) scale(1.1);
  }
  50% {
    transform: translate(calc(var(--cell) * var(--throw-dx) * 0.5),
                         calc(var(--cell) * (var(--throw-dy) * 0.5 - var(--throw-lift))))
               rotate(calc(var(--throw-spin) * 180deg)) scale(1.12);
  }
  60% {
    transform: translate(calc(var(--cell) * var(--throw-dx) * 0.6),
                         calc(var(--cell) * (var(--throw-dy) * 0.6 - var(--throw-lift) * 0.96)))
               rotate(calc(var(--throw-spin) * 216deg)) scale(1.1);
  }
  80% {
    transform: translate(calc(var(--cell) * var(--throw-dx) * 0.8),
                         calc(var(--cell) * (var(--throw-dy) * 0.8 - var(--throw-lift) * 0.64)))
               rotate(calc(var(--throw-spin) * 288deg)) scale(1.05);
  }
  100% {
    /* Lands UPRIGHT — a full turn, never a fraction of one. A piece that comes to
       rest tilted reads as a rendering bug, not as a flourish. */
    transform: translate(calc(var(--cell) * var(--throw-dx)), calc(var(--cell) * var(--throw-dy)))
               rotate(calc(var(--throw-spin) * 360deg)) scale(1);
  }
}
@keyframes throwLand { /* the impact: squash, overshoot, settle */
  0%   { transform: scale(1.2, 0.8); }
  45%  { transform: scale(0.93, 1.07); }
  100% { transform: scale(1, 1); }
}
/* a destroyed piece spins off the board (plan/board-vfx-refinements §2 — the shared
   spin-away cadence, driven inline by flyPieceOff). Lift it above the other pieces and
   let it fly past the board edge; the transform transition is set inline per stage. */
.piece-layer .pc.dying { z-index: 6; will-change: transform, opacity; }
/* shove-crush death (Maxx 2026-07-31): the victim was packed against an obstacle, so
   flyPieceOff replaces the gentle pre-spin lift with a slam — a jolt + squash driven
   inline — and this class adds the impact READ: a hard white-hot flash that decays as
   the squash lands, so the kill visibly comes from the hit, not from nowhere. */
.piece-layer .pc.crushed .glyph { animation: crush-flash 0.32s ease-out 1; }
@keyframes crush-flash {
  0%   { filter: brightness(2.1) saturate(0.4); }
  45%  { filter: brightness(1.4) saturate(0.8); }
  100% { filter: brightness(1) saturate(1); }
}

.sq {
  width: var(--cell);
  height: var(--cell);
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 46px;
  position: relative;
  cursor: default;
}

.sq.light { background: var(--light); }
.sq.dark { background: var(--dark); }
/* encroachment telegraph (#8, reworked 2026-07-24 — show, don't tell):
   .encroach-warn — a warned square's ring pulse: color ramps orange→red and cadence
   quickens as the bite nears (both set inline per-square: --enc-col from turns-left,
   --encroach-period likewise). Smooth ease ≠ the hard steps() blink below, and both
   stay distinct from the steady-red king-panic pulse (.sq.panic) so a square about to
   encroach never reads as check.
   .encroach-now — this square dies THIS turn, before the move lands: a rapid hard
   blink into the ✕-Destroyed preview. Unmissable by design.
   Both draw on OVERLAY PSEUDO-ELEMENTS so the burst can FADE OUT smoothly:
   .encroach-fading transitions the overlay opacity to 0 (the animation keeps running
   underneath as it fades — no abrupt cut), then a re-render drops the classes. The
   keyframes therefore must never animate opacity themselves (the ✕ blinks via color). */
.sq.encroach-warn::before, .sq.encroach-now::before {
  content: ""; position: absolute; inset: 0; pointer-events: none;
  opacity: 1; transition: opacity 0.8s ease-out;
}
.sq.encroach-warn::before { animation: encroach-pulse var(--encroach-period, 1.5s) ease-in-out infinite; }
@keyframes encroach-pulse {
  0%, 100% { box-shadow: inset 0 0 0 3px color-mix(in srgb, var(--enc-col, #e6952f) 55%, transparent); }
  50%      { box-shadow: inset 0 0 0 6px var(--enc-col, #e6952f); }
}
.sq.encroach-now::before { animation: encroach-now-blink 0.34s steps(1, end) infinite; }
.sq.encroach-now::after {
  content: "✕"; font-size: 28px; pointer-events: none;
  position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
  opacity: 1; transition: opacity 0.8s ease-out;
  animation: encroach-now-x 0.34s steps(1, end) infinite;
}
@keyframes encroach-now-blink {
  0%, 49%   { box-shadow: inset 0 0 0 6px #ff4a30; background: var(--destroyed); }
  50%, 100% { box-shadow: inset 0 0 0 6px #ff4a3055; }
}
@keyframes encroach-now-x {
  0%, 49%   { color: #ff5f45; }
  50%, 100% { color: transparent; }
}
.sq.encroach-fading::before, .sq.encroach-fading::after { opacity: 0; }
.sq.destroyed { background: var(--destroyed); }
.sq.destroyed::after { content: "✕"; color: #4c4c55; font-size: 28px; }
/* final-arena outline (follow-up #4): the duel core the board converges to, shown
   from turn 1 — a quiet dashed frame spanning the core cells, click-through, under
   the piece layer. Deliberately subtle at rest; the warn pulse takes over near the
   end. */
.cells .enc-core-outline {
  pointer-events: none;
  z-index: 2;
  margin: 2px;
  border: 2px dashed #c9543a77;
  border-radius: 5px;
}
.sq.selected { box-shadow: inset 0 0 0 5px var(--selected); }
.sq.legal::before {
  content: "";
  position: absolute;
  width: 22px; height: 22px;
  border-radius: 50%;
  background: var(--legal);
  opacity: 0.85;
}
.sq.legal.occupied::before {
  width: 64px; height: 64px;
  background: none;
  border: 5px solid var(--legal);
}
.sq.selectable { cursor: pointer; }
.sq.legal { cursor: pointer; }

/* L1 ground-effect marker (cryo, sticky, ward, barricade, shove-trap, …):
   the effect's card-icon shown on its square, behind any piece glyph.
   NEUTRAL by design (backlog #25, 2026-07-24): an L1 ground fires on whoever
   enters — the old per-owner blue/orange glow implied a side-allegiance the
   mechanic doesn't have, so every marker now wears the one gold treatment
   (the effect's identity is the ICON; a per-effect-type palette is a possible
   later step). The element may be an <img> or an inline <svg> (#5) — the
   explicit `color` pins the inline glyph to the same black the img rendered. */
.sq .ground-fx {
  position: absolute; inset: 6px;
  width: calc(100% - 12px); height: calc(100% - 12px);
  object-fit: contain; opacity: 0.62; z-index: 0;
  pointer-events: none; color: #000;
  filter: drop-shadow(0 0 3px rgba(240,226,184,0.85)) drop-shadow(0 0 8px rgba(240,226,184,0.45));
}

/* L2 aura coverage (plan/board-vfx-refinements §1): rendered as ONE source-rooted
   FIELD per aura in a dedicated SVG layer between the cells grid and the pieces, NOT
   per-cell boxes — a single tinted region (radial falloff from the source piece) with
   one soft union boundary + a ring on the source, and a hit-marker only on pieces the
   aura actually affects. Built in renderAuraLayer(); coords are cell units (viewBox).
   Tint = owner polarity (--friendly / --enemy theme tokens), resolved in JS. */
.aura-layer {
  position: absolute; inset: 0; width: 100%; height: 100%;
  z-index: 2; pointer-events: none; overflow: visible;
}
/* The kind ICONS overlay (affect-markers + click-reveal) — HTML <img> above the pieces
   (piece-layer is z-index 3). Cells are positioned by transform like .pc. */
.aura-icon-layer { position: absolute; inset: 0; z-index: 5; pointer-events: none; }
.aura-icon-layer .aura-rev, .aura-icon-layer .aura-mk, .aura-icon-layer .aura-dur {
  position: absolute; left: 0; top: 0; width: var(--cell); height: var(--cell);
  display: flex; align-items: center; justify-content: center;
}
/* Dwell + duration timer badges (plan/aura-dwell-timer, 2026-07-02). Two clocks,
   two homes: DWELL (time-to-fire) hugs the thing that will fire — the aura
   affect-chip (just below it) or the trap square (top-left); DURATION (time-to-
   expire) sits with the thing that expires — each status icon (inline count),
   the aura SOURCE piece (bottom-left), the trap square (bottom-left). A forever
   clock renders NOTHING (no 0/∞). Numbers are raw world-ticks (Q2 unit open). */
.dwell-badge, .dur-badge, .link-badge {
  box-sizing: border-box;
  min-width: calc(var(--cell) * 0.26);
  height: calc(var(--cell) * 0.24);
  padding: 0 2px;
  border-radius: 4px;
  background: rgba(10, 10, 14, 0.88);
  border: 1px solid #8a8f99;
  color: #f4f4f6;
  font: 700 calc(var(--cell) * 0.17) var(--mono, monospace);
  line-height: calc(var(--cell) * 0.21);
  text-align: center;
  pointer-events: none;
}
.aura-icon-layer .aura-mk .dwell-badge { position: absolute; top: 45%; right: 6%; }
.aura-icon-layer .aura-dur .dur-badge { position: absolute; bottom: 6%; left: 6%; }
.sq .dur-badge.gnd { position: absolute; bottom: 4%; left: 4%; z-index: 1; }
/* Ground warm-up (the `warmup` lever): a warming ground renders as a slow
   INSTALLING pulse — breathing 0 → ~0.5 opacity (Maxx 2026-07-14) — with a
   countdown badge in the dwell slot (top-left). Present, telegraphed,
   cleansable, but inert; arms when the countdown lapses and the square is
   unoccupied. The renderer stamps a wall-clock-derived negative
   animation-delay on the mark so per-render element rebuilds resume the pulse
   mid-phase instead of restarting it (the piece-jitter lesson). */
.sq .ground-fx.warming { animation: dk-warm-pulse 2.6s ease-in-out infinite; filter: saturate(0.5); }
/* DARK sensor ground (sensor slice 4: activeWhile not holding, engine-stamped).
   Same first-pass treatment as a dark aura field — visible but dimmed, no pulse
   (a dark sensor accrues nothing, so animating it would promise activity). */
.sq .ground-fx.dark { opacity: 0.4; filter: saturate(0.4); }
@keyframes dk-warm-pulse {
  0%, 100% { opacity: 0; }
  50% { opacity: 0.52; }
}
.sq .dwell-badge.gnd.warm { border-color: #8a7a4a; color: #d9c98a; }
.sq .dwell-badge.gnd { position: absolute; top: 4%; left: 4%; z-index: 1; }
/* Portal-pair link badge (multi-pair disambiguation, 2026-07-15): when 2+
   relocate pairs (Teleporter/Portal) coexist, each pair's two mouths share a
   sticky letter — bottom-right, the free corner. A LETTER, not a pair color:
   hue already encodes owner on ground glyphs, and letters survive
   colorblindness. Hidden for a lone pair. */
.sq .link-badge { position: absolute; bottom: 4%; right: 4%; z-index: 1; border-color: #7fb2d9; color: #bfe0f7; }
/* Inspecting a mouth spotlights its exit (computeInspectFocus twinKey). */
.sq.twin-link { animation: dk-twin-pulse 1.6s ease-in-out infinite; }
@keyframes dk-twin-pulse {
  0%, 100% { box-shadow: inset 0 0 0 2px rgba(127, 178, 217, 0.9); }
  50%      { box-shadow: inset 0 0 0 4px rgba(127, 178, 217, 0.4); }
}

/* Burning square (fire v1 — a ground authored boardFx "burning", today
   fire_ground): the square itself is ALIGHT. Three layers on one overlay span:
   a heat-glow bed hugging the bottom of the cell (the element's own
   background) plus two blurred flame tongues (::before/::after) flickering at
   offset phases and durations so they never sync. INTENSITY is the renderer's
   fi-1 … fi-5 class (remaining turns, clamped; untimed = fi-5): --fi scales
   every alpha and both tongue heights, so a fire visibly dies from a raging
   blaze down to embers as it nears burnout — the two lowest steps also slow
   the whole thing, because a near-out fire smolders rather than dances.
   --fseed is stamped inline by the renderer (wall-clock + coord seed): keeps a
   per-render rebuild mid-phase AND desyncs neighbouring fires, so a spreading
   blaze doesn't flicker in mechanical lockstep. The effect icon paints ABOVE
   this (appended after) as a dark silhouette in the flames. */
.sq .fire-fx {
  position: absolute; inset: 0; z-index: 0; pointer-events: none;
  overflow: hidden; --fi: 1;
  background: radial-gradient(ellipse 95% 60% at 50% 100%,
    rgba(255, 214, 92, calc(var(--fi) * 0.5)) 0%,
    rgba(255, 122, 26, calc(var(--fi) * 0.38)) 38%,
    rgba(214, 42, 8, calc(var(--fi) * 0.22)) 66%,
    rgba(214, 42, 8, 0) 100%);
  animation: dk-fire-bed 1.7s ease-in-out infinite;
  animation-delay: var(--fseed, 0ms);
}
@keyframes dk-fire-bed {
  0%, 100% { opacity: 0.75; }
  23% { opacity: 0.95; }
  41% { opacity: 0.7; }
  66% { opacity: 1; }
  82% { opacity: 0.8; }
}
.sq .fire-fx::before,
.sq .fire-fx::after {
  content: ""; position: absolute; bottom: 5%;
  border-radius: 50% 50% 40% 40% / 68% 68% 32% 32%;
  background: radial-gradient(ellipse at 50% 82%,
    rgba(255, 240, 176, calc(var(--fi) * 0.95)) 0%,
    rgba(255, 172, 48, calc(var(--fi) * 0.85)) 40%,
    rgba(255, 88, 16, calc(var(--fi) * 0.6)) 70%,
    rgba(255, 88, 16, 0) 100%);
  filter: blur(2px);
  transform-origin: 50% 100%;
}
.sq .fire-fx::before {
  left: 21%; width: 30%; height: calc(26% + var(--fi) * 40%);
  animation: dk-fire-tongue-a 1.05s ease-in-out infinite;
  animation-delay: var(--fseed, 0ms);
}
.sq .fire-fx::after {
  left: 52%; width: 26%; height: calc(20% + var(--fi) * 34%);
  animation: dk-fire-tongue-b 1.35s ease-in-out infinite;
  animation-delay: calc(var(--fseed, 0ms) - 500ms);
}
@keyframes dk-fire-tongue-a {
  0%, 100% { transform: scaleY(0.82) scaleX(1) skewX(0deg); opacity: 0.8; }
  28% { transform: scaleY(1.08) scaleX(0.92) skewX(3deg); opacity: 0.95; }
  55% { transform: scaleY(0.9) scaleX(1.05) skewX(-4deg); opacity: 0.75; }
  78% { transform: scaleY(1.14) scaleX(0.9) skewX(2deg); opacity: 1; }
}
@keyframes dk-fire-tongue-b {
  0%, 100% { transform: scaleY(0.9) skewX(0deg); opacity: 0.75; }
  33% { transform: scaleY(1.12) skewX(-3.5deg); opacity: 0.95; }
  62% { transform: scaleY(0.8) skewX(3deg); opacity: 0.7; }
  85% { transform: scaleY(1.05) skewX(-2deg); opacity: 0.9; }
}
/* the five intensity steps — fresh blaze to dying embers */
.sq .fire-fx.fi-5 { --fi: 1; }
.sq .fire-fx.fi-4 { --fi: 0.82; }
.sq .fire-fx.fi-3 { --fi: 0.64; }
.sq .fire-fx.fi-2 { --fi: 0.44; }
.sq .fire-fx.fi-1 { --fi: 0.26; }
.sq .fire-fx.fi-2, .sq .fire-fx.fi-2::before, .sq .fire-fx.fi-2::after { animation-duration: 1.9s; }
.sq .fire-fx.fi-1, .sq .fire-fx.fi-1::before, .sq .fire-fx.fi-1::after { animation-duration: 2.6s; }
/* afire: a burning ground DEMOTES its icon. The standard full-square black
   marker buried the flame overlay and read as a dark piece standing there
   (measured on the fire-v1 preset) — but the flames are self-evident in a way
   no other ground's icon is, so identity flips channels: the FIRE is the
   marker, and the icon shrinks to a small ember-rimmed chip in the top-right
   corner (the free one: duration sits bottom-left, dwell/warm top-left,
   pair-link bottom-right) for inspection continuity. */
.sq .ground-fx.afire {
  inset: 4% 4% auto auto; width: 30%; height: 30%;
  opacity: 0.75;
  filter: drop-shadow(0 0 2px rgba(255, 154, 40, 0.95)) drop-shadow(0 0 5px rgba(255, 84, 10, 0.55));
}
/* status icons: an inline remaining-turns count after each icon in the row
   (fixed px — the status row is px-sized, not cell-scaled) */
.pc .st-wrap { display: inline-flex; align-items: flex-end; gap: 1px; }
/* DARK status (sensor slice 4): installed, clock ticking, contributing nothing —
   the whole wrap dims, duration badge included (existence ages while dark). */
.pc .st-wrap.dark { opacity: 0.4; }
/* PRESENCE badge (slice 4, law 3): granted state — LIT, not burning. A soft
   glow ring distinguishes it from an installed pill, and it never carries a
   duration badge: a granted thing has no clock to lie with. Glow sits on the
   wrap because the icon's own filter channel is owned by the p1/p2 recolor. */
.pc .st-wrap.presence { border-radius: 50%; box-shadow: 0 0 4px 1px rgba(0, 156, 137, 0.75); }
.pc .st-wrap .dur-badge { min-width: 10px; height: 11px; font-size: 8.5px; line-height: 9px; border-color: #6a6f79; }
/* reveal: the kind stamped centered into every covered block (cream silhouette + halo) */
.aura-icon-layer .aura-rev-ico { width: 50%; height: 50%; object-fit: contain; opacity: 0.95;
  filter: brightness(0) invert(1) drop-shadow(0 0 1px #000) drop-shadow(0 0 1px #000); }
.aura-icon-layer .aura-rev-txt { color: #fff; font: 700 calc(var(--cell) * 0.4) var(--mono, monospace); text-shadow: 0 0 2px #000, 0 0 2px #000; }
/* affect-marker: small kind chip in the bitten piece's top-right corner, ring = tint */
.aura-icon-layer .aura-mk-chip {
  position: absolute; top: 6%; right: 6%; width: 36%; height: 36%; box-sizing: border-box;
  border-radius: 50%; background: rgba(12,12,16,0.85); border: 2px solid;
  display: flex; align-items: center; justify-content: center;
  box-shadow: 0 0 0 1px rgba(0,0,0,0.45);
}
.aura-icon-layer .aura-mk-ico { width: 70%; height: 70%; object-fit: contain;
  filter: brightness(0) invert(1) drop-shadow(0 0 1px #000); }
.aura-icon-layer .aura-mk-txt { color: #f4f4f6; font: 700 calc(var(--cell) * 0.22) var(--mono, monospace); }
/* #5: the directional markers (ground-effect / aura / status arrows) carry an inline
   rotate(deg) set at creation. Establish their compositor layer UP FRONT so the rotated
   marker never paints one frame at its default (right-facing) SVG orientation before the
   transform composites — the initial-frame orientation flash. */
.sq .ground-fx, .pc .status-ico,
.aura-icon-layer .aura-rev-ico, .aura-icon-layer .aura-rev-txt,
.aura-icon-layer .aura-mk-ico, .aura-icon-layer .aura-mk-txt { will-change: transform; }

.pc .status-ico.p1 { filter: brightness(0) invert(1) drop-shadow(0 0 1px #000b) drop-shadow(0 0 1px #000b); }
.pc .status-ico.p2 { filter: brightness(0) drop-shadow(0 0 1px #fffb) drop-shadow(0 0 1px #fffb); }

/* L3 status carry: tiny status icons in a row along the bottom of the piece
   element (replaces the old generic "statused" dot). */
.pc .status-row {
  position: absolute; bottom: 3px; left: 0; right: 0;
  display: flex; justify-content: center; gap: 1px;
  z-index: 2; pointer-events: none;
}
.pc .status-ico { width: 15px; height: 15px; object-fit: contain; opacity: 0.95; }

/* Immunity status badge: the refused effect's glyph + a prohibition slash
   (auto-derived from the status's `immunity` predicate; card-face.ts
   immunityBadgeInner). The board's flat-silhouette analogue of the card face's
   bg-knockout: the base and slash are filtered SEPARATELY so the slash's own
   opposite-color halo rims it off the base. The group filter (above) is removed
   from the span so each layer keeps its own halo. */
.pc .status-ico.immunity { filter: none; position: relative; }
.pc .status-ico.immunity .imm-base,
.pc .status-ico.immunity .imm-slash { position: absolute; inset: 0; width: 100%; height: 100%; }
.pc .status-ico.immunity .imm-slash { fill: none; stroke: #000; stroke-width: 2.6; stroke-linecap: round; }
.pc .status-ico.immunity.p1 .imm-base,
.pc .status-ico.immunity.p1 .imm-slash { filter: brightness(0) invert(1) drop-shadow(0 0 1px #000b) drop-shadow(0 0 1px #000b); }
.pc .status-ico.immunity.p2 .imm-base,
.pc .status-ico.immunity.p2 .imm-slash { filter: brightness(0) drop-shadow(0 0 1px #fffb) drop-shadow(0 0 1px #fffb); }

.pc .glyph { position: relative; z-index: 1; }
.pc .glyph.p1 { color: #fdfdfd; text-shadow: 0 0 3px #000; }
.pc .glyph.p2 { color: #17171c; text-shadow: 0 0 3px #fff8; }

/* Board VFX treatments (effect-animation-library): an at-a-glance motion/glow
   cue that a piece carries a status, complementary to the status-row icons (the
   glow says "there's an effect"; the icon says which; hover says the detail).
   One treatment per piece, resolved by card-face.ts boardFxClass(). */

/* frozen-glow — cool blue rim-light from within the silhouette ("iced").
   Layered over the per-owner legibility shadow so the char stays readable. */
.pc .glyph.fx-frozen { animation: fx-frozen-pulse 2.4s ease-in-out infinite; }
.pc .glyph.fx-frozen.p1 { text-shadow: 0 0 7px #7fd6ff, 0 0 13px #36a7ff, 0 0 3px #000; }
.pc .glyph.fx-frozen.p2 { text-shadow: 0 0 7px #7fd6ff, 0 0 13px #36a7ff, 0 0 3px #fff8; }
@keyframes fx-frozen-pulse { 0%,100% { filter: brightness(1); } 50% { filter: brightness(1.25); } }

/* generic glow — a plain "something is applied here" halo (warm/neutral, so it
   reads distinct from the blue frozen treatment). Fallback for any status with
   no specific treatment. */
.pc .glyph.fx-glow { animation: fx-glow-pulse 2.2s ease-in-out infinite; }
/* SATURATED amber bloom (#11): the old pale gold (#ffe6a0) washed to white on the dark p2
   pieces, reading as a generic highlight rather than an effect. Amber reads as a deliberate
   "something's applied here" cue on BOTH the white p1 and black p2 glyphs; the tight inner
   ring keeps each side's legibility halo (dark edge on white pieces, light edge on black). */
.pc .glyph.fx-glow.p1 { text-shadow: 0 0 6px #ffb43c, 0 0 12px #ff921e, 0 0 3px #000; }
.pc .glyph.fx-glow.p2 { text-shadow: 0 0 6px #ffb43c, 0 0 12px #ff921e, 0 0 2px #fff; }
@keyframes fx-glow-pulse { 0%,100% { opacity: 0.92; } 50% { opacity: 1; } }

/* stuck — one or two horizontal floor-pins under the piece ("held in place").
   A line just below the glyph + a second, fainter line via box-shadow. Sits
   above the centered glyph, clear of the bottom status-row. */
.pc .glyph.fx-underline::after {
  content: ""; position: absolute; left: 50%; bottom: -8px;
  transform: translateX(-50%);
  width: 38px; height: 3px; border-radius: 2px;
  background: #d9534f;
  box-shadow: 0 5px 0 -0.5px #d9534faa, 0 0 4px #000a;
  pointer-events: none;
}

/* phasing / ethereal — slowly drifting after-shadow ghost copies ("half-here /
   passes through"). NOT Flying. Two offset duplicates of the glyph char (set via
   the --fx-ch custom property by the renderer) drift in opposite directions and
   fade; the main glyph stays put but breathes its opacity. */
.pc .glyph.fx-aftershadow { animation: fx-phase-breathe 2.6s ease-in-out infinite; }
.pc .glyph.fx-aftershadow::before,
.pc .glyph.fx-aftershadow::after {
  content: var(--fx-ch, ""); position: absolute; left: 0; top: 0;
  color: inherit; opacity: 0.35; z-index: -1; pointer-events: none;
}
.pc .glyph.fx-aftershadow::before { animation: fx-phase-drift-a 2.6s ease-in-out infinite; }
.pc .glyph.fx-aftershadow::after  { animation: fx-phase-drift-b 2.6s ease-in-out infinite; }
@keyframes fx-phase-breathe { 0%,100% { opacity: 1; } 50% { opacity: 0.78; } }
@keyframes fx-phase-drift-a {
  0%,100% { transform: translate(0,0); opacity: 0; }
  50% { transform: translate(-6px,-4px); opacity: 0.4; }
}
@keyframes fx-phase-drift-b {
  0%,100% { transform: translate(0,0); opacity: 0; }
  50% { transform: translate(6px,4px); opacity: 0.4; }
}

/* fly — the piece is AIRBORNE: a gentle hover-bob with a detached ground-shadow
   beneath that counter-scales (smaller/fainter as the piece rises — the lift
   illusion). Distinct from aftershadow (ethereal/phasing = half-here drift):
   a flying piece is solid, just off the ground. (Raw hex like its siblings —
   the VFX-color token pass is owned by board-vfx-refinements.) */
.pc .glyph.fx-fly { animation: fx-fly-bob 2.3s ease-in-out infinite; }
/* a faint high-altitude rim so a static frame still reads as treated — much
   softer/whiter than frozen's saturated blue (7/13px), it reads "airy" not "iced" */
.pc .glyph.fx-fly.p1 { text-shadow: 0 0 5px rgba(205,228,255,0.75), 0 0 3px #000; }
.pc .glyph.fx-fly.p2 { text-shadow: 0 0 5px rgba(205,228,255,0.75), 0 0 3px #fff8; }
.pc .glyph.fx-fly::after {
  /* a LIGHT ground-glow, not a dark drop-shadow — the board squares are near-black,
     so a realistic shadow is invisible; a soft cool pool of "air light" beneath the
     piece reads as hover on this palette. Counter-scales against the bob. */
  content: ""; position: absolute; left: 50%; bottom: -9px;
  width: 42px; height: 9px; border-radius: 50%;
  background: radial-gradient(ellipse at center, rgba(200,230,255,0.8) 15%, rgba(200,230,255,0) 72%);
  transform: translateX(-50%);
  animation: fx-fly-shadow 2.3s ease-in-out infinite;
  pointer-events: none; z-index: -1;
}
@keyframes fx-fly-bob { 0%,100% { transform: translateY(-1px); } 50% { transform: translateY(-8px); } }
@keyframes fx-fly-shadow {
  0%,100% { transform: translateX(-50%) scale(1); opacity: 0.7; }
  50% { transform: translateX(-50%) scale(0.65); opacity: 0.35; }
}

/* venom — a poison-class DEATH-CLOCK is running: sickly green seep breathing
   through the silhouette + two small toxin bubbles rising off it and popping.
   Green is unclaimed by the other treatments (blue frozen / amber generic /
   oxblood pins), so it reads as its own family at a glance. */
.pc .glyph.fx-venom { animation: fx-venom-pulse 2s ease-in-out infinite; }
.pc .glyph.fx-venom.p1 { text-shadow: 0 0 7px #7ee06a, 0 0 13px #3f9e2f, 0 0 3px #000; }
.pc .glyph.fx-venom.p2 { text-shadow: 0 0 7px #7ee06a, 0 0 13px #3f9e2f, 0 0 2px #fff8; }
@keyframes fx-venom-pulse { 0%,100% { filter: brightness(1) saturate(1); } 50% { filter: brightness(1.14) saturate(1.3); } }
.pc .glyph.fx-venom::before,
.pc .glyph.fx-venom::after {
  content: ""; position: absolute; bottom: 10px; width: 5px; height: 5px; border-radius: 50%;
  background: radial-gradient(circle at 35% 30%, #b9f0a4, #59b53e 65%, #2f7d22);
  opacity: 0; pointer-events: none;
}
.pc .glyph.fx-venom::before { left: 30%; animation: fx-venom-bubble-a 2s ease-in infinite; }
.pc .glyph.fx-venom::after  { left: 60%; width: 4px; height: 4px; animation: fx-venom-bubble-b 2s ease-in infinite 0.9s; }
@keyframes fx-venom-bubble-a {
  0% { transform: translateY(0) scale(0.6); opacity: 0; }
  25% { opacity: 0.85; }
  60% { transform: translateY(-16px) scale(1); opacity: 0.7; }
  75%, 100% { transform: translateY(-21px) scale(1.25); opacity: 0; }
}
@keyframes fx-venom-bubble-b {
  0% { transform: translateY(0) scale(0.6); opacity: 0; }
  25% { opacity: 0.8; }
  60% { transform: translateY(-12px) scale(1); opacity: 0.65; }
  75%, 100% { transform: translateY(-16px) scale(1.2); opacity: 0; }
}

/* miasma — the contagion family: SICKLY. The glyph goes queasy-pale (a slow
   desaturating pallor wobble — the piece looks unwell, not empowered) while two
   hazy pus-yellow wisps waft up off it. Bilious yellow-green, deliberately
   dirtier than venom's clean poison green (venom = a clock is running on YOU;
   miasma = you are a walking plague). */
.pc .glyph.fx-miasma { animation: fx-miasma-pallor 3.1s ease-in-out infinite; }
.pc .glyph.fx-miasma.p1 { text-shadow: 0 0 7px #c3cf62, 0 0 12px #8a9631, 0 0 3px #000; }
.pc .glyph.fx-miasma.p2 { text-shadow: 0 0 7px #c3cf62, 0 0 12px #8a9631, 0 0 2px #fff8; }
@keyframes fx-miasma-pallor {
  0%, 100% { filter: saturate(1) brightness(1); }
  50% { filter: saturate(0.5) brightness(0.88) sepia(0.4); }
}
.pc .glyph.fx-miasma::before,
.pc .glyph.fx-miasma::after {
  content: ""; position: absolute; bottom: 12px; width: 15px; height: 9px; border-radius: 50%;
  background: radial-gradient(ellipse at center, rgba(200,212,105,0.55), rgba(200,212,105,0) 70%);
  filter: blur(1.5px); opacity: 0; pointer-events: none;
}
.pc .glyph.fx-miasma::before { left: 16%; animation: fx-miasma-wisp-a 3.1s ease-in-out infinite; }
.pc .glyph.fx-miasma::after  { left: 50%; animation: fx-miasma-wisp-b 3.1s ease-in-out infinite 1.4s; }
@keyframes fx-miasma-wisp-a {
  0% { transform: translate(0, 0) scale(0.7); opacity: 0; }
  30% { opacity: 0.6; }
  70% { transform: translate(6px, -14px) scale(1.15); opacity: 0.3; }
  100% { transform: translate(9px, -20px) scale(1.3); opacity: 0; }
}
@keyframes fx-miasma-wisp-b {
  0% { transform: translate(0, 0) scale(0.7); opacity: 0; }
  30% { opacity: 0.55; }
  70% { transform: translate(-6px, -13px) scale(1.1); opacity: 0.3; }
  100% { transform: translate(-9px, -19px) scale(1.25); opacity: 0; }
}

/* feral — an auto-attacker (Bloodlust / Rabid): coiled, then SNAPS. A predatory
   crimson rim with an erratic lunge-tic once a cycle — still the rest of the
   time, so the motion signature reads "it will lash out on its own", not a
   smooth ambient pulse like the other treatments. */
.pc .glyph.fx-feral { animation: fx-feral-tic 1.9s ease-in-out infinite; }
.pc .glyph.fx-feral.p1 { text-shadow: 0 0 6px #ff5a3c, 0 0 12px #d92b18, 0 0 3px #000; }
.pc .glyph.fx-feral.p2 { text-shadow: 0 0 6px #ff5a3c, 0 0 12px #d92b18, 0 0 2px #fff8; }
@keyframes fx-feral-tic {
  0%, 84% { transform: translate(0, 0) rotate(0deg); }
  86% { transform: translate(-2px, 0) rotate(-3deg); }
  88% { transform: translate(3px, -2px) rotate(2.5deg); }
  90% { transform: translate(-2px, 1px) rotate(-2deg); }
  93% { transform: translate(1px, 0) rotate(1deg); }
  96%, 100% { transform: translate(0, 0) rotate(0deg); }
}

/* burning — the on_fire death-clock (fire v1): the piece is ALIGHT. A hot
   ember-orange rim with a fast IRREGULAR candle-flicker — fire jitters where
   the other treatments breathe (linear timing + uneven stops, so no two
   half-cycles match) — plus two small flame-licks rising off the silhouette
   and guttering out. Hue sits between glow's amber and feral's crimson, but
   the motion signatures can't be confused: glow breathes, feral coils then
   SNAPS, burning never holds still. */
.pc .glyph.fx-burning { animation: fx-burn-flicker 1.15s linear infinite; }
.pc .glyph.fx-burning.p1 { text-shadow: 0 0 6px #ffab3a, 0 0 13px #ff6414, 0 0 3px #000; }
.pc .glyph.fx-burning.p2 { text-shadow: 0 0 6px #ffab3a, 0 0 13px #ff6414, 0 0 2px #fff8; }
@keyframes fx-burn-flicker {
  0%, 100% { filter: brightness(1) saturate(1); }
  17% { filter: brightness(1.22) saturate(1.2); }
  34% { filter: brightness(0.95) saturate(1); }
  52% { filter: brightness(1.3) saturate(1.25); }
  71% { filter: brightness(1.06) saturate(1.1); }
  86% { filter: brightness(1.18) saturate(1.15); }
}
.pc .glyph.fx-burning::before,
.pc .glyph.fx-burning::after {
  content: ""; position: absolute; bottom: 12px; width: 6px; height: 9px;
  border-radius: 50% 50% 42% 42% / 64% 64% 36% 36%;
  background: radial-gradient(ellipse at 50% 78%, #fff3c4 0%, #ffb23e 42%, #ff5a14 72%, rgba(255, 90, 20, 0) 100%);
  filter: blur(0.5px); opacity: 0; pointer-events: none;
}
.pc .glyph.fx-burning::before { left: 27%; animation: fx-burn-lick-a 1.5s ease-in infinite; }
.pc .glyph.fx-burning::after  { left: 58%; width: 5px; height: 7px; animation: fx-burn-lick-b 1.5s ease-in infinite 0.7s; }
@keyframes fx-burn-lick-a {
  0% { transform: translateY(0) scale(0.7) rotate(-4deg); opacity: 0; }
  20% { opacity: 0.9; }
  55% { transform: translateY(-13px) scale(1.05) rotate(3deg); opacity: 0.65; }
  75%, 100% { transform: translateY(-19px) scale(0.5) rotate(-2deg); opacity: 0; }
}
@keyframes fx-burn-lick-b {
  0% { transform: translateY(0) scale(0.65) rotate(3deg); opacity: 0; }
  20% { opacity: 0.85; }
  55% { transform: translateY(-10px) scale(1) rotate(-3deg); opacity: 0.6; }
  75%, 100% { transform: translateY(-15px) scale(0.45) rotate(2deg); opacity: 0; }
}

/* status-end "revert beat" (Slice 2 — telegraph the end): a ONE-SHOT pulse the moment a
   status wears off / is cleansed, distinct from the persistent boardFx glow (already gone
   by now). thawing = a blue melt as a frozen piece unfreezes; reverting = a neutral flash
   for any other status end. JS adds the class on the .pc for one beat, then removes it. */
.pc.thawing .glyph { animation: fx-thaw 0.6s ease-out 1; }
@keyframes fx-thaw {
  0%   { text-shadow: 0 0 11px #9fe0ff, 0 0 19px #36a7ff, 0 0 4px #000; filter: brightness(1.45); }
  55%  { text-shadow: 0 0 6px #cdeeff, 0 0 3px #000; filter: brightness(1.12); }
  100% { text-shadow: 0 0 0 transparent; filter: brightness(1); }
}
/* reverting stays SMALL on purpose (Maxx 2026-07-31): a trivial status wearing off
   must not read like danger — the old scale-up + hard brighten mimicked the death
   animation's opening lift, so a slick expiry looked near-fatal. Now it's just the
   amber effect-halo evaporating: no transform, a soft glow that releases. Dramatic
   motion on a status end is reserved for death-clock family treatments. */
.pc.reverting .glyph { animation: fx-revert 0.5s ease-out 1; }
@keyframes fx-revert {
  0%   { text-shadow: 0 0 8px #ffb43cb0, 0 0 14px #ff921e66, 0 0 3px #000; filter: brightness(1.12); }
  60%  { text-shadow: 0 0 4px #ffb43c55, 0 0 7px #ff921e2e, 0 0 3px #000; filter: brightness(1.05); }
  100% { text-shadow: 0 0 0 transparent; filter: brightness(1); }
}

/* catch-moment (fire v1 follow-up): the apply-side sibling of the revert beat —
   a one-shot flare the instant something catches fire (igniteFx). Dramatic
   motion is licensed by the revert-beat rule itself: burning is a death-clock
   family. PIECE: a white-hot flash-over with a quick swell that decays into
   fx-burning's exact resting shadow, so the flash hands off seamlessly into
   the persistent flicker the same render applied. The selector deliberately
   ties fx-burning's specificity and sits later in the file, so the one-shot
   wins the animation slot for its 0.7s. */
.piece-layer .pc.igniting .glyph { animation: fx-ignite 0.7s ease-out 1; }
@keyframes fx-ignite {
  0%   { text-shadow: 0 0 3px #fff, 0 0 8px #ffe9b0; filter: brightness(2.1) saturate(0.7); transform: scale(1); }
  28%  { text-shadow: 0 0 14px #ffe27a, 0 0 26px #ff8c1e, 0 0 5px #000; filter: brightness(1.65) saturate(1.35); transform: scale(1.14); }
  55%  { transform: scale(0.98); }
  100% { text-shadow: 0 0 6px #ffab3a, 0 0 13px #ff6414, 0 0 3px #000; filter: brightness(1); transform: scale(1); }
}
/* SQUARE: a flame-shaped bloom that whooshes up from the cell floor and burns
   out over the .fire-fx overlay it announces. Own element in #board (cells
   rebuild per render — the blastFx pattern), self-removing; z-index 2 keeps it
   under the pieces (the occupant's own catch is the .igniting flash), unlike
   the blast quads which rightly wash everything. Positioned by left/top, not
   transform — the animation owns transform (the blast-cell lesson). */
#board .ignite-fx {
  position: absolute; width: var(--cell); height: var(--cell);
  z-index: 2; pointer-events: none;
  transform-origin: 50% 100%; will-change: opacity, transform;
  background: radial-gradient(ellipse 80% 75% at 50% 88%,
    rgba(255, 244, 200, 0.95) 0%, rgba(255, 176, 60, 0.8) 38%,
    rgba(255, 90, 16, 0.45) 68%, rgba(255, 90, 16, 0) 100%);
  animation: dk-ignite 520ms ease-out both;
}
@keyframes dk-ignite {
  0%   { opacity: 0; transform: scaleY(0.15) scaleX(0.7); }
  30%  { opacity: 1; transform: scaleY(1.18) scaleX(1); }
  60%  { opacity: 0.8; transform: scaleY(0.95) scaleX(1.03); }
  100% { opacity: 0; transform: scaleY(1.05) scaleX(0.97); }
}

/* pending committed move (awaiting the card phase) */
.sq.pending { box-shadow: inset 0 0 0 5px #c9a23a; }
/* card-phase placement targets */
.sq.target { cursor: pointer; }
.sq.target::after {
  content: ""; position: absolute; inset: 6px;
  border: 4px dashed #c98bff; border-radius: 6px;
}
/* the banked FIRST square of a two-square pick (portal pair — plan/teleporter):
   a solid locked ring, distinct from the dashed candidate squares awaiting the 2nd tap */
.sq.target-picked { cursor: pointer; }
.sq.target-picked::after {
  content: ""; position: absolute; inset: 6px;
  border: 4px solid #c98bff; border-radius: 6px;
  box-shadow: 0 0 10px #c98bffaa, inset 0 0 8px #c98bff55;
}

/* play column: hands (p2 top / p1 bottom) sandwich the board, then lock-in */
.play { display: flex; flex-direction: column; gap: 0.7rem; }
.turn-controls { display: flex; gap: 0.6rem; align-items: center; }
.turn-controls .auto-resolve { display: flex; align-items: center; gap: 0.3rem; font-size: 0.85rem; color: #c4c8d0; }
.build { font-size: 0.62rem; color: #6b7079; font-weight: 400; letter-spacing: 0.04em; }
/* Move Confirmation Warning prompt (M2 C5) */
.confirm { margin-top: 0.6rem; padding: 0.7rem 0.85rem; background: #3a2027; border: 1px solid #ff6b6b; border-radius: 6px; max-width: 340px; }
.confirm.hidden { display: none; }
/* explicit sans: the panel sits inside .dk-screen, which inherits the serif
   display font — a warning gate needs to read at a glance, not look pretty. */
.confirm-text { color: #ffe2e2; font-family: system-ui, sans-serif; font-size: 0.95rem;
  line-height: 1.45; margin-bottom: 0.6rem; }
.confirm-actions { display: flex; gap: 0.5rem; }
.confirm-actions button { font-size: 0.88rem; padding: 0.35rem 0.8rem; }
#confirm-yes { background: #6b2030; color: #ffd2d2; border-color: #ff6b6b; }

/* lock-in panel: both slots, committed in any order; resolve when both set */
.lockin { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.9rem; }
.lockin .slot { color: #c4c8d0; padding: 0.15rem 0.4rem; border-left: 3px solid #44464e; }
.lockin .slot b { color: #9aa0ab; font-weight: 600; }
.lockin .slot.set { border-left-color: var(--legal); color: #d9f5e4; }
.lockin .slot.set b { color: #6fce9c; }

#resolve { background: #2f6f4a; color: #eafff1; border: 1px solid #3fbf7f; font-weight: 600; }
#resolve:disabled { background: #2a2c34; color: #6b7079; border-color: #44464e; cursor: not-allowed; }

/* committed card target (vs in-progress dashed .target) */
.sq.card-committed { box-shadow: inset 0 0 0 5px #c98bff; }
/* cascade-cadence beat highlight (M2 C1): the square(s) a beat touches flash */
.sq.beat { box-shadow: inset 0 0 0 4px #ffd83d, 0 0 12px #ffd83daa; transition: box-shadow 0.1s; }
/* king-threatened panic mode (M2 C3): the king square pulses red, the enemy
   piece(s) threatening it are marked. */
.sq.panic { animation: panic-pulse 1.1s ease-in-out infinite; }
@keyframes panic-pulse {
  0%, 100% { box-shadow: inset 0 0 0 4px #ff3b3b66; }
  50%      { box-shadow: inset 0 0 0 4px #ff3b3b, 0 0 14px #ff3b3bcc; }
}
.sq.threat { box-shadow: inset 0 0 0 3px #ff8c3b; }
/* king-death cadence emphasis (M2 C4): the falling-king square flashes hard */
/* Aura activation sweep (Maxx 2026-07-31). An aura lights up only when it HAS AN
   EFFECT on a piece — never on a bare tick — and it lights the whole PATH between
   carrier and target, so "why did that piece get moved/killed" is answerable from
   the board. Direction is carried by a per-square `--aura-delay` stagger set
   inline: outward for a beam (carrier → victim), reversed for a pull (victim →
   carrier), which is what makes a Crush King read as reeling something in.
   The tint separates the two senses — gold for an outward strike, cyan for an
   inward pull — and `.aura-lit-end` marks the affected square itself. */
.sq.aura-lit::after {
  content: ""; position: absolute; inset: 0; pointer-events: none;
  animation: aura-sweep 420ms ease-out both; animation-delay: var(--aura-delay, 0ms);
  box-shadow: inset 0 0 0 3px var(--aura-tint, #ffd83d);
}
.sq.aura-lit.pull::after { --aura-tint: #6fd6ff; }
@keyframes aura-sweep {
  0%   { opacity: 0; transform: scale(0.82); }
  35%  { opacity: 1; transform: scale(1); }
  100% { opacity: 0; transform: scale(1); }
}
/* the target square holds a beat longer — it's the one the effect landed on */
.sq.aura-lit.aura-lit-end::after { animation-duration: 620ms; box-shadow: inset 0 0 0 4px var(--aura-tint, #ffd83d), 0 0 16px var(--aura-tint, #ffd83d); }

/* --- Turn nudge: pieces (turnNudge/nudgePieces) ------------------------------
   The turn wants a MOVE, so your pieces bloom once — a gold halo under each,
   rippling from your near edge outward, gone in a quarter second. Its own
   transient layer, sitting between the aura field (z 2) and the pieces (z 3), so
   the halo reads as light UNDER the piece and never touches the glyph's own fx.
   The hand's half of this lives in dk-ui.css (.dk-fan.nudge). */
.nudge-layer { position: absolute; inset: 0; z-index: 2; pointer-events: none; }
.nudge-dot {
  position: absolute; width: var(--cell); height: var(--cell);
  border-radius: 14%;
  background: radial-gradient(circle at 50% 50%,
    rgba(var(--gold-rgb), 0.42) 0%, rgba(var(--gold-rgb), 0.15) 46%, rgba(var(--gold-rgb), 0) 72%);
  opacity: 0; will-change: opacity, transform;
  animation: turn-nudge 240ms ease-out both;
}
@keyframes turn-nudge {
  0%   { opacity: 0; transform: scale(0.72); }
  38%  { opacity: 1; transform: scale(1.04); }
  100% { opacity: 0; transform: scale(0.92); }
}
/* Reduced motion keeps the CUE and drops the movement — the nudge is the point,
   the pop is the delivery. */
@media (prefers-reduced-motion: reduce) {
  .nudge-dot { animation-name: turn-nudge-still; }
  @keyframes turn-nudge-still { 0% { opacity: 0; } 38% { opacity: 1; } 100% { opacity: 0; } }
}

.sq.beat.crit { animation: crit-flash 0.5s ease-in-out infinite; }
@keyframes crit-flash {
  0%, 100% { box-shadow: inset 0 0 0 5px #ff2d2d, 0 0 22px #ff2d2dcc; }
  50%      { box-shadow: inset 0 0 0 6px #ffffff, 0 0 30px #ff2d2d; }
}

/* --- King-death cinematic (the match-ending capture, C4) --------------------
   killCam(): #board punches toward the fallen king (zoom + baked-in impact shake),
   HOLDS, then eases back — one `forwards` animation so the released state sticks until
   the JS clears the class. transform-origin = the kill (set inline via --cam-x/-y). */
#board.king-cam {
  transform-origin: var(--cam-x, 50%) var(--cam-y, 50%);
  animation: king-cam 1.5s cubic-bezier(.2,.85,.3,1) forwards;
  z-index: 5;
}
@keyframes king-cam {
  0%   { transform: scale(1); }
  9%   { transform: scale(1.15) translate(4px, -3px); }
  13%  { transform: scale(1.15) translate(-5px, 3px); }
  17%  { transform: scale(1.14) translate(4px, 2px); }
  21%  { transform: scale(1.15) translate(-3px, -2px); }
  28%  { transform: scale(1.14) translate(0, 0); }   /* shake done → settle into the zoom */
  64%  { transform: scale(1.14); }                    /* hold on the kill */
  100% { transform: scale(1); }                       /* ease back out */
}
/* spotlight: transparent at the kill, darkening + desaturating outward. Sits above the
   cells + pieces (z 4) but below the shard debris (z 7) so only the kill stays lit. */
.king-spotlight {
  position: absolute; inset: 0; z-index: 4; pointer-events: none;
  background: radial-gradient(circle at var(--spot-x, 50%) var(--spot-y, 50%),
    transparent 0, transparent 11%, rgba(6,6,11,0.5) 52%, rgba(3,3,7,0.84) 100%);
  opacity: 0;
  animation: king-spot 1.5s ease forwards;
}
@keyframes king-spot { 0% { opacity: 0; } 16% { opacity: 1; } 64% { opacity: 1; } 100% { opacity: 0; } }
/* shatterFx(): a piece breaks into wedge shards flung across the board — the crown on
   the match-ending capture, and any piece caught in a detonation. The debris rides its
   own top layer so it stays bright above the spotlight dim; --shard-glow is set per
   spray (crown = red, blast = ember orange). */
.shard-layer { position: absolute; inset: 0; z-index: 7; pointer-events: none; will-change: transform; }
.shard {
  position: absolute; left: 0; top: 0;
  width: var(--cell); height: var(--cell);
  display: flex; align-items: center; justify-content: center;
  font-size: 46px; will-change: transform, opacity;
}
.shard .glyph { position: relative; }
.shard .glyph.p1 { color: #fdfdfd; text-shadow: 0 0 3px #000; }
.shard .glyph.p2 { color: #17171c; text-shadow: 0 0 3px #fff8; }
.shard .glyph { filter: drop-shadow(0 0 7px var(--shard-glow, rgba(255,72,72,0.95))); }
/* the doomed king pulses under the suspended attacker (the held hover before the strike) */
.piece-layer .pc.doomed { z-index: 7; animation: king-doomed 0.44s ease-in-out infinite; }
@keyframes king-doomed {
  0%, 100% { filter: drop-shadow(0 0 3px #ff3b3b); }
  50%      { filter: drop-shadow(0 0 15px #ff2d2d) brightness(1.55); }
}
/* the fallen king's brief pre-shatter flare */
.piece-layer .pc.king-breaking { z-index: 8; }
.piece-layer .pc.king-breaking .glyph { filter: brightness(2.4) drop-shadow(0 0 12px #fff); }

/* --- Volatile detonation (blastFx) ------------------------------------------
   A bomb going off is the loudest thing on the board short of a king dying: the
   board jolts, a white-hot core and two shockwave rings punch out of the
   epicentre, and every covered square ignites. The layer sits above the pieces
   (z 6) but under the debris (z 7) — the bits a blast throws are the last thing
   still bright once the fire has burnt down. */
.blast-fx { position: absolute; inset: 0; z-index: 6; pointer-events: none; }
/* one ignition quad per covered square — flash, burn, and leave the crater.
   Positioned by left/top, NOT transform: the burn animation owns transform, and an
   animated property beats an inline one, so a transform-positioned quad would pile
   up at the board's origin. */
.blast-cell {
  position: absolute;
  width: var(--cell); height: var(--cell);
  background: radial-gradient(circle at 50% 50%, #fff 0%, #ffd166 26%, #ff7a1a 55%, rgba(120,22,0,0.75) 100%);
  opacity: 0; will-change: opacity, transform;
  animation: blast-burn 620ms ease-out both;
}
@keyframes blast-burn {
  0%   { opacity: 0; transform: scale(0.7); }
  12%  { opacity: 1; transform: scale(1.06); }
  55%  { opacity: 0.72; transform: scale(1); }
  100% { opacity: 0; transform: scale(0.94); }
}
/* the core + the shockwave rings: sized in cells to the blast's own footprint,
   so a radius-2 bomb throws a visibly wider wave than a radius-1 one */
.blast-core, .blast-ring {
  position: absolute; border-radius: 50%; pointer-events: none;
  transform: translate(-50%, -50%) scale(0.25);
  will-change: transform, opacity;
}
.blast-core {
  background: radial-gradient(circle, #fff 0%, #ffe9a8 22%, rgba(255,140,26,0.85) 52%, rgba(255,60,0,0) 78%);
  animation: blast-core 420ms cubic-bezier(.1,.75,.3,1) both;
}
.blast-ring {
  border: 3px solid rgba(255,214,140,0.95);
  box-shadow: 0 0 18px rgba(255,140,40,0.85), inset 0 0 14px rgba(255,190,90,0.6);
  animation: blast-ring 560ms cubic-bezier(.05,.7,.25,1) both;
}
/* the trailing wave — wider, thinner, dimmer, so the two read as one wave with a
   wake rather than as two rings */
.blast-ring.soft {
  border-width: 2px; border-color: rgba(255,196,120,0.5);
  box-shadow: 0 0 22px rgba(255,120,30,0.45);
}
@keyframes blast-core {
  0%   { opacity: 0; transform: translate(-50%, -50%) scale(0.2); }
  14%  { opacity: 1; }
  100% { opacity: 0; transform: translate(-50%, -50%) scale(var(--blast-scale, 1.15)); }
}
@keyframes blast-ring {
  0%   { opacity: 0.15; transform: translate(-50%, -50%) scale(0.2); }
  18%  { opacity: 1; }
  100% { opacity: 0; transform: translate(-50%, -50%) scale(var(--blast-scale, 3)); }
}
.blast-spark {
  position: absolute; width: 7px; height: 7px; border-radius: 50%;
  background: radial-gradient(circle, #fff 0%, #ffc24d 45%, rgba(255,90,0,0) 100%);
  will-change: transform, opacity;
}
/* the flash on a piece the instant before it comes apart */
.piece-layer .pc.blast-caught { z-index: 8; }
.piece-layer .pc.blast-caught .glyph { filter: brightness(2.2) drop-shadow(0 0 12px #ffb03a); }
/* boardShake(): the impact jolt. Rides .dk-board-wrap, NOT #board — #board's
   transform belongs to the reveal tuck and to killCam, and a blast that kills a
   king has to be able to shake while the kill-cam zooms. */
.dk-board-wrap.board-shake { animation: board-shake 460ms cubic-bezier(.36,.07,.19,.97) both; }
@keyframes board-shake {
  0%, 100% { transform: translate(0, 0); }
  8%   { transform: translate(calc(var(--shake-px, 8px) * 1), calc(var(--shake-px, 8px) * -0.6)); }
  20%  { transform: translate(calc(var(--shake-px, 8px) * -0.9), calc(var(--shake-px, 8px) * 0.7)); }
  33%  { transform: translate(calc(var(--shake-px, 8px) * 0.7), calc(var(--shake-px, 8px) * 0.5)); }
  47%  { transform: translate(calc(var(--shake-px, 8px) * -0.5), calc(var(--shake-px, 8px) * -0.4)); }
  62%  { transform: translate(calc(var(--shake-px, 8px) * 0.34), calc(var(--shake-px, 8px) * -0.22)); }
  80%  { transform: translate(calc(var(--shake-px, 8px) * -0.16), calc(var(--shake-px, 8px) * 0.12)); }
}
.sq.card-committed::after { content: ""; }

/* both hands always shown (perfect info) — p2 above the board, p1 below */
.hand {
  border: 1px solid #34363e; border-radius: 6px;
  padding: 0.4rem 0.5rem; background: #202229;
}
.hand.active { border-color: #c98bff; background: #251f30; }
.hand-label { font-size: 0.68rem; color: #9aa0ab; margin-bottom: 0.35rem; text-transform: uppercase; letter-spacing: 0.04em; }
.hand.active .hand-label { color: #c98bff; }
.tray { display: flex; flex-wrap: wrap; gap: 0.5rem; min-height: 4rem; }
.tray .empty { color: #6b7079; font-size: 0.8rem; align-self: center; }

/* Designed card faces (currency frame in public/card-styles.css +
   currency-variants.css). Each renders at its native 260x440 and is scaled
   into a fixed slot for the hand tray. */
.card-slot { width: 152px; height: 256px; }
.hand-card {
  width: 260px; height: 440px; position: relative;
  transform: scale(0.582); transform-origin: top left;
  user-select: none; transition: transform 0.08s ease;
}
.card-slot .hand-card.readonly { opacity: 0.6; }
.card-slot .hand-card:not(.readonly) { cursor: pointer; }
.card-slot .hand-card:not(.readonly):hover { transform: scale(0.6); }
.card-slot .hand-card.picked { box-shadow: 0 0 0 3px #c98bff, 0 0 16px #c98bffaa; }
/* Corner glyphs (scope + trigger-pill/magnitude) live on the art window; their
   styling is shared in public/card-styles.css § Card Face Model v2. */
.hand-card .card-art-wrap { position: relative; }
/* auto-play badge */
.hand-card .card-auto {
  position: absolute; top: 6px; left: 6px; z-index: 3;
  font-family: "Cinzel", serif; font-size: 11px; letter-spacing: 0.08em;
  color: #c9a23a; background: rgba(0, 0, 0, 0.72); padding: 2px 6px; border-radius: 3px;
}
.hand-card.auto { opacity: 0.9; }

/* Selected-card preview — the in-focus card shown big to the right of the
   board (renderCardPreview). Reuses .hand-card markup/styles; the inline
   transform overrides .hand-card's default 0.582 scale. */
.card-preview { display: flex; flex-direction: column; gap: 0.4rem; align-items: flex-start; }
.card-preview:empty { display: none; }
.preview-label { font-size: 0.68rem; color: #9aa0ab; text-transform: uppercase; letter-spacing: 0.04em; }
.preview-slot { position: relative; }
.preview-card { transform-origin: top left; cursor: default; }

aside { min-width: 320px; max-width: 420px; display: flex; flex-direction: column; gap: 0.7rem; }

/* Kings-remaining counter (M2 C4): persistent win-condition reminder. */
.kings { display: flex; gap: 1.4rem; align-items: center; padding: 0.4rem 0.6rem;
  background: #202229; border: 1px solid #34363e; border-radius: 6px; }
.kings .side { display: flex; align-items: baseline; gap: 0.4rem; }
.kings .who { font-size: 0.66rem; color: #9aa0ab; text-transform: uppercase; letter-spacing: 0.05em; }
.kings .glyphs { font-size: 1.3rem; letter-spacing: 0.08em; }
/* Both glyphs must read on the dark panel; the P1/P2 label carries identity
   (the board's dark P2 piece colour is invisible here). */
.kings .p1 .glyphs { color: #fdfdfd; text-shadow: 0 0 3px #000; }
.kings .p2 .glyphs { color: #b6bcc8; text-shadow: 0 0 3px #000; }
.kings .count { font-size: 0.78rem; color: #c4c8d0; }
.kings .side.dead .who, .kings .side.dead .count { color: #c9543a; }

/* Match-start / win / draw splash overlay (M2 C4). */
.splash { position: fixed; inset: 0; z-index: 50; display: flex; align-items: center;
  justify-content: center; background: rgba(10, 11, 14, 0.72); transition: opacity 0.4s; }
.splash.hidden { opacity: 0; pointer-events: none; }
.splash-inner { font-family: "Cinzel", serif; font-size: 2.3rem; font-weight: 700; color: #f0e6c8;
  text-align: center; padding: 1.4rem 2.6rem; border: 2px solid #c9a23a; border-radius: 10px;
  background: #1b1d24; box-shadow: 0 0 44px #000a; letter-spacing: 0.04em; max-width: 80vw; }
.splash-inner.win { color: #ffd66e; border-color: #ffd66e; }
.splash-inner.draw { color: #cdd2db; border-color: #8a909c; }
/* TYPOGRAPHIC TIER (backlog #34, Maxx 2026-08-05): the encroachment splash is a
   one-line THEATRICAL announcement, not functional text — it keeps the inherited
   Cinzel display serif. (0a03257 dropped it to sans on legibility grounds; that
   reasoning holds for the confirm gate and the log, which you read in order to
   DECIDE, and not for the splash, which you read because the game is shouting.) */
.splash-inner.encroach-alert { color: #ff6a4a; border-color: #c9543a; font-size: 1.9rem; letter-spacing: 0.04em; }
/* rematch panel (#13): the result + series score + Rematch / Leave actions. */
.splash-inner.rematch { font-size: 2rem; }
.rematch-note { font-family: system-ui, sans-serif; font-size: 1rem; font-weight: 600; color: #cdd2db; margin-top: 0.7rem; }
.rematch-note.hot { color: #ffd66e; }
.rematch-actions { display: flex; gap: 0.9rem; justify-content: center; margin-top: 1.2rem; }
.rematch-btn { font-family: system-ui, sans-serif; font-size: 1rem; font-weight: 600; padding: 0.6rem 1.4rem;
  border-radius: 8px; border: 1px solid #555c68; background: #2a2c34; color: #dfe3ea; cursor: pointer; letter-spacing: normal; }
.rematch-btn.primary { background: #2f6f4a; border-color: #3fbf7f; color: #eafff1; }
.rematch-btn:hover { filter: brightness(1.12); }
.splash-inner .sub { display: block; font-family: system-ui, sans-serif; font-size: 0.95rem;
  font-weight: 400; color: #c4c8d0; margin-top: 0.6rem; letter-spacing: 0; }

#status { font-size: 1rem; line-height: 1.5; }
#status .ended { color: #ffd66e; font-weight: bold; }

#msg { color: #ff8d7a; min-height: 1.2em; font-size: 0.85rem; }

h2 { font-size: 0.9rem; margin: 0.4rem 0 0; color: #9aa0ab; }

/* Active-effects panel (current board STATE — distinct from the log's history):
   each piece's statuses/auras + each square's ground effect. */
.effects {
  display: flex; flex-direction: column; gap: 0.25rem;
  background: #15161a; border: 1px solid #34363e; border-radius: 4px;
  padding: 0.5rem; max-height: 200px; overflow-y: auto; font-size: 0.76rem;
}
.effects .fx-empty { color: #6b7079; }
.fx-row { display: flex; align-items: center; gap: 0.35rem; flex-wrap: wrap; }
.fx-row .fx-pc { color: #c4c8d0; min-width: 4.2rem; }
.fx-row.pol-p1 .fx-pc { color: #fdfdfd; }
.fx-row.pol-p2 .fx-pc { color: #b6bcc8; }
.fx-badge {
  display: inline-flex; align-items: center; gap: 0.2rem;
  background: #24262e; border: 1px solid #3a3d46; border-radius: 3px;
  padding: 0.02rem 0.35rem; color: #d6dae2;
}
.fx-badge.aura { border-color: #8a6fce; }
.fx-badge.ground { border-color: #6f8ace; }
.fx-ico, .log-ico { width: 13px; height: 13px; object-fit: contain; flex: none; }

/* Event log — structured rows grouped by turn (was a flat <pre> dump). */
.log-head { display: flex; align-items: center; gap: 0.6rem; margin-top: 0.4rem; }
.log-head h2 { margin: 0; }
.log-toggle { font-size: 0.72rem; color: #9aa0ab; display: flex; align-items: center; gap: 0.25rem; }
#log-copy { font-size: 0.72rem; padding: 0.1rem 0.45rem; margin-left: auto; }
/* TYPOGRAPHIC TIER (backlog #31, Maxx 2026-08-05): the log is dense, small,
   FUNCTIONAL text — it takes the same explicit sans as .confirm-text. Without a
   font-family it inherited .dk-screen's Cinzel display serif, a face chosen for
   headings. Same rule, same reason: sans for text you read in order to decide,
   the display serif for the theatrical one-liner (.splash-inner.encroach-alert).
   Covers BOTH surfaces — the dev sidebar log and the player-facing log overlay,
   whose body carries `.dk-logview-body log`. */
.log {
  background: #15161a; border: 1px solid #34363e; border-radius: 4px;
  font-family: system-ui, sans-serif;
  padding: 0.4rem 0.5rem; font-size: 0.76rem; line-height: 1.4;
  max-height: 360px; overflow-y: auto;
}
.log .log-empty { color: #6b7079; }
.log .log-turn {
  margin: 0.5rem 0 0.15rem; padding-top: 0.3rem;
  border-top: 1px solid #2c2f38; color: #8a909c;
  font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.03em; font-weight: 600;
}
.log .log-turn:first-child { margin-top: 0; padding-top: 0; border-top: none; }
.log .log-row { display: flex; align-items: center; gap: 0.3rem; padding: 0.05rem 0 0.05rem 0.4rem; }
.log .log-row.cat-plumbing { color: #6b7079; }
.log .log-row.cat-effect { color: #c4c8d0; }
.log .log-row.cat-destruction { color: #ff9a86; font-weight: 600; }
.log .log-row.cat-critical { color: #ffd66e; font-weight: 700; }
.log .log-row.pol-p1.cat-effect { color: #dfe7f2; }
.log .log-row.pol-p2.cat-effect { color: #aeb6c4; }

/* Stacked grounds (#116 multi-occupancy): a square can carry several coexisting
   grounds. With 2+ marks each icon shrinks into its own corner slot so every
   identity stays readable — slots run NW, SE, NE, SW in install order (a 5th+
   ground shares the SW slot; by then the square is a design conversation, not a
   rendering problem). Positioned by inset offsets, NOT transform — the mark's
   transform is reserved for the directional-arrow rotation. */
.sq .ground-fx.gnd-stack { inset: auto; width: 54%; height: 54%; }
.sq .ground-fx.gnd-s0 { left: 2%; top: 2%; }
.sq .ground-fx.gnd-s1 { right: 2%; bottom: 2%; }
.sq .ground-fx.gnd-s2 { right: 2%; top: 2%; }
.sq .ground-fx.gnd-s3 { left: 2%; bottom: 2%; }
