← Node Graph Editor results

Node Graph Editor

Claude haiku 5.5 · full

Initial view of Node Graph Editor

Reviews & generation details

Reviews

No human review yet. Unreviewed does not mean unsuccessful.

Generation context

Task text captured before this run. Effective runtime system instructions were not captured.

Generation prompt · a69d273fc6
# Task: Build a Logic Circuit Node-Graph Editor

Build an interactive node-graph editor (vanilla JS, no external assets, no CDN, no libraries) where users wire logic gates together and watch signals flow live. The top priority is **correct graph interaction and evaluation** — dragging wires must feel precise, and the circuit must compute the right answer every time.

## Core Specification

**Canvas & Viewport**
- An infinite pannable canvas: drag empty space to pan, mouse wheel to zoom toward the cursor (50%–200%)
- Nodes and wires live in world coordinates; all hit-testing stays accurate at every zoom level
- Subtle dot grid that pans/zooms with the world

**Nodes**
- **Inputs:** Toggle switch (click to flip on/off), Clock (pulses at a selectable rate)
- **Gates:** AND, OR, NOT, XOR, NAND, NOR — each with the standard truth table
- **Outputs:** Lamp (glows on), plus a 7-segment-style digit display driven by 4 input bits (binary → hex digit) if you want a stretch goal
- Palette (sidebar or right-click menu) to add nodes; drag nodes by their body to move them; Delete key removes the selected node and all its wires
- Every node shows its name and a live on/off state tint; input/output **ports** are clearly visible circles on the left/right edges

**Wires**
- Drag from an output port to an input port to connect; dragging from an occupied input port picks up the existing wire to re-route it
- Live wire preview follows the cursor while dragging, snapping/highlighting when over a valid target port
- Wires render as smooth bezier curves; energized wires glow, de-energized wires are dim
- Type rules: one wire per input port (connecting to an occupied port replaces the old wire); outputs fan out freely
- **Cycle rejection:** a connection that would create a feedback loop must be refused with a clear visual cue (red flash on the wire) — combinational circuits only

**Evaluation Engine**
- Propagate signal changes reactively: toggling an input re-evaluates only downstream nodes, in topological order
- Evaluation must converge in a single pass per change (topological evaluation, not iterate-until-stable guessing)
- Clock nodes tick on a timer and re-trigger evaluation of their downstream graph each pulse
- No evaluation glitches when wiring or unwiring mid-simulation

**Persistence**
- The full graph (nodes, positions, wires, toggle states, clock rates) serializes to `localStorage` and restores exactly on reload

## Visual Style
- Dark, instrument-panel aesthetic: rounded node cards with subtle depth, color-coded ports (inputs cool, outputs warm), glowing wires and lamps
- Hover states on every interactive element (ports enlarge, nodes lift slightly)
- Clean and legible at both zoom extremes — port hit targets must stay comfortable

## Technical Requirements
- Vanilla JS with HTML5 Canvas or SVG for the graph surface; all code organized with clear section comments
- Clean data model: a serializable scene of `{ nodes: [{ id, type, x, y, state }], wires: [{ from, fromPort, to, toPort }] }` — rendering, hit-testing, evaluation, and persistence all derive from it
- Tunable constants block at the top (colors, zoom limits, port radius, hit tolerance, clock rates)
- Target: smooth panning/dragging with 50+ nodes and live evaluation

## Quality Bar (must hit all)
- Every gate computes its standard truth table; a 3-gate chain updates end-to-end on one input toggle
- Zoom-to-cursor: the world point under the cursor stays under the cursor while zooming
- Port snapping and wire hit-testing are accurate at 50% and 200% zoom
- A cycle attempt (e.g. NOR feeding its own input through an inverter chain) is rejected, not hung on
- Reload restores the exact circuit, including toggle states and wire routing

## Self-Check Before Finishing
Verify with the browser tools:
- Build: Toggle → AND(1) with Toggle → NOT → Lamp; flip each toggle and assert the lamp matches the truth table both ways
- Re-wire an occupied input port and confirm the old wire is gone and evaluation reflects the new source
- Attempt to wire an output back into an upstream input of its own chain — must be refused
- Zoom in fully, drag a wire between two distant ports — the drop must land on the port under the cursor
- Reload the page and confirm the circuit is restored and still evaluating
Inspect original source 35,149 bytes · SHA-256 3e6b3dc0ecde
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Logic Lab — Node Graph Editor</title>
<style>
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:#0b0f14;color:#cfd8e3;font:13px/1.4 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;overflow:hidden}
#app{display:flex;height:100vh;width:100vw}
#side{width:212px;flex:none;background:linear-gradient(#151c25,#10161e);border-right:1px solid #222c38;padding:14px 12px;display:flex;flex-direction:column;gap:6px;box-shadow:4px 0 16px rgba(0,0,0,.35);z-index:2;overflow-y:auto}
#side h1{font-size:13px;letter-spacing:.16em;text-transform:uppercase;margin:0 0 2px;color:#8fd6b5}
#side h2{font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:#5f6e7e;margin:12px 0 2px}
#side p{color:#6b7a8a;font-size:11.5px;margin:2px 0 4px;line-height:1.5}
#side button{display:block;width:100%;text-align:left;background:#1a232e;border:1px solid #2a3644;color:#cfd8e3;border-radius:8px;padding:7px 10px;font:inherit;cursor:pointer;transition:background .15s,border-color .15s,transform .15s,box-shadow .15s}
#side button:hover{background:#243245;border-color:#4a86c5;transform:translateX(2px);box-shadow:0 0 0 1px rgba(74,134,197,.3)}
#side button:active{transform:translateX(1px) scale(.98)}
#side .row{display:flex;gap:6px}
#side .row button{flex:1;text-align:center}
#wrap{position:relative;flex:1;overflow:hidden;min-width:0}
#cv{position:absolute;inset:0;width:100%;height:100%;display:block;touch-action:none}
#hud{position:absolute;left:12px;bottom:10px;font-size:11px;color:#6b7a8a;pointer-events:none;font-variant-numeric:tabular-nums;background:rgba(11,15,20,.6);padding:3px 8px;border-radius:6px}
#toast{position:absolute;top:12px;left:50%;transform:translateX(-50%);background:rgba(60,18,24,.94);border:1px solid #ff4d5e;color:#ffd1d6;padding:7px 14px;border-radius:8px;font-size:12px;opacity:0;transition:opacity .25s;pointer-events:none;box-shadow:0 6px 20px rgba(0,0,0,.4);white-space:nowrap}
#toast.on{opacity:1}
</style>
</head>
<body>
<div id="app">
  <aside id="side">
    <h1>Logic Lab</h1>
    <p>Drag an output port to a input port to wire. Drag a wired input to re-route. Drag bodies to move. Click toggles &amp; clock rate. Del removes. Wheel zooms.</p>
    <h2>Inputs</h2>
    <button data-add="toggle">Toggle switch</button>
    <button data-add="clock">Clock</button>
    <h2>Gates</h2>
    <button data-add="and">AND</button>
    <button data-add="or">OR</button>
    <button data-add="xor">XOR</button>
    <button data-add="nand">NAND</button>
    <button data-add="nor">NOR</button>
    <button data-add="not">NOT</button>
    <h2>Outputs</h2>
    <button data-add="lamp">Lamp</button>
    <button data-add="seg">Hex display</button>
    <h2>Scene</h2>
    <div class="row"><button id="btnDemo">Demo</button><button id="btnClear">Clear</button></div>
    <button id="btnDel">Delete selected</button>
    <button id="btnView">Reset view</button>
  </aside>
  <div id="wrap">
    <canvas id="cv"></canvas>
    <div id="hud"></div>
    <div id="toast"></div>
  </div>
