/* breadboard.jsx — interactive tap-to-place breadboard builder + live sim.
   A genuine connectivity engine: holes resolve to electrical nets, wires
   union nets, components are device-edges, and a DFS finds whether the
   battery's + and − are joined through an LED (correct polarity) and a
   current-limiting resistor. Lights the LED, pops it, or flags shorts.

   Exported: BreadboardBuilder. Self-contained SVG + engine. */

const { useState: _bbUse, useMemo: _bbMemo, useRef: _bbRef } = React;

/* ── board geometry ─────────────────────────────────────────────────────
   7 columns. Top rail (+), top bank rows a–e, center gap, bottom bank
   rows f–j, bottom rail (−). */
const BB_COLS = 7;
const BB_TOPROWS = ["a", "b", "c", "d", "e"];
const BB_BOTROWS = ["f", "g", "h", "i", "j"];

// Layout constants (SVG units)
const HX0 = 70, HDX = 46;            // first column x, column spacing
const RAILP_Y = 40;
const TOP_Y0 = 96, ROW_DY = 34;      // first top-bank row y
const GAP = 30;                       // center gap
const BOT_Y0 = TOP_Y0 + 5 * ROW_DY + GAP;
const RAILN_Y = BOT_Y0 + 5 * ROW_DY + 16;
const BB_W = HX0 + BB_COLS * HDX + 30;
const BB_H = RAILN_Y + 44;

function holeX(col) { return HX0 + (col - 1) * HDX; }
function railHoleX(i) { return HX0 + i * HDX; }     // rails: 7 holes too

// Hole id helpers
function topId(c, r) { return `T${c}${r}`; }
function botId(c, r) { return `B${c}${r}`; }
function railPId(i) { return `P${i}`; }
function railNId(i) { return `N${i}`; }

// Resolve a hole id → its base electrical net (before wires).
function baseNet(id) {
  if (id[0] === "P") return "RAILP";
  if (id[0] === "N") return "RAILN";
  if (id[0] === "T") return "T" + id[1];   // column digit
  if (id[0] === "B") return "B" + id[1];
  return id;
}

// Hole id → {x, y}
function holePos(id) {
  if (id[0] === "P") return { x: railHoleX(+id.slice(1)), y: RAILP_Y };
  if (id[0] === "N") return { x: railHoleX(+id.slice(1)), y: RAILN_Y };
  const c = +id[1];
  const r = id.slice(2);
  if (id[0] === "T") return { x: holeX(c), y: TOP_Y0 + BB_TOPROWS.indexOf(r) * ROW_DY };
  return { x: holeX(c), y: BOT_Y0 + BB_BOTROWS.indexOf(r) * ROW_DY };
}

/* ── union-find ─────────────────────────────────────────────────────────── */
function makeUF() {
  const p = {};
  const find = (x) => { while (p[x] !== undefined && p[x] !== x) { p[x] = p[p[x]] ?? p[x]; x = p[x]; } return x; };
  const ensure = (x) => { if (p[x] === undefined) p[x] = x; return x; };
  const union = (a, b) => { ensure(a); ensure(b); p[find(a)] = find(b); };
  return { find: (x) => { ensure(x); return find(x); }, union };
}

/* ── simulation ─────────────────────────────────────────────────────────
   parts: array of { type:'battery'|'resistor'|'led'|'wire', a, b }
   For battery, a = + leg, b = − leg. For led, a = anode(+), b = cathode(−).
   Returns a result describing the circuit state. */
function simulate(parts) {
  const battery = parts.find(p => p.type === "battery");
  if (!battery) return { state: "no-batt" };

  const VB = 9, VLED = 2.0;

  // 1) Union nets joined by wires.
  const uf = makeUF();
  for (const p of parts) {
    if (p.type === "wire") uf.union(baseNet(p.a), baseNet(p.b));
  }
  const net = (hole) => uf.find(baseNet(hole));

  const SRC = net(battery.a);   // V+
  const SNK = net(battery.b);   // GND

  if (SRC === SNK) return { state: "short", msg: "battery-short" };

  // 2) Build device edges between nets (excluding the battery itself).
  const edges = [];  // { from, to, type, dir? }
  for (const p of parts) {
    if (p.type === "resistor") {
      edges.push({ a: net(p.a), b: net(p.b), type: "resistor", R: p.R || 330 });
    } else if (p.type === "led") {
      edges.push({ a: net(p.a), b: net(p.b), type: "led", VLED });
    }
  }

  // 3) Two DFS passes over device edges.
  //    Pass A (strict): LEDs conduct only forward (anode→cathode). Find a
  //    SRC→SNK path; track total series R and whether an LED was used.
  //    Pass B (loose): LEDs bidirectional — only used to detect a reversed LED
  //    or a short when the strict pass fails.
  const search = (strict) => {
    let best = null; // { hasLED, sumR }
    const rank = (x) => (x.hasLED && x.sumR > 0 ? 4 : x.hasLED ? 3 : x.sumR > 0 ? 2 : 1);
    const visit = (node, used, acc) => {
      if (node === SNK) {
        if (!best || rank(acc) > rank(best)) best = { ...acc };
        return;
      }
      for (let i = 0; i < edges.length; i++) {
        if (used.has(i)) continue;
        const e = edges[i];
        let nxt = null;
        const fromA = e.a === node, fromB = e.b === node;
        if (!fromA && !fromB) continue;
        nxt = fromA ? e.b : e.a;
        if (e.type === "led") {
          if (strict && !fromA) continue;       // strict: must enter the anode
          used.add(i);
          visit(nxt, used, { hasLED: true, sumR: acc.sumR });
          used.delete(i);
        } else {
          used.add(i);
          visit(nxt, used, { hasLED: acc.hasLED, sumR: acc.sumR + e.R });
          used.delete(i);
        }
      }
    };
    visit(SRC, new Set(), { hasLED: false, sumR: 0 });
    return best;
  };

  const strict = search(true);
  if (strict && strict.hasLED) {
    if (strict.sumR <= 0) return { state: "pop", reason: "no-resistor" };
    const I = ((VB - VLED) / strict.sumR) * 1000; // mA
    const ledState = I > 35 ? "pop" : I > 30 ? "bright" : I >= 12 ? "good" : I >= 4 ? "dim" : "weak";
    return { state: ledState === "pop" ? "pop" : "lit", ledState, I, reason: ledState === "pop" ? "too-much" : null };
  }

  // No forward-LED path. Use the loose pass to explain why.
  const loose = search(false);
  if (!loose) return { state: "open" };
  if (loose.hasLED) return { state: "backwards" };
  return loose.sumR > 0
    ? { state: "short", msg: "resistor-only" }
    : { state: "short", msg: "wire-short" };
}

/* ── tap vs long-press wrapper for a placed part ──────────────────────────
   A quick tap calls onTap (toggle a switch, or open the action menu for any
   other part). A press-and-hold (or right-click) calls onLong, which always
   opens the Move/Delete menu — so switches stay movable/deletable even though
   their quick tap now flips them. */
function BBPlacedPart({ onTap, onLong, children }) {
  const timer = React.useRef(null);
  const longed = React.useRef(false);
  const start = React.useRef(null);
  const clearT = () => { if (timer.current) { clearTimeout(timer.current); timer.current = null; } };
  return (
    <g style={{ cursor: "pointer" }}
       onPointerDown={(e) => {
         longed.current = false;
         start.current = { x: e.clientX, y: e.clientY };
         clearT();
         timer.current = setTimeout(() => { longed.current = true; onLong(); }, 480);
       }}
       onPointerMove={(e) => {
         if (!start.current) return;
         const dx = e.clientX - start.current.x, dy = e.clientY - start.current.y;
         if (dx * dx + dy * dy > 64) clearT();   // it's a drag/scroll, not a hold
       }}
       onPointerUp={clearT}
       onPointerLeave={clearT}
       onPointerCancel={clearT}
       onContextMenu={(e) => { e.preventDefault(); clearT(); longed.current = true; onLong(); }}
       onClick={(e) => {
         e.stopPropagation(); e.preventDefault();
         if (longed.current) { longed.current = false; return; }  // the hold already acted
         onTap();
       }}>
      {children}
    </g>
  );
}

/* ── the component ──────────────────────────────────────────────────────── */
const DEFAULT_TOOLS = (kids) => [
  { id: "battery", label: kids ? "Battery (9V)" : "Battery 9V", hint: "+ leg first, then −" },
  { id: "resistor", label: kids ? "Resistor" : "Resistor 330Ω", hint: "either way round" },
  { id: "led", label: "LED", hint: "+ (long leg) first, then −" },
  { id: "wire", label: kids ? "Jumper wire" : "Jumper", hint: "connects two holes" },
];
const DEFAULT_LIMITS = { battery: 1, resistor: 2, led: 1, wire: 6, capacitor: 1, switch: 2, diode: 1 };

// Which parts care about orientation, shown as an at-a-glance tag on each tool.
// `warn` = has a direction you can get wrong; otherwise it goes in any way.
const BB_POLARITY = {
  battery:    { tag: "has + / −", warn: true },
  led:        { tag: "has + / −", warn: true },
  diode:      { tag: "one-way",   warn: true },
  transistor: { tag: "3 legs",    warn: true },
  resistor:   { tag: "any way",   warn: false },
  wire:       { tag: "any way",   warn: false },
  capacitor:  { tag: "any way",   warn: false },
  switch:     { tag: "any way",   warn: false },
};

/* value formatting for part labels + probe readouts */
const bbFmtR = (r) => (r >= 1000 ? String(r / 1000).replace(/\.0$/, "") + " kΩ" : r + " Ω");
const bbFmtUF = (p) => (p.uF || Math.round((p.C || 2) * 235)) + " µF";
/* preset value menus for the tap-a-part Value picker (E-series-ish, course-tuned).
   Resistors in Ω; capacitors in µF (display) → p.C units = µF/235 for the sim. */
const BB_R_VALUES = [100, 220, 330, 470, 680, 1000, 2200, 4700, 10000, 22000, 47000, 100000];
const BB_C_VALUES = [1, 10, 47, 100, 220, 470, 1000];
/* potentiometer resistance from knob position 0..1.
   'log' = audio taper: R rises slowly then steeply, so the bright→dim transition
   spreads evenly across the knob (what real volume/dimmer pots use). 'lin' is a
   plain linear pot (lopsided — most dimming bunched at one end). */
function bbPotR(pos, p) {
  const Rmax = (p && p.Rmax) || 10000;
  if (p && p.taper === "lin") return Math.max(1, Math.round(pos * Rmax));
  const B = 200;                                   // taper steepness
  return Math.max(1, Math.round(Rmax * (Math.pow(B, pos) - 1) / (B - 1)));
}

/* ── bbScopeSignal: does the probed part carry a TIME-VARYING signal? ──────
   Only the time-based sims hand us one: simulateRC returns { timed, tau } (a
   one-shot charging transient) and simulateRectifier returns { ripple } (a
   periodic rectified wave). Everything else is steady DC → no scope, just the
   multimeter rows. The probed part decides WHICH trace within that sim. */
function bbScopeSignal(p, result, live, rcFrac) {
  if (!p || !result) return null;

  // RC — a single charge/discharge transient
  if (result.timed) {
    const tau = Math.max(0.3, result.tau || result.delay || 1);
    const base = { mode: "rc", tau, live, rcFrac: rcFrac == null ? 1 : rcFrac };
    if (p.type === "capacitor")
      return { ...base, shape: "charge", color: "#7dff9a",
               label: "V across capacitor", kidLabel: "How full the bucket is" };
    if (p.type === "led")
      return { ...base, shape: "delayed", color: "#9af0ff",
               label: "LED current", kidLabel: "How bright the light is" };
    if (p.type === "resistor")
      return { ...base, shape: "decay", color: "#ffd479",
               label: "Current through R", kidLabel: "Flow through the pinch" };
    if (p.type === "battery")
      return { ...base, shape: "decay", color: "#ffd479",
               label: "Supply current", kidLabel: "Flow out of the pusher" };
    return null;
  }

  // Rectifier — a periodic rough/smoothed rectified wave
  if (result.ripple) {
    const smooth = result.ripple === "smooth";
    const base = { mode: "rect", live };
    if (p.type === "battery")
      return { ...base, shape: "ac", color: "#ff9aa8",
               label: "Rough source (AC)", kidLabel: "The wobbly source" };
    if (p.type === "diode")
      return { ...base, shape: "halfwave", color: "#9af0ff",
               label: "Right after the diode", kidLabel: "After the one-way door" };
    // the load (LED / resistor) and the smoothing cap see the rail
    if (smooth)
      return { ...base, shape: "smooth", color: "#7dff9a",
               label: p.type === "capacitor" ? "Smoothed rail" : "Across the load (smoothed)",
               kidLabel: "Smooth, steady power" };
    return { ...base, shape: "halfwave", color: "#ffd479",
             label: p.type === "capacitor" ? "Rail — still bumpy" : "Across the load (bumpy)",
             kidLabel: "Still bumpy — add a bucket" };
  }
  return null;
}

