/* ========================================================================
   LUMINA — Workspace: Nodes (visual programming, TouchDesigner-style)
   A complementary view to the parameter panels — shows audio→fx→viz→output
   data flow as draggable nodes with wires. Lets you reroute and add
   processing without touching code.
   ======================================================================== */
const { useState: useStateN, useEffect: useEffectN, useRef: useRefN } = React;

/* ── Web Serial manager (ESP32 / Arduino controllers over USB) ──
   Protocol: newline-separated lines like  V:<a>,<b>,<c>[,<d>...]
   Values 0..1 (floats) or 0..4095 (auto-normalized). Channels land in
   window.LuminaSerial.ch[0..7]; "gate" = any value seen in last 150 ms. */
window.LuminaSerial = window.LuminaSerial || {
  connected: false, ch: [0, 0, 0, 0, 0, 0, 0, 0], lastLine: "", lastT: 0,
  txBeat: true,    // broadcast BPM/beat/levels → hardware beat monitors
  listeners: new Set(),
  on(fn) { this.listeners.add(fn); return () => this.listeners.delete(fn); },
  async connect() {
    if (!navigator.serial) { alert("Web Serial is not supported by this browser. Use Chrome/Edge."); return false; }
    try {
      const port = await navigator.serial.requestPort();
      await port.open({ baudRate: 115200 });
      this._port = port;
      this.connected = true;
      this.listeners.forEach(fn => fn(this));
      this._readLoop();
      this._startTx();
      return true;
    } catch (e) { console.warn("Serial:", e); return false; }
  },
  async disconnect() {
    try { this._stopTx(); this._stop = true; await this._reader?.cancel(); await this._port?.close(); } catch (e) {}
    this.connected = false;
    this.listeners.forEach(fn => fn(this));
  },
  /* TX: "B:<bpm>,<beat>,<bass>,<mid>,<high>" — drives the ST7735 beat monitor */
  _startTx() {
    this._stopTx();
    try { this._writer = this._port.writable.getWriter(); } catch (e) { return; }
    const enc = new TextEncoder();
    this._txTimer = setInterval(() => {
      if (!this.connected || !this.txBeat || !this._writer) return;
      try {
        const C = window.LuminaClock, A = window.AudioEngine || {};
        const ti = C ? C.tickInfo() : { phase: 0 };
        const line = "B:" + (C ? C.bpm : 0) + "," + (ti.phase < 0.18 ? 1 : 0) + ","
          + (A.bass || 0).toFixed(2) + "," + (A.mid || 0).toFixed(2) + ","
          + (A.high || 0).toFixed(2) + "\n";
        this._writer.write(enc.encode(line)).catch(() => {});
      } catch (e) {}
    }, 80);
  },
  _stopTx() {
    if (this._txTimer) { clearInterval(this._txTimer); this._txTimer = null; }
    try { this._writer?.releaseLock(); } catch (e) {}
    this._writer = null;
  },
  async _readLoop() {
    this._stop = false;
    const dec = new TextDecoder();
    let buf = "";
    while (this._port?.readable && !this._stop) {
      this._reader = this._port.readable.getReader();
      try {
        while (true) {
          const { value, done } = await this._reader.read();
          if (done || this._stop) break;
          buf += dec.decode(value);
          let nl;
          while ((nl = buf.indexOf("\n")) >= 0) {
            const line = buf.slice(0, nl).trim();
            buf = buf.slice(nl + 1);
            if (!line) continue;
            this.lastLine = line; this.lastT = performance.now();
            const m = line.match(/^V:(.+)$/i);
            if (m) {
              m[1].split(",").forEach((s, i) => {
                if (i > 7) return;
                let v = parseFloat(s);
                if (!isNaN(v)) { if (v > 1.0001) v = v / 4095; this.ch[i] = Math.max(0, Math.min(1, v)); }
              });
            }
          }
        }
      } catch (e) { console.warn("Serial read:", e); }
      finally { try { this._reader.releaseLock(); } catch (e) {} }
    }
    this.connected = false;
    this.listeners.forEach(fn => fn(this));
  },
};

const NODE_KINDS = {
  audio: {
    label: "Audio Source", color: "#ffae3a",
    inputs: [], outputs: ["bass", "mid", "high", "energy", "beat"],
    params: [],
  },
  oscillator: {
    label: "Oscillator", color: "#ffd84a",
    inputs: ["freq", "amp"], outputs: ["audio", "control"],
    params: [
      { id: "wave", label: "Wave", kind: "seg", options: ["sine","tri","saw","sqr"], default: "sine" },
      { id: "freq", label: "Freq Hz", kind: "slider", min: 20, max: 4000, step: 1, default: 220 },
      { id: "amp", label: "Amp", kind: "slider", min: 0, max: 1, step: 0.01, default: 0.5 },
      { id: "detune", label: "Detune ¢", kind: "slider", min: -100, max: 100, step: 1, default: 0 },
    ],
  },
  filter: {
    label: "Filter", color: "#7cff4f",
    inputs: ["audio", "cutoff"], outputs: ["audio"],
    params: [
      { id: "type", label: "Type", kind: "seg", options: ["low","high","band","notch"], default: "low" },
      { id: "cutoff", label: "Cutoff", kind: "slider", min: 60, max: 8000, step: 1, default: 1000 },
      { id: "q", label: "Resonance", kind: "slider", min: 0.1, max: 30, step: 0.1, default: 1 },
    ],
  },
  envelope: {
    label: "ADSR Envelope", color: "#ff9a3a",
    inputs: ["trigger"], outputs: ["env"],
    params: [
      { id: "attack", label: "Attack s", kind: "slider", min: 0.001, max: 2, step: 0.01, default: 0.05 },
      { id: "decay", label: "Decay s", kind: "slider", min: 0.001, max: 2, step: 0.01, default: 0.2 },
      { id: "sustain", label: "Sustain", kind: "slider", min: 0, max: 1, step: 0.01, default: 0.5 },
      { id: "release", label: "Release s", kind: "slider", min: 0.01, max: 4, step: 0.01, default: 0.3 },
    ],
  },
  sequencer: {
    label: "Step Sequencer", color: "#ff5ad6",
    inputs: ["clock"], outputs: ["trigger", "pitch"],
    params: [
      { id: "steps", label: "Steps", kind: "slider", min: 4, max: 32, step: 1, default: 16 },
      { id: "bpm", label: "BPM", kind: "slider", min: 60, max: 200, step: 1, default: 128 },
      { id: "pattern", label: "Pattern", kind: "seg", options: ["1010","1100","1110","rand"], default: "1010" },
    ],
  },
  delay: {
    label: "Delay", color: "#b785ff",
    inputs: ["audio"], outputs: ["audio"],
    params: [
      { id: "time", label: "Time ms", kind: "slider", min: 10, max: 2000, step: 5, default: 250 },
      { id: "feedback", label: "Feedback", kind: "slider", min: 0, max: 0.95, step: 0.01, default: 0.4 },
      { id: "mix", label: "Mix", kind: "slider", min: 0, max: 1, step: 0.01, default: 0.35 },
    ],
  },
  bang: {
    label: "Bang Trigger", color: "#ff3b3b",
    inputs: [], outputs: ["trigger"],
    params: [
      { id: "label", label: "Label", kind: "text", default: "BANG" },
    ],
  },
  speaker: {
    label: "Speakers", color: "#9bcfff",
    inputs: ["audio_l", "audio_r"], outputs: [],
    params: [
      { id: "gain", label: "Master", kind: "slider", min: 0, max: 1, step: 0.01, default: 0.7 },
    ],
  },
  band: {
    label: "Band Filter", color: "#4be6ff",
    inputs: ["in"], outputs: ["out"],
    params: [
      { id: "band", label: "Band", kind: "seg", options: ["bass","mid","high","energy"], default: "bass" },
      { id: "gain", label: "Gain", kind: "slider", min: 0, max: 4, step: 0.01, default: 1 },
      { id: "smooth", label: "Smooth", kind: "slider", min: 0, max: 0.95, step: 0.01, default: 0.5 },
    ],
  },
  curve: {
    label: "Curve / Math", color: "#b785ff",
    inputs: ["in"], outputs: ["out"],
    params: [
      { id: "shape", label: "Shape", kind: "seg", options: ["linear","exp","log","invert","threshold"], default: "linear" },
      { id: "amount", label: "Amount", kind: "slider", min: 0.1, max: 4, step: 0.01, default: 1 },
    ],
  },
  lfo: {
    label: "LFO", color: "#7cff4f",
    inputs: [], outputs: ["out"],
    params: [
      { id: "wave", label: "Wave", kind: "seg", options: ["sin","tri","saw","sqr"], default: "sin" },
      { id: "rate", label: "Rate Hz", kind: "slider", min: 0.05, max: 8, step: 0.01, default: 1 },
      { id: "amp", label: "Amp", kind: "slider", min: 0, max: 1, step: 0.01, default: 0.5 },
    ],
  },
  visualizer: {
    label: "Visualizer", color: "#ff7a1a",
    inputs: ["audio", "color"], outputs: ["frame"],
    params: [
      { id: "kind", label: "Type", kind: "viz", default: "radial" },
      { id: "intensity", label: "Intensity", kind: "slider", min: 0, max: 2, step: 0.01, default: 1 },
    ],
  },
  fx: {
    label: "FX Chain", color: "#ff5ad6",
    inputs: ["frame", "amount"], outputs: ["frame"],
    params: [
      { id: "kind", label: "Effect", kind: "fxsel", default: "bloom" },
      { id: "amount", label: "Amount", kind: "slider", min: 0, max: 1, step: 0.01, default: 0.5 },
      { id: "wet", label: "Wet/Dry", kind: "slider", min: 0, max: 1, step: 0.01, default: 1 },
    ],
  },
  text: {
    label: "Text / Title", color: "#ffd84a",
    inputs: [], outputs: ["frame"],
    params: [
      { id: "text", label: "Text", kind: "text", default: "HELLO" },
      { id: "size", label: "Size", kind: "slider", min: 12, max: 300, step: 1, default: 80 },
    ],
  },
  composite: {
    label: "Composite", color: "#9bcfff",
    inputs: ["a", "b", "mask"], outputs: ["frame"],
    params: [
      { id: "mode", label: "Mode", kind: "seg", options: ["over","add","screen","mul"], default: "over" },
      { id: "mix", label: "Mix", kind: "slider", min: 0, max: 1, step: 0.01, default: 0.5 },
    ],
  },
  random: {
    label: "Random / S&H", color: "#ffd84a",
    inputs: ["trigger"], outputs: ["out"],
    params: [
      { id: "min", label: "Min", kind: "slider", min: 0, max: 1, step: 0.01, default: 0 },
      { id: "max", label: "Max", kind: "slider", min: 0, max: 1, step: 0.01, default: 1 },
      { id: "rate", label: "Rate Hz", kind: "slider", min: 0.1, max: 20, step: 0.1, default: 2 },
    ],
  },
  noise: {
    label: "Noise Gen", color: "#7cff4f",
    inputs: ["scale"], outputs: ["out"],
    params: [
      { id: "type", label: "Type", kind: "seg", options: ["perlin","simplex","white","value"], default: "perlin" },
      { id: "scale", label: "Scale", kind: "slider", min: 0.1, max: 10, step: 0.1, default: 2 },
      { id: "speed", label: "Speed", kind: "slider", min: 0, max: 4, step: 0.01, default: 1 },
    ],
  },
  mixer: {
    label: "Mixer (4ch)", color: "#9bcfff",
    inputs: ["a", "b", "c", "d"], outputs: ["out"],
    params: [
      { id: "ga", label: "A", kind: "slider", min: 0, max: 1, step: 0.01, default: 1 },
      { id: "gb", label: "B", kind: "slider", min: 0, max: 1, step: 0.01, default: 0.5 },
      { id: "gc", label: "C", kind: "slider", min: 0, max: 1, step: 0.01, default: 0 },
      { id: "gd", label: "D", kind: "slider", min: 0, max: 1, step: 0.01, default: 0 },
    ],
  },
  feedbackfx: {
    label: "Feedback FX", color: "#ff5ad6",
    inputs: ["frame"], outputs: ["frame"],
    params: [
      { id: "zoom", label: "Zoom", kind: "slider", min: 0.9, max: 1.1, step: 0.001, default: 1.01 },
      { id: "rotate", label: "Rotate °", kind: "slider", min: -10, max: 10, step: 0.1, default: 0.5 },
      { id: "decay", label: "Decay", kind: "slider", min: 0, max: 1, step: 0.01, default: 0.92 },
      { id: "hueShift", label: "Hue Shift", kind: "slider", min: 0, max: 30, step: 0.5, default: 4 },
    ],
  },
  output: {
    label: "Live Output", color: "#ff3b3b",
    inputs: ["frame"], outputs: [],
    params: [],
  },
  /* ── Logic & Automation — ported from the TM Studio node graph ── */
  constant: {
    label: "Constant", color: "#b785ff",
    inputs: [], outputs: ["out"],
    params: [{ id: "value", label: "Value", kind: "slider", min: 0, max: 1, step: 0.01, default: 0.5 }],
  },
  clock: {
    label: "BPM Clock", color: "#ffd84a",
    inputs: [], outputs: ["phase", "bar", "trig"],
    params: [
      { id: "div", label: "Division", kind: "seg", options: ["1/4","1/2","1","2","4"], default: "1" },
    ],
  },
  math: {
    label: "Math A∘B", color: "#b785ff",
    inputs: ["a", "b"], outputs: ["out"],
    params: [
      { id: "op", label: "Op", kind: "seg", options: ["add","sub","mul","min","max","avg","pow"], default: "add" },
      { id: "b", label: "B (if unwired)", kind: "slider", min: 0, max: 1, step: 0.01, default: 0.5 },
    ],
  },
  compare: {
    label: "Compare", color: "#b785ff",
    inputs: ["a", "b"], outputs: ["out"],
    params: [
      { id: "op", label: "Op", kind: "seg", options: [">","<","≈"], default: ">" },
      { id: "b", label: "B (if unwired)", kind: "slider", min: 0, max: 1, step: 0.01, default: 0.5 },
    ],
  },
  gate: {
    label: "Gate / Select", color: "#b785ff",
    inputs: ["a", "b", "ctrl"], outputs: ["out"],
    params: [{ id: "thresh", label: "Threshold", kind: "slider", min: 0, max: 1, step: 0.01, default: 0.5 }],
  },
  smooth: {
    label: "Smooth / Lag", color: "#b785ff",
    inputs: ["in"], outputs: ["out"],
    params: [{ id: "amount", label: "Lag", kind: "slider", min: 0, max: 0.99, step: 0.01, default: 0.8 }],
  },
  midiin: {
    label: "MIDI In", color: "#4be6ff",
    inputs: [], outputs: ["value", "gate"],
    params: [{ id: "filter", label: "Listen", kind: "seg", options: ["any","cc","note"], default: "any" }],
  },
  paramout: {
    label: "→ Param Out", color: "#ffae3a",
    inputs: ["in"], outputs: [],
    params: [
      { id: "target", label: "Target Param", kind: "seg",
        options: ["intensity","motion","density","glow","hue","lineWidth","bgAlpha","macro1","macro2","macro3","macro4"],
        default: "intensity" },
      { id: "mode", label: "Mode", kind: "seg", options: ["add","set"], default: "add" },
      { id: "amount", label: "Strength", kind: "slider", min: 0, max: 1, step: 0.01, default: 0.5 },
    ],
  },
  euclid: {
    label: "Euclid Rhythm", color: "#ff5ad6",
    inputs: [], outputs: ["trig", "phase"],
    params: [
      { id: "steps", label: "Steps", kind: "slider", min: 2, max: 16, step: 1, default: 8 },
      { id: "fills", label: "Fills", kind: "slider", min: 1, max: 16, step: 1, default: 3 },
      { id: "rotate", label: "Rotate", kind: "slider", min: 0, max: 15, step: 1, default: 0 },
      { id: "div", label: "Division", kind: "seg", options: ["1/4","1/2","1"], default: "1/4" },
    ],
  },
  counter: {
    label: "Counter", color: "#b785ff",
    inputs: ["trig"], outputs: ["out", "cycle"],
    params: [
      { id: "length", label: "Length", kind: "slider", min: 2, max: 32, step: 1, default: 8 },
    ],
  },
  macroin: {
    label: "Macro In", color: "#ffae3a",
    inputs: [], outputs: ["m1", "m2", "m3", "m4"],
    params: [],
  },
  serialin: {
    label: "Serial In (USB)", color: "#4be6ff",
    inputs: [], outputs: ["ch1", "ch2", "ch3", "ch4", "ch5", "ch6", "ch7", "ch8", "gate"],
    params: [
      { id: "connect", label: "USB Device", kind: "serial" },
    ],
  },
  /* ── Interactive installation I/O ── */
  wsin: {
    label: "WebSocket In", color: "#50c0e0",
    inputs: [], outputs: ["ch1", "ch2", "ch3", "ch4", "gate"],
    params: [{ id: "conn", label: "Server", kind: "ws" }],
  },
  wsout: {
    label: "WebSocket Out", color: "#50c0e0",
    inputs: ["v1", "v2", "v3", "v4"], outputs: [],
    params: [{ id: "conn", label: "Server", kind: "ws" }],
  },
  httpin: {
    label: "HTTP Poll", color: "#50c0e0",
    inputs: [], outputs: ["value"],
    params: [
      { id: "url", label: "URL", kind: "text", default: "http://localhost:8080/value.txt" },
      { id: "every", label: "Every s", kind: "slider", min: 0.2, max: 10, step: 0.1, default: 1 },
    ],
  },
  camera: {
    label: "Camera Motion", color: "#7cff4f",
    inputs: [], outputs: ["motion", "bright"],
    params: [{ id: "conn", label: "Webcam", kind: "cam" }],
  },
};