</div>
<script>
'use strict';

/* ==========================================================================
   1. TUNABLE CONSTANTS
   ========================================================================== */
const C = {
  storageKey: 'logic-lab-v1',
  zoomMin: 0.5, zoomMax: 2.0, wheelSensitivity: 0.0015,
  gridSpacing: 32, gridDot: '#243241', bg: '#0b0f14',
  nodeTop: '#2a3441', nodeBot: '#131921', nodeBorder: '#3a4757',
  nodeOn: '#4fe3a6', select: '#5aa9ff', text: '#e6edf5',
  headerH: 30, rowH: 24, minBodyH: 46,
  shadowIdle: 8, shadowHover: 22,
  portR: 5.5, portHoverR: 8.5, portHitPx: 12,
  wireHitPx: 7, wireSamples: 40,
  inPort: '#5ab4ff', outPort: '#ffb347',
  wireOn: '#5cffb0', wireOff: '#3a4656', wireBad: '#ff4d5e', wirePreview: '#9fd8ff',
  lampOn: '#ffd24a', lampOff: '#3b3121',
  segOn: '#ff5a3c', segOff: '#2a1b19',
  clockRates: [0.5, 1, 2, 4, 8], clockDefaultRate: 1,
  flashMs: 700, toastMs: 2400, dragThresholdPx: 3,
};

/* Node type catalogue. kind: input | gate | output */
const TYPES = {
  toggle: { name: 'Toggle',      kind: 'input',  ins: [],              outs: ['Q'],  w: 132, hdr: '#1d4658' },
  clock:  { name: 'Clock',       kind: 'input',  ins: [],              outs: ['CLK'], w: 132, hdr: '#1d4658' },
  and:    { name: 'AND',         kind: 'gate',   ins: ['A','B'],       outs: ['Y'],  w: 124, hdr: '#2b3361', sym: '&',   fn: a => a[0] && a[1] },
  or:     { name: 'OR',          kind: 'gate',   ins: ['A','B'],       outs: ['Y'],  w: 124, hdr: '#2b3361', sym: '≥1',  fn: a => a[0] || a[1] },
  xor:    { name: 'XOR',         kind: 'gate',   ins: ['A','B'],       outs: ['Y'],  w: 124, hdr: '#2b3361', sym: '=1',  fn: a => a[0] !== a[1] },
  nand:   { name: 'NAND',        kind: 'gate',   ins: ['A','B'],       outs: ['Y'],  w: 124, hdr: '#2b3361', sym: '&',   fn: a => !(a[0] && a[1]), bubble: true },
  nor:    { name: 'NOR',         kind: 'gate',   ins: ['A','B'],       outs: ['Y'],  w: 124, hdr: '#2b3361', sym: '≥1',  fn: a => !(a[0] || a[1]), bubble: true },
  not:    { name: 'NOT',         kind: 'gate',   ins: ['A'],           outs: ['Y'],  w: 112, hdr: '#2b3361', sym: '1',   fn: a => !a[0], bubble: true },
  lamp:   { name: 'Lamp',        kind: 'output', ins: ['IN'],          outs: [],     w: 112, hdr: '#4a3424' },
  seg:    { name: 'Hex Display', kind: 'output', ins: ['b0','b1','b2','b3'], outs: [], w: 176, hdr: '#4a3424' },
};
/* 7-segment encodings for 0..F: bit0=a … bit6=g */
const SEG_MAP = [0x3f,0x06,0x5b,0x4f,0x66,0x6d,0x7d,0x07,0x7f,0x6f,0x77,0x7c,0x39,0x5e,0x79,0x71];

/* ==========================================================================
   2. SCENE MODEL  —  { nodes:[{id,type,x,y,state,rate?}], wires:[{from,fromPort,to,toPort}] }
   ========================================================================== */
const scene = { nodes: [], wires: [] };
let nextId = 1;
const cam = { x: 0, y: 0, z: 1 };          // screen = world * z + (x,y)
let sel = { nodeId: null, wire: null };     // current selection
let drag = null;                            // active pointer interaction
const hover = { node: null, port: null, wire: null };
const flashes = [];                         // red cycle-rejection flashes (world curves)
const clockAcc = new Map();                 // runtime phase accumulators (not persisted)