/* ── ProbeScope: a tiny pocket oscilloscope. Draws the chosen waveform live
   onto an SVG graticule via rAF — RC traces sweep once then retrigger; the
   rectifier traces scroll continuously like a real scope. Pure presentation;
   the shape descriptor comes from bbScopeSignal. */
function ProbeScope({ signal, kids }) {
  const traceRef = React.useRef(null);
  const fillRef = React.useRef(null);
  const sweepRef = React.useRef(null);
  const W = 300, H = 116;
  const padL = 8, padR = 8, padT = 8, padB = 18;
  const xL = padL, xR = W - padR, yT = padT, yB = H - padB;
  const live = signal.live;

  // unit amplitude a∈[0,1] → y (0 at baseline yB, 1 at top yT)
  const yOf = (a) => yB - Math.max(0, Math.min(1, a)) * (yB - yT);

  React.useEffect(() => {
    const trace = traceRef.current, fill = fillRef.current, sweep = sweepRef.current;
    if (!trace) return;
    const N = 120;
    // sample one frame at time t (seconds since power-on) → points + sweep x
    const frame = (t) => {
      const pts = [];
      let headX = xR, headA = 0, rcDone = false;
      if (signal.mode === "rc") {
        // The reveal head is driven by the circuit's REAL charge fraction (the
        // same rcFrac that drives the LED brightness and the probe numbers), so
        // scope, glow and readings move in lockstep on ONE timebase. The x-axis
        // spans 4.5τ; charge fraction f maps to reveal position -ln(1-f)/4.5.
        const f = live ? Math.max(0, Math.min(0.9995, signal.rcFrac == null ? 0 : signal.rcFrac)) : 0;
        const prog = live ? Math.min(1, (-Math.log(1 - f)) / 4.5) : 0;
        rcDone = live && f >= 0.995;
        for (let i = 0; i <= N; i++) {
          const u = i / N;                        // 0..1 across width
          const x = xL + u * (xR - xL);
          let a;
          const ku = u * 4.5;                     // in units of τ
          if (signal.shape === "charge")  a = 1 - Math.exp(-ku);
          else if (signal.shape === "decay") a = 0.12 + 0.88 * Math.exp(-ku);
          else { // delayed (LED): stays low until the cap passes threshold
            const c = 1 - Math.exp(-ku);
            a = Math.max(0, (c - 0.28) / 0.72);
          }
          if (live && u > prog) { // ahead of the reveal head → flat pre-trigger baseline
            const flat = signal.shape === "decay" ? 0.12 : (signal.shape === "delayed" ? 0 : 0);
            pts.push(x + "," + yOf(flat).toFixed(1));
          } else {
            pts.push(x + "," + yOf(a).toFixed(1));
            headX = x; headA = a;
          }
        }
        if (!live) headX = xR;
      } else {
        // rectifier — continuous scroll, right→left
        const cycles = 3.4, speed = 0.55;
        for (let i = 0; i <= N; i++) {
          const u = i / N;
          const x = xL + u * (xR - xL);
          const ph = u * cycles - (live ? t * speed : 0);
          let a;
          if (signal.shape === "ac") {
            a = 0.5 + 0.4 * Math.sin(ph * Math.PI * 2);
          } else if (signal.shape === "halfwave") {
            a = Math.max(0, Math.sin(ph * Math.PI * 2)) * 0.86 + 0.04;
          } else { // smooth: high level with a small sawtooth ripple
            const frac = ((ph % 1) + 1) % 1;
            a = 0.82 + 0.11 * Math.exp(-3.2 * frac);
          }
          pts.push(x + "," + yOf(a).toFixed(1));
        }
        headX = null;
      }
      return { poly: pts.join(" "), headX, headA, done: rcDone };
    };

    let raf, t0 = performance.now();
    const draw = (f) => {
      trace.setAttribute("points", f.poly);
      if (fill) fill.setAttribute("points", `${xL},${yB} ${f.poly} ${xR},${yB}`);
      if (sweep) {
        if (signal.mode === "rc" && live && f.headX != null && !f.done) {
          sweep.style.display = "";
          sweep.setAttribute("cx", f.headX);
          sweep.setAttribute("cy", yOf(f.headA));
        } else { sweep.style.display = "none"; }
      }
    };
    if (signal.mode === "rc") {
      // RC is static per render — the reveal is driven by signal.rcFrac, which
      // updates from the parent's charge clock. No rAF: redraw on each prop change.
      draw(frame(0));
      return;
    }
    // rectifier — continuous scroll via rAF
    const tick = (now) => {
      const t = (now - t0) / 1000;
      draw(frame(t));
      if (live) raf = requestAnimationFrame(tick);
    };
    draw(frame(0));
    if (live) raf = requestAnimationFrame(tick);
    return () => raf && cancelAnimationFrame(raf);
  }, [signal.mode, signal.shape, signal.tau, signal.rcFrac, live]);

  const grid = [];
  for (let i = 1; i < 4; i++) { const y = yT + (i / 4) * (yB - yT); grid.push(<line key={"h" + i} x1={xL} y1={y} x2={xR} y2={y} />); }
  for (let i = 1; i < 6; i++) { const x = xL + (i / 6) * (xR - xL); grid.push(<line key={"v" + i} x1={x} y1={yT} x2={x} y2={yB} />); }

  return (
    <div className="bb-scope">
      <div className="bb-scope-head">
        <span>{kids ? signal.kidLabel : signal.label}</span>
        <span className={"bb-scope-dot" + (live ? " on" : "")}>{live ? "● LIVE" : "○ IDLE"}</span>
      </div>
      <svg viewBox={`0 0 ${W} ${H}`} className="bb-scope-svg" preserveAspectRatio="none">
        <rect x="0" y="0" width={W} height={H} fill="#06140d" />
        <g className="bb-scope-grid">{grid}</g>
        <line x1={xL} y1={yB} x2={xR} y2={yB} className="bb-scope-axis" />
        <polygon ref={fillRef} className="bb-scope-fill" points="" style={{ fill: signal.color }} />
        <polyline ref={traceRef} points="" className="bb-scope-trace" style={{ stroke: signal.color }} />
        <circle ref={sweepRef} r="3.4" className="bb-scope-sweep" style={{ fill: signal.color }} />
        {/* axis caption */}
        <text x={xL + 1} y={H - 5} className="bb-scope-cap">{kids ? "time →" : (signal.mode === "rc" ? "t →   (one charge)" : "t →   (repeating)")}</text>
        {signal.mode === "rc" && !kids && (
          <text x={xL + (1 / 4.5) * (xR - xL)} y={yT + 9} className="bb-scope-tau" textAnchor="middle">τ</text>
        )}
      </svg>
    </div>
  );
}

/* No-op stand-in for useMnaSim on pages that don't load mna-sim.jsx. Calls NO
   hooks and returns a stable empty shape — which function we use is fixed per
   page load, so the hook-count stays consistent across renders. */
function mnaNoLiveSim() { return { ready: false, reason: null, nodeV: [], current: {}, voltage: {}, brightness: {}, probe: () => 0, netId: () => -1, engine: null, link: null, reset: () => {} }; }
function mnaNoProbeHistory() { return {}; }