/* ════ AUTO-GENERATED FX NODES — every Prog FX becomes a first-class node
   (TM Studio style). Every numeric parameter is BOTH a slider AND an input
   port, so anything (audio, LFO, ESP, camera…) can modulate anything. ════ */
const FX_PARAM_RANGES = {
  amount: [0, 1, 0.01], threshold: [0, 1, 0.01], size: [0.05, 1, 0.01],
  decay: [0.5, 0.99, 0.005], zoom: [0, 0.1, 0.001], rotate: [-10, 10, 0.1],
  segments: [2, 12, 1], cutoff: [0, 1, 0.01], levels: [2, 12, 1],
  angle: [0, 360, 1], cell: [3, 24, 1], colors: [2, 10, 1],
  density: [0, 1, 0.01], hue: [0, 360, 1], hue1: [0, 360, 1], hue2: [0, 360, 1],
  x: [0, 1, 0.01], y: [0, 1, 0.01], soft: [0.01, 1, 0.01], temp: [-1, 1, 0.01],
};
const FX_PARAM_OVERRIDES = { pixelate: { size: [0.01, 0.25, 0.005] } };
Object.entries(window.FXTypes || {}).forEach(([type, def]) => {
  if (type === "customScript") return;
  const params = [];
  const inputs = ["frame"];
  Object.entries(def.defaults || {}).forEach(([k, v]) => {
    if (typeof v !== "number") return;
    const r = (FX_PARAM_OVERRIDES[type] && FX_PARAM_OVERRIDES[type][k]) || FX_PARAM_RANGES[k] || [0, 1, 0.01];
    params.push({ id: k, label: k, kind: "slider", min: r[0], max: r[1], step: r[2], default: v });
    inputs.push(k);
  });
  params.push({ id: "wet", label: "Wet/Dry", kind: "slider", min: 0, max: 1, step: 0.01, default: 1 });
  NODE_KINDS["fx_" + type] = {
    label: def.label, color: "#ff5ad6",
    inputs, outputs: ["frame"], params, isFx: type,
  };
});
const FX_GROUPS = [
  ["FX · Color & Grade", "#f0c060", ["hueshift", "saturate", "contrast", "invert", "posterize", "tint", "temptint", "gradmap", "grayscale", "threshold", "isolate"]],
  ["FX · Stylize", "#c8b8e8", ["halftone", "ascii", "dither", "crosshatch", "edge", "sharpen", "scanlines", "crt", "noise", "vignette"]],
  ["FX · Distort & Glitch", "#50d0b0", ["pixelate", "wavewarp", "kaleidopost", "glitch", "vhs", "pixelsort", "postertime", "disintegrate"]],
  ["FX · Light & Motion", "#ffce6a", ["bloom", "lightrays", "flare", "vortex", "stardust", "caustics", "aurora", "ripples", "mist", "strobeflash", "echo", "feedback", "lighttrails", "chromatic", "rgbshift", "blur"]],
  ["Masks", "#60b8d0", ["maskoval", "maskrect", "maskgrad", "maskradial", "maskluma"]],
];
FX_GROUPS.forEach(([name, color, list]) => list.forEach(t => {
  const k = NODE_KINDS["fx_" + t];
  if (k) k.color = color;
}));

let _nodeId = 0;

const NODE_CATEGORIES = [
  { name: "Sources & Audio", kinds: ["audio", "oscillator", "lfo", "noise", "bang"] },
  { name: "Clock & Rhythm", kinds: ["clock", "euclid", "sequencer", "counter", "random"] },
  { name: "Logic & Math", kinds: ["constant", "math", "compare", "gate", "smooth", "curve", "envelope"] },
  { name: "Interactive Inputs", kinds: ["midiin", "serialin", "wsin", "httpin", "camera", "macroin"] },
  { name: "Process Audio", kinds: ["band", "filter", "delay", "mixer"] },
  { name: "Visual Core", kinds: ["visualizer", "text", "fx", "feedbackfx", "composite"] },
  ...FX_GROUPS.map(([name, color, list]) => ({
    name, kinds: list.map(t => "fx_" + t).filter(k => NODE_KINDS[k]),
  })),
  { name: "Output & Param", kinds: ["paramout", "wsout", "speaker", "output"] },
];

/* Map the node-graph FX picker (one Amount knob) onto each Prog FX's
   native parameters. New TM-ported FX accept a plain `amount`. */
function nodeFxToProg(kind, amount) {
  const a = Math.max(0, Math.min(1, amount == null ? 0.5 : amount));
  switch (kind) {
    case "pixelate":   return { type: kind, size: 0.01 + a * 0.12 };
    case "echo":       return { type: kind, decay: 0.5 + a * 0.48, zoom: 0 };
    case "feedback":   return { type: kind, zoom: 0.005 + a * 0.05, rotate: a * 2, decay: 0.88 + a * 0.1 };
    case "kaleidopost":return { type: kind, segments: Math.max(2, Math.round(2 + a * 10)) };
    case "threshold":  return { type: kind, cutoff: a };
    case "isolate":    return { type: kind, cutoff: a };
    case "posterize":  return { type: kind, levels: Math.round(2 + (1 - a) * 10) };
    case "hueshift":   return { type: kind, angle: a * 360 };
    case "saturate":   return { type: kind, amount: a * 3 };
    case "contrast":   return { type: kind, amount: 0.5 + a * 2 };
    case "scanlines":  return { type: kind, density: a, amount: 0.3 + a * 0.5 };
    case "noise":      return { type: kind, amount: a * 0.5 };
    case "dither":     return { type: kind, colors: Math.round(2 + (1 - a) * 8) };
    case "halftone":   return { type: kind, cell: Math.round(4 + (1 - a) * 16) };
    case "ascii":      return { type: kind, cell: Math.round(6 + (1 - a) * 14) };
    case "edge":       return { type: kind, amount: a * 2 };
    case "tint":       return { type: kind, amount: a, hue: 200 };
    case "lighttrails":return { type: kind, amount: a, angle: 90 };
    case "flare":      return { type: kind, amount: a, x: 0.5, y: 0.35 };
    case "invert": case "grayscale": return { type: kind };
    default:           return { type: kind, amount: a };
  }
}

/* ───────────────────────── SIGNAL MATH ───────────────────────── */
function _osc(wave, phase) {
  const p = phase % 1;
  switch (wave) {
    case "tri": return 1 - 4 * Math.abs(Math.round(p) - p);
    case "saw": return 2 * p - 1;
    case "sqr": return p < 0.5 ? 1 : -1;
    case "sin": case "sine": default: return Math.sin(p * Math.PI * 2);
  }
}
function _hash(n) { const s = Math.sin(n * 127.1) * 43758.5453; return s - Math.floor(s); }
function _vnoise(x) {
  const i = Math.floor(x), f = x - i;
  const u = f * f * (3 - 2 * f);
  return _hash(i) * (1 - u) + _hash(i + 1) * u;
}
function _curve(v, shape, amt) {
  v = Math.max(0, Math.min(1, v));
  switch (shape) {
    case "exp": return Math.pow(v, Math.max(0.1, amt));
    case "log": return Math.pow(v, 1 / Math.max(0.1, amt));
    case "invert": return 1 - v;
    case "threshold": return v >= (1 - amt / 4) ? 1 : 0;
    case "linear": default: return Math.max(0, Math.min(1, v * amt));
  }
}

/* ─────────────────── GRAPH EVALUATOR (live engine) ───────────────────
   Walks the node graph every frame. Each output port resolves to a numeric
   control signal (0..1) or a "frame" video token. Per-node memory (smoothing,
   S&H, sequencer step) is kept in `mem` keyed by node id. Cycles are guarded. */
