/* osc-build.jsx — FREE-BUILD 555 oscillator bench. The learner seats an NE555
   and wires the timing network (RA, RB, C), ties trigger to threshold, and
   hangs an LED on the output — by tapping terminals. The real transient engine
   (osc-engine.jsx) then resolves the nets and STEPS TIME, so a correctly-wired
   astable genuinely oscillates (LED blinks, every pin shows its true waveform)
   and a miswired one doesn't. A live checklist says what's still missing, and
   once it blinks you can reveal the discrete-vs-chip proof (osc-compare.jsx).

   Wiring is terminal-based (V+, GND, the 8 chip pins) rather than raw holes —
   an 8-pin DIP is wired pin-to-pin, not by column/row. Names ob- and OB_ prefixed. */

const { useState: obUseState, useEffect: obUseEffect, useRef: obUseRef, useMemo: obUseMemo } = React;

const OB_VB = 9;
const OB_LED_VF = 2.0;
const OB_LED_R  = 60;     // LED dynamic series (Ω) used inside the engine branch

/* 555 pin metadata (DIP order) */
const OB_PINS = [
  { n: 1, key: "gnd",  label: "GND",  side: "L", row: 3 },
  { n: 2, key: "trig", label: "TRIG", side: "L", row: 2 },
  { n: 3, key: "out",  label: "OUT",  side: "L", row: 1 },
  { n: 4, key: "rst",  label: "RST",  side: "L", row: 0 },
  { n: 5, key: "ctrl", label: "CTRL", side: "R", row: 3 },
  { n: 6, key: "thr",  label: "THR",  side: "R", row: 2 },
  { n: 7, key: "dis",  label: "DIS",  side: "R", row: 1 },
  { n: 8, key: "vcc",  label: "VCC",  side: "R", row: 0 },
];

/* the parts the learner can drop, with preset values */
const OB_PARTBIN = [
  { id: "rA",   type: "res", label: "Resistor", sub: "R_A",  R: 10000, swatch: "R" },
  { id: "rB",   type: "res", label: "Resistor", sub: "R_B",  R: 47000, swatch: "R" },
  { id: "rLed", type: "res", label: "Resistor", sub: "LED",  R: 470,   swatch: "R" },
  { id: "cap",  type: "cap", label: "Capacitor", sub: "C",   C: 1e-6,  swatch: "C" },
  { id: "led",  type: "led", label: "LED",       sub: "",              swatch: "L" },
  { id: "wire", type: "wire", label: "Jumper",   sub: "",              swatch: "—" },
];

const OB_RES_OPTS = [1000, 4700, 10000, 22000, 47000, 100000];
const OB_CAP_OPTS = [1e-7, 1e-6, 1e-5, 4.7e-5];
const obFmtR = (r) => r >= 1000 ? (r % 1000 ? (r / 1000).toFixed(1) : r / 1000) + "k" : r + "Ω";
const obFmtC = (c) => c >= 1e-6 ? (c * 1e6 % 1 ? (c * 1e6).toFixed(1) : c * 1e6) + "µF" : (c * 1e9) + "nF";

/* ── terminal geometry ──────────────────────────────────────────────────── */
const OB_W = 560, OB_H = 460;
const OB_RAILP_Y = 46, OB_RAILN_Y = 414;
const OB_CHIP = { x: 210, y: 150, w: 140, h: 170 };
function obTermPos(id) {
  if (id === "Vp") return { x: 90, y: OB_RAILP_Y };
  if (id === "Gnd") return { x: 90, y: OB_RAILN_Y };
  if (id === "J1") return { x: 150, y: 366 };
  if (id === "J2") return { x: 410, y: 366 };
  const p = OB_PINS.find(p => "P" + p.n === id);
  if (!p) return { x: 0, y: 0 };
  const rowY = OB_CHIP.y + 28 + p.row * ((OB_CHIP.h - 56) / 3);
  return p.side === "L" ? { x: OB_CHIP.x - 20, y: rowY } : { x: OB_CHIP.x + OB_CHIP.w + 20, y: rowY };
}
const OB_TERMINALS = ["Vp", "Gnd", "J1", "J2", ...OB_PINS.map(p => "P" + p.n)];