function BreadboardBuilder({ kids, sim, tools: toolsProp, limits: limitsProp,
                             goalAdult, goalKids, describe, onResult, showCharges, preset, live, valueBin }) {
  const [tool, setTool] = React.useState(null);
  const [pending, setPending] = React.useState(null);
  const [removeMode, setRemoveMode] = React.useState(false);
  // primer collapse state persists site-wide: open the first time, stay closed once dismissed
  const [primerOpen, setPrimerOpen] = React.useState(() => {
    try { return localStorage.getItem("hte-bb-primer") !== "closed"; } catch (e) { return true; }
  });
  const togglePrimer = () => setPrimerOpen(o => {
    const next = !o;
    try { localStorage.setItem("hte-bb-primer", next ? "open" : "closed"); } catch (e) {}
    return next;
  });
  const [parts, setParts] = React.useState([]);
  const [tested, setTested] = React.useState(false);
  const [armed, setArmed] = React.useState(false);
  const [rcT, setRcT] = React.useState(0);
  const [probeMode, setProbeMode] = React.useState(false);
  const [probeIdx, setProbeIdx] = React.useState(null);
  const [switchStates, setSwitchStates] = React.useState({}); // partIndex -> bool
  const [menu, setMenu] = React.useState(null);   // {i} — tap-a-part action menu
  const [hoverNet, setHoverNet] = React.useState(null); // baseNet under the pointer — lights the whole node
  const [valuePick, setValuePick] = React.useState(null); // part index whose Value picker is open
  const [moveIdx, setMoveIdx] = React.useState(null); // part whose legs are draggable
  const bbSvgRef = React.useRef(null);
  const dragRef = React.useRef(null);             // {i, leg} mid-drag
  const [dragHint, setDragHint] = React.useState(null); // live snapped hole id

  // map a pointer event to the nearest board hole id (works through the scaled viewBox)
  const nearestHole = (evt) => {
    const svg = bbSvgRef.current; if (!svg) return null;
    const ctm = svg.getScreenCTM(); if (!ctm) return null;
    const inv = ctm.inverse();
    const pt = svg.createSVGPoint(); pt.x = evt.clientX; pt.y = evt.clientY;
    const u = pt.matrixTransform(inv);
    let best = null, bd = Infinity;
    for (const h of holeCircles) { const q = holePos(h.id); const d = (q.x - u.x) ** 2 + (q.y - u.y) ** 2; if (d < bd) { bd = d; best = h.id; } }
    return bd < 26 * 26 ? best : null;   // only snap if reasonably close
  };

  const legField = (leg) => (leg === 0 ? "a" : leg === 1 ? "b" : "c");
  const reassignLeg = (i, leg, hole) => {
    setParts(ps => ps.map((p, j) => {
      if (j !== i) return p;
      const f = legField(leg);
      // don't let two legs land on the same hole
      if ([p.a, p.b, p.c].some((h, k) => h === hole && legField(k) !== f)) return p;
      return { ...p, [f]: hole };
    }));
    setTested(false);
  };

  const onLegDown = (i, leg) => (e) => {
    e.stopPropagation(); e.preventDefault();
    dragRef.current = { i, leg };
    const move = (ev) => { setDragHint(nearestHole(ev)); };
    const up = (ev) => {
      const hole = nearestHole(ev);
      if (hole && dragRef.current) reassignLeg(dragRef.current.i, dragRef.current.leg, hole);
      dragRef.current = null; setDragHint(null);
      window.removeEventListener("pointermove", move);
      window.removeEventListener("pointerup", up);
    };
    window.addEventListener("pointermove", move);
    window.addEventListener("pointerup", up);
  };

  const toggleSwitch = (i) => {
    setSwitchStates(s => ({ ...s, [i]: !s[i] }));
    // keep the board powered so flipping a switch updates the LED live
    // (don't force a re-power — that made OR gates look like they turned off)
  };

  // load a preset example (sandbox "fill it in for me"). Keyed on preset.nonce.
  React.useEffect(() => {
    if (!preset || !preset.parts) return;
    setParts(preset.parts.map(p => ({ ...p })));
    setSwitchStates(preset.switches || {});
    setTool(null); setPending(null); setTested(false); setArmed(false);
    setProbeMode(false); setProbeIdx(null); setRemoveMode(false); setMenu(null); setValuePick(null);
  }, [preset && preset.nonce]);

  const simFn = sim || simulate;
  const result = React.useMemo(() => simFn(parts, switchStates), [parts, simFn, switchStates]);

  // M3: optional live transient sim for free-build boards. Always call the
  // resolved hook (real on pages that load mna-sim.jsx, no-op elsewhere) so the
  // hook order is stable; it only spins the rAF loop when live + powered.
  const useLiveSim = window.useMnaSim || mnaNoLiveSim;
  const liveSim = useLiveSim(parts, switchStates, { active: !!live && tested });
  const liveOn = !!live && liveSim.ready;

  // M5: auto-probe the dynamic nodes (capacitors first, then LEDs) and stream
  // their node voltages into the live scope. No tap UI — it just plots what the
  // board is doing: RC ramps, oscillation, switch steps.
  const BB_PROBE_COLORS = ["#7dff9a", "#4aa3e0", "#ffd36b", "#ff8e6b"];
  const scopeProbes = React.useMemo(() => {
    if (!live) return [];
    const picks = [];
    parts.forEach((p, i) => { if (p.type === "capacitor") picks.push({ i, p, kind: "cap" }); });
    parts.forEach((p, i) => { if (p.type === "led") picks.push({ i, p, kind: "led" }); });
    return picks.slice(0, 3).map((x, k) => ({
      id: x.kind + x.i,
      hole: x.p.a,
      label: (x.kind === "cap" ? (kids ? "bucket" : "cap") : "LED") + " \u00b7 " + x.p.a,
      color: BB_PROBE_COLORS[k % BB_PROBE_COLORS.length],
    }));
  }, [parts, live, kids]);
  const useProbeHist = window.useProbeHistory || mnaNoProbeHistory;
  const probeBufs = useProbeHist(scopeProbes, liveSim, { len: 240 });

  // Live charge fraction of a timed (capacitor) circuit: 0 at power-on → 1 when
  // settled. ONE source of truth shared by the LED glow, the scope reveal and
  // the probe numbers so all three move together. 1 (settled) for steady DC.
  const rcTau = result.tau || result.delay || 1;
  const rcFrac = (tested && result.timed) ? 1 - Math.exp(-rcT / rcTau) : 1;

  const tools = toolsProp || DEFAULT_TOOLS(kids);
  const limit = limitsProp || DEFAULT_LIMITS;

  const counts = parts.reduce((m, p) => (m[p.type] = (m[p.type] || 0) + 1, m), {});

  const tapHole = (id) => {
    if (removeMode) return;
    if (!tool) return;
    const toolDef = tools.find(td => td.id === tool) || {};
    const legs = toolDef.legs || 2;     // transistor uses 3 (a=base,b=collector,c=emitter)
    if ((counts[tool] || 0) >= limit[tool] && !pending) return; // at limit
    if (!pending) { setPending([id]); return; }
    if (pending.includes(id)) return;   // can't reuse a hole
    const next = [...pending, id];
    if (next.length < legs) { setPending(next); return; }
    const extra = toolDef.part || {};
    const placed = { type: tool, a: next[0], b: next[1], ...extra };
    if (legs >= 3) placed.c = next[2];
    setParts(ps => [...ps, placed]);
    setPending(null);
    setTested(false);
  };

  const undo = () => { setParts(ps => ps.slice(0, -1)); setPending(null); setTested(false); setArmed(false); };
  const clear = () => { setParts([]); setPending(null); setTested(false); setArmed(false); setRemoveMode(false); setMenu(null); setValuePick(null); };

  // Pluck a single part off the board — no need to undo everything since.
  const removePart = (i) => {
    setParts(ps => ps.filter((_, j) => j !== i));
    setProbeIdx(pi => (pi == null ? null : pi === i ? null : pi > i ? pi - 1 : pi));
    // switchStates is keyed by part index; shift keys above the gap down
    setSwitchStates(s => {
      const next = {};
      for (const k of Object.keys(s)) {
        const ki = Number(k);
        if (ki < i) next[ki] = s[k];
        else if (ki > i) next[ki - 1] = s[k];
      }
      return next;
    });
    setPending(null); setTested(false); setArmed(false);
  };

  // ── render helpers ──
  const holeCircles = [];
  // rails
  for (let i = 0; i < BB_COLS; i++) {
    holeCircles.push({ id: railPId(i), rail: "p" });
    holeCircles.push({ id: railNId(i), rail: "n" });
  }
  for (let c = 1; c <= BB_COLS; c++) {
    for (const r of BB_TOPROWS) holeCircles.push({ id: topId(c, r) });
    for (const r of BB_BOTROWS) holeCircles.push({ id: botId(c, r) });
  }

  const onPotDown = (i) => (e) => {
    e.stopPropagation(); e.preventDefault();
    const startX = e.clientX;
    const startPos = parts[i] && parts[i].pos != null ? parts[i].pos : 0.5;
    const move = (ev) => {
      const pos = Math.max(0, Math.min(1, startPos + (ev.clientX - startX) / 170));
      setParts(ps => ps.map((p, j) => j === i ? { ...p, pos, R: bbPotR(pos, p) } : p));
    };
    const up = () => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); };
    window.addEventListener("pointermove", move);
    window.addEventListener("pointerup", up);
  };

  const partGlyph = (p, i) => {
    // 3-leg transistor: draw legs from a hub to base/collector/emitter.
    if (p.type === "transistor") {
      const Ba = holePos(p.a), Co = holePos(p.b), Em = holePos(p.c);
      const hx = (Ba.x + Co.x + Em.x) / 3, hy = (Ba.y + Co.y + Em.y) / 3;
      const on = result.baseDriven && result.state === "lit";
      const col = on ? "var(--current)" : "var(--ink-soft)";
      const legLabel = (P, t, c) => (
        <text x={P.x} y={P.y - 14} textAnchor="middle" fontSize="13.5"
              fontFamily="IBM Plex Mono, monospace" fill={c}>{t}</text>
      );
      return (
        <g key={"part" + i}>
          <line x1={hx} y1={hy} x2={Ba.x} y2={Ba.y} stroke="var(--current)" strokeWidth="3" opacity="0.55" strokeLinecap="round" />
          <line x1={hx} y1={hy} x2={Co.x} y2={Co.y} stroke="var(--ink)" strokeWidth="3" opacity="0.45" strokeLinecap="round" />
          <line x1={hx} y1={hy} x2={Em.x} y2={Em.y} stroke="var(--ink)" strokeWidth="3" opacity="0.45" strokeLinecap="round" />
          <circle cx={hx} cy={hy} r="15" fill="var(--bg-card)" stroke={col} strokeWidth="2.5" />
          <text x={hx} y={hy + 4} textAnchor="middle" fontFamily="IBM Plex Mono, monospace"
                fontSize="15" fill={col} fontWeight="600">T</text>
          {legLabel(Ba, "B", "var(--current)")}
          {legLabel(Co, "C", "var(--ink-soft)")}
          {legLabel(Em, "E", "var(--ink-soft)")}
        </g>
      );
    }
    const A = holePos(p.a), B = holePos(p.b);
    const mx = (A.x + B.x) / 2, my = (A.y + B.y) / 2;
    const color = p.type === "wire" ? "var(--ink-soft)"
                : p.type === "battery" ? "var(--water)"
                : p.type === "resistor" ? "var(--ink)"
                : p.type === "capacitor" ? "var(--water-deep)"
                : "var(--current)";
    const ledInfo = result.leds && result.leds[i];
    const powered = tested;                              // board only "live" once powered on
    const litTarget = p.type === "led" && (ledInfo ? ledInfo.on : result.state === "lit");
    const litVerdict = powered && (result.timed ? (litTarget && armed) : litTarget);
    // brightness is CONTINUOUS in the LED's actual current (mA), not stepped:
    // normalise to a ~20 mA full-scale and apply a mild gamma so low currents
    // are still visible but bright ones clearly hotter.
    // brightness tracks the LIVE current. For a timed (capacitor) circuit that
    // means it ramps with the charge fraction — dark at power-on, brightening as
    // the bucket fills — instead of snapping to a steady glow.
    const ledMa = ((ledInfo ? ledInfo.I : result.I) || 0) * (result.timed ? rcFrac : 1);
    const glowVerdict = litVerdict ? Math.max(0, Math.min(1, Math.pow(ledMa / 20, 0.72))) : 0;
    // M3: in Live mode an LED's glow is the transient engine's solved branch
    // current, not the steady-state verdict — so dimmers, RC fades and switch
    // flips animate continuously on the real board.
    const liveBright = (liveOn && powered && p.type === "led") ? (liveSim.brightness[i] || 0) : null;
    const lit = liveBright != null ? liveBright > 0.02 : litVerdict;
    const glow = liveBright != null ? liveBright : glowVerdict;
    const popped = powered && p.type === "led" && (ledInfo ? ledInfo.popped : result.state === "pop");
    const rampDur = result.timed ? 0.16 : 0.25;

    // ── realistic LED glyph: domed lens, layered glow halo, emission rays ──
    if (p.type === "led") {
      const rays = [0, 45, 90, 135, 180, 225, 270, 315];
      return (
        <g key={"part" + i} className={lit ? "bb-led-on" : ""}>
          <line x1={A.x} y1={A.y} x2={B.x} y2={B.y} stroke="var(--current)"
                strokeWidth="6" strokeLinecap="round" opacity={lit ? 0.5 : 0.3} />
          {lit && (
            <g className="bb-led-halo" style={{ transition: `opacity ${rampDur}s linear`, opacity: 0.12 + glow * 0.88, pointerEvents: "none" }}>
              {/* wide soft bloom */}
              <circle cx={mx} cy={my} r={28 + glow * 22} fill="url(#bbLedGlow)" filter="url(#bbLedBloom)" opacity={0.25 + glow * 0.4} />
              {/* tight bright core halo */}
              <circle cx={mx} cy={my} r={14 + glow * 14} fill="url(#bbLedGlow)" opacity={0.3 + glow * 0.6} />
            </g>
          )}
          {lit && glow > 0.02 && rays.map((a) => {
            const rad = (a * Math.PI) / 180, r1 = 15, r2 = 19 + glow * 14;
            return (
              <line key={a}
                x1={mx + Math.cos(rad) * r1} y1={my + Math.sin(rad) * r1}
                x2={mx + Math.cos(rad) * r2} y2={my + Math.sin(rad) * r2}
                stroke="var(--current)" strokeWidth="2.2" strokeLinecap="round" opacity={glow * 0.85} pointerEvents="none" />
            );
          })}
          <g transform={`translate(${mx} ${my})`}>
            <circle r="12"
                    fill={lit ? "url(#bbLedLens)" : "var(--bg-card)"}
                    stroke={lit ? "var(--current-deep)" : popped ? "var(--current)" : "var(--current-soft)"}
                    strokeWidth="2" style={{ transition: `fill ${rampDur}s linear` }} />
            {lit && <circle r="6.5" fill="#fff" opacity={0.45 + glow * 0.45} style={{ transition: `opacity ${rampDur}s linear` }} />}
            {lit && <circle cx="-4" cy="-4.5" r="3" fill="#fff" opacity="0.95" />}
            {!lit && !popped && <circle r="4.5" fill="none" stroke="var(--current-soft)" strokeWidth="1.5" opacity="0.6" />}
            {popped && <text y="5" textAnchor="middle" fontFamily="IBM Plex Mono, monospace" fontSize="17.5" fill="var(--current)" fontWeight="700">✕</text>}
          </g>
          <text x={A.x} y={A.y - 14} fontSize="15" textAnchor="middle" fill="var(--current)" fontFamily="IBM Plex Mono, monospace">+</text>
          <text x={B.x} y={B.y - 14} fontSize="15" textAnchor="middle" fill="var(--ink-faint)" fontFamily="IBM Plex Mono, monospace">−</text>
          {popped && <text x={mx} y={my - 22} fontSize="27" textAnchor="middle">💥</text>}
        </g>
      );
    }

    // potentiometer: a resistor (type "resistor") with a draggable knob that
    // sets its resistance live. Drag the knob left/right to dim the load.
    if (p.pot) {
      const pos = p.pos != null ? p.pos : 0.5;
      const ang = (-130 + pos * 260) * Math.PI / 180;
      return (
        <g key={"part" + i}>
          <line x1={A.x} y1={A.y} x2={B.x} y2={B.y} stroke="var(--ink)" strokeWidth="6" strokeLinecap="round" opacity="0.35" />
          <g transform={`translate(${mx} ${my})`}>
            <circle r="15.5" fill="var(--bg-card)" stroke="var(--water)" strokeWidth="2.2"
                    style={{ cursor: "grab", touchAction: "none" }} onPointerDown={onPotDown(i)} />
            <line x1="0" y1="0" x2={Math.cos(ang - Math.PI / 2) * 11} y2={Math.sin(ang - Math.PI / 2) * 11}
                  stroke="var(--water)" strokeWidth="2.6" strokeLinecap="round" style={{ pointerEvents: "none" }} />
            <circle r="2.4" fill="var(--water)" style={{ pointerEvents: "none" }} />
          </g>
          <text x={mx} y={my - 22} textAnchor="middle" fontFamily="IBM Plex Mono, monospace" fontSize="10" fill="var(--water)">{(p.taper === "lin" ? (kids ? "knob ↺" : "POT ↺ lin") : (kids ? "knob ↺" : "POT ↺ log"))}</text>
          <text x={mx} y={my + 30} textAnchor="middle" fontFamily="IBM Plex Mono, monospace" fontSize="12" fill="var(--ink-faint)">{bbFmtR(p.R || 0)} · {Math.round(pos * 100)}%</text>
        </g>
      );
    }

    return (
      <g key={"part" + i}>
        <line x1={A.x} y1={A.y} x2={B.x} y2={B.y} stroke={color}
              strokeWidth={p.type === "wire" ? 4 : 6} strokeLinecap="round"
              opacity={p.type === "wire" ? 0.9 : 0.35} />
        {p.type !== "wire" && (
          <g transform={`translate(${mx} ${my})`}
             style={{ cursor: p.type === "switch" ? "pointer" : "default" }}>
            <circle r="13" fill="var(--bg-card)"
                    stroke={p.type === "switch" ? (switchStates[i] ? "var(--water)" : "var(--ink-faint)") : color}
                    strokeWidth="2" />
            <text y="4" textAnchor="middle" fontFamily="IBM Plex Mono, monospace"
                  fontSize="16" fill={p.type === "switch" ? (switchStates[i] ? "var(--water)" : "var(--ink-faint)") : color} fontWeight="600">
              {p.type === "battery" ? "B" : p.type === "resistor" ? "R" : p.type === "capacitor" ? "C"
               : p.type === "diode" ? "▷" : p.type === "switch" ? (switchStates[i] ? "1" : "0") : popped ? "✕" : "◗"}
            </text>
          </g>
        )}
        {/* value tag — every part wears its size (m0142) */}
        {(p.type === "resistor" || p.type === "capacitor" || p.type === "battery") && (
          <text x={mx} y={my + 29} textAnchor="middle" fontFamily="IBM Plex Mono, monospace"
                fontSize="12" fill="var(--ink-faint)">
            {p.type === "resistor" ? bbFmtR(p.R || 330) : p.type === "capacitor" ? bbFmtUF(p) : "9 V"}
          </text>
        )}
        {popped && <text x={mx} y={my - 20} fontSize="27" textAnchor="middle">💥</text>}
        {p.type === "battery" && (
          <>
            <text x={A.x} y={A.y - 16} fontSize="17.5" textAnchor="middle" fill="var(--water)" fontFamily="IBM Plex Mono, monospace">+</text>
            <text x={B.x} y={B.y - 16} fontSize="17.5" textAnchor="middle" fill="var(--ink-faint)" fontFamily="IBM Plex Mono, monospace">−</text>
          </>
        )}
        {p.type === "led" && (
          <>
            <text x={A.x} y={A.y - 14} fontSize="15" textAnchor="middle" fill="var(--current)" fontFamily="IBM Plex Mono, monospace">+</text>
            <text x={B.x} y={B.y - 14} fontSize="15" textAnchor="middle" fill="var(--ink-faint)" fontFamily="IBM Plex Mono, monospace">−</text>
          </>
        )}
        {p.type === "diode" && (
          <>
            <text x={A.x} y={A.y - 14} fontSize="15" textAnchor="middle" fill="var(--current)" fontFamily="IBM Plex Mono, monospace">▷</text>
            <text x={B.x} y={B.y - 14} fontSize="15" textAnchor="middle" fill="var(--ink-faint)" fontFamily="IBM Plex Mono, monospace">|</text>
          </>
        )}
      </g>
    );
  };

  const verdict = (() => {
    if (!tested) return null;
    if (describe) return describe(result, kids);
    const r = result;
    switch (r.state) {
      case "no-batt": return ["warn", kids ? "Add a battery first — it's the push!" : "No battery placed. Add the 9 V source."];
      case "open": return ["warn", kids ? "The loop isn't finished — follow the path from + all the way back to −." : "Open circuit. Trace a complete loop from + back to −."];
      case "backwards": return ["warn", kids ? "The LED is in backwards! Flip it — the + leg must face the push." : "LED reversed. A diode only passes current one way — swap its legs."];
      case "short":
        return ["fail", r.msg === "resistor-only"
          ? (kids ? "You connected + to − with no LED — that just wastes the battery." : "Short through a resistor, no LED in the loop — current flows but does nothing useful.")
          : (kids ? "Yikes — you connected + straight to −! That's a short circuit. The battery overheats." : "Dead short: + tied to − with no load. In real life the battery gets hot. Add a resistor + LED.")];
      case "pop":
        return ["fail", r.reason === "no-resistor"
          ? (kids ? "Pop! 💥 No resistor — too much flow fried the LED." : "LED destroyed: no current limiting. An LED straight across 9 V draws far too much. Add a series resistor.")
          : (kids ? "Pop! 💥 Too much current for the LED." : `LED destroyed at ${(result.I||0).toFixed(0)} mA. Use a larger resistor.`)];
      case "lit":
        if (r.ledState === "good") return ["pass", kids ? "Perfect! A nice steady glow. You built a real circuit!" : `Lit at ${r.I.toFixed(0)} mA — right in the LED's happy zone. Textbook.`];
        if (r.ledState === "bright") return ["pass", kids ? "It works — running a little hot, but it glows!" : `Lit at ${r.I.toFixed(0)} mA — bright, slightly over target but safe enough.`];
        if (r.ledState === "dim") return ["warn", kids ? "It glows, but dimly. A smaller resistor lets more through." : `Only ${r.I.toFixed(1)} mA — visible but dim. Try a smaller resistor.`];
        return ["warn", kids ? "Barely glowing — too much squeeze." : `Just ${r.I.toFixed(1)} mA — too little current to see well.`];
      default: return ["warn", "Hmm."];
    }
  })();

  React.useEffect(() => {
    if (tested && verdict && onResult) onResult(verdict[0] === "pass", result);
  }, [tested, verdict && verdict[0]]);

  // Live RC transient clock: seconds since power-on, ticking only while a
  // time-based (capacitor) circuit is being probed. Drives the changing probe
  // numbers so the charging current is SEEN to decay to zero, then settle —
  // the capacitor's whole point. Resets on power-off / probe-off.
  React.useEffect(() => {
    if (!tested || !result.timed) { setRcT(0); return; }
    const t0 = performance.now();
    const id = setInterval(() => {
      const el = (performance.now() - t0) / 1000;
      setRcT(el);
      if (el > 6 * (result.tau || result.delay || 1)) clearInterval(id); // settled — stop ticking
    }, 90);
    return () => clearInterval(id);
  }, [tested, result.timed, result.tau, result.delay]);

  return (
    <div className="bb">
      <div className={`bb-primer ${primerOpen ? "open" : ""}`}>
        <button className="bb-primer-head" onClick={togglePrimer} aria-expanded={primerOpen}>
          <span className="bb-primer-title">{kids ? "How the board works" : "How a breadboard works"}</span>
          <span className="bb-primer-chevron">{primerOpen ? "−" : "+"}</span>
        </button>
        {primerOpen && (
          <ul className="bb-primer-list">
            <li>
              <span className="bb-primer-k">{kids ? "A column is one wire." : "A column is one node."}</span>{" "}
              {kids
                ? "The 5 holes going down each column are secretly joined — drop two legs in the same column and they're connected."
                : "The 5 holes stacked in each column are linked inside the board. Two legs in the same column are wired together."}
            </li>
            <li>
              <span className="bb-primer-k">{kids ? "The side stripes are power." : "The rails carry power."}</span>{" "}
              {kids
                ? "The + and − stripes run all the way down both sides of the board."
                : "The + and − rails run the full length, feeding every column you tap into them."}
            </li>
            <li>
              <span className="bb-primer-k">{kids ? "Order doesn't matter." : "In one loop, order is free."}</span>{" "}
              {kids
                ? "The same flow goes through everything in the loop, so the resistor can sit before OR after the LED — it calms the flow either way."
                : "The same current flows through every part in a series loop, so a resistor before or after the LED limits it identically. Position is tidiness, not function."}
            </li>
            <li>
              <span className="bb-primer-k">{kids ? "Some parts have a front." : "Some parts have a direction."}</span>{" "}
              {kids
                ? "The LED and battery have a + and − end. A resistor or wire doesn't care which way round it goes."
                : "The LED, battery and diode have a + / − orientation; a resistor or jumper goes in either way."}
            </li>
          </ul>
        )}
      </div>
      <div className="bb-board-wrap">
        <svg ref={bbSvgRef} viewBox={`0 0 ${BB_W} ${BB_H}`} width="100%" className="bb-svg"
             onClick={() => { if (valuePick != null) setValuePick(null); else if (menu) setMenu(null); else if (moveIdx != null && !dragRef.current) setMoveIdx(null); }}>
          <defs>
            <radialGradient id="bbLedGlow" cx="50%" cy="50%" r="50%">
              <stop offset="0%" stopColor="var(--current)" stopOpacity="1" />
              <stop offset="35%" stopColor="var(--current)" stopOpacity="0.6" />
              <stop offset="100%" stopColor="var(--current)" stopOpacity="0" />
            </radialGradient>
            <radialGradient id="bbLedLens" cx="38%" cy="30%" r="75%">
              <stop offset="0%" stopColor="#fff" />
              <stop offset="30%" stopColor="var(--current-soft)" />
              <stop offset="70%" stopColor="var(--current)" />
              <stop offset="100%" stopColor="var(--current-deep)" />
            </radialGradient>
            <filter id="bbLedBloom" x="-120%" y="-120%" width="340%" height="340%">
              <feGaussianBlur stdDeviation="5" />
            </filter>
          </defs>
          {/* board body */}
          <rect x="20" y="20" width={BB_W - 40} height={BB_H - 40} rx="12"
                fill="var(--bg-card)" stroke="var(--rule-strong)" strokeWidth="1.5" />
          {/* rail lines */}
          <line x1={railHoleX(0) - 16} y1={RAILP_Y} x2={railHoleX(BB_COLS - 1) + 16} y2={RAILP_Y}
                stroke="var(--current)" strokeWidth="2" opacity="0.35" />
          <line x1={railHoleX(0) - 16} y1={RAILN_Y} x2={railHoleX(BB_COLS - 1) + 16} y2={RAILN_Y}
                stroke="var(--water)" strokeWidth="2" opacity="0.4" />
          <text x="36" y={RAILP_Y + 4} fontSize="21.5" fill="var(--current)" fontFamily="IBM Plex Mono, monospace">+</text>
          <text x="36" y={RAILN_Y + 4} fontSize="21.5" fill="var(--water)" fontFamily="IBM Plex Mono, monospace">−</text>

          {/* column connection hints — faint always; the hovered/touched node
              lights up whole, teaching which holes are electrically one point */}
          {Array.from({ length: BB_COLS }, (_, i) => i + 1).map(c => (
            <g key={"colhint" + c}>
              <rect x={holeX(c) - 9} y={TOP_Y0 - 9} width="18" height={5 * ROW_DY - ROW_DY + 18}
                    rx="9" fill={hoverNet === "T" + c ? "var(--current)" : "var(--ink)"}
                    opacity={hoverNet === "T" + c ? 0.16 : 0.04} />
              <rect x={holeX(c) - 9} y={BOT_Y0 - 9} width="18" height={5 * ROW_DY - ROW_DY + 18}
                    rx="9" fill={hoverNet === "B" + c ? "var(--current)" : "var(--ink)"}
                    opacity={hoverNet === "B" + c ? 0.16 : 0.04} />
            </g>
          ))}
          {hoverNet === "RAILP" && (
            <rect x={railHoleX(0) - 16} y={RAILP_Y - 11} width={railHoleX(BB_COLS - 1) - railHoleX(0) + 32} height="22" rx="11" fill="var(--current)" opacity="0.14" />
          )}
          {hoverNet === "RAILN" && (
            <rect x={railHoleX(0) - 16} y={RAILN_Y - 11} width={railHoleX(BB_COLS - 1) - railHoleX(0) + 32} height="22" rx="11" fill="var(--water)" opacity="0.16" />
          )}

          {/* holes */}
          {holeCircles.map(h => {
            const { x, y } = holePos(h.id);
            const isPending = Array.isArray(pending) && pending.includes(h.id);
            return (
              <g key={h.id} onClick={() => tapHole(h.id)} style={{ cursor: tool ? "pointer" : "default" }}
                 onPointerEnter={() => setHoverNet(baseNet(h.id))}
                 onPointerLeave={() => setHoverNet(null)}>
                <circle cx={x} cy={y} r="15" fill="transparent" />
                <circle cx={x} cy={y} r="6"
                        fill={isPending ? "var(--current)" : "var(--bg)"}
                        stroke={h.rail === "p" ? "var(--current)" : h.rail === "n" ? "var(--water)" : "var(--rule-strong)"}
                        strokeWidth="1.5" />
              </g>
            );
          })}

          {/* placed parts — remove mode deletes; probe mode probes; normal mode opens a tap menu */}
          {parts.map((p, i) => (
            removeMode
              ? <g key={"rm" + i} className="bb-removable"
                   onClickCapture={(e) => { e.stopPropagation(); e.preventDefault(); removePart(i); }}>
                  {partGlyph(p, i)}
                </g>
              : probeMode
              ? <g key={"pb" + i} style={{ cursor: "crosshair" }}
                   onClickCapture={(e) => { e.stopPropagation(); e.preventDefault(); setProbeIdx(i); }}>
                  {partGlyph(p, i)}
                </g>
              : moveIdx === i
              ? <g key={"mv" + i}>{partGlyph(p, i)}</g>
              : <BBPlacedPart key={"pt" + i}
                   onTap={() => { if (p.type === "switch") toggleSwitch(i); else { setMenu({ i }); setMoveIdx(null); } }}
                   onLong={() => { setMenu({ i }); setMoveIdx(null); }}>
                  {partGlyph(p, i)}
                </BBPlacedPart>
          ))}

          {/* drag-a-leg: handles on the part being moved + live snap target */}
          {moveIdx != null && parts[moveIdx] && (() => {
            const p = parts[moveIdx];
            const legs = [["a", 0], ["b", 1]]; if (p.c != null) legs.push(["c", 2]);
            return (
              <g key="movelayer">
                {dragHint && (() => { const q = holePos(dragHint); return (
                  <circle cx={q.x} cy={q.y} r="13" fill="none" stroke="var(--current)" strokeWidth="2.5" />
                ); })()}
                {legs.map(([f, li]) => { const q = holePos(p[f]); return (
                  <circle key={f} cx={q.x} cy={q.y} r="11"
                          fill="var(--current)" fillOpacity="0.22" stroke="var(--current)" strokeWidth="2.5"
                          style={{ cursor: "grab", touchAction: "none" }}
                          onPointerDown={onLegDown(moveIdx, li)} />
                ); })}
              </g>
            );
          })()}

          {/* tap-a-part action menu (Move / Delete) */}
          {menu && parts[menu.i] && (() => {
            const p = parts[menu.i];
            const A = holePos(p.a), B = holePos(p.b);
            const cx = p.c != null ? (A.x + B.x + holePos(p.c).x) / 3 : (A.x + B.x) / 2;
            const cy = p.c != null ? (A.y + B.y + holePos(p.c).y) / 3 : (A.y + B.y) / 2;
            const isSw = p.type === "switch";
            const hasValue = (p.type === "resistor" && !p.pot) || p.type === "capacitor";
            // dynamic button layout — Value button only where it makes sense
            const items = [];
            if (isSw) items.push({ w: 58, label: switchStates[menu.i] ? "Open" : "Close", fill: "var(--ink)", on: () => { toggleSwitch(menu.i); setMenu(null); } });
            if (hasValue) items.push({ w: 62, label: kids ? "Value" : "Value", fill: "var(--current-deep, #b5651d)", on: () => { setValuePick(menu.i); setMenu(null); } });
            items.push({ w: isSw ? 60 : (hasValue ? (kids ? 84 : 60) : 78), label: kids ? "Move legs" : "Move", fill: "var(--water)", on: () => { setMoveIdx(menu.i); setMenu(null); } });
            items.push({ w: 54, label: kids ? "Trash" : "Delete", fill: "oklch(0.55 0.17 35)", on: () => { removePart(menu.i); setMenu(null); } });
            const pad = 7, gap = 5, mh = 40;
            const mw = pad * 2 + items.reduce((s, it) => s + it.w, 0) + gap * (items.length - 1);
            const mx = Math.max(6, Math.min(BB_W - mw - 6, cx - mw / 2)), my = Math.max(6, cy - mh - 18);
            const btn = (x, w, label, fill, onClick) => (
              <g style={{ cursor: "pointer" }} onClickCapture={(e) => { e.stopPropagation(); e.preventDefault(); onClick(); }}>
                <rect x={x} y={my + 6} width={w} height={mh - 12} rx="6" fill={fill} />
                <text x={x + w / 2} y={my + mh / 2 + 1} textAnchor="middle" dominantBaseline="middle"
                      fontFamily="IBM Plex Mono, monospace" fontSize="13" fill="#fff">{label}</text>
              </g>
            );
            let ax = mx + pad;
            return (
              <g key="partmenu">
                <rect x={mx} y={my} width={mw} height={mh} rx="9" fill="var(--bg-card)" stroke="var(--rule-strong)" strokeWidth="1.5" />
                {items.map((it, k) => { const x = ax; ax += it.w + gap; return <React.Fragment key={k}>{btn(x, it.w, it.label, it.fill, it.on)}</React.Fragment>; })}
              </g>
            );
          })()}

          {/* scrollable Value picker — native-scroll HTML inside a foreignObject,
              anchored in board coords. Tap a value to set it. */}
          {valuePick != null && parts[valuePick] && (() => {
            const p = parts[valuePick];
            const isR = p.type === "resistor";
            const opts = isR ? BB_R_VALUES : BB_C_VALUES;
            const A = holePos(p.a), B = holePos(p.b);
            const cx = (A.x + B.x) / 2, cy = (A.y + B.y) / 2;
            const pw = 138, ph = 168, rowH = 32;
            const px = Math.max(6, Math.min(BB_W - pw - 6, cx - pw / 2));
            const py = Math.max(6, Math.min(BB_H - ph - 6, cy - ph / 2));
            const cur = isR ? (p.R || 330) : (p.uF || Math.round((p.C || 2) * 235));
            const setVal = (v) => {
              // keep the board powered — the live sim re-solves with the new value
              // so you SEE the change (brighter LED, slower charge) without re-powering.
              setParts(ps => ps.map((q, j) => j !== valuePick ? q
                : (isR ? { ...q, R: v } : { ...q, uF: v, C: +(v / 235).toFixed(4) })));
              setValuePick(null);
            };
            return (
              <foreignObject x={px} y={py} width={pw} height={ph}>
                <div onClick={(e) => e.stopPropagation()}
                     style={{ width: "100%", height: "100%", boxSizing: "border-box", display: "flex", flexDirection: "column",
                              background: "var(--bg-card)", border: "1.5px solid var(--rule-strong)", borderRadius: 11,
                              overflow: "hidden", boxShadow: "0 8px 22px rgba(0,0,0,0.22)", fontFamily: "'IBM Plex Mono', monospace" }}>
                  <div style={{ padding: "8px 10px 6px", fontSize: 10, letterSpacing: "0.14em", textTransform: "uppercase",
                                color: "var(--ink-soft)", borderBottom: "1px solid var(--rule)", flex: "0 0 auto" }}>
                    {isR ? (kids ? "How big a squeeze?" : "Resistance") : (kids ? "How big a bucket?" : "Capacitance")}
                  </div>
                  <div style={{ overflowY: "auto", flex: "1 1 auto", WebkitOverflowScrolling: "touch" }}
                       ref={(el) => { if (el && !el.__did) { el.__did = true; const idx = Math.max(0, opts.indexOf(cur)); el.scrollTop = Math.max(0, idx * rowH - 56); } }}>
                    {opts.map(v => {
                      const on = v === cur;
                      return (
                        <button key={v} onClick={() => setVal(v)}
                                style={{ display: "block", width: "100%", height: rowH, border: "none", cursor: "pointer",
                                         textAlign: "left", padding: "0 12px", fontFamily: "inherit", fontSize: 13.5,
                                         background: on ? "var(--water)" : "transparent", color: on ? "#fff" : "var(--ink)",
                                         fontWeight: on ? 600 : 400 }}>
                          {isR ? bbFmtR(v) : v + " µF"}
                        </button>
                      );
                    })}
                  </div>
                </div>
              </foreignObject>
            );
          })()}

          {/* probe marker on the probed part */}
          {probeMode && probeIdx != null && parts[probeIdx] && (() => {
            const p = parts[probeIdx];
            const A = holePos(p.a), B = holePos(p.b);
            const mx = p.c ? (A.x + B.x + holePos(p.c).x) / 3 : (A.x + B.x) / 2;
            const my = p.c ? (A.y + B.y + holePos(p.c).y) / 3 : (A.y + B.y) / 2;
            return (
              <g pointerEvents="none">
                <circle cx={mx} cy={my} r="22" fill="none" stroke="#1f8a5b" strokeWidth="2.5" strokeDasharray="5 4" />
                <line x1={mx + 16} y1={my - 16} x2={mx + 34} y2={my - 34} stroke="#1f8a5b" strokeWidth="3" strokeLinecap="round" />
                <circle cx={mx + 38} cy={my - 38} r="6" fill="#1f8a5b" />
              </g>
            );
          })()}

          {/* pending leg markers */}
          {Array.isArray(pending) && pending.map((pid) => { const P = holePos(pid); return (
            <circle key={pid} cx={P.x} cy={P.y} r="11" fill="none" stroke="var(--current)" strokeWidth="2" strokeDasharray="3 3" />
          ); })}

          {/* charge-flow overlay (Watch the charges) */}
          {showCharges && window.ChargeFlow &&
            <window.ChargeFlow parts={parts} switchStates={switchStates} live={showCharges} result={result} kids={kids} />}
        </svg>
        {live && tested && liveSim.ready && window.BBScope && scopeProbes.length > 0 && (
          <div style={{ marginTop: 12 }}>
            <div className="eyebrow" style={{ marginBottom: 6 }}>{kids ? "the scope · voltage over time" : "live scope · node voltage vs. time"}</div>
            <window.BBScope height={170}
              traces={scopeProbes.map(p => ({ label: p.label, color: p.color, samples: probeBufs[p.id] || [] }))} />
          </div>
        )}
      </div>

      <div className="bb-side">
        <div className="bb-goal">
          <div className="eyebrow">{kids ? "Your mission" : "Goal"}</div>
          <p>{kids
            ? (goalKids || "Build a circuit that lights the LED — without popping it! Use the battery, a resistor to calm the flow, the LED, and jumper wires.")
            : (goalAdult || "Light the LED safely: battery → resistor → LED → back to battery, using the rails and columns. Remember the LED needs current-limiting.")}</p>
        </div>

        {valueBin ? (
          <div className="bb-parts">
            {valueBin}
            <div className="bb-tools bb-tools-grouped">
              {tools.map(tl => {
                const atLimit = (counts[tl.id] || 0) >= limit[tl.id];
                const pol = BB_POLARITY[tl.id];
                return (
                  <button key={tl.id}
                          className={`bb-tool ${tool === tl.id ? "sel" : ""} ${atLimit ? "full" : ""}`}
                          onClick={() => { setTool(tool === tl.id ? null : tl.id); setPending(null); }}>
                    <span className="bb-tool-label">{tl.label}</span>
                    <span className="bb-tool-meta">
                      {pol && <span className={`bb-tool-polar ${pol.warn ? "warn" : ""}`}>{pol.tag}</span>}
                      <span className="bb-tool-count">{counts[tl.id] || 0}/{limit[tl.id]}</span>
                    </span>
                  </button>
                );
              })}
            </div>
          </div>
        ) : (
          <div className="bb-tools">
            {tools.map(tl => {
              const atLimit = (counts[tl.id] || 0) >= limit[tl.id];
              const pol = BB_POLARITY[tl.id];
              return (
                <button key={tl.id}
                        className={`bb-tool ${tool === tl.id ? "sel" : ""} ${atLimit ? "full" : ""}`}
                        onClick={() => { setTool(tool === tl.id ? null : tl.id); setPending(null); }}>
                  <span className="bb-tool-label">{tl.label}</span>
                  <span className="bb-tool-meta">
                    {pol && <span className={`bb-tool-polar ${pol.warn ? "warn" : ""}`}>{pol.tag}</span>}
                    <span className="bb-tool-count">{counts[tl.id] || 0}/{limit[tl.id]}</span>
                  </span>
                </button>
              );
            })}
          </div>
        )}

        {tool && (
          <div className="bb-prompt">
            {pending
              ? (kids ? `Now tap hole ${pending.length + 1}.` : `Tap hole ${pending.length + 1} to place the next leg.`)
              : `Tap a hole to place the ${tool}'s first leg. (${tools.find(t => t.id === tool).hint})`}
          </div>
        )}

        <div className="bb-actions">
          <button className="bb-btn" onClick={undo} disabled={!parts.length}>Undo</button>
          <button className={`bb-btn ${removeMode ? "danger-on" : ""}`}
                  onClick={() => { setRemoveMode(m => !m); setProbeMode(false); setTool(null); setPending(null); }}
                  disabled={!parts.length && !removeMode}
                  title={kids ? "Tap a part on the board to take it off" : "Then click any placed part to delete just that part"}>
            {removeMode ? "✕ Done removing" : "Remove a part"}
          </button>
          <button className={`bb-btn ${probeMode ? "danger-on" : ""}`}
                  style={probeMode ? { borderColor: "#1f8a5b", color: "#1f8a5b" } : undefined}
                  onClick={() => { setProbeMode(m => !m); setRemoveMode(false); setTool(null); setPending(null); setProbeIdx(null); }}
                  disabled={!parts.length && !probeMode}
                  title={kids ? "Tap any part to measure it, like a real meter" : "Click any placed part to read V / I / P at that point, oscilloscope-style"}>
            {probeMode ? "◉ Done probing" : "Probe"}
          </button>
          <button className="bb-btn" onClick={clear} disabled={!parts.length}>Clear</button>
          <button className={`bb-btn primary ${tested ? "on" : ""}`}
                  onClick={() => {
                    if (tested) { setTested(false); setArmed(false); }
                    else { setTested(true); setArmed(false); requestAnimationFrame(() => requestAnimationFrame(() => setArmed(true))); }
                  }}
                  disabled={!parts.length}>
            {tested ? "Power off" : (kids ? "Power on!" : "Power on")}
          </button>
        </div>

        {verdict && (
          <div className={`bb-verdict ${verdict[0]}`}>
            <span className="v-icon">{verdict[0] === "pass" ? "✓" : verdict[0] === "fail" ? "✕" : "!"}</span>
            <span>{verdict[1]}</span>
          </div>
        )}

        {/* ── probe readout — a pocket oscilloscope/multimeter (m0143) ── */}
        {probeMode && (() => {
          const panel = (body) => (
            <div className="bb-probe" style={{ background: "#0c1d14", border: "2px solid #1f8a5b", borderRadius: 10,
                          padding: "12px 16px", marginTop: 0, fontFamily: "IBM Plex Mono, monospace" }}>
              <div style={{ fontSize: 10.5, letterSpacing: "0.14em", color: "#5f8f72", marginBottom: 8 }}>
                PROBE {tested ? "· BOARD LIVE" : "· BOARD UNPOWERED"}
              </div>
              {body}
            </div>
          );
          if (probeIdx == null || !parts[probeIdx]) {
            return panel(<div style={{ color: "#7fae90", fontSize: 13 }}>
              {kids ? "Tap any part on the board to measure it!" : "Click any placed part to read it."}
            </div>);
          }
          const p = parts[probeIdx];
          const ledInfo = result.leds && result.leds[probeIdx];
          const live = tested;
          // charge fraction of a timed (capacitor) circuit (lifted to component
          // scope as rcFrac) — 1 (settled) for every steady-DC circuit.
          // total loop current: a top-level I (single-loop sims) OR the sum of
          // lit branch currents (multi-LED mode returns state "multi" + leds[]).
          // a fault/no-flow state means no current; anything else that reports a
          // positive I or lit LEDs is conducting (blocklist beats whitelist so new
          // engines/states don't silently drop the jumper's "I through" reading).
          const noFlow = { "no-batt": 1, "short": 1, "pop": 1, "cooked": 1, "open": 1,
                           "switch-open": 1, "backwards": 1, "no-diode": 1, "no-tx": 1,
                           "cap-series": 1, "reverse": 1 };
          let loopI = live && !noFlow[result.state] && result.I > 0 ? result.I : null;
          if (loopI == null && live && !noFlow[result.state] && result.leds) {
            const tot = Object.values(result.leds).reduce((a, L) => a + (L && L.on ? (L.I || 0) : 0), 0);
            if (tot > 0) loopI = tot;
          }
          const R = p.R || 330;
          const rows = [];
          let name = p.type;
          // Prefer the LIVE MNA engine's SOLVED values over the topological
          // verdict's approximations: real branch current + real voltage across
          // the probed part, plus the real node voltage at each terminal. This is
          // what makes the board "actually solve the voltages" (dividers, the
          // inverter's collector swing, loaded taps — all read true).
          const lv = (liveOn && live) ? liveSim : null;
          const liveI = lv && lv.current[probeIdx] != null ? Math.abs(lv.current[probeIdx]) * 1000 : null;   // mA
          const liveVacross = lv && lv.voltage[probeIdx] != null ? Math.abs(lv.voltage[probeIdx]) : null;     // V
          const Va = lv && p.a != null ? lv.probe(p.a) : null;
          const Vb = lv && p.b != null ? lv.probe(p.b) : null;
          if (p.type === "battery") {
            name = kids ? "Battery · the pusher" : "Battery · source";
            rows.push([kids ? "push" : "V", "9.0 V"]);
            const Ib = liveI != null ? liveI : loopI;
            if (Ib) { rows.push([kids ? "flow" : "I", Ib.toFixed(1) + " mA"]); rows.push([kids ? "work rate" : "P out", (9 * Ib).toFixed(0) + " mW"]); }
          } else if (p.type === "resistor") {
            name = (kids ? "Resistor · " : "Resistor · ") + bbFmtR(R);
            rows.push(["R", bbFmtR(R)]);
            const Ir = liveI != null ? liveI : loopI;
            if (Ir) {
              const v = liveVacross != null ? liveVacross : R * Ir / 1000;
              rows.push([kids ? "flow through" : "I", Ir.toFixed(1) + " mA"]);
              rows.push([kids ? "push used up" : "V across", v.toFixed(2) + " V"]);
              if (Va != null && Vb != null && !kids) rows.push(["nodes", Va.toFixed(2) + " → " + Vb.toFixed(2) + " V"]);
              rows.push([kids ? "heat made" : "P (heat)", (v * Ir).toFixed(1) + " mW"]);
            } else if (live && result.leds) {
              rows.push(["I", kids ? "probe a light for its branch flow" : "probe an LED for branch current"]);
            }
          } else if (p.type === "led") {
            name = kids ? "LED · the load" : "LED · load";
            const IiSteady = liveI != null ? liveI : (ledInfo ? ledInfo.I : loopI);
            const Ii = (liveI != null) ? liveI : (IiSteady != null ? IiSteady * rcFrac : IiSteady);   // ramps up as a parallel cap charges
            const on = live && (liveI != null ? liveI > 0.05 : (ledInfo ? ledInfo.on : result.state === "lit"));
            if (on && Ii) {
              rows.push([kids ? "push used" : "V across", (liveVacross != null ? liveVacross : 2.0).toFixed(2) + " V"]);
              rows.push([kids ? "flow" : "I", Ii.toFixed(1) + " mA"]);
              rows.push([kids ? "light + warmth" : "P", ((liveVacross != null ? liveVacross : 2) * Ii).toFixed(0) + " mW"]);
              if (live && result.timed && rcFrac < 0.98) rows.push([kids ? "watch" : "note", kids ? "brightening as the bucket fills" : "ramping up as the capacitor charges"]);
              else if (Ii < 4) rows.push([kids ? "how bright" : "note", kids ? "barely glowing — less squeeze (smaller resistor) = brighter" : "conducting but faint — lower the resistance for a brighter glow"]);
            } else if (live && Ii && Ii > 0.05) {
              // there IS a path, current is just too small to call "on"
              rows.push([kids ? "flow" : "I", Ii.toFixed(2) + " mA"]);
              rows.push(["state", kids ? "too little flow to glow — use a smaller resistor" : `only ${Ii.toFixed(2)} mA — below visible; lower the resistance`]);
            } else rows.push(["state", live ? (kids ? "dark — no path to it" : "dark — no forward path") : (kids ? "asleep — power the board!" : "unpowered")]);
          } else if (p.type === "wire") {
            name = "Jumper";
            rows.push(["R", "≈ 0 Ω"]); rows.push([kids ? "push used" : "V across", "≈ 0 V"]);
            if (loopI) rows.push([kids ? "flow through" : "I (through)", loopI.toFixed(1) + " mA"]);
            else if (live) rows.push([kids ? "flow through" : "I (through)", "0 mA — no current"]);
            rows.push([kids ? "the idea" : "note", kids ? "a wire uses no push — but the flow still runs through it" : "ideal wire: 0 Ω, 0 V drop — yet it carries the full loop current"]);
          } else if (p.type === "switch") {
            const on = !!switchStates[probeIdx];
            name = (kids ? "Switch · " : "Switch · ") + (on ? "closed (1)" : "open (0)");
            rows.push(on ? [kids ? "push used" : "V across", "≈ 0 V — flow passes"] : [kids ? "push across" : "V across", kids ? "ALL of it — it blocks the loop" : "≈ full supply — blocks the loop"]);
          } else if (p.type === "capacitor") {
            name = (kids ? "Capacitor · " : "Capacitor · ") + bbFmtUF(p);
            rows.push(["C", bbFmtUF(p)]);
            if (live && result.timed) {
              const Icap = (result.I || 0) * (1 - rcFrac);    // charging current: high → 0
              rows.push([kids ? "how full" : "charge", Math.round(rcFrac * 100) + "%"]);
              rows.push([kids ? "flow into it" : "I (charging)", Icap.toFixed(1) + " mA"]);
              rows.push(rcFrac > 0.98
                ? [kids ? "now" : "settled", kids ? "full — no flow goes in anymore" : "charged — current has stopped (a full cap blocks DC)"]
                : [kids ? "watch" : "note", kids ? "filling — the flow keeps dropping" : "current decays as it fills · \u03c4 = R·C"]);
            } else if (live) {
              rows.push([kids ? "what it's doing" : "behavior", kids ? "not in a filling loop" : "no charging path (DC-blocked or open)"]);
            }
          } else if (p.type === "diode") {
            name = kids ? "Diode · one-way valve" : "Diode";
            if (loopI) { rows.push([kids ? "push used" : "V across", "≈ 0.7 V"]); rows.push([kids ? "flow" : "I", loopI.toFixed(1) + " mA"]); }
            else rows.push(["passes", kids ? "only the ▷ way" : "▷ direction only"]);
          } else if (p.type === "transistor") {
            name = kids ? "Transistor · the magic valve" : "Transistor · B/C/E";
            rows.push([kids ? "opens at" : "V_BE on", "≈ 0.6–0.7 V"]);
            if (live && "baseDriven" in result) rows.push([kids ? "gate" : "state", result.baseDriven ? (kids ? "trickle ON — gate open" : "base driven — conducting") : (kids ? "no trickle — gate shut" : "no base drive — off")]);
          }
          const scopeSig = bbScopeSignal(p, result, live, rcFrac);
          return panel(
            <>
              <div style={{ color: "#a8d4b6", fontSize: 13.5, marginBottom: 6 }}>{name}</div>
              {scopeSig && <ProbeScope signal={scopeSig} kids={kids} />}
              {rows.map((r, j) => (
                <div key={j} style={{ display: "flex", justifyContent: "space-between", gap: 12, fontSize: 12.5, padding: "2.5px 0" }}>
                  <span style={{ color: "#5f8f72" }}>{r[0]}</span>
                  <span style={{ color: "#7dff9a", textAlign: "right" }}>{r[1]}</span>
                </div>
              ))}
              {!live && <div style={{ color: "#5f8f72", fontSize: 11.5, marginTop: 8 }}>{kids ? "Power on to see live numbers!" : "Power the board for live readings."}</div>}
            </>
          );
        })()}
      </div>
    </div>
  );
}