function evalGraph(nodes, edges, audio, t, mem) {
  const values = {};
  const visiting = {};
  const inEdge = (nodeId, port) => edges.find(e => e.to === nodeId && e.toPort === port);
  const inputVal = (nodeId, port) => {
    const e = inEdge(nodeId, port);
    return e ? outVal(e.from, e.fromPort) : null;
  };
  function outVal(nodeId, port) {
    const key = nodeId + ":" + port;
    if (key in values) return values[key];
    if (visiting[nodeId]) return 0;           // cycle guard
    visiting[nodeId] = true;
    const n = nodes.find(x => x.id === nodeId);
    if (!n) { visiting[nodeId] = false; return 0; }
    const P = n.params; const m = (mem[nodeId] = mem[nodeId] || {});
    let v = 0;
    if (n.bypass) {
      // bypassed node passes its first input through unchanged
      const fi = (NODE_KINDS[n.kind].inputs || [])[0];
      v = fi ? (inputVal(nodeId, fi) ?? 0) : 0;
      visiting[nodeId] = false;
      values[key] = v;
      return v;
    }
    switch (n.kind) {
      case "audio": v = audio[port] ?? 0; break;
      case "band": {
        const raw = inputVal(nodeId, "in"); const base = raw != null ? raw : (audio[P.band] ?? 0);
        const sm = P.smooth ?? 0; m.e = (m.e == null ? base : m.e + (base - m.e) * (1 - sm));
        v = Math.max(0, Math.min(1, m.e * (P.gain ?? 1)));
        break;
      }
      case "lfo": v = (_osc(P.wave, t * P.rate) * 0.5 + 0.5) * P.amp; break;
      case "oscillator": v = (_osc(P.wave, t * (P.freq / 100)) * 0.5 + 0.5) * P.amp; break;
      case "noise": {
        const sp = P.speed ?? 1, sc = P.scale ?? 2;
        v = _vnoise(t * sp + nodeId.charCodeAt(1) * 7) * (P.type === "white" ? Math.random() : 1);
        v = Math.max(0, Math.min(1, v));
        break;
      }
      case "curve": { const i = inputVal(nodeId, "in"); v = _curve(i != null ? i : 0, P.shape, P.amount); break; }
      case "filter": { const i = inputVal(nodeId, "audio"); v = i != null ? i : 0; break; }
      case "envelope": {
        const trig = inputVal(nodeId, "trigger");
        if (trig != null && trig > 0.6 && !m.gate) { m.gate = true; m.v = 1; }
        if (trig == null || trig < 0.4) m.gate = false;
        m.v = (m.v || 0) * (1 - (P.release ?? 0.3) * 0.06);
        v = m.v;
        break;
      }
      case "sequencer": {
        const steps = Math.round(P.steps), bpm = P.bpm;
        const step = Math.floor((t * bpm / 60)) % steps;
        const pat = P.pattern === "rand" ? null : P.pattern;
        const on = pat ? (pat[step % pat.length] === "1") : (_hash(step + Math.floor(t)) > 0.5);
        v = port === "pitch" ? (step / steps) : (on && step !== m.last ? 1 : 0);
        if (port === "trigger") m.last = step;
        break;
      }
      case "random": {
        const rate = P.rate ?? 2; const slot = Math.floor(t * rate);
        if (slot !== m.slot) { m.slot = slot; m.val = P.min + Math.random() * (P.max - P.min); }
        v = m.val ?? P.min;
        break;
      }
      case "bang": v = m.bang ? 1 : 0; if (m.bang) m.bang = false; break;
      case "mixer": {
        const a = (inputVal(nodeId, "a") || 0) * P.ga, b = (inputVal(nodeId, "b") || 0) * P.gb;
        const c = (inputVal(nodeId, "c") || 0) * P.gc, d = (inputVal(nodeId, "d") || 0) * P.gd;
        v = Math.max(0, Math.min(1, a + b + c + d));
        break;
      }
      case "delay": { const i = inputVal(nodeId, "audio"); v = i != null ? i : 0; break; }
      case "visualizer": { const a = inputVal(nodeId, "audio"); v = (a != null ? a : (audio.energy || 0)) * (P.intensity ?? 1); break; }
      case "text": v = 1; break;
      case "fx": { const i = inputVal(nodeId, "frame"); v = i != null ? i : 0; break; }
      case "feedbackfx": { const i = inputVal(nodeId, "frame"); v = i != null ? i : 0; break; }
      case "composite": { const a = inputVal(nodeId, "a") || 0, b = inputVal(nodeId, "b") || 0; v = a * (1 - P.mix) + b * P.mix; break; }
      /* — Logic & Automation (TM Studio ports) — */
      case "constant": v = P.value ?? 0.5; break;
      case "clock": {
        const C = window.LuminaClock;
        const beats = C ? C.beat : t * 2.1333;
        const div = { "1/4": 0.25, "1/2": 0.5, "1": 1, "2": 2, "4": 4 }[P.div] || 1;
        const ph = ((beats / div) % 1 + 1) % 1;
        if (port === "phase") v = ph;
        else if (port === "bar") v = ((beats / 4) % 1 + 1) % 1;
        else { v = (m.ph != null && ph < m.ph) ? 1 : 0; m.ph = ph; }   // trig fires on wrap
        break;
      }
      case "math": {
        const a = inputVal(nodeId, "a") ?? 0;
        const b = inputVal(nodeId, "b") ?? (P.b ?? 0.5);
        switch (P.op) {
          case "sub": v = a - b; break;
          case "mul": v = a * b; break;
          case "min": v = Math.min(a, b); break;
          case "max": v = Math.max(a, b); break;
          case "avg": v = (a + b) / 2; break;
          case "pow": v = Math.pow(Math.max(0, a), 1 + b * 3); break;
          default: v = a + b;
        }
        v = Math.max(0, Math.min(1, v));
        break;
      }
      case "compare": {
        const a = inputVal(nodeId, "a") ?? 0;
        const b = inputVal(nodeId, "b") ?? (P.b ?? 0.5);
        v = P.op === ">" ? (a > b ? 1 : 0)
          : P.op === "<" ? (a < b ? 1 : 0)
          : (Math.abs(a - b) < 0.05 ? 1 : 0);
        break;
      }
      case "gate": {
        const c = inputVal(nodeId, "ctrl") ?? 0;
        v = c >= (P.thresh ?? 0.5) ? (inputVal(nodeId, "a") ?? 0) : (inputVal(nodeId, "b") ?? 0);
        break;
      }
      case "smooth": {
        const i = inputVal(nodeId, "in") ?? 0;
        const lag = P.amount ?? 0.8;
        m.v = (m.v == null) ? i : m.v + (i - m.v) * (1 - lag);
        v = m.v;
        break;
      }
      case "midiin": {
        const E = window.LuminaMidi && window.LuminaMidi.lastEvent;
        if (E && (P.filter === "any"
          || (P.filter === "cc" && E.kind === "knob")
          || (P.filter === "note" && E.kind === "trigger"))) {
          if (E.time !== m.t) { m.val = E.value; m.t = E.time; }
        }
        v = port === "gate" ? ((m.t && performance.now() - m.t < 150) ? 1 : 0) : (m.val ?? 0);
        break;
      }
      case "paramout": { v = inputVal(nodeId, "in") ?? 0; break; }
      case "wsin": {
        const W = window.LuminaWS;
        if (!W) { v = 0; break; }
        if (port === "gate") v = (performance.now() - W.lastT < 300) ? 1 : 0;
        else v = W.ch[(parseInt(port.slice(2), 10) || 1) - 1] || 0;
        break;
      }
      case "wsout": { v = 0; break; }
      case "httpin": {
        const ev2 = Math.max(0.2, P.every || 1) * 1000;
        if (!m.t || performance.now() - m.t > ev2) {
          m.t = performance.now();
          if (!m.busy && P.url) {
            m.busy = true;
            fetch(P.url).then(r => r.text()).then(txt => {
              const f = parseFloat(txt);
              if (!isNaN(f)) m.val = f > 1.0001 ? Math.min(1, f / 4095) : Math.max(0, Math.min(1, f));
              m.busy = false;
            }).catch(() => { m.busy = false; });
          }
        }
        v = m.val || 0;
        break;
      }
      case "camera": {
        const C = window.LuminaCam;
        v = C ? (port === "motion" ? C.motion : C.bright) : 0;
        break;
      }
      case "euclid": {
        const C = window.LuminaClock;
        const beats = C ? C.beat : t * 2.1333;
        const div = { "1/4": 0.25, "1/2": 0.5, "1": 1 }[P.div] || 0.25;
        const steps = Math.max(2, Math.round(P.steps));
        const fills = Math.max(1, Math.min(steps, Math.round(P.fills)));
        const rot = Math.round(P.rotate) % steps;
        const idx = Math.floor(beats / div) % steps;
        const on = Math.floor(((idx + rot) % steps) * fills / steps)
                !== Math.floor((((idx + rot) % steps) + 1) * fills / steps);
        if (port === "phase") v = (beats / div / steps) % 1;
        else { v = (on && idx !== m.last) ? 1 : 0; m.last = idx; }
        break;
      }
      case "counter": {
        const trig = inputVal(nodeId, "trig") ?? 0;
        if (trig > 0.6 && !m.gate) { m.gate = true; m.c = ((m.c || 0) + 1); }
        if (trig < 0.4) m.gate = false;
        const len = Math.max(2, Math.round(P.length));
        v = port === "cycle" ? ((m.c || 0) % len === 0 ? 1 : 0) : (((m.c || 0) % len) / (len - 1));
        break;
      }
      case "macroin": {
        const p = window.__paramsSnapshot ? window.__paramsSnapshot() : {};
        v = { m1: p.macro1, m2: p.macro2, m3: p.macro3, m4: p.macro4 }[port] ?? 0;
        break;
      }
      case "serialin": {
        const S = window.LuminaSerial;
        if (!S) { v = 0; break; }
        if (port === "gate") v = (performance.now() - S.lastT < 150) ? 1 : 0;
        else v = S.ch[(parseInt(port.slice(2), 10) || 1) - 1] || 0;
        break;
      }
      default: {
        // auto-generated FX nodes: frame passthrough (signal level)
        const kd = NODE_KINDS[n.kind];
        if (kd && kd.isFx) v = inputVal(nodeId, "frame") ?? 0;
        else v = 0;
      }
    }
    visiting[nodeId] = false;
    values[key] = v;
    return v;
  }
  nodes.forEach(n => (NODE_KINDS[n.kind].outputs || []).forEach(p => outVal(n.id, p)));
  return values;
}

/* Trace the graph from the Output node back to the driving Visualizer + FX,
   so the node program actually controls the live render. */
function deriveOutputChain(nodes, edges, values) {
  const out = nodes.find(n => n.kind === "output");
  if (!out) return null;
  const fxChain = [];
  let vizNode = null;
  const seen = new Set();
  let cur = out, guard = 0;
  while (cur && guard++ < 64) {
    const e = edges.find(ed => ed.to === cur.id && (ed.toPort === "frame" || ed.toPort === "a"));
    if (!e) break;
    const src = nodes.find(n => n.id === e.from);
    if (!src || seen.has(src.id)) break;
    seen.add(src.id);
    if (src.kind === "fx" && !src.bypass) {
      // amount can be modulated live by wiring a signal into the "amount" port
      let amt = src.params.amount;
      if (values) {
        const ae = edges.find(ed => ed.to === src.id && ed.toPort === "amount");
        const mv = ae ? values[ae.from + ":" + ae.fromPort] : null;
        if (mv != null) amt = mv;
      }
      fxChain.unshift({ ...nodeFxToProg(src.params.kind, amt), wet: src.params.wet ?? 1 });
    }
    else if (NODE_KINDS[src.kind] && NODE_KINDS[src.kind].isFx && !src.bypass) {
      // auto-generated FX node — every numeric param port can be modulated
      const def = NODE_KINDS[src.kind];
      const entry = { type: def.isFx, wet: src.params.wet ?? 1 };
      def.params.forEach(p => {
        if (p.id === "wet" || p.kind !== "slider") return;
        let pv = src.params[p.id];
        if (values) {
          const pe = edges.find(ed => ed.to === src.id && ed.toPort === p.id);
          if (pe) {
            const mv = values[pe.from + ":" + pe.fromPort];
            if (mv != null) pv = p.min + Math.max(0, Math.min(1, mv)) * (p.max - p.min);
          }
        }
        entry[p.id] = pv;
      });
      fxChain.unshift(entry);
    }
    else if (src.kind === "feedbackfx" && !src.bypass) fxChain.unshift({ type: "feedback", amount: src.params.decay, zoom: src.params.zoom, rotate: src.params.rotate, hueShift: src.params.hueShift });
    else if (src.kind === "visualizer") { vizNode = src; break; }
    cur = src;
  }
  return { vizNode, fxChain };
}

function makeNode(kind, x, y) {
  const def = NODE_KINDS[kind];
  const params = {};
  def.params.forEach(p => params[p.id] = p.default);
  return {
    id: "n" + (++_nodeId), kind, x, y, params,
  };
}

