Back to Shiftfield
SOURCE / PINNED RELEASE

Made of little things.

Shiftfield

Release
5a170c505f6b…
Author-recorded commit
a2e23e0171aa…
License
LICENSE
Author’s source reference
nostr://npub16nzc4ql748r2kv9202rgzrt0fmtk4n3tzsjl86yf6ty9agqe9u0qjcc7hc/wss%3A%2F%2Fgit.napplet.soy%2F/n-e4fb6341ccd

Archive hash verified: 66de7623efd91da1…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/juice.ts
/* Minesweeper DX — Phase 4 "Juice": the effects layer.

 * A single fixed, `pointer-events:none` <canvas> above the DOM board (below
 * the overlays) plus a transform-level screen shake on #app. The DOM board
 * stays the sanctioned render layer (spec §8 #3 ruling 2026-10-25: hybrid);
 * this canvas is decoration only — it never holds game state and never
 * receives input, so conformance and the QA hook are untouched.

 * Everything is procedural: particles, shockwaves, sweep streaks, flashes and
 * the chromatic ghost halo are drawn from primitives at play time. No
 * assets, no rAF work while idle (the loop stops when the effect list drains)
 * and no canvas raster work on frames with nothing to draw (P2.1 dirty flag).

 * `prefers-reduced-motion: reduce` tames the whole layer: no particles, no
 * shake, no chromatic split — one brief static flash per event at most. The
 * preference is followed live (changeMediaQuery listener), not just at boot.
 */

import type { Sfx } from './sfx.js';

export type FxEvent =
  | { kind: 'reveal'; cell: string; count: number }
  | { kind: 'flag'; cell: string; mine: boolean }
  | { kind: 'unflag'; cell: string }
  | { kind: 'shift'; dx: number; dy: number }
  | { kind: 'shift-warning'; dx: number; dy: number; turns: number }
  | { kind: 'boom'; cell: string; cause: string; shielded: boolean }
  | { kind: 'deny' };

interface Particle {
  x: number; y: number;
  vx: number; vy: number;
  age: number; ttl: number;
  size: number;
  color: string;
  /** px/ms² downward; negative = rises (sparks). */
  grav: number;
  drag: number;
}

interface Ring {
  x: number; y: number;
  age: number; ttl: number;
  r0: number; r1: number;
  width: number;
  color: string;
}

/** Board-space streak that rides the shift vector across the field. */
interface Sweep {
  age: number; ttl: number;
  dx: number; dy: number;
  // D2 (audit 2026-10-28): per-sweep `color` field deleted — drawSweep always
  // painted the ice-blue gradient; train stagger rides age-offset + alpha,
  // never a color. (The old sweepBurst even faked a color with an
  // `as unknown as string` cast, which nothing read.)
}

interface Flash { age: number; ttl: number; color: string; peak: number; board?: boolean }

/** Toroidal slide tell: a fading gradient band hugging the board EDGES the
 * mines exit (and the opposite edge they re-enter). ax/ay are the raw shift
 * components (-1/0/1). Distinct property names from Sweep on purpose — the
 * draw loop discriminates effects by key presence. */
interface Band { age: number; ttl: number; ax: number; ay: number; strength?: number }

/** Telegraph wind: one drifting streak dot, position normalized (0..1)
 * inside the board rect, wrapping toroidally like the mines themselves. */
interface WindDot { u: number; v: number }

type Effect = Particle | Ring | Sweep | Flash | Band;

const TAU = Math.PI * 2;

export class Fx {
  private readonly canvas: HTMLCanvasElement;
  // NOT readonly: T4 (audit 2026-10-28) re-acquires it on contextrestored.
  private ctx2d: CanvasRenderingContext2D | null;
  private readonly dpr: number;
  private readonly effects: Effect[] = [];
  private raf = 0;
  private rafDeadLogged = false;
  private last = 0;
  /** shake impulse state */
  private shakeAmp = 0;
  private shakeTtl = 0;
  private shakeAge = 0;
  private shakeEl: HTMLElement | null = null;
  /** chromatic split impulse (P2.2: ghosts are a halo around this point,
   * not a viewport-wide tint) */
  private chroma = 0;
  private chromaTtl = 0;
  private chromaAge = 0;
  private chromaX = 0;
  private chromaY = 0;
  /** Shifter telegraph (ruling 2026-10-28): persistent on-board wind layer.
   * Null while the Shifter is asleep or motion is reduced. urgency 0..1
   * ramps with the countdown; frozen = Stabilizer hold (ice tint, no drift). */
  private wind: {
    dx: number; dy: number; urgency: number; frozen: boolean; dots: WindDot[];
  } | null = null;
  /** canvas holds pixels from the last draw() — one clear on the first
   * empty frame guarantees no residue (P2.1 dirty flag) */
  private canvasDirty = false;
  /** A2 (audit 2026-10-29): wind-only paint duty-cycle is a WALL-TIME
   * accumulator (max 30 Hz) instead of B2's frame-parity skip — parity
   * halved an already-degraded paint rate under load (7.3 paints/s at
   * 15 fps measured); wall-time never drops below the actual frame rate. */
  private lastWindPaint = 0;
  /** A4 (audit 2026-10-29) watchdog: >0 while the 2D context is lost and
   * contextrestored has NOT fired; the live loop then self-heals by probe. */
  private ctxLostAt = 0;
  private lostProbeAt = 0;
  /** A3 (audit 2026-10-29): board rect cache — one forced-layout fetch per
   * 500 ms instead of one per painted frame; resize/scroll invalidate it. */
  private rectCache: DOMRect | null = null;
  private rectAt = 0;
  /** C2 (audit 2026-10-29): the fx canvas flips between viewport-sized
   * backing store and board+margin. The 30 Hz wind steady state (depth
   * >=6, always-on at the depths that matter) paints only inside
   * rect+/-16, yet a viewport buffer (3200x2000 px at dpr 2) re-rastered
   * ~2.6 MP of empty margin every frame — software-raster clients
   * (VRAM-pressure fallback, the bug 2 "slows over time" case) paid it
   * 24-30x/s. Anything that can paint outside the board box (full wash,
   * chroma ghosts, sweep strip, particles, rings) flips it back to
   * viewport size; flips are transient-bounded, the steady state never
   * touches canvas size again. Draw coords stay viewport CSS coords in
   * both modes — the board-mode offset rides the draw transform. */
  private fullCanvas = true;
  private boxX = 0;
  private boxY = 0;
  private boxW = 0;
  private boxH = 0;
  /** Covers the wind erase rect (rect+/-16) + band wash (rect+/-8) + AA. */
  private static readonly BOARD_MARGIN = 32;
  /** E1 (2026-10-28): dirty-rect drawing bookkeeping. The canvas is only
   * re-rastered over (last draw's bbox ∪ this frame's bbox); `bboxFull`
   * means the last draw painted viewport-wide (flash/sweep), forcing one
   * full clear next frame. Without this the sparse blast tail (~1.4 s per
   * boom) dirties the whole viewport-sized buffer every frame — the
   * software-raster HiDPI case (old/VM GPU) where a full-viewport
   * re-raster dominated blast-window frames (25–31 frames >30 ms). */
  private prevBBox: { x0: number; y0: number; x1: number; y1: number } | null = null;
  private bboxFull = false;
  /** E2: chromatic ghost gradients cached per blast center (stops at alpha
   * 1; the decaying alpha rides globalAlpha) so the 520 ms chroma window
   * allocates 2 gradients total instead of ~2 per frame. */
  private chromaGrads: { x: number; y: number; g: [CanvasGradient, CanvasGradient] } | null = null;
  // B4 (audit 2026-10-28): sweep/band gradient caches. The sweep gradient is
  // built in the effect's own translate/rotate space with a constant half
  // band, so ONE lazy CanvasGradient serves every sweep on every shift; the
  // band strip gradients depend only on the board rect, keyed by its edges
  // (rebuilt on resize/scroll only). Time-varying alpha rides globalAlpha
  // with color stops pinned at alpha 1 — the E2 chroma-cache pattern.
  private sweepGrad: CanvasGradient | null = null;
  private bandGrads: { left: number; top: number; right: number; bottom: number; g: CanvasGradient[] } | null = null;
  private reduce: MediaQueryList | null = null;