Object.assign(window, { BreadboardBuilder, simulate });
// geometry helpers for the charge-flow overlay (charge-flow.jsx, separate scope)
Object.assign(window, { bbHolePos: holePos, bbBaseNet: baseNet, BB_GEO: { W: BB_W, H: BB_H } });

/* ── simulateMulti: per-LED evaluation for series & parallel (L2·2) ──────
   Each LED is judged on its own forward path POS→…→LED→…→GND, summing series
   resistance and counting series LEDs (each drops ~2 V). Lets parallel LEDs
   light independently and series LEDs share current. */
function simulateMulti(parts) {
  const battery = parts.find(p => p.type === "battery");
  if (!battery) return { state: "no-batt", leds: {} };
  const VB = 9, VF = 2.0;

  const uf = makeUF();
  for (const p of parts) if (p.type === "wire") uf.union(baseNet(p.a), baseNet(p.b));
  const net = (h) => uf.find(baseNet(h));
  const POS = net(battery.a), GND = net(battery.b);
  if (POS === GND) return { state: "short", leds: {} };

  const edges = parts
    .map((p, i) => ({ i, p, a: net(p.a), b: net(p.b) }))
    .filter(e => e.p.type === "resistor" || e.p.type === "led");
  const leds = parts.map((p, i) => ({ p, i })).filter(x => x.p.type === "led");

  const findPath = (targetI, looseTarget) => {
    let best = null; // { sumR, ledCount }
    const dfs = (node, used, sumR, ledCount, usedTarget) => {
      if (node === GND) {
        if (usedTarget && (!best || ledCount < best.ledCount ||
            (ledCount === best.ledCount && sumR < best.sumR))) best = { sumR, ledCount };
        return;
      }
      for (const e of edges) {
        if (used.has(e.i)) continue;
        let nxt = null; const fromA = e.a === node, fromB = e.b === node;
        if (!fromA && !fromB) continue;
        nxt = fromA ? e.b : e.a;
        if (e.p.type === "led") {
          const isTarget = e.i === targetI;
          const allowRev = isTarget && looseTarget;
          if (!fromA && !allowRev) continue;        // LEDs forward only (anode→cathode)
          used.add(e.i);
          dfs(nxt, used, sumR, ledCount + 1, usedTarget || isTarget);
          used.delete(e.i);
        } else {
          used.add(e.i);
          dfs(nxt, used, sumR + (e.p.R || 330), ledCount, usedTarget);
          used.delete(e.i);
        }
      }
    };
    dfs(POS, new Set(), 0, 0, false);
    return best;
  };

  const ledOut = {};
  for (const { i } of leds) {
    const fwd = findPath(i, false);
    if (!fwd) {
      const loose = findPath(i, true);
      ledOut[i] = { on: false, reason: loose ? "backwards" : "open" };
      continue;
    }
    if (fwd.sumR <= 0) { ledOut[i] = { on: false, popped: true, reason: "no-resistor" }; continue; }
    const avail = VB - fwd.ledCount * VF;
    if (avail <= 0) { ledOut[i] = { on: false, reason: "starved" }; continue; }
    const I = (avail / fwd.sumR) * 1000;
    const popped = I > 35;
    ledOut[i] = { on: !popped && I >= 0.8, popped, I, dim: I < 12, faint: I < 4 };
  }

  const litCount = Object.values(ledOut).filter(l => l.on).length;
  const anyPop = Object.values(ledOut).some(l => l.popped);
  const anyBack = Object.values(ledOut).some(l => l.reason === "backwards");
  return { state: "multi", leds: ledOut, litCount, ledTotal: leds.length, anyPop, anyBack };
}

