/* global React, ReactDOM */
const { useState, useEffect, useRef, useCallback, useMemo } = React;

// =============================================================
// Pixel-art device — measured coordinates (1179 x 2556 source)
// =============================================================
// All hit zones / display rects use ABSOLUTE coords in the source PNG.
// They get scaled with the image via percent positioning at render time.

const DEVICE_W = 1179;
const DEVICE_H = 2556;

// Display rectangles (inner safe area for CRT mount) — calibrated
const TITLE_DISPLAY_DEFAULT = { id: "title", x: 107, y: 284, w: 954, h: 98 };
const MAIN_DISPLAY_DEFAULT  = { id: "main",  x: 121, y: 431, w: 943, h: 1574 };

// Top LEDs (3 amber slots in the gold yoke) — calibrated
const LEDS_DEFAULT = [
  { id: "led1", x: 422, y: 176, w: 72, h: 42 },
  { id: "led2", x: 549, y: 176, w: 73, h: 42 },
  { id: "led3", x: 680, y: 176, w: 72, h: 42 },
];

// Bottom buttons (hit zones — calibrated to physical button bounds)
const BUTTONS_DEFAULT = {
  next: { id: "next", label: ">",   x: 387, y: 2185, w: 122, h: 126 },
  go:   { id: "go",   label: "GO",  x: 602, y: 2173, w: 174, h: 180 },
  opt:  { id: "opt",  label: "...", x: 874, y: 2181, w: 178, h: 128 },
};

// Chassis art — the shell is split into layers so the gear can sandwich
// between them: bottom strip (y 2435-2556) under the gear, body (y 0-2435)
// over it, occluding the gear's top half.
const ART_DIR = "assets/simple anims/";
const CHASSIS_TOP_SRC = ART_DIR + "top_gear_tile.png"; // 1179x2435
const CHASSIS_BOT_SRC = ART_DIR + "bot_gear_tile.png"; // 1179x121
const GEAR_SRC        = ART_DIR + "gear-to-turn.png";  // 150x150
const PRESS_ART_SRC = {
  next: ART_DIR + "arrow_press.png", // 149x172
  go:   ART_DIR + "go_press.png",    // 265x295
  opt:  ART_DIR + "ell_press.png",   // 202x171
};
// 224x382 h264, audio tracks -> always muted. Slow runs the landing idle;
// fast is the GO feedback, playing through the research wait.
const WOODPECKER_SLOW_SRC = "assets/woodpecker%20slow%20smol.mp4";
const WOODPECKER_FAST_SRC = "assets/woodpecka%20fast%20smol.mp4"; // sic: "woodpecka"

// Sprite rects (source-pixel coords, natural tile sizes). Press tiles default
// to the center of their hit zones, the gear near its baked-in spot — nudge
// pixel-perfect in calibrate mode, then bake the exported values back here.
const SPRITES_DEFAULT = {
  gear: { id: "gear", x: 761, y: 2355, w: 150, h: 150 },
  next: { id: "next", x: 373, y: 2162, w: 149, h: 172 },
  go:   { id: "go",   x: 556, y: 2115, w: 265, h: 295 },
  opt:  { id: "opt",  x: 862, y: 2159, w: 202, h: 171 },
  // Landing/loading video, 1.5x native 224x382 (aspect held so objectFit:
  // "contain" leaves no letterbox and the calib box hugs the visible video).
  woodpecker: { id: "woodpecker", x: 424, y: 500, w: 336, h: 573 },
};

// =============================================================
// Event bus — UI emits typed events; backend / Claude Code listens
// =============================================================
const eventBus = (() => {
  const listeners = new Set();
  return {
    emit(evt) {
      console.log("[device:emit]", evt);
      listeners.forEach((fn) => fn(evt));
      // Also broadcast on window for cross-frame integration
      try { window.dispatchEvent(new CustomEvent("device:event", { detail: evt })); } catch (_) {}
    },
    on(fn) { listeners.add(fn); return () => listeners.delete(fn); },
  };
})();
window.deviceEventBus = eventBus;

// =============================================================
// Tweakable defaults — direct-edit persisted block
// =============================================================
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
  "pressDepth": 6,
  "pressDarken": 0.18,
  "ledHueA": 360,
  "ledHueB": 360,
  "ledHueC": 360,
  "ledCycleMs": 3000,
  "ledIntensity": 0.65,
  "ledMaxFps": 20,
  "gearRpm": 8,
  "gearStepDeg": 6,
  "gearRampMs": 3000,
  "gear_clickMult": 23.5,
  "gear_clickVol": -40,
  "gear_clickHz": 8000,
  "gear_clickPan": 1,
  "printSpeed": 38,
  "showStubText": true,
  "showHitZones": false,
  "showDisplayBounds": false,
  "calibrate": false,
  "scanlineOpacity": 0.15,
  "bloomStrength": 0.6,
  "vidOpacity": 1,
  "vidBlend": "screen",
  "topicFlashMs": 1500,
  "topicFontScale": 0.66,
  "hintFontScale": 0.6,
  "inputPlaceholder": "",
  "hintText": "PRESS 'GO' FOR TROUBBLE",
  "topicX": 50,
  "topicY": 43.5,
  "inputX": 50,
  "inputY": 42.5,
  "hintX": 50,
  "hintY": 50,
  "musicBpm": 108,
  "mus_filterHz": 1250,
  "mus_verbWet": 0,
  "mus_verbRoom": 0,
  "mus_pan": -1,
  "mus_v1_osc": "square",
  "mus_v1_vol": -24,
  "mus_v1_trans": -24,
  "mus_v1_detune": 0,
  "mus_v1_attack": 0.011,
  "mus_v1_decay": 0.06,
  "mus_v1_sustain": 0.05,
  "mus_v1_release": 0.05,
  "mus_v2_on": true,
  "mus_v2_osc": "square",
  "mus_v2_vol": -24,
  "mus_v2_trans": -24,
  "mus_v2_detune": -20,
  "mus_v2_attack": 0.001,
  "mus_v2_decay": 0.01,
  "mus_v2_sustain": 0.05,
  "mus_v2_release": 0.05,

  "eng_fontSize": 28,
  "eng_lineHeight": 0.75,
  "eng_textColor": "#ffcdd0",
  "eng_bloomColor": "#e00d4e",
  "eng_backgroundColor": "#101010",
  "eng_backgroundTransparent": true,
  "eng_contrast": 1,
  "eng_brightness": 1.1,
  "eng_sepia": 0,
  "eng_scanlineDensity": 12,
  "eng_scanlineDriftSpeed": 57,
  "eng_screenCurvature": 0,
  "eng_flickerIntensity": 0.01,
  "eng_jitterStrength": 0,
  "eng_maxFps": 20,
  "eng_pauseDuration": 600,
  "eng_halationStrength": 0.2,
  "eng_halationSpread": 4,
  "eng_choiceFontScale": 1,
  "eng_choiceLineHeight": 0.65,
  "eng_titleMarqueeStep": 1.25,
  "eng_titleMarqueeIntervalMs": 230,
  "eng_bloomStrength": 0.1,
  "eng_bloom_enabled": true,
  "eng_bloom_waveType": "bezier",
  "eng_bloom_min": 0.05,
  "eng_bloom_max": 0.10,
  "eng_bloom_freq": 0.10,
  "eng_bloom_noise": 0.04,
  "eng_chromaticAberration": 4,
  "eng_aber_enabled": true,
  "eng_aber_waveType": "sine",
  "eng_aber_min": 1,
  "eng_aber_max": 2,
  "eng_aber_freq": 0.30,
  "eng_aber_noise": 0.12,
  "eng_scanlineIntensity": 0.05,
  "eng_scan_enabled": false,
  "eng_scan_waveType": "sine",
  "eng_scan_min": 0,
  "eng_scan_max": 0.20,
  "eng_scan_freq": 0.10,
  "eng_scan_noise": 0.01,
  "eng_grainOpacity": 0.02,
  "eng_grain_enabled": false,
  "eng_grain_waveType": "sine",
  "eng_grain_min": 0,
  "eng_grain_max": 0.10,
  "eng_grain_freq": 0.50,
  "eng_grain_noise": 0.02,
  "eng_grainSpeed": 50,
  "eng_typewriterSpeed": 0,
  "eng_tw_enabled": false,
  "eng_tw_waveType": "sine",
  "eng_tw_min": 5,
  "eng_tw_max": 30,
  "eng_tw_freq": 0.50,
  "eng_tw_noise": 0.20
}/*EDITMODE-END*/;

// Bundle the flat mus_* tweak keys into the shape LandingMusic consumes.
// Game code (not dev tooling): production plays the baked defaults above.
function musicFromTweaks(t) {
  const voice = (p) => ({
    osc: t[`mus_${p}_osc`], vol: t[`mus_${p}_vol`],
    trans: t[`mus_${p}_trans`], detune: t[`mus_${p}_detune`],
    attack: t[`mus_${p}_attack`], decay: t[`mus_${p}_decay`],
    sustain: t[`mus_${p}_sustain`], release: t[`mus_${p}_release`],
  });
  return {
    bpm: t.musicBpm,
    filterHz: t.mus_filterHz,
    verbWet: t.mus_verbWet,
    verbRoom: t.mus_verbRoom,
    pan: t.mus_pan,
    v1: voice("v1"),
    v2: { on: !!t.mus_v2_on, ...voice("v2") },
  };
}

// =============================================================
// Hooks
// =============================================================
// useTweaks comes from tweaks-panel.jsx (window.useTweaks). Do NOT redefine
// it here: this file loads last, so a local copy silently shadows the real
// one — which is exactly what happened, and it killed the standalone
// sessionStorage persistence (tweaks stopped surviving reloads).

// =============================================================
// LED pulse — see LEDCluster below. The clock used to live here as a
// useLEDPhases hook called from the top of Device: a 60fps setState that
// re-rendered the ENTIRE app on every animation frame, all the time — the
// single biggest steady-state main-thread burn on the phone. The cluster
// now owns its own clock and writes styles directly; nothing else in the
// tree hears a frame tick.
// =============================================================