  constructor(
    host: HTMLElement,
    private readonly center: (cell: string) => { x: number; y: number } | null,
    private readonly boardEl: () => HTMLElement | null,
    private readonly sfx?: Pick<Sfx, 'play'>,
    /** Observer tapped BEFORE the built-in effects run (main.ts marks the
     * summary panel .death-blast from the FATAL boom here). */
    private readonly tap?: (ev: FxEvent) => void,
  ) {
    this.canvas = document.createElement('canvas');
    this.canvas.id = 'fx';
    this.canvas.setAttribute('aria-hidden', 'true');
    host.prepend(this.canvas);
    this.ctx2d = this.canvas.getContext('2d');
    this.dpr = Math.min(2, window.devicePixelRatio || 1);
    this.resize();
    window.addEventListener('resize', this.resize);
    // A3 (audit 2026-10-29): scroll moves the board's viewport coords —
    // drop the rect cache (passive capture listener, no perf cost).
    window.addEventListener('scroll', this.invalidateRect, { capture: true, passive: true });
    // T4 (audit 2026-10-28): if the GPU drops the 2D context, every draw
    // becomes a silent no-op while the loop keeps burning frames — the wind
    // reads dead but gameplay is fine, forever. React to loss/restore: drop
    // ctx2d (draw/clearCanvas are null-safe; the rAF loop keeps running) and
    // also drop the cached CanvasGradients, which belong to the dead context.
    // On restore: re-acquire, re-apply dpr/size bookkeeping, force a redraw.
    this.canvas.addEventListener('contextlost', (e) => {
      e.preventDefault(); // required or the browser never fires contextrestored
      this.ctx2d = null;
      this.sweepGrad = null;
      this.bandGrads = null;
      this.chromaGrads = null;
      // A4 (audit 2026-10-29): contextrestored can simply never arrive —
      // Chrome under VRAM pressure (e.g. a local LLM sharing the GPU) can
      // leave a canvas lost for minutes or until reload. Arm the watchdog;
      // the live rAF loop then self-heals via lostWatchdog().
      this.ctxLostAt = performance.now();
      this.lostProbeAt = this.ctxLostAt;
    });
    this.canvas.addEventListener('contextrestored', () => this.restoreContext());
    if (window.matchMedia) {
      this.reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
      this.reduce.addEventListener?.('change', () => {
        if (this.calm) this.stopAll();
      });
    }
  }

  private get calm(): boolean {
    return this.reduce?.matches ?? false;
  }

  private readonly resize = (): void => {
    this.rectCache = null; // A3
    // C2: rebuild at viewport size; the next frame's layoutFx() flips
    // back to the board box if only board-locked layers are live.
    this.fullCanvas = true;
    this.boxX = 0;
    this.boxY = 0;
    this.boxW = Math.max(1, window.innerWidth);
    this.boxH = Math.max(1, window.innerHeight);
    const s = this.canvas.style;
    s.left = '0px';
    s.top = '0px';
    s.width = `${this.boxW}px`;
    s.height = `${this.boxH}px`;
    this.canvas.width = Math.floor(this.boxW * this.dpr);
    this.canvas.height = Math.floor(this.boxH * this.dpr);
    // E1: setting width/height implicitly clears the buffer — stale bbox
    // bookkeeping must go with it.
    this.prevBBox = null;
    this.bboxFull = false;
  };

  private readonly invalidateRect = (): void => {
    this.rectCache = null;
  };

  /** C2: pick canvas mode/geometry from what is live right now. Board
   * mode only while every live effect is board-locked (wind, board
   * washes, edge bands, board-scoped sweeps); particles/rings/full
   * washes/chroma flip to viewport. A real switch resizes the canvas
   * (implicit bitmap clear) so bbox bookkeeping restarts and the same
   * frame repaints everything alive — geometry-stable calls are pure
   * no-ops. */
  private layoutFx(): void {
    let full = this.chroma > 0.2;
    if (!full) {
      for (const e of this.effects) {
        if (e.age < 0) continue; // pre-born draws nothing
        if ('peak' in e) {
          if (!(e as Flash).board) full = true;
        } else if (!('ax' in e)) {
          full = true; // sweep strip / particles / rings reach past the box
        }
        if (full) break;
      }
    }
    let bx = 0;
    let by = 0;
    let bw = Math.max(1, window.innerWidth);
    let bh = Math.max(1, window.innerHeight);
    if (!full) {
      const r = this.boardRect();
      if (!r) full = true; // no board in DOM yet: keep viewport default
      else {
        const m = Fx.BOARD_MARGIN;
        bx = Math.max(0, Math.floor(r.left - m));
        by = Math.max(0, Math.floor(r.top - m));
        bw = Math.min(window.innerWidth, Math.ceil(r.right + m)) - bx;
        bh = Math.min(window.innerHeight, Math.ceil(r.bottom + m)) - by;
        if (bw < 80 || bh < 80) full = true; // degenerate: stay wide
      }
    }
    if (full) {
      const vw = Math.max(1, window.innerWidth);
      const vh = Math.max(1, window.innerHeight);
      if (this.fullCanvas && this.boxW === vw && this.boxH === vh) return;
      this.fullCanvas = true;
      this.boxX = 0;
      this.boxY = 0;
      this.boxW = vw;
      this.boxH = vh;
    } else {
      if (!this.fullCanvas && bx === this.boxX && by === this.boxY && bw === this.boxW && bh === this.boxH) return;
      this.fullCanvas = false;
      this.boxX = bx;
      this.boxY = by;
      this.boxW = bw;
      this.boxH = bh;
    }
    const s = this.canvas.style;
    s.left = `${this.boxX}px`;
    s.top = `${this.boxY}px`;
    s.width = `${this.boxW}px`;
    s.height = `${this.boxH}px`;
    this.canvas.width = Math.floor(this.boxW * this.dpr);
    this.canvas.height = Math.floor(this.boxH * this.dpr);
    this.prevBBox = null;
    this.bboxFull = false;
  };