Object.assign(window, { simulateMulti });

/* ── simulatePower: like simulate() but the user picks the resistor VALUE
   and the resistor has a power RATING; we judge whether it cooks (L2·3).
   The resistor tool carries p.R (ohms) and p.rating (watts). */
function simulatePower(parts) {
  const battery = parts.find(p => p.type === "battery");
  if (!battery) return { state: "no-batt" };
  const VB = 9, VF = 2.0;

  const uf = makeUF();
  for (const p of parts) if (p.type === "wire") uf.union(baseNet(p.a), baseNet(p.b));
  const net = (h) => uf.find(baseNet(h));
  const POS = net(battery.a), GND = net(battery.b);
  if (POS === GND) return { state: "short" };

  const edges = parts
    .map((p, i) => ({ i, p, a: net(p.a), b: net(p.b) }))
    .filter(e => e.p.type === "resistor" || e.p.type === "led");

  // strict forward-LED DFS, collecting the resistor edges on the path
  let best = null;
  const dfs = (node, used, sumR, rEdges, hasLED) => {
    if (node === GND) {
      if (hasLED && (!best || sumR < best.sumR)) best = { sumR, rEdges: [...rEdges] };
      return;
    }
    for (const e of edges) {
      if (used.has(e.i)) continue;
      const fromA = e.a === node, fromB = e.b === node;
      if (!fromA && !fromB) continue;
      const nxt = fromA ? e.b : e.a;
      if (e.p.type === "led") {
        if (!fromA) continue;
        used.add(e.i); dfs(nxt, used, sumR, rEdges, true); used.delete(e.i);
      } else {
        used.add(e.i); dfs(nxt, used, sumR + (e.p.R || 0), [...rEdges, e], hasLED); used.delete(e.i);
      }
    }
  };
  dfs(POS, new Set(), 0, [], false);

  if (!best) {
    // detect reversed LED / short for messaging
    let loose = null;
    const dfs2 = (node, used, hasLED, sumR) => {
      if (node === GND) { if (!loose || (hasLED && !loose.hasLED)) loose = { hasLED, sumR }; return; }
      for (const e of edges) {
        if (used.has(e.i)) continue;
        const fromA = e.a === node, fromB = e.b === node;
        if (!fromA && !fromB) continue;
        const nxt = fromA ? e.b : e.a;
        used.add(e.i);
        dfs2(nxt, used, hasLED || e.p.type === "led", sumR + (e.p.type === "resistor" ? (e.p.R||0) : 0));
        used.delete(e.i);
      }
    };
    dfs2(POS, new Set(), false, 0);
    if (!loose) return { state: "open" };
    if (loose.hasLED) return { state: "backwards" };
    return { state: "short", msg: loose.sumR > 0 ? "resistor-only" : "wire-short" };
  }

  if (best.sumR <= 0) return { state: "pop", reason: "no-resistor" };
  const I = (VB - VF) / best.sumR;             // amps
  const ImA = I * 1000;
  // power in each resistor on the path = I²R; compare to its rating
  let cooked = null;
  for (const e of best.rEdges) {
    const Pr = I * I * (e.p.R || 0);
    const rating = e.p.rating || 0.25;
    if (Pr > rating) cooked = { Pr, rating, R: e.p.R };
  }
  if (ImA > 35) return { state: "pop", reason: "too-much", I: ImA };
  if (cooked) return { state: "cooked", ...cooked, I: ImA };
  const ledState = ImA > 30 ? "bright" : ImA >= 12 ? "good" : ImA >= 4 ? "dim" : "weak";
  return { state: "lit", ledState, I: ImA, sumR: best.sumR };
}