// =============================================================
// Audio — placeholder Web Audio blips. Replace with samples later.
// =============================================================
const audio = (() => {
  let ctx = null;
  const ensure = () => { if (!ctx) ctx = new (window.AudioContext || window.webkitAudioContext)(); return ctx; };
  const blip = (freq, dur, type = "square", gain = 0.06) => {
    // Player SOUND setting: blips live on their own raw AudioContext, outside
    // the Tone graph, so they read the shared flag instead of the master trim.
    const level = window.__soundLevel || "NORMAL";
    if (level === "OFF") return;
    const vol = level === "SOFT" ? gain * 0.25 : gain;
    try {
      const ac = ensure();
      // iOS suspends the context on backgrounding and never auto-resumes;
      // blip only runs inside press handlers, so resume() is privileged here.
      if (ac.state === "suspended") ac.resume().catch(() => {});
      const o = ac.createOscillator(); const g = ac.createGain();
      o.type = type; o.frequency.setValueAtTime(freq, ac.currentTime);
      g.gain.setValueAtTime(vol, ac.currentTime);
      g.gain.exponentialRampToValueAtTime(0.0001, ac.currentTime + dur);
      o.connect(g).connect(ac.destination);
      o.start(); o.stop(ac.currentTime + dur);
    } catch (_) {}
  };
  return {
    pressNext: () => blip(560, 0.05, "square"),
    pressGo:   () => { blip(420, 0.04, "square", 0.08); setTimeout(() => blip(640, 0.06, "triangle", 0.07), 30); },
    pressOpt:  () => blip(320, 0.06, "square", 0.06),
  };
})();

// iOS pans the layout viewport to keep a focused input above the keyboard and
// doesn't always pan back after dismissal, leaving a strip of bare canvas above
// the page. Restore on every blur; the delayed pass runs after WebKit finishes
// its keyboard-dismiss geometry (which can undo an immediate scroll).
window.addEventListener("focusout", () => {
  window.scrollTo(0, 0);
  setTimeout(() => window.scrollTo(0, 0), 250);
});

// =============================================================
// Gear clicks — the machine in the right channel while the gear visibly
// spins (loading/printing). Music sits left; the machine sits right.
// Discrete ticks whose rate couples to the gear: RPM x the multiple knob,
// per minute (multiple 0 = off; 60 = one click per 6-degree step). Plain
// setInterval, NOT Tone.Transport: the landing music owns the transport
// and stops/cancels it on fade-out. The graph builds once and persists
// across spins. Every entry point no-ops if Tone.js isn't loaded, and
// nothing resumes the context outside a gesture — silent skips, never
// errors. (A synthesized whir alternative lived here briefly; recipe in
// git history at b96fbf9 if the texture is ever wanted back.)
// =============================================================
const gearAudio = (() => {
  const ctxRunning = () => {
    if (typeof Tone === "undefined") return false;
    const ctx = Tone.getContext ? Tone.getContext() : Tone.context;
    return !!ctx && ctx.state === "running";
  };
  let clickSynth = null, clickFilter = null, clickPanner = null, clickTimer = null;
  const build = () => {
    if (clickSynth || typeof Tone === "undefined") return;
    clickPanner = new Tone.Panner(1).toDestination();
    clickFilter = new Tone.Filter(1500, "bandpass").connect(clickPanner);
    clickSynth = new Tone.NoiseSynth({
      noise: { type: "brown" },
      envelope: { attack: 0.001, decay: 0.04, sustain: 0, release: 0.02 },
    }).connect(clickFilter);
  };
  const stop = () => { if (clickTimer) { clearInterval(clickTimer); clickTimer = null; } };
  const start = (cfg) => {
    stop();
    const perMin = (cfg.rpm || 0) * (cfg.clickMult || 0);
    if (perMin <= 0 || typeof Tone === "undefined") return;
    build();
    if (!clickSynth) return;
    clickPanner.pan.value = cfg.clickPan;
    clickFilter.frequency.value = cfg.clickHz;
    clickSynth.volume.value = cfg.clickVol;
    const click = () => {
      if (!ctxRunning()) return;
      try { clickSynth.triggerAttackRelease(0.03); } catch (_) {}
    };
    clickTimer = setInterval(click, 60000 / perMin);
  };
  return { start, stop };
})();

// =============================================================
// HitZone — generic absolutely-positioned button overlay
// =============================================================
function HitZone({ zone, onPress, debug, depth, darken, label, ariaLabel, pressArt, children }) {
  const [pressed, setPressed] = useState(false);
  // Pointer events only — never mouse+touch pairs. Binding both let iOS fire
  // one tap twice (touchend, then the synthesized compat mouse pair; the
  // touchstart preventDefault that should stop it is passive in React 18),
  // which advanced option cycling by two. Cancelling pointerdown suppresses
  // the compat mouse events at spec level and keeps focus where it is, so a
  // GO tap no longer dismisses the landing keyboard.
  const handleDown = (e) => { e.preventDefault(); setPressed(true); };
  const handleUp = () => {
    if (pressed) {
      setPressed(false);
      onPress && onPress();
    }
  };
  const handleLeave = () => setPressed(false);

  const pct = {
    left:   `${(zone.x / DEVICE_W) * 100}%`,
    top:    `${(zone.y / DEVICE_H) * 100}%`,
    width:  `${(zone.w / DEVICE_W) * 100}%`,
    height: `${(zone.h / DEVICE_H) * 100}%`,
  };

  // Pressed-state art: always mounted (preloads at boot), toggled by opacity.
  // Its rect is in source-pixel coords; convert to percent of this button's
  // zone box so it scales with the device. Art may extend past the hit zone.
  const fallbackPress = !pressArt && pressed;

  return (
    <button
      aria-label={ariaLabel || label}
      onPointerDown={handleDown}
      onPointerUp={handleUp}
      onPointerLeave={handleLeave}
      onPointerCancel={handleLeave}
      style={{
        position: "absolute",
        ...pct,
        background: debug ? "rgba(120, 255, 180, 0.22)" : "transparent",
        border: debug ? "1px dashed rgba(120, 255, 180, 0.7)" : "none",
        cursor: "pointer",
        padding: 0,
        margin: 0,
        outline: "none",
        WebkitTapHighlightColor: "transparent",
        touchAction: "manipulation",
        transform: fallbackPress ? `translateY(${depth}px)` : "translateY(0)",
        filter: fallbackPress ? `brightness(${1 - darken})` : "none",
        transition: "transform 50ms ease-out, filter 50ms ease-out",
      }}
    >
      {pressArt && (
        <img
          className="sprite"
          src={pressArt.src}
          alt=""
          draggable="false"
          style={{
            left:   `${((pressArt.rect.x - zone.x) / zone.w) * 100}%`,
            top:    `${((pressArt.rect.y - zone.y) / zone.h) * 100}%`,
            width:  `${(pressArt.rect.w / zone.w) * 100}%`,
            height: `${(pressArt.rect.h / zone.h) * 100}%`,
            opacity: pressed ? 1 : 0,
          }}
        />
      )}
      {children}
    </button>
  );
}