const nodeById = id => scene.nodes.find(n => n.id === id);
const dist2 = (a, b) => (a.x - b.x) ** 2 + (a.y - b.y) ** 2;
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));

/* ==========================================================================
   3. GEOMETRY  (all local coords are relative to the node's top-left)
   ========================================================================== */
function bodyH(t) { return Math.max(C.minBodyH, Math.max(t.ins.length, 1) * C.rowH + 10); }
function nodeH(t) { return C.headerH + bodyH(t); }
function localIn(t, i) {
  const cy = C.headerH + bodyH(t) / 2;
  return { x: 0, y: cy + (i - (t.ins.length - 1) / 2) * C.rowH };
}
function localOut(t) { return { x: t.w, y: C.headerH + bodyH(t) / 2 }; }
function portWorld(n, kind, idx) {
  const t = TYPES[n.type];
  const p = kind === 'in' ? localIn(t, idx) : localOut(t);
  return { x: n.x + p.x, y: n.y + p.y };
}
function wireCurve(a, b) {
  const dx = Math.max(60, Math.abs(b.x - a.x) * 0.5);
  return [a, { x: a.x + dx, y: a.y }, { x: b.x - dx, y: b.y }, b];
}
function bezAt(c, t) {
  const u = 1 - t, a = u * u * u, b = 3 * u * u * t, cc = 3 * u * t * t, d = t * t * t;
  return { x: a * c[0].x + b * c[1].x + cc * c[2].x + d * c[3].x,
           y: a * c[0].y + b * c[1].y + cc * c[2].y + d * c[3].y };
}
function wireEnds(w) {
  const a = nodeById(w.from), b = nodeById(w.to);
  if (!a || !b) return null;
  return { a: portWorld(a, 'out', w.fromPort), b: portWorld(b, 'in', w.toPort) };
}

/* ==========================================================================
   4. CONNECTIVITY, CYCLE DETECTION, EVALUATION ENGINE
   ========================================================================== */
function inboundWire(nodeId, idx) { return scene.wires.find(w => w.to === nodeId && w.toPort === idx); }
function removeWire(w) { const i = scene.wires.indexOf(w); if (i >= 0) scene.wires.splice(i, 1); }

/* Would wiring from->to:toPort create a feedback loop?  A loop exists iff `from`
   is already reachable downstream of `to`. The wire currently occupying to:toPort
   is ignored because it is about to be replaced. */
function wouldCycle(fromId, toId, toPort) {
  if (fromId === toId) return true;
  const wires = scene.wires.filter(w => !(w.to === toId && w.toPort === toPort));
  const seen = new Set([toId]), q = [toId];
  while (q.length) {
    const id = q.shift();
    if (id === fromId) return true;
    for (const w of wires) if (w.from === id && !seen.has(w.to)) { seen.add(w.to); q.push(w.to); }
  }
  return false;
}

/* Re-evaluate the downstream closure of `seeds` in ONE topological pass (Kahn).
   Each node is computed exactly once, after all of its upstream inputs are final,
   so no glitches are possible. Input nodes (toggle/clock) keep their own state. */
function evaluate(seeds) {
  if (!seeds || !seeds.length) return;
  const byId = new Map(scene.nodes.map(n => [n.id, n]));
  const inMap = new Map(), outAdj = new Map();
  for (const w of scene.wires) {
    inMap.set(w.to + ':' + w.toPort, w);
    if (!outAdj.has(w.from)) outAdj.set(w.from, []);
    outAdj.get(w.from).push(w);
  }
  // 1) downstream closure
  const set = new Set(), stack = [];
  for (const s of seeds) if (byId.has(s) && !set.has(s)) { set.add(s); stack.push(s); }
  while (stack.length) {
    const id = stack.pop();
    for (const w of outAdj.get(id) || []) if (!set.has(w.to)) { set.add(w.to); stack.push(w.to); }
  }
  // 2) in-degree restricted to the closure
  const indeg = new Map();
  for (const id of set) indeg.set(id, 0);
  for (const w of scene.wires) if (set.has(w.from) && set.has(w.to)) indeg.set(w.to, indeg.get(w.to) + 1);
  // 3) Kahn topological pass
  const q = [];
  for (const id of set) if (indeg.get(id) === 0) q.push(id);
  while (q.length) {
    const id = q.shift();
    const n = byId.get(id), t = TYPES[n.type];
    if (t.kind !== 'input') {
      const ins = t.ins.map((_, i) => {
        const w = inMap.get(id + ':' + i), src = w && byId.get(w.from);
        return !!(src && src.state);
      });
      if (t.fn) n.state = !!t.fn(ins);
      else if (n.type === 'lamp') n.state = ins[0];
      else if (n.type === 'seg') { n.val = ins.reduce((acc, b, i) => acc | (b ? 1 << i : 0), 0); n.state = n.val !== 0; }
    }
    for (const w of outAdj.get(id) || []) {
      if (!set.has(w.to)) continue;
      const d = indeg.get(w.to) - 1;
      indeg.set(w.to, d);
      if (d === 0) q.push(w.to);
    }
  }
}

/* Connect with validation. Replaces any occupant of the target input. */
function tryConnect(fromId, fromPort, toId, toPort) {
  const a = nodeById(fromId), b = nodeById(toId);
  if (!a || !b) return { ok: false, reason: 'missing' };
  if (fromPort >= TYPES[a.type].outs.length || toPort >= TYPES[b.type].ins.length) return { ok: false, reason: 'port' };
  if (wouldCycle(fromId, toId, toPort)) return { ok: false, reason: 'cycle' };
  const old = inboundWire(toId, toPort);
  if (old) removeWire(old);
  scene.wires.push({ from: fromId, fromPort, to: toId, toPort });
  evaluate([toId]);
  return { ok: true };
}