  // ------------------------------ event API --------------------------------

  /** One entry point: every board/engine observation lands here. Sound is
   * coupled at the effect site so visuals and audio can never diverge. */
  event(ev: FxEvent): void {
    try {
      this.tap?.(ev);
      this.onEvent(ev);
    } catch {
      // Juice must never break gameplay (or the headless error assertions).
    }
  }

  private onEvent(ev: FxEvent): void {
    switch (ev.kind) {
      case 'reveal': {
        this.sfx?.play('reveal');
        if (this.calm) return;
        const c = this.center(ev.cell);
        if (!c) return;
        // gentle puff proportional to how much the flood opened
        const n = Math.min(4 + ev.count, 16);
        this.burst(c.x, c.y, { n, speed: 0.05, color: '#7fa6c9', grav: -0.00002, ttl: 500, size: 2 });
        break;
      }
      case 'flag': {
        this.sfx?.play('flag');
        const c = this.center(ev.cell);
        if (!c) return;
        this.ring(c.x, c.y, { r1: 26, ttl: 380, color: ev.mine ? '#ff6b6b' : '#e8b13c', width: 2 });
        if (!this.calm) {
          this.burst(c.x, c.y, {
            n: ev.mine ? 14 : 8, speed: 0.12, color: ev.mine ? '#ff6b6b' : '#e8b13c',
            grav: 0.0002, ttl: 520, size: 2,
          });
        }
        break;
      }
      case 'unflag':
        this.sfx?.play('unflag');
        break;
      case 'shift': {
        this.sfx?.play('shift');
        if (this.calm) return;
        this.sweepBurst(ev.dx, ev.dy);
        break;
      }
      case 'shift-warning': {
        // Final-3-turns beat (ruling 2026-10-28): every remaining turn of a
        // shift cycle stamps itself across the field — urgent rising tick,
        // one amber warning streak, and a soft board-wide pulse.
        this.sfx?.play('shiftWarn');
        if (this.calm) return;
        const urgency = ev.turns <= 1 ? 0.9 : ev.turns === 2 ? 0.6 : 0.4;
        this.sweep(ev.dx, ev.dy, urgency);
        this.flash('#e8b13c', 0.07 + 0.05 * (4 - ev.turns), 340, true);
        // Edge direction bars on EVERY warning turn (user ruling 2026-10-28):
        // at depth 6 the first shift lands ~28 turns in, so previously no
        // exit/re-entry band was painted until the landing itself — the
        // "screen bar showing the shift direction" read as missing.
        this.effects.push({
          age: 0, ttl: 460 + urgency * 220,
          ax: Math.sign(ev.dx), ay: Math.sign(ev.dy),
          strength: 0.4 + 0.35 * urgency,
        } as Band);
        break;
      }
      case 'boom': {
        const c = this.center(ev.cell) ?? this.boardCenter();
        if (ev.shielded || ev.cause === 'SHIELDED') {
          this.sfx?.play('shielded');
          this.ring(c.x, c.y, { r1: 60, ttl: 500, color: '#4fd2ff', width: 3 });
          this.flash('#4fd2ff', 0.16, 260);
          this.shake(4, 240);
          break;
        }
        this.sfx?.play('boom');
        this.flash('#ffdfc4', 0.55, 320);
        this.ring(c.x, c.y, { r1: 160, ttl: 520, color: '#ff7a29', width: 5 });
        this.ring(c.x, c.y, { r1: 90, ttl: 380, color: '#ffe9d2', width: 3 });
        if (!this.calm) {
          this.burst(c.x, c.y, { n: 46, speed: 0.42, color: '#ff7a29', grav: 0.00055, ttl: 900, size: 3 });
          this.burst(c.x, c.y, { n: 22, speed: 0.2, color: '#ffd9c0', grav: -0.00012, ttl: 1100, size: 2 });
          this.chromatic(9, 520, c);
          this.shake(14, 620);
        } else {
          this.flash('#e5484d', 0.25, 200);
        }
        break;
      }
      case 'deny':
        this.sfx?.play('deny');
        break;
    }
  }

  /** Sector-clear celebration: a rising spark sweep across the whole field.
   * `cells` are the revealed tiles (cell keys) to seed sparks from. */
  celebrate(cells: string[]): void {
    this.sfx?.play('clear');
    if (this.calm) {
      this.flash('#7ee2a0', 0.14, 300);
      return;
    }
    const step = Math.max(1, Math.floor(cells.length / 24));
    for (let i = 0; i < cells.length; i += step) {
      const c = this.center(cells[i]);
      if (!c) continue;
      this.burst(c.x, c.y + 4, {
        n: 3,
        speed: 0.09,
        color: i % (step * 2) === 0 ? '#7ee2a0' : '#e8b13c',
        grav: -0.00028, // rises like embers
        ttl: 900 + (i % 5) * 120,
        size: 2,
        delay: (i % 10) * 40,
      });
    }
    this.ringAtBoard({ r1: 220, ttl: 700, color: '#7ee2a0', width: 2 });
  }

  /** Drop everything and settle (used between tests / on newRun). */
  reset(): void {
    this.stopAll();
  }

  private stopAll(): void {
    this.effects.length = 0;
    this.wind = null;
    this.shakeAmp = 0;
    this.chroma = 0;
    if (this.shakeEl) {
      this.shakeEl.style.transform = '';
      this.shakeEl.classList.remove('shaking');
    }
    this.stopRaf();
    // the dirty-flag skip in frame() no longer guarantees a final clear on
    // this path (loop cancelled mid-flight), so clear here.
    this.clearCanvas();
    this.canvasDirty = false;
  }

  // ------------------------------ primitives --------------------------------