/* ── net resolution + netlist build ─────────────────────────────────────── */
function obMakeUF() {
  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; };
  return { find: (x) => { ensure(x); return find(x); }, union: (a, b) => { ensure(a); ensure(b); p[find(a)] = find(b); } };
}
function obResolve(parts) {
  const uf = obMakeUF();
  OB_TERMINALS.forEach(t => uf.find(t));
  for (const pt of parts) if (pt.type === "wire") uf.union(pt.a, pt.b);
  const net = (t) => uf.find(t);
  // assign integer node ids; GND net = 0
  const gndRoot = net("Gnd");
  const ids = { [gndRoot]: 0 };
  let next = 1;
  const nodeOf = (t) => {
    const r = net(t);
    if (ids[r] === undefined) ids[r] = next++;
    return ids[r];
  };
  // pin → node
  const pinNode = {};
  OB_PINS.forEach(p => pinNode[p.key] = nodeOf("P" + p.n));
  const netlist = {
    vsources: [{ pos: nodeOf("Vp"), neg: 0, V: OB_VB }],
    resistors: [], caps: [], chips: [{ id: "u1", pins: pinNode }],
  };
  const leds = [];
  for (const pt of parts) {
    if (pt.type === "res") netlist.resistors.push({ a: nodeOf(pt.a), b: nodeOf(pt.b), R: pt.R });
    else if (pt.type === "cap") netlist.caps.push({ a: nodeOf(pt.a), b: nodeOf(pt.b), C: pt.C, id: pt.uid });
    else if (pt.type === "led") {
      // model LED as a series resistor branch in the engine; remember its nodes
      netlist.resistors.push({ a: nodeOf(pt.a), b: nodeOf(pt.b), R: OB_LED_R, _led: pt.uid });
      leds.push({ uid: pt.uid, a: nodeOf(pt.a), b: nodeOf(pt.b) });
    }
  }
  return { netlist, nodeOf, net, leds, pinNode };
}

/* build checklist — what a working astable needs (topology, not waveforms) */
function obChecklist(parts) {
  const { net } = obResolve(parts);
  const same = (a, b) => net(a) === net(b);
  const hasBetween = (type, x, y) => parts.some(p => p.type === type &&
    ((net(p.a) === net(x) && net(p.b) === net(y)) || (net(p.a) === net(y) && net(p.b) === net(x))));
  // nets reachable from a terminal through wires + resistors (not through the LED)
  const reachRW = (start) => {
    const seen = new Set([net(start)]); let grew = true;
    while (grew) {
      grew = false;
      for (const p of parts) {
        if (p.type !== "wire" && p.type !== "res") continue;
        const na = net(p.a), nb = net(p.b);
        if (seen.has(na) && !seen.has(nb)) { seen.add(nb); grew = true; }
        if (seen.has(nb) && !seen.has(na)) { seen.add(na); grew = true; }
      }
    }
    return seen;
  };
  const fromOut = reachRW("P3"), toGnd = reachRW("Gnd");
  const ledOnOut = parts.some(p => p.type === "led" &&
    ((fromOut.has(net(p.a)) && toGnd.has(net(p.b))) || (fromOut.has(net(p.b)) && toGnd.has(net(p.a)))));
  const items = [
    { ok: same("P8", "Vp"), label: "VCC (8) → +9V" },
    { ok: same("P1", "Gnd"), label: "GND (1) → ground" },
    { ok: same("P4", "Vp") || same("P4", "P8"), label: "RST (4) → +9V (chip enabled)" },
    { ok: hasBetween("res", "Vp", "P7"), label: "R_A: +9V → DIS (7)" },
    { ok: hasBetween("res", "P7", "P6"), label: "R_B: DIS (7) → THR (6)" },
    { ok: same("P2", "P6"), label: "TRIG (2) tied to THR (6)" },
    { ok: hasBetween("cap", "P6", "Gnd"), label: "C: THR (6) → ground" },
    { ok: ledOnOut, label: "LED (+ R) from OUT (3) → ground" },
  ];
  return items;
}

/* estimate the true period from the placed timing parts (for display scaling) */
function obEstPeriod(parts) {
  const { net } = obResolve(parts);
  const find = (type, x, y) => parts.find(p => p.type === type &&
    ((net(p.a) === net(x) && net(p.b) === net(y)) || (net(p.a) === net(y) && net(p.b) === net(x))));
  const ra = find("res", "Vp", "P7"), rb = find("res", "P7", "P6"), c = find("cap", "P6", "Gnd");
  if (ra && rb && c) {
    const RA = ra.R, RB = rb.R, C = c.C;
    return { T: 0.693 * (RA + 2 * RB) * C, duty: (RA + RB) / (RA + 2 * RB), f: 1 / (0.693 * (RA + 2 * RB) * C) };
  }
  return { T: 0.04, duty: 0.5, f: 25 };
}