Object.assign(window, { simulatePower });

/* ── simulateRC: capacitor charges through R; the LED turns on after a
   delay τ = R·C (applies Ch4). Requires a battery, a resistor, a capacitor,
   and an LED in the loop. Returns { timed, delay, ... } so the builder runs
   a ramp. The capacitor tool carries p.C (in our arbitrary units). */
function simulateRC(parts) {
  const battery = parts.find(p => p.type === "battery");
  if (!battery) return { state: "no-batt" };
  const VB = 9, VF = 2.0;

  const uf = makeUF();
  for (const p of parts) if (p.type === "wire") uf.union(baseNet(p.a), baseNet(p.b));
  const net = (h) => uf.find(baseNet(h));
  const POS = net(battery.a), GND = net(battery.b);
  if (POS === GND) return { state: "short" };

  const edges = parts
    .map((p, i) => ({ i, p, a: net(p.a), b: net(p.b) }))
    .filter(e => e.p.type === "resistor" || e.p.type === "led");
  const caps = parts.filter(p => p.type === "capacitor");

  // forward LED path (battery → R → LED → battery)
  let best = null;
  const dfs = (node, used, sumR, hasLED, hasR) => {
    if (node === GND) { if (hasLED && (!best || sumR < best.sumR)) best = { sumR, hasR }; return; }
    for (const e of edges) {
      if (used.has(e.i)) continue;
      const fromA = e.a === node, fromB = e.b === node;
      if (!fromA && !fromB) continue;
      const nxt = fromA ? e.b : e.a;
      if (e.p.type === "led") {
        if (!fromA) continue;
        used.add(e.i); dfs(nxt, used, sumR, true, hasR); used.delete(e.i);
      } else {
        used.add(e.i); dfs(nxt, used, sumR + (e.p.R || 0), hasLED, true); used.delete(e.i);
      }
    }
  };
  dfs(POS, new Set(), 0, false, false);

  if (!best) {
    let loose = false, anyLED = false;
    const dfs2 = (node, used, hasLED) => {
      if (node === GND) { loose = true; if (hasLED) anyLED = true; return; }
      for (const e of edges) {
        if (used.has(e.i)) continue;
        const fromA = e.a === node, fromB = e.b === node;
        if (!fromA && !fromB) continue;
        const nxt = fromA ? e.b : e.a;
        used.add(e.i); dfs2(nxt, used, hasLED || e.p.type === "led"); used.delete(e.i);
      }
    };
    dfs2(POS, new Set(), false);
    if (!loose) {
      // Would the circuit conduct if the capacitor acted as a wire? If so, a
      // capacitor is sitting in SERIES and blocking the steady current (it
      // charges up, then stops). That's the #1 "my cap won't work" mistake.
      if (caps.length) {
        const ufc = makeUF();
        for (const p of parts) if (p.type === "wire" || p.type === "capacitor") ufc.union(baseNet(p.a), baseNet(p.b));
        const netc = (h) => ufc.find(baseNet(h));
        const ledE = parts.map((p, i) => ({ i, p, a: netc(p.a), b: netc(p.b) })).filter(e => e.p.type === "resistor" || e.p.type === "led");
        const POSc = netc(battery.a), GNDc = netc(battery.b);
        let through = false;
        const dfsc = (node, used, hasLED) => {
          if (through) return;
          if (node === GNDc) { if (hasLED) through = true; return; }
          for (const e of ledE) {
            if (used.has(e.i)) continue;
            const fA = e.a === node, fB = e.b === node; if (!fA && !fB) continue;
            if (e.p.type === "led" && !fA) continue;       // LEDs still one-way
            used.add(e.i); dfsc(fA ? e.b : e.a, used, hasLED || e.p.type === "led"); used.delete(e.i);
          }
        };
        dfsc(POSc, new Set(), false);
        if (through) return { state: "cap-series" };
      }
      return { state: "open" };
    }
    if (anyLED) return { state: "backwards" };
    return { state: "short" };
  }
  if (best.sumR <= 0) return { state: "pop", reason: "no-resistor" };

  const I = ((VB - VF) / best.sumR) * 1000;
  if (I > 35) return { state: "pop", reason: "too-much", I };

  // Does a capacitor sit across the LED (in parallel)? That's what creates the
  // visible turn-on delay: it must connect the LED's two nodes.
  const ledEdge = edges.find(e => e.p.type === "led");
  const capAcross = caps.find(c => {
    const cn = [net(c.a), net(c.b)].sort().join("|");
    const ln = [ledEdge.a, ledEdge.b].sort().join("|");
    return cn === ln;
  });
  if (!capAcross) {
    // lights, but instantly — no delay element
    return { state: "lit-nodelay", I };
  }
  // delay τ = R · C (scaled to feel watchable: ~0.4s per R·C unit)
  const C = capAcross.C || 2;
  const tau = (best.sumR / 330) * C;       // normalize R to the 330Ω baseline
  const delay = Math.max(0.3, Math.min(6, tau));
  const ledState = I > 30 ? "bright" : I >= 12 ? "good" : I >= 4 ? "dim" : "weak";
  return { state: "lit", timed: true, delay, tau, I, ledState };
}