// =============================================================
// LED cluster — glow on top of the dark LED slots in the gold yoke.
// Sequence: LED1 ramps up → peaks → hands off to LED2 → LED3 → loops.
// Each LED's brightness is a smoothstep window over the shared phase.
//
// Self-clocked: the rAF loop lives here and writes the three dynamic
// styles per LED straight to the DOM — zero React state per frame, so a
// frame tick never re-renders anything. React.memo keeps parent renders
// (typewriter ticks, state changes) from touching the cluster; a real
// prop change (hue/intensity knob, calibration drag, loading) still flows
// through normally. Formulas are unchanged from the old LED component.
// =============================================================
const LEDCluster = React.memo(function LEDCluster({ leds, hueA, hueB, hueC, cycleMs, intensity, loading, maxFps, debug }) {
  const layersRef = useRef([]); // per LED: { core, bloom, spot } DOM nodes
  const live = useRef({});      // latest knob values, read by the clock each frame
  live.current = { hues: [hueA, hueB, hueC], intensity, maxFps };
  // LED speed adapts: faster while the device is working.
  const effectiveCycle = loading ? Math.max(400, cycleMs * 0.35) : cycleMs;

  useEffect(() => {
    const CENTERS = [0.166, 0.5, 0.833]; // evenly spaced; width 0.55 → overlap
    let raf, start = performance.now(), last = 0;
    // Last string written per layer — a dark LED (b pinned at 0) produces the
    // same strings every tick, and skipping the write skips the repaint. Blur
    // and blend re-rasterize on every style touch, so no-op writes aren't free.
    const applied = [{}, {}, {}];
    const tick = (now) => {
      raf = requestAnimationFrame(tick);
      // FPS cap: the pulse is a slow smoothstep — it doesn't need display
      // rate (120Hz on ProMotion phones). Skipped ticks cost one time check.
      const { hues, intensity, maxFps } = live.current;
      const fps = Number(maxFps) || 0;
      if (fps > 0) {
        const interval = 1000 / fps;
        if (now - last < interval) return;
        last = now - ((now - last) % interval); // keep the average rate honest
      }
      const phase = ((now - start) % effectiveCycle) / effectiveCycle; // 0..1
      for (let i = 0; i < 3; i++) {
        const els = layersRef.current[i];
        if (!els || !els.core) continue;
        // smoothstep window over wrapped phase distance
        const d = ((phase - CENTERS[i] + 1.5) % 1) - 0.5;
        const x = Math.max(0, 1 - Math.abs(d) / 0.275); // width 0.55 / 2
        const b = Math.max(0, Math.min(1, x * x * (3 - 2 * x) * intensity));
        const hue = hues[i];
        const a = applied[i];
        // cache key is per layer+prop — three layers share the "background" prop
        const set = (el, prop, key, val) => { if (a[key] !== val) { a[key] = val; el.style[prop] = val; } };
        set(els.core, "background", "coreBg", `hsl(${hue}, ${70 + b * 25}%, ${35 + b * 50}%)`);
        set(els.core, "opacity", "coreOp", String(0.15 + b * 0.85));
        set(els.bloom, "background", "bloomBg", `radial-gradient(ellipse at center, hsla(${hue},90%,70%,${b * 0.55}) 0%, transparent 65%)`);
        set(els.bloom, "filter", "bloomBlur", `blur(${4 + b * 8}px)`);
        set(els.spot, "background", "spotBg", `hsla(${hue}, 100%, 92%, ${b * 0.9})`);
      }
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [effectiveCycle]);

  const hues = [hueA, hueB, hueC];
  return (
    <React.Fragment>
      {leds.map((zone, i) => {
        const layer = (layersRef.current[i] = layersRef.current[i] || {});
        return (
          <div
            key={zone.id}
            style={{
              position: "absolute",
              left:   `${(zone.x / DEVICE_W) * 100}%`,
              top:    `${(zone.y / DEVICE_H) * 100}%`,
              width:  `${(zone.w / DEVICE_W) * 100}%`,
              height: `${(zone.h / DEVICE_H) * 100}%`,
              pointerEvents: "none",
              zIndex: 3,
              outline: debug ? "1px dashed rgba(255,200,200,0.7)" : "none",
            }}
          >
            {/* Layers render at brightness 0; the clock writes live values.
                (A re-render resets them to these base styles — safe with the
                write-dedup because base IS the b=0 frame visually, and a lit
                LED's strings change every tick so it rewrites immediately.) */}
            {/* core fill */}
            <div ref={(el) => { layer.core = el; }} style={{
              position: "absolute", inset: "15% 12%",
              background: `hsl(${hues[i]}, 70%, 35%)`,
              opacity: 0.15,
              borderRadius: "2px",
              mixBlendMode: "screen",
            }} />
            {/* bloom */}
            <div ref={(el) => { layer.bloom = el; }} style={{
              position: "absolute", inset: "-40% -30%",
              background: "none",
              filter: "blur(4px)",
              mixBlendMode: "screen",
              pointerEvents: "none",
            }} />
            {/* highlight pinpoint */}
            <div ref={(el) => { layer.spot = el; }} style={{
              position: "absolute", left: "30%", top: "20%", width: "20%", height: "30%",
              background: "none",
              borderRadius: "50%",
              filter: `blur(2px)`,
              mixBlendMode: "screen",
            }} />
          </div>
        );
      })}
    </React.Fragment>
  );
});
window.LEDCluster = LEDCluster; // probe hook — tests mount the cluster standalone

// =============================================================
// Display mount — exposes safe area for CRT engine to render into
// =============================================================
function DisplayMount({ id, label, rect, debug, children }) {
  const pct = {
    left:   `${(rect.x / DEVICE_W) * 100}%`,
    top:    `${(rect.y / DEVICE_H) * 100}%`,
    width:  `${(rect.w / DEVICE_W) * 100}%`,
    height: `${(rect.h / DEVICE_H) * 100}%`,
  };
  return (
    <div
      id={id}
      data-display={label}
      data-pixel-x={rect.x}
      data-pixel-y={rect.y}
      data-pixel-w={rect.w}
      data-pixel-h={rect.h}
      style={{
        position: "absolute",
        ...pct,
        pointerEvents: "none",
        zIndex: 2,
        outline: debug ? "2px dashed rgba(120, 200, 255, 0.7)" : "none",
        // expose CSS vars for downstream renderers
        "--display-pixel-w": rect.w,
        "--display-pixel-h": rect.h,
      }}
    >
      {children}
    </div>  );
}

// =============================================================
// Gear sprite — sandwiched between the chassis layers. Spins while the
// device works (loading / printing). Velocity follows a smoothstep
// envelope: eased acceleration on start, eased deceleration on stop.
// Renders in stepDeg detents; the continuous angle lives in a ref.
// =============================================================
function GearSprite({ zone, rpm, stepDeg, rampMs, spinning, debug }) {
  const [angle, setAngle] = useState(0);
  const motion = useRef({ angle: 0, phase: 0, last: null });
  const live = useRef({});
  live.current = { rpm, stepDeg, rampMs, spinning };

  useEffect(() => {
    // The loop only runs while there's motion to render: spinning, or
    // easing out after a stop. Parked (phase 0, not spinning) schedules
    // nothing — this used to tick at 60fps forever, doing no visible work.
    if (!spinning && motion.current.phase === 0) return;
    let raf;
    const tick = (now) => {
      const m = motion.current;
      const { rpm, stepDeg, rampMs, spinning } = live.current;
      if (m.last == null) m.last = now;
      const dt = Math.min((now - m.last) / 1000, 0.1); // clamp tab-away gaps
      m.last = now;
      // Ramp phase 0→1 while spinning, back 1→0 while stopping, over rampMs.
      const dp = rampMs > 0 ? (dt * 1000) / rampMs : 1;
      m.phase = Math.max(0, Math.min(1, m.phase + (spinning ? dp : -dp)));
      // Smoothstep the phase — zero slope at both ends, so speed eases in and out.
      const ease = m.phase * m.phase * (3 - 2 * m.phase);
      m.angle = (m.angle + dt * rpm * 6 * ease) % 360; // rpm * 360/60 = deg/sec at full speed
      const step = Math.max(1, stepDeg || 15);
      setAngle(Math.floor(m.angle / step) * step); // same value → React skips the re-render
      if (!spinning && m.phase === 0) { m.last = null; return; } // parked — stop the clock
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => { cancelAnimationFrame(raf); motion.current.last = null; };
  }, [spinning]);
  const pct = {
    left:   `${(zone.x / DEVICE_W) * 100}%`,
    top:    `${(zone.y / DEVICE_H) * 100}%`,
    width:  `${(zone.w / DEVICE_W) * 100}%`,
    height: `${(zone.h / DEVICE_H) * 100}%`,
  };
  return (
    <img
      className="sprite"
      src={GEAR_SRC}
      alt=""
      draggable="false"
      style={{
        ...pct,
        transform: `rotate(${angle}deg)`,
        transformOrigin: "center",
        outline: debug ? "1px dashed rgba(255,200,120,0.7)" : "none",
      }}
    />
  );
}
window.GearSprite = GearSprite; // probe hook — tests mount the sprite standalone

// =============================================================
// Woodpecker sprite — ambient looping video UNDER the main CRT text.
// Renders directly in the device layer: above the chassis art (source
// order), below the display mounts (their z2 wins). Two consequences,
// both load-bearing: all CRT text paints over the video unblended and
// stays legible, and the videos' mix-blend-mode reaches the chassis art
// — with "screen" the video's black is a no-op, so the baked screen
// gradient shows through and the bird reads as glow on the glass. The
// wrapper must NOT gain z-index/opacity/transform: any of those makes
// it an isolated group and the blend silently turns inert (the original
// inside-the-mount + z5 placement had exactly that bug).
// Rect is device-absolute (calibrated like any sprite).
// =============================================================
function WoodpeckerSprite({ rect, fast, opacity, blend, debug }) {
  const slowRef = useRef(null);
  const fastRef = useRef(null);
  useEffect(() => {
    // Only the visible video plays; the hidden twin PAUSES so it stops
    // decoding (it used to burn battery invisibly the whole landing). Both
    // stay mounted and buffered, so the GO swap resumes from the paused
    // frame — a frame of latency at most, never a reload.
    const playVisible = () => {
      const v = (fast ? fastRef : slowRef).current;
      if (!v) return;
      v.muted = true; // property, not just attribute — React's muted handling is unreliable
      v.play().catch(() => {});
    };
    const hidden = (fast ? slowRef : fastRef).current;
    if (hidden) hidden.pause();
    playVisible();
    // Autoplay-blocked fallback (iOS low-power etc.): one retry on first
    // gesture. touchend too — iOS doesn't grant activation to every
    // pointerdown, and play() on an already-playing video is a no-op.
    window.addEventListener("pointerdown", playVisible, { once: true });
    window.addEventListener("touchend", playVisible, { once: true });
    return () => {
      window.removeEventListener("pointerdown", playVisible);
      window.removeEventListener("touchend", playVisible);
    };
  }, [fast]);
  const pct = {
    left:   `${(rect.x / DEVICE_W) * 100}%`,
    top:    `${(rect.y / DEVICE_H) * 100}%`,
    width:  `${(rect.w / DEVICE_W) * 100}%`,
    height: `${(rect.h / DEVICE_H) * 100}%`,
  };
  // Both videos stay mounted; the slow/fast swap is an opacity flip plus a
  // pause/resume of the hidden twin (effect above). Blend and the opacity
  // toggle live per-video, NOT on the wrapper — wrapper opacity < 1 would
  // isolate mix-blend from the CRT behind it.
  const vidStyle = (visible) => ({
    position: "absolute",
    inset: 0,
    width: "100%",
    height: "100%",
    objectFit: "contain",
    opacity: visible ? (opacity ?? 1) : 0,
    mixBlendMode: blend || "normal",
  });
  return (
    <div
      aria-hidden="true"
      style={{
        position: "absolute",
        ...pct,
        pointerEvents: "none",
        outline: debug ? "1px dashed rgba(255,210,120,0.7)" : "none",
      }}
    >
      <video ref={slowRef} src={WOODPECKER_SLOW_SRC}
        muted autoPlay loop playsInline preload="auto" style={vidStyle(!fast)} />
      <video ref={fastRef} src={WOODPECKER_FAST_SRC}
        muted autoPlay loop playsInline preload="auto" style={vidStyle(!!fast)} />
    </div>
  );
}

// =============================================================
// Text sanitization — fix characters that render oddly in pixel fonts
// =============================================================
function fixText(s) {
  if (!s) return s;
  return s.replace(/\u2014/g, '-'); // em-dash → ASCII hyphen-minus (EXEPixelPerfect renders box-drawing too high)
}

// =============================================================
// Friend roster — sessionStorage, entry shape mirrors the vanilla client's
// ally shape (client/js/persistence.js) so a future merge is trivial.
// One companion at a time: the roster array holds at most one entry (a new
// join offer over an incumbent becomes a swap decision at the coda). The
// array shape survives so storage, the invoke payload, and the server clamp
// all stay unchanged.
// Stored as { v, allies } — bump ROSTER_VERSION to invalidate every
// in-flight roster at deploy (a version mismatch or the legacy bare-array
// shape reads as empty).
// =============================================================
const ROSTER_KEY = "deviceRoster";
const ROSTER_VERSION = 1;
function loadRoster() {
  try {
    const p = JSON.parse(sessionStorage.getItem(ROSTER_KEY) || "null");
    if (p && p.v === ROSTER_VERSION && Array.isArray(p.allies)) return p.allies;
  } catch (_) {}
  return [];
}
function saveRoster(roster) {
  try { sessionStorage.setItem(ROSTER_KEY, JSON.stringify({ v: ROSTER_VERSION, allies: roster })); } catch (_) {}
}
function recordInvocation(name, domain, memory) {
  const roster = loadRoster();
  const friend = roster.find((a) => a.name === name);
  if (!friend) return;
  friend.invocations = friend.invocations || [];
  friend.invocations.push({ domain, memory });
  saveRoster(roster);
}

// =============================================================
// C.V. — the player's record, résumé-shaped. localStorage, so the record
// survives the tab (user ruling 2026-09-25: the C.V. is the one artifact
// that persists; the friend roster stays per-sitting). One entry accrues
// per episode ({ position_line, bullets[] }, newest last in storage,
// rendered newest-first) plus a document-level skills line the writer
// refreshes each episode. window.__biography holds the flat newest shape
// { position_line, bullets, skills } for the coda reveal.
// =============================================================
const BIO_KEY = "deviceBiography";
const BIO_VERSION = 2; // v2 = C.V. shape; bump wipes stale records on deploy
const BIO_MAX_ENTRIES = 8;
function loadBioRecord() {
  try {
    const p = JSON.parse(localStorage.getItem(BIO_KEY) || "null");
    if (p && p.v === BIO_VERSION && Array.isArray(p.entries)) return p;
  } catch (_) {}
  return { v: BIO_VERSION, entries: [], skills: "" };
}
function appendBioEntry(bio) {
  const rec = loadBioRecord();
  rec.entries.push({
    position_line: bio.position_line || "",
    bullets: Array.isArray(bio.bullets) ? bio.bullets.filter(Boolean) : [],
  });
  rec.entries = rec.entries.slice(-BIO_MAX_ENTRIES);
  rec.skills = bio.skills || rec.skills || "";
  try {
    localStorage.setItem(BIO_KEY, JSON.stringify(rec));
  } catch (_) {}
}
// Newest-first prose join — feeds friend invocation/culling prompts and
// rides back to /biography as the writer's view of the record so far.
// Newest first means the server's head-keeping clip drops the OLDEST
// episodes when overlong, so the front-slice here matches.
function bioAsText() {
  const t = loadBioRecord().entries.slice().reverse()
    .filter((e) => e.position_line)
    .map((e) => `${e.position_line}: ${(e.bullets || []).join(" ")}`)
    .join("\n\n");
  return t.length > 3800 ? t.slice(0, 3800) : t;
}
function biographyForInvoke() {
  return bioAsText();
}
// Rehydrate after a reload so the coda reveal and invocations carry the
// record again without waiting for a fresh episode.
(() => {
  const rec = loadBioRecord();
  if (rec.entries.length) {
    const last = rec.entries[rec.entries.length - 1];
    window.__biography = { position_line: last.position_line, bullets: last.bullets, skills: rec.skills };
  }
})();

// Dev reset (CLEAR FRIENDS in the dev panel): wipe the friend roster and
// the C.V. record. Defined here so the storage keys live in one file.
window.__clearFriendData = () => {
  try {
    sessionStorage.removeItem(ROSTER_KEY);
    localStorage.removeItem(BIO_KEY);
  } catch (_) {}
  window.__biography = null;
};

// --- The two client-side choice cards ---
const FREEDOM_TITLE = "OFF SCRIPT";
function freedomCardFor(turn, dropTurn) {
  // Episodes are exactly 5 turns (server-enforced), so turn 4 is the last
  // page with options — the countdown never promises turns past it.
  const lastTurn = Math.min(dropTurn + 2, 4);
  const remaining = lastTurn - turn;
  const windowLine = remaining >= 2 ? `Open for ${remaining} more turns.`
    : remaining === 1 ? "Open for 1 more turn."
    : "Closes after this turn.";
  return {
    title: FREEDOM_TITLE,
    body: `Say what you'd actually do, in your own words. ${windowLine}`,
    isFreedom: true,
  };
}
// Epithets come from the model and sometimes arrive lowercase ("exiled…");
// every card body that opens with one gets the first letter raised here, so
// existing rosters are fixed too — no prompt change needed.
function capFirst(s) {
  return s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
}
function friendCardFor(friend) {
  return {
    title: friend.name.toUpperCase(),
    body: friend.epithet ? `${capFirst(friend.epithet)}. Bring them into this.` : "Bring them into this.",
    isFriend: true,
    friend,
  };
}

// Friend swap decision — dealt after the coda when a new figure offers to
// join over an incumbent (one companion at a time). Two cards, one per
// person; GO on a card is the answer. Resolves entirely client-side
// (SWAP_RESOLVED), no API call. The incumbent's line is the generated
// parting observation; until (or unless) it lands, they just watch.
// First-companion offer — the empty-slot counterpart of swapPageFor. Same
// card flags, so SWAP_RESOLVED settles both; incumbent stays null. Joining
// is always the player's call: an offer is a decision, never an announcement.
function offerPageFor(newcomer) {
  return {
    body: `${newcomer.name} offers to join you.`,
    choices: [
      {
        title: newcomer.name.toUpperCase(),
        body: newcomer.epithet ? `${capFirst(newcomer.epithet)}. Let them join you.` : "Let them join you.",
        isSwapChoice: true, take: true, newcomer, incumbent: null,
      },
      {
        title: "GO ALONE",
        body: `Turn ${newcomer.name} away.`,
        isSwapChoice: true, take: false, newcomer, incumbent: null,
      },
    ],
  };
}

function swapBodyFor(newcomer, incumbent, plea) {
  return `${newcomer.name} wants to join you. But if they do, ${incumbent.name} will leave.\n\n${plea}`;
}
function swapPageFor(newcomer, incumbent) {
  return {
    body: swapBodyFor(newcomer, incumbent, `${incumbent.name} watches you decide.`),
    choices: [
      {
        title: newcomer.name.toUpperCase(),
        body: `Let them join you. ${incumbent.name} departs.`,
        isSwapChoice: true, take: true, newcomer, incumbent,
      },
      {
        title: incumbent.name.toUpperCase(),
        body: `Keep their company. Let ${newcomer.name} depart.`,
        isSwapChoice: true, take: false, newcomer, incumbent,
      },
    ],
  };
}

// =============================================================
// Settings → RetroDisplay params mapper
// =============================================================
// Translates user-facing settings (from settingValues) into the engine's
// VisualParams shape. Devs can also override individual params via tweaks.
// Text sizes are multipliers of the art-directed default (eng_fontSize), not
// absolute px: MEDIUM is exactly the shipped look, so the menu never lies
// about the starting state, and S/L stay proportional on every screen.
// 2026-09-25 shift-down: the shipped base dropped 35 → 28, so the default
// look IS the old SMALL. LARGE is 1.25 (not 1.2) so it lands exactly on the
// old 35px shipped look; SMALL extends the ladder one step down (22px).
const SIZE_MULT   = { SMALL: 0.8, MEDIUM: 1.0, LARGE: 1.25 };
const SPEED_MAP   = { SLOW: 18, NORMAL: 38, FAST: 80, INSTANT: 9999 }; // chars/sec for state-machine typewriter
const BLOOM_MAP   = { OFF: 0,    LOW: 0.1, HIGH: 0.35 };
const SCAN_MAP    = { OFF: 0,    LOW: 0.05, HIGH: 0.18 };
const CHROMA_MAP  = { OFF: 0,    LOW: 2,    HIGH: 5 };

function mapSettingsToRetroParams(settingValues, optionsTree, touchedSettings, baseFontSize) {
  // Walk tree to collect EXPLICITLY-TOUCHED choice-id → selected-label.
  // Defaults are *not* applied here — settingValues always holds them so the
  // in-app menu can show the right starting selection, but the engine-tweaks
  // panel stays the source of truth for the engine's params until the user
  // actually cycles a setting in-app.
  const flat = {};
  const walk = (n) => {
    if (n.kind === "choice" && touchedSettings && touchedSettings.has(n.id)) {
      flat[n.id] = n.options[settingValues[n.id]];
    }
    if (n.items) n.items.forEach(walk);
  };
  walk(optionsTree);

  const out = {};
  if (flat["text.size"]) out.fontSize = Math.round((baseFontSize || 28) * (SIZE_MULT[flat["text.size"]] ?? 1));
  // (Other setting → engine mappings can be added here later as they get
  //  exposed in the in-app Options menu.)
  return out;
}

function speedFromSetting(settingValues) {
  // text.speed selected option → chars/sec for state machine
  const idx = settingValues["text.speed"];
  const lbl = idx != null ? ["SLOW","NORMAL","FAST","INSTANT"][idx] : "NORMAL";
  return SPEED_MAP[lbl] ?? 38;
}

// =============================================================
// Device — top-level component
// =============================================================
function Device({ tweaks, leds, buttons, displays, sprites, setLeds, setButtons, setDisplays, setSprites, deviceRef }) {
  const [smInput, setSmInput] = useState({ printSpeed: tweaks.printSpeed });
  const sm = useDeviceStateMachine({
    printSpeed: smInput.printSpeed,
    emit: (e) => eventBus.emit(e),
  });

  // Gear turns whenever the device is working: engine waits + typewriter
  const gearSpinning = sm.appState === "loading" || sm.appState === "printing";

  // Right-channel gear clicks while the gear spins; knobs stream in live.
  useEffect(() => {
    if (gearSpinning) {
      gearAudio.start({
        rpm: tweaks.gearRpm,
        clickMult: tweaks.gear_clickMult,
        clickVol: tweaks.gear_clickVol,
        clickHz: tweaks.gear_clickHz,
        clickPan: tweaks.gear_clickPan,
      });
    } else {
      gearAudio.stop();
    }
    return () => gearAudio.stop();
  }, [gearSpinning, tweaks.gearRpm, tweaks.gear_clickMult, tweaks.gear_clickVol,
      tweaks.gear_clickHz, tweaks.gear_clickPan]);

  // Player SOUND setting (NORMAL / SOFT / OFF) → every audio path. One trim
  // on the Tone destination covers both Tone graphs (landing music + gear
  // clicks); blip() reads the window flag because it runs on its own raw
  // AudioContext. A single volume value — no .mute — sidesteps Tone's
  // mute/volume restore interplay. Label order matches OPTIONS_TREE.
  const volumeLevel = ["NORMAL", "SOFT", "OFF"][sm.settingValues["volume"]] || "NORMAL";
  useEffect(() => {
    window.__soundLevel = volumeLevel;
    if (typeof Tone !== "undefined" && Tone.getDestination) {
      Tone.getDestination().volume.value =
        volumeLevel === "OFF" ? -Infinity : volumeLevel === "SOFT" ? -12 : 0;
    }
  }, [volumeLevel]);

  // Woodpecker video: landing, the initial research wait, or calibrate mode
  // (always visible while dragging). The loading clause mirrors StubMain's
  // initial-LoadingView branch (loading && !narrative && !pendingDecision);
  // the gate stays true across domainInput -> loading so the loop never
  // remounts/restarts at submit. The same loading condition drives the
  // slow->fast swap: GO feedback, running until turn 1 prints.
  const woodpeckerLoading = sm.appState === "loading" && !sm.narrative && !sm.pendingDecision;
  const showWoodpecker = tweaks.calibrate
    || sm.appState === "domainInput"
    || woodpeckerLoading;

  // Session ID persists across turns
  const sessionRef = useRef(null);
  // Domain ref for the input component
  const domainRef = useRef("");
  // Hook-card summaries for cardPicked analytics (set when a hand is dealt)
  const nextCardsSummaryRef = useRef(null);
  // Continuity ledger of the finished episode (from /next-episodes). Sent
  // with /start only when the player picks the continuity card; held in
  // memory only — a reload between episodes drops the carry by design.
  const prevLedgerRef = useRef(null);
  // Whether the running episode is an arc chapter: a continuity pick whose
  // carry the server actually applied. Gates the coda's arc sign-off line.
  const arcCarriedRef = useRef(false);
  // Coda prefetch: fired when the episode-complete turn arrives, so the
  // ally/biography/next-episodes model time hides behind the final turn's
  // typewriter. The coda > press awaits this instead of firing fresh.
  const codaPromiseRef = useRef(null);
  // Hook-card hand parked while a friend-swap decision is on screen; the
  // SWAP_RESOLVED handler queues it behind the resolution page.
  const pendingCardsPageRef = useRef(null);
  // The queued swap page, patchable until the player answers: /ally-cull
  // replaces the fallback plea with a generated line if it lands in time.
  const swapPatchRef = useRef(null);
  // Per-episode card state: current turn number, the OFF SCRIPT drop window
  // (rolled per episode: appears on a random turn 2-4, stays 3 turns, one
  // use), and the one-friend-invocation-per-episode flag.
  const turnRef = useRef(0);
  const freedomRef = useRef(null); // { dropTurn, used }
  const invokedThisEpisodeRef = useRef(false);

  // Server-backed options first (their positions must mirror the server's
  // currentChoices indices), client-only cards after: FRIEND, then OFF SCRIPT.
  const assembleChoices = useCallback((serverOptions, forTurn) => {
    const choices = serverOptions.map((o) => ({ title: fixText(o.title), body: fixText(o.text) }));
    const roster = loadRoster();
    if (roster.length && !invokedThisEpisodeRef.current) {
      choices.push(friendCardFor(roster[roster.length - 1]));
    }
    const f = freedomRef.current;
    if (f && !f.used && forTurn >= f.dropTurn && forTurn <= f.dropTurn + 2) {
      choices.push(freedomCardFor(forTurn, f.dropTurn));
    }
    return choices;
  }, []);

  // When settings change, recalc print speed for the state machine.
  useEffect(() => {
    const fromSetting = speedFromSetting(sm.settingValues);
    setSmInput({ printSpeed: tweaks.printSpeed !== 38 ? tweaks.printSpeed : fromSetting });
  }, [sm.settingValues, tweaks.printSpeed]);

  // Engine params, undefined-filtered HERE so the object StubMain receives
  // keeps a stable identity between renders. It used to be rebuilt inline at
  // the callsite on every render — a fresh identity that re-fired every
  // params-keyed hook down the CRT tree (and during printing that's dozens
  // of renders a second).
  const retroParams = useMemo(() => {
    const merged = {
      ...window.engineParamsFromTweaks(tweaks),
      ...mapSettingsToRetroParams(sm.settingValues, window.OPTIONS_TREE, sm.touchedSettings, tweaks.eng_fontSize),
    };
    return Object.fromEntries(Object.entries(merged).filter(([, v]) => v !== undefined));
  }, [sm.settingValues, sm.touchedSettings, tweaks]);

  // Same story for the landing prop bundle (it feeds LandingMusic and the
  // topic flash); memoized so its identity only moves when tweaks do.
  const landingParams = useMemo(() => ({
    topicFlashMs: tweaks.topicFlashMs,
    topicFontScale: tweaks.topicFontScale,
    hintFontScale: tweaks.hintFontScale,
    inputPlaceholder: tweaks.inputPlaceholder,
    hintText: tweaks.hintText,
    topicX: tweaks.topicX,
    topicY: tweaks.topicY,
    inputX: tweaks.inputX,
    inputY: tweaks.inputY,
    hintX: tweaks.hintX,
    hintY: tweaks.hintY,
    music: musicFromTweaks(tweaks),
  }), [tweaks]);

  // --- API orchestration ---

  const handleDomainSubmit = useCallback(async (domain, carry) => {
    domainRef.current = domain;
    codaPromiseRef.current = null;
    pendingCardsPageRef.current = null;
    swapPatchRef.current = null;
    invokedThisEpisodeRef.current = false;
    prevLedgerRef.current = null; // consumed (or forfeited) the moment a new episode starts
    sm.startLoading();
    try {
      const data = await window.storyAPI.start(domain, carry ? { prevEpisode: carry } : {});
      sessionRef.current = data.sessionId;
      // Arc chapter only if we sent a carry AND the server kept it (failed
      // continuity research degrades to a fresh start and drops the carry).
      arcCarriedRef.current = !!(carry && data.carryApplied);
      sm.setEpisodeTitle(domain.toUpperCase());
      // New episode: turn 1, roll the OFF SCRIPT drop window, clear the draft
      turnRef.current = 1;
      freedomRef.current = { dropTurn: 2 + Math.floor(Math.random() * 3), used: false };
      window.__freedomDraft = "";
      const body = fixText(data.segments.join('\n\n'));
      sm.pushPage({ body, choices: assembleChoices(data.options, 1) });
    } catch (err) {
      const msg = err.status === 422 ? (err.message || 'Domain flagged.')
        : err.status === 429 ? 'Too many requests. Wait a moment.'
        : err.status === 503 ? (err.message || 'Service busy. Try again shortly.')
        : 'Something went wrong. Try again.';
      sm.showError(msg);
    }
  }, [sm, assembleChoices]);

  // Fire the coda trio (ally decision + biography + next episodes) and cache
  // the promise. Failures resolve into a marker object rather than rejecting,
  // so nothing surfaces as an unhandled rejection while the player is still
  // reading the final turn; handleCoda rethrows into its own catch.
  const fireCodaCalls = useCallback(() => {
    const sid = sessionRef.current;
    if (!sid) return null;
    const t0 = Date.now();
    console.log("[coda] prefetch: ally-decision + biography + next-episodes");
    const p = Promise.all([
      window.storyAPI.allyDecision(sid),
      // The prior record rides along so the writer can derive the skills
      // line from the whole C.V. and avoid reusing its joke constructions.
      window.storyAPI.biography(sid, bioAsText()).catch(() => null),
      window.storyAPI.nextEpisodes(sid).catch(() => null),
    ]).then(
      (r) => { console.log(`[coda] calls resolved in ${Date.now() - t0}ms`); return r; },
      (err) => ({ __codaError: err })
    );
    codaPromiseRef.current = p;
    return p;
  }, []);

  // Session gone server-side (restart/deploy or idle eviction). The episode
  // is unrecoverable — every retry would 404 — so don't leave the player
  // re-pressing into a dead session: back to the landing with the reason.
  const handleSessionLost = useCallback(() => {
    sessionRef.current = null;
    sm.resetToDomain();
    sm.showError('Connection to this episode was lost. Start a new one.');
  }, [sm]);

  const handleChoiceConfirmed = useCallback(async (choiceIndex) => {
    if (!sessionRef.current) { sm.showError('Something went wrong. Try again.'); return; }
    sm.startLoading();
    try {
      const data = await window.storyAPI.choice(sessionRef.current, choiceIndex);
      const body = fixText(data.segments.join('\n\n'));
      if (data.episodeComplete) {
        fireCodaCalls();
        sm.pushPage({ body, choices: null, episodeComplete: true });
      } else {
        turnRef.current += 1;
        sm.pushPage({ body, choices: assembleChoices(data.options, turnRef.current) });
      }
    } catch (err) {
      if (err.status === 404) return handleSessionLost();
      const msg = err.status === 409 ? (err.message || 'This episode has ended.')
        : err.status === 429 ? 'Too many requests. Wait a moment.'
        : err.status === 503 ? (err.message || 'Generation failed. Try again.')
        : 'Something went wrong. Try again.';
      sm.showError(msg);
    }
  }, [sm, fireCodaCalls, assembleChoices, handleSessionLost]);

  // OFF SCRIPT submit: the typed text becomes the turn's decision. Folding
  // happens only on success, so a gate/network failure leaves the narrative
  // clean and the card window open for a retry.
  const handleFreedomText = useCallback(async (text) => {
    const trimmed = (text || "").trim();
    if (!trimmed || !sessionRef.current) return;
    // One string for both the pending render and the committed fold — the
    // success swap is seamless only if they are character-identical.
    const decisionLine = `\n\n[ ${FREEDOM_TITLE} ]\n${trimmed}`;
    sm.beginPendingDecision(decisionLine);
    sm.startLoading();
    try {
      const data = await window.storyAPI.freedom(sessionRef.current, trimmed);
      if (freedomRef.current) freedomRef.current.used = true;
      window.__freedomDraft = "";
      window.storyAPI.analytics({ event: 'freedomUsed', domain: domainRef.current });
      sm.foldDecision(decisionLine);
      const body = fixText(data.segments.join('\n\n'));
      if (data.episodeComplete) {
        fireCodaCalls();
        sm.pushPage({ body, choices: null, episodeComplete: true });
      } else {
        turnRef.current += 1;
        sm.pushPage({ body, choices: assembleChoices(data.options, turnRef.current) });
      }
    } catch (err) {
      if (err.status === 404) return handleSessionLost();
      const msg = err.status === 422 ? (err.message || 'That input was flagged.')
        : err.status === 409 ? (err.message || 'This episode has ended.')
        : err.status === 429 ? 'Too many requests. Wait a moment.'
        : err.status === 503 ? (err.message || 'Generation failed. Try again.')
        : 'Something went wrong. Try again.';
      sm.showError(msg);
    }
  }, [sm, fireCodaCalls, assembleChoices, handleSessionLost]);

  // Friend card confirmed: /invoke-ally brings the friend into the current
  // turn — entrance narrative plus their option appended to the choice set.
  // The state machine already set loading; narrative folds on success only.
  const handleInvokeFriend = useCallback(async (friend) => {
    if (!sessionRef.current || !friend) {
      sm.showError('Something went wrong. Try again.');
      return;
    }
    try {
      const data = await window.storyAPI.invokeAlly(
        sessionRef.current, friend, biographyForInvoke()
      );
      invokedThisEpisodeRef.current = true;
      recordInvocation(friend.name, domainRef.current, data.memory_line);
      window.storyAPI.analytics({ event: 'friendInvoked', domain: domainRef.current, allyInvoked: friend.name });
      // Fold the current turn body with no decision line — the turn is still
      // open, now with the friend's option in the hand.
      sm.foldDecision(null);
      const serverChoices = (sm.page?.choices || []).filter((c) => !c.isFriend && !c.isFreedom);
      // The notch is the friend's name alone — the mark that the card is
      // theirs. Joining the option's own title made the notch wrap, and a
      // wrapped notch breaks the border overlay. The body carries the move,
      // and the confirm fold attributes it as [ NAME ] in the narrative.
      const choices = [...serverChoices, { title: friend.name.toUpperCase(), body: fixText(data.option.text) }];
      const f = freedomRef.current;
      if (f && !f.used && turnRef.current >= f.dropTurn && turnRef.current <= f.dropTurn + 2) {
        choices.push(freedomCardFor(turnRef.current, f.dropTurn));
      }
      sm.pushPage({ body: fixText(data.entrance_narrative), choices });
    } catch (err) {
      if (err.status === 404) return handleSessionLost();
      if (err.status === 409) {
        // Server says an invocation already landed this episode (or the
        // episode closed) — likely a lost response replayed. Trust it: the
        // friend card stays out of the hand for the rest of the episode.
        invokedThisEpisodeRef.current = true;
        sm.showError(err.message || 'They are already at your side.');
        return;
      }
      const msg = err.status === 429 ? 'Too many requests. Wait a moment.'
        : err.status === 503 ? (err.message || 'Generation failed. Try again.')
        : 'Something went wrong. Try again.';
      sm.showError(msg);
    }
  }, [sm, handleSessionLost]);

  const handleCoda = useCallback(async () => {
    if (!sessionRef.current) {
      console.warn("[coda] no sessionRef, aborting");
      return;
    }
    try {
      // Prefetched at episode-complete arrival; firing here is the fallback
      // for any path that reaches the coda without having passed through it.
      const pending = codaPromiseRef.current || fireCodaCalls();
      codaPromiseRef.current = null;
      const t0 = Date.now();
      const result = await pending;
      console.log(`[coda] residual wait after > press: ${Date.now() - t0}ms`);
      if (result && result.__codaError) throw result.__codaError;
      const [allyData, bioData, nextData] = result;
      console.log("[coda] allyData received:", allyData);
      // Append the episode's C.V. entry to the record (feeds the menu view,
      // friend invocations, and the next episode's writer). The reveal line
      // lands at the end of the coda body below.
      if (bioData && bioData.position_line) {
        appendBioEntry(bioData);
        window.__biography = {
          position_line: bioData.position_line,
          bullets: Array.isArray(bioData.bullets) ? bioData.bullets.filter(Boolean) : [],
          skills: bioData.skills || "",
        };
      }
      let codaBody = "";
      if (allyData?.name) {
        codaBody += `[ ${allyData.name.toUpperCase()} ]\n\n`;
      }
      if (allyData?.closing_beat) {
        codaBody += fixText(allyData.closing_beat);
      }
      if (allyData?.regard) {
        codaBody += `\n\n${fixText(allyData.regard)}`;
      }

      // Build the hook-card hand page up front; whether it queues directly
      // or parks behind a swap decision is settled below. Fetch failure or
      // a thin hand (under 2 cards) builds nothing, so the old end screen
      // stays as the fallback.
      const cards = Array.isArray(nextData?.options) && nextData.options.length >= 2
        ? nextData.options : null;
      // Continuity ledger rides the same response; absent (failed distill,
      // old server) just means a continuity pick starts clean.
      prevLedgerRef.current = typeof nextData?.ledger === 'string' && nextData.ledger ? nextData.ledger : null;
      let cardsPage = null;
      if (cards) {
        const choices = cards.map((c, i) => ({
          title: fixText(c.title), body: fixText(c.hook), domain: c.domain, mode: c.mode, index: i,
        }));
        choices.push({ title: "SOMEWHERE ELSE", body: "Name a person, a place, a moment.", isTypeYourOwn: true });
        nextCardsSummaryRef.current = cards.map((c) => `${c.title} | ${c.domain}`);
        cardsPage = { body: `[ ${cards.length} SITUATIONS IN MOTION ]`, choices, isCards: true };
        window.storyAPI.analytics({
          event: 'cardsShown',
          domain: domainRef.current,
          cards: nextCardsSummaryRef.current,
        });
      }

      // Join capture: a real join verdict, or the dev forceJoin knob (joins
      // are rare by design, so playtesting this path needs the override —
      // the knob forces the OFFER, never the add). One companion at a time,
      // and every join is the player's decision: an empty slot deals an
      // offer page, an occupied one deals a swap page.
      const dev = window.__devMode;
      const forcedJoin = !!(dev && dev.enabled && dev.forceJoin && allyData && allyData.name);
      let swapPage = null;
      if ((allyData?.joins || forcedJoin) && allyData.name) {
        const incumbent = loadRoster()[0] || null;
        const newcomer = {
          name: allyData.name,
          epithet: allyData.epithet || "",
          sketch: allyData.sketch || "",
          pronouns: allyData.pronouns || "",
          fromDomain: domainRef.current,
          invocations: [],
        };
        if (incumbent && incumbent.name === newcomer.name) {
          codaBody += `\n\n${allyData.name} is already at your side.`;
        } else if (!incumbent) {
          swapPage = offerPageFor(newcomer);
        } else {
          swapPage = swapPageFor(newcomer, incumbent);
          // Upgrade the plea with a generated parting line if it lands
          // before the player answers; the memory-quote fallback stands on
          // any failure or slow return. Patching the queued page object is
          // safe until it is pushed; the ref is cleared once answered.
          swapPatchRef.current = swapPage;
          const target = swapPage;
          window.storyAPI.allyCull(sessionRef.current, incumbent, newcomer.name, biographyForInvoke())
            .then((d) => {
              if (d && d.line && swapPatchRef.current === target) {
                target.body = swapBodyFor(newcomer, incumbent, `${incumbent.name}: "${fixText(d.line)}"`);
              }
            })
            .catch(() => {});
        }
      }
      if (!codaBody) codaBody = "The episode ends.";

      // Arc sign-off: this episode was an arc chapter and the ledger judged
      // its driving question settled. Diegetic — it rides the coda body, not
      // the chrome — and sits before the C.V. beat so the administrative
      // line stays last.
      if (arcCarriedRef.current && nextData?.arcConcluded) {
        codaBody += `\n\nSo ends this arc${allyData?.name ? ` with ${fixText(allyData.name)}` : ""}.`;
      }

      // C.V. reveal: one administrative beat at the end of the coda page —
      // the new position line and its lead bullet. A failed biography call
      // skips this silently; the full document lives in the "..." menu.
      if (bioData && bioData.position_line) {
        const lead = Array.isArray(bioData.bullets) && bioData.bullets[0]
          ? `\n- ${fixText(bioData.bullets[0])}` : "";
        codaBody += `\n\nC.V. UPDATED\n${fixText(bioData.position_line)}${lead}`;
      }

      // Queue what the coda's > press should reveal: the swap decision when
      // one is pending (the hook cards park behind it until SWAP_RESOLVED),
      // otherwise the hook cards directly.
      if (swapPage) {
        pendingCardsPageRef.current = cardsPage;
        sm.queueNextPage(swapPage);
      } else if (cardsPage) {
        sm.queueNextPage(cardsPage);
      }

      console.log("[coda] pushing page with body:", codaBody.slice(0, 80));
      sm.pushPage({ body: codaBody, choices: null, isCoda: true });
    } catch (err) {
      console.error("[coda] error:", err);
      sm.pushPage({ body: "The episode ends.", choices: null, isCoda: true });
    }
  }, [sm, fireCodaCalls]);

  // GO on a swap card: settle the roster, then give the beat its own page.
  // The parked hook-card hand (if any) queues behind the resolution page,
  // so > continues into the next-episode cards as usual. Synchronous — no
  // API call, no loading state.
  const handleSwapResolved = useCallback((choice) => {
    const { take, newcomer, incumbent } = choice || {};
    if (!newcomer) return;
    swapPatchRef.current = null; // answered — a late parting line is dropped
    let line;
    if (take) {
      saveRoster([newcomer]);
      line = incumbent
        ? `${incumbent.name} leaves.\n\n${newcomer.name} joined you.`
        : `${newcomer.name} joined you.`;
    } else {
      line = incumbent
        ? `${incumbent.name} stays at your side.`
        : "You continue on your own.";
    }
    window.storyAPI.analytics({
      event: incumbent ? 'friendSwap' : 'friendOffer',
      domain: domainRef.current,
      detail: incumbent
        ? `kept ${take ? newcomer.name : incumbent.name} | released ${take ? incumbent.name : newcomer.name}`
        : `${take ? 'took' : 'declined'} ${newcomer.name}`,
    });
    const cardsPage = pendingCardsPageRef.current;
    pendingCardsPageRef.current = null;
    if (cardsPage) sm.queueNextPage(cardsPage);
    sm.pushPage({ body: line, choices: null, isCoda: true });
  }, [sm]);

  // GO on a hook card: existing new-episode path on the card's domain.
  // Fired before reset so the analytics event carries the finished episode's
  // domain; storyAPI.start reads window.__devMode at call time, so play
  // settings carry over without threading.
  const handleStartNextEpisode = useCallback((card) => {
    // Continuity pick carries the finished episode's ledger into /start.
    // Captured before resetToDomain, which wipes episode state.
    const carry = card.mode === 'continuity' && prevLedgerRef.current
      ? { domain: domainRef.current, ledger: prevLedgerRef.current }
      : null;
    window.storyAPI.analytics({
      event: 'cardPicked',
      domain: domainRef.current,
      cards: nextCardsSummaryRef.current || undefined,
      cardPicked: `${card.index} | ${card.title} | ${card.domain} | ${card.mode || 'unknown'}${carry ? ' | carried' : ''}`,
    });
    sm.resetToDomain();
    handleDomainSubmit(card.domain, carry);
  }, [sm, handleDomainSubmit]);

  // Listen for state machine events that trigger API calls
  useEffect(() => {
    return eventBus.on((evt) => {
      if (evt.type === "ACTION" && evt.state === "domainInput" && evt.raw === "next") {
        // > on the landing: reject a captured topic — the flash resumes.
        if (window.__landingNext) window.__landingNext();
      } else if (evt.type === "DOMAIN_SUBMITTED") {
        // Read domain from the input component
        const domain = window.__domainInputValue && window.__domainInputValue();
        if (domain) handleDomainSubmit(domain);
        // Empty field: GO drives the landing ritual instead — the first
        // press starts the topic flash, the next captures the flashed topic
        // into the field (selected, keyboard up).
        else if (window.__landingGo) window.__landingGo();
      } else if (evt.type === "CHOICE_CONFIRMED") {
        if (evt.choice?.isTypeYourOwn) {
          // SOMEWHERE ELSE: back to the domain-input screen
          window.storyAPI.analytics({
            event: 'cardPicked',
            domain: domainRef.current,
            cards: nextCardsSummaryRef.current || undefined,
            cardPicked: 'somewhere-else',
          });
          sm.resetToDomain();
        } else if (evt.choice?.domain) {
          handleStartNextEpisode(evt.choice);
        } else {
          handleChoiceConfirmed(evt.index);
        }
      } else if (evt.type === "FREEDOM_SUBMITTED") {
        // GO pressed in the freedom entry — read the typed text (mirrors the
        // DOMAIN_SUBMITTED pattern). Empty text: stay in the entry, no call.
        const text = window.__freedomInputValue && window.__freedomInputValue();
        if (text) handleFreedomText(text);
      } else if (evt.type === "FRIEND_INVOKED") {
        handleInvokeFriend(evt.friend);
      } else if (evt.type === "SWAP_RESOLVED") {
        handleSwapResolved(evt.choice);
      } else if (evt.type === "CODA_TRIGGERED") {
        handleCoda();
      }
    });
  }, [handleDomainSubmit, handleChoiceConfirmed, handleFreedomText, handleInvokeFriend, handleSwapResolved, handleCoda, handleStartNextEpisode, sm]);

  const press = (id) => {
    // > during printing: page advance when paused, no-op otherwise
    if (id === "next" && sm.appState === "printing") {
      if (sm.pagePaused) {
        const container = window.__crtScrollContainer;
        if (container) {
          container.scrollBy({ top: container.clientHeight, behavior: 'instant' });
        }
        sm.advancePage();
        eventBus.emit({ type: "PRESS", id, at: Date.now(), state: sm.appState, action: "ADVANCE_PAGE" });
        audio.pressNext();
      }
      return;
    }

    // > during awaitingInput: scroll to card area if card is off-screen, otherwise cycle
    if (id === "next" && sm.appState === "awaitingInput" && sm.page?.choices?.length && sm.choicesRevealed) {
      const container = window.__crtScrollContainer;
      const card = window.__crtChoiceCard;
      if (container && card) {
        const containerRect = container.getBoundingClientRect();
        const cardRect = card.getBoundingClientRect();
        if (cardRect.top >= containerRect.bottom) {
          // Card is entirely below viewport (user scrolled up) — align card-bottom with viewport-bottom
          const deficit = cardRect.bottom - containerRect.bottom;
          container.scrollBy({ top: deficit + 4, behavior: 'smooth' });
          eventBus.emit({ type: "PRESS", id, at: Date.now(), state: sm.appState, action: "SCROLL_TO_CARD" });
          audio.pressNext();
          return;
        }
      }
    }

    eventBus.emit({ type: "PRESS", id, at: Date.now(), state: sm.appState });
    if (id === "go")   audio.pressGo();
    if (id === "next") audio.pressNext();
    if (id === "opt")  audio.pressOpt();
    sm.handlePress(id);
  };

  return (
    <div className="stage">
      <div
        ref={deviceRef}
        className="device"
        style={{
          aspectRatio: `${DEVICE_W} / ${DEVICE_H}`,
        }}
      >
        {/* Chassis stack: bottom strip → gear → body, in paint order. The
            body art occludes the gear's top half; the stack's overflow clip
            cuts the gear at the device's bottom edge. */}
        <div className="chassis-stack" aria-hidden="true">
          <img
            className="sprite"
            src={CHASSIS_BOT_SRC}
            alt=""
            draggable="false"
            style={{ left: 0, bottom: 0, width: "100%" }}
          />
          <GearSprite
            zone={sprites.gear}
            rpm={tweaks.gearRpm}
            stepDeg={tweaks.gearStepDeg}
            rampMs={tweaks.gearRampMs}
            spinning={gearSpinning}
            debug={tweaks.showHitZones}
          />
          <img
            className="sprite"
            src={CHASSIS_TOP_SRC}
            alt="T_ou_le device chassis"
            draggable="false"
            style={{ left: 0, top: 0, width: "100%" }}
          />
        </div>

        {/* Woodpecker video — above the chassis art, below the displays
            (mount z2), so all CRT text paints over it and screen-blend
            reaches the baked screen gradient. Outside showStubText on
            purpose — the stub-text dev toggle shouldn't kill the ambient
            layer. */}
        {showWoodpecker && sprites.woodpecker && (
          <WoodpeckerSprite
            rect={sprites.woodpecker}
            fast={woodpeckerLoading}
            opacity={tweaks.vidOpacity}
            blend={tweaks.vidBlend}
            debug={tweaks.showHitZones}
          />
        )}

        <DisplayMount
          id="title-display"
          label="title"
          rect={displays.title}
          debug={tweaks.showDisplayBounds}
        >
          {tweaks.showStubText && <StubTitle text={sm.episodeTitle || ""} stepSize={tweaks.eng_titleMarqueeStep} stepIntervalMs={tweaks.eng_titleMarqueeIntervalMs} />}
        </DisplayMount>
        <DisplayMount
          id="main-display"
          label="main"
          rect={displays.main}
          debug={tweaks.showDisplayBounds}
        >
          {tweaks.showStubText && (
            <StubMain
              appState={sm.appState}
              narrative={sm.narrative}
              body={sm.body}
              bodyDone={sm.bodyDone}
              choices={sm.page?.choices}
              choiceIdx={sm.choiceIdx}
              menuStack={sm.menuStack}
              settingValues={sm.settingValues}
              isOptions={sm.appState === "options"}
              retroParams={retroParams}
              landing={landingParams}
              errorMsg={sm.errorMsg}
              onDomainSubmit={handleDomainSubmit}
              onFreedomSubmit={handleFreedomText}
              pagePaused={sm.pagePaused}
              onPageFull={sm.pauseForPage}
              lastDecision={sm.lastDecision}
              pendingDecision={sm.pendingDecision}
              choicesRevealed={sm.choicesRevealed}
              episodeComplete={!!sm.page?.episodeComplete}
              isCoda={!!sm.page?.isCoda}
            />
          )}
        </DisplayMount>

        {/* LEDs — bezier-cycling glow, self-clocked (see LEDCluster) */}
        <LEDCluster
          leds={leds}
          maxFps={tweaks.ledMaxFps}
          hueA={tweaks.ledHueA}
          hueB={tweaks.ledHueB}
          hueC={tweaks.ledHueC}
          cycleMs={tweaks.ledCycleMs}
          intensity={tweaks.ledIntensity}
          loading={sm.appState === "loading"}
          debug={tweaks.showHitZones}
        />


        {/* Hit zones for buttons — disabled in calibrate mode so drags don't trigger presses */}
        {!tweaks.calibrate && Object.values(buttons).map((b) => (
          <HitZone
            key={b.id}
            zone={b}
            label={b.label}
            ariaLabel={b.label === ">" ? "Next / skip" : b.label}
            depth={tweaks.pressDepth}
            darken={tweaks.pressDarken}
            debug={tweaks.showHitZones}
            pressArt={PRESS_ART_SRC[b.id] && sprites[b.id] ? { src: PRESS_ART_SRC[b.id], rect: sprites[b.id] } : null}
            onPress={() => press(b.id)}
          />
        ))}

        {/* Calibration overlay — draggable/resizable boxes. Dev-only file;
            typeof guard because the identifier doesn't exist in production. */}
        {typeof CalibrationOverlay !== "undefined" && (
          <CalibrationOverlay
            enabled={tweaks.calibrate}
            leds={leds}
            buttons={buttons}
            displays={displays}
            sprites={sprites}
            setLeds={setLeds}
            setButtons={setButtons}
            setDisplays={setDisplays}
            setSprites={setSprites}
            deviceRef={deviceRef}
          />
        )}
      </div>

      {/* Playtest flight recorder — the one non-diegetic overlay (feedback.jsx).
          Snapshot is read at chip-press: narrative carries the whole episode so
          far (turn bodies + confirmed choices folded in), page is the screen
          they're looking at. */}
      {window.FeedbackOverlay && (
        <window.FeedbackOverlay
          getSnapshot={() => ({
            sessionId: sessionRef.current,
            domain: domainRef.current,
            episodeTitle: sm.episodeTitle,
            appState: sm.appState,
            pageIdx: sm.pageIdx,
            page: sm.page,
            narrative: sm.narrative,
            lastDecision: sm.lastDecision,
            pendingDecision: sm.pendingDecision,
            choiceIdx: sm.choiceIdx,
            choicesRevealed: sm.choicesRevealed,
            pagePaused: sm.pagePaused,
            bodyDone: sm.bodyDone,
            errorMsg: sm.errorMsg,
            settingValues: sm.settingValues,
            devMode: window.__devMode
              ? { flavour: window.__devMode.flavour, wordsPerTurn: window.__devMode.wordsPerTurn }
              : null,
          })}
        />
      )}
    </div>
  );
}

// =============================================================
// Tweaks panel
// =============================================================
// Shared knob set for one music voice (see musicFromTweaks / LandingMusic).
// Only ever rendered inside TweaksUI, so the Tweak* globals are present.
function MusicVoiceSection({ label, prefix, tweaks, setTweak, toggleable }) {
  const k = (name) => `mus_${prefix}_${name}`;
  return (
    <TweakSection label={label}>
      {toggleable && (
        <TweakToggle label="Enabled" value={!!tweaks[k("on")]}
          onChange={(v) => setTweak(k("on"), v)} />
      )}
      <TweakRadio label="Oscillator" value={tweaks[k("osc")]}
        options={["sine", "triangle", "square", "sawtooth", "fatsawtooth", "pulse"]}
        onChange={(v) => setTweak(k("osc"), v)} />
      <TweakSlider label="Volume (dB)" min={-40} max={0} step={1}
        value={tweaks[k("vol")]} onChange={(v) => setTweak(k("vol"), v)} />
      <TweakSlider label="Transpose (st)" min={-24} max={24} step={1}
        value={tweaks[k("trans")]} onChange={(v) => setTweak(k("trans"), v)} />
      <TweakSlider label="Detune (cents)" min={-50} max={50} step={1}
        value={tweaks[k("detune")]} onChange={(v) => setTweak(k("detune"), v)} />
      <TweakSlider label="Attack (s)" min={0.001} max={0.5} step={0.005}
        value={tweaks[k("attack")]} onChange={(v) => setTweak(k("attack"), v)} />
      <TweakSlider label="Decay (s)" min={0.01} max={1} step={0.01}
        value={tweaks[k("decay")]} onChange={(v) => setTweak(k("decay"), v)} />
      <TweakSlider label="Sustain" min={0} max={1} step={0.05}
        value={tweaks[k("sustain")]} onChange={(v) => setTweak(k("sustain"), v)} />
      <TweakSlider label="Release (s)" min={0.05} max={3} step={0.05}
        value={tweaks[k("release")]} onChange={(v) => setTweak(k("release"), v)} />
    </TweakSection>
  );
}

function TweaksUI({ tweaks, setTweak }) {
  const [tab, setTab] = useState("engine");
  return (
    <TweaksPanel title="Tweaks">
      {/* Export current settings as JSON — copy to clipboard + download */}
      <div style={{ marginBottom: 8 }}>
        <button type="button" onClick={() => {
          const json = JSON.stringify(tweaks, null, 2);
          // Clipboard
          try {
            navigator.clipboard?.writeText(json);
          } catch (_) { /* ignore */ }
          // Also offer download as a file for portability
          const blob = new Blob([json], { type: "application/json" });
          const url = URL.createObjectURL(blob);
          const a = document.createElement("a");
          a.href = url;
          a.download = `tweaks-${new Date().toISOString().replace(/[:.]/g, "-")}.json`;
          document.body.appendChild(a);
          a.click();
          a.remove();
          setTimeout(() => URL.revokeObjectURL(url), 1000);
        }} style={{
          width: "100%", border: 0, borderRadius: 4, padding: "8px 10px",
          background: "rgba(120,200,140,0.18)", color: "#cfe",
          fontWeight: 600, fontSize: 11, letterSpacing: "0.08em",
          textTransform: "uppercase", cursor: "pointer",
        }}>↓ Export settings (JSON)</button>
      </div>
      {/* Tab switcher */}
      <div style={{
        display: "flex", gap: 4, padding: 4,
        background: "rgba(255,255,255,0.04)", borderRadius: 6, marginBottom: 8,
      }}>
        {[
          { id: "engine", label: "Engine" },
          { id: "app",    label: "App" },
        ].map((t) => (
          <button key={t.id} type="button" onClick={() => setTab(t.id)}
            style={{
              flex: 1, border: 0, borderRadius: 4, padding: "6px 8px",
              background: tab === t.id ? "rgba(120,160,255,0.25)" : "transparent",
              color: tab === t.id ? "#cfe" : "rgba(255,255,255,0.6)",
              fontWeight: 600, fontSize: 11, letterSpacing: "0.06em",
              cursor: "pointer", textTransform: "uppercase",
            }}>{t.label}</button>
        ))}
      </div>

      {tab === "engine" && <EngineTweaks tweaks={tweaks} setTweak={setTweak} />}
      {tab === "app" && (<>
        <TweakSection label="Press feel">
          <TweakSlider label="Press depth (px)" min={0} max={12} step={1}
            value={tweaks.pressDepth} onChange={(v) => setTweak("pressDepth", v)} />
          <TweakSlider label="Press darken" min={0} max={0.6} step={0.02}
            value={tweaks.pressDarken} onChange={(v) => setTweak("pressDarken", v)} />
        </TweakSection>

        <TweakSection label="LEDs">
          <TweakSlider label="Cycle (ms)" min={500} max={6000} step={50}
            value={tweaks.ledCycleMs} onChange={(v) => setTweak("ledCycleMs", v)} />
          <TweakSlider label="Intensity" min={0} max={1.4} step={0.05}
            value={tweaks.ledIntensity} onChange={(v) => setTweak("ledIntensity", v)} />
          <TweakSlider label="Clock cap (fps, 0 = display rate)" min={0} max={60} step={5}
            value={tweaks.ledMaxFps} onChange={(v) => setTweak("ledMaxFps", v)} />
          <TweakSlider label="LED 1 hue" min={0} max={360} step={1}
            value={tweaks.ledHueA} onChange={(v) => setTweak("ledHueA", v)} />
          <TweakSlider label="LED 2 hue" min={0} max={360} step={1}
            value={tweaks.ledHueB} onChange={(v) => setTweak("ledHueB", v)} />
          <TweakSlider label="LED 3 hue" min={0} max={360} step={1}
            value={tweaks.ledHueC} onChange={(v) => setTweak("ledHueC", v)} />
        </TweakSection>

        <TweakSection label="Story (stub)">
          <TweakSlider label="Print speed (chars/s)" min={5} max={120} step={1}
            value={tweaks.printSpeed} onChange={(v) => setTweak("printSpeed", v)} />
          <TweakToggle label="Show stub text" value={!!tweaks.showStubText}
            onChange={(v) => setTweak("showStubText", v)} />
        </TweakSection>

        <TweakSection label="Greebles">
          <TweakSlider label="Gear RPM" min={0} max={40} step={1}
            value={tweaks.gearRpm} onChange={(v) => setTweak("gearRpm", v)} />
          <TweakSlider label="Gear step (deg)" min={1} max={90} step={1}
            value={tweaks.gearStepDeg} onChange={(v) => setTweak("gearStepDeg", v)} />
          <TweakSlider label="Gear ramp (ms)" min={0} max={3000} step={50}
            value={tweaks.gearRampMs} onChange={(v) => setTweak("gearRampMs", v)} />
          <TweakSlider label="Clicks per rev (x RPM, 0 = off)" min={0} max={60} step={0.5}
            value={tweaks.gear_clickMult} onChange={(v) => setTweak("gear_clickMult", v)} />
          <TweakSlider label="Click volume (dB)" min={-40} max={0} step={1}
            value={tweaks.gear_clickVol} onChange={(v) => setTweak("gear_clickVol", v)} />
          <TweakSlider label="Click tone (Hz)" min={100} max={8000} step={25}
            value={tweaks.gear_clickHz} onChange={(v) => setTweak("gear_clickHz", v)} />
          <TweakSlider label="Click pan (L to R)" min={-1} max={1} step={0.05}
            value={tweaks.gear_clickPan} onChange={(v) => setTweak("gear_clickPan", v)} />
        </TweakSection>

        <TweakSection label="Landing">
          <TweakSlider label="Video opacity" min={0} max={1} step={0.05}
            value={tweaks.vidOpacity} onChange={(v) => setTweak("vidOpacity", v)} />
          <TweakRadio label="Video blend" value={tweaks.vidBlend}
            options={["normal", "screen", "lighten"]}
            onChange={(v) => setTweak("vidBlend", v)} />
        </TweakSection>

        <TweakSection label="Landing: music">
          <TweakSlider label="Tempo (bpm)" min={40} max={240} step={1}
            value={tweaks.musicBpm} onChange={(v) => setTweak("musicBpm", v)} />
          <TweakSlider label="Filter cutoff (Hz)" min={200} max={12000} step={50}
            value={tweaks.mus_filterHz} onChange={(v) => setTweak("mus_filterHz", v)} />
          <TweakSlider label="Reverb wet" min={0} max={0.6} step={0.02}
            value={tweaks.mus_verbWet} onChange={(v) => setTweak("mus_verbWet", v)} />
          <TweakSlider label="Reverb room" min={0} max={0.95} step={0.05}
            value={tweaks.mus_verbRoom} onChange={(v) => setTweak("mus_verbRoom", v)} />
          <TweakSlider label="Pan (L to R)" min={-1} max={1} step={0.05}
            value={tweaks.mus_pan} onChange={(v) => setTweak("mus_pan", v)} />
        </TweakSection>

        <MusicVoiceSection label="Music: voice 1" prefix="v1"
          tweaks={tweaks} setTweak={setTweak} />
        <MusicVoiceSection label="Music: voice 2" prefix="v2"
          tweaks={tweaks} setTweak={setTweak} toggleable />

        <TweakSection label="Landing: topic flash">
          <TweakSlider label="Flash interval (ms)" min={200} max={3000} step={50}
            value={tweaks.topicFlashMs} onChange={(v) => setTweak("topicFlashMs", v)} />
          <TweakSlider label="Font (x)" min={0.2} max={1.2} step={0.02}
            value={tweaks.topicFontScale} onChange={(v) => setTweak("topicFontScale", v)} />
          <TweakSlider label="X (%)" min={0} max={100} step={0.5}
            value={tweaks.topicX} onChange={(v) => setTweak("topicX", v)} />
          <TweakSlider label="Y (%)" min={0} max={100} step={0.5}
            value={tweaks.topicY} onChange={(v) => setTweak("topicY", v)} />
        </TweakSection>

        <TweakSection label="Landing: input box">
          {/* No font slider: the input takes after the topic flash font. */}
          <TweakText label="Placeholder text" value={tweaks.inputPlaceholder}
            onChange={(v) => setTweak("inputPlaceholder", v)} />
          <TweakSlider label="X (%)" min={0} max={100} step={0.5}
            value={tweaks.inputX} onChange={(v) => setTweak("inputX", v)} />
          <TweakSlider label="Y (%)" min={0} max={100} step={0.5}
            value={tweaks.inputY} onChange={(v) => setTweak("inputY", v)} />
        </TweakSection>

        <TweakSection label="Landing: instruction">
          <TweakText label="Text" value={tweaks.hintText}
            onChange={(v) => setTweak("hintText", v)} />
          <TweakSlider label="Font (x)" min={0.2} max={1.2} step={0.02}
            value={tweaks.hintFontScale} onChange={(v) => setTweak("hintFontScale", v)} />
          <TweakSlider label="X (%)" min={0} max={100} step={0.5}
            value={tweaks.hintX} onChange={(v) => setTweak("hintX", v)} />
          <TweakSlider label="Y (%)" min={0} max={100} step={0.5}
            value={tweaks.hintY} onChange={(v) => setTweak("hintY", v)} />
        </TweakSection>

        <TweakSection label="Debug">
          <TweakToggle label="Calibrate mode" value={!!tweaks.calibrate}
            onChange={(v) => setTweak("calibrate", v)} />
          <TweakToggle label="Show hit zones" value={tweaks.showHitZones}
            onChange={(v) => setTweak("showHitZones", v)} />
          <TweakToggle label="Show display bounds" value={tweaks.showDisplayBounds}
            onChange={(v) => setTweak("showDisplayBounds", v)} />
        </TweakSection>
      </>)}
    </TweaksPanel>
  );
}

// =============================================================
// App root
// =============================================================
function App() {
  // useTweaks comes from tweaks-panel.jsx, which production strips from the
  // page — fall back to the baked defaults with a no-op setter so the game
  // runs identically without any dev tooling loaded.
  const [tweaks, setTweak] = (window.useTweaks || ((d) => [d, () => {}]))(TWEAK_DEFAULTS);
  const [leds, setLeds] = useState(LEDS_DEFAULT);
  const [buttons, setButtons] = useState(BUTTONS_DEFAULT);
  const [displays, setDisplays] = useState({ title: TITLE_DISPLAY_DEFAULT, main: MAIN_DISPLAY_DEFAULT });
  const [sprites, setSprites] = useState(SPRITES_DEFAULT);
  const deviceRef = useRef(null);
  const reset = () => {
    setLeds(LEDS_DEFAULT);
    setButtons(BUTTONS_DEFAULT);
    setDisplays({ title: TITLE_DISPLAY_DEFAULT, main: MAIN_DISPLAY_DEFAULT });
    setSprites(SPRITES_DEFAULT);
  };
  return (
    <React.Fragment>
      <Device
        tweaks={tweaks}
        leds={leds}
        buttons={buttons}
        displays={displays}
        sprites={sprites}
        setLeds={setLeds}
        setButtons={setButtons}
        setDisplays={setDisplays}
        setSprites={setSprites}
        deviceRef={deviceRef}
      />
      {/* Dev-only chrome below: the scripts defining these are stripped in
          production, so each render is guarded on its global existing. */}
      {window.TweaksPanel && <TweaksUI tweaks={tweaks} setTweak={setTweak} />}
      {typeof CalibrationPanel !== "undefined" && (
        <CalibrationPanel
          enabled={!!tweaks.calibrate}
          leds={leds}
          buttons={buttons}
          displays={displays}
          sprites={sprites}
          onReset={reset}
        />
      )}
    </React.Fragment>
  );
}

ReactDOM.createRoot(document.getElementById("root")).render(<App />);