/* ── small glyphs ───────────────────────────────────────────────────────── */
function ObLed({ x, y, on, r = 13 }) {
  return (
    <g style={{ pointerEvents: "none" }}>
      {on && <circle cx={x} cy={y} r={r + 16} fill="var(--current)" opacity="0.16" />}
      {on && <circle cx={x} cy={y} r={r + 7} fill="var(--current)" opacity="0.32" />}
      <circle cx={x} cy={y} r={r} fill={on ? "var(--current)" : "var(--bg-card)"}
              stroke={on ? "var(--current-deep)" : "var(--current-soft)"} strokeWidth="2"
              style={{ transition: "fill 70ms linear" }} />
      {on && <circle cx={x - 4} cy={y - 4} r={r * 0.28} fill="var(--current-soft)" opacity="0.9" />}
    </g>
  );
}
function obPartGlyph(pt, mid) {
  const c = "var(--ink-soft)";
  if (pt.type === "res") return (
    <g><rect x={mid.x - 15} y={mid.y - 7} width="30" height="14" rx="2.5" fill="var(--bg-card)" stroke={c} strokeWidth="1.7" transform={`rotate(${mid.ang} ${mid.x} ${mid.y})`} />
      <text x={mid.x} y={mid.y - 12} textAnchor="middle" fontFamily="IBM Plex Mono, monospace" fontSize="11" fill={c}>{obFmtR(pt.R)}</text></g>
  );
  if (pt.type === "cap") return (
    <g><line x1={mid.x - 7} y1={mid.y - 9} x2={mid.x - 7} y2={mid.y + 9} stroke="var(--water-deep)" strokeWidth="2.4" />
      <line x1={mid.x + 1} y1={mid.y - 9} x2={mid.x + 1} y2={mid.y + 9} stroke="var(--water-deep)" strokeWidth="2.4" />
      <text x={mid.x} y={mid.y - 13} textAnchor="middle" fontFamily="IBM Plex Mono, monospace" fontSize="11" fill="var(--water)">{obFmtC(pt.C)}</text></g>
  );
  if (pt.type === "led") return <ObLed x={mid.x} y={mid.y} on={false} r={10} />;
  return null;
}

/* a routed orthogonal-ish path between two terminals */
function obRoute(a, b) {
  const pa = obTermPos(a), pb = obTermPos(b);
  const midx = (pa.x + pb.x) / 2;
  return { d: `M${pa.x} ${pa.y} L${midx} ${pa.y} L${midx} ${pb.y} L${pb.x} ${pb.y}`,
           mid: { x: midx, y: (pa.y + pb.y) / 2, ang: Math.abs(pa.y - pb.y) > Math.abs(pa.x - pb.x) ? 90 : 0 } };
}