Object.assign(window, { simulateRC });

/* ── simulateLogic: switches gate the circuit (applies Ch5). A switch edge
   only conducts when its switchStates[index] is true. Lights the LED if a
   forward path exists through closed switches + a resistor. */
function simulateLogic(parts, switchStates = {}) {
  const battery = parts.find(p => p.type === "battery");
  if (!battery) return { state: "no-batt" };
  const VB = 9, VF = 2.0;

  const uf = makeUF();
  for (const p of parts) if (p.type === "wire") uf.union(baseNet(p.a), baseNet(p.b));
  const net = (h) => uf.find(baseNet(h));
  const POS = net(battery.a), GND = net(battery.b);
  if (POS === GND) return { state: "short" };

  // device edges: resistor, led, switch (switch conducts only when closed)
  const edges = parts
    .map((p, i) => ({ i, p, a: net(p.a), b: net(p.b) }))
    .filter(e => ["resistor", "led", "switch"].includes(e.p.type));

  const switchesPlaced = parts.filter(p => p.type === "switch").length;

  let best = null;
  const dfs = (node, used, sumR, hasLED) => {
    if (node === GND) { if (hasLED && (!best || sumR < best.sumR)) best = { sumR }; return; }
    for (const e of edges) {
      if (used.has(e.i)) continue;
      const fromA = e.a === node, fromB = e.b === node;
      if (!fromA && !fromB) continue;
      const nxt = fromA ? e.b : e.a;
      if (e.p.type === "switch") {
        if (!switchStates[e.i]) continue;          // open switch blocks
        used.add(e.i); dfs(nxt, used, sumR, hasLED); used.delete(e.i);
      } else if (e.p.type === "led") {
        if (!fromA) continue;
        used.add(e.i); dfs(nxt, used, sumR, true); used.delete(e.i);
      } else {
        used.add(e.i); dfs(nxt, used, sumR + (e.p.R || 330), hasLED); used.delete(e.i);
      }
    }
  };
  dfs(POS, new Set(), 0, false);

  if (best) {
    if (best.sumR <= 0) return { state: "pop", reason: "no-resistor", switchesPlaced };
    const I = ((VB - VF) / best.sumR) * 1000;
    if (I > 35) return { state: "pop", reason: "too-much", I, switchesPlaced };
    const ledState = I > 30 ? "bright" : I >= 12 ? "good" : I >= 4 ? "dim" : "weak";
    return { state: "lit", I, ledState, switchesPlaced };
  }

  // Why not lit? Check if a path exists ignoring switch state (so we can say
  // "a switch is open") vs genuinely open/backwards.
  let pathIfClosed = false, anyLEDloose = false, looseClosed = false;
  const dfs2 = (node, used, hasLED, ignoreSw) => {
    if (node === GND) { looseClosed = true; if (hasLED) anyLEDloose = true; if (ignoreSw) pathIfClosed = true; return; }
    for (const e of edges) {
      if (used.has(e.i)) continue;
      const fromA = e.a === node, fromB = e.b === node;
      if (!fromA && !fromB) continue;
      const nxt = fromA ? e.b : e.a;
      let usedSwitchOpen = ignoreSw;
      if (e.p.type === "switch" && !switchStates[e.i]) usedSwitchOpen = true;
      if (e.p.type === "led" && !fromA) {
        used.add(e.i); dfs2(nxt, used, true, usedSwitchOpen); used.delete(e.i);
        continue;
      }
      used.add(e.i); dfs2(nxt, used, hasLED || e.p.type === "led", usedSwitchOpen); used.delete(e.i);
    }
  };
  dfs2(POS, new Set(), false, false);

  if (switchesPlaced > 0 && pathIfClosed) return { state: "switch-open", switchesPlaced };
  if (anyLEDloose) return { state: "backwards", switchesPlaced };
  if (!looseClosed) return { state: "open", switchesPlaced };
  return { state: "open", switchesPlaced };
}

