/* loop-scene.jsx — the reusable closed-loop charge-flow visual (Chapter 1).
   A CLOSED LOOP of conserved charge driven round a circuit by a battery (push)
   past a pinch (resistance). Charges are NEVER used up — the same crowd
   recirculates; only their ENERGY (colour) is spent at the pinch and refilled
   at the battery. WATER mode lets them bunch; ELECTRONS mode keeps them orderly.
   Driven by v/r PROPS so it can be embedded anywhere. Reports gate rates via
   onStats. Uses React.* directly (no destructuring) to avoid global collisions.
   Exports window.LoopScene + window.FL. */

/* ---- geometry: a rounded-rectangle racetrack centreline ---- */
const FL = (() => {
  const W = 900, H = 480, X0 = 130, X1 = 770, Y0 = 95, Y1 = 385, rc = 60;
  const Larc = Math.PI / 2 * rc;
  const segs = [
    { k: "line", x: X0 + rc, y: Y0, dx: 1, dy: 0, len: (X1 - rc) - (X0 + rc) },
    { k: "arc", cx: X1 - rc, cy: Y0 + rc, a0: -Math.PI / 2, a1: 0, len: Larc },
    { k: "line", x: X1, y: Y0 + rc, dx: 0, dy: 1, len: (Y1 - rc) - (Y0 + rc) },
    { k: "arc", cx: X1 - rc, cy: Y1 - rc, a0: 0, a1: Math.PI / 2, len: Larc },
    { k: "line", x: X1 - rc, y: Y1, dx: -1, dy: 0, len: (X1 - rc) - (X0 + rc) },
    { k: "arc", cx: X0 + rc, cy: Y1 - rc, a0: Math.PI / 2, a1: Math.PI, len: Larc },
    { k: "line", x: X0, y: Y1 - rc, dx: 0, dy: -1, len: (Y1 - rc) - (Y0 + rc) },
    { k: "arc", cx: X0 + rc, cy: Y0 + rc, a0: Math.PI, a1: 3 * Math.PI / 2, len: Larc },
  ];
  let P = 0; const starts = segs.map(s => { const a = P; P += s.len; return a; });
  function at(s) {
    s = ((s % P) + P) % P;
    for (let i = segs.length - 1; i >= 0; i--) {
      if (s >= starts[i]) {
        const sg = segs[i], t = s - starts[i];
        if (sg.k === "line") return { x: sg.x + sg.dx * t, y: sg.y + sg.dy * t, tx: sg.dx, ty: sg.dy };
        const a = sg.a0 + (sg.a1 - sg.a0) * (t / sg.len);
        return { x: sg.cx + rc * Math.cos(a), y: sg.cy + rc * Math.sin(a), tx: -Math.sin(a), ty: Math.cos(a) };
      }
    }
    return { x: segs[0].x, y: segs[0].y, tx: 1, ty: 0 };
  }
  const Ltop = segs[0].len;
  return {
    W, H, P, at, starts,
    resS0: Ltop / 2 - 80, resS1: Ltop / 2 + 80,
    batS0: starts[6] + segs[6].len / 2 - 60,
    batS1: starts[6] + segs[6].len / 2 + 60,
    gateA: starts[2] + segs[2].len / 2,
    gateB: starts[4] + segs[4].len / 2,
  };
})();

function flOpenFromR(R) { return Math.max(0.14, Math.min(1, 1.04 - (R - 1) / 9 * 0.9)); }
function flSmooth(t) { return t * t * (3 - 2 * t); }
function flHexRgb(h) { h = (h || "").trim().replace("#", ""); if (h.length === 3) h = h.split("").map(c => c + c).join(""); return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]; }
function flMix(a, b, t) { const A = flHexRgb(a), B = flHexRgb(b); return `rgb(${Math.round(A[0] + (B[0] - A[0]) * t)},${Math.round(A[1] + (B[1] - A[1]) * t)},${Math.round(A[2] + (B[2] - A[2]) * t)})`; }
/* energy tint: a full charge is BRIGHT (push-blue); a spent one fades PALE.
   No jump to red — "bright -> pale" matches the prose and the legend swatches.
   full/empty come from the live theme vars so it reads in every palette. */
const FL_FULL_FB = "#2c5e8e", FL_EMPTY_FB = "#a39d92";
function flSpectrum(e, full, empty) {
  e = Math.max(0, Math.min(1, e));
  const t = e * e * (3 - 2 * e);
  return flMix(empty || FL_EMPTY_FB, full || FL_FULL_FB, t);
}