function addNode(type, x, y) {
  const n = { id: nextId++, type, x, y, state: false };
  if (type === 'clock') n.rate = C.clockDefaultRate;
  scene.nodes.push(n);
  evaluate([n.id]);
  save();
  return n;
}
function removeNode(id) {
  const targets = [];
  scene.wires = scene.wires.filter(w => {
    if (w.from === id) targets.push(w.to);
    return w.from !== id && w.to !== id;
  });
  scene.nodes = scene.nodes.filter(n => n.id !== id);
  clockAcc.delete(id);
  evaluate(targets);
}
function onNodeClick(n) {
  if (n.type === 'toggle') { n.state = !n.state; evaluate([n.id]); }
  else if (n.type === 'clock') {
    const i = C.clockRates.indexOf(n.rate);
    n.rate = C.clockRates[(i + 1) % C.clockRates.length];
    clockAcc.set(n.id, 0);
  }
}
function deleteSelected() {
  if (sel.nodeId != null) { removeNode(sel.nodeId); sel.nodeId = null; save(); }
  else if (sel.wire && scene.wires.includes(sel.wire)) {
    const w = sel.wire; removeWire(w); evaluate([w.to]); sel.wire = null; save();
  }
}

/* ==========================================================================
   5. PERSISTENCE  (localStorage)
   ========================================================================== */
function serialize() {
  return {
    v: 1, nextId, camera: { x: cam.x, y: cam.y, z: cam.z },
    nodes: scene.nodes.map(n => {
      const o = { id: n.id, type: n.type, x: n.x, y: n.y, state: !!n.state };
      if (n.type === 'clock') o.rate = n.rate;
      return o;
    }),
    wires: scene.wires.map(w => ({ from: w.from, fromPort: w.fromPort, to: w.to, toPort: w.toPort })),
  };
}
function save() { try { localStorage.setItem(C.storageKey, JSON.stringify(serialize())); } catch (e) {} }
function load() {
  let data = null;
  try { data = JSON.parse(localStorage.getItem(C.storageKey) || 'null'); } catch (e) { data = null; }
  if (!data || !Array.isArray(data.nodes)) return false;
  const ids = new Set();
  scene.nodes = [];
  for (const o of data.nodes) {
    if (!TYPES[o.type] || !Number.isInteger(o.id) || ids.has(o.id)) continue;
    if (typeof o.x !== 'number' || typeof o.y !== 'number') continue;
    ids.add(o.id);
    const n = { id: o.id, type: o.type, x: o.x, y: o.y, state: !!o.state };
    if (o.type === 'clock') n.rate = C.clockRates.includes(o.rate) ? o.rate : C.clockDefaultRate;
    scene.nodes.push(n);
  }
  scene.wires = [];
  const used = new Set();
  for (const o of Array.isArray(data.wires) ? data.wires : []) {
    const a = nodeById(o.from), b = nodeById(o.to);
    if (!a || !b) continue;
    if (o.fromPort >= TYPES[a.type].outs.length || o.toPort >= TYPES[b.type].ins.length) continue;
    const key = o.to + ':' + o.toPort;
    if (used.has(key)) continue;
    used.add(key);
    scene.wires.push({ from: o.from, fromPort: o.fromPort, to: o.to, toPort: o.toPort });
  }
  nextId = Math.max(data.nextId || 1, ...scene.nodes.map(n => n.id + 1), 1);
  if (data.camera && [data.camera.x, data.camera.y, data.camera.z].every(Number.isFinite)) {
    cam.x = data.camera.x; cam.y = data.camera.y; cam.z = clamp(data.camera.z, C.zoomMin, C.zoomMax);
  }
  evaluate(scene.nodes.map(n => n.id));   // restore consistent derived state
  return true;
}
let saveTimer = 0;
function saveSoon() { clearTimeout(saveTimer); saveTimer = setTimeout(save, 250); }

function buildDemo() {
  scene.nodes = []; scene.wires = []; nextId = 1; clockAcc.clear();
  const A = addNode('toggle', -330, -60), B = addNode('toggle', -330, 90);
  const K = addNode('clock', -330, 230);
  const AND = addNode('and', -150, -20), NOT = addNode('not', 40, -20);
  const L1 = addNode('lamp', 220, -20), L2 = addNode('lamp', 220, 150);
  const NOR = addNode('nor', -150, 160);
  const connections = [[A,0,AND,0],[B,0,AND,1],[AND,0,NOT,0],[NOT,0,L1,0],[K,0,NOR,0],[NOR,0,L2,0]];
  for (const [f,fp,t,tp] of connections) tryConnect(f.id, fp, t.id, tp);
  save();
}

/* ==========================================================================
   6. RENDERING  (canvas 2D — world transform applied for graph layers)
   ========================================================================== */
const cv = document.getElementById('cv');
const ctx = cv.getContext('2d');
let W = 0, H = 0, DPR = 1;
function resize() {
  const r = cv.getBoundingClientRect();
  DPR = window.devicePixelRatio || 1;
  W = r.width; H = r.height;
  cv.width = Math.round(W * DPR); cv.height = Math.round(H * DPR);
}
window.addEventListener('resize', resize);
function s2w(p) { return { x: (p.x - cam.x) / cam.z, y: (p.y - cam.y) / cam.z }; }
function w2s(p) { return { x: p.x * cam.z + cam.x, y: p.y * cam.z + cam.y }; }
function strokeCurve(c) {
  ctx.beginPath(); ctx.moveTo(c[0].x, c[0].y);
  ctx.bezierCurveTo(c[1].x, c[1].y, c[2].x, c[2].y, c[3].x, c[3].y); ctx.stroke();
}
function roundRect(x, y, w, h, r) {
  ctx.beginPath();
  ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r);
  ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath();
}