Object.assign(window, { simulateLogic });

/* ── simulateTransistor: applies Ch6. A transistor has THREE legs encoded as
   a single placed part with holes base(a), collector(b), emitter(c). A small
   base path (through a switch + base resistor from +) turns the transistor on,
   which then conducts collector→emitter, lighting a load LED on the collector.
   We model it as: transistor conducts C→E iff the base node is driven HIGH
   (a closed path from POS to the base node, through any switch states). */
function simulateTransistor(parts, switchStates = {}) {
  const battery = parts.find(p => p.type === "battery");
  if (!battery) return { state: "no-batt" };
  const tx = parts.find(p => p.type === "transistor");
  if (!tx) return { state: "no-tx" };
  const VB = 9, VF = 2.0;

  const uf = makeUF();
  for (const p of parts) if (p.type === "wire") uf.union(baseNet(p.a), baseNet(p.b));
  const net = (h) => uf.find(baseNet(h));
  const POS = net(battery.a), GND = net(battery.b);
  if (POS === GND) return { state: "short" };

  const B = net(tx.a), C = net(tx.b), E = net(tx.c);

  // device edges excluding the transistor (resistor/led/switch)
  const edges = parts
    .map((p, i) => ({ i, p, a: net(p.a), b: net(p.b) }))
    .filter(e => ["resistor", "led", "switch"].includes(e.p.type));

  // Is the base driven high? Path from POS to B through closed switches +
  // (optionally) a resistor, NOT passing through the transistor.
  let baseDriven = false, baseHasR = false, basePathExists = false;
  const dfsBase = (node, used, hasR, ignoreSwitch) => {
    if (node === B) {
      basePathExists = true;
      if (!ignoreSwitch) { baseDriven = true; if (hasR) baseHasR = true; }
      return;
    }
    for (const e of edges) {
      if (used.has(e.i)) continue;
      const fromA = e.a === node, fromB2 = e.b === node;
      if (!fromA && !fromB2) continue;
      const nxt = fromA ? e.b : e.a;
      let openSw = ignoreSwitch;
      if (e.p.type === "switch" && !switchStates[e.i]) openSw = true;
      used.add(e.i);
      dfsBase(nxt, used, hasR || e.p.type === "resistor", openSw);
      used.delete(e.i);
    }
  };
  dfsBase(POS, new Set(), false, false);

  // Collector load path: POS → (resistor + LED) → C, and E → GND.
  let loadBest = null;
  const dfsLoad = (node, used, sumR, hasLED) => {
    if (node === C) { if (hasLED && (!loadBest || sumR < loadBest.sumR)) loadBest = { sumR }; return; }
    for (const e of edges) {
      if (used.has(e.i)) continue;
      const fromA = e.a === node, fromB2 = e.b === node;
      if (!fromA && !fromB2) continue;
      const nxt = fromA ? e.b : e.a;
      if (e.p.type === "switch") { if (!switchStates[e.i]) continue; used.add(e.i); dfsLoad(nxt, used, sumR, hasLED); used.delete(e.i); }
      else if (e.p.type === "led") { if (!fromA) continue; used.add(e.i); dfsLoad(nxt, used, sumR, true); used.delete(e.i); }
      else { used.add(e.i); dfsLoad(nxt, used, sumR + (e.p.R || 330), hasLED); used.delete(e.i); }
    }
  };
  dfsLoad(POS, new Set(), 0, false);

  // Emitter must reach ground.
  const emitterToGnd = (() => {
    let ok = false;
    const dfsE = (node, used) => {
      if (node === GND) { ok = true; return; }
      for (const e of edges) {
        if (used.has(e.i)) continue;
        const fromA = e.a === node, fromB2 = e.b === node;
        if (!fromA && !fromB2) continue;
        if (e.p.type === "switch" && !switchStates[e.i]) continue;
        const nxt = fromA ? e.b : e.a;
        used.add(e.i); dfsE(nxt, used); used.delete(e.i);
      }
    };
    // emitter often wired straight to GND rail
    if (E === GND) return true;
    dfsE(E, new Set());
    return ok;
  })();

  if (!loadBest) return { state: "no-load", baseDriven };
  if (!emitterToGnd) return { state: "no-emitter", baseDriven };
  if (loadBest.sumR <= 0) return { state: "pop", reason: "no-resistor" };

  if (!baseDriven) {
    return { state: basePathExists ? "base-low" : "no-base", baseDriven: false };
  }

  // Transistor ON: load current flows.
  const I = ((VB - VF) / loadBest.sumR) * 1000;
  if (I > 35) return { state: "pop", reason: "too-much", I };
  const ledState = I > 30 ? "bright" : I >= 12 ? "good" : I >= 4 ? "dim" : "weak";

  // Per-LED branch currents across the collector network (POS → load → C), so
  // probing series vs parallel LEDs gives correct, distinct readings. Series
  // LEDs share a branch current (and each extra one drops ~2 V, lowering it);
  // parallel LEDs each get their own branch.
  const ledParts = parts.map((p, i) => ({ p, i })).filter(x => x.p.type === "led");
  const findToC = (targetI, loose) => {
    let best = null;
    const dfs = (node, used, sumR, ledCount, usedTarget) => {
      if (node === C) { if (usedTarget && (!best || ledCount < best.ledCount || (ledCount === best.ledCount && sumR < best.sumR))) best = { sumR, ledCount }; return; }
      for (const e of edges) {
        if (used.has(e.i)) continue;
        const fromA = e.a === node, fromB2 = e.b === node;
        if (!fromA && !fromB2) continue;
        const nxt = fromA ? e.b : e.a;
        if (e.p.type === "switch") { if (!switchStates[e.i]) continue; used.add(e.i); dfs(nxt, used, sumR, ledCount, usedTarget); used.delete(e.i); }
        else if (e.p.type === "led") { const isT = e.i === targetI, allowRev = isT && loose; if (!fromA && !allowRev) continue; used.add(e.i); dfs(nxt, used, sumR, ledCount + 1, usedTarget || isT); used.delete(e.i); }
        else { used.add(e.i); dfs(nxt, used, sumR + (e.p.R || 330), ledCount, usedTarget); used.delete(e.i); }
      }
    };
    dfs(POS, new Set(), 0, 0, false);
    return best;
  };
  const ledOut = {};
  for (const { i } of ledParts) {
    const fwd = findToC(i, false);
    if (!fwd) { ledOut[i] = { on: false, reason: findToC(i, true) ? "backwards" : "open" }; continue; }
    if (fwd.sumR <= 0) { ledOut[i] = { on: false, popped: true, reason: "no-resistor" }; continue; }
    const avail = VB - fwd.ledCount * VF;            // each series LED drops ~2 V
    if (avail <= 0) { ledOut[i] = { on: false, reason: "starved" }; continue; }
    const Ibr = (avail / fwd.sumR) * 1000;
    const popped = Ibr > 35;
    ledOut[i] = { on: !popped && Ibr >= 4, popped, I: Ibr, dim: Ibr < 12 };
  }
  return { state: "lit", I, ledState, baseDriven: true, baseHasR, leds: ledOut };
}

Object.assign(window, { simulateTransistor });

/* ── simulateRectifier: applies Ch7 (diode) + Ch4 (cap). A rough/AC-ish
   source feeds a DIODE (one-way) then a smoothing CAP across the load (an
   LED + resistor). Pass requires: source → diode (forward) → node, cap from
   that node to ground, and the LED load also from that node to ground. The
   diode blocks reverse; the cap smooths. We grade topology, not waveforms. */
function simulateRectifier(parts, switchStates = {}) {
  const battery = parts.find(p => p.type === "battery");   // the "rough source"
  if (!battery) return { state: "no-batt" };
  const diode = parts.find(p => p.type === "diode");
  if (!diode) return { state: "no-diode" };

  const uf = makeUF();
  for (const p of parts) if (p.type === "wire") uf.union(baseNet(p.a), baseNet(p.b));
  const net = (h) => uf.find(baseNet(h));
  const POS = net(battery.a), GND = net(battery.b);
  if (POS === GND) return { state: "short" };

  const dA = net(diode.a), dK = net(diode.b);     // anode, cathode
  // diode must sit between source+ and the rail (anode toward POS)
  const diodeForward = dA === POS;
  const diodeReversed = dK === POS;
  const railNode = diodeForward ? dK : null;
  if (diodeReversed) return { state: "diode-backwards" };
  if (!diodeForward) return { state: "diode-floating" };

  // LED load path from railNode → ... → GND, through a resistor
  const edges = parts.map((p, i) => ({ i, p, a: net(p.a), b: net(p.b) }))
    .filter(e => e.p.type === "resistor" || e.p.type === "led");
  let load = null;
  const dfs = (node, used, sumR, hasLED) => {
    if (node === GND) { if (hasLED && (!load || sumR < load.sumR)) load = { sumR }; return; }
    for (const e of edges) {
      if (used.has(e.i)) continue;
      const fA = e.a === node, fB = e.b === node;
      if (!fA && !fB) continue;
      const nxt = fA ? e.b : e.a;
      if (e.p.type === "led") { if (!fA) continue; used.add(e.i); dfs(nxt, used, sumR, true); used.delete(e.i); }
      else { used.add(e.i); dfs(nxt, used, sumR + (e.p.R || 330), hasLED); used.delete(e.i); }
    }
  };
  dfs(railNode, new Set(), 0, false);
  if (!load) return { state: "no-load", railNode };
  if (load.sumR <= 0) return { state: "pop", reason: "no-resistor" };

  // smoothing cap across the rail (railNode ↔ GND)?
  const caps = parts.filter(p => p.type === "capacitor");
  const capAcross = caps.find(c => {
    const cn = [net(c.a), net(c.b)].sort().join("|");
    const rn = [railNode, GND].sort().join("|");
    return cn === rn;
  });
  const VB = 9, VF = 2.0, VD = 0.7;
  const I = ((VB - VD - VF) / load.sumR) * 1000;
  if (I > 35) return { state: "pop", reason: "too-much", I };
  return {
    state: "lit",
    smoothed: !!capAcross,
    ripple: capAcross ? "smooth" : "rough",
    I, leds: {}, railNode,
  };
}

Object.assign(window, { simulateRectifier });