  private burst(
    x: number,
    y: number,
    o: { n: number; speed: number; color: string; grav: number; ttl: number; size: number; delay?: number },
  ): void {
    const delay = o.delay ?? 0;
    for (let i = 0; i < o.n; i++) {
      const a = Math.random() * TAU;
      const v = o.speed * (0.35 + Math.random() * 0.9);
      this.effects.push({
        x, y,
        vx: Math.cos(a) * v,
        vy: Math.sin(a) * v,
        age: -delay * Math.random(), // negative age = not born yet
        ttl: o.ttl * (0.6 + Math.random() * 0.7),
        size: o.size * (0.6 + Math.random()),
        color: o.color,
        grav: o.grav,
        drag: 0.985,
      });
    }
    this.startRaf();
  }

  private ring(
    x: number, y: number,
    o: { r1: number; ttl: number; color: string; width: number },
  ): void {
    this.effects.push({ x, y, age: 0, ttl: o.ttl, r0: 2, r1: o.r1, width: o.width, color: o.color });
    this.startRaf();
  }

  private ringAtBoard(o: { r1: number; ttl: number; color: string; width: number }): void {
    const c = this.boardCenter();
    this.ring(c.x, c.y, o);
  }

  private flash(color: string, peak: number, ttl: number, board = false): void {
    this.effects.push({ age: 0, ttl, color, peak, board } as Flash);
    this.startRaf();
  }

  /** Streak riding the shift direction across the board rect. `strength`
   * scales brightness/speed so the final-turn warning streaks read louder
   * than the ambient drift but quieter than the shift itself. */
  private sweep(dx: number, dy: number, strength = 1): void {
    const len = Math.hypot(dx, dy) || 1;
    this.effects.push({
      age: 0, ttl: 420,
      dx: (dx / len) * strength, dy: (dy / len) * strength,
    } as Sweep);
    this.startRaf();
  }

  /** The shift ITSELF: a train of three parallel streaks plus edge bands on
   * the exit and re-entry edges — makes the whole-field slide and the
   * toroidal wrap legible in one beat (ruling 2026-10-28). */
  private sweepBurst(dx: number, dy: number): void {
    for (const age of [0, -110, -220]) {
      const len = Math.hypot(dx, dy) || 1;
      this.effects.push({ age, ttl: 420, dx: dx / len, dy: dy / len } as Sweep);
    }
    this.effects.push({ age: 0, ttl: 560, ax: Math.sign(dx), ay: Math.sign(dy) } as Band);
    this.startRaf();
  }

  // --------------------------- telegraph wind layer -------------------------

  /** The Shifter telegraph as a live on-board wind field. Called from the
   * render-sync point (main.ts) after EVERY engine change, so direction,
   * countdown urgency and Stabilizer freeze ride the same source of truth
   * as the HUD chip. Null ⇒ Shifter asleep (or reduced motion). */
  setTelegraph(w: { dx: number; dy: number; urgency: number; frozen: boolean } | null): void {
    if (!w || this.calm) {
      this.wind = null;
      return;
    }
    const urgency = Math.max(0, Math.min(1, w.urgency));
    if (!this.wind || this.wind.dx !== w.dx || this.wind.dy !== w.dy) {
      const dots: WindDot[] = [];
      for (let i = 0; i < Fx.WIND_DOTS; i++) dots.push({ u: Math.random(), v: Math.random() });
      this.wind = { dx: w.dx, dy: w.dy, urgency, frozen: w.frozen, dots };
    } else {
      this.wind.urgency = urgency;
      this.wind.frozen = w.frozen;
    }
    if (this.wind) this.startRaf();
  }

  /** QA/browser-suite view of the live wind layer (never game logic). */
  get telegraphWind(): { dx: number; dy: number; frozen: boolean } | null {
    return this.wind
      ? { dx: this.wind.dx, dy: this.wind.dy, frozen: this.wind.frozen }
      : null;
  }

  /** Streak dots advance toroidally inside the wind field (normalized units). */
  private stepWind(dt: number): void {
    const w = this.wind;
    if (!w || w.frozen) return;
    const len = Math.hypot(w.dx, w.dy) || 1;
    const speed = 0.00018 + w.urgency * 0.00035; // board-widths per ms
    const ux = (w.dx / len) * speed * dt;
    const uy = (w.dy / len) * speed * dt;
    for (const d of w.dots) {
      d.u = ((d.u + ux) % 1 + 1) % 1;
      d.v = ((d.v + uy) % 1 + 1) % 1;
    }
  }

  /** Depth-6 awakening intro beat (user ruling 2026-10-28): a subtle,
   * one-shot screen shake so the Shifter warning modal has physical weight.
   * Amplitude is deliberately near the shielded-thud level (4), not a boom. */
  introRumble(): void {
    this.shake(3.4, 700);
  }

  private shake(amp: number, ttl: number): void {
    if (this.calm) return;
    this.shakeEl = document.getElementById('app');
    this.shakeAmp = Math.max(this.shakeAmp, amp);
    this.shakeTtl = ttl;
    this.shakeAge = 0;
    // D5 (audit 2026-10-28): layer promotion lives on .shaking for exactly
    // the shake window instead of permanently on #app — the 60 fps #fx
    // repaint no longer churns a promoted viewport-sized layer at rest.
    this.shakeEl?.classList.add('shaking');
    this.startRaf();
  }

  private chromatic(ampPx: number, ttl: number, at?: { x: number; y: number }): void {
    if (this.calm) return;
    const c = at ?? this.boardCenter();
    this.chromaX = c.x;
    this.chromaY = c.y;
    this.chroma = Math.max(this.chroma, ampPx);
    this.chromaTtl = ttl;
    this.chromaAge = 0;
    this.startRaf();
  }

  // -------------------------------- geometry --------------------------------

  private boardRect(): DOMRect | null {
    // A3 (audit 2026-10-29): B3 fetched exactly one rect per frame, but a
    // wind-live idle frame is 24-30/s — ~30 forced-layout syncs/s measured
    // (UpdateLayoutTree+PrePaint pairs in the idle-wind CDP trace). The
    // board cannot move without a resize/scroll/rebuild, all three covered:
    // 500 ms TTL + event invalidation; rebuilds keep identical geometry.
    const now = performance.now();
    if (this.rectCache && now - this.rectAt < 500) return this.rectCache;
    const r = this.boardEl()?.getBoundingClientRect() ?? null;
    this.rectCache = r;
    this.rectAt = now;
    return r;
  }

  private boardCenter(): { x: number; y: number } {
    const r = this.boardRect();
    return r ? { x: r.left + r.width / 2, y: r.top + r.height / 2 } : { x: innerWidth / 2, y: innerHeight / 2 };
  }

  // --------------------------------- loop -----------------------------------

  private startRaf(): void {
    if (!this.raf) {
      this.last = performance.now();
      this.rafDeadLogged = false; // fresh chain: a new death may log once
      this.raf = requestAnimationFrame(this.frame);
    }
  }

  private stopRaf(): void {
    if (this.raf) cancelAnimationFrame(this.raf);
    this.raf = 0;
  }