function drawGrid() {
  const s = C.gridSpacing * cam.z;
  if (s < 6) return;
  ctx.fillStyle = C.gridDot;
  const ox = ((cam.x % s) + s) % s, oy = ((cam.y % s) + s) % s;
  for (let x = ox; x < W; x += s) for (let y = oy; y < H; y += s) ctx.fillRect(x - 1, y - 1, 2, 2);
}

function drawWires() {
  for (const w of scene.wires) {
    const e = wireEnds(w); if (!e) continue;
    const c = wireCurve(e.a, e.b);
    const on = !!nodeById(w.from).state;
    const selc = sel.wire === w;
    ctx.save(); ctx.lineCap = 'round';
    if (on) {
      ctx.strokeStyle = 'rgba(92,255,176,.28)'; ctx.lineWidth = 7;
      ctx.shadowColor = C.wireOn; ctx.shadowBlur = 16; strokeCurve(c);
      ctx.shadowBlur = 0; ctx.strokeStyle = C.wireOn; ctx.lineWidth = 2.2; strokeCurve(c);
    } else {
      ctx.strokeStyle = C.wireOff; ctx.lineWidth = 2; strokeCurve(c);
    }
    if (selc) { ctx.strokeStyle = C.select; ctx.lineWidth = 1; ctx.setLineDash([5, 4]); strokeCurve(c); }
    ctx.restore();
  }
}

function drawPreview() {
  if (!drag || drag.kind !== 'wire') return;
  const src = nodeById(drag.fromId); if (!src) return;
  const a = portWorld(src, 'out', drag.fromPort);
  const b = drag.target ? portWorld(drag.target.node, 'in', drag.target.idx) : drag.pos;
  const c = wireCurve(a, b);
  ctx.save(); ctx.lineCap = 'round';
  const col = drag.bad ? C.wireBad : C.wirePreview;
  ctx.strokeStyle = col; ctx.lineWidth = 2.4; ctx.setLineDash([9, 6]);
  ctx.shadowColor = col; ctx.shadowBlur = drag.bad ? 14 : 6;
  strokeCurve(c);
  ctx.restore();
}

function drawFlashes(now) {
  for (let i = flashes.length - 1; i >= 0; i--) {
    const f = flashes[i];
    const k = 1 - (now - f.t0) / C.flashMs;
    if (k <= 0) { flashes.splice(i, 1); continue; }
    ctx.save();
    ctx.globalAlpha = k; ctx.strokeStyle = C.wireBad; ctx.lineWidth = 3.5;
    ctx.shadowColor = C.wireBad; ctx.shadowBlur = 14; ctx.lineCap = 'round';
    strokeCurve(f.c);
    ctx.restore();
  }
}

function drawSevenSeg(x, y, dw, dh, val, on) {
  const t = dh * 0.1, hw = dw - 2 * t, vh = dh / 2 - t / 2 - t;
  const segs = [
    [x + t, y, hw, t],                       // a
    [x + dw - t, y + t, t, vh],              // b
    [x + dw - t, y + dh / 2 + t / 2, t, vh], // c
    [x + t, y + dh - t, hw, t],              // d
    [x, y + dh / 2 + t / 2, t, vh],          // e
    [x, y + t, t, vh],                       // f
    [x + t, y + dh / 2 - t / 2, hw, t],      // g
  ];
  const mask = SEG_MAP[val & 15];
  segs.forEach((r, i) => {
    const lit = (mask >> i) & 1;
    ctx.fillStyle = lit ? (on ? C.segOn : '#5a2a22') : C.segOff;
    if (lit) { ctx.shadowColor = C.segOn; ctx.shadowBlur = 8; }
    ctx.beginPath(); ctx.roundRect ? ctx.roundRect(r[0], r[1], r[2], r[3], 1.5) : ctx.rect(r[0], r[1], r[2], r[3]);
    ctx.fill();
    ctx.shadowBlur = 0;
  });
}

function drawPort(x, y, kind, lit, hot, target, bad) {
  const r = (hot ? C.portHoverR : C.portR) + (target ? 1.5 : 0);
  ctx.save();
  const base = kind === 'in' ? C.inPort : C.outPort;
  if (hot || target) { ctx.shadowColor = bad ? C.wireBad : base; ctx.shadowBlur = 10; }
  ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2);
  ctx.fillStyle = '#0b0f14'; ctx.fill();
  ctx.lineWidth = target ? 2.5 : 2;
  ctx.strokeStyle = target ? (bad ? C.wireBad : '#ffffff') : base;
  ctx.stroke();
  ctx.shadowBlur = 0;
  ctx.beginPath(); ctx.arc(x, y, r * 0.45, 0, Math.PI * 2);
  ctx.fillStyle = lit ? '#ffffff' : base; ctx.globalAlpha = lit ? 1 : 0.55; ctx.fill();
  ctx.restore();
}

function headerColor(t) { return t.hdr; }

