← Node Graph Editor results

Node Graph Editor

muse spark 1.2 · basic

Initial view of Node Graph Editor

Browser check: Artifact runtime error. This is a technical check, not a quality review.

Reviews & generation details

Reviews

No human review yet. Unreviewed does not mean unsuccessful.

Generation context

Imported historical artifact. Its exact original instructions and lineage are not known.

Generation prompt · exact text unavailable

See the current prompt for reference. It may differ from the instructions used to generate this artifact.

Inspect original source 47,841 bytes · SHA-256 ba4528bc4afd
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Logic Node Graph — Instrument Panel</title>
<style>
  :root{
    --bg:#070b13;
    --panel:#0f1623;
    --panel2:#131e32;
    --card:#1a2640;
    --card2:#1e2e4a;
    --border:#24344f;
    --border2:#2b3e5e;
    --text:#e6edf8;
    --muted:#8a9bb8;
    --accent:#f59e0b;
    --accent2:#38bdf8;
    --glow-amber:0 0 10px rgba(245,158,11,.7),0 0 22px rgba(245,158,11,.35);
    --glow-blue:0 0 10px rgba(56,189,248,.6);
    --radius:14px;
  }
  *{box-sizing:border-box}
  html,body{height:100%;margin:0;background:var(--bg);color:var(--text);font-family:Inter,system-ui,Segoe UI,Roboto,Helvetica,Arial,sans-serif;overflow:hidden}
  #app{display:grid;grid-template-columns:260px 1fr 300px;grid-template-rows:56px 1fr;height:100vh}
  header{grid-column:1/4;display:flex;align-items:center;gap:16px;padding:0 16px;background:linear-gradient(180deg,#0f1a2e 0%,#0c1426 100%);border-bottom:1px solid var(--border);z-index:5}
  header h1{font-size:15px;letter-spacing:.12em;font-weight:800;margin:0;display:flex;align-items:center;gap:10px}
  header h1 i{width:28px;height:28px;border-radius:8px;background:linear-gradient(135deg,#f59e0b,#ef4444);display:grid;place-items:center;font-style:normal;font-size:14px;box-shadow:var(--glow-amber)}
  .hdr-actions{margin-left:auto;display:flex;gap:8px;align-items:center}
  .btn{appearance:none;border:1px solid var(--border);background:#16233d;color:var(--text);padding:7px 12px;border-radius:10px;font-size:12px;font-weight:600;cursor:pointer;transition:.15s}
  .btn:hover{transform:translateY(-1px);border-color:var(--border2);background:#1a2a4a}
  .btn.primary{background:linear-gradient(180deg,#f59e0b,#d97706);border-color:#b45309;color:#1a1000;box-shadow:0 4px 14px rgba(245,158,11,.3)}
  .btn.ghost{background:transparent}
  .zoom-group{display:flex;align-items:center;gap:6px;background:#0e1a30;border:1px solid var(--border);padding:4px;border-radius:999px}
  .zoom-group button{width:28px;height:28px;border-radius:999px;border:1px solid var(--border);background:#16233d;color:var(--text);cursor:pointer}
  .zoom-group span{font-size:12px;min-width:52px;text-align:center;color:var(--muted);font-variant-numeric:tabular-nums}
  /* left palette */
  #palette{background:var(--panel);border-right:1px solid var(--border);padding:14px 12px;overflow:auto}
  .group{margin-bottom:18px}
  .group h3{font-size:10px;letter-spacing:.14em;color:var(--muted);margin:0 0 8px 4px;text-transform:uppercase}
  .pal-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}
  .pal-btn{display:flex;flex-direction:column;align-items:center;gap:6px;padding:10px 6px;background:linear-gradient(180deg,var(--card) 0%,#15223a 100%);border:1px solid var(--border);border-radius:12px;cursor:pointer;transition:.15s;color:var(--text)}
  .pal-btn:hover{transform:translateY(-2px);border-color:#3a4f74;box-shadow:0 6px 18px rgba(0,0,0,.35)}
  .pal-btn b{font-size:11px;letter-spacing:.04em}
  .pal-btn small{font-size:9px;color:var(--muted);letter-spacing:.06em;text-transform:uppercase}
  .pal-icon{width:28px;height:28px;border-radius:8px;display:grid;place-items:center;font-weight:800;font-size:13px;border:1px solid rgba(255,255,255,.08)}
  .pal-icon.in{background:rgba(56,189,248,.14);color:#7dd3fc;border-color:rgba(56,189,248,.3)}
  .pal-icon.gate{background:rgba(148,163,184,.12);color:#e2e8f0}
  .pal-icon.out{background:rgba(245,158,11,.15);color:#fcd34d;border-color:rgba(245,158,11,.3)}
  .hint{font-size:11px;color:var(--muted);line-height:1.5;background:#0e1a30;border:1px solid var(--border);padding:10px;border-radius:10px;margin-top:8px}
  .hint kbd{background:#1a2a48;border:1px solid #2a3c5e;border-bottom-width:2px;padding:1px 6px;border-radius:6px;font-size:10px;color:#cbd5e1}
  /* viewport */
  #viewport{position:relative;overflow:hidden;background:#070b13;cursor:grab}
  #viewport.panning{cursor:grabbing}
  #viewport.grab-wire{cursor:crosshair}
  #grid{position:absolute;inset:0;pointer-events:none;opacity:.9}
  #world{position:absolute;left:0;top:0;width:0;height:0;transform-origin:0 0;will-change:transform}
  #wire-layer{position:absolute;left:0;top:0;overflow:visible;pointer-events:none}
  #node-layer{position:absolute;left:0;top:0}
  /* nodes */
  .node{position:absolute;width:148px;background:linear-gradient(180deg,var(--card2) 0%,#16233d 100%);border:1px solid var(--border);border-radius:14px;box-shadow:0 8px 24px rgba(0,0,0,.45), inset 0 1px 0 rgba(255,255,255,.06);user-select:none;transition:box-shadow .15s, transform .15s, border-color .15s}
  .node:hover{transform:translateY(-1px);box-shadow:0 12px 30px rgba(0,0,0,.55);border-color:#324867}
  .node.selected{border-color:#f59e0b;box-shadow:0 0 0 1px rgba(245,158,11,.5),0 12px 30px rgba(0,0,0,.6), var(--glow-amber)}
  .node.live{border-color:rgba(245,158,11,.35)}
  .node-head{display:flex;align-items:center;gap:8px;padding:10px 10px 8px;cursor:grab;border-radius:14px 14px 0 0}
  .node-head:active{cursor:grabbing}
  .node-type{width:28px;height:28px;border-radius:8px;display:grid;place-items:center;font-size:11px;font-weight:900;letter-spacing:.02em;flex-shrink:0;border:1px solid rgba(255,255,255,.08)}
  .node-type.t-in{background:rgba(56,189,248,.18);color:#7dd3fc;border-color:rgba(56,189,248,.35)}
  .node-type.t-gate{background:#0f1d33;color:#cbd5e1}
  .node-type.t-out{background:rgba(245,158,11,.18);color:#fcd34d;border-color:rgba(245,158,11,.35)}
  .node-title{flex:1;min-width:0}
  .node-title strong{display:block;font-size:11px;letter-spacing:.06em;text-transform:uppercase;line-height:1}
  .node-title span{font-size:10px;color:var(--muted)}
  .led{width:10px;height:10px;border-radius:50%;background:#1f2937;border:1px solid #334155;box-shadow:inset 0 1px 2px rgba(0,0,0,.5);transition:.2s}
  .led.on{background:#f59e0b;border-color:#fbbf24;box-shadow:0 0 8px #f59e0b,0 0 18px rgba(245,158,11,.6),inset 0 1px 2px rgba(255,255,255,.6)}
  .led.blue.on{background:#38bdf8;border-color:#7dd3fc;box-shadow:0 0 8px #38bdf8,0 0 18px rgba(56,189,248,.6)}
  .node-body{padding:8px 10px 10px}
  .toggle-btn{width:100%;padding:8px;border-radius:10px;border:1px solid var(--border);background:#0e1a30;color:var(--text);font-weight:800;letter-spacing:.08em;font-size:11px;cursor:pointer;transition:.15s;display:flex;align-items:center;justify-content:center;gap:8px}
  .toggle-btn.on{background:linear-gradient(180deg,#f59e0b,#d97706);color:#1a1000;border-color:#92400e;box-shadow:var(--glow-amber)}
  .toggle-btn.off{background:#0e1a30}
  .clock-row{display:flex;gap:6px;align-items:center}
  .clock-row select{flex:1;background:#0e1a30;border:1px solid var(--border);color:var(--text);border-radius:8px;padding:6px;font-size:11px}
  .seg{display:grid;place-items:center;background:#020617;border:1px solid #1e293b;border-radius:10px;padding:8px 0}
  .seg-char{font-family:ui-monospace,Menlo,Monaco,monospace;font-size:28px;font-weight:900;letter-spacing:.06em;line-height:1}
  .seg-char.on{color:#f59e0b;text-shadow:0 0 10px rgba(245,158,11,.8),0 0 20px rgba(245,158,11,.4)}
  .seg-char.off{color:#1e293b}
  .seg-bits{font-size:9px;color:var(--muted);letter-spacing:.08em;margin-top:4px}
  .lamp{height:38px;border-radius:10px;display:grid;place-items:center;background:radial-gradient(120px 60px at 50% 30%, rgba(255,255,255,.08), transparent 60%), #020617;border:1px solid #1e293b;position:relative;overflow:hidden}
  .lamp.on{background:radial-gradient(180px 90px at 50% 30%, rgba(245,158,11,.55), transparent 65%), radial-gradient(120px 80px at 50% 50%, rgba(245,158,11,.9), rgba(120,60,0,.9));border-color:#f59e0b;box-shadow:0 0 18px rgba(245,158,11,.7), inset 0 0 20px rgba(255,255,255,.25)}
  .lamp-dot{width:18px;height:18px;border-radius:50%;background:#0f172a;border:1px solid #334155}
  .lamp.on .lamp-dot{background:#fff;box-shadow:0 0 10px #fff,0 0 18px #f59e0b}
  .gate-label{font-family:ui-monospace,monospace;font-size:11px;color:#94a3b8;text-align:center;padding:4px 0 2px;letter-spacing:.12em}
  .io-row{display:flex;justify-content:space-between;align-items:center;gap:6px;margin-top:2px}
  .port{width:14px;height:14px;border-radius:50%;border:2px solid #334155;background:#0f172a;position:absolute;cursor:pointer;transition:transform .15s, box-shadow .15s, border-color .15s;z-index:2}
  .port:hover{transform:scale(1.35)}
  .port.input{left:-7px;background:#0b1e2e;border-color:#38bdf8;box-shadow:0 0 0 3px rgba(56,189,248,.12)}
  .port.output{right:-7px;background:#2a1a06;border-color:#f59e0b;box-shadow:0 0 0 3px rgba(245,158,11,.12)}
  .port.input.highlight{border-color:#7dd3fc;box-shadow:0 0 0 6px rgba(56,189,248,.25),0 0 12px rgba(56,189,248,.6);transform:scale(1.45)}
  .port.output.highlight{border-color:#fcd34d;box-shadow:0 0 0 6px rgba(245,158,11,.25),0 0 12px rgba(245,158,11,.6);transform:scale(1.45)}
  .port.occupied::after{content:"";position:absolute;inset:3px;border-radius:50%;background:currentColor;opacity:.9}
  .port.input{color:#38bdf8}
  .port.output{color:#f59e0b}
  .port-label{position:absolute;top:50%;transform:translateY(-50%);font-size:8px;letter-spacing:.08em;color:var(--muted);pointer-events:none;white-space:nowrap}
  .port.input + .port-label{left:14px}
  .port.output + .port-label{right:14px}
  /* right panel */
  #inspector{background:var(--panel);border-left:1px solid var(--border);padding:14px;overflow:auto}
  #inspector h2{font-size:11px;letter-spacing:.14em;color:var(--muted);margin:0 0 12px;text-transform:uppercase}
  .stat-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:14px}
  .stat{background:#0e1a30;border:1px solid var(--border);border-radius:10px;padding:10px;text-align:center}
  .stat strong{display:block;font-size:18px;line-height:1}
  .stat span{font-size:10px;color:var(--muted);letter-spacing:.08em;text-transform:uppercase}
  .sel-card{background:linear-gradient(180deg,#13203a,#0e1a30);border:1px solid var(--border);border-radius:12px;padding:12px}
  .sel-card h3{margin:0 0 6px;font-size:12px}
  .sel-card p{margin:0;color:var(--muted);font-size:11px;line-height:1.5}
  .wire-legend{display:flex;gap:10px;align-items:center;margin:10px 0;font-size:11px;color:var(--muted)}
  .wire-legend i{width:26px;height:4px;border-radius:99px;display:inline-block}
  /* toast */
  #toast{position:fixed;left:50%;bottom:18px;transform:translateX(-50%) translateY(80px);background:#1a2338;border:1px solid #334155;color:var(--text);padding:10px 14px;border-radius:999px;font-size:12px;font-weight:600;box-shadow:0 10px 30px rgba(0,0,0,.5);transition:.35s;z-index:50;opacity:0;pointer-events:none}
  #toast.show{transform:translateX(-50%) translateY(0);opacity:1}
  /* responsive */
  @media(max-width:980px){#app{grid-template-columns:1fr;grid-template-rows:56px auto 1fr auto} #palette{grid-column:1} #inspector{grid-column:1} #viewport{height:520px}}
</style>
</head>
<body>
<div id="app">
  <header>
    <h1><i>◈</i> LOGIC NODE-GRAPH</h1>
    <div style="font-size:11px;color:var(--muted);letter-spacing:.06em">DRAG SPACE TO PAN • WHEEL TO ZOOM • DRAG PORTS TO WIRE</div>
    <div class="hdr-actions">
      <div class="zoom-group">
        <button id="zoomOut">−</button>
        <span id="zoomLabel">100%</span>
        <button id="zoomIn">+</button>
        <button id="zoomFit" title="Center view" style="width:auto;padding:0 8px;font-size:11px">Fit</button>
      </div>
      <button class="btn ghost" id="btnClear">Clear</button>
      <button class="btn primary" id="btnDemo">Load Demo</button>
    </div>
  </header>

  <aside id="palette">
    <div class="group">
      <h3>Inputs</h3>
      <div class="pal-grid">
        <button class="pal-btn" data-add="TOGGLE"><span class="pal-icon in">◯</span><b>Toggle</b><small>Click to flip</small></button>
        <button class="pal-btn" data-add="CLOCK"><span class="pal-icon in">◷</span><b>Clock</b><small>Pulse</small></button>
      </div>
    </div>
    <div class="group">
      <h3>Gates</h3>
      <div class="pal-grid">
        <button class="pal-btn" data-add="AND"><span class="pal-icon gate">∧</span><b>AND</b><small>A·B</small></button>
        <button class="pal-btn" data-add="OR"><span class="pal-icon gate">∨</span><b>OR</b><small>A+B</small></button>
        <button class="pal-btn" data-add="NOT"><span class="pal-icon gate">¬</span><b>NOT</b><small>!A</small></button>
        <button class="pal-btn" data-add="XOR"><span class="pal-icon gate">⊕</span><b>XOR</b><small>A⊕B</small></button>
        <button class="pal-btn" data-add="NAND"><span class="pal-icon gate">⊼</span><b>NAND</b><small>!(A·B)</small></button>
        <button class="pal-btn" data-add="NOR"><span class="pal-icon gate">⊽</span><b>NOR</b><small>!(A+B)</small></button>
      </div>
    </div>
    <div class="group">
      <h3>Outputs</h3>
      <div class="pal-grid">
        <button class="pal-btn" data-add="LAMP"><span class="pal-icon out">●</span><b>Lamp</b><small>Glows on 1</small></button>
        <button class="pal-btn" data-add="HEX"><span class="pal-icon out">#</span><b>Hex</b><small>4-bit → 0-F</small></button>
      </div>
    </div>
    <div class="hint">
      <b style="color:var(--text)">Wiring</b><br>
      Drag <span style="color:#f59e0b">warm output</span> → <span style="color:#38bdf8">cool input</span>. One wire per input (replaces old). Output fans out.<br><br>
      <b style="color:var(--text)">Cycle rejection</b> — feedback loops flash red and are refused (combinational only).<br><br>
      <kbd>Delete</kbd> removes selected node &amp; its wires. Drag node body to move.
    </div>
  </aside>

  <main id="viewport">
    <div id="grid"></div>
    <div id="world">
      <svg id="wire-layer"></svg>
      <div id="node-layer"></div>
    </div>
  </main>

  <aside id="inspector">
    <h2>Instrument</h2>
    <div class="stat-grid">
      <div class="stat"><strong id="stNodes">0</strong><span>Nodes</span></div>
      <div class="stat"><strong id="stWires">0</strong><span>Wires</span></div>
      <div class="stat"><strong id="stZoom">100%</strong><span>Zoom</span></div>
      <div class="stat"><strong id="stEval">0 ms</strong><span>Eval</span></div>
    </div>
    <div class="sel-card" id="selCard">
      <h3>No selection</h3>
      <p>Click a node to select it. Press Delete to remove. Drag ports to wire.</p>
      <div class="wire-legend"><i style="background:#f59e0b;box-shadow:0 0 8px #f59e0b"></i> Energized (1) <i style="background:#334155"></i> Low (0)</div>
      <p style="margin-top:8px">Tip: drag from an occupied input to re-route its wire.</p>
    </div>
    <div style="margin-top:12px;background:#0e1a30;border:1px solid var(--border);border-radius:10px;padding:10px">
      <div style="font-size:11px;font-weight:800;letter-spacing:.08em;margin-bottom:6px">Truth snapshot</div>
      <div id="truthLog" style="font-family:ui-monospace,monospace;font-size:11px;color:#94a3b8;line-height:1.6;min-height:54px"></div>
    </div>
  </aside>
</div>
<div id="toast"></div>

<script>
/* ================= CONFIG ================= */
const CONFIG = {
  ZOOM_MIN: 0.5,
  ZOOM_MAX: 2.0,
  ZOOM_STEP: 0.12,
  PORT_RADIUS: 7,           // visual radius
  HIT_TOLERANCE_PX: 22,     // snap distance in screen px
  GRID_GAP: 28,
  GRID_DOT: 1.6,
  COLORS: {
    wireOff: '#334155',
    wireOn: '#f59e0b',
    wireErr: '#ef4444',
    bg: '#070b13',
    grid: 'rgba(148,163,184,.18)'
  },
  CLOCK_RATES: [
    {label:'0.5 Hz', ms:2000},
    {label:'1 Hz', ms:1000},
    {label:'2 Hz', ms:500},
    {label:'4 Hz', ms:250},
  ]
};

/* ================= DATA MODEL ================= */
const NODE_DEFS = {
  TOGGLE:{label:'Toggle', ports:{in:0,out:1}, tag:'in'},
  CLOCK:{label:'Clock', ports:{in:0,out:1}, tag:'in'},
  AND:{label:'AND', ports:{in:2,out:1}, tag:'gate'},
  OR:{label:'OR', ports:{in:2,out:1}, tag:'gate'},
  NOT:{label:'NOT', ports:{in:1,out:1}, tag:'gate'},
  XOR:{label:'XOR', ports:{in:2,out:1}, tag:'gate'},
  NAND:{label:'NAND', ports:{in:2,out:1}, tag:'gate'},
  NOR:{label:'NOR', ports:{in:2,out:1}, tag:'gate'},
  LAMP:{label:'Lamp', ports:{in:1,out:0}, tag:'out'},
  HEX:{label:'Hex Display', ports:{in:4,out:0}, tag:'out'},
};

let state = {
  nodes: [],
  wires: [],
  nextId: 1,
  pan:{x: 60, y: 80},
  zoom: 1,
  selectedId: null,
  draggingNode: null, // {id, dx, dy}
  panning: null, // {sx, sy, ox, oy}
  wiring: null, // {fromId, fromPort, curX, curY, tempWireId?}
  flashErr: null, // {from,to,until}
};

const LS_KEY = 'logic-circuit-scene-v2';

/* ================= HELPERS ================= */
const $ = s=>document.querySelector(s);
const viewport = $('#viewport');
const world = $('#world');
const wireLayer = $('#wire-layer');
const nodeLayer = $('#node-layer');
const gridEl = $('#grid');

function uid(){ return 'n'+(state.nextId++); }
function defOf(type){ return NODE_DEFS[type]; }

function screenToWorld(sx, sy){
  const r = viewport.getBoundingClientRect();
  const x = sx - r.left;
  const y = sy - r.top;
  return { x: (x - state.pan.x)/state.zoom, y: (y - state.pan.y)/state.zoom };
}
function worldToScreen(wx, wy){
  return { x: wx*state.zoom + state.pan.x, y: wy*state.zoom + state.pan.y };
}
function applyTransform(){
  world.style.transform = `translate(${state.pan.x}px, ${state.pan.y}px) scale(${state.zoom})`;
  // grid that pans/zooms with world: use background position/size
  const gap = CONFIG.GRID_GAP * state.zoom;
  gridEl.style.backgroundImage = `radial-gradient(circle at 1px 1px, ${CONFIG.COLORS.grid} 1.2px, transparent 0)`;
  gridEl.style.backgroundSize = `${gap}px ${gap}px`;
  gridEl.style.backgroundPosition = `${state.pan.x}px ${state.pan.y}px`;
  $('#zoomLabel').textContent = Math.round(state.zoom*100)+'%';
  $('#stZoom').textContent = Math.round(state.zoom*100)+'%';
  renderWires(); // keep crisp
}

/* ================= PERSISTENCE ================= */
function save(){
  const payload = {
    nodes: state.nodes,
    wires: state.wires,
    nextId: state.nextId,
    pan: state.pan,
    zoom: state.zoom
  };
  localStorage.setItem(LS_KEY, JSON.stringify(payload));
}
function load(){
  try{
    const raw = localStorage.getItem(LS_KEY);
    if(!raw) return false;
    const data = JSON.parse(raw);
    if(!data.nodes) return false;
    state.nodes = data.nodes;
    state.wires = data.wires || [];
    state.nextId = data.nextId || (Math.max(0,...state.nodes.map(n=>parseInt(n.id.slice(1))||0))+1);
    state.pan = data.pan || state.pan;
    state.zoom = data.zoom || 1;
    return true;
  }catch(e){ return false; }
}

/* ================= EVALUATION ENGINE ================= */
function evaluate(){
  const t0 = performance.now();
  // build maps
  const byId = new Map(state.nodes.map(n=>[n.id,n]));
  // incoming wires per node port
  const incoming = new Map(); // key `${toId}:${toPort}` -> wire
  state.wires.forEach(w=>{
    incoming.set(w.to+':'+w.toPort, w);
  });
  // adjacency for topo
  const adj = new Map(); // fromId -> [toId]
  const indeg = new Map();
  state.nodes.forEach(n=>{ adj.set(n.id,[]); indeg.set(n.id,0); });
  state.wires.forEach(w=>{
    adj.get(w.from).push(w.to);
    indeg.set(w.to, (indeg.get(w.to)||0)+1);
  });
  // Kahn
  const q = [];
  indeg.forEach((d,id)=>{ if(d===0) q.push(id); });
  const topo=[];
  while(q.length){
    const id=q.shift(); topo.push(id);
    for(const nb of adj.get(id)||[]){
      indeg.set(nb, indeg.get(nb)-1);
      if(indeg.get(nb)===0) q.push(nb);
    }
  }
  // if cycle somehow present, fall back to nodes order
  const order = topo.length===state.nodes.length ? topo : state.nodes.map(n=>n.id);

  // values: node output boolean (for gates/inputs), for outputs store input capture
  const outVal = new Map(); // id -> boolean (output)
  const inputVals = new Map(); // id -> array of booleans per input port

  // helper to get input bool for a node/port
  function getInput(nodeId, portIdx){
    const w = incoming.get(nodeId+':'+portIdx);
    if(!w) return false;
    const src = byId.get(w.from);
    if(!src) return false;
    // source output is outVal if computed, otherwise src.state
    if(outVal.has(w.from)) return !!outVal.get(w.from);
    // for order not yet computed (should not happen) fallback to false
    return !!src.state;
  }

  for(const id of order){
    const n = byId.get(id);
    const def = defOf(n.type);
    const ins = [];
    for(let i=0;i<def.ports.in;i++) ins.push( getInput(id,i) );
    inputVals.set(id, ins);

    let out = false;
    switch(n.type){
      case 'TOGGLE': out = !!n.state; break;
      case 'CLOCK': out = !!n.state; break;
      case 'AND': out = (ins[0] && ins[1]); break;
      case 'OR': out = (ins[0] || ins[1]); break;
      case 'NOT': out = !ins[0]; break;
      case 'XOR': out = (!!ins[0] !== !!ins[1]); break;
      case 'NAND': out = !(ins[0] && ins[1]); break;
      case 'NOR': out = !(ins[0] || ins[1]); break;
      case 'LAMP': n._in = ins[0]; out = ins[0]; break;
      case 'HEX': n._ins = ins.slice(); // 4 bits
               // value computed for display
               out = false; break;
      default: out=false;
    }
    if(def.ports.out>0) outVal.set(id, out);
    // stash for rendering
    n._out = out;
    n._ins = ins;
  }
  // wire energized
  state.wires.forEach(w=>{
    w._on = !!outVal.get(w.from);
  });
  const dt = performance.now()-t0;
  $('#stEval').textContent = dt.toFixed(1)+' ms';
  // truth log for demo
  updateTruthLog(outVal, inputVals);
  return outVal;
}

function updateTruthLog(outVal, inputVals){
  const el = $('#truthLog');
  if(!el) return;
  // show last lamp/hex states
  const lines=[];
  state.nodes.filter(n=>n.type==='LAMP' || n.type==='HEX').forEach(n=>{
    if(n.type==='LAMP') lines.push(`${n.name}: ${n._in? '● ON':'○ OFF'}`);
    if(n.type==='HEX'){
      const bits = (n._ins||[]).map(b=>b?1:0);
      // bits[0]=LSB? we treat port0 = LSB (2^0) at bottom? Use port0 = 1, port3=8
      const v = (bits[0]?1:0)|(bits[1]?2:0)|(bits[2]?4:0)|(bits[3]?8:0);
      lines.push(`${n.name}: ${v.toString(16).toUpperCase()} (0b${bits.slice().reverse().join('')})`);
    }
  });
  if(lines.length===0) lines.push('Add a Lamp or Hex to see values.');
  el.innerHTML = lines.join('<br>');
}

/* ================= CYCLE DETECTION ================= */
function wouldCreateCycle(fromId, toId){
  if(fromId===toId) return true;
  // does there exist path from toId -> fromId ?
  const adj = new Map();
  state.nodes.forEach(n=>adj.set(n.id,[]));
  state.wires.forEach(w=> adj.get(w.from).push(w.to));
  // DFS
  const stack=[toId], seen=new Set([toId]);
  while(stack.length){
    const cur=stack.pop();
    if(cur===fromId) return true;
    for(const nb of adj.get(cur)||[]){
      if(!seen.has(nb)){ seen.add(nb); stack.push(nb); }
    }
  }
  return false;
}

/* ================= RENDERING ================= */
function portWorldPos(node, dir, idx){
  // node x,y is top-left of card in world coords. Port positions: left edge middle + offset per port
  // Inputs distributed vertically, outputs single centered unless multiple? All nodes have 1 out max.
  const W=148, H = cardHeight(node);
  if(dir==='input'){
    const n = defOf(node.type).ports.in;
    const start = 38; // below header
    const step = n>1 ? 22 : 0;
    const cy = n===1 ? H/2 + 8 : start + idx*step + 8;
    return {x: node.x, y: node.y + cy};
  } else {
    // output on right middle
    return {x: node.x+W, y: node.y + H/2 + 8};
  }
}
function cardHeight(node){
  // approximate based on type
  if(node.type==='TOGGLE' || node.type==='CLOCK') return 96;
  if(node.type==='HEX') return 118;
  if(node.type==='LAMP') return 96;
  return 86;
}

function renderNodes(){
  nodeLayer.innerHTML='';
  // sort selected on top
  const sorted = [...state.nodes].sort((a,b)=> (a.id===state.selectedId)-(b.id===state.selectedId));
  for(const n of sorted){
    const def = defOf(n.type);
    const el = document.createElement('div');
    el.className = 'node'+(state.selectedId===n.id?' selected':'') + (n._out? ' live':'');
    el.style.left = n.x+'px';
    el.style.top = n.y+'px';
    el.dataset.id = n.id;

    const tagCls = def.tag==='in'?'t-in':def.tag==='out'?'t-out':'t-gate';
    const icon = n.type==='AND'?'∧':n.type==='OR'?'∨':n.type==='NOT'?'¬':n.type==='XOR'?'⊕':n.type==='NAND'?'⊼':n.type==='NOR'?'⊽':n.type==='TOGGLE'?'◯':n.type==='CLOCK'?'◷':n.type==='LAMP'?'●':'#';
    const ledOn = n.type==='LAMP' ? !!n._in : (def.ports.out? !!n._out : !!n.state);
    const ledCls = n.type==='TOGGLE' || n.type==='CLOCK' ? 'blue' : '';
    el.innerHTML = `
      <div class="node-head">
        <div class="node-type ${tagCls}">${icon}</div>
        <div class="node-title"><strong>${n.name}</strong><span>${n.type}</span></div>
        <div class="led ${ledCls} ${ledOn?'on':''}"></div>
      </div>
      <div class="node-body">
        ${n.type==='TOGGLE' ? `
          <button class="toggle-btn ${n.state?'on':'off'}" data-toggle="${n.id}">${n.state?'● ON':'○ OFF'}</button>
        `:''}
        ${n.type==='CLOCK' ? `
          <div class="clock-row">
            <select data-clock="${n.id}">
              ${CONFIG.CLOCK_RATES.map(r=>`<option value="${r.ms}" ${r.ms===n.rate?'selected':''}>${r.label}</option>`).join('')}
            </select>
            <span style="font-size:11px;color:var(--muted)">${n.state?'◉':'○'}</span>
          </div>
        `:''}
        ${def.tag==='gate' ? `<div class="gate-label">${gateFormula(n.type)}</div>`:''}
        ${n.type==='LAMP' ? `
          <div class="lamp ${n._in?'on':''}"><div class="lamp-dot"></div></div>
        `:''}
        ${n.type==='HEX' ? `
          <div class="seg">
            <div class="seg-char ${hexVal(n)>=0?'on':''}">${hexChar(hexVal(n))}</div>
            <div class="seg-bits">${(n._ins||[0,0,0,0]).slice().reverse().map(b=>b?'1':'0').join(' ')} • ${hexVal(n).toString(16).toUpperCase()}</div>
          </div>
        `:''}
      </div>
    `;
    // ports
    for(let i=0;i<def.ports.in;i++){
      const p = document.createElement('div');
      p.className='port input'+(hasIncoming(n.id,i)?' occupied':'');
      const pos = portWorldPos(n,'input',i);
      // position absolute relative to node element: left -7, top = pos.y - n.y
      p.style.top = (pos.y - n.y - 7)+'px';
      p.dataset.node = n.id; p.dataset.dir='input'; p.dataset.port=i;
      el.appendChild(p);
      // label
      const lab = document.createElement('div');
      lab.className='port-label';
      lab.style.top = (pos.y - n.y)+'px';
      lab.style.left='10px';
      lab.textContent = n.type==='HEX' ? ['1','2','4','8'][i] : ['A','B','C','D'][i];
      el.appendChild(lab);
    }
    for(let i=0;i<def.ports.out;i++){
      const p=document.createElement('div');
      p.className='port output';
      const pos = portWorldPos(n,'output',i);
      p.style.top = (pos.y - n.y -7)+'px';
      p.dataset.node=n.id; p.dataset.dir='output'; p.dataset.port=i;
      el.appendChild(p);
      const lab=document.createElement('div');
      lab.className='port-label';
      lab.style.top=(pos.y - n.y)+'px';
      lab.style.right='10px';
      lab.style.left='auto';
      lab.textContent='OUT';
      el.appendChild(lab);
    }

    nodeLayer.appendChild(el);
  }
  $('#stNodes').textContent = state.nodes.length;
}

function hasIncoming(toId, port){ return state.wires.some(w=>w.to===toId && w.toPort===port); }
function gateFormula(t){
  return t==='AND'?'A · B':t==='OR'?'A + B':t==='NOT'?'¬A':t==='XOR'?'A ⊕ B':t==='NAND'?'¬(A·B)':t==='NOR'?'¬(A+B)':'';
}
function hexVal(node){
  const ins = node._ins||[false,false,false,false];
  return (ins[0]?1:0)|(ins[1]?2:0)|(ins[2]?4:0)|(ins[3]?8:0);
}
function hexChar(v){ return v.toString(16).toUpperCase(); }

function bezierPath(x1,y1,x2,y2){
  const dx = Math.abs(x2-x1);
  const cx = Math.max(60, dx*0.55);
  return `M ${x1} ${y1} C ${x1+cx} ${y1}, ${x2-cx} ${y2}, ${x2} ${y2}`;
}

function renderWires(){
  // size svg to cover large world area: use bounding box of nodes + viewport
  // Simpler: set svg size huge and draw in world coords (world transform will scale)
  // We'll set svg attributes to cover 4000x4000 around center
  wireLayer.setAttribute('width', 6000);
  wireLayer.setAttribute('height', 6000);
  wireLayer.style.left = '-3000px';
  wireLayer.style.top = '-3000px';
  wireLayer.setAttribute('viewBox','-3000 -3000 6000 6000');
  // Actually easier: draw with world coords directly with offset 3000
  // We'll just draw paths with coordinates shifted by +3000
  const off=3000;
  wireLayer.innerHTML='';
  const defs = document.createElementNS('http://www.w3.org/2000/svg','defs');
  defs.innerHTML = `
    <filter id="glowOn"><feDropShadow dx="0" dy="0" stdDeviation="3" flood-color="#f59e0b" flood-opacity="0.8"/></filter>
    <filter id="glowOff"><feDropShadow dx="0" dy="0" stdDeviation="0" flood-color="#000" flood-opacity="0"/></filter>
  `;
  wireLayer.appendChild(defs);

  for(const w of state.wires){
    const fromNode = state.nodes.find(n=>n.id===w.from);
    const toNode = state.nodes.find(n=>n.id===w.to);
    if(!fromNode||!toNode) continue;
    const p1 = portWorldPos(fromNode,'output',w.fromPort);
    const p2 = portWorldPos(toNode,'input',w.toPort);
    const path = document.createElementNS('http://www.w3.org/2000/svg','path');
    // check flash error
    const isErr = state.flashErr && state.flashErr.from===w.from && state.flashErr.to===w.to && Date.now()<state.flashErr.until;
    path.setAttribute('d', bezierPath(p1.x+off, p1.y+off, p2.x+off, p2.y+off));
    path.setAttribute('fill','none');
    path.setAttribute('stroke', isErr? CONFIG.COLORS.wireErr : (w._on? CONFIG.COLORS.wireOn: CONFIG.COLORS.wireOff));
    path.setAttribute('stroke-width', w._on? '3.2':'2.4');
    path.setAttribute('stroke-linecap','round');
    path.setAttribute('opacity', w._on? '1':'0.95');
    if(w._on) path.setAttribute('filter','url(#glowOn)');
    // hit area wider transparent path
    wireLayer.appendChild(path);
    // small dots at ends
    const c1=document.createElementNS('http://www.w3.org/2000/svg','circle');
    c1.setAttribute('cx',p1.x+off); c1.setAttribute('cy',p1.y+off); c1.setAttribute('r', w._on? '3.2':'2.2');
    c1.setAttribute('fill', w._on? '#f59e0b':'#475569');
    wireLayer.appendChild(c1);
  }
  // preview wire
  if(state.wiring){
    const fromNode = state.nodes.find(n=>n.id===state.wiring.fromId);
    if(fromNode){
      const p1 = portWorldPos(fromNode,'output',state.wiring.fromPort);
      const p2 = {x: state.wiring.curX, y: state.wiring.curY};
      const isOverValid = state.wiring.overPort && state.wiring.overPort.dir==='input';
      const path = document.createElementNS('http://www.w3.org/2000/svg','path');
      path.setAttribute('d', bezierPath(p1.x+off,p1.y+off,p2.x+off,p2.y+off));
      path.setAttribute('fill','none');
      path.setAttribute('stroke', isOverValid? '#38bdf8' : '#94a3b8');
      path.setAttribute('stroke-width','2.6');
      path.setAttribute('stroke-dasharray','8 6');
      path.setAttribute('opacity','0.9');
      path.setAttribute('stroke-linecap','round');
      wireLayer.appendChild(path);
      // target highlight circle
      if(state.wiring.overPort){
        const tn = state.nodes.find(n=>n.id===state.wiring.overPort.nodeId);
        if(tn){
          const tp = portWorldPos(tn, 'input', state.wiring.overPort.port);
          const cc=document.createElementNS('http://www.w3.org/2000/svg','circle');
          cc.setAttribute('cx',tp.x+off); cc.setAttribute('cy',tp.y+off); cc.setAttribute('r','10');
          cc.setAttribute('fill','none'); cc.setAttribute('stroke','#38bdf8'); cc.setAttribute('stroke-width','1.6'); cc.setAttribute('opacity','0.9');
          wireLayer.appendChild(cc);
        }
      }
    }
  }
  // error flash wire preview
  if(state.flashErr && Date.now()<state.flashErr.until){
    // find nodes
    const fN = state.nodes.find(n=>n.id===state.flashErr.from);
    const tN = state.nodes.find(n=>n.id===state.flashErr.to);
    if(fN&&tN){
      const p1=portWorldPos(fN,'output',0), p2=portWorldPos(tN,'input',state.flashErr.toPort||0);
      const path=document.createElementNS('http://www.w3.org/2000/svg','path');
      path.setAttribute('d', bezierPath(p1.x+off,p1.y+off,p2.x+off,p2.y+off));
      path.setAttribute('fill','none'); path.setAttribute('stroke','#ef4444'); path.setAttribute('stroke-width','4'); path.setAttribute('opacity','0.95');
      path.setAttribute('stroke-linecap','round');
      wireLayer.appendChild(path);
    }
  }
  $('#stWires').textContent = state.wires.length;
}

/* ================= NODE CREATION ================= */
function createNode(type, x, y){
  const id=uid();
  const baseName = type==='TOGGLE'?'Toggle':type==='CLOCK'?'Clock':type==='LAMP'?'Lamp':type==='HEX'?'Hex':type;
  const name = baseName+' '+(state.nodes.filter(n=>n.type===type).length+1);
  const node={id, type, x: Math.round(x), y: Math.round(y), name, state:false, rate:1000, _out:false};
  if(type==='TOGGLE') node.state=false;
  if(type==='CLOCK') node.state=false;
  state.nodes.push(node);
  state.selectedId=id;
  if(type==='CLOCK') startClock(node);
  evaluate(); renderNodes(); renderWires(); save();
}
function addNodeAtCenter(type){
  const center = screenToWorld(viewport.clientWidth/2 + viewport.getBoundingClientRect().left, viewport.clientHeight/2 + viewport.getBoundingClientRect().top);
  // But screenToWorld expects client coords. So compute directly:
  const wcx = (viewport.clientWidth/2 - state.pan.x)/state.zoom;
  const wcy = (viewport.clientHeight/2 - state.pan.y)/state.zoom;
  createNode(type, wcx-74, wcy-40);
}

/* ================= CLOCKS ================= */
const clockTimers = new Map();
function startClock(node){
  stopClock(node.id);
  const ms = node.rate||1000;
  const id = setInterval(()=>{
    node.state = !node.state;
    evaluate(); renderNodes(); renderWires(); save();
  }, ms);
  clockTimers.set(node.id, id);
}
function stopClock(id){
  if(clockTimers.has(id)){ clearInterval(clockTimers.get(id)); clockTimers.delete(id); }
}
function restartAllClocks(){
  clockTimers.forEach((v,k)=>clearInterval(v)); clockTimers.clear();
  state.nodes.filter(n=>n.type==='CLOCK').forEach(n=>startClock(n));
}

/* ================= INTERACTIONS ================= */
let lastMouseWorld={x:0,y:0};

viewport.addEventListener('mousedown', e=>{
  const target = e.target;
  const portEl = target.closest('.port');
  const nodeEl = target.closest('.node');
  const isPalette = target.closest('#palette');
  if(isPalette) return;

  if(portEl){
    e.preventDefault(); e.stopPropagation();
    const nodeId = portEl.dataset.node;
    const dir = portEl.dataset.dir;
    const port = parseInt(portEl.dataset.port);
    if(dir==='output'){
      // start wiring from output
      const wp = screenToWorld(e.clientX, e.clientY);
      state.wiring = {fromId:nodeId, fromPort:port, curX:wp.x, curY:wp.y, overPort:null};
      viewport.classList.add('grab-wire');
    } else if(dir==='input'){
      // if occupied, pick up existing wire to re-route
      const existing = state.wires.find(w=>w.to===nodeId && w.toPort===port);
      if(existing){
        // remove wire, start from its source
        const fromId = existing.from, fromPort=existing.fromPort;
        state.wires = state.wires.filter(w=>!(w.to===nodeId && w.toPort===port));
        evaluate(); renderWires(); save();
        const wp = screenToWorld(e.clientX, e.clientY);
        state.wiring = {fromId, fromPort, curX:wp.x, curY:wp.y, overPort:null};
        viewport.classList.add('grab-wire');
      } else {
        // could allow drag from input? ignore
      }
    }
    return;
  }

  if(nodeEl){
    // check if toggle/clock controls clicked
    if(target.closest('[data-toggle]') || target.closest('[data-clock]')) return;
    const id = nodeEl.dataset.id;
    state.selectedId=id;
    renderNodes(); updateInspector();
    // start dragging node
    const n = state.nodes.find(nn=>nn.id===id);
    const wp = screenToWorld(e.clientX, e.clientY);
    state.draggingNode = {id, dx: wp.x - n.x, dy: wp.y - n.y};
    e.preventDefault();
    return;
  }

  // empty space -> pan
  state.panning = {sx:e.clientX, sy:e.clientY, ox:state.pan.x, oy:state.pan.y};
  viewport.classList.add('panning');
});

window.addEventListener('mousemove', e=>{
  const wp = screenToWorld(e.clientX, e.clientY);
  lastMouseWorld = wp;

  if(state.draggingNode){
    const n = state.nodes.find(nn=>nn.id===state.draggingNode.id);
    if(n){
      n.x = Math.round(wp.x - state.draggingNode.dx);
      n.y = Math.round(wp.y - state.draggingNode.dy);
      renderNodes(); renderWires();
    }
    return;
  }
  if(state.panning){
    state.pan.x = state.panning.ox + (e.clientX - state.panning.sx);
    state.pan.y = state.panning.oy + (e.clientY - state.panning.sy);
    applyTransform();
    return;
  }
  if(state.wiring){
    state.wiring.curX = wp.x;
    state.wiring.curY = wp.y;
    // find port under cursor within tolerance
    const over = findPortNear(e.clientX, e.clientY);
    // only allow input ports as target, not same node
    if(over && over.dir==='input' && over.nodeId!==state.wiring.fromId){
      state.wiring.overPort = over;
      // highlight
      document.querySelectorAll('.port').forEach(p=>p.classList.remove('highlight'));
      const sel = document.querySelector(`.port[data-node="${over.nodeId}"][data-dir="input"][data-port="${over.port}"]`);
      if(sel) sel.classList.add('highlight');
    } else {
      state.wiring.overPort=null;
      document.querySelectorAll('.port').forEach(p=>p.classList.remove('highlight'));
    }
    renderWires();
  }
});

window.addEventListener('mouseup', e=>{
  document.querySelectorAll('.port').forEach(p=>p.classList.remove('highlight'));
  if(state.draggingNode){
    state.draggingNode=null;
    save();
  }
  if(state.panning){
    state.panning=null;
    viewport.classList.remove('panning');
    save();
  }
  if(state.wiring){
    viewport.classList.remove('grab-wire');
    const over = state.wiring.overPort;
    if(over && over.dir==='input'){
      // validate and create wire
      const fromId = state.wiring.fromId;
      const toId = over.nodeId;
      const toPort = over.port;
      // type check: from has output
      if(wouldCreateCycle(fromId, toId)){
        // reject with red flash
        state.flashErr = {from:fromId, to:toId, toPort, until: Date.now()+900};
        toast('⛔ Cycle rejected — combinational only');
        renderWires();
        setTimeout(()=>{ state.flashErr=null; renderWires(); }, 920);
      } else {
        // remove existing wire on that input
        state.wires = state.wires.filter(w=>!(w.to===toId && w.toPort===toPort));
        state.wires.push({from:fromId, fromPort:state.wiring.fromPort, to:toId, toPort});
        evaluate(); renderNodes(); renderWires(); save();
      }
    }
    state.wiring=null;
    renderWires();
  }
});

// wheel zoom to cursor
viewport.addEventListener('wheel', e=>{
  e.preventDefault();
  const rect = viewport.getBoundingClientRect();
  const mx = e.clientX - rect.left;
  const my = e.clientY - rect.top;
  const worldX = (mx - state.pan.x)/state.zoom;
  const worldY = (my - state.pan.y)/state.zoom;
  let newZoom = state.zoom * (e.deltaY < 0 ? 1.08 : 0.92);
  newZoom = Math.min(CONFIG.ZOOM_MAX, Math.max(CONFIG.ZOOM_MIN, newZoom));
  // snap to 0.1 steps? keep smooth
  newZoom = Math.round(newZoom*100)/100;
  state.pan.x = mx - worldX*newZoom;
  state.pan.y = my - worldY*newZoom;
  state.zoom = newZoom;
  applyTransform();
  save();
},{passive:false});

function findPortNear(clientX, clientY){
  // check all ports screen distance
  let best=null, bestDist=CONFIG.HIT_TOLERANCE_PX;
  for(const n of state.nodes){
    const def = defOf(n.type);
    for(let i=0;i<def.ports.in;i++){
      const wp = portWorldPos(n,'input',i);
      const sp = worldToScreen(wp.x, wp.y);
      const rect = viewport.getBoundingClientRect();
      const sx = rect.left + sp.x;
      const sy = rect.top + sp.y;
      const d = Math.hypot(sx - clientX, sy - clientY);
      if(d < bestDist){ bestDist=d; best={nodeId:n.id, dir:'input', port:i}; }
    }
    // outputs not needed as targets, but for completeness
  }
  return best;
}

/* node body interactions (toggle, clock select) */
nodeLayer.addEventListener('click', e=>{
  const t = e.target.closest('[data-toggle]');
  if(t){
    const id=t.dataset.toggle;
    const n=state.nodes.find(nn=>nn.id===id);
    if(n){ n.state=!n.state; evaluate(); renderNodes(); renderWires(); save(); }
    return;
  }
});
nodeLayer.addEventListener('change', e=>{
  const s=e.target.closest('[data-clock]');
  if(s){
    const id=s.dataset.clock;
    const n=state.nodes.find(nn=>nn.id===id);
    if(n){ n.rate=parseInt(s.value); startClock(n); save(); }
  }
});
nodeLayer.addEventListener('mousedown', e=>{
  const nodeEl=e.target.closest('.node');
  if(nodeEl && !e.target.closest('.port')){
    // select
    state.selectedId=nodeEl.dataset.id;
    renderNodes(); updateInspector();
  }
});

/* keyboard */
window.addEventListener('keydown', e=>{
  if((e.key==='Delete' || e.key==='Backspace') && state.selectedId){
    // avoid deleting when typing in select
    if(e.target.tagName==='SELECT' || e.target.tagName==='INPUT') return;
    const id=state.selectedId;
    stopClock(id);
    state.nodes = state.nodes.filter(n=>n.id!==id);
    state.wires = state.wires.filter(w=>w.from!==id && w.to!==id);
    state.selectedId=null;
    evaluate(); renderNodes(); renderWires(); updateInspector(); save();
  }
});

/* palette */
document.querySelectorAll('[data-add]').forEach(b=>{
  b.addEventListener('click', ()=> addNodeAtCenter(b.dataset.add));
});

/* zoom buttons */
$('#zoomIn').addEventListener('click', ()=>{
  const mx=viewport.clientWidth/2, my=viewport.clientHeight/2;
  const wx=(mx - state.pan.x)/state.zoom, wy=(my - state.pan.y)/state.zoom;
  let nz=Math.min(CONFIG.ZOOM_MAX, Math.round((state.zoom*1.2)*100)/100);
  state.pan.x=mx - wx*nz; state.pan.y=my - wy*nz; state.zoom=nz; applyTransform(); save();
});
$('#zoomOut').addEventListener('click', ()=>{
  const mx=viewport.clientWidth/2, my=viewport.clientHeight/2;
  const wx=(mx - state.pan.x)/state.zoom, wy=(my - state.pan.y)/state.zoom;
  let nz=Math.max(CONFIG.ZOOM_MIN, Math.round((state.zoom/1.2)*100)/100);
  state.pan.x=mx - wx*nz; state.pan.y=my - wy*nz; state.zoom=nz; applyTransform(); save();
});
$('#zoomFit').addEventListener('click', ()=>{
  if(state.nodes.length===0){ state.pan={x:60,y:80}; state.zoom=1; applyTransform(); save(); return; }
  let minX=Infinity,minY=Infinity,maxX=-Infinity,maxY=-Infinity;
  state.nodes.forEach(n=>{ minX=Math.min(minX,n.x); minY=Math.min(minY,n.y); maxX=Math.max(maxX,n.x+148); maxY=Math.max(maxY,n.y+cardHeight(n)); });
  const pad=80;
  const vw=viewport.clientWidth - pad*2, vh=viewport.clientHeight - pad*2;
  const bw=maxX-minX, bh=maxY-minY;
  let nz=Math.min(CONFIG.ZOOM_MAX, Math.max(CONFIG.ZOOM_MIN, Math.min(vw/bw, vh/bh)));
  nz=Math.round(nz*100)/100;
  state.zoom=nz;
  state.pan.x = (viewport.clientWidth - bw*nz)/2 - minX*nz;
  state.pan.y = (viewport.clientHeight - bh*nz)/2 - minY*nz;
  applyTransform(); save();
});

$('#btnClear').addEventListener('click', ()=>{
  if(!confirm('Clear entire circuit?')) return;
  state.nodes.forEach(n=>stopClock(n.id));
  state.nodes=[]; state.wires=[]; state.selectedId=null; state.nextId=1;
  evaluate(); renderNodes(); renderWires(); updateInspector(); save();
});
$('#btnDemo').addEventListener('click', ()=> loadDemo(true));

function updateInspector(){
  const el=$('#selCard');
  if(!state.selectedId){ el.innerHTML=`<h3>No selection</h3><p>Click a node to inspect. Drag ports to wire.</p><div class="wire-legend"><i style="background:#f59e0b;box-shadow:0 0 8px #f59e0b"></i> ON <i style="background:#334155"></i> OFF</div>`; return; }
  const n=state.nodes.find(x=>x.id===state.selectedId);
  if(!n) return;
  const ins = (n._ins||[]).map(b=>b?'1':'0').join(' ');
  const out = n._out? '1':'0';
  el.innerHTML = `
    <h3>${n.name} <span style="color:var(--muted);font-weight:600">— ${n.type}</span></h3>
    <p>Position: ${n.x}, ${n.y} • Inputs: [${ins||'—'}] • Out: ${out}</p>
    <div style="margin-top:8px;display:flex;gap:6px">
      <button class="btn" id="dupBtn">Duplicate</button>
      <button class="btn" id="delBtn" style="background:#450a0a;border-color:#7f1d1d">Delete</button>
    </div>
  `;
  el.querySelector('#delBtn').addEventListener('click', ()=>{
    stopClock(n.id);
    state.nodes=state.nodes.filter(x=>x.id!==n.id);
    state.wires=state.wires.filter(w=>w.from!==n.id && w.to!==n.id);
    state.selectedId=null; evaluate(); renderNodes(); renderWires(); updateInspector(); save();
  });
  el.querySelector('#dupBtn').addEventListener('click', ()=>{
    createNode(n.type, n.x+24, n.y+24);
    // copy state/rate
    const nn=state.nodes[state.nodes.length-1];
    nn.state=n.state; nn.rate=n.rate;
    if(nn.type==='CLOCK') startClock(nn);
    evaluate(); renderNodes(); renderWires(); save();
  });
}

function toast(msg){
  const t=$('#toast'); t.textContent=msg; t.classList.add('show');
  clearTimeout(t._id); t._id=setTimeout(()=>t.classList.remove('show'), 1800);
}

/* ================= DEMO CIRCUIT ================= */
function loadDemo(force=false){
  if(!force && state.nodes.length>0) return;
  // clear existing clocks
  state.nodes.forEach(n=>stopClock(n.id));
  state.nodes=[]; state.wires=[]; state.nextId=1;
  // Build: Toggle → AND(1) with Toggle → NOT → Lamp ; plus extra chain to test 3-gate
  // Positions
  createNode('TOGGLE', 60, 80);  // n1
  createNode('TOGGLE', 60, 220); // n2
  createNode('AND', 260, 120);   // n3
  createNode('NOT', 460, 130);   // n4
  createNode('LAMP', 640, 130);  // n5
  createNode('OR', 260, 300);    // n6
  createNode('HEX', 460, 300);   // n7
  createNode('CLOCK', 60, 380);  // n8
  // set states
  state.nodes[0].state=true;
  state.nodes[1].state=false;
  // wires: T1->AND A, T2->AND B, AND->NOT, NOT->LAMP, T1->OR A, T2->OR B, OR->HEX bit0?, clock->hex bit1 etc
  // simpler: OR -> HEX bit0, AND -> HEX bit1, NOT->HEX bit2, CLOCK->HEX bit3
  const ids = state.nodes.map(n=>n.id);
  const [t1,t2,and,not,lamp,or,hex,clk]=ids;
  state.wires=[
    {from:t1, fromPort:0, to:and, toPort:0},
    {from:t2, fromPort:0, to:and, toPort:1},
    {from:and, fromPort:0, to:not, toPort:0},
    {from:not, fromPort:0, to:lamp, toPort:0},
    {from:t1, fromPort:0, to:or, toPort:0},
    {from:t2, fromPort:0, to:or, toPort:1},
    {from:or, fromPort:0, to:hex, toPort:0},
    {from:and, fromPort:0, to:hex, toPort:1},
    {from:not, fromPort:0, to:hex, toPort:2},
    {from:clk, fromPort:0, to:hex, toPort:3},
  ];
  state.pan={x:40,y:30}; state.zoom=1;
  evaluate(); renderNodes(); renderWires(); updateInspector(); save();
  // fit
  setTimeout(()=> $('#zoomFit').click(), 50);
}

/* ================= INIT ================= */
function init(){
  const had = load();
  if(!had){
    loadDemo(false);
  } else {
    evaluate();
    restartAllClocks();
  }
  renderNodes(); renderWires(); applyTransform(); updateInspector();
  // handle resize
  window.addEventListener('resize', applyTransform);
  // click on viewport background to deselect
  viewport.addEventListener('mousedown', e=>{
    if(e.target===viewport || e.target===world || e.target===gridEl){
      state.selectedId=null; renderNodes(); updateInspector();
    }
  });
}
init();
</script>
</body>
</html>
<!-- agent-meta {"model":"meta/muse-spark-1.2","provider":"openrouter","persona":"basic","sessionId":"a1a0135c-5a65-4a92-811a-af4239820e77","tokensIn":1144,"tokensOut":17454,"tokensTotal":18598,"cost":0.0756095,"turns":1,"toolCalls":0,"failedToolCalls":0,"timestamp":"2026-08-06T15:21:07.039Z"} -->