const NODES_STORE = "lumina_nodes_graph_v1";
function _loadGraph() {
  try {
    const d = JSON.parse(localStorage.getItem(NODES_STORE) || "null");
    if (d && Array.isArray(d.nodes) && d.nodes.length) {
      const nodes = d.nodes.filter(n => NODE_KINDS[n.kind]);
      const ids = new Set(nodes.map(n => n.id));
      const edges = (d.edges || []).filter(e => ids.has(e.from) && ids.has(e.to));
      _nodeId = Math.max(0, ...nodes.map(n => parseInt(String(n.id).slice(1), 10) || 0));
      if (nodes.length) return { nodes, edges };
    }
  } catch (e) {}
  return null;
}
function _defaultGraph() {
  const nodes = [
    makeNode("audio",      80, 80),
    makeNode("band",       320, 80),
    makeNode("visualizer", 580, 160),
    makeNode("fx",         860, 160),
    makeNode("output",     1120, 200),
  ];
  const edges = [
    { from: nodes[0].id, fromPort: "bass",  to: nodes[1].id, toPort: "in" },
    { from: nodes[1].id, fromPort: "out",   to: nodes[2].id, toPort: "audio" },
    { from: nodes[2].id, fromPort: "frame", to: nodes[3].id, toPort: "frame" },
    { from: nodes[3].id, fromPort: "frame", to: nodes[4].id, toPort: "frame" },
  ].map(e => ({ ...e, id: "e" + Math.random() }));
  return { nodes, edges };
}

/* ── WebSocket bridge (sensors, phones, external engines) ── */
window.LuminaWS = window.LuminaWS || {
  url: (() => { try { return localStorage.getItem("lumina_ws_url") || "ws://localhost:9090"; } catch (e) { return "ws://localhost:9090"; } })(),
  connected: false, ch: [0, 0, 0, 0, 0, 0, 0, 0], lastT: 0, lastLine: "",
  _out: [0, 0, 0, 0], _outDirty: false,
  listeners: new Set(),
  on(fn) { this.listeners.add(fn); return () => this.listeners.delete(fn); },
  emit() { this.listeners.forEach(fn => { try { fn(this); } catch (e) {} }); },
  connect(url) {
    if (url) { this.url = url; try { localStorage.setItem("lumina_ws_url", url); } catch (e) {} }
    this.disconnect();
    try {
      const s = this._sock = new WebSocket(this.url);
      s.onopen = () => { this.connected = true; this.emit(); };
      s.onclose = () => { this.connected = false; this.emit(); };
      s.onerror = () => { this.connected = false; this.emit(); };
      s.onmessage = (ev) => {
        const txt = String(ev.data);
        this.lastLine = txt.slice(0, 80); this.lastT = performance.now();
        const m = txt.match(/^V:(.+)$/i);
        if (m) {
          m[1].split(",").forEach((x, i) => {
            if (i > 7) return;
            let v = parseFloat(x);
            if (!isNaN(v)) { if (v > 1.0001) v /= 4095; this.ch[i] = Math.max(0, Math.min(1, v)); }
          });
        } else {
          try {
            const j = JSON.parse(txt);
            const arr = Array.isArray(j) ? j : (j && Array.isArray(j.ch)) ? j.ch : (typeof j === "number" ? [j] : null);
            if (arr) arr.forEach((v, i) => { if (i < 8 && typeof v === "number") this.ch[i] = Math.max(0, Math.min(1, v > 1.0001 ? v / 4095 : v)); });
          } catch (e) {}
        }
        this.emit();
      };
      this._txTimer = setInterval(() => {
        if (this.connected && this._outDirty && this._sock && this._sock.readyState === 1) {
          this._outDirty = false;
          try { this._sock.send("V:" + this._out.map(v => v.toFixed(3)).join(",")); } catch (e) {}
        }
      }, 50);
    } catch (e) { console.warn("WS:", e); }
  },
  disconnect() {
    if (this._txTimer) { clearInterval(this._txTimer); this._txTimer = null; }
    try { this._sock && this._sock.close(); } catch (e) {}
    this._sock = null; this.connected = false; this.emit();
  },
  setOut(i, v) {
    v = Math.max(0, Math.min(1, v));
    if (Math.abs(this._out[i] - v) > 0.002) { this._out[i] = v; this._outDirty = true; }
  },
};

/* ── Webcam motion/brightness analysis (interactive installations) ── */
window.LuminaCam = window.LuminaCam || {
  active: false, motion: 0, bright: 0,
  listeners: new Set(),
  on(fn) { this.listeners.add(fn); return () => this.listeners.delete(fn); },
  emit() { this.listeners.forEach(fn => { try { fn(this); } catch (e) {} }); },
  async start() {
    if (this.active) return true;
    try {
      this._stream = await navigator.mediaDevices.getUserMedia({ video: { width: 160, height: 120 }, audio: false });
      const v = this._video = document.createElement("video");
      v.srcObject = this._stream; v.muted = true; v.playsInline = true;
      await v.play();
      const c = this._cnv = document.createElement("canvas");
      c.width = 32; c.height = 24;
      const x = c.getContext("2d", { willReadFrequently: true });
      this._prev = null;
      this._timer = setInterval(() => {
        try {
          x.drawImage(v, 0, 0, 32, 24);
          const d = x.getImageData(0, 0, 32, 24).data;
          let sum = 0, diff = 0;
          const cur = new Float32Array(32 * 24);
          for (let i = 0; i < 32 * 24; i++) {
            const l = (d[i * 4] + d[i * 4 + 1] + d[i * 4 + 2]) / 765;
            cur[i] = l; sum += l;
            if (this._prev) diff += Math.abs(l - this._prev[i]);
          }
          this.bright = sum / (32 * 24);
          const mRaw = this._prev ? (diff / (32 * 24)) * 12 : 0;
          this.motion = this.motion * 0.6 + Math.min(1, mRaw) * 0.4;   // smoothed
          this._prev = cur;
        } catch (e) {}
      }, 80);
      this.active = true; this.emit();
      return true;
    } catch (e) { console.warn("Camera:", e); return false; }
  },
  stop() {
    if (this._timer) clearInterval(this._timer);
    try { this._stream && this._stream.getTracks().forEach(t => t.stop()); } catch (e) {}
    this._timer = null; this._stream = null;
    this.active = false; this.motion = 0; this.bright = 0; this.emit();
  },
};

/* ── READY-TO-PLAY PRESETS (TM Studio-style) ──────────────────────────
   Each returns a fresh {nodes, edges}. All include the backbone
   audio → band → visualizer → fx → output so the graph drives the show. */
function _mk(kind, x, y, params) {
  const n = makeNode(kind, x, y);
  if (params) Object.assign(n.params, params);
  return n;
}
function _backbone(fxKind, fxAmount) {
  const a = _mk("audio", 60, 60);
  const b = _mk("band", 320, 60, { band: "bass", gain: 1, smooth: 0.5 });
  const v = _mk("visualizer", 580, 120);
  const f = _mk("fx", 850, 120, { kind: fxKind || "bloom", amount: fxAmount == null ? 0.5 : fxAmount, wet: 1 });
  const o = _mk("output", 1120, 170);
  const E = (from, fp, to, tp) => ({ id: "e" + Math.random(), from: from.id, fromPort: fp, to: to.id, toPort: tp });
  return {
    n: { a, b, v, f, o },
    nodes: [a, b, v, f, o],
    edges: [E(a, "bass", b, "in"), E(b, "out", v, "audio"), E(v, "frame", f, "frame"), E(f, "frame", o, "frame")],
    E,
  };
}
const NODE_PRESETS = [
  { name: "💥 Bass Pump", desc: "Il basso pompa luminosità e drive", make() {
    const g = _backbone("bloom", 0.45);
    const b2 = _mk("band", 320, 280, { band: "energy", gain: 1.3, smooth: 0.6 });
    const po = _mk("paramout", 580, 300, { target: "intensity", mode: "set", amount: 1 });
    g.nodes.push(b2, po);
    g.edges.push(g.E(g.n.a, "energy", b2, "in"), g.E(b2, "out", po, "in"));
    return g;
  } },
  { name: "⏱ Beat Machine", desc: "Euclid sul clock: glow pulsa a ritmo, glitch sul phase", make() {
    const g = _backbone("glitch", 0.3);
    const c = _mk("clock", 60, 300, { div: "1/4" });
    const eu = _mk("euclid", 300, 300, { steps: 8, fills: 3, rotate: 0, div: "1/4" });
    const env = _mk("envelope", 540, 300, { attack: 0.01, decay: 0.2, sustain: 0.4, release: 0.5 });
    const po = _mk("paramout", 780, 320, { target: "glow", mode: "add", amount: 0.8 });
    g.nodes.push(c, eu, env, po);
    g.edges.push(g.E(eu, "trig", env, "trigger"), g.E(env, "env", po, "in"),
      g.E(c, "phase", g.n.f, "amount"));
    return g;
  } },
  { name: "✨ Auto Drift", desc: "Senza mani: hue in deriva lenta, density che respira", make() {
    const g = _backbone("lightrays", 0.35);
    const l = _mk("lfo", 60, 300, { wave: "sin", rate: 0.07, amp: 1 });
    const po1 = _mk("paramout", 320, 300, { target: "hue", mode: "set", amount: 1 });
    const nz = _mk("noise", 60, 440, { type: "perlin", scale: 2, speed: 0.25 });
    const sm = _mk("smooth", 320, 440, { amount: 0.9 });
    const po2 = _mk("paramout", 580, 440, { target: "density", mode: "add", amount: 0.5 });
    g.nodes.push(l, po1, nz, sm, po2);
    g.edges.push(g.E(l, "out", po1, "in"), g.E(nz, "out", sm, "in"), g.E(sm, "out", po2, "in"));
    return g;
  } },
  { name: "🎛 ESP · XY Pad", desc: "Pad rotondo pag.1 — X=hue, Y=intensity, tocco=glow", make() {
    const g = _backbone("bloom", 0.4);
    const s = _mk("serialin", 60, 300);
    const p1 = _mk("paramout", 380, 280, { target: "hue", mode: "set", amount: 1 });
    const p2 = _mk("paramout", 380, 420, { target: "intensity", mode: "set", amount: 1 });
    const p3 = _mk("paramout", 640, 420, { target: "glow", mode: "add", amount: 0.6 });
    g.nodes.push(s, p1, p2, p3);
    g.edges.push(g.E(s, "ch1", p1, "in"), g.E(s, "ch2", p2, "in"), g.E(s, "ch3", p3, "in"));
    return g;
  } },
  { name: "🌀 ESP · Ring Knob", desc: "Pad rotondo pag.2 — il ring gira l'FX, il centro spara macro1", make() {
    const g = _backbone("kaleidopost", 0.3);
    const s = _mk("serialin", 60, 300);
    const sm = _mk("smooth", 380, 300, { amount: 0.7 });
    const p1 = _mk("paramout", 640, 380, { target: "macro1", mode: "set", amount: 1 });
    g.nodes.push(s, sm, p1);
    g.edges.push(g.E(s, "ch4", sm, "in"), g.E(sm, "out", g.n.f, "amount"), g.E(s, "ch5", p1, "in"));
    return g;
  } },
  { name: "🎚 ESP · Mixer 4 Fader", desc: "TZT 2.4\" — fader su intensity/motion/glow/macro1", make() {
    const g = _backbone("bloom", 0.35);
    const s = _mk("serialin", 60, 320);
    const targets = ["intensity", "motion", "glow", "macro1"];
    targets.forEach((t, i) => {
      const p = _mk("paramout", 400 + (i % 2) * 260, 280 + Math.floor(i / 2) * 140,
        { target: t, mode: "set", amount: 1 });
      g.nodes.push(p);
      g.edges.push(g.E(s, "ch" + (i + 1), p, "in"));
    });
    g.nodes.push(s);
    return g;
  } },
];