function drawNode(n) {
  const t = TYPES[n.type];
  const w = t.w, h = nodeH(t);
  const hov = hover.node === n;
  const selc = sel.nodeId === n.id;
  const lift = hov ? -2 : 0;
  const cy0 = C.headerH, bh = bodyH(t), cy = cy0 + bh / 2;

  ctx.save();
  ctx.translate(n.x, n.y + lift);

  // body with depth shadow
  ctx.shadowColor = 'rgba(0,0,0,.6)';
  ctx.shadowBlur = hov ? C.shadowHover : C.shadowIdle;
  ctx.shadowOffsetY = hov ? 8 : 4;
  const g = ctx.createLinearGradient(0, 0, 0, h);
  if (n.state) { g.addColorStop(0, '#22433c'); g.addColorStop(1, '#10231e'); }
  else { g.addColorStop(0, C.nodeTop); g.addColorStop(1, C.nodeBot); }
  roundRect(0, 0, w, h, 10); ctx.fillStyle = g; ctx.fill();
  ctx.shadowColor = 'transparent'; ctx.shadowBlur = 0; ctx.shadowOffsetY = 0;

  // header band
  ctx.save(); roundRect(0, 0, w, h, 10); ctx.clip();
  ctx.fillStyle = headerColor(t); ctx.fillRect(0, 0, w, cy0);
  ctx.fillStyle = 'rgba(255,255,255,.04)'; ctx.fillRect(0, cy0 - 1, w, 1);
  ctx.restore();

  // state LED in header
  ctx.beginPath(); ctx.arc(w - 13, cy0 / 2, 4, 0, Math.PI * 2);
  ctx.fillStyle = n.state ? C.nodeOn : '#2b3543';
  if (n.state) { ctx.shadowColor = C.nodeOn; ctx.shadowBlur = 8; }
  ctx.fill(); ctx.shadowBlur = 0;

  // border (selection / state tint)
  roundRect(0.5, 0.5, w - 1, h - 1, 10);
  ctx.lineWidth = selc ? 2 : 1;
  ctx.strokeStyle = selc ? C.select : (n.state ? C.nodeOn : C.nodeBorder);
  if (n.state && !selc) { ctx.shadowColor = C.nodeOn; ctx.shadowBlur = 10; }
  ctx.stroke(); ctx.shadowBlur = 0;

  // title
  ctx.fillStyle = C.text; ctx.font = '600 11px system-ui,sans-serif';
  ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
  ctx.fillText(t.name.toUpperCase(), 12, cy0 / 2 + 0.5);

  // body content
  if (n.type === 'toggle') {
    const px = 16, py = cy - 11;
    roundRect(px, py, 58, 22, 11);
    ctx.fillStyle = n.state ? '#1d7656' : '#262f3b'; ctx.fill();
    ctx.beginPath(); ctx.arc(n.state ? px + 47 : px + 11, cy, 8.5, 0, Math.PI * 2);
    ctx.fillStyle = '#eef6ff'; ctx.fill();
    ctx.fillStyle = n.state ? C.nodeOn : C.dim || '#6b7a8a';
    ctx.font = '700 11px system-ui,sans-serif'; ctx.textAlign = 'left';
    ctx.fillText(n.state ? 'ON' : 'OFF', px + 68, cy + 0.5);
  } else if (n.type === 'clock') {
    ctx.beginPath(); ctx.arc(20, cy, 6, 0, Math.PI * 2);
    ctx.fillStyle = n.state ? C.nodeOn : '#2b3543';
    if (n.state) { ctx.shadowColor = C.nodeOn; ctx.shadowBlur = 10; }
    ctx.fill(); ctx.shadowBlur = 0;
    ctx.fillStyle = C.text; ctx.font = '600 13px ui-monospace,Menlo,monospace'; ctx.textAlign = 'left';
    ctx.fillText(n.rate + ' Hz', 34, cy + 0.5);
    ctx.fillStyle = '#5f6e7e'; ctx.font = '10px system-ui,sans-serif';
    ctx.fillText('click: rate', 34, cy + 16);
  } else if (t.kind === 'gate') {
    ctx.fillStyle = '#dfe8f2'; ctx.font = '700 17px system-ui,sans-serif';
    ctx.textAlign = 'center'; ctx.fillText(t.sym, w / 2 - 6, cy + 0.5);
    t.ins.forEach((lbl, i) => {
      const p = localIn(t, i);
      ctx.fillStyle = '#6b7a8a'; ctx.font = '600 9px system-ui,sans-serif';
      ctx.textAlign = 'left'; ctx.fillText(lbl, 12, p.y + 0.5);
    });
    if (t.bubble) {
      ctx.beginPath(); ctx.arc(w - 11, cy, 3.2, 0, Math.PI * 2);
      ctx.strokeStyle = '#dfe8f2'; ctx.lineWidth = 1.4; ctx.stroke();
    }
  } else if (n.type === 'lamp') {
    const r = 17;
    if (n.state) {
      const rg = ctx.createRadialGradient(w / 2, cy, 2, w / 2, cy, r * 2.4);
      rg.addColorStop(0, 'rgba(255,210,74,.75)'); rg.addColorStop(1, 'rgba(255,210,74,0)');
      ctx.fillStyle = rg; ctx.beginPath(); ctx.arc(w / 2, cy, r * 2.4, 0, Math.PI * 2); ctx.fill();
    }
    ctx.beginPath(); ctx.arc(w / 2, cy, r, 0, Math.PI * 2);
    ctx.fillStyle = n.state ? C.lampOn : C.lampOff; ctx.fill();
    ctx.lineWidth = 1.5; ctx.strokeStyle = n.state ? '#fff3c4' : '#4a3d26'; ctx.stroke();
    t.ins.forEach((lbl, i) => {
      const p = localIn(t, i);
      ctx.fillStyle = '#6b7a8a'; ctx.font = '600 9px system-ui,sans-serif';
      ctx.textAlign = 'left'; ctx.fillText(lbl, 12, p.y + 0.5);
    });
  } else if (n.type === 'seg') {
    const dx = 46, dy = cy0 + 8, dw = w - dx - 12, dh = bh - 16;
    roundRect(dx - 6, dy - 4, dw + 12, dh + 8, 6); ctx.fillStyle = '#07090c'; ctx.fill();
    drawSevenSeg(dx, dy, dw, dh, n.val || 0, !!n.state);
    t.ins.forEach((lbl, i) => {
      const p = localIn(t, i);
      ctx.fillStyle = '#6b7a8a'; ctx.font = '600 9px system-ui,sans-serif';
      ctx.textAlign = 'left'; ctx.fillText(lbl, 12, p.y + 0.5);
    });
  }

  // ports
  t.ins.forEach((_, i) => {
    const p = localIn(t, i);
    const src = inboundWire(n.id, i);
    const lit = !!(src && nodeById(src.from) && nodeById(src.from).state);
    const hot = hover.port && hover.port.node === n && hover.port.kind === 'in' && hover.port.idx === i;
    const tgt = drag && drag.kind === 'wire' && drag.target && drag.target.node === n && drag.target.idx === i;
    drawPort(p.x, p.y, 'in', lit, hot, tgt, drag && drag.bad);
  });
  t.outs.forEach((_, j) => {
    const p = localOut(t);
    const hot = hover.port && hover.port.node === n && hover.port.kind === 'out' && hover.port.idx === j;
    drawPort(p.x, p.y, 'out', !!n.state, hot, false, false);
  });
  ctx.restore();
}