  /** A4: shared restore path for contextrestored AND the watchdog probe. */
  private restoreContext(): void {
    this.ctx2d = this.canvas.getContext('2d');
    this.ctxLostAt = 0;
    this.lostProbeAt = 0;
    document.documentElement.classList.remove('fx-dead');
    this.resize(); // re-applies dpr sizing (also resets bbox bookkeeping)
    this.canvasDirty = true; // force a full repaint once pixels are live
    this.startRaf(); // self-stopping if nothing pending; recovers a chain
    // that died while the context was gone (e.g. a T2-catched frame error)
  }

  /** A4 watchdog (runs from the live loop while the context is lost and
   * the restore event has not fired). Once the loss lingers past a beat:
   * flag html.fx-dead (main.ts keeps a DOM/CSS fallback glow armed on the
   * board for the shift telegraph — the fairness-critical signal must not
   * depend on the canvas) and probe once per second whether the context
   * actually executes commands again (1px paint + getImageData round
   * trip; getImageData throws or reads zeros while lost). */
  private lostWatchdog(now: number): void {
    if (now - this.ctxLostAt < 1200) return;
    if (!document.documentElement.classList.contains('fx-dead')) {
      console.warn('[fx] canvas context lost, contextrestored not received — DOM telegraph fallback armed, watchdog probing for recovery');
      document.documentElement.classList.add('fx-dead');
      // D5 (residue variant, playtest 2026-10-29): we preventDefault the
      // lost event (required to ever get contextrestored), which per spec
      // means the canvas KEEPS displaying its last painted bitmap while the
      // context is dead. If that frozen last frame was a flash (amber shift
      // wash / blue shield wash), the wash is stuck on screen and a dead
      // ctx2d cannot clearRect it. A canvas width/height assignment resets
      // the bitmap at the DOM level — works even with the context dead —
      // so the stuck wash is gone within ~1.2 s of the loss. Only at arm
      // time, so brief losses that restore in-flight keep their bitmap.
      this.canvas.width = this.canvas.width;
      this.canvas.height = this.canvas.height;
      this.prevBBox = null;
      this.bboxFull = false;
      this.sweepGrad = null;
      this.bandGrads = null;
      this.chromaGrads = null;
    }
    if (now - this.lostProbeAt < 1000) return;
    this.lostProbeAt = now;
    try {
      const c = this.canvas.getContext('2d');
      if (!c) return;
      c.setTransform(1, 0, 0, 1, 0, 0);
      c.clearRect(0, 0, 2, 2);
      c.fillStyle = 'rgba(7,9,11,255)';
      c.fillRect(0, 0, 2, 2);
      const px = c.getImageData(0, 0, 1, 1).data;
      if (px[3] === 255 && px[0] === 7) {
        console.warn('[fx] watchdog recovered live 2D context (contextrestored never fired)');
        this.restoreContext(); // resize() wipes the probe pixel
      }
    } catch {
      // still lost: getImageData throws while dead — keep waiting
    }
  }

  // T2 (audit 2026-10-28): a single exception escaping step()/draw() silently
  // kills the rAF chain while `raf` stays set — every later startRaf() is a
  // no-op and ALL juice is dead for the rest of the run (the "stops entirely,
  // forever" signature). Catch at the loop boundary, clear the handle so the
  // next game action re-arms the loop, and log once per death (startRaf
  // re-arms the flag) so a persistently broken draw cannot spam the console.
  private readonly frame = (now: number): void => {
    try {
      if (this.ctxLostAt) this.lostWatchdog(now);
      this.runFrame(now);
    } catch (err) {
      if (!this.rafDeadLogged) {
        this.rafDeadLogged = true;
        console.warn('[fx] draw frame threw — juice loop halted until next action:', err);
      }
      this.raf = 0;
    }
  };

  private runFrame(now: number): void {
    // A1 (audit 2026-10-29): the old 48 ms clamp THREW AWAY real time —
    // measured clampFactor 0.478 at 15 fps (wind dots advanced at half
    // speed; at 2-5 fps the telegraph "froze" — user bug 1's hang/freeze).
    // Everything integrated here is wrap/age physics (wind toroidal mod 1,
    // particles/rings/sweeps driven by age vs ttl), so it must see WALL
    // time; a huge gap just completes effects instead of slow-mo'ing them.
    // Capped at 2 s purely for float sanity after a backgrounded tab.
    const dt = Math.min(2000, now - this.last);
    this.last = now;
    this.step(dt);
    // P2.1: only touch the canvas when there is something to draw. During
    // the shake-only tail (effects drained, chroma decayed) the previous
    // frame already left the canvas clean — skip draw() and do a single
    // clear on the transition to empty so no residue frame lingers.
    const windLive = this.wind !== null && !this.calm;
    const hasPaint = this.effects.length > 0 || this.chroma > 0.2 || windLive;
    // B2 -> A2 (audit 2026-10-29): keep the wind-only paint duty-cycle at
    // max 30 Hz, but bound it by WALL time, not frame parity — parity
    // halved an already-degraded rate under load (7.3 paints/s at 15 fps
    // measured; dots visibly strobed under the telegraph). Wall-time means
    // every frame paints once fps drops below 30; at 60 fps the board-
    // sized clear+repaint still happens only ~30/s.
    const windOnly = windLive && this.effects.length === 0 && this.chroma <= 0.2;
    // C2: size the canvas for what just stepped (board box in the
    // wind-only steady state, viewport the moment a burst is live).
    if (hasPaint) this.layoutFx();
    if (hasPaint && (!windOnly || now - this.lastWindPaint >= 33)) {
      this.draw();
      this.lastWindPaint = now;
      this.canvasDirty = true;
    } else if (hasPaint) {
      // wind-only throttle window: the last wind frame stays on canvas
    } else if (this.canvasDirty) {
      this.clearCanvas();
      this.canvasDirty = false;
    }
    // shake decay (transform on #app; overlays are fixed to the viewport and
    // inherit the shake too — the whole world shakes as one)
    if (this.shakeAmp > 0.1) {
      this.shakeAge += dt;
      // LINEAR ttl decay guarantees termination: a multiplicative decay
      // asymptotes to 0, the rAF loop exits with `> 0.1` false while the
      // element is still displaced — #app left translated = page overflow.
      const k = Math.max(0, 1 - this.shakeAge / this.shakeTtl);
      const amp = this.shakeAmp * k;
      if (this.shakeEl) {
        this.shakeEl.style.transform = amp <= 0.1
          ? ''
          : `translate(${(Math.random() * 2 - 1) * amp}px, ${(Math.random() * 2 - 1) * amp}px) rotate(${(Math.random() * 2 - 1) * amp * 0.06}deg)`;
        if (amp <= 0.1) this.shakeEl.classList.remove('shaking');
      }
      if (this.shakeAge >= this.shakeTtl) {
        this.shakeAmp = 0;
        if (this.shakeEl) {
          this.shakeEl.style.transform = '';
          this.shakeEl.classList.remove('shaking');
        }
      }
    }
    if (this.chroma > 0.2) {
      this.chromaAge += dt;
      this.chroma = Math.max(0, this.chroma * (1 - dt / this.chromaTtl));
      if (this.chromaAge >= this.chromaTtl) this.chroma = 0; // canvas fully cleared by loop-exit clearCanvas
    }
    if (this.effects.length > 0 || this.shakeAmp > 0.1 || this.chroma > 0.2
      || (this.wind !== null && !this.calm)) {
      this.raf = requestAnimationFrame(this.frame);
    } else {
      this.stopRaf();
      this.clearCanvas(); // guaranteed final clear at teardown
      this.canvasDirty = false;
    }
  }