/* backbone with a CHAIN of auto-generated FX nodes in series */
function _backboneChain(fxDefs) {
  const a = _mk("audio", 60, 60);
  const b = _mk("band", 300, 60, { band: "bass", gain: 1, smooth: 0.5 });
  const v = _mk("visualizer", 540, 120);
  const o = _mk("output", 820 + fxDefs.length * 240, 170);
  const E = (from, fp, to, tp) => ({ id: "e" + Math.random(), from: from.id, fromPort: fp, to: to.id, toPort: tp });
  const nodes = [a, b, v, o];
  const edges = [E(a, "bass", b, "in"), E(b, "out", v, "audio")];
  let prev = v, x = 800;
  const fxNodes = [];
  fxDefs.forEach(fd => {
    const f = _mk(fd.k, x, 120, fd.p);
    nodes.push(f); fxNodes.push(f);
    edges.push(E(prev, "frame", f, "frame"));
    prev = f; x += 240;
  });
  edges.push(E(prev, "frame", o, "frame"));
  return { n: { a, b, v, o }, fx: fxNodes, nodes, edges, E };
}
NODE_PRESETS.push(
  { name: "📷 Camera Paint", desc: "Installazione: il movimento del pubblico dipinge (webcam)", make() {
    const g = _backboneChain([{ k: "fx_lighttrails", p: { amount: 0.6, angle: 90 } }]);
    const c = _mk("camera", 60, 320);
    const sm = _mk("smooth", 320, 320, { amount: 0.75 });
    const p1 = _mk("paramout", 560, 320, { target: "intensity", mode: "set", amount: 1 });
    const p2 = _mk("paramout", 560, 460, { target: "glow", mode: "add", amount: 0.7 });
    g.nodes.push(c, sm, p1, p2);
    g.edges.push(g.E(c, "motion", sm, "in"), g.E(sm, "out", p1, "in"), g.E(c, "bright", p2, "in"));
    return g;
  } },
  { name: "🌐 WebSocket Bridge", desc: "Sensori/telefoni via WS: ch1=hue, ch2=intensity, gate=strobe", make() {
    const g = _backboneChain([{ k: "fx_strobeflash", p: { amount: 0 } }]);
    const w = _mk("wsin", 60, 320);
    const p1 = _mk("paramout", 360, 300, { target: "hue", mode: "set", amount: 1 });
    const p2 = _mk("paramout", 360, 440, { target: "intensity", mode: "set", amount: 1 });
    g.nodes.push(w, p1, p2);
    g.edges.push(g.E(w, "ch1", p1, "in"), g.E(w, "ch2", p2, "in"),
      g.E(w, "gate", g.fx[0], "amount"));
    return g;
  } },
  { name: "🏛 Building Wash", desc: "Lavaggio lento per facciate: aurora + nebbia, hue in deriva", make() {
    const g = _backboneChain([{ k: "fx_aurora", p: { amount: 0.5 } }, { k: "fx_mist", p: { amount: 0.35 } }]);
    const l = _mk("lfo", 60, 320, { wave: "sin", rate: 0.04, amp: 1 });
    const p1 = _mk("paramout", 320, 320, { target: "hue", mode: "set", amount: 1 });
    g.nodes.push(l, p1);
    g.edges.push(g.E(l, "out", p1, "in"));
    return g;
  } },
  { name: "💧 Water Temple", desc: "Caustiche + increspature sul beat — perfetto su pietra", make() {
    const g = _backboneChain([{ k: "fx_caustics", p: { amount: 0.55 } }, { k: "fx_ripples", p: { amount: 0.6 } }]);
    const b2 = _mk("band", 300, 320, { band: "bass", gain: 1.4, smooth: 0.3 });
    g.nodes.push(b2);
    g.edges.push(g.E(g.n.a, "bass", b2, "in"), g.E(b2, "out", g.fx[1], "amount"));
    return g;
  } },
  { name: "⚡ Glitch Drop", desc: "Gli alti sparano glitch, posterize-time sul drop (macro1)", make() {
    const g = _backboneChain([{ k: "fx_glitch", p: { amount: 0.1 } }, { k: "fx_postertime", p: { amount: 0 } }]);
    const b2 = _mk("band", 300, 320, { band: "high", gain: 1.6, smooth: 0.4 });
    const mi = _mk("macroin", 60, 460);
    g.nodes.push(b2, mi);
    g.edges.push(g.E(g.n.a, "high", b2, "in"), g.E(b2, "out", g.fx[0], "amount"),
      g.E(mi, "m1", g.fx[1], "amount"));
    return g;
  } },
  { name: "🌀 Tunnel Feedback", desc: "Kaleido + feedback zoom, rotazione sul clock — ipnotico", make() {
    const g = _backboneChain([{ k: "fx_kaleidopost", p: { segments: 6 } }, { k: "fx_feedback", p: { zoom: 0.03, rotate: 1.2, decay: 0.93 } }]);
    const c = _mk("clock", 60, 320, { div: "4" });
    g.nodes.push(c);
    g.edges.push(g.E(c, "phase", g.fx[1], "rotate"));
    return g;
  } },
);

const USER_PRESETS_STORE = "lumina_node_presets_v1";
function loadUserPresets() {
  try { return JSON.parse(localStorage.getItem(USER_PRESETS_STORE) || "[]"); } catch (e) { return []; }
}

/* Mini monitor of the real composited output — visible from inside the
   Nodes workspace even when the Stage isn't mounted. */
function OutputMini() {
  const ref = useRefN(null);
  useEffectN(() => {
    let raf, last = 0;
    const draw = (t) => {
      const c = ref.current;
      if (c && t - last > 33) {
        last = t;
        c.width = 232; c.height = 130;
        const x = c.getContext("2d");
        if (window.LuminaComposite) {
          try { window.LuminaComposite(x, c.width, c.height, t / 1000); }
          catch (e) { x.fillStyle = "#000"; x.fillRect(0, 0, c.width, c.height); }
        }
      }
      raf = requestAnimationFrame(draw);
    };
    raf = requestAnimationFrame(draw);
    return () => cancelAnimationFrame(raf);
  }, []);
  return (
    <canvas ref={ref} style={{
      width: "100%", aspectRatio: "16/9", borderRadius: 6, display: "block",
      background: "#000", boxShadow: "var(--nshadow-in-sm)",
    }} />
  );
}