function render(now) {
  ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
  ctx.fillStyle = C.bg; ctx.fillRect(0, 0, W, H);
  drawGrid();
  ctx.save();
  ctx.translate(cam.x, cam.y); ctx.scale(cam.z, cam.z);
  drawWires();
  drawPreview();
  for (const n of scene.nodes) drawNode(n);
  drawFlashes(now);
  ctx.restore();
}

/* ==========================================================================
   7. HIT TESTING  (screen tolerances converted to world units via zoom)
   ========================================================================== */
function hitPort(w, kindFilter) {
  const tol2 = (C.portHitPx / cam.z) ** 2;
  let best = null, bd = tol2;
  for (const n of scene.nodes) {
    const t = TYPES[n.type];
    if (!kindFilter || kindFilter === 'in') {
      for (let i = 0; i < t.ins.length; i++) {
        const p = portWorld(n, 'in', i), d = dist2(p, w);
        if (d <= bd) { bd = d; best = { node: n, kind: 'in', idx: i, x: p.x, y: p.y }; }
      }
    }
    if (!kindFilter || kindFilter === 'out') {
      for (let j = 0; j < t.outs.length; j++) {
        const p = portWorld(n, 'out', j), d = dist2(p, w);
        if (d <= bd) { bd = d; best = { node: n, kind: 'out', idx: j, x: p.x, y: p.y }; }
      }
    }
  }
  return best;
}
function hitNode(w) {
  for (let i = scene.nodes.length - 1; i >= 0; i--) {
    const n = scene.nodes[i], t = TYPES[n.type];
    if (w.x >= n.x && w.x <= n.x + t.w && w.y >= n.y && w.y <= n.y + nodeH(t)) return n;
  }
  return null;
}
function hitWire(p) {
  let best = null, bd = C.wireHitPx;
  for (const w of scene.wires) {
    const e = wireEnds(w); if (!e) continue;
    const c = wireCurve(e.a, e.b);
    for (let k = 0; k <= C.wireSamples; k++) {
      const s = w2s(bezAt(c, k / C.wireSamples));
      const d = Math.hypot(s.x - p.x, s.y - p.y);
      if (d < bd) { bd = d; best = w; }
    }
  }
  return best;
}

/* ==========================================================================
   8. POINTER / KEYBOARD INTERACTION
   ========================================================================== */
const pos = e => { const r = cv.getBoundingClientRect(); return { x: e.clientX - r.left, y: e.clientY - r.top }; };

function showToast(msg) {
  const el = document.getElementById('toast');
  el.textContent = msg; el.classList.add('on');
  clearTimeout(showToast._t);
  showToast._t = setTimeout(() => el.classList.remove('on'), C.toastMs);
}
function flashCurve(fromId, fromPort, toNode, toIdx) {
  const src = nodeById(fromId);
  if (!src) return;
  flashes.push({ c: wireCurve(portWorld(src, 'out', fromPort), portWorld(toNode, 'in', toIdx)), t0: performance.now() });
}
function restoreDragged() {
  if (drag && drag.restore) { scene.wires.push(drag.restore); evaluate([drag.restore.to]); }
}

function onDown(e) {
  if (e.button !== 0) return;
  cv.setPointerCapture(e.pointerId);
  const p = pos(e), w = s2w(p);
  const port = hitPort(w);
  if (port) {
    if (port.kind === 'out') {
      drag = { kind: 'wire', fromId: port.node.id, fromPort: port.idx, restore: null, pos: w, target: null, bad: false };
    } else {
      const wire = inboundWire(port.node.id, port.idx);
      if (!wire) return;                       // empty input: nothing to pick up
      removeWire(wire);                        // pick up existing wire and re-route it
      evaluate([wire.to]);
      drag = { kind: 'wire', fromId: wire.from, fromPort: wire.fromPort, restore: wire, pos: w, target: null, bad: false };
    }
    sel = { nodeId: null, wire: null };
    return;
  }
  const node = hitNode(w);
  if (node) {
    sel = { nodeId: node.id, wire: null };
    scene.nodes.splice(scene.nodes.indexOf(node), 1); scene.nodes.push(node); // bring to front
    drag = { kind: 'node', node, ox: w.x - node.x, oy: w.y - node.y, sx: p.x, sy: p.y, moved: false };
    return;
  }
  const wire = hitWire(p);
  if (wire) { sel = { nodeId: null, wire }; drag = null; return; }
  sel = { nodeId: null, wire: null };
  drag = { kind: 'pan', sx: p.x, sy: p.y, cx: cam.x, cy: cam.y };
}

function onMove(e) {
  const p = pos(e), w = s2w(p);
  if (!drag) {
    hover.port = hitPort(w);
    hover.node = hover.port ? hover.port.node : hitNode(w);
    hover.wire = (!hover.port && !hover.node) ? hitWire(p) : null;
  } else if (drag.kind === 'pan') {
    cam.x = drag.cx + (p.x - drag.sx); cam.y = drag.cy + (p.y - drag.sy);
    saveSoon();
  } else if (drag.kind === 'node') {
    if (!drag.moved && Math.hypot(p.x - drag.sx, p.y - drag.sy) > C.dragThresholdPx) drag.moved = true;
    if (drag.moved) { drag.node.x = w.x - drag.ox; drag.node.y = w.y - drag.oy; }
  } else if (drag.kind === 'wire') {
    drag.pos = w;
    drag.target = hitPort(w, 'in');
    drag.bad = !!drag.target && wouldCycle(drag.fromId, drag.target.node.id, drag.target.idx);
  }
}