  private step(dt: number): void {
    this.stepWind(dt);
    for (let i = this.effects.length - 1; i >= 0; i--) {
      const e = this.effects[i];
      e.age += dt;
      if (e.age > e.ttl) {
        this.effects.splice(i, 1);
        continue;
      }
      if ('vx' in e && e.age > 0) {
        e.vy += e.grav * dt;
        e.x += e.vx * dt;
        e.y += e.vy * dt;
        const drag = Math.pow(e.drag, dt / 16);
        e.vx *= drag;
        e.vy *= drag;
      }
    }
  }

  private clearCanvas(): void {
    this.ctx2d?.clearRect(0, 0, this.canvas.width, this.canvas.height);
    this.prevBBox = null;
    this.bboxFull = false;
  }

  /** Radius (CSS px) of the chromatic ghost halo around the blast center
   * (P2.2). Two viewport-wide fills became two clamped radial fills. */
  private static readonly CHROMA_RADIUS = 260;

  /** Telegraph wind streak count (ruling 2026-10-28). */
  private static readonly WIND_DOTS = 30;

  private draw(): void {
    const ctx = this.ctx2d;
    if (!ctx) return;
    const w = this.canvas.width / this.dpr;
    const h = this.canvas.height / this.dpr;
    // C2: board mode offsets the transform so every draw call keeps
    // using viewport CSS coords; full mode has boxX/Y = 0 (identity).
    ctx.setTransform(this.dpr, 0, 0, this.dpr, -this.boxX * this.dpr, -this.boxY * this.dpr);
    // B3 (audit 2026-10-28): the board rect CANNOT change mid-frame, yet it
    // was fetched 2–8× per frame (bbox loop + every drawSweep/drawBand/
    // drawWind call) — each a forced-layout sync point (~50/s measured).
    // Fetch once per frame and thread it through the draw helpers.
    const rect = this.boardRect();

    // E1: dirty-rect drawing. Compute this frame's paint bbox (union of
    // live particle/ring/halo bounds), then clear (prev ∪ cur) instead of
    // the whole viewport-sized buffer. Flash/sweep paint viewport-wide, so
    // frames containing one clear fully. In the sparse blast tail a few
    // particles keep the loop alive ~1.4 s per boom — per-frame canvas
    // damage shrinks from the full viewport to the particles' few-hundred
    // px box, which is what re-raster cost on software-raster machines.
    let cx0 = Infinity, cy0 = Infinity, cx1 = -Infinity, cy1 = -Infinity;
    let curFull = false;
    const grow = (x0: number, y0: number, x1: number, y1: number): void => {
      if (x0 < cx0) cx0 = x0;
      if (y0 < cy0) cy0 = y0;
      if (x1 > cx1) cx1 = x1;
      if (y1 > cy1) cy1 = y1;
    };
    if (this.chroma > 0.2) {
      const cr = Fx.CHROMA_RADIUS + this.chroma; // ghosts offset by ±chroma px
      grow(this.chromaX - cr, this.chromaY - cr, this.chromaX + cr, this.chromaY + cr);
    }
    if (this.wind !== null && !this.calm) {
      if (rect) grow(rect.left - 16, rect.top - 16, rect.right + 16, rect.bottom + 16);
    }
    for (const e of this.effects) {
      if (e.age < 0) continue; // pre-born: draws nothing this frame
      if ('peak' in e) {
        if ((e as Flash).board) {
          if (rect) grow(rect.left - 8, rect.top - 8, rect.right + 8, rect.bottom + 8);
          continue;
        }
        curFull = true; break;
      }
      if ('dx' in e) {
        // B5: sweeps are board-relative — bound the strip's four rotated
        // corners (exactly the region drawSweep fills) instead of forcing
        // a viewport-wide clear. Rare impact effects (full flashes, rings,
        // chroma) still set curFull, so the machinery stays exercised.
        const half = Math.max(rect?.width ?? 0, rect?.height ?? 0) / 2 + 14;
        const bandS = 26;
        const k = e.age < 0 ? 0 : Math.min(1, e.age / e.ttl);
        const travel = (k - 0.5) * 2 * half * 1.2;
        const cx = (rect?.left ?? 0) + (rect?.width ?? w) / 2;
        const cy = (rect?.top ?? 0) + (rect?.height ?? 0) / 2;
        const len = Math.hypot(e.dx, e.dy) || 1;
        const ux = e.dx / len, uy = e.dy / len;
        for (const sx of [-1, 1]) {
          for (const sy of [-1, 1]) {
            const px = cx + ux * (travel + sx * bandS) - uy * sy * half;
            const py = cy + uy * (travel + sx * bandS) + ux * sy * half;
            grow(px, py, px, py);
          }
        }
        continue;
      }
      if ('ax' in e) {
        if (rect) grow(rect.left - 8, rect.top - 8, rect.right + 8, rect.bottom + 8);
        continue;
      }
      const k = e.age / e.ttl;
      if ('vx' in e) {
        const s = e.size * (1 - k * 0.5) + 1; // arc radius ≤ size
        grow(e.x - s, e.y - s, e.x + s, e.y + s);
      } else {
        // ring: eased radius + current stroke half-width (+1 AA slop)
        const rr = e.r0 + (e.r1 - e.r0) * (1 - Math.pow(1 - k, 2))
          + (e.width * (1 - k * 0.6)) / 2 + 1;
        grow(e.x - rr, e.y - rr, e.x + rr, e.y + rr);
      }
    }
    if (curFull || this.bboxFull) {
      ctx.clearRect(this.boxX, this.boxY, w, h); // CSS coords of the canvas itself
    } else if (cx1 > cx0 && cy1 > cy0) {
      let ux0 = Math.min(cx0, this.prevBBox?.x0 ?? cx0);
      let uy0 = Math.min(cy0, this.prevBBox?.y0 ?? cy0);
      let ux1 = Math.max(cx1, this.prevBBox?.x1 ?? cx1);
      let uy1 = Math.max(cy1, this.prevBBox?.y1 ?? cy1);
      // C2: clamp to the canvas's own CSS footprint (viewport coords),
      // not [0, w] — board mode has boxX/Y > 0.
      ux0 = Math.max(this.boxX, Math.floor(ux0)); uy0 = Math.max(this.boxY, Math.floor(uy0));
      ux1 = Math.min(this.boxX + w, Math.ceil(ux1)); uy1 = Math.min(this.boxY + h, Math.ceil(uy1));
      if (ux1 > ux0 && uy1 > uy0) ctx.clearRect(ux0, uy0, ux1 - ux0, uy1 - uy0);
    }

    // P2.3: chromatic ghosts AND the flashes share one `screen` pass —
    // `globalCompositeOperation` is toggled at most once per frame instead
    // of mid-frame. Without active chroma (e.g. every reduced-motion event,
    // and the post-chroma tail of a normal boom) flashes stay plain
    // source-over exactly as before.
    const screenPass = this.chroma > 0.2;
    if (screenPass) {
      ctx.globalCompositeOperation = 'screen';
      this.drawChromaGhosts(ctx);
      for (const e of this.effects) {
        if (e.age >= 0 && 'peak' in e) this.drawFlash(ctx, e, rect);
      }
      ctx.globalCompositeOperation = 'source-over';
      ctx.globalAlpha = 1;
    }

    for (const e of this.effects) {
      if (e.age < 0) continue;
      const k = e.age / e.ttl;
      if ('vx' in e) {
        const alpha = 1 - k;
        ctx.globalAlpha = alpha;
        ctx.fillStyle = e.color;
        ctx.beginPath();
        ctx.arc(e.x, e.y, e.size * (1 - k * 0.5), 0, TAU);
        ctx.fill();
      } else if ('r1' in e) {
        ctx.globalAlpha = (1 - k) * 0.9;
        ctx.strokeStyle = e.color;
        ctx.lineWidth = e.width * (1 - k * 0.6);
        ctx.beginPath();
        ctx.arc(e.x, e.y, e.r0 + (e.r1 - e.r0) * (1 - Math.pow(1 - k, 2)), 0, TAU);
        ctx.stroke();
      } else if ('dx' in e) {
        this.drawSweep(ctx, e, k, rect);
      } else if ('ax' in e) {
        this.drawBand(ctx, e, k, rect);
      } else if (!screenPass) {
        this.drawFlash(ctx, e, rect);
      }
    }
    if (this.wind !== null && !this.calm) this.drawWind(ctx, rect);
    ctx.globalAlpha = 1;
    // E1: record what THIS draw could have painted, for the next frame's
    // clear. On an empty-cur frame (all effects pre-born) keep the pending
    // bookkeeping — the dirty region must survive until a frame clears it.
    if (curFull) {
      this.bboxFull = true;
      this.prevBBox = null;
    } else if (cx1 > cx0 && cy1 > cy0) {
      this.bboxFull = false;
      this.prevBBox = {
        x0: Math.max(this.boxX, Math.floor(cx0)),
        y0: Math.max(this.boxY, Math.floor(cy0)),
        x1: Math.min(this.boxX + w, Math.ceil(cx1)),
        y1: Math.min(this.boxY + h, Math.ceil(cy1)),
      };
    }
  }