function LoopScene({ v, r, kids, onStats }) {
  const cv = React.useRef(null);
  const vRef = React.useRef(v), rRef = React.useRef(r), traceRef = React.useRef(true), modeRef = React.useRef("water");
  const onStatsRef = React.useRef(onStats);
  const [trace, setTrace] = React.useState(true);
  const [mode, setMode] = React.useState("water");

  React.useEffect(() => { vRef.current = v; }, [v]);
  React.useEffect(() => { rRef.current = r; }, [r]);
  React.useEffect(() => { traceRef.current = trace; }, [trace]);
  React.useEffect(() => { modeRef.current = mode; }, [mode]);
  React.useEffect(() => { onStatsRef.current = onStats; }, [onStats]);

  React.useEffect(() => {
    const canvas = cv.current, ctx = canvas.getContext("2d");
    const dpr = Math.min(2, window.devicePixelRatio || 1);
    let scale = 1;
    function fit() {
      const cw = canvas.clientWidth || FL.W;
      scale = cw / FL.W;
      canvas.width = Math.round(cw * dpr);
      canvas.height = Math.round(FL.H * scale * dpr);
      ctx.setTransform(dpr * scale, 0, 0, dpr * scale, 0, 0);
    }
    fit(); window.addEventListener("resize", fit);

    const cs = getComputedStyle(document.body);
    const C = {
      water: (cs.getPropertyValue("--water").trim() || "#2f6090"),
      current: (cs.getPropertyValue("--current").trim() || "#c45a36"),
      ink: (cs.getPropertyValue("--ink").trim() || "#2c2a26"),
      faint: (cs.getPropertyValue("--ink-faint").trim() || "#a79f90"),
      card: (cs.getPropertyValue("--bg-card").trim() || "#faf6ec"),
      deep: (cs.getPropertyValue("--bg-deeper").trim() || "#e6dfca"),
    };
    const spent = "#cfc8b8";
    // full charge = the theme's push-blue; spent charge fades to the faint ink tone
    const eColor = (e) => flSpectrum(e, C.water, C.faint);

    const hWide = 19, rad = 5;
    // The resistor region keeps a FIXED gentle waist — resistance is shown by
    // lattice density (how packed the material is), not by pipe width.
    function halfW(s) {
      if (s >= FL.resS0 && s <= FL.resS1) {
        const m = Math.min(s - FL.resS0, FL.resS1 - s), k = flSmooth(Math.min(1, m / 34));
        return hWide - (hWide - hWide * 0.72) * k;
      }
      return hWide;
    }
    const inRes = s => s >= FL.resS0 && s <= FL.resS1;
    const inBat = s => s >= FL.batS0 && s <= FL.batS1;

    const N = 200, elLanes = 3;
    const ps = [];
    for (let i = 0; i < N; i++) {
      const s = Math.random() * FL.P, h = halfW(s);
      ps.push({ s, u: (Math.random() * 2 - 1) * (h - rad), e: Math.random(), lane: 0 });
    }
    ps.map((_, i) => i).sort((a, b) => ps[a].s - ps[b].s).forEach((idx, k) => { ps[idx].lane = k % elLanes; });
    const cache = new Array(N);
    // heat wisps — energy leaving the loop at the pinch as heat (conservation:
    // what the battery pours in comes out HERE). Emission rate ∝ power.
    const wisps = [];
    // lattice — the pinch is a MATERIAL: an ordered grid of fixed atoms the
    // charges must weave through. More resistance = rows packed denser.
    // (Real physics: electrons scattering off the crystal lattice.)
    let gravel = [], gravelR = -1;
    function rebuildGravel(R) {
      gravelR = R;
      gravel = [];
      const s0 = FL.resS0 + 12, s1 = FL.resS1 - 12;
      const cols = Math.max(2, Math.round(1 + R * 1.1));   // columns ↑ with R — density IS the resistance
      const dsCol = (s1 - s0) / (cols - 1 || 1);
      for (let cIdx = 0; cIdx < cols; cIdx++) {
        const s = s0 + cIdx * dsCol;
        const h = Math.max(4, halfW(s) - 4);
        const rows = Math.max(2, Math.round(h / 5.5));
        for (let rIdx = 0; rIdx < rows; rIdx++) {
          // hexagonal offset: odd columns shifted half a row — charges must weave.
          // Wrap (not clip) the offset row so every column keeps the SAME row
          // count, symmetric top-to-bottom — otherwise the skipped edge row
          // biases the whole lattice toward one side and steadily nudges
          // charges outward as they pass through.
          const off = (cIdx % 2) * 0.5;
          const frac = ((rIdx + 0.5 + off) % rows) / rows;
          const u = -h + frac * 2 * h;
          gravel.push({ s, u, u0: u, ph: Math.random() * 6.28, fq: 0.6 + Math.random() * 0.5, gr: 2.6 });
        }
      }
    }
    const cell = 2 * rad;
    let gaC = 0, gbC = 0, lastM = performance.now(), emaA = null, emaB = null, clock = 0;
    let raf;

    function frame() {
      const V = vRef.current, R = rRef.current;
      if (R !== gravelR) rebuildGravel(R);
      clock += 1 / 60;
      // lattice atoms jitter in place — thermal vibration, physically apt
      // (hotter/denser lattice = more jiggle) and it keeps the weave from
      // looking like a static grid every charge threads the same way.
      for (const g of gravel) { g.u = g.u0 + Math.sin(clock * g.fq + g.ph) * 1.6; g.heat = (g.heat || 0) * 0.9; g.bump = (g.bump || 0) * 0.82; }
      const baseFlow = 0.46 * V / Math.max(1, R);

      for (let i = 0; i < N; i++) {
        const p = ps[i], a = FL.at(p.s);
        const nx = -a.ty, ny = a.tx;
        cache[i] = { x: a.x + nx * p.u, y: a.y + ny * p.u, tx: a.tx, ty: a.ty, nx, ny };
      }
      const grid = new Map();
      for (let i = 0; i < N; i++) { const c = cache[i]; const k = Math.floor(c.x / cell) + "," + Math.floor(c.y / cell); let g = grid.get(k); if (!g) { g = []; grid.set(k, g); } g.push(i); }

      for (let i = 0; i < N; i++) {
        const ci = cache[i], gx = Math.floor(ci.x / cell), gy = Math.floor(ci.y / cell);
        for (let ox = -1; ox <= 1; ox++) for (let oy = -1; oy <= 1; oy++) {
          const g = grid.get((gx + ox) + "," + (gy + oy)); if (!g) continue;
          for (const j of g) {
            if (j <= i) continue;
            const cj = cache[j];
            let dx = cj.x - ci.x, dy = cj.y - ci.y, d2 = dx * dx + dy * dy, mn = 2 * rad;
            if (d2 < mn * mn && d2 > 1e-4) {
              const d = Math.sqrt(d2), ov = (mn - d) / d * 0.5; dx *= ov; dy *= ov;
              const pi = ps[i], pj = ps[j];
              pi.s -= dx * ci.tx + dy * ci.ty; pi.u -= dx * ci.nx + dy * ci.ny;
              pj.s += dx * cj.tx + dy * cj.ty; pj.u += dx * cj.nx + dy * cj.ny;
            }
          }
        }
      }

      const electrons = modeRef.current === "electrons";
      for (let i = 0; i < N; i++) {
        const p = ps[i], prevS = p.s;
        const h = halfW(p.s);
        const moved = baseFlow * (hWide / h);
        p.s += moved;
        if (electrons) {
          const tu = (p.lane - 1) * Math.min(8.5, h - rad);
          p.u += (tu - p.u) * 0.15;
        } else {
          const maxU = Math.max(rad * 0.5, h - rad);
          if (p.u > maxU) p.u = maxU;
          if (p.u < -maxU) p.u = -maxU;
        }
        const a = ((prevS % FL.P) + FL.P) % FL.P, b = a + moved;
        if (a < FL.gateA && b >= FL.gateA) gaC++;
        if (a < FL.gateB && b >= FL.gateB) gbC++;
        p.s = ((p.s % FL.P) + FL.P) % FL.P;
        if (inRes(p.s)) {
          const pr = (p.s - FL.resS0) / (FL.resS1 - FL.resS0); p.e = 1 - pr;
          // weave through the lattice: gentle deflection around nearby atoms +
          // scatter jitter that grows with R (more collisions per step)
          let hitG = null;
          for (const g of gravel) {
            const ds = p.s - g.s;
            if (ds > -9 && ds < 9) {
              const du = p.u - g.u, d2 = ds * ds + du * du, mn = rad + g.gr + 1.2;
              if (d2 < mn * mn && d2 > 1e-4) {
                const d = Math.sqrt(d2);
                p.u += (du / d) * (mn - d) * 0.45;
                // each hit heats the atom — fast flow means frequent hits, so
                // it glows red; decay (below) cools it back down quickly if
                // the flow slows.
                g.heat = Math.min(1, (g.heat || 0) + 0.4);
                g.bump = 1;
                hitG = g;
              }
            }
          }
          p.u += (Math.random() - 0.5) * Math.min(1.4, R * 0.06);
          // shed heat: smoke rises ONLY from an actual collision — the charge
          // just handed that atom a bit of its energy, right where it hit.
          if (hitG && wisps.length < 90 && Math.random() < 0.5) {
            const c = cache[i];
            wisps.push({ x: c.x, y: c.y, age: 0, drift: (Math.random() * 2 - 1) * 0.5, ph: Math.random() * 6.28 });
          }
        }
        else if (inBat(p.s)) { const pr = (p.s - FL.batS0) / (FL.batS1 - FL.batS0); p.e = pr; }
      }
      if (electrons) {
        const order = ps.map((_, i) => i).sort((a, b) => ps[a].s - ps[b].s);
        for (let k = 0; k < N; k++) {
          const i = order[k], ah = order[(k + 1) % N], bh = order[(k - 1 + N) % N];
          let sa = ps[ah].s, sb = ps[bh].s; const si = ps[i].s;
          if (sa < si) sa += FL.P;
          if (sb > si) sb -= FL.P;
          let d = (sa + sb) / 2 - si; if (d > FL.P / 2) d -= FL.P; if (d < -FL.P / 2) d += FL.P;
          ps[i].s = ((ps[i].s + d * 0.09) % FL.P + FL.P) % FL.P;
        }
      }

      const now = performance.now();
      if (now - lastM > 650) {
        const dt = (now - lastM) / 1000;
        const ia = gaC / dt, ib = gbC / dt;
        emaA = emaA == null ? ia : emaA * 0.78 + ia * 0.22;
        emaB = emaB == null ? ib : emaB * 0.78 + ib * 0.22;
        gaC = 0; gbC = 0; lastM = now;
        if (onStatsRef.current) onStatsRef.current({ gA: emaA, gB: emaB });
      }

      // ===== draw =====
      ctx.clearRect(0, 0, FL.W, FL.H);
      const step = 7, outer = [], inner = [];
      for (let s = 0; s <= FL.P; s += step) { const a = FL.at(s), nx = -a.ty, ny = a.tx, h = halfW(s); outer.push([a.x + nx * h, a.y + ny * h]); inner.push([a.x - nx * h, a.y - ny * h]); }
      ctx.beginPath();
      outer.forEach((p, i) => i ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1]));
      for (let i = inner.length - 1; i >= 0; i--) ctx.lineTo(inner[i][0], inner[i][1]);
      ctx.closePath(); ctx.fillStyle = C.deep; ctx.fill();

      const pinch = FL.at((FL.resS0 + FL.resS1) / 2);
      const powN = Math.min(1, (V * V / R) / 81);
      if (powN > 0.04) {
        const g = ctx.createRadialGradient(pinch.x, pinch.y, 4, pinch.x, pinch.y, 120);
        g.addColorStop(0, flMix(C.card, C.current, 0.8 * powN).replace("rgb", "rgba").replace(")", `,${0.55 * powN})`));
        g.addColorStop(1, "rgba(0,0,0,0)");
        ctx.fillStyle = g; ctx.fillRect(pinch.x - 130, pinch.y - 90, 260, 180);
      }

      // lattice — ordered atoms of the material, packed denser as R rises
      for (const g of gravel) {
        const a = FL.at(g.s), nx = -a.ty, ny = a.tx;
        const h = Math.max(3, halfW(g.s) - 3);
        const gu = Math.max(-h, Math.min(h, g.u));
        const heatT = g.heat || 0;
        const gr = g.gr * (1 + (g.bump || 0) * 0.5);
        const hot = flMix("#8a95a5", "#ff5a3c", Math.min(1, heatT));
        if (heatT > 0.08) {
          const glowR = gr * (3.2 + heatT * 3.8);
          const gg = ctx.createRadialGradient(a.x + nx * gu, a.y + ny * gu, 0, a.x + nx * gu, a.y + ny * gu, glowR);
          gg.addColorStop(0, `rgba(255,196,60,${0.6 * heatT})`);
          gg.addColorStop(0.5, `rgba(255,150,40,${0.32 * heatT})`);
          gg.addColorStop(1, "rgba(255,150,40,0)");
          ctx.fillStyle = gg;
          ctx.beginPath(); ctx.arc(a.x + nx * gu, a.y + ny * gu, glowR, 0, 7); ctx.fill();
        }
        ctx.beginPath(); ctx.arc(a.x + nx * gu, a.y + ny * gu, gr, 0, 7);
        ctx.fillStyle = heatT > 0.02 ? hot : C.ink;
        ctx.globalAlpha = heatT > 0.02 ? Math.min(1, 0.72 + heatT * 0.4) : 0.72; ctx.fill(); ctx.globalAlpha = 1;
        ctx.lineWidth = 1; ctx.strokeStyle = heatT > 0.3 ? hot : C.ink;
        ctx.stroke();
      }

      for (let i = 0; i < N; i++) {
        const c = cache[i], p = ps[i];
        ctx.beginPath(); ctx.arc(c.x, c.y, rad, 0, 7);
        ctx.fillStyle = eColor(p.e); ctx.fill();
      }
      // heat wisps rising off the pinch — the spent push, leaving as heat
      for (let i = wisps.length - 1; i >= 0; i--) {
        const w = wisps[i]; w.age += 1 / 60;
        const t = w.age / 1.15;
        if (t >= 1) { wisps.splice(i, 1); continue; }
        const wx = w.x + w.drift * w.age * 30 + Math.sin(w.ph + w.age * 5) * 2.5;
        const wy = w.y - 26 * w.age - 14 * w.age * w.age;
        ctx.beginPath(); ctx.arc(wx, wy, 2 + 4.5 * t, 0, 7);
        ctx.fillStyle = flMix(C.current, C.card, t).replace("rgb", "rgba").replace(")", `,${0.55 * (1 - t)})`);
        ctx.fill();
      }
      if (traceRef.current) {
        const c = cache[0], p = ps[0];
        ctx.beginPath(); ctx.arc(c.x, c.y, rad + 3.5, 0, 7);
        ctx.lineWidth = 2.4; ctx.strokeStyle = C.current; ctx.stroke();
        ctx.beginPath(); ctx.arc(c.x, c.y, rad, 0, 7); ctx.fillStyle = eColor(p.e); ctx.fill();
        ctx.fillStyle = C.current; ctx.font = "600 11px 'IBM Plex Mono', monospace"; ctx.textAlign = "center";
        ctx.fillText("this one", c.x, c.y - 13);
      }

      ctx.lineWidth = 2.4; ctx.strokeStyle = C.ink; ctx.lineJoin = "round";
      ctx.beginPath(); outer.forEach((p, i) => i ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1])); ctx.stroke();
      ctx.beginPath(); inner.forEach((p, i) => i ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1])); ctx.stroke();

      const bc = FL.at((FL.batS0 + FL.batS1) / 2);
      ctx.strokeStyle = C.ink; ctx.lineCap = "round";
      ctx.lineWidth = 3.4; ctx.beginPath(); ctx.moveTo(bc.x - 30, bc.y - 9); ctx.lineTo(bc.x + 30, bc.y - 9); ctx.stroke();
      ctx.lineWidth = 3.4; ctx.beginPath(); ctx.moveTo(bc.x - 16, bc.y + 6); ctx.lineTo(bc.x + 16, bc.y + 6); ctx.stroke();
      ctx.fillStyle = C.ink; ctx.font = "600 15px 'IBM Plex Mono', monospace"; ctx.textAlign = "center";
      ctx.fillText("+", bc.x - 42, bc.y - 4); ctx.fillText("–", bc.x - 42, bc.y + 14);
      ctx.fillStyle = C.water; ctx.font = "600 10px 'IBM Plex Mono', monospace";
      ctx.fillText("BATTERY", bc.x, bc.y + 30); ctx.fillStyle = C.faint; ctx.fillText("adds push", bc.x, bc.y + 43);

      ctx.fillStyle = powN > 0.3 ? C.current : C.faint; ctx.font = "600 10px 'IBM Plex Mono', monospace";
      ctx.fillText("PINCH", pinch.x, pinch.y - 30); ctx.fillStyle = C.faint; ctx.fillText(kids ? "push turns into heat" : "drains push → heat", pinch.x, pinch.y - 18);

      [["GATE A", FL.gateA], ["GATE B", FL.gateB]].forEach(([lbl, gs]) => {
        const a = FL.at(gs), nx = -a.ty, ny = a.tx, h = hWide + 7;
        ctx.setLineDash([3, 4]); ctx.lineWidth = 1.5; ctx.strokeStyle = C.faint;
        ctx.beginPath(); ctx.moveTo(a.x + nx * h, a.y + ny * h); ctx.lineTo(a.x - nx * h, a.y - ny * h); ctx.stroke();
        ctx.setLineDash([]); ctx.fillStyle = C.faint; ctx.font = "600 9.5px 'IBM Plex Mono', monospace";
        ctx.fillText(lbl, a.x + nx * (h + 14), a.y + ny * (h + 14) + 3);
      });

      const da = FL.at(FL.gateB - 70);
      ctx.fillStyle = C.faint; ctx.save(); ctx.translate(da.x, da.y); ctx.rotate(Math.atan2(da.ty, da.tx));
      ctx.beginPath(); ctx.moveTo(-5, -4); ctx.lineTo(5, 0); ctx.lineTo(-5, 4); ctx.closePath(); ctx.fill(); ctx.restore();

      if (traceRef.current) {
        const e0 = ps[0].e, mx = 450, my = 232;
        ctx.textAlign = "center";
        // CONSTANT: the AMOUNT of charge never changes — only its energy does.
        // (Charge is measured in coulombs, C. Current = coulombs per second = amps.)
        ctx.fillStyle = C.faint; ctx.font = "600 10px 'IBM Plex Mono', monospace";
        ctx.fillText(kids ? "CHARGE IT CARRIES" : "CHARGE CARRIED", mx, my - 66);
        ctx.fillStyle = C.ink; ctx.font = "600 13px 'IBM Plex Mono', monospace";
        ctx.fillText(kids ? "always the same — never used up" : "1 C (coulomb) — never changes", mx, my - 49);
        ctx.fillStyle = C.faint; ctx.font = "600 10px 'IBM Plex Mono', monospace";
        ctx.fillText(kids ? "THIS DROP’S ENERGY" : "THIS CHARGE’S ENERGY", mx, my - 26);
        const bw = 150, bh = 15;
        ctx.fillStyle = C.deep;
        if (ctx.roundRect) { ctx.beginPath(); ctx.roundRect(mx - bw / 2, my - 11, bw, bh, 7.5); ctx.fill(); }
        else ctx.fillRect(mx - bw / 2, my - 11, bw, bh);
        ctx.fillStyle = eColor(e0);
        const fw = Math.max(bh, bw * e0);
        if (ctx.roundRect) { ctx.beginPath(); ctx.roundRect(mx - bw / 2, my - 11, fw, bh, 7.5); ctx.fill(); }
        else ctx.fillRect(mx - bw / 2, my - 11, fw, bh);
        ctx.fillStyle = e0 > 0.55 ? C.water : C.ink; ctx.font = "600 22px 'Newsreader', serif";
        ctx.fillText(Math.round(e0 * 100) + "%", mx, my + 34);
        ctx.fillStyle = C.faint; ctx.font = "500 10px 'IBM Plex Mono', monospace";
        ctx.fillText(e0 > 0.7 ? "charged · fresh from the battery" : e0 < 0.25 ? "spent · heading back to recharge" : "draining through the pinch", mx, my + 50);
        ctx.fillText(kids ? "the push doesn’t vanish — it leaves as heat!" : "energy in (battery) = heat out (pinch) — none vanishes", mx, my + 66);
      }

      raf = requestAnimationFrame(frame);
    }
    raf = requestAnimationFrame(frame);
    return () => { cancelAnimationFrame(raf); window.removeEventListener("resize", fit); };
  }, []);

  return (
    <>
      <div className="lab-stage">
        <canvas ref={cv} className="lab-canvas"></canvas>
        <button className={"fl-spotbtn" + (trace ? " on" : "")} onClick={() => setTrace(x => !x)}>
          {trace ? "● tracking one charge" : "◎ spotlight one charge"}
        </button>
      </div>
      <div className="fl-modebar">
        <div className="fl-seg" role="tablist">
          <button className={mode === "water" ? "on" : ""} onClick={() => setMode("water")}>💧 Water</button>
          <button className={mode === "electrons" ? "on" : ""} onClick={() => setMode("electrons")}>⚡ Electrons</button>
        </div>
        <p className="fl-modenote">
          {mode === "water"
            ? <>Water <b>bunches up</b> where it flows slowly (the wide wires) and thins where it’s fast (the pinch) — denser here, sparser there, like any fluid.</>
            : <>Real electrons <b>repel each other</b>, so they stay <b>orderly and evenly spaced</b> — no random clumps. They still <i>speed up</i> through the pinch, but in neat ranks instead of a jumble.</>}
        </p>
      </div>
    </>
  );
}

Object.assign(window, { LoopScene, FL });