/* ════════════════════════════════════════════════════════════════════════ */
function ObBoard({ kids, onResult }) {
  const [parts, setParts] = obUseState([]);
  const [tool, setTool] = obUseState(null);
  const [pending, setPending] = obUseState(null);     // first terminal tapped
  const [running, setRunning] = obUseState(false);
  const [scopedPin, setScopedPin] = obUseState(3);
  const [resVal, setResVal] = obUseState(10000);
  const [capVal, setCapVal] = obUseState(1e-6);
  const [, force] = obUseState(0);

  const uidRef = obUseRef(1);
  const simRef = obUseRef({ state: null, hist: [], v: null });

  const resolved = obUseMemo(() => obResolve(parts), [parts]);
  const checklist = obUseMemo(() => obChecklist(parts), [parts]);
  const est = obUseMemo(() => obEstPeriod(parts), [parts]);
  const allDone = checklist.every(c => c.ok);

  // reset sim when the circuit changes
  obUseEffect(() => { simRef.current = { state: null, hist: [], v: null }; }, [parts]);

  // the transient run loop
  obUseEffect(() => {
    if (!running) return;
    const dt = Math.max(1e-5, est.T / 600);
    const stepsPerFrame = 4;
    simRef.current.hist = [];          // fresh trace whenever pin/circuit changes
    let raf, acc = 0, last = performance.now();
    const tick = (now) => {
      last = now;
      let st = simRef.current.state || { capV: {}, latch: { u1: 0 }, t: 0 };
      let v = simRef.current.v;
      for (let s = 0; s < stepsPerFrame; s++) {
        const r = oscStep(resolved.netlist, st, dt);
        st = r.state; v = r.v;
      }
      simRef.current.state = st; simRef.current.v = v;
      // sample scoped pin's net voltage
      const node = resolved.pinNode[OB_PINS.find(p => p.n === scopedPin).key];
      const volt = node === 0 ? 0 : (v[node] || 0);
      const h = simRef.current.hist;
      h.push(volt); if (h.length > 240) h.shift();
      force(n => (n + 1) & 0xffff);
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [running, resolved, est.T, scopedPin]);

  // when we stop running, clear history so the next run starts fresh
  obUseEffect(() => { if (!running) simRef.current.hist = []; }, [running]);

  const v = simRef.current.v;
  const chipSpan = v ? (v[resolved.pinNode.vcc] || 0) - (v[resolved.pinNode.gnd] || 0) : 0;
  // LED brightness from the real solved branch current
  let ledOn = false, ledI = 0;
  if (v && resolved.leds.length) {
    const L = resolved.leds[0];
    const va = L.a ? v[L.a] : 0, vb = L.b ? v[L.b] : 0;
    ledI = Math.max(0, (va - vb) / OB_LED_R) * 1000; // mA through the LED branch
    ledOn = ledI > 0.5;
  }

  obUseEffect(() => { onResult && onResult({ allDone, running, f: est.f }); }, [allDone, running, est.f]);

  const tapTerminal = (id) => {
    if (running) return;
    if (!tool) return;
    if (tool === "wire" || OB_PARTBIN.find(p => p.id === tool)?.type === "wire") {
      if (!pending) { setPending(id); return; }
      if (pending === id) { setPending(null); return; }
      addPart("wire", pending, id); setPending(null); return;
    }
    const def = OB_PARTBIN.find(p => p.id === tool);
    if (!pending) { setPending(id); return; }
    if (pending === id) { setPending(null); return; }
    if (def.type === "res") addPart("res", pending, id, { R: resVal });
    else if (def.type === "cap") addPart("cap", pending, id, { C: capVal });
    else if (def.type === "led") addPart("led", pending, id);
    setPending(null);
  };
  const addPart = (type, a, b, extra = {}) => {
    setParts(ps => [...ps, { type, a, b, uid: "p" + (uidRef.current++), ...extra }]);
  };
  const removePart = (uid) => setParts(ps => ps.filter(p => p.uid !== uid));
  const clearAll = () => { setParts([]); setPending(null); setRunning(false); };

  const loadPreset = () => {
    const u = () => "p" + (uidRef.current++);
    setRunning(false);
    setParts([
      { type: "wire", a: "P8", b: "Vp", uid: u() },
      { type: "wire", a: "P4", b: "Vp", uid: u() },
      { type: "wire", a: "P1", b: "Gnd", uid: u() },
      { type: "res", a: "Vp", b: "P7", R: 10000, uid: u() },
      { type: "res", a: "P7", b: "P6", R: 47000, uid: u() },
      { type: "wire", a: "P2", b: "P6", uid: u() },
      { type: "cap", a: "P6", b: "Gnd", C: 1e-6, uid: u() },
      { type: "res", a: "P3", b: "J1", R: 470, uid: u() },
      { type: "led", a: "J1", b: "Gnd", uid: u() },
    ]);
  };

  const selPart = OB_PARTBIN.find(p => p.id === tool);

  return (
    <div className="ob-wrap">
      {/* parts bin */}
      <div className="ob-bin">
        <span className="ob-bin-title">{kids ? "parts — pick one, then tap two dots" : "drop a part: pick it, then tap two terminals"}</span>
        <div className="ob-bin-row">
          {OB_PARTBIN.map(p => (
            <button key={p.id} className={"ob-chip" + (tool === p.id ? " on" : "")}
                    disabled={running} onClick={() => { setTool(p.id); setPending(null); }}>
              <span className="ob-chip-sw">{p.swatch}</span>
              <span>{p.label}{p.sub ? <em> {p.sub}</em> : ""}</span>
            </button>
          ))}
        </div>
        {selPart && selPart.type === "res" && (
          <div className="ob-vals"><span>value:</span>{OB_RES_OPTS.map(r => (
            <button key={r} className={"ob-val" + (resVal === r ? " on" : "")} onClick={() => setResVal(r)}>{obFmtR(r)}</button>))}</div>
        )}
        {selPart && selPart.type === "cap" && (
          <div className="ob-vals"><span>value:</span>{OB_CAP_OPTS.map(c => (
            <button key={c} className={"ob-val" + (capVal === c ? " on" : "")} onClick={() => setCapVal(c)}>{obFmtC(c)}</button>))}</div>
        )}
        <div className="ob-bin-actions">
          <button className="ob-act" disabled={running} onClick={loadPreset}>{kids ? "fill it in for me" : "auto-wire (show me one)"}</button>
          <button className="ob-act" disabled={running} onClick={clearAll}>clear</button>
          {pending && <span className="ob-hint">tap the second terminal… <button className="ob-act ghost" onClick={() => setPending(null)}>cancel</button></span>}
        </div>
      </div>

      <div className="ob-stage">
        {/* the board */}
        <svg viewBox={`0 0 ${OB_W} ${OB_H}`} width="100%" height="auto" className="ob-svg" preserveAspectRatio="xMidYMid meet">
          {/* rails */}
          <g onClick={() => tapTerminal("Vp")} style={{ cursor: tool && !running ? "pointer" : "default" }}>
            <rect x="20" y={OB_RAILP_Y - 12} width={OB_W - 40} height="24" rx="6"
                  fill={pending === "Vp" ? "var(--current)" : "var(--bg-deeper)"} stroke="var(--current)" strokeWidth="1.5" opacity="0.9" />
            <text x="34" y={OB_RAILP_Y + 5} fontFamily="IBM Plex Mono, monospace" fontSize="14" fill="var(--current)" fontWeight="600">+9V</text>
          </g>
          <g onClick={() => tapTerminal("Gnd")} style={{ cursor: tool && !running ? "pointer" : "default" }}>
            <rect x="20" y={OB_RAILN_Y - 12} width={OB_W - 40} height="24" rx="6"
                  fill={pending === "Gnd" ? "var(--water)" : "var(--bg-deeper)"} stroke="var(--water)" strokeWidth="1.5" opacity="0.9" />
            <text x="34" y={OB_RAILN_Y + 5} fontFamily="IBM Plex Mono, monospace" fontSize="14" fill="var(--water)" fontWeight="600">GND</text>
          </g>

          {/* placed parts */}
          {parts.map((pt) => {
            const rt = obRoute(pt.a, pt.b);
            const isWire = pt.type === "wire";
            const live = running && v;
            let stroke = isWire ? "var(--ink-faint)" : "var(--ink-soft)";
            if (isWire) {
              // colour wires by what rail they touch
              if (pt.a === "Vp" || pt.b === "Vp") stroke = "var(--current-soft)";
              else if (pt.a === "Gnd" || pt.b === "Gnd") stroke = "var(--water-soft)";
            }
            return (
              <g key={pt.uid} onClick={() => !running && removePart(pt.uid)} style={{ cursor: running ? "default" : "pointer" }}>
                <path d={rt.d} fill="none" stroke={stroke} strokeWidth={isWire ? 2.4 : 2} strokeLinejoin="round" strokeLinecap="round"
                      strokeDasharray={pt.type === "cap" ? "none" : "none"} />
                {!isWire && obPartGlyph(pt, rt.mid)}
                {pt.type === "led" && <ObLed x={rt.mid.x} y={rt.mid.y} on={running && ledOn} r={11} />}
              </g>
            );
          })}

          {/* tie-point junctions (for series chains like R→LED) */}
          {["J1", "J2"].map(j => {
            const pos = obTermPos(j);
            const sel = pending === j;
            const used = parts.some(p => p.a === j || p.b === j);
            return (
              <g key={j} onClick={() => tapTerminal(j)} style={{ cursor: tool && !running ? "pointer" : "default" }}>
                <circle cx={pos.x} cy={pos.y} r={sel ? 7 : 5.5}
                        fill={sel ? "var(--current)" : (used ? "var(--bg-card)" : "var(--bg-deeper)")}
                        stroke="var(--ink-soft)" strokeWidth="1.6" strokeDasharray={used ? "none" : "3 3"} />
                <text x={pos.x} y={pos.y + 20} textAnchor="middle" fontFamily="IBM Plex Mono, monospace"
                      fontSize="10" fill="var(--ink-faint)">{j} · tie</text>
              </g>
            );
          })}

          {/* the 555 chip */}
          <rect x={OB_CHIP.x} y={OB_CHIP.y} width={OB_CHIP.w} height={OB_CHIP.h} rx="10"
                fill="var(--bg-deeper)" stroke="var(--ink)" strokeWidth="2" />
          <path d={`M${OB_CHIP.x + OB_CHIP.w / 2 - 11} ${OB_CHIP.y} a11 11 0 0 0 22 0`} fill="none" stroke="var(--ink)" strokeWidth="2" />
          <text x={OB_CHIP.x + OB_CHIP.w / 2} y={OB_CHIP.y + OB_CHIP.h / 2 - 4} textAnchor="middle"
                fontFamily="IBM Plex Mono, monospace" fontSize="19" fill="var(--ink)" letterSpacing="0.08em">NE555</text>
          <text x={OB_CHIP.x + OB_CHIP.w / 2} y={OB_CHIP.y + OB_CHIP.h / 2 + 18} textAnchor="middle"
                fontFamily="IBM Plex Mono, monospace" fontSize="11" fill="var(--ink-faint)">
            {running ? (chipSpan > 1 ? "powered" : "no power") : (kids ? "wire me up" : "seat & wire")}
          </text>

          {/* pins */}
          {OB_PINS.map(p => {
            const pos = obTermPos("P" + p.n);
            const sel = pending === "P" + p.n;
            const scoped = scopedPin === p.n;
            const node = resolved.pinNode[p.key];
            const volt = v && node ? (v[node] || 0) : 0;
            const lit = running && volt > 0.55 * (chipSpan || OB_VB);
            const tx = p.side === "L" ? pos.x - 6 : pos.x + 6;
            return (
              <g key={p.n} onClick={() => tapTerminal("P" + p.n)} style={{ cursor: tool && !running ? "pointer" : "default" }}>
                <line x1={p.side === "L" ? OB_CHIP.x : OB_CHIP.x + OB_CHIP.w} y1={pos.y} x2={pos.x} y2={pos.y}
                      stroke={lit ? "var(--current)" : "var(--ink-soft)"} strokeWidth="2.4" />
                <circle cx={pos.x} cy={pos.y} r={sel ? 7 : scoped ? 6 : 5}
                        fill={sel ? "var(--current)" : scoped ? "var(--current-soft)" : "var(--bg-card)"}
                        stroke={scoped ? "var(--current-deep)" : "var(--ink)"} strokeWidth="1.8" />
                <text x={tx} y={pos.y - 8} textAnchor={p.side === "L" ? "end" : "start"}
                      fontFamily="IBM Plex Mono, monospace" fontSize="10.5" fill="var(--ink-soft)">{p.n} {p.label}</text>
              </g>
            );
          })}
        </svg>

        {/* checklist */}
        <div className="ob-side">
          <div className="ob-check-title">{kids ? "what it needs" : "build checklist"}</div>
          <ul className="ob-check">
            {checklist.map((c, i) => (
              <li key={i} className={c.ok ? "ok" : ""}><span className="ob-tick">{c.ok ? "✓" : "○"}</span>{c.label}</li>
            ))}
          </ul>
          <button className={"ob-run" + (allDone ? " ready" : "")} onClick={() => setRunning(r => !r)} disabled={!allDone && !running}>
            {running ? "⏸ stop" : allDone ? "▶ power it up" : "finish the checklist to run"}
          </button>
          {running && (
            <div className="ob-readout">
              <div>f ≈ <b>{est.f < 1 ? est.f.toFixed(2) : est.f.toFixed(1)} Hz</b></div>
              <div>duty ≈ <b>{Math.round(est.duty * 100)}%</b></div>
              <div>LED ≈ <b>{ledI.toFixed(1)} mA</b></div>
            </div>
          )}
        </div>
      </div>

      {/* scope */}
      <div className="ob-scope-card">
        <div className="ob-scope-head">
          <span className="ob-scope-title">{kids ? "scope — tap a pin to watch it" : "oscilloscope · probe any pin"}</span>
          <div className="ob-pinrow">
            {OB_PINS.map(p => (
              <button key={p.n} className={"ob-pinbtn" + (scopedPin === p.n ? " on" : "")} onClick={() => setScopedPin(p.n)}>{p.n} {p.label}</button>
            ))}
          </div>
        </div>
        <ObScope hist={simRef.current.hist} vcc={chipSpan > 1 ? chipSpan : OB_VB} running={running}
                 pin={OB_PINS.find(p => p.n === scopedPin)} kids={kids} />
      </div>
    </div>
  );
}

/* ── scope of the REAL engine voltages ──────────────────────────────────── */
function ObScope({ hist, vcc, running, pin, kids }) {
  const W = 900, H = 220, x0 = 56, x1 = W - 16, y0 = 24, y1 = H - 30;
  const Yv = (v) => y1 - (Math.max(0, Math.min(1, v / vcc))) * (y1 - y0);
  const n = hist.length;
  const pts = hist.map((v, i) => `${x0 + (i / 239) * (x1 - x0)},${Yv(v)}`).join(" ");
  return (
    <svg viewBox={`0 0 ${W} ${H}`} width="100%" height="auto" preserveAspectRatio="xMidYMid meet" className="ob-scope">
      <rect x="2" y="2" width={W - 4} height={H - 4} rx="9" fill="var(--bg-deeper)" stroke="var(--rule)" />
      {[{ v: vcc, t: "Vcc" }, { v: 2 * vcc / 3, t: "⅔" }, { v: vcc / 3, t: "⅓" }, { v: 0, t: "0" }].map((g, i) => (
        <g key={i}>
          <line x1={x0} y1={Yv(g.v)} x2={x1} y2={Yv(g.v)} stroke="var(--rule-strong)"
                strokeDasharray={g.v === 0 || g.v === vcc ? "none" : "4 5"} opacity="0.7" />
          <text x={x0 - 8} y={Yv(g.v) + 4} textAnchor="end" fontFamily="IBM Plex Mono, monospace" fontSize="11" fill="var(--ink-faint)">{g.t}</text>
        </g>
      ))}
      <text x={14} y={20} fontFamily="IBM Plex Mono, monospace" fontSize="13" fill="var(--current-deep)" transform={`rotate(-90 14 ${H / 2})`} style={{ transformOrigin: `14px ${H / 2}px` }}>PIN {pin.n} {pin.label}</text>
      {n > 1 && <polyline points={pts} fill="none" stroke="var(--current)" strokeWidth="2.6" />}
      {!running && <text x={W / 2} y={H / 2} textAnchor="middle" fontFamily="IBM Plex Mono, monospace" fontSize="13" fill="var(--ink-faint)">{kids ? "press play to see the wave" : "power up the circuit to trace this pin"}</text>}
    </svg>
  );
}

Object.assign(window, { ObBoard });

/* ── OscMode — the whole sandbox "Oscillator" mode: free-build first, then a
   reveal that proves the hand-built 555 is the same machine as the discrete
   two-transistor blinker (OscCompareBench from osc-compare.jsx). ──────────── */
function OscMode({ kids }) {
  const [built, setBuilt] = obUseState(false);
  const [showProof, setShowProof] = obUseState(false);
  const onResult = React.useCallback((r) => { if (r && r.running && r.allDone) setBuilt(true); }, []);
  return (
    <div>
      <ObBoard kids={kids} onResult={onResult} />

      <div className="ob-proof">
        {!showProof ? (
          <div className={"ob-proof-tease" + (built ? " unlocked" : "")}>
            <div>
              <div className="eyebrow" style={{ marginBottom: 6 }}>{built ? "nice — it oscillates" : "next: prove it"}</div>
              <p className="marg" style={{ margin: 0, fontSize: 14 }}>
                {kids
                  ? <>You built a blinker from a chip. Want to see it's the <em>same</em> as a blinker made from plain transistors? They keep the same beat.</>
                  : <>You wired a 555 astable from scratch. The same job can be done with two transistors and two caps — the <em>discrete</em> astable. Reveal both side-by-side, on one shared clock, and probe any pin to confirm the waveforms match.</>}
              </p>
            </div>
            <button className="ob-run ready" style={{ width: "auto", whiteSpace: "nowrap" }}
                    onClick={() => setShowProof(true)}>
              {kids ? "show me both →" : "prove it matches the discrete →"}
            </button>
          </div>
        ) : (
          <div className="ob-proof-open">
            <div className="ob-proof-head">
              <div className="eyebrow">the same machine, two ways</div>
              <button className="ob-act" onClick={() => setShowProof(false)}>↑ back to my build</button>
            </div>
            <OscCompareBench kids={kids} />
          </div>
        )}
      </div>
    </div>
  );
}

Object.assign(window, { ObBoard, OscMode });