  /** Full-viewport wash (320 ms on boom peak) — the one intentional
   * viewport-wide fill, coalesced into the screen pass when chroma is live. */
  private drawFlash(ctx: CanvasRenderingContext2D, f: Flash, r: DOMRect | null): void {
    ctx.globalAlpha = f.peak * (1 - f.age / f.ttl);
    ctx.fillStyle = f.color;
    // B5 (audit 2026-10-28): the recurring shift-warning wash paints the
    // board rect, not the whole viewport — the per-frame clear then stays
    // board-sized instead of viewport-sized (B1's P3 clone pattern). The
    // impact flashes (boom/shield/death) keep the viewport-wide wash:
    // those are rare one-offs the partial-clear machinery must survive.
    if (f.board && r) ctx.fillRect(r.left, r.top, r.width, r.height);
    else ctx.fillRect(0, 0, this.canvas.width / this.dpr, this.canvas.height / this.dpr);
  }

  /** P2.2: R/B ghosts as radial halos clamped to `CHROMA_RADIUS` around the
   * blast center (offset by ±chroma px), instead of two viewport-sized
   * `fillRect`s. Rect is clipped to the canvas so off-edge halos don't
   * request out-of-bounds raster. */
  private drawChromaGhosts(ctx: CanvasRenderingContext2D): void {
    const a = Math.min(0.35, this.chroma / 30);
    const r = Fx.CHROMA_RADIUS;
    const w = this.canvas.width / this.dpr;
    const h = this.canvas.height / this.dpr;
    // E2: the two radial gradients are cached per blast center with
    // alpha-1 stops; the decaying alpha rides globalAlpha and the ±chroma
    // R/B separation is applied by translating the paint (the closing-split
    // motion costs nothing per frame). 2 gradient allocations per boom
    // instead of ~2/frame across the 520 ms window.
    let cg = this.chromaGrads;
    if (!cg || cg.x !== this.chromaX || cg.y !== this.chromaY) {
      const mk = (rgb: string): CanvasGradient => {
        const grad = ctx.createRadialGradient(this.chromaX, this.chromaY, 0, this.chromaX, this.chromaY, r);
        grad.addColorStop(0, `rgba(${rgb}, 1)`);
        grad.addColorStop(0.55, `rgba(${rgb}, 0.45)`);
        grad.addColorStop(1, `rgba(${rgb}, 0)`);
        return grad;
      };
      cg = { x: this.chromaX, y: this.chromaY, g: [mk('255, 40, 60'), mk('40, 120, 255')] };
      this.chromaGrads = cg;
    }
    const offs = [this.chroma, -this.chroma];
    for (let i = 0; i < 2; i++) {
      const off = offs[i];
      const gx = this.chromaX + off;
      // clamp in canvas space, then shift back into the translated frame
      const x0 = Math.max(0, gx - r) - off;
      const x1 = Math.min(w, gx + r) - off;
      const y0 = Math.max(0, this.chromaY - r);
      const y1 = Math.min(h, this.chromaY + r);
      if (x1 <= x0 || y1 <= y0) continue;
      ctx.save();
      ctx.translate(off, 0);
      ctx.globalAlpha = a;
      ctx.fillStyle = cg.g[i];
      ctx.fillRect(x0, y0, x1 - x0, y1 - y0);
      ctx.restore();
    }
  }