function onUp(e) {
  if (!drag) return;
  const p = pos(e), w = s2w(p);
  const d = drag; drag = null;
  if (d.kind === 'node') {
    if (!d.moved) onNodeClick(d.node);
    save();
  } else if (d.kind === 'wire') {
    const t = hitPort(w, 'in');
    if (t) {
      const r = tryConnect(d.fromId, d.fromPort, t.node.id, t.idx);
      if (!r.ok) {
        if (r.reason === 'cycle') {
          flashCurve(d.fromId, d.fromPort, t.node, t.idx);
          showToast('Connection refused: would create a feedback loop');
        }
        restoreDragged();
      }
    } else {
      restoreDragged();
    }
    save();
  }
}
function cancelDrag() { if (drag && drag.kind === 'wire') restoreDragged(); drag = null; }

cv.addEventListener('pointerdown', onDown);
cv.addEventListener('pointermove', onMove);
cv.addEventListener('pointerup', onUp);
cv.addEventListener('pointercancel', cancelDrag);
cv.addEventListener('wheel', e => {
  e.preventDefault();
  const p = pos(e), before = s2w(p);
  const f = clamp(Math.exp(-e.deltaY * C.wheelSensitivity), 0.8, 1.25);
  cam.z = clamp(cam.z * f, C.zoomMin, C.zoomMax);
  cam.x = p.x - before.x * cam.z;               // keep world point under cursor fixed
  cam.y = p.y - before.y * cam.z;
  saveSoon();
}, { passive: false });
cv.addEventListener('contextmenu', e => e.preventDefault());

window.addEventListener('keydown', e => {
  if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA')) return;
  if (e.key === 'Delete' || e.key === 'Backspace') { e.preventDefault(); deleteSelected(); }
  else if (e.key === 'Escape') cancelDrag();
});

/* Palette */
function addAtViewCenter(type) {
  const c = s2w({ x: W / 2, y: H / 2 });
  const t = TYPES[type];
  const jitter = (scene.nodes.length % 6) * 18;
  addNode(type, Math.round(c.x - t.w / 2 + jitter), Math.round(c.y - nodeH(t) / 2 + jitter));
}
document.querySelectorAll('[data-add]').forEach(b => b.addEventListener('click', () => addAtViewCenter(b.dataset.add)));
document.getElementById('btnDemo').onclick = () => { buildDemo(); showToast('Demo circuit loaded'); };
document.getElementById('btnClear').onclick = () => { scene.nodes = []; scene.wires = []; clockAcc.clear(); sel = { nodeId: null, wire: null }; save(); };
document.getElementById('btnDel').onclick = () => deleteSelected();
document.getElementById('btnView').onclick = () => { cam.x = W / 2; cam.y = H / 2; cam.z = 1; saveSoon(); };

/* ==========================================================================
   9. MAIN LOOP  (clock ticks → downstream re-evaluation; render; HUD)
   ========================================================================== */
let lastT = performance.now(), hudT = 0;
function frame(now) {
  const dt = Math.min(0.1, (now - lastT) / 1000); lastT = now;
  for (const n of scene.nodes) {
    if (n.type !== 'clock') continue;
    const half = 0.5 / n.rate;                 // one half-period per toggle
    let acc = (clockAcc.get(n.id) || 0) + dt, pulsed = false;
    while (acc >= half) { acc -= half; n.state = !n.state; pulsed = true; }
    clockAcc.set(n.id, acc);
    if (pulsed) evaluate([n.id]);             // re-trigger downstream on each edge
  }
  if (drag && drag.kind === 'pan') cv.style.cursor = 'grabbing';
  else if (drag && drag.kind === 'node') cv.style.cursor = 'grabbing';
  else if (drag && drag.kind === 'wire') cv.style.cursor = 'crosshair';
  else if (hover.port) cv.style.cursor = 'crosshair';
  else if (hover.node) cv.style.cursor = 'grab';
  else cv.style.cursor = 'default';
  render(now);
  if (now - hudT > 250) {
    hudT = now;
    const clk = scene.nodes.filter(n => n.type === 'clock').length;
    document.getElementById('hud').textContent =
      `${scene.nodes.length} nodes · ${scene.wires.length} wires · ${clk} clock · zoom ${Math.round(cam.z * 100)}%`;
  }
  requestAnimationFrame(frame);
}

/* ==========================================================================
   10. INIT  +  TEST HOOKS
   ========================================================================== */
resize();
cam.x = W / 2; cam.y = H / 2;
if (!load()) buildDemo();
window.addEventListener('beforeunload', save);
requestAnimationFrame(frame);

window.LogicLab = {
  scene, cam, TYPES, C,
  addNode, tryConnect, evaluate, wouldCycle, removeNode, save, load, serialize,
  s2w, w2s, nodeById, hitPort, hitWire,
  portScreen(id, kind, idx) { const n = nodeById(id); return w2s(portWorld(n, kind, idx)); },
  nodeScreen(id) { const n = nodeById(id); return w2s({ x: n.x, y: n.y }); },
  toggleNode(id) { const n = nodeById(id); onNodeClick(n); evaluate([id]); save(); },
  get drag() { return drag; },
  get sel() { return sel; },
};
</script>
</body>
</html>
<!-- agent-meta {"model":"anthropic/claude-haiku-5.5","provider":"openrouter","persona":"full","sessionId":"406de584-b85f-4b05-adaf-680fef343882","tokensIn":96606,"tokensOut":117282,"tokensTotal":213888,"cost":0.062988515,"turns":5,"toolCalls":5,"failedToolCalls":1,"timestamp":"2026-10-08T03:52:24.537Z"} -->