/* visuals555.jsx — Level 2 · The Oscillator.
   Two synced sims for "build a blinker from scratch, then meet the chip":
     • AstableScene   — the discrete two-transistor astable multivibrator.
                        Two LEDs flip-flop; period T ≈ 1.38·R·C.
     • Timer555Scene  — the 555 cracked open: divider + 2 comparators +
                        SR flip-flop + discharge transistor + output, with a
                        live scope of the cap ramping between ⅓ and ⅔ Vcc.
   Self-contained SVG. Shares CSS vars + Slider from shared.jsx. All names
   are osc- / t555- prefixed to stay unique in the shared Babel scope. */

const LN2 = Math.log(2);

/* real-time phase 0..1. The TRUE period (seconds) is mapped into a watchable
   window by LOG COMPRESSION — not a hard clamp. A hard clamp pinned every fast
   setting to the same rate, so sliding RA/RB/C below ~0.7 s did nothing visible
   ("blinks fast, never changes"). Compression keeps the rate monotonic in R·C
   across the whole slider range while staying in ~0.45–5.4 s. The caller still
   prints the exact Hz separately. */
function useOscPhase(periodSec, running = true) {
  const [phase, setPhase] = React.useState(0);
  const pRef = React.useRef(0);
  const perRef = React.useRef(periodSec);
  perRef.current = 0.45 + 2.4 * Math.log10(1 + Math.max(0, periodSec) / 0.18);
  React.useEffect(() => {
    if (!running) { return; }
    let raf, last = performance.now();
    const tick = (now) => {
      const dt = (now - last) / 1000; last = now;
      pRef.current = (pRef.current + dt / perRef.current) % 1;
      setPhase(pRef.current);
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [running]);
  return phase;
}

/* shared domed LED glyph (matches the breadboard look) */
function OscLed({ cx, cy, on, r = 13 }) {
  return (
    <g>
      {on && <circle cx={cx} cy={cy} r={r + 19} fill="var(--current)" opacity="0.16" />}
      {on && <circle cx={cx} cy={cy} r={r + 8} fill="var(--current)" opacity="0.3" />}
      <circle cx={cx} cy={cy} r={r}
              fill={on ? "var(--current)" : "var(--bg-card)"}
              stroke={on ? "var(--current-deep)" : "var(--current-soft)"} strokeWidth="2"
              style={{ transition: "fill 120ms linear" }} />
      {on && <circle cx={cx - 4} cy={cy - 4.5} r={r * 0.26} fill="var(--current-soft)" opacity="0.92" />}
    </g>
  );
}

function oscWire(d, accent, w = 2.5, dash) {
  return <path d={d} fill="none" stroke={accent} strokeWidth={w} strokeLinecap="round"
               strokeDasharray={dash || "none"} />;
}

/* ─────────────────────────────────────────────────────────────────────────
   AstableScene — the discrete blinker: 2 transistors, 2 LEDs, 2 caps, R's.
   ───────────────────────────────────────────────────────────────────────── */
function AstableScene({ R = 47, C = 10, kids, running = true, height = 420 }) {
  const T = 1.38e-3 * R * C;                 // seconds (R kΩ, C µF)
  const phase = useOscPhase(T, running);
  const aOn = phase < 0.5;                    // LED A lit first half
  const chargeU = aOn ? phase / 0.5 : (phase - 0.5) / 0.5;  // 0..1 within the half

  const W = 860, H = 430;
  const railP = 56, railN = 372;
  const xL = 250, xR = 610;
  const ledY = 120, qY = 300;

  const branch = (x, on, idLetter) => (
    <g>
      {/* collector resistor (rail → LED) */}
      {oscWire(`M${x} ${railP} L${x} ${ledY - 40}`, "var(--ink-soft)", 2.5)}
      <rect x={x - 13} y={ledY - 40} width="26" height="14" rx="3" fill="var(--bg-card)" stroke="var(--ink)" strokeWidth="1.6" />
      {oscWire(`M${x} ${ledY - 26} L${x} ${ledY - 13}`, "var(--ink-soft)", 2.5)}
      {/* LED */}
      <OscLed cx={x} cy={ledY} on={on} />
      {oscWire(`M${x} ${ledY + 13} L${x} ${qY - 26}`, on ? "var(--current)" : "var(--ink-faint)", on ? 3 : 2.5)}
      {/* transistor */}
      <g transform={`translate(${x} ${qY})`}>
        <circle r="22" fill="var(--bg-card)" stroke={on ? "var(--current)" : "var(--ink-soft)"} strokeWidth="2.5" />
        <line x1="-9" y1="-11" x2="-9" y2="11" stroke={on ? "var(--current)" : "var(--ink-soft)"} strokeWidth="3" />
        <line x1="-9" y1="-3" x2="9" y2="-13" stroke={on ? "var(--current)" : "var(--ink-soft)"} strokeWidth="2.5" />
        <line x1="-9" y1="3" x2="9" y2="13" stroke={on ? "var(--current)" : "var(--ink-soft)"} strokeWidth="2.5" />
        <text x="0" y="38" textAnchor="middle" fontFamily="IBM Plex Mono, monospace" fontSize="17.5"
              fill={on ? "var(--current)" : "var(--ink-faint)"}>Q{idLetter}</text>
      </g>
      {/* emitter → ground */}
      {oscWire(`M${x} ${qY + 22} L${x} ${railN}`, on ? "var(--current)" : "var(--ink-faint)", on ? 3 : 2.5)}
      {/* base resistor from + rail */}
      {oscWire(`M${x + (idLetter === "A" ? -70 : 70)} ${railP} L${x + (idLetter === "A" ? -70 : 70)} ${qY} L${x + (idLetter === "A" ? -22 : 22)} ${qY}`, "var(--ink-faint)", 2)}
    </g>
  );

  return (
    <svg viewBox={`0 0 ${W} ${H}`} width="100%" height={height}
         preserveAspectRatio="xMidYMid meet" style={{ display: "block", maxHeight: height }}>
      {/* rails */}
      {oscWire(`M40 ${railP} L${W - 40} ${railP}`, "var(--current)", 3)}
      {oscWire(`M40 ${railN} L${W - 40} ${railN}`, "var(--water)", 3)}
      <text x="44" y={railP - 12} fontFamily="IBM Plex Mono, monospace" fontSize="19" fill="var(--current)">+9V</text>
      <text x="44" y={railN + 26} fontFamily="IBM Plex Mono, monospace" fontSize="19" fill="var(--water)">GND</text>

      {/* the two cross-coupling timing capacitors */}
      {[{ from: xL, to: xR, charging: !aOn }, { from: xR, to: xL, charging: aOn }].map((c, i) => {
        const my = 215 + i * 26;
        const u = c.charging ? chargeU : 1;
        return (
          <g key={i}>
            {oscWire(`M${c.from} ${ledY + 90} C ${(c.from + c.to) / 2} ${my}, ${(c.from + c.to) / 2} ${my}, ${c.to} ${qY - 6}`,
              c.charging ? "var(--water)" : "var(--rule-strong)", c.charging ? 3 : 2, c.charging ? "none" : "4 5")}
            {/* charge pip travelling along while charging */}
            {c.charging && (
              <circle r="5" fill="var(--water)">
                <animateMotion dur="0.9s" repeatCount="indefinite"
                  path={`M${c.from} ${ledY + 90} C ${(c.from + c.to) / 2} ${my}, ${(c.from + c.to) / 2} ${my}, ${c.to} ${qY - 6}`} />
              </circle>
            )}
          </g>
        );
      })}

      {branch(xL, aOn, "A")}
      {branch(xR, !aOn, "B")}

      {/* labels */}
      <g fontFamily="IBM Plex Mono, monospace">
        <text x={xL} y={ledY - 54} textAnchor="middle" fontSize="16" fill="var(--ink-faint)">R</text>
        <text x={xR} y={ledY - 54} textAnchor="middle" fontSize="16" fill="var(--ink-faint)">R</text>
        <text x={(xL + xR) / 2} y={250} textAnchor="middle" fontSize="16" fill="var(--water)">
          {kids ? "buckets (C) fill, then flip" : "C charges → tips the other side"}
        </text>
        <text x={(xL + xR) / 2} y={H - 18} textAnchor="middle" fontSize="17.5" fill="var(--ink-soft)">
          {kids
            ? `the two lights take turns — about ${T < 1 ? (1 / T).toFixed(1) + " blinks/sec" : T.toFixed(1) + "s each cycle"}`
            : `T ≈ 1.38·R·C ≈ ${T.toFixed(2)} s   ·   f ≈ ${(1 / T).toFixed(1)} Hz`}
        </text>
      </g>
    </svg>
  );
}

/* ─────────────────────────────────────────────────────────────────────────
   Timer555Scene — the chip cracked open + a live scope, sharing one clock.
   ───────────────────────────────────────────────────────────────────────── */
function Timer555Scene({ RA = 10, RB = 47, C = 10, kids, running = true, height = 430 }) {
  const tHigh = 0.693e-3 * (RA + RB) * C;     // s
  const tLow = 0.693e-3 * RB * C;             // s
  const T = tHigh + tLow;
  const hiFrac = tHigh / T;
  const phase = useOscPhase(T, running);
  const charging = phase < hiFrac;            // output HIGH while charging
  const u = charging ? phase / hiFrac : (phase - hiFrac) / (1 - hiFrac);
  // cap voltage normalized to Vcc, bouncing between 1/3 and 2/3
  const vcap = charging ? 1 - (2 / 3) * Math.exp(-LN2 * u) : (2 / 3) * Math.exp(-LN2 * u);
  const out = charging;                        // output high during charge

  return (
    <div className="t555-grid" style={{ display: "grid", gridTemplateColumns: "1.35fr 1fr", gap: 18, alignItems: "stretch" }}>
      <Chip555 vcap={vcap} out={out} charging={charging} kids={kids} />
      <Scope555 vcap={vcap} out={out} running={running} kids={kids}
                tHigh={tHigh} tLow={tLow} T={T} />
    </div>
  );
}

/* the internal block diagram, with live highlighting */
function Chip555({ vcap, out, charging, kids }) {
  const W = 540, H = 430;
  const hi = (cond) => (cond ? "var(--current)" : "var(--ink-faint)");
  const live = (cond) => (cond ? "var(--water)" : "var(--rule-strong)");
  const upperFired = vcap >= 0.66;            // threshold comparator near top
  const lowerFired = vcap <= 0.34;            // trigger comparator near bottom

  // chip body
  const bx = 150, by = 40, bw = 250, bh = 350;
  // divider node ys
  const dTop = by + 36, dBot = by + bh - 36;
  const n23 = dTop + (dBot - dTop) * (1 / 3);
  const n13 = dTop + (dBot - dTop) * (2 / 3);

  return (
    <svg viewBox={`0 0 ${W} ${H}`} width="100%" height="100%"
         preserveAspectRatio="xMidYMid meet" style={{ display: "block" }}>
      {/* chip outline */}
      <rect x={bx} y={by} width={bw} height={bh} rx="14" fill="var(--bg-card)" stroke="var(--ink)" strokeWidth="2" />
      <path d={`M${bx + bw / 2 - 14} ${by} a14 14 0 0 0 28 0`} fill="none" stroke="var(--ink)" strokeWidth="2" />
      <text x={bx + bw - 12} y={by + bh - 12} textAnchor="end" fontFamily="IBM Plex Mono, monospace"
            fontSize="17.5" fill="var(--ink-faint)" letterSpacing="0.12em">NE555</text>

      {/* resistor divider — three 5k */}
      {oscWire(`M${bx + 30} ${by + 14} L${bx + 30} ${dTop}`, "var(--current)", 2)}
      {[0, 1, 2].map((k) => (
        <rect key={k} x={bx + 18} y={dTop + k * ((dBot - dTop) / 3) + 6}
              width="24" height={(dBot - dTop) / 3 - 12} rx="3"
              fill="var(--bg-deeper)" stroke="var(--ink-soft)" strokeWidth="1.5" />
      ))}
      {oscWire(`M${bx + 30} ${dBot} L${bx + 30} ${by + bh - 14}`, "var(--water)", 2)}
      <text x={bx + 6} y={by + 8} fontFamily="IBM Plex Mono, monospace" fontSize="15" fill="var(--current)">Vcc</text>
      {/* divider taps */}
      <circle cx={bx + 30} cy={n23} r="3.5" fill="var(--ink)" />
      <circle cx={bx + 30} cy={n13} r="3.5" fill="var(--ink)" />
      <text x={bx + 46} y={n23 - 4} fontFamily="IBM Plex Mono, monospace" fontSize="15" fill="var(--ink-soft)">⅔Vcc</text>
      <text x={bx + 46} y={n13 + 14} fontFamily="IBM Plex Mono, monospace" fontSize="15" fill="var(--ink-soft)">⅓Vcc</text>

      {/* comparator 1 (upper / threshold) */}
      <g>
        <path d={`M${bx + 120} ${n23 - 26} L${bx + 168} ${n23} L${bx + 120} ${n23 + 26} Z`}
              fill="var(--bg-deeper)" stroke={hi(upperFired)} strokeWidth="2" />
        <text x={bx + 132} y={n23 + 4} fontFamily="IBM Plex Mono, monospace" fontSize="19" fill={hi(upperFired)}>▷</text>
        <text x={bx + 144} y={n23 - 32} textAnchor="middle" fontFamily="IBM Plex Mono, monospace" fontSize="11" fill={hi(upperFired)}>
          {kids ? "checker: too full?" : "comparator · above ⅔?"}
        </text>
      </g>
      {/* comparator 2 (lower / trigger) */}
      <g>
        <path d={`M${bx + 120} ${n13 - 26} L${bx + 168} ${n13} L${bx + 120} ${n13 + 26} Z`}
              fill="var(--bg-deeper)" stroke={hi(lowerFired)} strokeWidth="2" />
        <text x={bx + 132} y={n13 + 4} fontFamily="IBM Plex Mono, monospace" fontSize="19" fill={hi(lowerFired)}>▷</text>
        <text x={bx + 144} y={n13 + 40} textAnchor="middle" fontFamily="IBM Plex Mono, monospace" fontSize="11" fill={hi(lowerFired)}>
          {kids ? "checker: too empty?" : "comparator · below ⅓?"}
        </text>
      </g>
      {oscWire(`M${bx + 30} ${n23} L${bx + 120} ${n23}`, hi(false), 1.5)}
      {oscWire(`M${bx + 30} ${n13} L${bx + 120} ${n13}`, hi(false), 1.5)}

      {/* SR flip-flop */}
      <rect x={bx + 178} y={by + bh / 2 - 36} width="44" height="72" rx="6"
            fill="var(--bg-deeper)" stroke="var(--ink)" strokeWidth="2" />
      <text x={bx + 200} y={by + bh / 2 + 5} textAnchor="middle" fontFamily="IBM Plex Mono, monospace"
            fontSize="17.5" fill="var(--ink)">{out ? "Q=1" : "Q=0"}</text>
      <text x={bx + 200} y={by + bh / 2 - 44} textAnchor="middle" fontFamily="IBM Plex Mono, monospace"
            fontSize="12" fill="var(--ink-faint)">FLIP-FLOP</text>
      {oscWire(`M${bx + 168} ${n23} L${bx + 178} ${by + bh / 2 - 22}`, hi(upperFired), upperFired ? 2.5 : 1.5)}
      {oscWire(`M${bx + 168} ${n13} L${bx + 178} ${by + bh / 2 + 22}`, hi(lowerFired), lowerFired ? 2.5 : 1.5)}

      {/* output stage → OUT pin */}
      {oscWire(`M${bx + 222} ${by + bh / 2 - 12} L${bx + bw} ${by + bh / 2 - 12}`, hi(out), out ? 3 : 1.5)}
      <text x={bx + bw + 6} y={by + bh / 2 - 8} fontFamily="IBM Plex Mono, monospace" fontSize="16" fill={hi(out)}>OUT</text>
      {/* discharge transistor → DISCHARGE pin */}
      {oscWire(`M${bx + 222} ${by + bh / 2 + 18} L${bx + bw - 30} ${by + bh / 2 + 18} L${bx + bw - 30} ${by + bh - 14}`,
        live(!charging), !charging ? 2.5 : 1.5)}
      <text x={bx + bw - 24} y={by + bh / 2 + 14} fontFamily="IBM Plex Mono, monospace" fontSize="13.5"
            fill={live(!charging)}>DIS</text>

      {/* OUTPUT LED, external, blinking with OUT */}
      <OscLed cx={bx + bw + 60} cy={by + bh / 2 - 12} on={out} r={14} />
      <text x={bx + bw + 60} y={by + bh / 2 + 26} textAnchor="middle" fontFamily="IBM Plex Mono, monospace"
            fontSize="15" fill="var(--ink-faint)">LED</text>

      {/* caption */}
      <text x={W / 2} y={H - 6} textAnchor="middle" fontFamily="IBM Plex Mono, monospace" fontSize="15.5"
            fill="var(--ink-soft)">
        {kids
          ? (charging ? "bucket filling → light ON" : "referee emptied it → light OFF")
          : (charging ? "charging: threshold not reached → OUT high" : "≥⅔Vcc → reset → discharge → OUT low")}
      </text>
    </svg>
  );
}

/* live scope: cap voltage between ⅓ & ⅔, plus the output square wave */
function Scope555({ vcap, out, running, kids, tHigh, tLow, T }) {
  const W = 360, H = 430, x0 = 44, x1 = W - 14, y0 = 30, y1 = 250;
  const histRef = React.useRef([]);
  const vRef = React.useRef(vcap); vRef.current = vcap;
  const oRef = React.useRef(out); oRef.current = out;
  const [, force] = React.useState(0);
  React.useEffect(() => {
    if (!running) { return; }
    let raf, last = performance.now();
    const tick = (now) => {
      if (now - last > 38) {
        last = now;
        histRef.current.push({ v: vRef.current, o: oRef.current });
        if (histRef.current.length > 150) histRef.current.shift();
        force((n) => n + 1);
      }
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [running]);

  const hist = histRef.current;
  const n = hist.length;
  const X = (i) => x0 + (i / 149) * (x1 - x0);
  const Yv = (v) => y1 - v * (y1 - y0);
  const capPts = hist.map((p, i) => `${X(i)},${Yv(p.v)}`).join(" ");
  // output square wave band below
  const oy0 = y1 + 40, oy1 = H - 56;
  const Yo = (o) => (o ? oy0 : oy1);
  const outPts = hist.map((p, i) => `${X(i)},${Yo(p.o)}`).join(" ");

  return (
    <svg viewBox={`0 0 ${W} ${H}`} width="100%" height="100%"
         preserveAspectRatio="xMidYMid meet" style={{ display: "block" }}>
      <rect x="2" y="2" width={W - 4} height={H - 4} rx="10" fill="var(--bg-deeper)" stroke="var(--rule)" strokeWidth="1" />
      {/* threshold lines */}
      {[{ y: Yv(2 / 3), c: "var(--current)", t: "⅔Vcc" }, { y: Yv(1 / 3), c: "var(--water)", t: "⅓Vcc" }].map((g, i) => (
        <g key={i}>
          <line x1={x0} y1={g.y} x2={x1} y2={g.y} stroke={g.c} strokeWidth="1.2" strokeDasharray="4 4" opacity="0.7" />
          <text x={x0 - 4} y={g.y + 4} textAnchor="end" fontFamily="IBM Plex Mono, monospace" fontSize="13.5" fill={g.c}>{g.t}</text>
        </g>
      ))}
      <line x1={x0} y1={y0} x2={x0} y2={y1} stroke="var(--rule-strong)" strokeWidth="1" />
      <line x1={x0} y1={y1} x2={x1} y2={y1} stroke="var(--rule-strong)" strokeWidth="1" />
      <text x={x0} y={y0 - 10} fontFamily="IBM Plex Mono, monospace" fontSize="15" fill="var(--ink-soft)">
        {kids ? "bucket level" : "capacitor voltage"}
      </text>
      {n > 1 && <polyline points={capPts} fill="none" stroke="var(--water)" strokeWidth="2.5" />}
      {n > 0 && <circle cx={X(n - 1)} cy={Yv(hist[n - 1].v)} r="4" fill="var(--water)" />}

      {/* output square wave */}
      <text x={x0} y={oy0 - 12} fontFamily="IBM Plex Mono, monospace" fontSize="15" fill="var(--ink-soft)">
        {kids ? "light on / off" : "output"}
      </text>
      {n > 1 && <polyline points={outPts} fill="none" stroke="var(--current)" strokeWidth="2.5" />}

      {/* numeric readout */}
      <g fontFamily="IBM Plex Mono, monospace">
        <text x={x0} y={H - 26} fontSize="15.5" fill="var(--ink-soft)">
          {kids
            ? `${(1 / T).toFixed(1)} blinks/sec`
            : `f ≈ ${(1 / T).toFixed(1)} Hz · duty ${(100 * tHigh / T).toFixed(0)}%`}
        </text>
        {!kids && (
          <text x={x0} y={H - 10} fontSize="14" fill="var(--ink-faint)">
            t_hi=0.69(RA+RB)C · t_lo=0.69·RB·C
          </text>
        )}
      </g>
    </svg>
  );
}

/* ─────────────────────────────────────────────────────────────────────
   use555Sim — the chapter-10 lesson clock. Bounces a bucket level between
   ⅓ and ⅔ on the RC pace (exponential ramps, like the real cap), reporting
   { level, high }: level is the absolute fill fraction 0..1, high is true
   while filling (output ON). Animation period is clamped to a watchable
   window; callers display the TRUE rate from R·C themselves.
   ───────────────────────────────────────────────────────────────────── */
function use555Sim({ R = 5, C = 2, running = true }) {
  const [state, setState] = React.useState({ level: 1 / 3, high: true });
  const phaseRef = React.useRef(0);
  const halfRef = React.useRef(1);
  halfRef.current = Math.max(0.35, Math.min(2.6, (R * C) / 6));
  React.useEffect(() => {
    if (!running) { return; }
    let raf, last = performance.now();
    const tick = (now) => {
      const dt = (now - last) / 1000; last = now;
      phaseRef.current = (phaseRef.current + dt / (2 * halfRef.current)) % 1;
      const p = phaseRef.current;
      const high = p < 0.5;
      const u = high ? p / 0.5 : (p - 0.5) / 0.5;
      const level = high ? 1 - (2 / 3) * Math.exp(-LN2 * u)
                         : (2 / 3) * Math.exp(-LN2 * u);
      setState({ level, high });
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [running]);
  return state;
}

/* ─────────────────────────────────────────────────────────────────────
   Astable555Scene — the water side of the 555: a bucket that fills through
   a pinch (R) to the ⅔ HIGH mark, then drains to the ⅓ LOW mark, forever.
   The lamp is ON while filling. Bucket girth scales with C; the pinch's gap
   scales with R — the same vocabulary as chapters 1 & 4.
   ───────────────────────────────────────────────────────────────────── */
function Astable555Scene({ R = 5, C = 2, level = 0.5, high = true, height = 420, kids }) {
  const W = 640, H = 470;
  // inlet pipe with the pinch
  const pipeY = 92, baseH = 30, pipeStart = 18, pipeEnd = 252;
  const open = pipeOpenness(R, 12);
  const gap = baseH * (0.16 + 0.84 * open);
  const pXa = 96, pXb = 168;
  // bucket — girth scales with C
  const bcx = 330, bTop = 170, bBot = 400;
  const half = 80 + C * 6.5;
  const wallW = 5;
  const innerTop = bTop + 16;
  const levelY = bBot - wallW - level * (bBot - innerTop);
  const markY = (f) => bBot - wallW - f * (bBot - innerTop);
  const yHi = markY(2 / 3), yLo = markY(1 / 3);
  // drain — open while NOT high
  const dX = bcx + half - 26, dY = bBot;
  // true timing readout
  const tHalf = Math.max(0.3, (R * C) / 6);
  const fTrue = 1 / (2 * tHalf);
  const pourX = pipeEnd + 14;

  return (
    <svg viewBox={`0 0 ${W} ${H}`} width="100%" height={height}
         preserveAspectRatio="xMidYMid meet" style={{ display: "block", maxHeight: height }}>
      <defs>
        <clipPath id="a555-pipe-clip">
          <path d={`M ${pipeStart} ${pipeY - baseH / 2} L ${pXa} ${pipeY - baseH / 2}
                    L ${pXa + 22} ${pipeY - gap / 2} L ${pXb - 22} ${pipeY - gap / 2} L ${pXb} ${pipeY - baseH / 2}
                    L ${pipeEnd} ${pipeY - baseH / 2} L ${pipeEnd} ${pipeY + baseH / 2}
                    L ${pXb} ${pipeY + baseH / 2} L ${pXb - 22} ${pipeY + gap / 2} L ${pXa + 22} ${pipeY + gap / 2}
                    L ${pXa} ${pipeY + baseH / 2} L ${pipeStart} ${pipeY + baseH / 2} Z`} />
        </clipPath>
      </defs>

      {/* ── inlet pipe ── */}
      <g clipPath="url(#a555-pipe-clip)">
        <rect x={pipeStart} y={pipeY - baseH / 2} width={pipeEnd - pipeStart} height={baseH}
              fill={high ? "var(--water)" : "var(--water-soft)"} opacity={high ? 0.9 : 0.35}
              style={{ transition: "opacity 200ms linear" }} />
        {high && renderWaterCurrent({ x1: pipeStart, x2: pipeEnd, y: pipeY + 4,
                                      amp: 3.5, period: "1.1s", flowing: true,
                                      stroke: "var(--water-soft)", width: 2.4, opacity: 0.9 })}
        {high && renderWaterCurrent({ x1: pipeStart, x2: pipeEnd, y: pipeY - 6,
                                      amp: 2, period: "1.1s", flowing: true,
                                      stroke: "rgba(255,255,255,0.55)", width: 1.5, opacity: 0.75, phase: 24 })}
      </g>
      {/* pipe walls + pinch wedges */}
      <path d={`M ${pipeStart} ${pipeY - baseH / 2} L ${pXa} ${pipeY - baseH / 2} L ${pXa + 22} ${pipeY - gap / 2}
                L ${pXb - 22} ${pipeY - gap / 2} L ${pXb} ${pipeY - baseH / 2} L ${pipeEnd} ${pipeY - baseH / 2}`}
            fill="none" stroke="var(--ink)" strokeWidth="2.5" strokeLinejoin="round" />
      <path d={`M ${pipeStart} ${pipeY + baseH / 2} L ${pXa} ${pipeY + baseH / 2} L ${pXa + 22} ${pipeY + gap / 2}
                L ${pXb - 22} ${pipeY + gap / 2} L ${pXb} ${pipeY + baseH / 2} L ${pipeEnd} ${pipeY + baseH / 2}`}
            fill="none" stroke="var(--ink)" strokeWidth="2.5" strokeLinejoin="round" />
      <text x={(pXa + pXb) / 2} y={pipeY - baseH / 2 - 10} textAnchor="middle"
            fontFamily="IBM Plex Mono, monospace" fontSize="15" fill="var(--ink-faint)">
        {kids ? "pinch" : `pinch · R`}
      </text>
      <text x={pipeStart + 4} y={pipeY + baseH / 2 + 22} fontFamily="IBM Plex Mono, monospace"
            fontSize="15" fill="var(--ink-faint)">{kids ? "supply" : "supply · Vcc"}</text>

      {/* pour into the bucket */}
      {high && (
        <>
          <path d={`M ${pipeEnd} ${pipeY} C ${pourX + 8} ${pipeY + 8}, ${pourX + 16} ${pipeY + 40}, ${pourX + 16} ${levelY}`}
                fill="none" stroke="var(--water)" strokeWidth={5 + open * 5} strokeLinecap="round" opacity="0.85" />
          <ellipse cx={pourX + 16} cy={levelY} rx={10 + open * 5} ry="3.2" fill="var(--water)" opacity="0.4">
            <animate attributeName="opacity" values="0.25;0.5;0.25" dur="0.7s" repeatCount="indefinite" />
          </ellipse>
        </>
      )}

      {/* ── bucket ── */}
      <rect x={bcx - half} y={levelY} width={half * 2} height={bBot - wallW - levelY}
            fill="var(--water)" opacity="0.85" />
      <path d={`M ${bcx - half} ${levelY} q ${half * 0.5} ${high ? -4 : 4} ${half} 0 q ${half * 0.5} ${high ? 4 : -4} ${half} 0`}
            fill="none" stroke="var(--water-deep)" strokeWidth="2.5" />
      {/* walls */}
      <path d={`M ${bcx - half} ${bTop} L ${bcx - half} ${bBot} L ${bcx + half} ${bBot} L ${bcx + half} ${bTop}`}
            fill="none" stroke="var(--ink)" strokeWidth={wallW} strokeLinejoin="round" strokeLinecap="round" />
      {/* HIGH / LOW marks */}
      {[{ y: yHi, t: kids ? "HIGH mark" : "⅔ · HIGH", fired: level >= 0.655 },
        { y: yLo, t: kids ? "LOW mark" : "⅓ · LOW", fired: level <= 0.345 }].map((m, i) => (
        <g key={i}>
          <line x1={bcx - half + 8} y1={m.y} x2={bcx + half - 8} y2={m.y}
                stroke={m.fired ? "var(--current-deep)" : "var(--ink-faint)"}
                strokeWidth={m.fired ? 2.5 : 1.5} strokeDasharray="7 6" />
          <text x={bcx + half + 12} y={m.y + 5} fontFamily="IBM Plex Mono, monospace"
                fontSize="15" fill={m.fired ? "var(--current-deep)" : "var(--ink-faint)"}>{m.t}</text>
        </g>
      ))}
      <text x={bcx} y={bBot + 32} textAnchor="middle" fontFamily="IBM Plex Mono, monospace"
            fontSize="15" fill="var(--ink-faint)">{kids ? "the bucket (C)" : "bucket · C"}</text>

      {/* ── drain valve ── */}
      <path d={`M ${dX} ${bBot} L ${dX} ${dY + 34} L ${W - 70} ${dY + 34}`}
            fill="none" stroke="var(--ink)" strokeWidth="2.5" strokeLinecap="round" />
      {!high && (
        <path d={`M ${dX} ${bBot} L ${dX} ${dY + 34} L ${W - 70} ${dY + 34}`}
              fill="none" stroke="var(--water)" strokeWidth="9" strokeLinecap="round" opacity="0.8" />
      )}
      {/* valve gate */}
      <g transform={`translate(${dX + 60} ${dY + 34})`}>
        <rect x="-7" y={high ? -16 : -26} width="14" height={high ? 26 : 16} rx="2"
              fill={high ? "var(--ink)" : "var(--bg-card)"} stroke="var(--ink)" strokeWidth="2"
              style={{ transition: "all 180ms ease" }} />
        <text x="0" y="30" textAnchor="middle" fontFamily="IBM Plex Mono, monospace" fontSize="14"
              fill={high ? "var(--ink-faint)" : "var(--current-deep)"}>
          {high ? (kids ? "drain shut" : "DIS · shut") : (kids ? "drain open!" : "DIS · open")}
        </text>
      </g>

      {/* ── the referee + lamp ── */}
      <g>
        <rect x={W - 116} y={150} width="72" height="54" rx="8"
              fill="var(--bg-card)" stroke="var(--ink)" strokeWidth="2" />
        <text x={W - 80} y={172} textAnchor="middle" fontFamily="IBM Plex Mono, monospace"
              fontSize="15" fill="var(--ink)">555</text>
        <text x={W - 80} y={192} textAnchor="middle" fontFamily="IBM Plex Mono, monospace"
              fontSize="11.5" fill="var(--ink-faint)">{kids ? "referee" : "watches marks"}</text>
        {/* sense lines to the two marks */}
        <path d={`M ${W - 116} ${166} C ${bcx + half + 70} ${166}, ${bcx + half + 80} ${yHi}, ${bcx + half + 56} ${yHi}`}
              fill="none" stroke="var(--ink-faint)" strokeWidth="1.2" strokeDasharray="3 5" />
        <path d={`M ${W - 116} ${190} C ${bcx + half + 84} ${190}, ${bcx + half + 92} ${yLo}, ${bcx + half + 60} ${yLo}`}
              fill="none" stroke="var(--ink-faint)" strokeWidth="1.2" strokeDasharray="3 5" />
        {/* output wire up to the lamp */}
        <line x1={W - 80} y1={150} x2={W - 80} y2={112}
              stroke={high ? "var(--current)" : "var(--ink-faint)"} strokeWidth={high ? 3 : 2} />
        <OscLed cx={W - 80} cy={92} on={high} r={15} />
        <text x={W - 80} y={52} textAnchor="middle" fontFamily="IBM Plex Mono, monospace"
              fontSize="14.5" fill={high ? "var(--current-deep)" : "var(--ink-faint)"}>
          {high ? "ON" : "OFF"}
        </text>
      </g>

      {/* caption — left-anchored so it stays clear of the drain-valve label */}
      <text x={22} y={H - 10} fontFamily="IBM Plex Mono, monospace"
            fontSize="16" fill="var(--ink-soft)">
        {kids
          ? (high ? "filling to the HIGH mark → light ON" : "draining to the LOW mark → light OFF")
          : `fill → ⅔, drain → ⅓ · f ≈ ${fTrue.toFixed(2)} Hz`}
      </text>
    </svg>
  );
}

/* ─────────────────────────────────────────────────────────────────────
   Blink555Chart — rolling strip-chart of the output: the blink over time.
   ───────────────────────────────────────────────────────────────────── */
function Blink555Chart({ high, R = 5, C = 2, height = 340, kids }) {
  const W = 640, H = 330, x0 = 30, x1 = W - 22, yOn = 84, yOff = H - 96;
  const histRef = React.useRef([]);
  const hRef = React.useRef(high); hRef.current = high;
  const [, force] = React.useState(0);
  React.useEffect(() => {
    let raf, last = performance.now();
    const tick = (now) => {
      if (now - last > 40) {
        last = now;
        histRef.current.push(hRef.current);
        if (histRef.current.length > 140) histRef.current.shift();
        force((n) => n + 1);
      }
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, []);

  const hist = histRef.current;
  const n = hist.length;
  const X = (i) => x0 + (i / 139) * (x1 - x0);
  const pts = hist.map((o, i) => `${X(i)},${o ? yOn : yOff}`).join(" ");
  const tHalf = Math.max(0.3, (R * C) / 6);
  const fTrue = 1 / (2 * tHalf);

  return (
    <svg viewBox={`0 0 ${W} ${H}`} width="100%" height={height}
         preserveAspectRatio="xMidYMid meet" style={{ display: "block", maxHeight: height }}>
      <rect x="2" y="2" width={W - 4} height={H - 4} rx="10"
            fill="var(--bg-deeper)" stroke="var(--rule)" strokeWidth="1" />
      {[{ y: yOn, t: kids ? "ON" : "HIGH" }, { y: yOff, t: kids ? "OFF" : "LOW" }].map((g, i) => (
        <g key={i}>
          <line x1={x0} y1={g.y} x2={x1} y2={g.y} stroke="var(--rule-strong)" strokeWidth="1" strokeDasharray="4 5" />
          <text x={x0} y={g.y - 8} fontFamily="IBM Plex Mono, monospace" fontSize="14" fill="var(--ink-faint)">{g.t}</text>
        </g>
      ))}
      {n > 1 && <polyline points={pts} fill="none" stroke="var(--current)" strokeWidth="2.5" />}
      {n > 0 && <circle cx={X(n - 1)} cy={hist[n - 1] ? yOn : yOff} r="4.5" fill="var(--current)" />}
      <text x={x0} y={H - 40} fontFamily="IBM Plex Mono, monospace" fontSize="15.5" fill="var(--ink-soft)">
        {kids ? `about ${fTrue.toFixed(1)} blinks per second` : `square wave · f ≈ ${fTrue.toFixed(2)} Hz`}
      </text>
      {!kids && (
        <text x={x0} y={H - 18} fontFamily="IBM Plex Mono, monospace" fontSize="13.5" fill="var(--ink-faint)">
          ON while the cap charges · OFF while it drains
        </text>
      )}
    </svg>
  );
}

Object.assign(window, { AstableScene, Timer555Scene, Chip555, Scope555, OscLed, useOscPhase,
                        use555Sim, Astable555Scene, Blink555Chart });