  /** A luminous band sweeping from the trailing edge of the board toward the
   * shift direction — makes the toroidal slide readable as MOTION. */
  private drawSweep(ctx: CanvasRenderingContext2D, e: Sweep, k: number, r: DOMRect | null): void {
    if (!r) return;
    const pad = 14;
    const cx = r.left + r.width / 2;
    const cy = r.top + r.height / 2;
    const half = Math.max(r.width, r.height) / 2 + pad;
    const travel = (k - 0.5) * 2 * half * 1.2;
    const band = 26;
    const alpha = Math.sin(Math.PI * k) * 0.5;
    ctx.save();
    ctx.translate(cx + e.dx * travel, cy + e.dy * travel);
    ctx.rotate(Math.atan2(e.dy, e.dx));
    // B4 (audit 2026-10-28): one cached gradient per ctx — it lives in this
    // translated/rotated space and `band` is constant, so every sweep frame
    // reuses it (was: 1 allocation per sweep per frame). The animated alpha
    // rides globalAlpha with stops pinned at alpha 1 (E2 pattern). Side
    // benefit: sweep alpha is now DETERMINISTIC — previously it multiplied
    // the gradient's alpha into whatever globalAlpha a preceding effect in
    // the draw loop had left behind.
    if (!this.sweepGrad) {
      const grad = ctx.createLinearGradient(-band, 0, band, 0);
      grad.addColorStop(0, 'rgba(143, 212, 255, 0)');
      grad.addColorStop(0.5, 'rgba(143, 212, 255, 1)');
      grad.addColorStop(1, 'rgba(143, 212, 255, 0)');
      this.sweepGrad = grad;
    }
    ctx.globalAlpha = alpha;
    ctx.fillStyle = this.sweepGrad;
    ctx.fillRect(-band, -half, band * 2, half * 2);
    ctx.restore();
  }

  /** Toroidal wrap tell (ruling 2026-10-28): amber bands on the edges mines
   * EXIT (full strength) and the opposite edges they RE-ENTER (fainter) —
   * teaches the wrap while the slide itself sweeps through the field. */
  private drawBand(ctx: CanvasRenderingContext2D, e: Band, k: number, r: DOMRect | null): void {
    if (!r) return;
    const band = 22;
    const a = Math.sin(Math.PI * k) * 0.55 * (e.strength ?? 1);
    // B4 (audit 2026-10-28): strip gradients are pure functions of the board
    // rect — cache them keyed by its edges (was: up to 8 createLinearGradient
    // allocations per band per frame). Rebuild only on resize/scroll. The
    // animated alpha rides globalAlpha; stops pin alpha 1 at the edge.
    let bg = this.bandGrads;
    if (!bg || bg.left !== r.left || bg.top !== r.top || bg.right !== r.right || bg.bottom !== r.bottom) {
      const mk = (x0: number, y0: number, x1: number, y1: number): CanvasGradient => {
        const grad = ctx.createLinearGradient(x0, y0, x1, y1);
        grad.addColorStop(0, 'rgba(232, 177, 60, 0)');
        grad.addColorStop(1, 'rgba(232, 177, 60, 1)');
        return grad;
      };
      bg = {
        left: r.left, top: r.top, right: r.right, bottom: r.bottom,
        // edge index: 0 right, 1 left, 2 bottom, 3 top (transparent inner →
        // opaque outer, matching each strip's original gradient direction)
        g: [
          mk(r.right - band, 0, r.right, 0),
          mk(r.left + band, 0, r.left, 0),
          mk(0, r.bottom - band, 0, r.bottom),
          mk(0, r.top + band, 0, r.top),
        ],
      };
      this.bandGrads = bg;
    }
    const grads = bg.g;
    const strip = (edge: number, x0: number, y0: number, x1: number, y1: number, mul: number): void => {
      ctx.globalAlpha = a * mul;
      ctx.fillStyle = grads[edge];
      ctx.fillRect(x0, y0, x1 - x0, y1 - y0);
    };
    // D3 (audit 2026-10-28): the single-entry loop wrapper left over from
    // the old multi-edge design flattened to straight-line code (mul was 1).
    if (e.ax > 0) strip(0, r.right - band, r.top, r.right, r.bottom, 1);
    if (e.ax < 0) strip(1, r.left, r.top, r.left + band, r.bottom, 1);
    if (e.ay > 0) strip(2, r.left, r.bottom - band, r.right, r.bottom, 1);
    if (e.ay < 0) strip(3, r.left, r.top, r.right, r.top + band, 1);
    const back = 0.55; // re-entry edges: fainter
    if (e.ax > 0) strip(1, r.left, r.top, r.left + band, r.bottom, back);
    if (e.ax < 0) strip(0, r.right - band, r.top, r.right, r.bottom, back);
    if (e.ay > 0) strip(3, r.left, r.top, r.right, r.top + band, back);
    if (e.ay < 0) strip(2, r.left, r.bottom - band, r.right, r.bottom, back);
  }

  /** The always-on telegraph wind: short streaks drifting across the board in
   * the announced shift direction, wrapping like the mines themselves. Alpha,
   * length and speed ramp with countdown urgency; a Stabilizer hold freezes
   * the drift and turns it ice-blue (the pause is felt ON the board). */
  private drawWind(ctx: CanvasRenderingContext2D, r: DOMRect | null): void {
    const w = this.wind;
    if (!w || !r) return;
    const len = Math.hypot(w.dx, w.dy) || 1;
    const ux = w.dx / len;
    const uy = w.dy / len;
    const streak = w.frozen ? 9 : 10 + w.urgency * 14;
    // Visibility floor raised (user ruling 2026-10-28): at depth 6 the first
    // shift is ~28 turns out, so urgency starts near 0 — the old 0.12-alpha
    // hairlines were invisible there, making the telegraph read as "missing".
    const alpha = w.frozen ? 0.42 : 0.26 + w.urgency * 0.34;
    ctx.save();
    ctx.beginPath();
    ctx.rect(r.left - 4, r.top - 4, r.width + 8, r.height + 8);
    ctx.clip();
    ctx.strokeStyle = w.frozen ? 'rgb(79, 210, 255)' : 'rgb(232, 177, 60)';
    ctx.lineWidth = w.frozen ? 2.6 : 2 + w.urgency * 1.1;
    ctx.lineCap = 'round';
    ctx.globalAlpha = alpha;
    ctx.beginPath();
    for (const d of w.dots) {
      const x = r.left + d.u * r.width;
      const y = r.top + d.v * r.height;
      ctx.moveTo(x - (ux * streak) / 2, y - (uy * streak) / 2);
      ctx.lineTo(x + (ux * streak) / 2, y + (uy * streak) / 2);
    }
    ctx.stroke();
    ctx.restore();
  }
}