function NodesWorkspace(props = {}) {
  const isPopout = !!props.popout;
  const [graph0] = useStateN(() => _loadGraph() || _defaultGraph());
  const [nodes, setNodes] = useStateN(graph0.nodes);
  const [edges, setEdges] = useStateN(graph0.edges);

  // persist graph (survives workspace switches + travels with projects)
  useEffectN(() => {
    try { localStorage.setItem(NODES_STORE, JSON.stringify({ nodes, edges })); } catch (e) {}
    window.LuminaNodes = { nodes, edges };
  }, [nodes, edges]);

  const [selectedId, setSelectedId] = useStateN(null);
  const [pan, setPan] = useStateN({ x: 0, y: 0 });
  const [zoom, setZoom] = useStateN(1);
  const [drag, setDrag] = useStateN(null);
  const [wireDraft, setWireDraft] = useStateN(null);
  const [mouse, setMouse] = useStateN({ x: 0, y: 0 });
  const stageRef = useRefN(null);
  const valsRef = useRefN({});       // live computed port values
  const memRef = useRefN({});        // per-node memory (smoothing / S&H / seq)
  const [tick, setTick] = useStateN(0);      // throttled redraw pulse
  const [driveOut, setDriveOut] = useStateN(() => {
    try { return localStorage.getItem("lumina_nodes_drive") !== "0"; } catch (e) { return true; }
  });
  const [addOpen, setAddOpen] = useStateN(false);
  const [addQuery, setAddQuery] = useStateN("");
  const [presetsOpen, setPresetsOpen] = useStateN(false);
  const [userPresets, setUserPresets] = useStateN(loadUserPresets);
  useEffectN(() => {
    try { localStorage.setItem(USER_PRESETS_STORE, JSON.stringify(userPresets)); } catch (e) {}
  }, [userPresets]);
  const nodesRef = useRefN(nodes), edgesRef = useRefN(edges), driveRef = useRefN(driveOut);
  nodesRef.current = nodes; edgesRef.current = edges; driveRef.current = driveOut;

  // ── LIVE GRAPH ENGINE — evaluates every frame & drives the real output ──
  useEffectN(() => {
    let raf, timer, last = 0, lastViz = null;
    const tick = () => {
      try {
      // if this graph also lives in a popout window, let THAT instance drive
      if (!isPopout && window.LuminaPopout && window.LuminaPopout.isOpen("nodes")) {
        return;
      }
      const A = window.AudioEngine;
      const audio = A ? {
        bass: A.bass || 0, mid: A.mid || 0, high: A.high || 0,
        energy: A.energy || 0, beat: A.beat || 0,
      } : { bass: 0, mid: 0, high: 0, energy: 0, beat: 0 };
      const t = performance.now() / 1000;
      valsRef.current = evalGraph(nodesRef.current, edgesRef.current, audio, t, memRef.current);
      window.__nodeEngineBeat = (window.__nodeEngineBeat || 0) + 1;
      // drive the real output: set live visualizer + FX chain from the graph
      if (driveRef.current) {
        const chain = deriveOutputChain(nodesRef.current, edgesRef.current, valsRef.current);
        if (chain && chain.vizNode) {
          const vid = chain.vizNode.params.kind;
          if (vid && vid !== lastViz && window.__setVizId) { lastViz = vid; window.__setVizId(vid); }
          if (window.Prog) {
            // re-apply every frame — other subsystems may clear Prog.fxChain
            window.Prog.fxChain = chain.fxChain.map((f, i) => ({
              id: "nodefx" + i, enabled: true, ...f,
            }));
          }
        }
      }
      // "→ Param Out" nodes modulate the live params (read by Prog.computeParams)
      {
        const mods = [];
        if (driveRef.current) {
          nodesRef.current.forEach(n => {
            if (n.kind !== "paramout" || n.bypass) return;
            const e = edgesRef.current.find(ed => ed.to === n.id && ed.toPort === "in");
            if (!e) return;
            const val = valsRef.current[e.from + ":" + e.fromPort];
            if (val == null) return;
            mods.push({
              target: n.params.target, mode: n.params.mode,
              value: Math.max(0, Math.min(1, val)) * (n.params.amount ?? 1),
            });
          });
        }
        window.__nodeParamMods = mods;
        // WebSocket Out nodes push their wired inputs to the bridge
        if (driveRef.current && window.LuminaWS) {
          nodesRef.current.forEach(n => {
            if (n.kind !== "wsout" || n.bypass) return;
            ["v1", "v2", "v3", "v4"].forEach((p, i) => {
              const e = edgesRef.current.find(ed => ed.to === n.id && ed.toPort === p);
              if (!e) return;
              const val = valsRef.current[e.from + ":" + e.fromPort];
              if (val != null) window.LuminaWS.setOut(i, val);
            });
          });
        }
      }
      // throttle React redraw of value badges to ~20fps
      if (t - last > 0.05) { last = t; setTick(x => (x + 1) % 1000000); }
      } catch (err) { if (!window.__nodeEngineErr) { window.__nodeEngineErr = String(err); console.warn("Node engine:", err); } }
    };
    const loop = () => { tick(); raf = requestAnimationFrame(loop); };
    raf = requestAnimationFrame(loop);
    // hidden-tab failsafe: Chrome pauses rAF in background tabs — during a
    // show the graph must NEVER stop, so we keep ticking on a timer
    timer = setInterval(() => { if (document.hidden) tick(); }, 50);
    return () => { cancelAnimationFrame(raf); clearInterval(timer); window.__nodeParamMods = []; };
  }, []);
  useEffectN(() => { try { localStorage.setItem("lumina_nodes_drive", driveOut ? "1" : "0"); } catch (e) {} }, [driveOut]);
  const portValue = (nodeId, port) => valsRef.current[nodeId + ":" + port];

  const updNode = (id, patch) => setNodes(arr => arr.map(n => n.id === id ? { ...n, ...patch } : n));

  /* ── undo (Ctrl+Z) — snapshots on structural changes ── */
  const undoRef = useRefN([]);
  const pushUndo = () => {
    try {
      undoRef.current.push(JSON.stringify({ nodes: nodesRef.current, edges: edgesRef.current }));
      if (undoRef.current.length > 30) undoRef.current.shift();
    } catch (e) {}
  };
  const undo = () => {
    const s = undoRef.current.pop();
    if (!s) return;
    try { const g = JSON.parse(s); setNodes(g.nodes); setEdges(g.edges); } catch (e) {}
  };

  const removeNode = (id) => {
    pushUndo();
    setNodes(arr => arr.filter(n => n.id !== id));
    setEdges(arr => arr.filter(e => e.from !== id && e.to !== id));
  };
  const disconnectNode = (id) => {
    pushUndo();
    setEdges(arr => arr.filter(e => e.from !== id && e.to !== id));
  };
  const addNode = (kind) => {
    pushUndo();
    const rect = stageRef.current?.getBoundingClientRect();
    const cx = rect ? (rect.width / 2 - pan.x) / zoom : 400;
    const cy = rect ? (rect.height / 2 - pan.y) / zoom : 200;
    setNodes(arr => [...arr, makeNode(kind, cx - 80, cy - 40)]);
  };

  /* ── preset actions ── */
  const applyGraph = (g) => {
    pushUndo();
    const nodes2 = g.nodes.map(n => ({ ...n, params: { ...n.params } }));
    _nodeId = Math.max(_nodeId, ...nodes2.map(n => parseInt(String(n.id).slice(1), 10) || 0));
    setNodes(nodes2);
    setEdges(g.edges.map(e => ({ ...e })));
    setSelectedId(null);
    setPan({ x: 0, y: 0 }); setZoom(1);
  };
  const loadFactoryPreset = (p) => { applyGraph(p.make()); setPresetsOpen(false); };
  const saveUserPreset = () => {
    const name = window.prompt("Nome del preset:", "Mio preset " + (userPresets.length + 1));
    if (!name) return;
    setUserPresets(arr => [...arr, {
      id: Date.now(), name,
      created: new Date().toISOString(),
      graph: { nodes: JSON.parse(JSON.stringify(nodesRef.current)), edges: JSON.parse(JSON.stringify(edgesRef.current)) },
    }]);
  };
  const loadUserPreset = (p) => { applyGraph(p.graph); setPresetsOpen(false); };
  const dupUserPreset = (p) => {
    setUserPresets(arr => [...arr, { ...JSON.parse(JSON.stringify(p)), id: Date.now(), name: p.name + " copia" }]);
  };
  const delUserPreset = (p) => {
    if (!window.confirm(`Eliminare il preset "${p.name}"?`)) return;
    setUserPresets(arr => arr.filter(x => x.id !== p.id));
  };

  /* ── keyboard: Del = remove · Ctrl+D = duplicate · Ctrl+Z = undo ── */
  const selRef = useRefN(null);
  selRef.current = selectedId;
  useEffectN(() => {
    const doc = stageRef.current ? stageRef.current.ownerDocument : document;
    const onKey = (e) => {
      const tag = e.target && e.target.tagName;
      if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
      if ((e.key === "Delete" || e.key === "Backspace") && selRef.current) {
        e.preventDefault(); removeNode(selRef.current); setSelectedId(null);
      } else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "d" && selRef.current) {
        e.preventDefault(); duplicateNode(selRef.current);
      } else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z") {
        e.preventDefault(); undo();
      }
    };
    doc.addEventListener("keydown", onKey);
    return () => doc.removeEventListener("keydown", onKey);
  }, []);

  const onStageMouseDown = (e) => {
    if (e.target === stageRef.current || e.target.tagName === "svg") {
      setSelectedId(null);
      // pan
      const start = { x: e.clientX, y: e.clientY };
      const startPan = { ...pan };
      const win = (e.nativeEvent && e.nativeEvent.view) || window;
      const onMove = (m) => {
        setPan({ x: startPan.x + (m.clientX - start.x), y: startPan.y + (m.clientY - start.y) });
      };
      const onUp = () => {
        win.removeEventListener("mousemove", onMove);
        win.removeEventListener("mouseup", onUp);
      };
      win.addEventListener("mousemove", onMove);
      win.addEventListener("mouseup", onUp);
    }
  };

  const onMouseMove = (e) => {
    const rect = stageRef.current?.getBoundingClientRect();
    if (!rect) return;
    setMouse({ x: (e.clientX - rect.left - pan.x) / zoom, y: (e.clientY - rect.top - pan.y) / zoom });
  };

  const duplicateNode = (id) => {
    const n = nodesRef.current.find(nn => nn.id === id);
    if (!n) return;
    pushUndo();
    const copy = { ...n, id: "n" + (++_nodeId), x: n.x + 40, y: n.y + 40, params: { ...n.params } };
    setNodes(arr => [...arr, copy]);
    setSelectedId(copy.id);
  };

  const startDrag = (id, e) => {
    e.stopPropagation();
    const node = nodes.find(n => n.id === id);
    const start = { x: e.clientX, y: e.clientY };
    const sX = node.x, sY = node.y;
    setSelectedId(id);
    const win = (e.nativeEvent && e.nativeEvent.view) || window;
    const onMove = (m) => {
      updNode(id, { x: sX + (m.clientX - start.x) / zoom, y: sY + (m.clientY - start.y) / zoom });
    };
    const onUp = () => {
      win.removeEventListener("mousemove", onMove);
      win.removeEventListener("mouseup", onUp);
    };
    win.addEventListener("mousemove", onMove);
    win.addEventListener("mouseup", onUp);
  };

  const startWire = (nodeId, port, isOutput, e) => {
    e.stopPropagation();
    e.preventDefault();
    setWireDraft({ nodeId, port, isOutput });
    // popout-safe: listen on the window the event came from
    const win = (e.nativeEvent && e.nativeEvent.view) || window;
    const doc = win.document;
    const onUp = (m) => {
      // find target port under cursor — direct hit first…
      const els = doc.elementsFromPoint(m.clientX, m.clientY);
      let portEl = els.find(el => el.dataset && el.dataset.port);
      // …then snap to the nearest port dot within 28px (forgiving release)
      if (!portEl) {
        let best = null, bestD = 28;
        doc.querySelectorAll("[data-port]").forEach(el => {
          const r = el.getBoundingClientRect();
          const d = Math.hypot(m.clientX - (r.x + r.width / 2), m.clientY - (r.y + r.height / 2));
          if (d < bestD) { bestD = d; best = el; }
        });
        portEl = best;
      }
      if (portEl) {
        const tid = portEl.dataset.node, tport = portEl.dataset.port;
        const tIsOut = portEl.dataset.kind === "out";
        if (tid !== nodeId && isOutput !== tIsOut) {
          const fromId = isOutput ? nodeId : tid;
          const fromPort = isOutput ? port : tport;
          const toId = isOutput ? tid : nodeId;
          const toPort = isOutput ? tport : port;
          pushUndo();
          // remove existing edges to that input
          setEdges(arr => [...arr.filter(ed => !(ed.to === toId && ed.toPort === toPort)),
            { id: "e" + Math.random(), from: fromId, fromPort, to: toId, toPort }]);
        }
      }
      setWireDraft(null);
      win.removeEventListener("mouseup", onUp);
    };
    win.addEventListener("mouseup", onUp);
  };

  const removeEdge = (id) => setEdges(arr => arr.filter(e => e.id !== id));

  // compute port positions — anchors aligned to the real dot centers
  const portPos = (nodeId, port, isOutput) => {
    const n = nodes.find(nn => nn.id === nodeId);
    if (!n) return { x: 0, y: 0 };
    const def = NODE_KINDS[n.kind];
    const ports = isOutput ? def.outputs : def.inputs;
    const idx = ports.indexOf(port);
    const yOff = 56 + idx * 20;
    return {
      x: n.x + (isOutput ? 200 : 0),
      y: n.y + yOff,
    };
  };

  const selected = nodes.find(n => n.id === selectedId);

  return (
    <div className="panel stage-bezel" style={{ padding: 0, display: "flex", flexDirection: "column", overflow: "hidden" }}>
      <Screws />
      <div className="stage-toolbar">
        <div className="stage-tabs"><div className="stage-tab active">Nodes</div></div>
        <div style={{
          display: "flex", alignItems: "center", gap: 5, marginLeft: 8,
          padding: "2px 8px", borderRadius: 5,
          background: "linear-gradient(180deg,#0a0a0c,#14141a)", boxShadow: "var(--nshadow-in-sm)",
        }}>
          <span style={{
            width: 7, height: 7, borderRadius: "50%",
            background: driveOut ? "var(--led-green)" : "var(--ink-faint)",
            boxShadow: driveOut ? "0 0 8px var(--led-green)" : "none",
            animation: driveOut ? "pulse 1.2s infinite" : "none",
          }} />
          <span style={{ fontFamily: "var(--font-mono)", fontSize: 9, color: "var(--ink-dim)" }}>
            {driveOut ? "ENGINE LIVE" : "ENGINE IDLE"}
          </span>
        </div>
        <button className={`nbtn compact ${driveOut ? "glow pressed" : ""}`}
          onClick={() => setDriveOut(d => !d)}
          title="When on, the node graph drives the live output (visualizer + FX chain)">
          ▶ Drive Output
        </button>
        {!isPopout && window.LuminaPopout && (
          <button className="nbtn compact"
            title="Open the node editor in its own window — drag it to another screen"
            onClick={() => window.LuminaPopout.open("nodes")}
            style={{ color: "var(--led-cyan)" }}>
            ⇱ Window
          </button>
        )}
        <div style={{ flex: 1 }} />
        <div style={{ position: "relative" }}>
          <button className={`nbtn compact ${presetsOpen ? "glow pressed" : ""}`}
            onClick={() => setPresetsOpen(o => !o)} style={{ padding: "4px 10px", color: "var(--led-amber)" }}>
            ★ Presets ▾
          </button>
          {presetsOpen && (
            <div data-lum-close="1" onClick={() => setPresetsOpen(false)}
              style={{ position: "fixed", inset: 0, zIndex: 40 }} />
          )}
          {presetsOpen && (
            <div style={{
              position: "absolute", top: "calc(100% + 6px)", right: 0, zIndex: 41,
              width: 300, maxHeight: 460, overflowY: "auto",
              background: "linear-gradient(180deg, var(--panel-hi), var(--panel))",
              borderRadius: 10, padding: 8,
              boxShadow: "var(--nshadow-out), 0 16px 40px rgba(0,0,0,0.6)",
              border: "1px solid rgba(255,255,255,0.06)",
            }}>
              <div className="cap" style={{ fontSize: 8, padding: "2px 4px 4px", color: "var(--led-amber)" }}>
                Ready to play
              </div>
              {NODE_PRESETS.map(p => (
                <button key={p.name} className="nbtn compact"
                  onClick={() => loadFactoryPreset(p)}
                  title={p.desc}
                  style={{ display: "block", width: "100%", textAlign: "left",
                    padding: "6px 8px", fontSize: 10, marginBottom: 3,
                    borderLeft: "2px solid var(--led-amber)" }}>
                  <div style={{ fontWeight: 700 }}>{p.name}</div>
                  <div style={{ fontSize: 8.5, color: "var(--ink-faint)", textTransform: "none",
                    letterSpacing: 0, marginTop: 2 }}>{p.desc}</div>
                </button>
              ))}
              <div className="cap" style={{ fontSize: 8, padding: "8px 4px 4px", color: "var(--led-cyan)",
                display: "flex", justifyContent: "space-between", alignItems: "center" }}>
                <span>My presets ({userPresets.length})</span>
                <button className="nbtn compact glow pressed" style={{ padding: "3px 8px", fontSize: 9 }}
                  onClick={saveUserPreset} title="Salva il grafo attuale come preset personale">
                  💾 Save current
                </button>
              </div>
              {userPresets.length === 0 && (
                <div style={{ fontSize: 9, color: "var(--ink-faint)", padding: 6, textAlign: "center" }}>
                  Nessun preset salvato — costruisci un grafo e premi Save.
                </div>
              )}
              {userPresets.map(p => (
                <div key={p.id} style={{
                  display: "grid", gridTemplateColumns: "1fr auto auto auto", gap: 4, alignItems: "center",
                  padding: "4px 6px", borderRadius: 5, marginBottom: 3,
                  background: "linear-gradient(180deg, var(--panel-hi), var(--panel))",
                  boxShadow: "var(--nshadow-out-sm)",
                }}>
                  <span style={{ fontFamily: "var(--font-mono)", fontSize: 10, color: "var(--led-cyan)",
                    whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}
                    title={new Date(p.created).toLocaleString()}>
                    ★ {p.name}
                  </span>
                  <button className="nbtn compact" style={{ padding: "2px 6px", fontSize: 8 }}
                    onClick={() => loadUserPreset(p)} title="Carica">↺</button>
                  <button className="nbtn compact" style={{ padding: "2px 6px", fontSize: 8 }}
                    onClick={() => dupUserPreset(p)} title="Duplica">⧉</button>
                  <button className="nbtn compact danger" style={{ padding: "2px 6px", fontSize: 8 }}
                    onClick={() => delUserPreset(p)} title="Elimina">×</button>
                </div>
              ))}
            </div>
          )}
        </div>
        <div style={{ position: "relative" }}>
          <button className={`nbtn compact ${addOpen ? "glow pressed" : ""}`}
            onClick={() => setAddOpen(o => !o)} style={{ padding: "4px 10px" }}>
            ＋ Add Node ▾
          </button>
          {addOpen && (
            <div data-lum-close="1" onClick={() => setAddOpen(false)}
              style={{ position: "fixed", inset: 0, zIndex: 40 }} />
          )}
          {addOpen && (
            <div style={{
              position: "absolute", top: "calc(100% + 6px)", right: 0, zIndex: 41,
              width: 260, maxHeight: 420, overflowY: "auto",
              background: "linear-gradient(180deg, var(--panel-hi), var(--panel))",
              borderRadius: 10, padding: 8,
              boxShadow: "var(--nshadow-out), 0 16px 40px rgba(0,0,0,0.6)",
              border: "1px solid rgba(255,255,255,0.06)",
            }}>
              <input autoFocus value={addQuery} onChange={e => setAddQuery(e.target.value)}
                placeholder="Cerca tra i nodi… (es. glitch, mask, clock)"
                style={{
                  width: "100%", marginBottom: 8, padding: "6px 8px", borderRadius: 6,
                  border: "none", outline: "none", boxSizing: "border-box",
                  background: "linear-gradient(180deg,#0a0a0c,#14141a)",
                  color: "var(--accent)", fontFamily: "var(--font-mono)", fontSize: 10,
                  boxShadow: "var(--nshadow-in-sm)",
                }} />
              {NODE_CATEGORIES.map(cat => {
                const q = addQuery.trim().toLowerCase();
                const kinds = q
                  ? cat.kinds.filter(k => (NODE_KINDS[k].label + " " + k).toLowerCase().includes(q))
                  : cat.kinds;
                if (!kinds.length) return null;
                return (
                <div key={cat.name} style={{ marginBottom: 8 }}>
                  <div className="cap" style={{ fontSize: 8, padding: "2px 4px 4px", color: "var(--ink-faint)" }}>{cat.name}</div>
                  <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 4 }}>
                    {kinds.map(k => (
                      <button key={k} className="nbtn compact"
                        onClick={() => { addNode(k); setAddOpen(false); setAddQuery(""); }}
                        style={{ borderLeft: `2px solid ${NODE_KINDS[k].color}`, padding: "5px 6px", fontSize: 9,
                          justifyContent: "flex-start", textAlign: "left" }}>
                        {NODE_KINDS[k].label}
                      </button>
                    ))}
                  </div>
                </div>
                );
              })}
            </div>
          )}
        </div>
      </div>

      <div style={{ flex: 1, display: "grid", gridTemplateColumns: "1fr 260px", minHeight: 0 }}>
        {/* Stage */}
        <div ref={stageRef}
          onMouseDown={onStageMouseDown}
          onMouseMove={onMouseMove}
          style={{
            position: "relative", overflow: "hidden",
            background:
              "linear-gradient(180deg, #0a0a0e 0%, #0e0e14 100%)",
            backgroundImage: `
              linear-gradient(rgba(255,255,255,0.025) 1px, transparent 1px),
              linear-gradient(90deg, rgba(255,255,255,0.025) 1px, transparent 1px),
              linear-gradient(rgba(255,255,255,0.05) 1px, transparent 1px),
              linear-gradient(90deg, rgba(255,255,255,0.05) 1px, transparent 1px),
              linear-gradient(180deg, #0a0a0e 0%, #0e0e14 100%)`,
            backgroundSize: `20px 20px, 20px 20px, 100px 100px, 100px 100px, 100% 100%`,
            cursor: drag ? "grabbing" : "default",
          }}>
          {/* Connections SVG */}
          <svg style={{ position: "absolute", inset: 0, width: "100%", height: "100%", pointerEvents: "none" }}>
            <g transform={`translate(${pan.x},${pan.y}) scale(${zoom})`}>
              {edges.map(e => {
                const a = portPos(e.from, e.fromPort, true);
                const b = portPos(e.to, e.toPort, false);
                const dx = Math.abs(b.x - a.x) * 0.5;
                const fromNode = nodes.find(n => n.id === e.from);
                const color = NODE_KINDS[fromNode?.kind]?.color || "#888";
                const d = `M ${a.x} ${a.y} C ${a.x + dx} ${a.y}, ${b.x - dx} ${b.y}, ${b.x} ${b.y}`;
                // live signal value flowing on this wire
                const sig = portValue(e.from, e.fromPort);
                const lvl = sig == null ? 0 : Math.max(0, Math.min(1, Math.abs(sig)));
                const phase = (performance.now() / 1000 * (0.4 + lvl * 2.4)) % 1;
                return (
                  <g key={e.id} style={{ pointerEvents: "stroke", cursor: "pointer" }}
                    onClick={() => removeEdge(e.id)}>
                    {/* base wire */}
                    <path d={d} stroke={color} strokeWidth={2} fill="none"
                      opacity={0.35 + lvl * 0.5}
                      style={{ filter: `drop-shadow(0 0 ${2 + lvl * 6}px ${color})` }} />
                    {/* flowing energy pulse */}
                    {driveOut && lvl > 0.01 && (
                      <path d={d} stroke="#fff" strokeWidth={1 + lvl * 2.5} fill="none"
                        strokeLinecap="round" opacity={0.5 + lvl * 0.5}
                        strokeDasharray={`${2 + lvl * 6} ${40 - lvl * 18}`}
                        strokeDashoffset={-phase * 60}
                        style={{ filter: `drop-shadow(0 0 4px #fff)` }} />
                    )}
                    {/* hit area */}
                    <path d={d} stroke="transparent" strokeWidth={14} fill="none" />
                  </g>
                );
              })}
              {wireDraft && (() => {
                const a = portPos(wireDraft.nodeId, wireDraft.port, wireDraft.isOutput);
                const b = { x: mouse.x, y: mouse.y };
                const dx = Math.abs(b.x - a.x) * 0.5;
                const from = wireDraft.isOutput ? a : b;
                const to = wireDraft.isOutput ? b : a;
                return (
                  <path d={`M ${from.x} ${from.y} C ${from.x + dx} ${from.y}, ${to.x - dx} ${to.y}, ${to.x} ${to.y}`}
                    stroke="var(--accent)" strokeWidth={2.5} fill="none" strokeDasharray="6 6" opacity={0.8} />
                );
              })()}
            </g>
          </svg>

          {/* Nodes */}
          <div style={{ position: "absolute", inset: 0, transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`, transformOrigin: "0 0", pointerEvents: "none" }}>
            {nodes.map(n => {
              const def = NODE_KINDS[n.kind];
              const w = 200;
              const hasPreview = n.kind === "visualizer" || n.kind === "output";
              const h = 56 + Math.max(def.inputs.length, def.outputs.length) * 20 + def.params.length * 26 + (hasPreview ? 70 : 0);
              return (
                <div key={n.id}
                  onMouseDown={(e) => startDrag(n.id, e)}
                  style={{
                    position: "absolute", left: n.x, top: n.y,
                    width: w, minHeight: h,
                    background: "linear-gradient(180deg, var(--panel-hi), var(--panel))",
                    borderRadius: 8,
                    borderLeft: `3px solid ${def.color}`,
                    boxShadow: selectedId === n.id
                      ? `0 0 0 2px ${def.color}, 0 10px 24px rgba(0,0,0,0.6)`
                      : "var(--nshadow-out-sm), 0 6px 14px rgba(0,0,0,0.4)",
                    pointerEvents: "auto",
                    cursor: "move",
                    userSelect: "none",
                    opacity: n.bypass ? 0.55 : 1,
                    filter: n.bypass ? "grayscale(0.6)" : "none",
                  }}>
                  {/* Header */}
                  <div style={{
                    padding: "6px 10px",
                    background: `linear-gradient(180deg, ${def.color}40, transparent)`,
                    borderRadius: "8px 8px 0 0",
                    display: "flex", alignItems: "center", justifyContent: "space-between",
                    borderBottom: "1px solid rgba(255,255,255,0.06)",
                  }}>
                    <span style={{
                      fontFamily: "var(--font-display)", fontWeight: 700, fontSize: 11,
                      letterSpacing: "0.1em", textTransform: "uppercase",
                      color: def.color, textShadow: `0 0 4px ${def.color}80`,
                      flex: 1, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
                    }}>{def.label}</span>
                    {[
                      ["⏻", "Bypass (signal passes through)", () => updNode(n.id, { bypass: !n.bypass }), n.bypass],
                      ["⧉", "Duplicate", () => duplicateNode(n.id), false],
                      ["✂", "Disconnect all wires", () => disconnectNode(n.id), false],
                    ].map(([g, tip, fn, act]) => (
                      <button key={tip} title={tip}
                        onMouseDown={(e) => { e.stopPropagation(); fn(); }}
                        style={{
                          background: act ? `${def.color}40` : "rgba(255,255,255,0.06)",
                          border: "none", color: act ? def.color : "#888",
                          borderRadius: 3, width: 17, height: 17, fontSize: 9,
                          cursor: "pointer", lineHeight: 1, padding: 0, marginRight: 2,
                        }}>{g}</button>
                    ))}
                    <button className="nbtn compact danger" style={{ padding: "1px 4px", fontSize: 8 }}
                      onMouseDown={(e) => { e.stopPropagation(); removeNode(n.id); }}>×</button>
                  </div>
                  {/* Ports */}
                  <div style={{ position: "relative", padding: "8px 0" }}>
                    {/* Inputs (left) */}
                    {def.inputs.map((p, i) => (
                      <div key={"in-"+p} style={{
                        position: "absolute", left: -7, top: 30 - 8 + i * 20,
                        display: "flex", alignItems: "center", gap: 4,
                      }}>
                        <div data-node={n.id} data-port={p} data-kind="in"
                          onMouseDown={(e) => startWire(n.id, p, false, e)}
                          style={{ width: 26, height: 26, margin: -6, display: "grid",
                            placeItems: "center", cursor: "crosshair" }}>
                          <div style={{
                            width: 14, height: 14, borderRadius: "50%",
                            background: def.color,
                            boxShadow: `0 0 6px ${def.color}`,
                            border: "2px solid var(--bg-1)",
                            pointerEvents: "none",
                          }} />
                        </div>
                        <span style={{ fontFamily: "var(--font-mono)", fontSize: 9, color: "var(--ink-dim)",
                          textTransform: "uppercase", letterSpacing: "0.08em",
                          background: "var(--panel)", padding: "0 4px", borderRadius: 2 }}>{p}</span>
                      </div>
                    ))}
                    {/* Outputs (right) */}
                    {def.outputs.map((p, i) => {
                      const sv = portValue(n.id, p);
                      const lvl = sv == null ? 0 : Math.max(0, Math.min(1, Math.abs(sv)));
                      return (
                      <div key={"out-"+p} style={{
                        position: "absolute", right: -7, top: 30 - 8 + i * 20,
                        display: "flex", alignItems: "center", gap: 4,
                        flexDirection: "row-reverse",
                      }}>
                        <div data-node={n.id} data-port={p} data-kind="out"
                          onMouseDown={(e) => startWire(n.id, p, true, e)}
                          style={{ width: 26, height: 26, margin: -6, display: "grid",
                            placeItems: "center", cursor: "crosshair" }}>
                          <div style={{
                            width: 14, height: 14, borderRadius: "50%",
                            background: def.color,
                            boxShadow: `0 0 ${4 + lvl * 8}px ${def.color}`,
                            border: "2px solid var(--bg-1)",
                            pointerEvents: "none",
                            transform: `scale(${1 + lvl * 0.35})`,
                          }} />
                        </div>
                        <span style={{ fontFamily: "var(--font-mono)", fontSize: 9, color: "var(--ink-dim)",
                          textTransform: "uppercase", letterSpacing: "0.08em",
                          background: "var(--panel)", padding: "0 4px", borderRadius: 2,
                          display: "flex", alignItems: "center", gap: 4 }}>
                          {p}
                          {sv != null && (
                            <b style={{ color: def.color, fontSize: 8,
                              textShadow: `0 0 4px ${def.color}` }}>{sv.toFixed(2)}</b>
                          )}
                        </span>
                      </div>
                    );})}
                    {/* Spacer for ports */}
                    <div style={{ height: Math.max(def.inputs.length, def.outputs.length) * 20 + 8 }} />
                    {/* Live preview for frame-producing nodes */}
                    {(n.kind === "visualizer" || n.kind === "output") && (
                      <div style={{ padding: "2px 10px 6px" }}>
                        <NodePreview node={n} nodes={nodes} edges={edges} />
                      </div>
                    )}
                    {/* Param values (compact view) */}
                    {def.params.map(p => (
                      <div key={p.id} style={{
                        padding: "4px 12px",
                        fontFamily: "var(--font-mono)", fontSize: 9,
                        color: "var(--ink-faint)", display: "flex", justifyContent: "space-between",
                      }}>
                        <span>{p.label}</span>
                        <span style={{ color: "var(--accent)" }}>
                          {typeof n.params[p.id] === "number" ? n.params[p.id].toFixed(2) : n.params[p.id]}
                        </span>
                      </div>
                    ))}
                  </div>
                </div>
              );
            })}
          </div>

          {/* Zoom controls */}
          <div style={{
            position: "absolute", bottom: 14, right: 14,
            display: "flex", gap: 4, alignItems: "center",
            background: "var(--panel)", borderRadius: 6, padding: 4,
            boxShadow: "var(--nshadow-out-sm)",
          }}>
            <button className="nbtn compact" onClick={() => setZoom(z => Math.max(0.3, z - 0.1))}>−</button>
            <span style={{ fontFamily: "var(--font-mono)", fontSize: 10,
              color: "var(--accent)", padding: "0 6px", minWidth: 40, textAlign: "center" }}>
              {(zoom * 100).toFixed(0)}%
            </span>
            <button className="nbtn compact" onClick={() => setZoom(z => Math.min(3, z + 0.1))}>+</button>
            <button className="nbtn compact" onClick={() => { setZoom(1); setPan({ x: 0, y: 0 }); }}>fit</button>
          </div>

          {/* Stats overlay */}
          <div style={{
            position: "absolute", top: 14, left: 14,
            fontFamily: "var(--font-mono)", fontSize: 10, color: "var(--ink-faint)",
            background: "rgba(0,0,0,0.5)", padding: "4px 8px", borderRadius: 4,
          }}>
            {nodes.length} nodes · {edges.length} connections
          </div>
        </div>

        {/* Inspector */}
        <div style={{
          padding: 12, background: "linear-gradient(180deg, #14141a, #0a0a0c)",
          borderLeft: "1px solid rgba(255,255,255,0.04)",
          display: "flex", flexDirection: "column", gap: 8, overflowY: "auto",
        }}>
          {/* always-visible live output — what the projectors see RIGHT NOW */}
          <div className="dock-section" style={{ padding: 8, borderLeft: "3px solid var(--led-red)" }}>
            <div className="dock-label" style={{ display: "flex", alignItems: "center", gap: 6 }}>
              <span style={{
                width: 7, height: 7, borderRadius: "50%",
                background: driveOut ? "var(--led-red)" : "var(--ink-faint)",
                boxShadow: driveOut ? "0 0 6px var(--led-red)" : "none",
                animation: driveOut ? "pulse 1.2s infinite" : "none",
              }} />
              LIVE OUTPUT
            </div>
            <div style={{ marginTop: 6 }}><OutputMini /></div>
            <div style={{ fontSize: 8.5, color: "var(--ink-faint)", marginTop: 4, lineHeight: 1.5 }}>
              {driveOut
                ? "Il grafo sta pilotando questa uscita (Drive Output ON)."
                : "Drive Output è OFF — il grafo non pilota l'uscita."}
              {" "}È la stessa immagine di Stage, proiettori e Live Output.
            </div>
          </div>
          {!selected ? (
            <div style={{ padding: 16, textAlign: "center", color: "var(--ink-faint)", fontSize: 11, lineHeight: 1.6 }}>
              <div style={{ fontFamily: "var(--font-display)", fontSize: 11, fontWeight: 700,
                letterSpacing: "0.16em", textTransform: "uppercase", color: "var(--ink-dim)", marginBottom: 8 }}>
                Node Inspector
              </div>
              <p>Click a node to edit its parameters.</p>
              <div className="cap" style={{ marginTop: 14, marginBottom: 6 }}>How it works</div>
              <p style={{ fontSize: 10, color: "var(--ink-faint)", textAlign: "left" }}>
                • <b>Drag</b> nodes to rearrange<br/>
                • <b>Drag from port</b> to connect<br/>
                • <b>Click wire</b> to delete<br/>
                • <b>Drag canvas</b> to pan<br/>
                • Same data as Behaviors/FX — alternate view
              </p>
            </div>
          ) : (
            <>
              <div className="dock-section" style={{ padding: 8,
                borderLeft: `3px solid ${NODE_KINDS[selected.kind].color}` }}>
                <div className="dock-label">{NODE_KINDS[selected.kind].label}</div>
                <div style={{ fontFamily: "var(--font-mono)", fontSize: 9, color: "var(--ink-faint)", marginTop: 4 }}>
                  Node id: {selected.id}
                </div>
              </div>
              {NODE_KINDS[selected.kind].params.map(p => (
                <NodeParam key={p.id} param={p}
                  value={selected.params[p.id]}
                  onChange={v => updNode(selected.id, { params: { ...selected.params, [p.id]: v } })}
                />
              ))}
              {NODE_KINDS[selected.kind].params.length === 0 && (
                <div style={{ padding: 8, fontSize: 10, color: "var(--ink-faint)", textAlign: "center" }}>
                  No editable parameters.
                </div>
              )}
              <div style={{ display: "flex", gap: 6 }}>
                <button className={`nbtn compact ${selected.bypass ? "glow pressed" : ""}`} style={{ flex: 1 }}
                  title="Bypass — the signal passes through unchanged"
                  onClick={() => updNode(selected.id, { bypass: !selected.bypass })}>
                  ⏻ Bypass
                </button>
                <button className="nbtn compact" style={{ flex: 1 }}
                  title="Randomize slider parameters"
                  onClick={() => {
                    const def = NODE_KINDS[selected.kind];
                    const np = { ...selected.params };
                    def.params.forEach(p => {
                      if (p.kind === "slider") np[p.id] = p.min + Math.random() * (p.max - p.min);
                    });
                    updNode(selected.id, { params: np });
                  }}>
                  🎲 Random
                </button>
                <button className="nbtn compact" style={{ flex: 1 }}
                  title="Reset parameters to defaults"
                  onClick={() => {
                    const def = NODE_KINDS[selected.kind];
                    const np = {};
                    def.params.forEach(p => np[p.id] = p.default);
                    updNode(selected.id, { params: np });
                  }}>
                  ↺ Reset
                </button>
              </div>
              <div style={{ display: "flex", gap: 6 }}>
                <button className="nbtn compact" style={{ flex: 1 }} onClick={() => duplicateNode(selected.id)}>
                  Duplicate
                </button>
                <button className="nbtn compact danger" style={{ flex: 1 }} onClick={() => removeNode(selected.id)}>
                  Remove
                </button>
              </div>
            </>
          )}
        </div>
      </div>

      <div className="stage-strip" style={{ gap: 16 }}>
        <span className="cap">FLOW:</span>
        <span style={{ fontFamily: "var(--font-mono)", fontSize: 10, color: "var(--ink-dim)" }}>
          Audio → Process → Visualize → FX → Output
        </span>
        <div style={{ flex: 1 }} />
        <span className="cap" style={{ color: "var(--ink-faint)" }}>
          Same project state as the rest of the app — switch back to Stage to see results
        </span>
      </div>
    </div>
  );
}

function NodeParam({ param, value, onChange }) {
  if (param.kind === "slider") {
    return (
      <div className="dock-section" style={{ padding: 8 }}>
        <div className="dock-label" style={{ display: "flex", justifyContent: "space-between" }}>
          {param.label}
          <span style={{ color: "var(--accent)", fontFamily: "var(--font-mono)",
            textShadow: "0 0 3px var(--accent-glow)" }}>{value.toFixed(2)}</span>
        </div>
        <HSlider value={value} onChange={onChange} min={param.min} max={param.max} />
      </div>
    );
  }
  if (param.kind === "seg") {
    return (
      <div className="dock-section" style={{ padding: 8 }}>
        <div className="dock-label">{param.label}</div>
        <div className="chips" style={{ padding: 3, marginTop: 6 }}>
          {param.options.map(o => (
            <button key={o} className={`chip ${value === o ? "active" : ""}`}
              onClick={() => onChange(o)} style={{ padding: "4px 6px", fontSize: 9 }}>
              {o}
            </button>
          ))}
        </div>
      </div>
    );
  }
  if (param.kind === "text") {
    return (
      <div className="dock-section" style={{ padding: 8 }}>
        <div className="dock-label">{param.label}</div>
        <input value={value} onChange={e => onChange(e.target.value)}
          style={{
            width: "100%", marginTop: 6, padding: "5px 7px",
            background: "linear-gradient(180deg,#0a0a0c,#14141a)",
            border: "none", borderRadius: 4, outline: "none",
            color: "var(--accent)", fontFamily: "var(--font-mono)", fontSize: 11,
            boxShadow: "var(--nshadow-in-sm)",
          }} />
      </div>
    );
  }
  if (param.kind === "serial") {
    const S = window.LuminaSerial;
    return (
      <div className="dock-section" style={{ padding: 8 }}>
        <div className="dock-label">{param.label}</div>
        <button className={`nbtn compact ${S && S.connected ? "glow pressed" : ""}`}
          style={{ width: "100%", marginTop: 6, padding: "6px" }}
          onClick={() => S && (S.connected ? S.disconnect() : S.connect())}>
          {S && S.connected ? "● Connected · disconnect" : "🔌 Connect USB (115200 baud)"}
        </button>
        <div style={{ fontSize: 9, color: "var(--ink-faint)", marginTop: 5,
          fontFamily: "var(--font-mono)", wordBreak: "break-all" }}>
          {S && S.lastLine ? "RX: " + S.lastLine : "Protocol: lines \"V:a,b,…\" up to 8 ch — 0..1 or 0..4095"}
        </div>
        <div style={{ display: "flex", alignItems: "center", gap: 6, marginTop: 6 }}>
          <span className="cap" style={{ fontSize: 8 }}
            title="Send BPM/beat/levels back over USB — drives hardware beat monitors">TX beat → display</span>
          <Flip value={S ? S.txBeat !== false : true}
            onChange={v => { if (S) S.txBeat = v; }} />
        </div>
      </div>
    );
  }
  if (param.kind === "ws") {
    const W = window.LuminaWS;
    return (
      <div className="dock-section" style={{ padding: 8 }}>
        <div className="dock-label">{param.label}</div>
        <input defaultValue={W ? W.url : "ws://localhost:9090"}
          onBlur={e => { if (W) W.url = e.target.value; }}
          placeholder="ws://host:porta"
          style={{
            width: "100%", marginTop: 6, padding: "5px 7px", borderRadius: 4,
            border: "none", outline: "none", boxSizing: "border-box",
            background: "linear-gradient(180deg,#0a0a0c,#14141a)",
            color: "var(--led-cyan)", fontFamily: "var(--font-mono)", fontSize: 10,
            boxShadow: "var(--nshadow-in-sm)",
          }} />
        <button className={`nbtn compact ${W && W.connected ? "glow pressed" : ""}`}
          style={{ width: "100%", marginTop: 5, padding: "6px" }}
          onClick={() => W && (W.connected ? W.disconnect() : W.connect(W.url))}>
          {W && W.connected ? "● Connected · disconnect" : "🌐 Connect WebSocket"}
        </button>
        <div style={{ fontSize: 9, color: "var(--ink-faint)", marginTop: 5,
          fontFamily: "var(--font-mono)", wordBreak: "break-all" }}>
          {W && W.lastLine ? "RX: " + W.lastLine : "Accetta \"V:a,b,c\", JSON [..] o {ch:[..]}"}
        </div>
      </div>
    );
  }
  if (param.kind === "cam") {
    const C = window.LuminaCam;
    return (
      <div className="dock-section" style={{ padding: 8 }}>
        <div className="dock-label">{param.label}</div>
        <button className={`nbtn compact ${C && C.active ? "glow pressed" : ""}`}
          style={{ width: "100%", marginTop: 6, padding: "6px" }}
          onClick={() => C && (C.active ? C.stop() : C.start())}>
          {C && C.active ? "● Camera ON · stop" : "📷 Start webcam"}
        </button>
        <div style={{ fontSize: 9, color: "var(--ink-faint)", marginTop: 5, fontFamily: "var(--font-mono)" }}>
          {C && C.active
            ? `motion ${C.motion.toFixed(2)} · bright ${C.bright.toFixed(2)}`
            : "Movimento e luminosità della stanza → segnali 0..1"}
        </div>
      </div>
    );
  }
  if (param.kind === "fxsel") {
    const FX = window.FXTypes || {};
    return (
      <div className="dock-section" style={{ padding: 8 }}>
        <div className="dock-label">Effect</div>
        <select value={value} onChange={e => onChange(e.target.value)}
          style={{
            width: "100%", marginTop: 6, padding: "5px 7px",
            background: "linear-gradient(180deg,#0a0a0c,#14141a)",
            border: "none", borderRadius: 4, outline: "none",
            color: "var(--accent)", fontFamily: "var(--font-mono)", fontSize: 10,
            boxShadow: "var(--nshadow-in-sm)",
          }}>
          {Object.entries(FX).filter(([k]) => k !== "customScript").map(([k, d]) => (
            <option key={k} value={k}>{d.label}</option>
          ))}
        </select>
      </div>
    );
  }
  if (param.kind === "viz") {
    return (
      <div className="dock-section" style={{ padding: 8 }}>
        <div className="dock-label">{param.label}</div>
        <select value={value} onChange={e => onChange(e.target.value)}
          style={{
            width: "100%", marginTop: 6, padding: "5px 7px",
            background: "linear-gradient(180deg,#0a0a0c,#14141a)",
            border: "none", borderRadius: 4, outline: "none",
            color: "var(--accent)", fontFamily: "var(--font-mono)", fontSize: 10,
            boxShadow: "var(--nshadow-in-sm)",
          }}>
          {(window.VISUALIZERS || []).map(v => (
            <option key={v.id} value={v.id}>{v.name}</option>
          ))}
        </select>
      </div>
    );
  }
  return null;
}

window.NodesWorkspace = NodesWorkspace;

/* Live mini-canvas — renders the actual visualizer this node produces,
   so the graph shows real output inline (TouchDesigner-style). */
function NodePreview({ node, nodes, edges }) {
  const ref = useRefN(null);
  useEffectN(() => {
    let raf;
    const draw = () => {
      const cv = ref.current; if (!cv) { raf = requestAnimationFrame(draw); return; }
      const ctx = cv.getContext("2d");
      const w = cv.width, h = cv.height;
      const A = window.AudioEngine;
      const audio = A ? { bass: A.bass||0, mid: A.mid||0, high: A.high||0, energy: A.energy||0, beat: A.beat||0,
        freq: A.freq, wave: A.wave } : { bass:0,mid:0,high:0,energy:0,beat:0 };
      const t = performance.now() / 1000;
      // resolve which visualizer to draw
      let vizId = null;
      if (node.kind === "visualizer") vizId = node.params.kind;
      else { const chain = deriveOutputChain(nodes, edges); vizId = chain?.vizNode?.params.kind; }
      const VIS = window.VISUALIZERS || [];
      const viz = VIS.find(v => v.id === vizId);
      ctx.fillStyle = "#05050a"; ctx.fillRect(0, 0, w, h);
      if (viz) {
        try {
          const params = window.__paramsSnapshot ? window.__paramsSnapshot() : {};
          const computed = window.Prog ? window.Prog.computeParams(params, audio, t) : params;
          viz.draw(ctx, w, h, audio, computed, t);
        } catch (e) {}
      } else {
        ctx.fillStyle = "rgba(255,255,255,0.25)";
        ctx.font = "9px monospace"; ctx.textAlign = "center";
        ctx.fillText("no signal", w / 2, h / 2);
      }
      raf = requestAnimationFrame(draw);
    };
    raf = requestAnimationFrame(draw);
    return () => cancelAnimationFrame(raf);
  }, [node.kind, node.params.kind, nodes, edges]);
  return (
    <canvas ref={ref} width={176} height={92}
      style={{
        width: "100%", height: 60, borderRadius: 5, display: "block",
        background: "#05050a",
        boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.08), inset 0 2px 10px rgba(0,0,0,0.6)",
      }} />
  );
}
