<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>VL Workflow DAG</title>
<style>
:root {
  --bg: #0a0d12; --bg2: #12161c; --bg3: #1a1f27; --border: #2a3140;
  --text: #e6edf3; --text2: #8b949e; --blue: #58a6ff; --purple: #a371f7;
  --orange: #d29922; --green: #3fb950; --red: #f85149; --cyan: #39c5cf;
  --violet: #8b5cf6; --emerald: #10b981; --actor: #f43f5e;
  --subflow: #f97316; --review: #14b8a6; --graphpatch: #f59e0b; --swarm: #06b6d4;
  --pink: #db61a2; --teal: #2dd4bf;
}
* { margin:0; padding:0; box-sizing:border-box; }
body { background:var(--bg); color:var(--text); font-family:'SF Mono','Fira Code',monospace; font-size:12px; overflow:hidden; height:100vh; display:flex; flex-direction:column; }

/* ===== Toolbar ===== */
.toolbar {
  background:var(--bg2); border-bottom:1px solid var(--border);
  display:flex; align-items:center; gap:6px; padding:4px 12px; height:36px; flex-shrink:0;
}
.toolbar .title { font-size:11px; font-weight:600; color:var(--text); margin-right:8px; }
.toolbar .sep { width:1px; height:18px; background:var(--border); }
.tb-btn {
  background:var(--bg3); border:1px solid var(--border); color:var(--text2);
  padding:3px 10px; border-radius:4px; cursor:pointer; font-family:inherit; font-size:10px;
}
.tb-btn:hover { background:var(--border); color:var(--text); }
.tb-btn.primary { background:var(--green); color:#000; border-color:var(--green); font-weight:600; }
.tb-btn.primary:hover { opacity:0.9; }
.tb-btn.danger { border-color:var(--red); color:var(--red); }
.tb-btn.danger:hover { background:var(--red); color:#fff; }
.layout-density {
  display:flex; align-items:center; gap:8px;
  padding:0 8px; height:24px; border:1px solid var(--border);
  border-radius:6px; background:var(--bg3);
}
.layout-density-label { font-size:9px; color:var(--text2); white-space:nowrap; }
.layout-density-value { font-size:9px; color:var(--blue); min-width:34px; text-align:right; }
.layout-density input[type="range"] {
  width:120px; accent-color:var(--blue); cursor:pointer;
}

[hidden] { display:none!important; }
/* ===== Layout ===== */
.editor-body { flex:1; display:flex; min-height:0; }
.canvas-wrap { flex:1; overflow:auto; position:relative; min-width:0; }
.host-controlled .canvas-wrap { overflow-anchor:none; }
.bundle-sidebar {
  width:320px; flex-shrink:0; border-left:1px solid var(--border);
  background:var(--bg2); display:flex; flex-direction:column; min-height:0;
}
.bundle-sidebar.hidden { display:none; }
.bundle-sidebar-header {
  padding:10px 12px 8px; border-bottom:1px solid var(--border);
  display:flex; gap:10px; align-items:flex-start; justify-content:space-between;
}
.bundle-sidebar-title { font-size:11px; font-weight:600; color:var(--text); margin-bottom:4px; }
.bundle-sidebar-path { font-size:9px; color:var(--text2); line-height:1.4; word-break:break-all; }
.bundle-sidebar-stats {
  padding:8px 12px; border-bottom:1px solid var(--border);
  font-size:9px; color:var(--blue); display:flex; gap:8px; flex-wrap:wrap;
}
.bundle-node-list { overflow:auto; padding:8px; display:flex; flex-direction:column; gap:6px; }
.bundle-node-item {
  width:100%; text-align:left; background:var(--bg3); border:1px solid var(--border);
  border-radius:6px; color:var(--text); padding:8px 10px; cursor:pointer;
  font-family:inherit; display:flex; gap:8px; align-items:flex-start;
}
.bundle-node-item:hover { border-color:var(--blue); background:rgba(88,166,255,0.08); }
.bundle-node-item.selected { border-color:var(--blue); box-shadow:0 0 0 1px rgba(88,166,255,0.2) inset; }
.bundle-node-item-status {
  width:8px; height:8px; border-radius:50%; margin-top:4px; flex-shrink:0;
  background:var(--border);
}
.bundle-node-item-status.running { background:var(--orange); }
.bundle-node-item-status.done { background:var(--green); }
.bundle-node-item-status.error { background:var(--red); }
.bundle-node-item-status.paused { background:var(--violet); }
.bundle-node-item-status.skipped { background:var(--text2); }
.bundle-node-item-main { flex:1; min-width:0; }
.bundle-node-item-title { font-size:10px; font-weight:600; color:var(--text); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.bundle-node-item-meta { font-size:9px; color:var(--text2); margin-top:2px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.bundle-node-item-type { font-size:8px; color:var(--cyan); margin-top:2px; text-transform:uppercase; letter-spacing:0.4px; }
.bundle-load-empty {
  color:var(--text2); font-size:10px; line-height:1.5; padding:12px;
}

/* ===== Canvas container =====
   The canvas is the scroll sizer only: applyStageMetrics() writes its width and
   height from the measured content bounds at the current view scale, so the
   far-right / bottom of a graph is always reachable. The nodes and the
   connection SVG live on #nodesLayer / #connSvg, which carry the shared
   scale(view) transform. Both keep unscaled model coordinates, so offsetLeft /
   offsetWidth measurements stay in model space at any zoom. */
.canvas {
  position:relative; min-width:100%; min-height:100%;
  background-image:radial-gradient(circle, var(--border) 1px, transparent 1px);
  background-size:20px 20px;
}
.nodes-layer { position:absolute; left:0; top:0; transform-origin:0 0; }

.empty-msg {
  position:absolute; top:50%; left:50%; transform:translate(-50%,-50%);
  color:var(--text2); font-size:14px; text-align:center;
}

/* ===== Nodes ===== */
.node {
  position:absolute; width:240px; background:var(--bg3); border:1px solid var(--border);
  border-radius:8px; cursor:grab; transition:border-color 0.2s, box-shadow 0.2s;
  z-index:2; user-select:none;
}
.node:hover { border-color:var(--blue); box-shadow:0 0 12px rgba(88,166,255,0.15); }
.node.selected { border-color:var(--blue); box-shadow:0 0 20px rgba(88,166,255,0.25); }
.node.dragging { cursor:grabbing; z-index:10; opacity:0.92; box-shadow:0 0 24px rgba(88,166,255,0.3); }

.node-header { display:flex; align-items:center; gap:8px; padding:8px 10px; border-bottom:1px solid var(--border); }
.node-icon { width:24px; height:24px; border-radius:5px; display:flex; align-items:center; justify-content:center; font-size:9px; font-weight:700; color:#fff; flex-shrink:0; }
.node-title { font-size:11px; font-weight:600; color:var(--text); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.node-type { font-size:9px; color:var(--text2); }
.node-desc { font-size:9px; color:var(--cyan); margin-top:1px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:170px; }

.node-body { padding:6px 10px; font-size:10px; color:var(--text2); overflow:hidden; }
.node-body .field { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; margin-bottom:1px; }

/* Rich body styles */
.node-section { margin-bottom:5px; }
.node-section:last-child { margin-bottom:0; }
.node-section-title {
  font-size:8px; color:var(--text2); text-transform:uppercase;
  letter-spacing:0.5px; margin-bottom:2px; padding-bottom:2px;
  border-bottom:1px dashed var(--border);
}
.node-field { display:flex; align-items:flex-start; gap:4px; margin-bottom:2px; line-height:1.3; }
.node-field:last-child { margin-bottom:0; }
.node-label { color:var(--text2); min-width:36px; flex-shrink:0; font-size:9px; }
.node-value {
  color:var(--text); font-family:'SF Mono','Fira Code',monospace;
  font-size:9px; background:rgba(255,255,255,0.05); padding:1px 4px;
  border-radius:2px; word-break:break-all; max-width:160px;
  overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
}
.node-value.var { color:var(--cyan); }
.node-value.ref { color:var(--purple); }
.node-value.file { color:var(--orange); }
.node-io-list { display:flex; flex-direction:column; gap:2px; }
.node-io-item {
  display:flex; align-items:center; gap:4px; font-size:9px;
  background:rgba(255,255,255,0.04); padding:2px 4px; border-radius:2px;
}
.node-io-key { color:var(--text); font-family:'SF Mono','Fira Code',monospace; min-width:44px; }
.node-io-key.is-var { color:var(--cyan); }
.node-io-key.is-file { color:var(--orange); }
.node-io-arrow { color:var(--text2); }
.node-io-value { color:var(--cyan); font-family:'SF Mono','Fira Code',monospace; }
.node-docs-list { display:flex; flex-wrap:wrap; gap:3px; }
.node-doc-tag {
  background:rgba(210,153,34,0.2); color:var(--orange);
  padding:1px 4px; border-radius:2px; font-size:8px;
  font-family:'SF Mono','Fira Code',monospace;
  max-width:140px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
}
.node-condition {
  background:rgba(163,113,247,0.15); color:var(--purple);
  padding:2px 6px; border-radius:2px; font-size:9px;
  font-family:'SF Mono','Fira Code',monospace; margin-top:4px;
}
.node-footer {
  padding:4px 8px; border-top:1px dashed var(--border);
  font-size:9px; color:var(--purple);
}

/* I/O audit badges */
.node-io { display:flex; gap:3px; flex-wrap:wrap; padding:4px 10px 6px; border-top:1px solid var(--border); }
.io-badge { font-size:8px; padding:1px 5px; border-radius:3px; font-family:inherit; }
.io-badge.var-in { background:rgba(88,166,255,0.15); color:var(--blue); }
.io-badge.var-out { background:rgba(63,185,80,0.15); color:var(--green); }
.io-badge.doc { background:rgba(210,153,34,0.15); color:var(--orange); }
.io-badge.file { background:rgba(163,113,247,0.15); color:var(--purple); }

/* Node type colors — original 11 */
.type-LLM .node-icon { background:linear-gradient(135deg, #6366f1, #8b5cf6); }
.type-Service .node-icon { background:linear-gradient(135deg, var(--green), #2ea043); }
.type-API .node-icon { background:linear-gradient(135deg, var(--cyan), #1a7f8a); }
.type-Write .node-icon { background:linear-gradient(135deg, var(--orange), #b87f12); }
.type-Set .node-icon { background:linear-gradient(135deg, var(--blue), #1f6feb); }
.type-Actor .node-icon { background:linear-gradient(135deg, var(--actor), #be123c); }
.type-Subflow .node-icon { background:linear-gradient(135deg, var(--subflow), #c2410c); }
.type-Review .node-icon { background:linear-gradient(135deg, var(--review), #0f766e); }
.type-GraphPatch .node-icon { background:linear-gradient(135deg, var(--graphpatch), #b45309); }
.type-Swarm .node-icon { background:linear-gradient(135deg, var(--swarm), #0f766e); }
.type-Branch .node-icon { background:linear-gradient(135deg, var(--purple), #8957e5); }
.type-Loop .node-icon { background:linear-gradient(135deg, #db61a2, #bf4b8a); }
.type-Stop .node-icon { background:linear-gradient(135deg, var(--red), #da3633); }
.type-Component .node-icon { background:linear-gradient(135deg, #2dd4bf, #14b8a6); }
.type-Pause .node-icon { background:linear-gradient(135deg, var(--violet), #7c3aed); }
.type-Fork .node-icon { background:linear-gradient(135deg, var(--emerald), #059669); }
/* New in Spec 3.16 */
.type-Download .node-icon { background:linear-gradient(135deg, #38bdf8, #0284c7); }
.type-Unzip .node-icon { background:linear-gradient(135deg, #facc15, #ca8a04); }
.type-VisualLogic .node-icon { background:linear-gradient(135deg, #8b5cf6, #6d28d9); }
/* Structural / analysis StepTypes that lib/types.js already executes but that
   had no editor styling, so they rendered as an unlabelled "?" chip. */
.type-Noop .node-icon { background:linear-gradient(135deg, #64748b, #475569); }
.type-Score .node-icon { background:linear-gradient(135deg, #22d3ee, #0891b2); }
.type-Grow .node-icon { background:linear-gradient(135deg, #4ade80, #16a34a); }
.type-Channel .node-icon { background:linear-gradient(135deg, #c084fc, #7e22ce); }
.type-Topology .node-icon { background:linear-gradient(135deg, #fb923c, #ea580c); }
.type-CVA .node-icon { background:linear-gradient(135deg, #fb7185, #e11d48); }
.type-Iteration .node-icon { background:linear-gradient(135deg, #db61a2, #bf4b8a); }
.type-Check .node-icon { background:linear-gradient(135deg, var(--purple), #8957e5); }
.type-Done .node-icon { background:linear-gradient(135deg, var(--red), #da3633); }

/* Type accent bar */
.node::before {
  content:''; position:absolute; left:0; top:8px; bottom:8px; width:3px;
  border-radius:0 2px 2px 0;
}
.type-LLM::before { background:#6366f1; }
.type-Service::before { background:var(--green); }
.type-API::before { background:var(--cyan); }
.type-Write::before { background:var(--orange); }
.type-Set::before { background:var(--blue); }
.type-Actor::before { background:var(--actor); }
.type-Subflow::before { background:var(--subflow); }
.type-Review::before { background:var(--review); }
.type-GraphPatch::before { background:var(--graphpatch); }
.type-Swarm::before { background:var(--swarm); }
.type-Branch::before { background:var(--purple); }
.type-Loop::before { background:#db61a2; }
.type-Stop::before { background:var(--red); }
.type-Component::before { background:#2dd4bf; }
.type-Pause::before { background:var(--violet); }
.type-Fork::before { background:var(--emerald); }
/* New in Spec 3.16 */
.type-Download::before { background:#38bdf8; }
.type-Unzip::before { background:#facc15; }
.type-Noop::before { background:#64748b; }
.type-Score::before { background:#22d3ee; }
.type-Grow::before { background:#4ade80; }
.type-Channel::before { background:#c084fc; }
.type-Topology::before { background:#fb923c; }
.type-CVA::before { background:#fb7185; }
.type-Iteration::before { background:#db61a2; }
.type-Check::before { background:var(--purple); }
.type-Done::before { background:var(--red); }

/* Truthful empty-payload notice. Two different facts share one look but never
   one wording: "the runtime projection carries no payload" (renderer input
   limit) vs "this step declares no payload" (genuinely empty semantics). */
.node-empty {
  color:var(--text2); font-size:9px; line-height:1.4; font-style:italic;
  padding:2px 0;
}
.node-empty strong { color:var(--text); font-style:normal; font-weight:600; }
.node-degree { display:flex; gap:6px; margin-top:3px; font-size:9px; color:var(--text2); }
.node-degree span { background:rgba(255,255,255,0.05); border-radius:2px; padding:1px 5px; }

/* Status overlays */
.node.status-running { border-color:var(--orange); animation:pulse-border 1.5s ease-in-out infinite; }
.node.status-done { border-color:var(--green); }
.node.status-error { border-color:var(--red); }
.node.status-paused { border-color:var(--violet); animation:pulse-border-purple 1.5s ease-in-out infinite; }
.node.status-skipped { border-color:var(--text2); opacity:0.5; }

.status-badge { position:absolute; top:-6px; right:-6px; width:16px; height:16px; border-radius:50%; font-size:9px; display:flex; align-items:center; justify-content:center; z-index:5; }
.status-badge.running { background:var(--orange); animation:spin 1s linear infinite; }
.status-badge.done { background:var(--green); }
.status-badge.error { background:var(--red); }
.status-badge.paused { background:var(--violet); animation:pulse-badge-purple 2s ease-in-out infinite; }
.status-badge.skipped { background:var(--text2); opacity:0.5; }

@keyframes pulse-border { 0%,100% { box-shadow:0 0 8px rgba(210,153,34,0.3); } 50% { box-shadow:0 0 20px rgba(210,153,34,0.6); } }
@keyframes pulse-border-purple { 0%,100% { box-shadow:0 0 8px rgba(139,92,246,0.3); } 50% { box-shadow:0 0 20px rgba(139,92,246,0.6); } }
@keyframes pulse-badge-purple { 0%,100% { opacity:1; } 50% { opacity:0.4; } }
@keyframes spin { to { transform:rotate(360deg); } }

/* ===== Ports =====
   autoLayout() lays dependency layers out left-to-right, so the visible ports
   are on the left (in) and right (out) edge, vertically centred. The offsets
   are chosen so the port centre is exactly (x, y + h/2) and (x + w, y + h/2):
   .port is border-box 8px wide, so left:-4px / right:-4px centre it on the
   card edge. WFGeometry.inPort()/outPort() return those same two points, which
   is what every edge is anchored to. */
.port { position:absolute; width:8px; height:8px; border-radius:50%; background:var(--border); border:1px solid var(--text2); z-index:3; }
.port-in { left:-4px; top:50%; transform:translateY(-50%); }
.port-out { right:-4px; top:50%; transform:translateY(-50%); }

/* ===== Connections (SVG) =====
   Explicitly sized (in model px) by applyStageMetrics(); overflow:visible so a
   detour lane routed above the topmost card is never cut off. */
.connections { position:absolute; top:0; left:0; pointer-events:none; z-index:1; overflow:visible; transform-origin:0 0; }
.conn-path { fill:none; stroke-width:2; }
.conn-path.serial { stroke:var(--blue); }
.conn-path.parallel { stroke:var(--purple); stroke-dasharray:6 3; }
.conn-path.branch-case { stroke:var(--orange); stroke-dasharray:4 2 1 2; }
.conn-path.on-error { stroke:var(--red); stroke-dasharray:7 3; }
.conn-path.data { stroke:var(--emerald); stroke-dasharray:2 3; }
.conn-path.message { stroke:var(--teal); stroke-dasharray:9 3 2 3; }
.conn-path.other { stroke:var(--text2); stroke-dasharray:3 3; }
.conn-path.feedback { stroke:#db61a2; stroke-dasharray:2 4; }
.conn-path.contains { stroke:var(--text2); stroke-width:1.5; stroke-dasharray:1 4; }
.conn-label { fill:var(--text2); font-size:9px; font-family:inherit; }
.conn-label-bg { fill:var(--bg); fill-opacity:0.82; }
svg defs marker path { fill:var(--blue); }

/* ===== Legend =====
   Only the edge kinds actually present in the loaded graph are shown, so the
   overlay stays as small as the graph needs and never covers cards it does not
   have to. */
.legend {
  position:fixed; bottom:10px; left:10px; background:var(--bg2); border:1px solid var(--border);
  border-radius:6px; padding:6px 10px; font-size:9px; color:var(--text2); display:flex; gap:10px; z-index:10;
  max-width:calc(100% - 190px); flex-wrap:wrap; pointer-events:none;
}
.legend-item { display:flex; align-items:center; gap:4px; }
.legend-line { width:20px; height:2px; flex-shrink:0; }
.legend-line.serial { background:var(--blue); }
.legend-line.parallel { background:repeating-linear-gradient(90deg, var(--purple) 0 6px, transparent 6px 9px); }
.legend-line.branch-case { background:var(--orange); }
.legend-line.on-error { background:repeating-linear-gradient(90deg, var(--red) 0 7px, transparent 7px 10px); }
.legend-line.data { background:repeating-linear-gradient(90deg, var(--emerald) 0 2px, transparent 2px 5px); }
.legend-line.message { background:repeating-linear-gradient(90deg, var(--teal) 0 9px, transparent 9px 12px); }
.legend-line.other { background:repeating-linear-gradient(90deg, var(--text2) 0 3px, transparent 3px 6px); }
.legend-line.feedback { background:repeating-linear-gradient(90deg, #db61a2 0 2px, transparent 2px 6px); }
.legend-line.contains { background:repeating-linear-gradient(90deg, var(--text2) 0 1px, transparent 1px 5px); }

/* Runtime projection facets (composition companion): declared I/O labels,
   identity footer and container/interaction facts. Display only. */
.node-facet-io { padding:5px 10px; font-size:9px; color:var(--text2); border-top:1px dashed var(--border); }
.node-facet-io div { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.node-facet-io b { color:var(--cyan); font-weight:400; font-family:'SF Mono','Fira Code',monospace; }
.node-facet-footer {
  display:flex; justify-content:space-between; gap:6px; padding:4px 8px; border-top:1px dashed var(--border);
  font-size:9px; color:var(--text2); font-family:'SF Mono','Fira Code',monospace;
}
.node-facet-footer span { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.node-facet-footer .facet-state-running, .node-facet-footer .facet-state-waiting-human,
.node-facet-footer .facet-state-retrying { color:var(--orange); }
.node-facet-footer .facet-state-done, .node-facet-footer .facet-state-cached { color:var(--green); }
.node-facet-footer .facet-state-error { color:var(--red); }
.node-facet-container { padding:3px 10px 5px; font-size:9px; color:var(--purple); }
.node-facet-interaction { padding:3px 10px 5px; font-size:9px; color:var(--orange); }

/* ===== View (zoom) HUD =====
   Available in host-controlled mode too: hostToolbar=1 hides the authoring
   toolbar, and the graph must still be zoomable/fittable from inside the
   frame. Kept to the bottom-right corner so it never covers the legend. */
.viewhud {
  position:fixed; bottom:10px; right:10px; z-index:11;
  display:flex; align-items:center; gap:4px;
  background:var(--bg2); border:1px solid var(--border); border-radius:6px; padding:3px 5px;
}
.viewhud button {
  background:var(--bg3); border:1px solid var(--border); color:var(--text2);
  border-radius:4px; cursor:pointer; font-family:inherit; font-size:10px;
  min-width:20px; height:19px; padding:0 5px; line-height:1;
}
.viewhud button:hover { background:var(--border); color:var(--text); }
.viewhud .viewhud-scale {
  font-size:9px; color:var(--blue); min-width:36px; text-align:center;
  font-variant-numeric:tabular-nums;
}

/* ===== Toast ===== */
.toast {
  position:fixed; top:48px; left:50%; transform:translateX(-50%);
  background:var(--bg3); border:1px solid var(--border); border-radius:6px;
  padding:6px 16px; font-size:11px; color:var(--text); z-index:100;
  opacity:0; transition:opacity 0.3s;
}
.toast.show { opacity:1; }

/* ===== Context Menu ===== */
.ctx-menu {
  position:fixed; background:var(--bg2); border:1px solid var(--border);
  border-radius:6px; padding:4px 0; z-index:200; min-width:180px;
  box-shadow:0 4px 16px rgba(0,0,0,0.5); display:none;
}
.ctx-menu.show { display:block; }
.ctx-item {
  padding:6px 14px; font-size:11px; color:var(--text); cursor:pointer;
  display:flex; align-items:center; gap:8px; font-family:inherit;
}
.ctx-item:hover { background:var(--bg3); }
.ctx-item.disabled { color:var(--text2); cursor:default; opacity:0.5; }
.ctx-item.disabled:hover { background:transparent; }
.ctx-sep { height:1px; background:var(--border); margin:4px 0; }
.ctx-item .ctx-icon { font-size:12px; width:16px; text-align:center; }
.ctx-item .ctx-label { flex:1; }
.ctx-item .ctx-hint { font-size:9px; color:var(--text2); }

/* ===== Node Editor Modal ===== */
.modal-overlay {
  position:fixed; top:0; left:0; width:100%; height:100%;
  background:rgba(0,0,0,0.6); z-index:300; display:none;
  align-items:center; justify-content:center;
}
.modal-overlay.show { display:flex; }
.modal {
  background:var(--bg2); border:1px solid var(--border); border-radius:10px;
  width:520px; max-height:80vh; display:flex; flex-direction:column;
  box-shadow:0 8px 32px rgba(0,0,0,0.5);
}
.modal-header {
  padding:12px 16px; border-bottom:1px solid var(--border);
  display:flex; align-items:center; gap:8px;
}
.modal-header .modal-title { font-size:12px; font-weight:600; color:var(--text); flex:1; }
.modal-header .modal-close {
  background:none; border:none; color:var(--text2); font-size:16px;
  cursor:pointer; padding:2px 6px; border-radius:4px;
}
.modal-header .modal-close:hover { background:var(--bg3); color:var(--text); }
.modal-body { padding:12px 16px; overflow-y:auto; flex:1; }
.modal-footer {
  padding:10px 16px; border-top:1px solid var(--border);
  display:flex; justify-content:flex-end; gap:8px;
}
.modal-footer .tb-btn { padding:5px 14px; font-size:11px; }

/* Editor fields */
.editor-field { margin-bottom:10px; }
.editor-field:last-child { margin-bottom:0; }
.editor-label { font-size:9px; color:var(--text2); text-transform:uppercase; letter-spacing:0.5px; margin-bottom:4px; }
.editor-input {
  width:100%; background:var(--bg); border:1px solid var(--border);
  color:var(--text); font-family:'SF Mono','Fira Code',monospace; font-size:11px;
  padding:6px 8px; border-radius:4px; resize:vertical;
}
.editor-input:focus { outline:none; border-color:var(--blue); }
.editor-json {
  width:100%; background:var(--bg); border:1px solid var(--border);
  color:var(--text); font-family:'SF Mono','Fira Code',monospace; font-size:10px;
  padding:8px; border-radius:4px; resize:vertical; min-height:120px;
  line-height:1.5;
}
.editor-json:focus { outline:none; border-color:var(--blue); }
.editor-hint { font-size:9px; color:var(--text2); margin-top:3px; }
.editor-error { font-size:9px; color:var(--red); margin-top:3px; display:none; }

/* Node status info in editor */
.editor-status {
  display:flex; align-items:center; gap:6px; padding:6px 8px;
  background:rgba(255,255,255,0.03); border-radius:4px; margin-bottom:10px;
}
.editor-status-dot { width:8px; height:8px; border-radius:50%; }
.editor-status-dot.done { background:var(--green); }
.editor-status-dot.running { background:var(--orange); }
.editor-status-dot.error { background:var(--red); }
.editor-status-dot.paused { background:var(--violet); }
.editor-status-dot.skipped { background:var(--text2); }
.editor-status-dot.pending { background:var(--border); }
.editor-status-text { font-size:10px; color:var(--text2); }

/* Host-controlled Runtime debug mode keeps the canonical DAG surface while
   removing authoring and bundle custody from the embedded frame. */
body.host-controlled.debug-only [data-authoring-control],
body.host-controlled.debug-only #runBtn,
body.host-controlled.debug-only #bundleSidebar,
body.host-controlled.debug-only #importInput { display:none !important; }
body.host-controlled.debug-only .toolbar { min-height:40px; height:40px; }
.node.runtime-breakpoint { box-shadow:0 0 0 2px var(--red), 0 0 18px rgba(248,81,73,0.3); }
.node.runtime-breakpoint::after {
  content:'BP'; position:absolute; top:-8px; left:-8px; z-index:6;
  min-width:20px; height:20px; padding:0 4px; border-radius:10px;
  display:flex; align-items:center; justify-content:center;
  background:var(--red); color:#fff; font-size:8px; font-weight:700;
}

/* Pause button (amber) */
.tb-btn.warn { border-color:var(--orange); color:var(--orange); }
.tb-btn.warn:hover { background:var(--orange); color:#000; }
</style>
</head>
<body>

<!-- Toolbar -->
<div class="toolbar">
  <span class="title" id="wfTitle">VL Workflow DAG</span>
  <div class="sep"></div>
  <button class="tb-btn" onclick="autoLayout({ preserveManual: true });render();" title="Re-arrange nodes">&#8634; Layout</button>
  <div class="layout-density" title="Layout density">
    <span class="layout-density-label">Compact</span>
    <input type="range" id="layoutDensity" min="0" max="100" step="1" value="42">
    <span class="layout-density-label">Loose</span>
    <span class="layout-density-value" id="layoutDensityValue">42%</span>
  </div>
  <button class="tb-btn" onclick="downloadPNG()" title="Export as PNG image">&#128247; PNG</button>
  <div class="sep"></div>
  <button class="tb-btn" data-authoring-control onclick="openVflowBundle()" title="Open a .vflow bundle">&#128449; Open Bundle</button>
  <button class="tb-btn" data-authoring-control onclick="saveVflowBundle()" title="Save current workflow as .vflow">&#128190; Save</button>
  <button class="tb-btn" data-authoring-control onclick="saveVflowBundle({ saveAs: true })" title="Save current workflow as a new .vflow">&#128193; Save As</button>
  <button class="tb-btn" data-authoring-control onclick="exportJSON()" title="Export workflow JSON">&#8615; Export</button>
  <button class="tb-btn" data-authoring-control onclick="importJSON()" title="Import workflow JSON">&#8613; Import</button>
  <div class="sep" data-authoring-control></div>
  <button class="tb-btn primary" onclick="runWorkflow()" title="Execute this workflow" id="runBtn">&#9654; Run</button>
  <button class="tb-btn warn" onclick="pauseExecution()" title="Pause execution" id="pauseBtn" style="display:none;">&#9208; Pause</button>
  <button class="tb-btn" onclick="resumeExecution()" title="Resume execution" id="resumeBtn" style="display:none;">&#9654; Resume</button>
  <button class="tb-btn danger" onclick="stopExecution()" title="Stop execution" id="stopBtn" style="display:none;">&#9632; Stop</button>
  <div style="flex:1;"></div>
  <span id="statusLabel" style="font-size:10px;color:var(--text2);"></span>
</div>

<!-- Main Body -->
<div class="editor-body">
  <!-- Canvas -->
  <div class="canvas-wrap" id="canvasWrap">
    <div class="empty-msg" id="emptyMsg">No workflow loaded.<br>Select a workflow to visualize.</div>
    <div class="canvas" id="canvas" style="display:none;">
      <svg class="connections" id="connSvg">
        <defs>
          <marker id="arrowSerial" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
            <path d="M 0 0 L 8 3 L 0 6 Z" fill="var(--blue)"/>
          </marker>
          <marker id="arrowParallel" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
            <path d="M 0 0 L 8 3 L 0 6 Z" fill="var(--purple)"/>
          </marker>
          <marker id="arrowBranch" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
            <path d="M 0 0 L 8 3 L 0 6 Z" fill="var(--orange)"/>
          </marker>
          <marker id="arrowError" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
            <path d="M 0 0 L 8 3 L 0 6 Z" fill="var(--red)"/>
          </marker>
          <marker id="arrowData" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
            <path d="M 0 0 L 8 3 L 0 6 Z" fill="var(--emerald)"/>
          </marker>
          <marker id="arrowMessage" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
            <path d="M 0 0 L 8 3 L 0 6 Z" fill="var(--teal)"/>
          </marker>
          <marker id="arrowOther" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
            <path d="M 0 0 L 8 3 L 0 6 Z" fill="var(--text2)"/>
          </marker>
          <marker id="arrowFeedback" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
            <path d="M 0 0 L 8 3 L 0 6 Z" fill="#db61a2"/>
          </marker>
          <marker id="arrowContains" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
            <path d="M 0 0 L 8 3 L 0 6 Z" fill="var(--text2)"/>
          </marker>
        </defs>
      </svg>
      <div class="nodes-layer" id="nodesLayer"></div>
    </div>
  </div>

  <!-- Bundle Sidebar -->
  <aside class="bundle-sidebar" id="bundleSidebar">
    <div class="bundle-sidebar-header">
      <div style="min-width:0;">
        <div class="bundle-sidebar-title" id="bundleSidebarTitle">Bundle</div>
        <div class="bundle-sidebar-path" id="bundleSidebarPath">No .vflow loaded yet.</div>
      </div>
      <button class="tb-btn" onclick="toggleBundleSidebar()" id="bundleSidebarToggleBtn" title="Hide bundle sidebar">Hide</button>
    </div>
    <div class="bundle-sidebar-stats" id="bundleSidebarStats">0 nodes</div>
    <div class="bundle-node-list" id="bundleNodeList">
      <div class="bundle-load-empty">Open a `.vflow` bundle to inspect nodes, attached `.vl` resources, and support files.</div>
    </div>
  </aside>
</div>

<!-- Legend -->
<div class="legend" id="legend" style="display:none;">
  <div class="legend-item" data-edge-kind="serial"><div class="legend-line serial"></div>Serial (next)</div>
  <div class="legend-item" data-edge-kind="parallel"><div class="legend-line parallel"></div>Parallel (children)</div>
  <div class="legend-item" data-edge-kind="branch-case"><div class="legend-line branch-case"></div>Branch (case)</div>
  <div class="legend-item" data-edge-kind="on-error"><div class="legend-line on-error"></div>On error</div>
  <div class="legend-item" data-edge-kind="data"><div class="legend-line data"></div>Data</div>
  <div class="legend-item" data-edge-kind="message"><div class="legend-line message"></div>Message</div>
  <div class="legend-item" data-edge-kind="other"><div class="legend-line other"></div>Other</div>
  <div class="legend-item" data-edge-kind="feedback"><div class="legend-line feedback"></div>Iteration feedback</div>
  <div class="legend-item" data-edge-kind="contains"><div class="legend-line contains"></div>Contains / executes</div>
</div>

<!-- View HUD (zoom / fit) -->
<div class="viewhud" id="viewHud" style="display:none;">
  <button type="button" id="zoomOutBtn" onclick="zoomBy(1 / 1.2)" title="Zoom out (Ctrl + wheel)">&minus;</button>
  <span class="viewhud-scale" id="viewScaleLabel">100%</span>
  <button type="button" id="zoomInBtn" onclick="zoomBy(1.2)" title="Zoom in (Ctrl + wheel)">+</button>
  <button type="button" id="fitViewBtn" onclick="fitWorkflowView()" title="Fit every node into the visible area">Fit</button>
</div>

<!-- Toast -->
<div class="toast" id="toast"></div>

<!-- Context Menu -->
<div class="ctx-menu" id="ctxMenu">
  <div class="ctx-item" id="ctxRerun" onclick="ctxRerunFromHere()">
    <span class="ctx-icon">&#9654;</span><span class="ctx-label">Re-run from here</span>
  </div>
  <div class="ctx-item" id="ctxEditRerun" onclick="ctxEditAndRerun()">
    <span class="ctx-icon">&#9998;</span><span class="ctx-label">Edit inputs &amp; re-run</span>
  </div>
  <div class="ctx-item" id="ctxBreakpoint" onclick="ctxToggleBreakpoint()" style="display:none;">
    <span class="ctx-icon">BP</span><span class="ctx-label" id="ctxBreakpointLabel">Set breakpoint</span>
  </div>
  <div class="ctx-item" id="ctxReview" onclick="ctxReviewAcceptance()">
    <span class="ctx-icon">&#9989;</span><span class="ctx-label">Review acceptance</span>
  </div>
  <div class="ctx-sep"></div>
  <div class="ctx-item" onclick="ctxViewDetails()">
    <span class="ctx-icon">&#128269;</span><span class="ctx-label">View details</span>
  </div>
  <div class="ctx-item" onclick="ctxCopyId()">
    <span class="ctx-icon">&#128203;</span><span class="ctx-label">Copy node ID</span><span class="ctx-hint" id="ctxNodeIdHint"></span>
  </div>
</div>

<!-- Acceptance Modal -->
<div class="modal-overlay" id="acceptanceOverlay">
  <div class="modal">
    <div class="modal-header">
      <span class="modal-title" id="acceptanceTitle">Review Acceptance</span>
      <button class="modal-close" onclick="closeAcceptanceModal()">&times;</button>
    </div>
    <div class="modal-body" id="acceptanceBody"></div>
    <div class="modal-footer">
      <button class="tb-btn" onclick="closeAcceptanceModal()">Cancel</button>
      <button class="tb-btn primary" onclick="submitAcceptanceReview()">Submit review</button>
    </div>
  </div>
</div>

<!-- Node Editor Modal -->
<div class="modal-overlay" id="editorOverlay">
  <div class="modal">
    <div class="modal-header">
      <span class="modal-title" id="editorTitle">Edit Node Inputs</span>
      <button class="modal-close" onclick="closeEditor()">&times;</button>
    </div>
    <div class="modal-body" id="editorBody"></div>
    <div class="modal-footer">
      <button class="tb-btn" onclick="closeEditor()">Cancel</button>
      <button class="tb-btn primary" onclick="editorRerun()" id="editorRerunBtn">&#9654; Re-run from here</button>
    </div>
  </div>
</div>

<!-- Hidden file input for import -->
<input type="file" id="importInput" accept=".json" style="display:none">

<script id="wf-geometry">
/* ===== WFGeometry — pure DAG geometry core =====
 * Canonical source: vl-workflow-engine/ui/workflow-editor.html
 *
 * Every layout / edge-routing / fit decision the editor makes lives here as a
 * pure function of *measured* boxes. Nothing in this block touches the DOM, so
 * the same code that runs in the browser is what the deterministic geometry
 * regressions in @vl/ui-runtime-graph execute directly.
 *
 * Model space is unscaled CSS px: a box is { id, x, y, w, h } where x/y is the
 * card's top-left and w/h are its *measured* offsetWidth/offsetHeight. There is
 * no fixed card height anywhere in this module.
 */
(function (global) {
  'use strict';

  // Only used when a card has not been measured yet (first layout pass before
  // the very first paint). Every later pass replaces it with a real height.
  var FALLBACK_W = 240;
  var FALLBACK_H = 140;

  function clamp(n, min, max) { return Math.max(min, Math.min(max, n)); }
  function num(value, fallback) { return Number.isFinite(value) ? value : fallback; }
  function round(n) { return Math.round(n * 100) / 100; }

  /** Normalize any node-ish record into a measured model box. */
  function box(node) {
    var w = num(node && node.w, 0);
    var h = num(node && node.h, 0);
    return {
      id: node && node.id,
      x: num(node && node.x, 0),
      y: num(node && node.y, 0),
      w: w > 0 ? w : FALLBACK_W,
      h: h > 0 ? h : FALLBACK_H,
    };
  }

  function boxTop(b) { return b.y; }
  function boxBottom(b) { return b.y + b.h; }
  function boxLeft(b) { return b.x; }
  function boxRight(b) { return b.x + b.w; }

  /** Visible in-port centre: left edge, vertically centred (see .port-in). */
  function inPort(b) { var m = box(b); return { x: m.x, y: m.y + m.h / 2 }; }
  /** Visible out-port centre: right edge, vertically centred (see .port-out). */
  function outPort(b) { var m = box(b); return { x: m.x + m.w, y: m.y + m.h / 2 }; }

  /**
   * Layer packing that honours measured heights.
   *
   * items: [{ id, layer, w, h, x, y, pinned }] in the intra-layer order the
   * caller already decided (barycenter ordering). Pinned items (manual
   * placement, or an explicitly preserved position) keep their own x/y and are
   * treated as obstacles so flowed cards are never stacked on top of them.
   *
   * Column x advances by the widest measured card in the column, and card y
   * advances by that card's own measured height — so a tall expanded Set card
   * pushes its neighbours down instead of overlapping them.
   */
  function packColumns(items, options) {
    var opt = options || {};
    var startX = num(opt.startX, 60);
    var startY = num(opt.startY, 48);
    var layerGap = num(opt.layerGap, 230);
    var nodeGap = num(opt.nodeGap, 40);
    var midY = num(opt.midY, 0);

    var byLayer = new Map();
    (items || []).forEach(function (item) {
      var layer = num(item && item.layer, 0);
      if (!byLayer.has(layer)) byLayer.set(layer, []);
      var entry = box(item);
      entry.pinned = !!(item && item.pinned);
      byLayer.get(layer).push(entry);
    });

    var layers = Array.from(byLayer.keys()).sort(function (a, b) { return a - b; });
    var pinnedAll = [];
    byLayer.forEach(function (column) {
      column.forEach(function (entry) { if (entry.pinned) pinnedAll.push(entry); });
    });

    var positions = new Map();
    var columns = [];
    var cursorX = startX;

    layers.forEach(function (layer) {
      var column = byLayer.get(layer);
      var flow = column.filter(function (entry) { return !entry.pinned; });
      var columnW = flow.reduce(function (widest, entry) { return Math.max(widest, entry.w); }, 0) || FALLBACK_W;
      var flowH = flow.reduce(function (total, entry) { return total + entry.h; }, 0)
        + Math.max(0, flow.length - 1) * nodeGap;
      var cursorY = Math.max(startY, midY - flowH / 2);

      // Pinned cards anywhere that horizontally overlap this column band.
      var bandLeft = cursorX;
      var bandRight = cursorX + columnW;
      var blockers = pinnedAll.filter(function (entry) {
        return boxRight(entry) > bandLeft && boxLeft(entry) < bandRight;
      }).sort(function (a, b) { return a.y - b.y; });

      column.forEach(function (entry) {
        if (entry.pinned) positions.set(entry.id, { x: Math.round(entry.x), y: Math.round(entry.y) });
      });

      flow.forEach(function (entry) {
        cursorY = clearBlockers(cursorY, entry.h, blockers, nodeGap);
        positions.set(entry.id, { x: Math.round(cursorX), y: Math.round(cursorY) });
        cursorY += entry.h + nodeGap;
      });

      columns.push({ layer: layer, x: Math.round(cursorX), width: columnW, count: column.length });
      cursorX += columnW + layerGap;
    });

    return { positions: positions, columns: columns };
  }

  /** Push y down until [y, y+h] clears every blocker band (bounded). */
  function clearBlockers(y, h, blockers, gap) {
    var next = y;
    for (var pass = 0; pass < blockers.length + 1; pass++) {
      var moved = false;
      for (var i = 0; i < blockers.length; i++) {
        var b = blockers[i];
        if (next < boxBottom(b) + gap && next + h > boxTop(b) - gap) {
          next = boxBottom(b) + gap;
          moved = true;
        }
      }
      if (!moved) break;
    }
    return next;
  }

  /**
   * Boxes that a straight source-port → target-port run would cross.
   * A cubic whose control points keep the endpoint y values is monotone in y,
   * so the endpoint-spanned rectangle (widened by `clearance`) is a sound
   * corridor test.
   */
  function blockingBoxes(a, b, obstacles, skipIds, clearance) {
    var pad = num(clearance, 18);
    var left = Math.min(a.x, b.x);
    var right = Math.max(a.x, b.x);
    var top = Math.min(a.y, b.y) - pad;
    var bottom = Math.max(a.y, b.y) + pad;
    var skip = new Set(skipIds || []);
    return (obstacles || []).map(box).filter(function (entry) {
      if (skip.has(entry.id)) return false;
      return boxRight(entry) > left && boxLeft(entry) < right
        && boxBottom(entry) > top && boxTop(entry) < bottom;
    });
  }

  /**
   * Boxes whose horizontal extent overlaps a detour lane's travel span.
   * A lane runs the whole way from the exit stub to the entry stub, so it can
   * meet cards that the straight corridor never came near — this is what a back
   * edge crossing an unrelated column looks like.
   */
  function spanBoxes(left, right, obstacles, skipIds) {
    var lo = Math.min(left, right);
    var hi = Math.max(left, right);
    var skip = new Set(skipIds || []);
    return (obstacles || []).map(box).filter(function (entry) {
      if (skip.has(entry.id)) return false;
      return boxRight(entry) > lo && boxLeft(entry) < hi;
    });
  }

  /** Pick a horizontal detour lane above or below everything in the way. */
  function pickLane(a, b, boxes, clearance) {
    var pad = num(clearance, 18);
    var tops = boxes.map(boxTop).concat([a.y, b.y]);
    var bottoms = boxes.map(boxBottom).concat([a.y, b.y]);
    var above = Math.min.apply(null, tops) - pad;
    var below = Math.max.apply(null, bottoms) + pad;
    var mid = (a.y + b.y) / 2;
    if (above < pad) return below;
    return (mid - above) <= (below - mid) ? above : below;
  }

  function samePoint(p, q) { return Math.abs(p.x - q.x) < 0.01 && Math.abs(p.y - q.y) < 0.01; }

  function dedupePoints(points) {
    var out = [];
    points.forEach(function (p) {
      if (!out.length || !samePoint(out[out.length - 1], p)) out.push(p);
    });
    // Drop points that are collinear with both neighbours on an axis run.
    var trimmed = [out[0]];
    for (var i = 1; i < out.length - 1; i++) {
      var prev = trimmed[trimmed.length - 1];
      var cur = out[i];
      var next = out[i + 1];
      var flatX = Math.abs(prev.x - cur.x) < 0.01 && Math.abs(cur.x - next.x) < 0.01;
      var flatY = Math.abs(prev.y - cur.y) < 0.01 && Math.abs(cur.y - next.y) < 0.01;
      if (!flatX && !flatY) trimmed.push(cur);
    }
    if (out.length > 1) trimmed.push(out[out.length - 1]);
    return trimmed;
  }

  function distance(p, q) { return Math.hypot(q.x - p.x, q.y - p.y); }

  function towards(from, to, dist) {
    var len = distance(from, to);
    if (len < 0.01) return { x: from.x, y: from.y };
    var t = Math.min(1, dist / len);
    return { x: from.x + (to.x - from.x) * t, y: from.y + (to.y - from.y) * t };
  }

  function fmt(p) { return round(p.x) + ' ' + round(p.y); }

  /** Orthogonal polyline with rounded corners, as an SVG path. */
  function roundedPath(points, radius) {
    var pts = dedupePoints(points || []);
    if (pts.length < 2) return '';
    var r = num(radius, 12);
    var d = 'M ' + fmt(pts[0]);
    for (var i = 1; i < pts.length - 1; i++) {
      var prev = pts[i - 1];
      var cur = pts[i];
      var next = pts[i + 1];
      var corner = Math.min(r, distance(prev, cur) / 2, distance(cur, next) / 2);
      d += ' L ' + fmt(towards(cur, prev, corner));
      d += ' Q ' + fmt(cur) + ' ' + fmt(towards(cur, next, corner));
    }
    d += ' L ' + fmt(pts[pts.length - 1]);
    return d;
  }

  function pointsBounds(points) {
    var xs = points.map(function (p) { return p.x; });
    var ys = points.map(function (p) { return p.y; });
    return {
      minX: Math.min.apply(null, xs), maxX: Math.max.apply(null, xs),
      minY: Math.min.apply(null, ys), maxY: Math.max.apply(null, ys),
    };
  }

  /**
   * Route one real edge from the source card's visible out-port to the target
   * card's visible in-port. The anchors are always the two port centres — a
   * back edge loops around rather than sprouting from a different side.
   *
   * options.spread separates duplicate edges between the same pair without
   * moving either anchor.
   */
  function edgeRoute(fromBox, toBox, options) {
    var opt = options || {};
    var clearance = num(opt.clearance, 18);
    var stub = num(opt.stub, 26);
    var radius = num(opt.radius, 12);
    var minForwardGap = num(opt.minForwardGap, 24);
    var spread = num(opt.spread, 0);

    var source = box(fromBox);
    var target = box(toBox);
    var a = outPort(source);
    var b = inPort(target);
    var gap = b.x - a.x;
    var blockers = blockingBoxes(a, b, opt.obstacles, [source.id, target.id], clearance);

    if (gap >= minForwardGap && blockers.length === 0) {
      var cp = clamp(gap * 0.5, 24, 160);
      var c1 = { x: a.x + cp, y: a.y + spread };
      var c2 = { x: b.x - cp, y: b.y + spread };
      return {
        kind: 'direct',
        from: a,
        to: b,
        d: 'M ' + fmt(a) + ' C ' + fmt(c1) + ', ' + fmt(c2) + ', ' + fmt(b),
        label: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 + spread - 5 },
        bounds: pointsBounds([a, b, c1, c2]),
        detour: false,
      };
    }

    // Blocked forward run, back edge, or same-column edge: leave the out-port
    // to the right, travel along a clear lane, and arrive at the in-port from
    // the left. Both anchors stay exactly on their ports.
    var exitX = a.x + stub;
    var entryX = b.x - stub;
    // The lane must clear everything it actually travels past, not only what
    // sat inside the straight corridor.
    var inTheWay = spanBoxes(exitX, entryX, opt.obstacles, [source.id, target.id]);
    var baseLane = pickLane(a, b, inTheWay.concat([source, target]), clearance);
    var above = baseLane < (a.y + b.y) / 2;
    // laneOffset stacks detours that would otherwise share one lane; it always
    // pushes further away from the cards, never back across them.
    var lane = baseLane + spread + (above ? -1 : 1) * Math.abs(num(opt.laneOffset, 0));
    var points = [
      a,
      { x: exitX, y: a.y },
      { x: exitX, y: lane },
      { x: entryX, y: lane },
      { x: entryX, y: b.y },
      b,
    ];
    return {
      kind: gap >= minForwardGap ? 'detour' : 'back',
      from: a,
      to: b,
      d: roundedPath(points, radius),
      label: { x: (exitX + entryX) / 2, y: lane - 5 },
      bounds: pointsBounds(points),
      detour: true,
      lane: lane,
      laneAbove: above,
      laneSpan: { lo: Math.min(exitX, entryX), hi: Math.max(exitX, entryX) },
    };
  }

  /**
   * Full content bounds: every card plus every routed edge, so a detour lane
   * above the topmost card or a back edge to the left of the first column is
   * still inside the scrollable area.
   */
  function contentBounds(boxes, routes, pad) {
    var padding = num(pad, 40);
    var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
    (boxes || []).map(box).forEach(function (b) {
      minX = Math.min(minX, boxLeft(b));
      minY = Math.min(minY, boxTop(b));
      maxX = Math.max(maxX, boxRight(b));
      maxY = Math.max(maxY, boxBottom(b));
    });
    (routes || []).forEach(function (route) {
      if (!route || !route.bounds) return;
      minX = Math.min(minX, route.bounds.minX);
      minY = Math.min(minY, route.bounds.minY);
      maxX = Math.max(maxX, route.bounds.maxX);
      maxY = Math.max(maxY, route.bounds.maxY);
    });
    if (!Number.isFinite(minX)) return { minX: 0, minY: 0, maxX: 0, maxY: 0, width: 0, height: 0, empty: true };
    minX = Math.max(0, minX - padding);
    minY = Math.max(0, minY - padding);
    maxX += padding;
    maxY += padding;
    return {
      minX: minX, minY: minY, maxX: maxX, maxY: maxY,
      width: maxX - minX, height: maxY - minY, empty: false,
    };
  }

  /**
   * Scale + scroll that brings the whole of `bounds` into `viewport`.
   * Shrinks when the graph is larger than the remaining space (which is what
   * makes the far-right / bottom cards visible), centres when it is smaller.
   */
  function fitView(bounds, viewport, options) {
    var opt = options || {};
    var minScale = num(opt.minScale, 0.2);
    var maxScale = num(opt.maxScale, 1);
    var pad = num(opt.pad, 16);
    var vw = Math.max(1, num(viewport && viewport.width, 0));
    var vh = Math.max(1, num(viewport && viewport.height, 0));
    var bw = Math.max(1, num(bounds && bounds.width, 0));
    var bh = Math.max(1, num(bounds && bounds.height, 0));
    var scale = clamp(Math.min((vw - pad * 2) / bw, (vh - pad * 2) / bh), minScale, maxScale);
    var contentW = bw * scale;
    var contentH = bh * scale;
    return {
      scale: scale,
      scrollLeft: Math.max(0, Math.round(bounds.minX * scale - Math.max(pad, (vw - contentW) / 2))),
      scrollTop: Math.max(0, Math.round(bounds.minY * scale - Math.max(pad, (vh - contentH) / 2))),
    };
  }

  /** Scroll-sizer dimensions so the far edge of the content stays reachable. */
  function stageMetrics(bounds, viewport, scale, pad) {
    var s = num(scale, 1);
    var padding = num(pad, 24);
    return {
      width: Math.max(num(viewport && viewport.width, 0), Math.ceil((num(bounds && bounds.maxX, 0) + padding) * s)),
      height: Math.max(num(viewport && viewport.height, 0), Math.ceil((num(bounds && bounds.maxY, 0) + padding) * s)),
    };
  }

  /** Zoom about a viewport anchor, keeping the model point under it fixed. */
  function zoomAt(current, factor, anchor, scroll, options) {
    var opt = options || {};
    var minScale = num(opt.minScale, 0.2);
    var maxScale = num(opt.maxScale, 2);
    var from = num(current, 1) || 1;
    var next = clamp(from * num(factor, 1), minScale, maxScale);
    var ax = num(anchor && anchor.x, 0);
    var ay = num(anchor && anchor.y, 0);
    var left = num(scroll && scroll.left, 0);
    var top = num(scroll && scroll.top, 0);
    var modelX = (left + ax) / from;
    var modelY = (top + ay) / from;
    return {
      scale: next,
      scrollLeft: Math.max(0, Math.round(modelX * next - ax)),
      scrollTop: Math.max(0, Math.round(modelY * next - ay)),
    };
  }

  /** Viewport-relative pointer delta → model delta at the current scale. */
  function dragDelta(clientDelta, scale) {
    var s = num(scale, 1) || 1;
    return {
      dx: num(clientDelta && clientDelta.dx, 0) / s,
      dy: num(clientDelta && clientDelta.dy, 0) / s,
    };
  }

  var api = {
    FALLBACK_W: FALLBACK_W,
    FALLBACK_H: FALLBACK_H,
    box: box,
    inPort: inPort,
    outPort: outPort,
    packColumns: packColumns,
    clearBlockers: clearBlockers,
    blockingBoxes: blockingBoxes,
    pickLane: pickLane,
    spanBoxes: spanBoxes,
    roundedPath: roundedPath,
    edgeRoute: edgeRoute,
    contentBounds: contentBounds,
    fitView: fitView,
    stageMetrics: stageMetrics,
    zoomAt: zoomAt,
    dragDelta: dragDelta,
    clamp: clamp,
  };

  global.WFGeometry = api;
  if (typeof module === 'object' && module.exports) module.exports = api;
}(typeof globalThis !== 'undefined' ? globalThis : this));
</script>

<script>
// ===== VL Workflow Editor v4.0 (Spec 4.0) =====
// Canonical source: vl-workflow-engine/ui/workflow-editor.html
// Consumed by: VL-Code, VLCode-Lite, VLClaw

const EDITOR_QUERY = new URLSearchParams(window.location?.search || '');
const HOST_CONTROLLED = EDITOR_QUERY.get('hostControlled') === '1';
const DEBUG_ONLY = HOST_CONTROLLED && EDITOR_QUERY.get('debugOnly') === '1';
const RUNTIME_EDITOR_SOURCE = 'vl-workflow-editor';
const RUNTIME_HOST_SOURCE = 'vl-runtime-dag-host';
const RUNTIME_DEBUG_REQUEST = 'vl:runtime-debug-request';
const RUNTIME_NODE_SELECT = 'vl:runtime-node-select';
const RUNTIME_READY = 'vl:runtime-editor-ready';
const RUNTIME_DEBUG_ACTIONS = new Set([
  'inspect', 'pause', 'resume', 'stop', 'rerun', 'edit-rerun', 'toggle-breakpoint'
]);
const HOST_MESSAGE_TYPES = new Set([
  'loadWorkflow', 'highlightNode', 'updateNodeStatus', 'clearStatus',
  'setCheckpoint', 'setBreakpoints', 'setRuntimeControls', 'setDebugCapabilities', 'fitWorkflow',
  'layoutWorkflow', 'setLayoutDensity', 'exportWorkflowPNG'
]);
let _runtimeBreakpoints = new Set();
let _runtimeDebugCapabilities = new Set();

if (HOST_CONTROLLED) document.body.classList.add('host-controlled');
if (HOST_CONTROLLED && EDITOR_QUERY.get('hostToolbar') === '1') document.querySelector('.toolbar').hidden = true;
if (DEBUG_ONLY) document.body.classList.add('debug-only');

function postRuntimeMessage(message) {
  window.parent.postMessage({ source: RUNTIME_EDITOR_SOURCE, ...message }, window.location.origin);
}

function emitNodeSelection(node) {
  if (!node) return;
  if (HOST_CONTROLLED) {
    postRuntimeMessage({
      type: RUNTIME_NODE_SELECT,
      nodeId: node.id,
      nodeType: node.type,
      nodeData: node.data
    });
    return;
  }
  window.parent.postMessage({ type: 'nodeClick', nodeId: node.id, nodeType: node.type, nodeData: node.data }, '*');
}

function requestRuntimeDebugAction(action, details = {}) {
  if (!HOST_CONTROLLED || !DEBUG_ONLY) return false;
  if (!_runtimeDebugCapabilities.has(action)) return false;
  postRuntimeMessage({ type: RUNTIME_DEBUG_REQUEST, action, ...details });
  return true;
}

function cloneRuntimeValue(value) {
  if (typeof structuredClone === 'function') return structuredClone(value);
  return JSON.parse(JSON.stringify(value));
}

function setRuntimeDebugCapabilities(actions) {
  _runtimeDebugCapabilities = new Set(
    (Array.isArray(actions) ? actions : [])
      .map(action => String(action || ''))
      .filter(action => RUNTIME_DEBUG_ACTIONS.has(action))
  );
  if (!DEBUG_ONLY) return;
  const show = (id, action) => {
    const element = $(id);
    if (element) element.style.display = _runtimeDebugCapabilities.has(action) ? '' : 'none';
  };
  show('pauseBtn', 'pause');
  show('resumeBtn', 'resume');
  show('stopBtn', 'stop');
  show('ctxRerun', 'rerun');
  show('ctxEditRerun', 'edit-rerun');
  show('ctxBreakpoint', 'toggle-breakpoint');
  show('ctxReview', '__never_in_runtime_debug__');
}

const RESERVED_NEXT = new Set(['RETURN', 'BREAK']);
const TYPE_ALIASES = {
  ChildRun: 'Subflow',
  SpawnChildRun: 'Subflow'
};

const NODE_ICONS = {
  LLM:'AI', Service:'SV', API:'AP', Component:'CP', Actor:'AC',
  Subflow:'SF', Review:'RV', GraphPatch:'GP', Swarm:'SW',
  Set:'=', Write:'WR', Branch:'BR', Loop:'LP', Stop:'ST',
  Pause:'\u23F8', Fork:'FK',
  Download:'DL', Unzip:'UZ', VisualLogic:'VL',
  // Structural / analysis StepTypes from lib/types.js. They used to fall
  // through to the "?" chip with no body at all, which read as a broken card.
  Noop:'\u2205', Score:'SC', Grow:'GR', Channel:'CH', Topology:'TP',
  CVA:'CV', Iteration:'IT', Check:'CK', Done:'DN'
};

// Types whose whole job is graph structure: they legitimately carry no
// executable payload, and the card says so instead of looking empty.
const STRUCTURAL_TYPES = new Set(['Noop', 'Fork', 'Topology', 'Channel']);

const KNOWN_TYPES = ['LLM','Service','API','Component','Actor','Subflow','Review','GraphPatch','Swarm','Set','Write','Branch','Loop','Stop','Pause','Fork','Download','Unzip','VisualLogic','Noop','Score','Grow','Channel','Topology','CVA','Iteration','Check','Done'];
const DEFAULT_LAYOUT_DENSITY = 0.42;

// Runtime-graph edge kind (vl.workflow.runtime-graph.v1) → connection class.
// Every kind keeps its own colour so an on-error / data / message edge is never
// silently redrawn as a plain parallel child edge.
const EDGE_KIND_CLASS = {
  serial: 'serial', next: 'serial', 'default': 'serial',
  parallel: 'parallel', child: 'parallel', children: 'parallel', fanout: 'parallel', join: 'parallel',
  branch: 'branch-case', 'branch-case': 'branch-case', 'case': 'branch-case',
  'on-error': 'on-error', error: 'on-error', timeout: 'on-error',
  data: 'data', variable: 'data',
  message: 'message', channel: 'message',
  // Composition semantics (vl.workflow.runtime-composition.v1) mapped by the
  // trusted host adapter: dependency/control are serial dependencies,
  // result/delegation are message edges, Iteration feedback and contains/
  // executes hierarchy keep their own styles so they never read as a
  // dependency.
  dependency: 'serial', control: 'serial',
  result: 'message', delegation: 'message',
  feedback: 'feedback',
  contains: 'contains', executes: 'contains',
};
const EDGE_MARKERS = {
  serial: 'arrowSerial', parallel: 'arrowParallel', 'branch-case': 'arrowBranch',
  'on-error': 'arrowError', data: 'arrowData', message: 'arrowMessage', other: 'arrowOther',
  feedback: 'arrowFeedback', contains: 'arrowContains',
};
// Fit must actually include every node, so the floor has to be low enough for
// a genuinely large graph (hundreds of columns) to land inside a narrow pane.
// A big graph at 0.2 still left its far-right columns outside the viewport.
const VIEW_MIN_SCALE = 0.05;
const VIEW_MAX_SCALE = 2;

let state = {
  nodes: [],
  connections: [],
  selectedNodeId: null,
  registry: {},
  dragging: null,
  layoutDensity: DEFAULT_LAYOUT_DENSITY,
  // Shared scale for #nodesLayer and #connSvg. `autoFit` stays on until the
  // user zooms by hand; while it is on, a host resize re-fits against the
  // space that is actually left (Assistant dock open or closed).
  view: { scale: 1, autoFit: true },
  bounds: null,
  bundle: {
    path: null,
    manifest: null,
    supportFiles: { docs: [], prompts: [], configs: [], vl: [] },
    entries: [],
    label: null,
  },
  sidebarHidden: false,
};
let _currentWorkflowJson = null;
let _executing = false;
let _eventSource = null;
let _lastCheckpoint = null;   // latest checkpoint from engine
let _currentRunID = null;     // active run ID
let _ctxTargetNode = null;    // node targeted by context menu
let _editorNode = null;       // node being edited in modal
let _acceptanceNode = null;   // node targeted by acceptance modal
let _pendingReviewState = null;

// ===== Utilities =====
function $(id) { return document.getElementById(id); }
function esc(s) { return s ? String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;') : ''; }
function trunc(s, n) { return s && s.length > n ? s.substring(0, n) + '\u2026' : (s || ''); }
function resolveDocName(docId) { return state.registry?.docs?.[docId] || `Doc #${docId}`; }

function normalizeBundleFiles(files) {
  if (!Array.isArray(files)) return [];
  return files
    .map((file) => {
      const pathValue = String(file?.path || '').trim().replace(/^\/+/, '');
      if (!pathValue) return null;
      return {
        path: pathValue,
        content: file?.content === undefined || file?.content === null ? '' : String(file.content),
      };
    })
    .filter(Boolean);
}

function normalizeSupportFiles(raw = {}) {
  return {
    docs: normalizeBundleFiles(raw.docs),
    prompts: normalizeBundleFiles(raw.prompts),
    configs: normalizeBundleFiles(raw.configs),
    vl: normalizeBundleFiles(raw.vl),
  };
}

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

function clamp(n, min, max) {
  return Math.max(min, Math.min(max, n));
}

function lerp(a, b, t) {
  return a + (b - a) * t;
}

function hasNodePosition(node) {
  return Number.isFinite(node?.x) && Number.isFinite(node?.y) && (node.x !== 0 || node.y !== 0);
}

function getLayoutStorageKey(json = _currentWorkflowJson) {
  if (HOST_CONTROLLED) return null;
  const name = String(json?.name || '').trim();
  if (!name) return null;
  return `wf_layout_${name}`;
}

function getSidebarStorageKey() {
  return 'wf_bundle_sidebar_hidden';
}

function syncLayoutControls() {
  const slider = $('layoutDensity');
  const valueEl = $('layoutDensityValue');
  const pct = Math.round(clamp(Number.isFinite(state.layoutDensity) ? state.layoutDensity : DEFAULT_LAYOUT_DENSITY, 0, 1) * 100);
  if (slider) slider.value = String(pct);
  if (valueEl) valueEl.textContent = `${pct}%`;
}

function loadLayoutState(json = _currentWorkflowJson) {
  const key = getLayoutStorageKey(json);
  if (!key) return null;
  try {
    const raw = localStorage.getItem(key);
    if (!raw) return null;
    const parsed = JSON.parse(raw);
    const positions = parsed?.positions && typeof parsed.positions === 'object' ? parsed.positions : {};
    return {
      density: typeof parsed?.density === 'number' ? clamp(parsed.density, 0, 1) : null,
      positions
    };
  } catch {
    return null;
  }
}

function saveLayoutState() {
  const key = getLayoutStorageKey();
  if (!key || !state.nodes.length) return;
  try {
    const positions = {};
    for (const node of state.nodes) {
      positions[node.id] = {
        x: Math.round(node.x),
        y: Math.round(node.y),
        manual: !!node.manualPosition
      };
    }
    localStorage.setItem(key, JSON.stringify({
      density: clamp(Number.isFinite(state.layoutDensity) ? state.layoutDensity : DEFAULT_LAYOUT_DENSITY, 0, 1),
      positions,
      ts: Date.now()
    }));
  } catch {}
}

function syncBundleSidebarVisibility() {
  const sidebar = $('bundleSidebar');
  const button = $('bundleSidebarToggleBtn');
  if (sidebar) sidebar.classList.toggle('hidden', !!state.sidebarHidden);
  if (button) button.textContent = state.sidebarHidden ? 'Show' : 'Hide';
}

function loadBundleSidebarState() {
  try {
    state.sidebarHidden = localStorage.getItem(getSidebarStorageKey()) === '1';
  } catch {
    state.sidebarHidden = false;
  }
  syncBundleSidebarVisibility();
}

function setBundleSidebarHidden(hidden, persist = true) {
  state.sidebarHidden = !!hidden;
  syncBundleSidebarVisibility();
  if (persist) {
    try {
      localStorage.setItem(getSidebarStorageKey(), state.sidebarHidden ? '1' : '0');
    } catch {}
  }
}

function toggleBundleSidebar() {
  setBundleSidebarHidden(!state.sidebarHidden);
}

function setBundleContext(bundle = {}) {
  const supportFiles = bundle.supportFiles ? normalizeSupportFiles(bundle.supportFiles) : { docs: [], prompts: [], configs: [], vl: [] };
  state.bundle = {
    path: bundle.path || null,
    manifest: bundle.manifest || null,
    supportFiles,
    entries: Array.isArray(bundle.entries) ? bundle.entries : [],
    label: bundle.label || null,
  };
  if (!HOST_CONTROLLED) renderBundleSidebar();
}

function persistNodePosition(node, options = {}) {
  if (!node) return;
  node.x = Math.round(node.x);
  node.y = Math.round(node.y);
  node.manualPosition = options.manual !== undefined ? !!options.manual : !!node.manualPosition;
  if (node.data && typeof node.data === 'object') {
    node.data._x = node.x;
    node.data._y = node.y;
    node.data._manualPosition = node.manualPosition;
  }
  if (options.persist !== false) saveLayoutState();
}

function setLayoutDensity(rawValue, options = {}) {
  const numeric = typeof rawValue === 'number'
    ? rawValue
    : (Number.parseFloat(String(rawValue || '0')) / 100);
  state.layoutDensity = clamp(Number.isFinite(numeric) ? numeric : DEFAULT_LAYOUT_DENSITY, 0, 1);
  syncLayoutControls();
  if (options.relayout !== false && state.nodes.length > 0) {
    autoLayout({
      preserveExisting: options.preserveExisting === true,
      preserveManual: options.preserveManual !== false,
      persist: options.persist !== false
    });
    render();
  } else if (options.persist !== false) {
    saveLayoutState();
  }
}

function normalizeBranchTarget(target) {
  if (target && typeof target === 'object' && !Array.isArray(target)) {
    return target.next ?? target.step ?? target.then ?? target.target ?? null;
  }
  return target ?? null;
}

function isBranchCaseObject(cas) {
  return !!cas
    && typeof cas === 'object'
    && !Array.isArray(cas)
    && (
      Object.prototype.hasOwnProperty.call(cas, 'condition')
      || Object.prototype.hasOwnProperty.call(cas, 'when')
      || Object.prototype.hasOwnProperty.call(cas, 'next')
      || Object.prototype.hasOwnProperty.call(cas, 'then')
      || Object.prototype.hasOwnProperty.call(cas, 'step')
      || Object.prototype.hasOwnProperty.call(cas, 'target')
    );
}

function normalizeBranchCase(cas) {
  let expr, targetId;
  if (Array.isArray(cas)) {
    if (cas.length < 2) return null;
    [expr, targetId] = cas;
  } else if (isBranchCaseObject(cas)) {
    expr = cas.condition ?? cas.when;
    targetId = normalizeBranchTarget(cas.next ?? cas.step ?? cas.then ?? cas.target);
  } else {
    return null;
  }

  if (expr === 'default') expr = 'ELSE';
  if (!expr || !targetId) return null;
  return { expr, targetId };
}

function normalizeBranchCases(rawCases) {
  if (Array.isArray(rawCases)) {
    return rawCases.map(normalizeBranchCase).filter(Boolean);
  }
  if (rawCases && typeof rawCases === 'object') {
    if (isBranchCaseObject(rawCases)) {
      const normalized = normalizeBranchCase(rawCases);
      return normalized ? [normalized] : [];
    }
    return Object.entries(rawCases)
      .map(([expr, target]) => normalizeBranchCase([expr, normalizeBranchTarget(target)]))
      .filter(Boolean);
  }
  return [];
}

function getBranchCaseEntries(step) {
  const cases = [
    ...normalizeBranchCases(step.branches),
    ...normalizeBranchCases(step.cases)
  ];
  const defaultTarget = normalizeBranchTarget(step.default);
  if (defaultTarget) cases.push({ expr: 'ELSE', targetId: defaultTarget });
  return cases;
}

// ===== Parse workflow JSON into internal state =====
function parseWorkflow(json, options = {}) {
  _currentWorkflowJson = json;
  state.nodes = [];
  state.connections = [];
  // A new workflow shape gets a fresh view: scale 1 and auto-fit armed, so the
  // next fit (host `fitWorkflow`, or a resize) measures this graph, not the
  // previous one.
  state.view = { scale: 1, autoFit: true };
  state.bounds = null;
  state.registry = json.registry || {};
  setBundleContext({
    path: options.bundlePath || null,
    manifest: options.manifest || null,
    supportFiles: options.supportFiles || null,
    entries: options.entries || [],
    label: options.label || null,
  });
  const savedLayout = loadLayoutState(json);
  state.layoutDensity = savedLayout?.density ?? DEFAULT_LAYOUT_DENSITY;
  syncLayoutControls();

  if (!json?.steps?.length) {
    renderBundleSidebar();
    return;
  }

  const steps = json.steps;
  $('wfTitle').textContent = json.name || 'VL Workflow DAG';

  // Create nodes
  for (const step of steps) {
    const savedPos = savedLayout?.positions?.[step.id] || null;
    const type = getStepType(step);
    state.nodes.push({
      id: step.id,
      type,
      x: Number.isFinite(savedPos?.x) ? savedPos.x : (step._x || 0),
      y: Number.isFinite(savedPos?.y) ? savedPos.y : (step._y || 0),
      manualPosition: savedPos?.manual ?? !!step._manualPosition,
      data: step,
      status: null
    });
  }

  // Create connections.
  //
  // A runtime projection supplies `json.edges` — the sanitized graph's real
  // edge list, with its own ids and kinds. When it is present it is the whole
  // truth about dependencies and is used verbatim: no de-duplication, no kind
  // collapsing, no synthesized case labels. Authored workflow JSON has no
  // `edges` key and keeps the original next/children/cases derivation.
  const explicitEdges = Array.isArray(json.edges) ? json.edges : null;
  let connId = 0;
  if (explicitEdges) {
    for (const edge of explicitEdges) {
      const from = String(edge?.from ?? '');
      const to = String(edge?.to ?? '');
      if (!from || !to) continue;
      const kindLabel = String(edge?.kind || 'serial');
      const type = EDGE_KIND_CLASS[kindLabel] || 'other';
      state.connections.push({
        id: String(edge?.id ?? ('c' + (connId++))),
        from,
        to,
        type,
        kindLabel,
        // Only kinds whose meaning is not already carried by the line style
        // get a drawn label; nothing here is invented.
        label: (type === 'serial' || type === 'parallel') ? null : kindLabel,
      });
    }
  } else {
    for (const step of steps) {
      // FIX #1: exclude both RETURN and BREAK (reserved keywords, not real nodes)
      if (step.next && !RESERVED_NEXT.has(step.next)) {
        state.connections.push({ id: 'c' + (connId++), from: step.id, to: step.next, type: 'serial', kindLabel: 'serial' });
      }
      if (step.children?.length) {
        for (const childId of step.children) {
          state.connections.push({ id: 'c' + (connId++), from: step.id, to: childId, type: 'parallel', kindLabel: 'parallel' });
        }
      }
      // Branch cases: [[expr, target]], {expr: target}, {condition,next}, {when,then}, plus default.
      for (const { expr, targetId } of getBranchCaseEntries(step)) {
        state.connections.push({ id: 'c' + (connId++), from: step.id, to: targetId, type: 'branch-case', kindLabel: 'branch', label: expr });
      }
    }
  }

  const positionedCount = state.nodes.filter(hasNodePosition).length;
  if (positionedCount === 0) autoLayout({ persist: false });
  else if (positionedCount < state.nodes.length) autoLayout({ preserveExisting: true, persist: false });

  // Restore checkpoint state (node statuses) if available
  if (!HOST_CONTROLLED) restoreFromStorage();

  render();
}

function getStepType(step) {
  if (step.type) return TYPE_ALIASES[step.type] || step.type;
  const id = step.id || '';
  const prefix = id.split('_')[0];
  if (TYPE_ALIASES[prefix]) return TYPE_ALIASES[prefix];
  return KNOWN_TYPES.includes(prefix) ? prefix : 'LLM';
}

// ===== Auto Layout (topological, with barycenter crossing reduction) =====
function autoLayout(options = {}) {
  const preserveExisting = options.preserveExisting === true;
  const preserveManual = options.preserveManual === true;
  const density = clamp(Number.isFinite(state.layoutDensity) ? state.layoutDensity : DEFAULT_LAYOUT_DENSITY, 0, 1);
  // Density now scales the gap *between* measured cards. The old NODE_GAP was
  // the whole row pitch, which is why a tall expanded card overlapped its
  // neighbour: the pitch never grew with the card.
  const LAYER_GAP = Math.round(lerp(120, 300, density));
  const NODE_GAP = Math.round(lerp(28, 120, density));
  const START_X = Math.round(lerp(60, 120, density));
  const START_Y = Math.round(lerp(48, 92, density));
  const nodeMap = new Map(state.nodes.map(n => [n.id, n]));

  const succs = new Map(), preds = new Map();
  for (const n of state.nodes) { succs.set(n.id, []); preds.set(n.id, []); }
  for (const c of state.connections) {
    if (succs.has(c.from) && preds.has(c.to)) {
      succs.get(c.from).push(c.to);
      preds.get(c.to).push(c.from);
    }
  }

  // Find roots
  const roots = state.nodes.filter(n => (preds.get(n.id)?.length || 0) === 0).map(n => n.id);
  if (roots.length === 0 && state.nodes.length > 0) roots.push(state.nodes[0].id);

  // Longest-path layer assignment (with cycle protection)
  const layers = new Map();
  const inStack = new Set();
  function assignLayer(id, depth) {
    if (inStack.has(id)) return;
    inStack.add(id);
    layers.set(id, Math.max(layers.get(id) || 0, depth));
    for (const s of (succs.get(id) || [])) assignLayer(s, depth + 1);
    inStack.delete(id);
  }
  for (const r of roots) assignLayer(r, 0);
  for (const n of state.nodes) { if (!layers.has(n.id)) layers.set(n.id, 0); }

  // Group by layer
  const layerGroups = new Map();
  for (const [id, layer] of layers) {
    if (!layerGroups.has(layer)) layerGroups.set(layer, []);
    layerGroups.get(layer).push(id);
  }

  // Barycenter ordering (reduce edge crossings)
  const sortedLayers = [...layerGroups.keys()].sort((a, b) => a - b);
  for (let pass = 0; pass < 4; pass++) {
    for (let li = 1; li < sortedLayers.length; li++) {
      const group = layerGroups.get(sortedLayers[li]);
      const prevGroup = layerGroups.get(sortedLayers[li - 1]);
      const prevIdx = new Map(prevGroup.map((id, i) => [id, i]));
      group.sort((a, b) => {
        const aParents = preds.get(a)?.filter(p => prevIdx.has(p)) || [];
        const bParents = preds.get(b)?.filter(p => prevIdx.has(p)) || [];
        const aAvg = aParents.length ? aParents.reduce((s, p) => s + prevIdx.get(p), 0) / aParents.length : 0;
        const bAvg = bParents.length ? bParents.reduce((s, p) => s + prevIdx.get(p), 0) / bParents.length : 0;
        return aAvg - bAvg;
      });
    }
  }

  // Position nodes from *measured* boxes (WFGeometry.packColumns). Column x
  // advances by the widest measured card in the column; row y advances by each
  // card's own measured height, so tall/expanded cards can no longer overlap.
  const viewportMidY = Math.max(START_Y + 140, (($('canvasWrap')?.clientHeight || 800) / 2));
  const items = [];
  for (const layer of sortedLayers) {
    const group = layerGroups.get(layer);
    for (const id of group) {
      const node = nodeMap.get(id);
      if (!node) continue;
      const pinned = (
        (preserveExisting && hasNodePosition(node)) ||
        (preserveManual && node.manualPosition && hasNodePosition(node))
      );
      items.push({
        id: node.id, layer, pinned,
        x: node.x, y: node.y,
        w: measuredWidth(node), h: measuredHeight(node),
      });
    }
  }

  const packed = WFGeometry.packColumns(items, {
    startX: START_X, startY: START_Y, layerGap: LAYER_GAP, nodeGap: NODE_GAP, midY: viewportMidY,
  });

  for (const item of items) {
    const node = nodeMap.get(item.id);
    const position = packed.positions.get(item.id);
    if (!node || !position) continue;
    node.x = position.x;
    node.y = position.y;
    if (!item.pinned) node.manualPosition = false;
    // Remember which measured height this placement was computed from, so a
    // later content change (expansion) can trigger exactly one re-pack.
    node.packedHeight = item.h;
  }

  for (const node of state.nodes) {
    persistNodePosition(node, {
      manual: node.manualPosition,
      persist: false
    });
  }
  if (options.persist !== false) saveLayoutState();
}

// ===== Measured node geometry =====
// The single source of truth for card size is the rendered DOM box. Nothing in
// the layout, edge routing or fit path assumes 240x140 any more.
function measuredWidth(node) {
  return Number.isFinite(node?.w) && node.w > 0 ? node.w : WFGeometry.FALLBACK_W;
}

function measuredHeight(node) {
  return Number.isFinite(node?.h) && node.h > 0 ? node.h : WFGeometry.FALLBACK_H;
}

function nodeBox(node) {
  return { id: node.id, x: node.x, y: node.y, w: measuredWidth(node), h: measuredHeight(node) };
}

function nodeBoxes() {
  return state.nodes.map(nodeBox);
}

/** Read every card's real rendered box. Returns true when a height changed. */
function measureNodes() {
  let changed = false;
  for (const node of state.nodes) {
    const el = $(`node-${node.id}`);
    if (!el) continue;
    const w = el.offsetWidth;
    const h = el.offsetHeight;
    if (!w || !h) continue;
    if (node.w !== w || node.h !== h) changed = true;
    node.w = w;
    node.h = h;
  }
  return changed;
}

/** True when a card grew/shrank since the placement that positioned it. */
function layoutNeedsRepack() {
  return state.nodes.some(node => (
    !node.manualPosition
    && Number.isFinite(node.h)
    && Math.abs(node.h - (Number.isFinite(node.packedHeight) ? node.packedHeight : 0)) > 0.5
  ));
}

/** Move already-rendered cards without rebuilding their DOM. */
function applyNodePositions() {
  for (const node of state.nodes) {
    const el = $(`node-${node.id}`);
    if (!el) continue;
    el.style.left = node.x + 'px';
    el.style.top = node.y + 'px';
  }
}

// ===== Compute I/O audit for a node =====
function computeIO(step) {
  const io = { varsIn: [], varsOut: [], docs: [], files: [] };
  if (!step) return io;
  const data = step.in || step;

  // Input vars (references starting with = or $)
  const jsonStr = JSON.stringify(data);
  const varRefs = jsonStr.match(/=\$?[\w.]+|"\$[\w.]+"/g) || [];
  for (const ref of varRefs) {
    const clean = ref.replace(/[="]/g, '');
    if (clean.startsWith('$') && !io.varsIn.includes(clean)) io.varsIn.push(clean);
  }

  // Docs
  if (step.in?.docs) io.docs = step.in.docs;

  // Output vars
  if (step.out) {
    for (const [key, val] of Object.entries(step.out)) {
      if (key.startsWith('$')) io.varsOut.push(key);
      else if (key.startsWith('/')) io.files.push(key);
    }
  }

  // Source (loop / download / unzip)
  if (step.source) {
    const src = String(step.source);
    if (src.startsWith('=$') || src.startsWith('$')) io.varsIn.push(src.replace(/^=/, ''));
  }

  // FIX #4: while expression can reference variables
  if (step.while) {
    const whileStr = String(step.while);
    const whileRefs = whileStr.match(/\$[\w.]+/g) || [];
    for (const ref of whileRefs) {
      if (!io.varsIn.includes(ref)) io.varsIn.push(ref);
    }
  }

  return io;
}

// ===== Type-Specific Body Renderers =====

function renderLLMBody(data) {
  let html = '';
  const docs = data.in?.docs?.length ? data.in.docs : null;
  if (docs) {
    html += `<div class="node-section"><div class="node-section-title">Documents</div>
      <div class="node-docs-list">${docs.map(d =>
        `<span class="node-doc-tag" title="${esc(resolveDocName(d))}">${esc(d)}: ${trunc(resolveDocName(d), 16)}</span>`
      ).join('')}</div></div>`;
  }
  if (data.in?.model || data.model) {
    html += `<div class="node-field"><span class="node-label">model</span><span class="node-value">${trunc(data.in?.model || data.model, 20)}</span></div>`;
  }
  if (data.in?.max_tokens) {
    html += `<div class="node-field"><span class="node-label">tokens</span><span class="node-value">${data.in.max_tokens}</span></div>`;
  }
  if (data.in?.messages?.length) {
    html += `<div class="node-field"><span class="node-label">msgs</span><span class="node-value">${data.in.messages.length} messages</span></div>`;
  }
  html += renderToolingFields(data);
  html += renderOutSection(data.out);
  return html;
}

function renderServiceBody(data) {
  let html = '';
  if (data.serviceId) {
    html += `<div class="node-field"><span class="node-label">service</span><span class="node-value ref">${trunc(data.serviceId, 20)}</span></div>`;
  }
  html += renderInputSection(data.in);
  html += renderOutSection(data.out);
  return html;
}

function renderAPIBody(data) {
  let html = '';
  const apiId = data.apiId || '';
  const apis = state.registry?.apis || [];
  const apiDef = apis.find(a => a.id === apiId || a === apiId);
  if (apiDef && typeof apiDef === 'object') {
    html += `<div class="node-field"><span class="node-label">${esc(apiDef.method || 'POST')}</span><span class="node-value ref" title="${esc(apiDef.url || '')}">${trunc(apiDef.desc || apiDef.url || apiId, 20)}</span></div>`;
  } else {
    html += `<div class="node-field"><span class="node-label">api</span><span class="node-value ref">${trunc(apiId || data.id || '', 20)}</span></div>`;
  }
  html += renderInputSection(data.in);
  html += renderOutSection(data.out);
  return html;
}

function renderComponentBody(data) {
  let html = '';
  if (data.componentId) {
    html += `<div class="node-field"><span class="node-label">comp</span><span class="node-value ref">${trunc(data.componentId, 20)}</span></div>`;
  }
  html += renderInputSection(data.in);
  html += renderOutSection(data.out);
  return html;
}

function renderActorBody(data) {
  let html = '';
  const actorId = data.actorId || data.role || '';
  const taskGoal = data.task?.goal || data.goal || '';
  const allowedTools = Array.isArray(data.domain?.tools?.allow) ? data.domain.tools.allow.length : 0;
  if (actorId) {
    html += `<div class="node-field"><span class="node-label">actor</span><span class="node-value ref">${trunc(actorId, 20)}</span></div>`;
  }
  if (taskGoal) {
    html += `<div class="node-field"><span class="node-label">goal</span><span class="node-value">${trunc(taskGoal, 28)}</span></div>`;
  }
  if (data.workflow || data.workflowRef || data.subflow) {
    html += `<div class="node-field"><span class="node-label">flow</span><span class="node-value ref">${trunc(data.workflow || data.workflowRef || data.subflow, 20)}</span></div>`;
  }
  if (allowedTools > 0) {
    html += `<div class="node-field"><span class="node-label">tools</span><span class="node-value">${allowedTools} allowed</span></div>`;
  }
  html += renderToolingFields(data);
  html += renderInputSection(data.in || data.input);
  html += renderOutSection(data.out);
  return html;
}

function renderSubflowBody(data) {
  let html = '';
  const workflowRef = data.workflowRef || data.workflowPath || data.subflow || data.workflow?.name || data.workflow || '';
  const alias = data.alias || data.childRunAlias || '';
  if (workflowRef) {
    html += `<div class="node-field"><span class="node-label">flow</span><span class="node-value ref">${trunc(typeof workflowRef === 'string' ? workflowRef : JSON.stringify(workflowRef), 22)}</span></div>`;
  }
  if (alias) {
    html += `<div class="node-field"><span class="node-label">alias</span><span class="node-value">${trunc(alias, 20)}</span></div>`;
  }
  html += `<div class="node-field"><span class="node-label">share</span><span class="node-value">${data.shareArtifacts === false ? 'artifacts off' : 'artifacts on'}</span></div>`;
  if (data.importResultEnvelopes === true) {
    html += `<div class="node-field"><span class="node-label">result</span><span class="node-value">import envelopes</span></div>`;
  }
  html += renderToolingFields(data);
  html += renderInputSection(data.in || data.input);
  html += renderOutSection(data.out);
  return html;
}

function renderReviewBody(data) {
  let html = '';
  const gate = data.gate || data.acceptance || {};
  const gateType = gate.type || data.reviewType || 'human';
  const allowedDecisions = Array.isArray(gate.allowedDecisions) ? gate.allowedDecisions : [];
  const timeoutSec = data.timeout?.sec || data.timeout?.seconds || 0;

  html += `<div class="node-field"><span class="node-label">gate</span><span class="node-value">${esc(gateType)}</span></div>`;
  if (data.source || data.envelope) {
    html += `<div class="node-field"><span class="node-label">source</span><span class="node-value var">${trunc(String(data.source || data.envelope), 20)}</span></div>`;
  }
  if (allowedDecisions.length > 0) {
    html += `<div class="node-field"><span class="node-label">allow</span><span class="node-value">${trunc(allowedDecisions.join(', '), 20)}</span></div>`;
  }
  if (timeoutSec > 0) {
    html += `<div class="node-field"><span class="node-label">timeout</span><span class="node-value">${timeoutSec}s</span></div>`;
  }
  if (data.decisionTarget || data.statusTarget || data.envelopeTarget) {
    const targets = [data.decisionTarget, data.statusTarget, data.envelopeTarget].filter(Boolean).join(', ');
    html += `<div class="node-field"><span class="node-label">targets</span><span class="node-value">${trunc(targets, 24)}</span></div>`;
  }
  if (gateType === 'verifier' && data.review) {
    const reviewKeys = typeof data.review === 'object' ? Object.keys(data.review) : [];
    if (reviewKeys.length > 0) {
      html += `<div class="node-field"><span class="node-label">review</span><span class="node-value">${trunc(reviewKeys.join(', '), 24)}</span></div>`;
    }
  }
  html += renderToolingFields(data);
  html += renderOutSection(data.out);
  return html;
}

function renderGraphPatchBody(data) {
  let html = '';
  const patch = data.patch || data.graphPatch || data.operations || {};
  const operations = Array.isArray(patch)
    ? patch
    : Array.isArray(patch.operations)
      ? patch.operations
      : [
          ...((Array.isArray(patch.upsertSteps) ? patch.upsertSteps : []).map(step => ({ op: 'upsertStep', step }))),
          ...((Array.isArray(patch.removeSteps) ? patch.removeSteps : []).map(stepId => ({ op: 'removeStep', stepId }))),
          ...((Array.isArray(patch.setNext) ? patch.setNext : []).map(item => ({ op: 'setNext', ...item }))),
          ...((Array.isArray(patch.setFields) ? patch.setFields : []).map(item => ({ op: 'setField', ...item }))),
          ...((Array.isArray(patch.appendChildren) ? patch.appendChildren : []).map(item => ({ op: 'appendChildren', ...item }))),
          ...((Array.isArray(patch.replaceChildren) ? patch.replaceChildren : []).map(item => ({ op: 'replaceChildren', ...item })))
        ];
  const opCounts = {};
  for (const operation of operations) {
    const op = operation?.op || 'unknown';
    opCounts[op] = (opCounts[op] || 0) + 1;
  }

  html += `<div class="node-field"><span class="node-label">ops</span><span class="node-value">${operations.length}</span></div>`;
  Object.entries(opCounts).slice(0, 3).forEach(([op, count]) => {
    html += `<div class="node-field"><span class="node-label">${esc(op)}</span><span class="node-value">${count}</span></div>`;
  });
  if (data.resultEnvelope) {
    html += `<div class="node-field"><span class="node-label">env</span><span class="node-value">stores envelope</span></div>`;
  }
  html += renderOutSection(data.out);
  return html;
}

function renderSwarmBody(data) {
  let html = '';
  const problem = data.problem || {};
  const actors = Array.isArray(data.actors) ? data.actors : [];
  const initialAssignments = Array.isArray(data.scheduler?.strategy?.initial) ? data.scheduler.strategy.initial.length : 0;
  const schedulerRules = Array.isArray(data.scheduler?.strategy?.rules) ? data.scheduler.strategy.rules.length : 0;
  const maxRounds = data.scheduler?.maxRounds ?? data.rounds?.max ?? data.exit?.maxRounds?.count ?? '';
  const parallelism = data.scheduler?.concurrency ?? data.rounds?.parallelism ?? data.scheduler?.parallelism ?? '';
  const scope = data.knowledgeRoot?.scope || data.knowledgeTree?.scope || 'run';
  const reviewActors = actors.filter(actor => actor && actor.review).length;
  const exitExpr = data.exit?.when || data.exit?.condition || '';

  if (problem.title || problem.description) {
    html += `<div class="node-field"><span class="node-label">problem</span><span class="node-value">${trunc(problem.title || problem.description, 26)}</span></div>`;
  }
  html += `<div class="node-field"><span class="node-label">actors</span><span class="node-value">${actors.length}${reviewActors ? ` / ${reviewActors} review` : ''}</span></div>`;
  html += `<div class="node-field"><span class="node-label">plan</span><span class="node-value">${initialAssignments} initial, ${schedulerRules} rules</span></div>`;
  if (maxRounds !== '') {
    html += `<div class="node-field"><span class="node-label">rounds</span><span class="node-value">${maxRounds}${parallelism !== '' ? ` @ ${parallelism}` : ''}</span></div>`;
  }
  html += `<div class="node-field"><span class="node-label">share</span><span class="node-value">${data.contextPolicy?.sharePeerDrafts === true ? 'peer drafts on' : 'core facts only'}</span></div>`;
  html += `<div class="node-field"><span class="node-label">scope</span><span class="node-value">${esc(scope)}</span></div>`;
  if (exitExpr) {
    html += `<div class="node-field"><span class="node-label">exit</span><span class="node-value">${trunc(exitExpr, 26)}</span></div>`;
  }
  html += renderToolingFields(data);
  html += renderOutSection(data.out);
  return html;
}

function renderSetBody(data) {
  const hasTarget = data.target !== undefined && data.target !== null && String(data.target) !== '';
  const hasValue = data.value !== undefined && data.value !== null && String(data.value) !== '';
  // A Set step that declares neither target nor value is genuinely empty. The
  // old body printed a placeholder `$var <- ` assignment that no step ever
  // declared, which read as a real (and wrong) assignment.
  if (!hasTarget && !hasValue) {
    return `<div class="node-section"><div class="node-section-title">Assignment</div>
      <div class="node-empty"><strong>No assignment declared.</strong> This Set step carries no target and no value.</div></div>`;
  }
  const valueText = hasValue ? trunc(String(data.value), 18) : '\u2014 (no value declared)';
  const targetText = hasTarget ? esc(String(data.target)) : '\u2014 (no target declared)';
  return `<div class="node-section"><div class="node-section-title">Assignment</div>
    <div class="node-io-item"><span class="node-io-key is-var">${targetText}</span>
    <span class="node-io-arrow">\u2190</span>
    <span class="node-io-value">${valueText}</span></div></div>`;
}

/**
 * Structural StepTypes (Noop / Fork / Topology / Channel) run no step work by
 * design \u2014 lib/engine.js executes 'Noop' as a plain `break`. Saying so is the
 * truth; leaving the card blank looked like a rendering failure.
 */
function renderStructuralBody(type, data) {
  const notes = {
    Noop: 'Structural step \u2014 no executable work; it only carries graph edges.',
    Fork: 'Fan-out point \u2014 no executable work; it starts its parallel children.',
    Topology: 'Topology step \u2014 reshapes the graph rather than producing step output.',
    Channel: 'Channel step \u2014 carries messages between steps rather than producing step output.',
  };
  let html = `<div class="node-section"><div class="node-section-title">${esc(type)}</div>
    <div class="node-empty">${esc(notes[type] || 'Structural step \u2014 no executable work.')}</div></div>`;
  html += renderInputSection(data.in);
  html += renderOutSection(data.out);
  return html;
}

/** A step projected from a sanitized runtime graph rather than authored JSON. */
function isRuntimeProjection(data) {
  return data?.meta?.projection === 'runtime';
}

/**
 * Runtime-projection body.
 *
 * A sanitized runtime graph carries id/title/type/status per node and no step
 * settings at all. The card therefore says, in plain words, what it can and
 * cannot show, rather than looking empty or inventing work the node never
 * declared. The status is the reported one and the degrees below are the real
 * edges of this graph; nothing here is inferred.
 */
function renderRuntimeProjectionBody(node) {
  const data = node.data || {};
  const type = node.type || 'LLM';
  const status = data.meta?.status || 'pending';
  const incoming = state.connections.filter(conn => conn.to === node.id);
  const outgoing = state.connections.filter(conn => conn.from === node.id);
  const kindsOf = (list) => {
    const counts = new Map();
    list.forEach((conn) => counts.set(conn.kindLabel, (counts.get(conn.kindLabel) || 0) + 1));
    return Array.from(counts.entries()).map(([kind, count]) => `${count}\u00d7${kind}`).join(', ');
  };

  let html = '<div class="node-section"><div class="node-section-title">Runtime</div>';
  html += `<div class="node-field"><span class="node-label">status</span><span class="node-value">${esc(status)}</span></div>`;
  // Composition facet (runtime-composition companion): only the facts the
  // host declared \u2014 role, container membership, attempt and the exact
  // human-interaction identity. No prompt text or file content is carried.
  const facet = data.meta?.facet && typeof data.meta.facet === 'object' ? data.meta.facet : null;
  if (facet?.role) html += `<div class="node-field"><span class="node-label">role</span><span class="node-value">${esc(facet.role)}</span></div>`;
  if (facet?.actor) html += `<div class="node-field"><span class="node-label">actor</span><span class="node-value">${esc(facet.actor)}</span></div>`;
  if (Number.isFinite(facet?.attempt)) html += `<div class="node-field"><span class="node-label">attempt</span><span class="node-value">${esc(String(facet.attempt))}</span></div>`;
  html += `<div class="node-degree"><span title="${esc(kindsOf(incoming) || 'none')}">\u2193 in ${incoming.length}</span>`
    + `<span title="${esc(kindsOf(outgoing) || 'none')}">\u2191 out ${outgoing.length}</span></div>`;
  html += '</div>';
  if (facet?.container) {
    const c = facet.container;
    const round = c.round && Number.isFinite(c.round.current)
      ? ` \u00b7 round ${c.round.current}${Number.isFinite(c.round.max) ? ` / ${c.round.max}` : ''}${c.round.outcome ? ` \u00b7 ${esc(c.round.outcome)}` : ''}`
      : '';
    html += `<div class="node-facet-container">${esc(c.kind)} \u00b7 ${c.memberCount} member${c.memberCount === 1 ? '' : 's'} \u00b7 ${c.expanded ? 'expanded' : 'collapsed'}${round}</div>`;
  }
  if (facet?.interaction) {
    html += `<div class="node-facet-interaction">interaction ${esc(facet.interaction.interactionId)} \u00b7 ${esc(facet.interaction.status)}`
      + `${Number.isFinite(facet.interaction.attempt) ? ` \u00b7 attempt ${esc(String(facet.interaction.attempt))}` : ''}</div>`;
  }

  const structuralNote = {
    Noop: '<strong>Noop: does no work.</strong> It only joins the steps around it.',
    Fork: '<strong>Fork: does no work itself.</strong> It starts its parallel branches.',
    Topology: '<strong>Topology: does no work itself.</strong> It reshapes how the steps connect.',
    Channel: '<strong>Channel: does no work itself.</strong> It carries messages between steps.',
  };
  const note = STRUCTURAL_TYPES.has(type)
    ? (structuralNote[type] || `<strong>${esc(type)}: does no work itself.</strong> It only shapes how the steps connect.`)
    : `<strong>Live status and connections only.</strong> This ${esc(type)} step's settings are not part of this live view.`;
  html += `<div class="node-section"><div class="node-empty">${note}</div></div>`;
  return html;
}

function renderWriteBody(data) {
  const modeStr = data.mode ? ` [${esc(data.mode)}]` : '';
  return `<div class="node-section"><div class="node-section-title">Write Artifact${modeStr}</div>
    <div class="node-field"><span class="node-label">target</span><span class="node-value file">${trunc(data.target || '', 20)}</span></div>
    <div class="node-field"><span class="node-label">value</span><span class="node-value var">${trunc(String(data.value || ''), 18)}</span></div></div>`;
}

function renderBranchBody(data) {
  const cases = getBranchCaseEntries(data);
  if (cases.length === 0) return '';
  let html = '<div class="node-section"><div class="node-section-title">Cases</div>';
  cases.slice(0, 4).forEach(({ expr, targetId }) => {
    html += `<div class="node-io-item"><span class="node-io-key">${trunc(expr, 14)}</span>
      <span class="node-io-arrow">\u2192</span>
      <span class="node-io-value">${trunc(targetId || '?', 12)}</span></div>`;
  });
  if (cases.length > 4) html += `<div class="node-io-item" style="color:var(--text2)">... +${cases.length - 4} more</div>`;
  html += '</div>';
  return html;
}

// FIX #2: Loop body now handles both source mode and while mode (Spec 3.16)
function renderLoopBody(data) {
  const isWhile = !!data.while;
  let html = '<div class="node-section"><div class="node-section-title">Loop Config</div>';

  if (isWhile) {
    // While mode (3.16)
    html += `<div class="node-field"><span class="node-label">while</span><span class="node-value var">${trunc(data.while, 20)}</span></div>`;
    if (data.maxIterations != null) {
      html += `<div class="node-field"><span class="node-label">max</span><span class="node-value">${data.maxIterations}</span></div>`;
    }
  } else {
    // Source mode (original)
    html += `<div class="node-field"><span class="node-label">source</span><span class="node-value var">${trunc(data.source || '', 16)}</span></div>`;
    if (data.maxIterations != null) {
      html += `<div class="node-field"><span class="node-label">max</span><span class="node-value">${data.maxIterations}</span></div>`;
    }
  }

  html += `<div class="node-field"><span class="node-label">mode</span><span class="node-value">${esc(data.mode || 'parallel')}</span></div>`;
  html += '</div>';
  return html;
}

function renderStopBody() {
  return '<div class="node-section"><div class="node-field"><span class="node-value" style="color:var(--red);">Workflow terminates here</span></div></div>';
}

function renderPauseBody(data) {
  const msg = data.in?.message || data.message || data.reason || '';
  const displayKeys = data.in?.display ? Object.keys(data.in.display) : [];
  let html = '<div class="node-section"><div class="node-section-title">\u23F8 Pause \u2014 Waiting for Approval</div>';
  if (msg) html += `<div class="node-field"><span class="node-label">msg</span><span class="node-value">${trunc(msg, 24)}</span></div>`;
  if (displayKeys.length > 0) html += `<div class="node-field"><span class="node-label">show</span><span class="node-value">${trunc(displayKeys.join(', '), 24)}</span></div>`;
  html += '</div>';
  html += renderOutSection(data.out);
  return html;
}

function renderForkBody(data) {
  const children = data.children || [];
  return `<div class="node-section"><div class="node-section-title">Fork Config</div>
    ${data.source ? `<div class="node-field"><span class="node-label">source</span><span class="node-value var">${trunc(data.source, 16)}</span></div>` : ''}
    <div class="node-field"><span class="node-label">branches</span><span class="node-value">${children.length} parallel</span></div></div>`;
}

// FIX #3: New renderers for Download and Unzip (Spec 3.16)
function renderDownloadBody(data) {
  let html = '<div class="node-section"><div class="node-section-title">Download</div>';
  const src = data.source || '';
  const srcStr = typeof src === 'object' ? (src.url || JSON.stringify(src)) : String(src);
  html += `<div class="node-field"><span class="node-label">source</span><span class="node-value ref">${trunc(srcStr, 20)}</span></div>`;
  if (data.target) {
    html += `<div class="node-field"><span class="node-label">target</span><span class="node-value file">${trunc(data.target, 20)}</span></div>`;
  }
  if (data.routeByExt) {
    const extCount = typeof data.routeByExt === 'object' ? Object.keys(data.routeByExt).length : '?';
    html += `<div class="node-field"><span class="node-label">route</span><span class="node-value">${extCount} ext rules</span></div>`;
  }
  if (data.defaultDir) {
    html += `<div class="node-field"><span class="node-label">default</span><span class="node-value file">${trunc(data.defaultDir, 18)}</span></div>`;
  }
  html += '</div>';
  html += renderOutSection(data.out);
  return html;
}

function renderUnzipBody(data) {
  let html = '<div class="node-section"><div class="node-section-title">Unzip</div>';
  if (data.source) {
    html += `<div class="node-field"><span class="node-label">source</span><span class="node-value file">${trunc(String(data.source), 20)}</span></div>`;
  }
  if (data.routeByExt) {
    const extCount = typeof data.routeByExt === 'object' ? Object.keys(data.routeByExt).length : '?';
    html += `<div class="node-field"><span class="node-label">route</span><span class="node-value">${extCount} ext rules</span></div>`;
  }
  if (data.defaultDir) {
    html += `<div class="node-field"><span class="node-label">default</span><span class="node-value file">${trunc(data.defaultDir, 18)}</span></div>`;
  }
  if (data.overwrite != null) {
    html += `<div class="node-field"><span class="node-label">overwrite</span><span class="node-value">${data.overwrite}</span></div>`;
  }
  html += '</div>';
  html += renderOutSection(data.out);
  return html;
}

// --- Shared sub-renderers ---

function renderInputSection(inData) {
  if (!inData || typeof inData !== 'object') return '';
  const skip = new Set(['model', 'stream', 'messages', 'docs', 'max_tokens', 'output_config']);
  const entries = Object.entries(inData).filter(([k]) => !skip.has(k));
  if (entries.length === 0) return '';
  let html = '<div class="node-section"><div class="node-section-title">Input</div><div class="node-io-list">';
  entries.slice(0, 4).forEach(([key, val]) => {
    const valStr = typeof val === 'object' ? JSON.stringify(val) : String(val);
    html += `<div class="node-io-item"><span class="node-io-key">${trunc(key, 10)}</span>
      <span class="node-io-arrow">\u2190</span>
      <span class="node-io-value">${trunc(valStr, 14)}</span></div>`;
  });
  if (entries.length > 4) html += `<div class="node-io-item" style="color:var(--text2)">... +${entries.length - 4} more</div>`;
  html += '</div></div>';
  return html;
}

function renderOutSection(out) {
  if (!out || typeof out !== 'object' || Array.isArray(out) || Object.keys(out).length === 0) return '';
  let html = '<div class="node-section"><div class="node-section-title">Output</div><div class="node-io-list">';
  Object.entries(out).slice(0, 4).forEach(([key, val]) => {
    const isVar = key.startsWith('$');
    const isFile = key.startsWith('/') || key.startsWith('{');
    const keyClass = isVar ? 'is-var' : isFile ? 'is-file' : '';
    html += `<div class="node-io-item"><span class="node-io-key ${keyClass}">${trunc(key, 16)}</span>
      <span class="node-io-arrow">\u2190</span>
      <span class="node-io-value">${trunc(String(val), 14)}</span></div>`;
  });
  if (Object.keys(out).length > 4) html += `<div class="node-io-item" style="color:var(--text2)">... +${Object.keys(out).length - 4} more</div>`;
  html += '</div></div>';
  return html;
}

function renderToolingFields(data) {
  if (data.tools === undefined && data.toolScope === undefined) return '';
  const tools = data.tools;
  const toolCount = Array.isArray(tools)
    ? tools.length
    : tools && typeof tools === 'object'
      ? Array.isArray(tools.allow)
        ? tools.allow.length
        : Object.keys(tools).length
      : tools ? 1 : 0;
  const scope = typeof data.toolScope === 'string'
    ? data.toolScope
    : data.toolScope?.mode || null;
  let html = '<div class="node-section"><div class="node-section-title">Tooling</div>';
  if (toolCount > 0) {
    html += `<div class="node-field"><span class="node-label">tools</span><span class="node-value">${toolCount}</span></div>`;
  }
  if (scope) {
    html += `<div class="node-field"><span class="node-label">scope</span><span class="node-value">${esc(scope)}</span></div>`;
  }
  html += '</div>';
  return html;
}

// ===== Render =====
/**
 * One coherent pass: draw cards → measure their real boxes → re-pack the
 * automatic layout if a card changed height → size the scroll stage from the
 * measured content → draw edges between the measured ports.
 */
function render(options = {}) {
  $('emptyMsg').style.display = 'none';
  $('canvas').style.display = 'block';
  $('legend').style.display = 'flex';
  $('viewHud').style.display = state.nodes.length ? 'flex' : 'none';
  renderNodes();
  measureNodes();
  // At most one re-pack per render: re-packing moves cards, it never resizes
  // them, so this cannot loop.
  if (options.repack !== false && layoutNeedsRepack()) {
    autoLayout({ preserveManual: true, persist: false });
    applyNodePositions();
    measureNodes();
  }
  const routes = computeConnectionRoutes();
  applyStageMetrics({ fit: options.fit === true, routes });
  renderConnections(routes);
}

function renderNodes() {
  const layer = $('nodesLayer');
  layer.innerHTML = '';

  for (const node of state.nodes) {
    const div = document.createElement('div');
    const type = node.type || 'LLM';
    div.className = `node type-${type}`
      + (node.id === state.selectedNodeId ? ' selected' : '')
      + (node.status ? ` status-${node.status}` : '')
      + (_runtimeBreakpoints.has(node.id) ? ' runtime-breakpoint' : '');
    div.id = `node-${node.id}`;
    div.style.left = node.x + 'px';
    div.style.top = node.y + 'px';

    const icon = NODE_ICONS[type] || '?';
    const title = node.data?.meta?.title || node.id;
    const desc = node.data?.meta?.description || '';
    const data = node.data || {};

    // Body: type-specific rendering.
    // A runtime projection has no step payload at all, so the type renderers
    // would have nothing real to show; it gets its own truthful body instead.
    let bodyHtml = '';
    if (isRuntimeProjection(data)) {
      bodyHtml = renderRuntimeProjectionBody(node);
    } else if (STRUCTURAL_TYPES.has(type) && type !== 'Fork') {
      bodyHtml = renderStructuralBody(type, data);
    } else switch (type) {
      case 'LLM':       bodyHtml = renderLLMBody(data); break;
      case 'Service':   bodyHtml = renderServiceBody(data); break;
      case 'API':       bodyHtml = renderAPIBody(data); break;
      case 'Component': bodyHtml = renderComponentBody(data); break;
      case 'Actor':     bodyHtml = renderActorBody(data); break;
      case 'Subflow':   bodyHtml = renderSubflowBody(data); break;
      case 'Review':    bodyHtml = renderReviewBody(data); break;
      case 'GraphPatch': bodyHtml = renderGraphPatchBody(data); break;
      case 'Swarm':     bodyHtml = renderSwarmBody(data); break;
      case 'Set':       bodyHtml = renderSetBody(data); break;
      case 'Write':     bodyHtml = renderWriteBody(data); break;
      case 'Branch':    bodyHtml = renderBranchBody(data); break;
      case 'Loop':      bodyHtml = renderLoopBody(data); break;
      case 'Stop':      bodyHtml = renderStopBody(); break;
      case 'Pause':     bodyHtml = renderPauseBody(data); break;
      case 'Fork':      bodyHtml = renderForkBody(data); break;
      case 'Download':  bodyHtml = renderDownloadBody(data); break;
      case 'Unzip':     bodyHtml = renderUnzipBody(data); break;
      default:
        // Types without a dedicated renderer (Score, Grow, CVA, Iteration,
        // Check, Done, and anything a future spec adds) still show whatever
        // the step actually declares, and say so plainly when it declares
        // nothing — they no longer render as a blank card.
        bodyHtml = renderInputSection(data.in) + renderOutSection(data.out);
        if (!bodyHtml) {
          bodyHtml = `<div class="node-section"><div class="node-empty">`
            + `<strong>No inputs or outputs declared</strong> for this ${esc(type)} step.</div></div>`;
        }
    }

    // Condition badge
    const conditionHtml = data.if ? `<div class="node-condition">if: ${trunc(data.if, 28)}</div>` : '';

    // BREAK indicator for nodes inside Loop children
    const breakHtml = data.next === 'BREAK' ? `<div class="node-condition" style="background:rgba(248,81,73,0.15);color:var(--red);">next: BREAK</div>` : '';

    // I/O audit badges
    const io = computeIO(data);
    let ioHtml = '';
    const badges = [];
    for (const v of io.varsIn.slice(0, 3)) badges.push(`<span class="io-badge var-in">\u2193${esc(v)}</span>`);
    for (const v of io.varsOut.slice(0, 3)) badges.push(`<span class="io-badge var-out">\u2191${esc(v)}</span>`);
    for (const d of io.docs.slice(0, 2)) badges.push(`<span class="io-badge doc">\u{1F4C4}${esc(d)}</span>`);
    for (const f of io.files.slice(0, 2)) badges.push(`<span class="io-badge file">\u{1F4C1}${esc(f.substring(0, 20))}</span>`);
    if (badges.length) ioHtml = `<div class="node-io">${badges.join('')}</div>`;

    // Status badge
    let badgeHtml = '';
    if (node.status === 'running') badgeHtml = '<div class="status-badge running">&#9881;</div>';
    else if (node.status === 'done') badgeHtml = '<div class="status-badge done">&#10003;</div>';
    else if (node.status === 'error') badgeHtml = '<div class="status-badge error">&#10007;</div>';
    else if (node.status === 'paused') badgeHtml = '<div class="status-badge paused">&#9208;</div>';
    else if (node.status === 'skipped') badgeHtml = '<div class="status-badge skipped">&#8213;</div>';

    // Footer
    let footerHtml = data.children?.length ? `<div class="node-footer">\u2935 ${data.children.length} parallel children</div>` : '';
    // Runtime projection with a composition facet: declared input/output
    // labels in the body and the identity/state footer of the reference
    // design (compact rectangular card, I/O labels, footer identity + state).
    if (isRuntimeProjection(data) && data.meta?.facet && typeof data.meta.facet === 'object') {
      const facet = data.meta.facet;
      if (facet.input || facet.output) {
        ioHtml = `<div class="node-facet-io">${facet.input ? `<div>in <b>${esc(facet.input)}</b></div>` : ''}${facet.output ? `<div>out <b>${esc(facet.output)}</b></div>` : ''}</div>` + ioHtml;
      }
      const observed = data.meta?.status || 'pending';
      footerHtml += `<div class="node-facet-footer"><span title="${esc(facet.ref || node.id)}">${esc(facet.ref || node.id)}</span>`
        + `<span class="facet-state-${esc(observed)}">${esc(observed)}</span></div>`;
    }

    div.innerHTML = `
      ${badgeHtml}
      <div class="port port-in"></div>
      <div class="node-header">
        <div class="node-icon">${icon}</div>
        <div style="overflow:hidden;">
          <div class="node-title">${esc(title)}</div>
          <div class="node-type">${esc(type)}</div>
          ${desc ? `<div class="node-desc">${esc(desc)}</div>` : ''}
        </div>
      </div>
      ${bodyHtml ? `<div class="node-body">${bodyHtml}${conditionHtml}${breakHtml}</div>` : ''}
      ${ioHtml}
      ${footerHtml}
      <div class="port port-out"></div>
    `;

    // Drag + click handler (mousedown starts drag, stopDrag distinguishes click vs drag)
    div.addEventListener('mousedown', (e) => {
      if (e.button !== 0) return;
      startDrag(e, node);
    });

    // Right-click → context menu
    div.addEventListener('contextmenu', (e) => {
      showContextMenu(e, node);
    });

    layer.appendChild(div);
  }
  if (!HOST_CONTROLLED) renderBundleSidebar();
}

function renderBundleSidebar() {
  const list = $('bundleNodeList');
  if (!list) return;

  const bundle = state.bundle || {};
  const manifest = bundle.manifest || {};
  const supportFiles = bundle.supportFiles || { docs: [], prompts: [], configs: [], vl: [] };
  const supportCount = (supportFiles.docs?.length || 0)
    + (supportFiles.prompts?.length || 0)
    + (supportFiles.configs?.length || 0)
    + (supportFiles.vl?.length || 0);

  const bundleTitle = manifest.title || manifest.id || bundle.label || (_currentWorkflowJson?.name || 'Bundle');
  $('bundleSidebarTitle').textContent = bundleTitle;
  $('bundleSidebarPath').textContent = bundle.path
    ? `${bundle.path}${manifest.bundleType ? ` · ${manifest.bundleType}` : ''}`
    : (bundle.label || 'No .vflow loaded yet.');
  $('bundleSidebarStats').textContent = `${state.nodes.length} nodes · ${state.connections.length} edges · ${supportCount} attached files`;

  if (!state.nodes.length) {
    list.innerHTML = '<div class="bundle-load-empty">No nodes available in this workflow yet.</div>';
    return;
  }

  const typeOrder = state.nodes.map((node, index) => ({ node, index }));
  list.innerHTML = typeOrder.map(({ node, index }) => {
    const type = node.type || 'LLM';
    const title = node.data?.meta?.title || node.id;
    const subtitle = node.data?.meta?.description || node.data?.workflowRef || node.data?.workflowPath || '';
    const status = node.status || 'pending';
    return `
      <button class="bundle-node-item ${node.id === state.selectedNodeId ? 'selected' : ''}" data-node-id="${esc(node.id)}" onclick="focusSidebarNode('${esc(node.id)}')">
        <span class="bundle-node-item-status ${esc(status)}"></span>
        <span class="bundle-node-item-main">
          <div class="bundle-node-item-title">${esc(title)}</div>
          <div class="bundle-node-item-type">${esc(type)} #${index + 1}</div>
          ${subtitle ? `<div class="bundle-node-item-meta">${esc(subtitle)}</div>` : ''}
        </span>
      </button>
    `;
  }).join('');
}

function focusSidebarNode(nodeId) {
  state.selectedNodeId = nodeId;
  render();
  const el = $(`node-${nodeId}`);
  if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
}

/**
 * Route every connection from the source card's visible out-port to the target
 * card's visible in-port, using the measured boxes.
 *
 * Returns the computed routes so the caller (applyStageMetrics) can include
 * detour lanes in the scrollable content bounds.
 *
 * Duplicate edges between the same ordered pair keep both anchors on the ports
 * and are separated by a symmetric spread, so two real dependencies are never
 * drawn as one line and never dropped.
 */
function computeConnectionRoutes() {
  const boxes = new Map(state.nodes.map(node => [node.id, nodeBox(node)]));
  const obstacles = Array.from(boxes.values());
  const pairIndex = new Map();
  for (const conn of state.connections) {
    if (!boxes.has(conn.from) || !boxes.has(conn.to)) continue;
    const key = `${conn.from}|${conn.to}`;
    pairIndex.set(key, (pairIndex.get(key) || 0) + 1);
  }
  const seen = new Map();
  const routes = [];
  for (const conn of state.connections) {
    const from = boxes.get(conn.from);
    const to = boxes.get(conn.to);
    if (!from || !to) continue;
    const key = `${conn.from}|${conn.to}`;
    const total = pairIndex.get(key) || 1;
    const index = seen.get(key) || 0;
    seen.set(key, index + 1);
    const spread = total > 1 ? (index - (total - 1) / 2) * 16 : 0;
    routes.push({ conn, from, to, spread, route: WFGeometry.edgeRoute(from, to, { obstacles, spread }) });
  }
  return deconflictLanes(routes, obstacles);
}

/**
 * Two detours that pick the same clear lane and overlap horizontally would be
 * drawn as one line. Stack them further out — away from the cards, never back
 * across them — so both real edges stay separately visible.
 */
function deconflictLanes(routes, obstacles) {
  const used = [];
  for (const entry of routes) {
    if (!entry.route.detour) continue;
    let offset = 0;
    const span = entry.route.laneSpan;
    const above = entry.route.laneAbove;
    for (let guard = 0; guard < routes.length + 1; guard += 1) {
      const lane = entry.route.lane + (above ? -offset : offset);
      const clash = used.some(other => other.above === above
        && Math.abs(other.lane - lane) < 12
        && other.span.hi > span.lo && other.span.lo < span.hi);
      if (!clash) {
        if (offset) {
          entry.route = WFGeometry.edgeRoute(entry.from, entry.to, {
            obstacles, spread: entry.spread, laneOffset: offset,
          });
        }
        used.push({ lane: entry.route.lane, above, span: entry.route.laneSpan });
        break;
      }
      offset += 16;
    }
  }
  return routes;
}

function renderConnections(routes = computeConnectionRoutes()) {
  const svg = $('connSvg');
  svg.querySelectorAll('.conn-group').forEach(g => g.remove());
  const kindsPresent = new Set();

  for (const { conn, route } of routes) {
    const kind = conn.type || 'serial';
    kindsPresent.add(kind);
    const marker = EDGE_MARKERS[kind] || EDGE_MARKERS.serial;

    const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
    g.setAttribute('class', 'conn-group');
    g.setAttribute('data-conn-id', conn.id);
    g.setAttribute('data-from', conn.from);
    g.setAttribute('data-to', conn.to);
    g.setAttribute('data-route', route.kind);

    const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
    path.setAttribute('class', `conn-path ${kind}`);
    path.setAttribute('d', route.d);
    path.setAttribute('marker-end', `url(#${marker})`);
    const title = document.createElementNS('http://www.w3.org/2000/svg', 'title');
    title.textContent = `${conn.from} → ${conn.to} (${conn.kindLabel || kind})`;
    path.appendChild(title);
    g.appendChild(path);

    if (conn.label) {
      const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
      text.setAttribute('class', 'conn-label');
      text.setAttribute('x', route.label.x);
      text.setAttribute('y', route.label.y);
      text.setAttribute('text-anchor', 'middle');
      text.textContent = conn.label.length > 30 ? conn.label.substring(0, 30) + '...' : conn.label;
      g.appendChild(text);
    }

    svg.appendChild(g);
  }

  syncLegend(kindsPresent);
}

/** Show only the edge kinds this graph really has. */
function syncLegend(kindsPresent) {
  const legend = $('legend');
  if (!legend) return;
  let visible = 0;
  legend.querySelectorAll('[data-edge-kind]').forEach((item) => {
    const show = kindsPresent.has(item.getAttribute('data-edge-kind'));
    item.style.display = show ? 'flex' : 'none';
    if (show) visible += 1;
  });
  legend.style.display = visible ? 'flex' : 'none';
}

// ===== Node Drag =====
function initDrag() {
  const canvas = $('canvas');
  canvas.addEventListener('mousemove', onDrag);
  canvas.addEventListener('mouseup', stopDrag);
  canvas.addEventListener('mouseleave', stopDrag);
}

function startDrag(e, node) {
  e.preventDefault();
  state.dragging = { node, startX: e.clientX, startY: e.clientY, origX: node.x, origY: node.y };
  const el = $(`node-${node.id}`);
  if (el) el.classList.add('dragging');
}

function onDrag(e) {
  if (!state.dragging) return;
  const { node, startX, startY, origX, origY } = state.dragging;
  // Pointer deltas are viewport px; positions are model px. Without dividing
  // by the view scale the card lags (or outruns) the cursor at any zoom != 1.
  const delta = WFGeometry.dragDelta(
    { dx: e.clientX - startX, dy: e.clientY - startY },
    state.view.scale,
  );
  node.x = Math.max(0, Math.round(origX + delta.dx));
  node.y = Math.max(0, Math.round(origY + delta.dy));
  const el = $(`node-${node.id}`);
  if (el) {
    el.style.left = node.x + 'px';
    el.style.top = node.y + 'px';
  }
  if (!state._dragRAF) {
    state._dragRAF = requestAnimationFrame(() => {
      renderConnections();
      state._dragRAF = null;
    });
  }
}

function stopDrag() {
  if (!state.dragging) return;
  const { node, origX, origY } = state.dragging;
  const dx = Math.abs(node.x - origX);
  const dy = Math.abs(node.y - origY);
  const el = $(`node-${node.id}`);
  if (el) el.classList.remove('dragging');

  if (dx < 3 && dy < 3) {
    // Treat as click — select node
    state.selectedNodeId = node.id;
    renderNodes();
    measureNodes();
    renderConnections();
    emitNodeSelection(node);
  } else {
    // Dragged — persist manual position and grow the scroll extents so the
    // card cannot be dropped outside the reachable area.
    persistNodePosition(node, { manual: true });
    applyStageMetrics();
    renderConnections();
  }
  state.dragging = null;
}

// ===== PNG Export =====
function downloadPNG() {
  if (state.nodes.length === 0) return toast('No workflow to export');
  const SCALE = 2;
  const PAD = 60;

  // Export bounds come from the same measured boxes and the same routed edges
  // as the on-screen graph, so the PNG cannot clip what the canvas shows.
  measureNodes();
  const exportRoutes = computeConnectionRoutes();
  const exportBounds = WFGeometry.contentBounds(nodeBoxes(), exportRoutes.map(entry => entry.route), 0);
  const minX = exportBounds.minX, minY = exportBounds.minY;
  const maxX = exportBounds.maxX, maxY = exportBounds.maxY;

  const cw = (maxX - minX + PAD * 2) * SCALE;
  const ch = (maxY - minY + PAD * 2) * SCALE;
  const canvas = document.createElement('canvas');
  canvas.width = cw;
  canvas.height = ch;
  const ctx = canvas.getContext('2d');
  ctx.scale(SCALE, SCALE);
  const ox = -minX + PAD;
  const oy = -minY + PAD;

  // Background
  ctx.fillStyle = '#0a0d12';
  ctx.fillRect(0, 0, cw / SCALE, ch / SCALE);

  // Grid dots
  ctx.fillStyle = '#2a3140';
  for (let gx = 0; gx < cw / SCALE; gx += 20) {
    for (let gy = 0; gy < ch / SCALE; gy += 20) {
      ctx.fillRect(gx, gy, 1, 1);
    }
  }

  // Connections — replay the exact routes the canvas drew.
  const edgeColors = {
    serial: '#58a6ff', parallel: '#a371f7', 'branch-case': '#d29922',
    'on-error': '#f85149', data: '#10b981', message: '#2dd4bf', other: '#8b949e',
    feedback: '#db61a2', contains: '#8b949e',
  };
  const edgeDashes = {
    serial: [], parallel: [6, 3], 'branch-case': [4, 2, 1, 2],
    'on-error': [7, 3], data: [2, 3], message: [9, 3, 2, 3], other: [3, 3],
    feedback: [2, 4], contains: [1, 4],
  };
  ctx.save();
  ctx.translate(ox, oy);
  for (const { conn, route } of exportRoutes) {
    const kind = conn.type || 'serial';
    ctx.strokeStyle = edgeColors[kind] || edgeColors.serial;
    ctx.lineWidth = 2;
    ctx.setLineDash(edgeDashes[kind] || []);
    try {
      ctx.stroke(new Path2D(route.d));
    } catch {
      ctx.beginPath();
      ctx.moveTo(route.from.x, route.from.y);
      ctx.lineTo(route.to.x, route.to.y);
      ctx.stroke();
    }
    ctx.setLineDash([]);
    // Arrow head, pointing into the target's in-port (left edge).
    ctx.fillStyle = ctx.strokeStyle;
    ctx.beginPath();
    ctx.moveTo(route.to.x - 8, route.to.y - 4);
    ctx.lineTo(route.to.x, route.to.y);
    ctx.lineTo(route.to.x - 8, route.to.y + 4);
    ctx.fill();
  }
  ctx.restore();

  // Nodes
  const typeColors = {
    LLM:'#6366f1', Service:'#3fb950', API:'#39c5cf', Write:'#d29922',
    Set:'#58a6ff', Actor:'#f43f5e', Subflow:'#f97316', Review:'#14b8a6', GraphPatch:'#f59e0b', Branch:'#a371f7', Loop:'#db61a2', Stop:'#f85149',
    Component:'#2dd4bf', Pause:'#8b5cf6', Fork:'#10b981',
    Download:'#38bdf8', Unzip:'#facc15', VisualLogic:'#8b5cf6',
    Noop:'#64748b', Score:'#22d3ee', Grow:'#4ade80', Channel:'#c084fc',
    Topology:'#fb923c', CVA:'#fb7185', Iteration:'#db61a2', Check:'#a371f7', Done:'#f85149'
  };
  for (const node of state.nodes) {
    const nx = node.x + ox, ny = node.y + oy;
    const nw = measuredWidth(node), nh = measuredHeight(node);
    // Card — drawn at the card's real measured size so the exported frame
    // matches the canvas instead of a fixed 240x80 stand-in.
    ctx.fillStyle = '#1a1f27';
    ctx.strokeStyle = '#2a3140';
    ctx.lineWidth = 1;
    roundRect(ctx, nx, ny, nw, nh, 8);
    ctx.fill();
    ctx.stroke();
    // Type accent bar
    ctx.fillStyle = typeColors[node.type] || '#6366f1';
    ctx.fillRect(nx, ny + 8, 3, Math.max(8, nh - 16));
    // Icon
    ctx.fillStyle = typeColors[node.type] || '#6366f1';
    roundRect(ctx, nx + 10, ny + 10, 24, 24, 5);
    ctx.fill();
    ctx.fillStyle = '#fff';
    ctx.font = 'bold 9px monospace';
    ctx.textAlign = 'center';
    ctx.fillText(NODE_ICONS[node.type] || '?', nx + 22, ny + 26);
    // Title
    ctx.fillStyle = '#e6edf3';
    ctx.font = 'bold 11px monospace';
    ctx.textAlign = 'left';
    ctx.fillText((node.data?.meta?.title || node.id).substring(0, 28), nx + 42, ny + 24);
    // Type
    ctx.fillStyle = '#8b949e';
    ctx.font = '9px monospace';
    ctx.fillText(node.type, nx + 42, ny + 36);
    // Status
    if (node.status) {
      const sc = { done:'#3fb950', running:'#d29922', error:'#f85149', paused:'#8b5cf6', skipped:'#8b949e' };
      ctx.fillStyle = sc[node.status] || '#8b949e';
      ctx.beginPath();
      ctx.arc(nx + nw - 10, ny + 4, 6, 0, Math.PI * 2);
      ctx.fill();
    }
  }

  // Title
  ctx.fillStyle = '#e6edf3';
  ctx.font = 'bold 14px monospace';
  ctx.textAlign = 'left';
  ctx.fillText(_currentWorkflowJson?.name || 'VL Workflow', PAD, 20);
  ctx.fillStyle = '#8b949e';
  ctx.font = '10px monospace';
  ctx.fillText(`${state.nodes.length} nodes \u00b7 VL Workflow Spec ${_currentWorkflowJson?.version || '4.0'}`, PAD, 35);

  // Download
  const link = document.createElement('a');
  link.download = `workflow-${(_currentWorkflowJson?.name || 'dag').replace(/\s+/g, '-')}-${Date.now()}.png`;
  link.href = canvas.toDataURL('image/png');
  link.click();
  toast('PNG exported');
}

function roundRect(ctx, x, y, w, h, r) {
  ctx.beginPath();
  ctx.moveTo(x + r, y);
  ctx.lineTo(x + w - r, y);
  ctx.quadraticCurveTo(x + w, y, x + w, y + r);
  ctx.lineTo(x + w, y + h - r);
  ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
  ctx.lineTo(x + r, y + h);
  ctx.quadraticCurveTo(x, y + h, x, y + h - r);
  ctx.lineTo(x, y + r);
  ctx.quadraticCurveTo(x, y, x + r, y);
  ctx.closePath();
}

// ===== JSON Export / Import =====
function exportJSON() {
  if (HOST_CONTROLLED) return;
  if (!_currentWorkflowJson) return toast('No workflow to export');
  const blob = new Blob([JSON.stringify(_currentWorkflowJson, null, 2)], { type: 'application/json' });
  const link = document.createElement('a');
  link.download = `${(_currentWorkflowJson.name || 'workflow').replace(/\s+/g, '-')}-${Date.now()}.json`;
  link.href = URL.createObjectURL(blob);
  link.click();
  toast('JSON exported');
}

function importJSON() {
  if (HOST_CONTROLLED) return;
  $('importInput').click();
}
$('importInput')?.addEventListener('change', async (e) => {
  if (HOST_CONTROLLED) return;
  const file = e.target.files[0];
  if (!file) return;
  try {
    const text = await file.text();
    const json = JSON.parse(text);
    if (!json.steps?.length) throw new Error('Invalid workflow: missing steps');
    parseWorkflow(json);
    toast(`Loaded: ${json.name || file.name}`);
    window.parent.postMessage({ type: 'workflowImported', workflow: json }, '*');
  } catch (err) {
    toast('Import failed: ' + err.message);
  }
  e.target.value = '';
});

function bufferToBase64(buffer) {
  const bytes = new Uint8Array(buffer);
  const chunkSize = 0x8000;
  let binary = '';
  for (let i = 0; i < bytes.length; i += chunkSize) {
    binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
  }
  return btoa(binary);
}

async function postJson(url, body) {
  if (HOST_CONTROLLED) throw new Error('Network mutation is controlled by the host');
  const res = await fetch(url, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  });
  const text = await res.text();
  let data = null;
  try { data = text ? JSON.parse(text) : null; } catch { data = text; }
  if (!res.ok) {
    const message = typeof data === 'object' && data ? (data.error || data.message || res.statusText) : (data || res.statusText);
    throw new Error(message || 'Request failed');
  }
  return data;
}

async function loadVflowBundleFromBody(body, label = '') {
  if (HOST_CONTROLLED) throw new Error('Bundle loading is controlled by the host');
  const payload = await postJson('/api/bundle/read-vflow', body);
  if (!payload?.workflow) throw new Error('Bundle did not include a workflow');
  parseWorkflow(payload.workflow, {
    bundlePath: payload.bundlePath || body.bundlePath || null,
    manifest: payload.manifest || null,
    supportFiles: payload.support || {},
    entries: payload.entries || [],
    label,
  });
  toast(`Loaded bundle: ${payload.manifest?.title || payload.manifest?.id || label || 'workflow'}`);
  window.parent.postMessage({
    type: 'workflowBundleLoaded',
    bundlePath: payload.bundlePath || body.bundlePath || null,
    manifest: payload.manifest || null,
  }, '*');
  return payload;
}

async function openVflowBundle() {
  if (HOST_CONTROLLED) return;
  try {
    if (window.showOpenFilePicker) {
      const [handle] = await window.showOpenFilePicker({
        multiple: false,
        types: [{ description: 'VL Workflow Bundle', accept: { 'application/zip': ['.vflow', '.zip'] } }],
      });
      if (!handle) return;
      const file = await handle.getFile();
      const buffer = await file.arrayBuffer();
      await loadVflowBundleFromBody({ zipBase64: bufferToBase64(buffer) }, file.name);
      return;
    }

    const bundlePath = window.prompt('Enter .vflow bundle path');
    if (!bundlePath) return;
    await loadVflowBundleFromBody({ bundlePath }, bundlePath);
  } catch (err) {
    toast('Open bundle failed: ' + err.message);
  }
}

function getCurrentBundleName() {
  const manifest = state.bundle?.manifest || {};
  const fallback = _currentWorkflowJson?.name || manifest.id || 'workflow';
  return String(manifest.id || fallback).trim() || 'workflow';
}

async function buildVflowBundleBlob(bundleName = getCurrentBundleName()) {
  if (HOST_CONTROLLED) throw new Error('Bundle export is controlled by the host');
  if (!_currentWorkflowJson) throw new Error('No workflow loaded');
  const supportFiles = state.bundle?.supportFiles || { docs: [], prompts: [], configs: [], vl: [] };
  const safeBundleName = String(bundleName || 'workflow')
    .trim()
    .replace(/[\\/:*?"<>|]+/g, '-')
    .replace(/\s+/g, '-')
    || 'workflow';
  const payload = {
    workflow: _currentWorkflowJson,
    name: safeBundleName,
    title: _currentWorkflowJson.name || state.bundle?.manifest?.title || safeBundleName,
    version: _currentWorkflowJson.version || state.bundle?.manifest?.version || '0.1.0',
    description: _currentWorkflowJson.description || state.bundle?.manifest?.description || '',
    portability: state.bundle?.manifest?.portability || undefined,
    supportFiles,
  };

  const res = await fetch('/api/bundle/export-vflow-from-workflow', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(payload),
  });
  if (!res.ok) {
    let message = res.statusText;
    try {
      const data = await res.json();
      message = data?.error || data?.message || message;
    } catch {}
    throw new Error(message || 'Failed to build .vflow bundle');
  }
  return { blob: await res.blob(), filename: `${safeBundleName}.vflow` };
}

async function saveVflowBundle(options = {}) {
  if (HOST_CONTROLLED) return;
  if (!_currentWorkflowJson) return toast('No workflow to save');
  try {
    const suggestedName = options.saveAs
      ? window.prompt('Save bundle as', getCurrentBundleName())
      : getCurrentBundleName();
    if (!suggestedName) return;
    const { blob, filename } = await buildVflowBundleBlob(suggestedName);

    if (window.showSaveFilePicker) {
      const handle = await window.showSaveFilePicker({
        suggestedName: filename,
        types: [{ description: 'VL Workflow Bundle', accept: { 'application/zip': ['.vflow'] } }],
      });
      const writable = await handle.createWritable();
      await writable.write(blob);
      await writable.close();
      toast(`Saved bundle: ${filename}`);
    } else {
      const link = document.createElement('a');
      link.download = filename;
      link.href = URL.createObjectURL(blob);
      link.click();
      setTimeout(() => URL.revokeObjectURL(link.href), 2000);
      toast(`Downloaded bundle: ${filename}`);
    }

    window.parent.postMessage({
      type: 'workflowBundleSaved',
      filename,
      bundleName: suggestedName,
      bundlePath: state.bundle?.path || null,
    }, '*');
  } catch (err) {
    toast('Save bundle failed: ' + err.message);
  }
}

async function fetchWorkflowContent(ref, workDir) {
  if (HOST_CONTROLLED) throw new Error('Workflow loading is controlled by the host');
  const url = new URL('/api/workflow-content', window.location.origin);
  url.searchParams.set('ref', ref);
  if (workDir) url.searchParams.set('work_dir', workDir);
  const res = await fetch(url.toString());
  if (!res.ok) throw new Error(`Failed to load workflow: ${res.status}`);
  return res.json();
}

async function loadWorkflowFromLocation() {
  if (HOST_CONTROLLED) return false;
  const search = window.location?.search || '';
  if (!search) return false;
  const params = new URLSearchParams(search);
  const bundlePath = params.get('bundlePath') || params.get('bundle') || params.get('bundle_path');
  if (bundlePath) {
    try {
      await loadVflowBundleFromBody({ bundlePath }, bundlePath);
      return true;
    } catch (err) {
      console.warn('loadWorkflowFromLocation bundle failed:', err.message);
    }
  }
  const workflowRef = params.get('workflow') || params.get('ref');
  if (!workflowRef) return false;
  const workDir = params.get('work_dir') || '';
  try {
    const payload = await fetchWorkflowContent(workflowRef, workDir);
    const wf = payload.workflow || payload;
    const name = payload.workflowRef || workflowRef;
    parseWorkflow(wf, { label: payload.title || wf?.name || name });
    return true;
  } catch (err) {
    console.warn('loadWorkflowFromLocation failed:', err.message);
    return false;
  }
}

// ===== Run Workflow (SSE) =====
async function runWorkflow() {
  if (HOST_CONTROLLED) return;
  if (!_currentWorkflowJson || _executing) return;
  _executing = true;
  $('runBtn').style.display = 'none';
  $('pauseBtn').style.display = '';
  $('stopBtn').style.display = '';
  $('statusLabel').textContent = 'Executing...';

  // Clear statuses
  for (const n of state.nodes) n.status = null;
  render();

  try {
    const res = await fetch('/api/workflow/execute', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ workflowName: _currentWorkflowJson.name || 'ephemeral', params: {} })
    });
    await streamSSE(res);
  } catch (err) {
    toast('Execution error: ' + err.message);
  }

  _executing = false;
  $('runBtn').style.display = '';
  $('pauseBtn').style.display = 'none';
  $('stopBtn').style.display = 'none';
  $('statusLabel').textContent = 'Done';
  // Try to fetch final checkpoint
  fetchCheckpoint();
}

function handleExecEvent(evt) {
  // Map engine event types to UI (support both legacy and v0.3+ naming)
  const nodeId = evt.nodeId || evt.stepID;
  const evtType = evt.type;

  if (evtType === 'node_start' || evtType === 'step_start') {
    setNodeStatus(nodeId, 'running');
    $('statusLabel').textContent = `Running: ${evt.title || evt.payload?.meta?.title || nodeId}`;
  } else if (evtType === 'node_done' || evtType === 'step_done') {
    setNodeStatus(nodeId, 'done');
  } else if (evtType === 'node_error' || evtType === 'step_error') {
    setNodeStatus(nodeId, 'error');
  } else if (evtType === 'node_skipped' || evtType === 'step_skipped') {
    setNodeStatus(nodeId, 'skipped');
  } else if (evtType === 'pause' || evtType === 'pause_start') {
    setNodeStatus(nodeId, 'paused');
    $('statusLabel').textContent = `Paused: ${evt.title || nodeId}`;
  } else if (evtType === 'resumed' || evtType === 'pause_resumed') {
    setNodeStatus(nodeId, 'running');
  } else if (evtType === 'child_run_dispatch' || evtType === 'child_run_started') {
    setNodeStatus(nodeId, 'running');
    $('statusLabel').textContent = `Child run: ${evt.payload?.workflowName || evt.payload?.workflowRef || nodeId}`;
  } else if (evtType === 'child_run_done') {
    setNodeStatus(nodeId, 'done');
  } else if (evtType === 'child_run_failed') {
    setNodeStatus(nodeId, 'error');
  } else if (evtType === 'human_gate_opened') {
    setNodeStatus(nodeId, 'paused');
    _pendingReviewState = {
      nodeId,
      token: evt.payload?.waitToken || null,
      envelopeId: evt.payload?.envelopeId || null,
      allowedDecisions: evt.payload?.allowedDecisions || ['approve', 'reject', 'revise'],
      gateId: evt.payload?.gateId || null,
      summary: evt.payload?.summary || '',
      actorId: evt.payload?.actorId || null,
      sessionId: evt.payload?.sessionId || null
    };
    $('statusLabel').textContent = `Waiting for review: ${evt.payload?.summary || nodeId}`;
  } else if (evtType === 'human_gate_decided') {
    setNodeStatus(nodeId, 'running');
    if (_pendingReviewState && _pendingReviewState.nodeId === nodeId) {
      _pendingReviewState.decision = evt.payload?.decision || null;
    }
  } else if (evtType === 'review_completed') {
    setNodeStatus(nodeId, 'done');
    if (_pendingReviewState && _pendingReviewState.nodeId === nodeId) _pendingReviewState = null;
  } else if (evtType === 'review_timeout' || evtType === 'review_rejected') {
    setNodeStatus(nodeId, 'error');
  } else if (evtType === 'workflow_paused') {
    // User-initiated graceful pause — engine stopped after current step
    const pausedAt = evt.payload?.pausedAt || nodeId;
    $('statusLabel').textContent = `Paused after ${pausedAt} — right-click a node to edit & re-run`;
    _executing = false;
    $('runBtn').style.display = '';
    $('pauseBtn').style.display = 'none';
    $('stopBtn').style.display = 'none';
    fetchCheckpoint();
  } else if (evtType === 'done' || evtType === 'workflow_done') {
    $('statusLabel').textContent = `Complete! ${evt.filesWritten?.length || evt.payload?.filesWritten?.length || 0} files written`;
  } else if (evtType === 'error' || evtType === 'workflow_failed') {
    $('statusLabel').textContent = 'Error: ' + (evt.error || evt.payload?.error || 'unknown');
  }

  // Capture checkpoint if provided
  if (evt.checkpoint) {
    _lastCheckpoint = evt.checkpoint;
    saveCheckpointToStorage();
  }
  // Capture runID
  if (evt.runID || evt.payload?.runID) {
    _currentRunID = evt.runID || evt.payload?.runID;
  }
}

function setNodeStatus(nodeId, status, runtimeStatus) {
  const node = state.nodes.find(n => n.id === nodeId);
  if (node) {
    node.status = status;
    // Badges intentionally collapse runtime states (cached -> done, waiting-human
    // -> paused). Keep the exact observed status in the runtime card text too.
    // Authored descriptions remain user-owned and must never be overwritten.
    if (isRuntimeProjection(node.data)) {
      const observed = typeof runtimeStatus === 'string' ? runtimeStatus : (status || 'pending');
      node.data = { ...node.data, meta: { ...node.data.meta,
        status: observed, description: 'Runtime status: ' + observed,
      } };
    }
    // Avoid full re-render during drag
    if (state.dragging) return;
    render();
    // Scroll to node
    const el = $(`node-${nodeId}`);
    if (el && !HOST_CONTROLLED) el.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
  }
}

// ===== View stage (scroll extents, scale, fit) =====

function viewportSize() {
  const wrap = $('canvasWrap');
  return { width: wrap?.clientWidth || 0, height: wrap?.clientHeight || 0 };
}

/**
 * Size the scroll sizer and apply the current scale to the two content layers.
 *
 * `.canvas` is the only element that decides how far the user can scroll, and
 * it is now derived from the measured bounds of every card *and* every routed
 * edge — that is what makes far-right and bottom cards reachable instead of
 * being cut off by a hard-coded 2600x1600 canvas.
 */
function applyStageMetrics(options = {}) {
  const canvas = $('canvas');
  const svg = $('connSvg');
  const layer = $('nodesLayer');
  if (!canvas || !svg || !layer) return null;

  const routes = (options.routes || computeConnectionRoutes()).map(entry => entry.route);
  const bounds = WFGeometry.contentBounds(nodeBoxes(), routes, 40);
  state.bounds = bounds;

  const viewport = viewportSize();
  // A fit against a not-yet-laid-out frame (width/height 0) would lock the view
  // at the minimum scale. The ResizeObserver re-fits as soon as the host gives
  // the frame a real size, so skipping here is the correct behaviour.
  if (options.fit && !bounds.empty && viewport.width > 0 && viewport.height > 0) {
    const fit = WFGeometry.fitView(bounds, viewport, {
      minScale: VIEW_MIN_SCALE, maxScale: 1, pad: 16,
    });
    state.view.scale = fit.scale;
    state.view.pendingScroll = { left: fit.scrollLeft, top: fit.scrollTop };
  }

  const scale = WFGeometry.clamp(state.view.scale || 1, VIEW_MIN_SCALE, VIEW_MAX_SCALE);
  state.view.scale = scale;

  // Model-space size of the drawing surface (unscaled): the transform below
  // scales it, so measurements taken from the DOM stay in model coordinates.
  const modelW = Math.max(bounds.maxX, viewport.width / scale);
  const modelH = Math.max(bounds.maxY, viewport.height / scale);
  svg.setAttribute('width', Math.ceil(modelW));
  svg.setAttribute('height', Math.ceil(modelH));
  svg.style.width = `${Math.ceil(modelW)}px`;
  svg.style.height = `${Math.ceil(modelH)}px`;
  svg.style.transform = `scale(${scale})`;
  layer.style.width = `${Math.ceil(modelW)}px`;
  layer.style.height = `${Math.ceil(modelH)}px`;
  layer.style.transform = `scale(${scale})`;

  const stage = WFGeometry.stageMetrics(bounds, viewport, scale, 24);
  canvas.style.width = `${stage.width}px`;
  canvas.style.height = `${stage.height}px`;
  canvas.style.backgroundSize = `${20 * scale}px ${20 * scale}px`;

  const label = $('viewScaleLabel');
  if (label) label.textContent = `${Math.round(scale * 100)}%`;

  if (state.view.pendingScroll) {
    const wrap = $('canvasWrap');
    const target = state.view.pendingScroll;
    state.view.pendingScroll = null;
    if (wrap) {
      wrap.scrollLeft = target.left;
      wrap.scrollTop = target.top;
    }
  }
  return { bounds, scale, stage };
}

/** Fit every node and every routed edge into the space that is actually left. */
function fitWorkflowView() {
  if (!state.nodes.length) return false;
  measureNodes();
  state.view.autoFit = true;
  applyStageMetrics({ fit: true });
  renderConnections();
  return true;
}

/** Zoom about the centre of the visible area (HUD buttons). */
function zoomBy(factor) {
  const wrap = $('canvasWrap');
  if (!wrap || !state.nodes.length) return false;
  return zoomAtPoint(factor, { x: wrap.clientWidth / 2, y: wrap.clientHeight / 2 });
}

/** Zoom about a viewport point, keeping the model point under it fixed. */
function zoomAtPoint(factor, anchor) {
  const wrap = $('canvasWrap');
  if (!wrap) return false;
  const next = WFGeometry.zoomAt(state.view.scale, factor, anchor, {
    left: wrap.scrollLeft, top: wrap.scrollTop,
  }, { minScale: VIEW_MIN_SCALE, maxScale: VIEW_MAX_SCALE });
  if (Math.abs(next.scale - state.view.scale) < 0.0001) return false;
  // An explicit zoom is a deliberate view choice; stop re-fitting on resize.
  state.view.autoFit = false;
  state.view.scale = next.scale;
  state.view.pendingScroll = { left: next.scrollLeft, top: next.scrollTop };
  applyStageMetrics();
  renderConnections();
  return true;
}

/**
 * Host resize (Assistant dock opening/closing, window resize, inline pane
 * reflow): keep the stage coherent, and re-fit against the remaining space
 * while the view is still in auto-fit mode.
 */
function handleViewportResize() {
  if (!state.nodes.length) return;
  measureNodes();
  if (layoutNeedsRepack()) {
    autoLayout({ preserveManual: true, persist: false });
    applyNodePositions();
    measureNodes();
  }
  applyStageMetrics({ fit: state.view.autoFit === true });
  renderConnections();
}

function setRuntimeControls(runtime = {}) {
  if (!DEBUG_ONLY) return;
  const status = String(runtime.status || '').trim();
  const label = runtime.label || status;
  if (label) $('statusLabel').textContent = label;
  setRuntimeDebugCapabilities([..._runtimeDebugCapabilities]);
}

function stopExecution() {
  if (requestRuntimeDebugAction('stop')) {
    $('statusLabel').textContent = 'Stop requested';
    return;
  }
  if (HOST_CONTROLLED) return;
  if (_eventSource) { _eventSource.close(); _eventSource = null; }
  // Send abort to server
  if (_currentRunID) {
    fetch(`/api/workflow/${_currentRunID}/abort`, { method: 'POST' }).catch(() => {});
  }
  _executing = false;
  $('runBtn').style.display = '';
  $('pauseBtn').style.display = 'none';
  $('stopBtn').style.display = 'none';
  $('statusLabel').textContent = 'Stopped';
  fetchCheckpoint();
}

async function pauseExecution() {
  if (requestRuntimeDebugAction('pause')) {
    $('statusLabel').textContent = 'Pause requested';
    return;
  }
  if (HOST_CONTROLLED) return;
  if (!_currentRunID) return toast('No active run');
  $('statusLabel').textContent = 'Pausing...';
  try {
    await fetch(`/api/workflow/${_currentRunID}/pause`, { method: 'POST' });
    $('statusLabel').textContent = 'Paused (can resume from any node)';
  } catch (err) {
    // Fallback: try cancel endpoint
    try {
      await fetch(`/api/workflow/${_currentRunID}/cancel`, { method: 'POST' });
      $('statusLabel').textContent = 'Paused';
    } catch { toast('Pause failed'); }
  }
  _executing = false;
  $('runBtn').style.display = '';
  $('pauseBtn').style.display = 'none';
  $('stopBtn').style.display = 'none';
  fetchCheckpoint();
}

function resumeExecution() {
  if (requestRuntimeDebugAction('resume')) {
    $('statusLabel').textContent = 'Resume requested';
    return;
  }
  if (HOST_CONTROLLED) return;
  toast('Resume is available from a host-controlled Runtime');
}

// ===== Checkpoint Persistence =====
function saveCheckpointToStorage() {
  if (HOST_CONTROLLED) return;
  if (!_lastCheckpoint || !_currentWorkflowJson?.name) return;
  try {
    const key = `wf_cp_${_currentWorkflowJson.name}`;
    const data = {
      checkpoint: _lastCheckpoint,
      nodeStatuses: state.nodes.map(n => ({ id: n.id, status: n.status })),
      runID: _currentRunID,
      ts: Date.now()
    };
    localStorage.setItem(key, JSON.stringify(data));
  } catch {}
}

function restoreFromStorage() {
  if (HOST_CONTROLLED) return false;
  if (!_currentWorkflowJson?.name) return false;
  try {
    const key = `wf_cp_${_currentWorkflowJson.name}`;
    const raw = localStorage.getItem(key);
    if (!raw) return false;
    const data = JSON.parse(raw);
    // Only restore if less than 24h old
    if (Date.now() - data.ts > 86400000) { localStorage.removeItem(key); return false; }
    _lastCheckpoint = data.checkpoint;
    _currentRunID = data.runID;
    // Restore node statuses
    if (data.nodeStatuses) {
      for (const ns of data.nodeStatuses) {
        const node = state.nodes.find(n => n.id === ns.id);
        if (node && ns.status) node.status = ns.status;
      }
    }
    return true;
  } catch { return false; }
}

async function fetchCheckpoint() {
  if (HOST_CONTROLLED) return;
  if (!_currentRunID) return;
  try {
    const res = await fetch(`/api/workflow/${_currentRunID}/checkpoint`);
    if (res.ok) {
      _lastCheckpoint = await res.json();
      saveCheckpointToStorage();
    }
  } catch {}
}

// ===== Re-run from Step =====
async function rerunFromStep(stepId, overrides = {}) {
  if (!_currentWorkflowJson) return toast('No workflow loaded');
  if (HOST_CONTROLLED) {
    if (requestRuntimeDebugAction('rerun', { nodeId: stepId, overrides })) {
      $('statusLabel').textContent = `Re-run requested from ${stepId}`;
    }
    return;
  }

  // Build checkpoint: use last checkpoint or create minimal one
  const checkpoint = _lastCheckpoint ? { ..._lastCheckpoint, currentStepID: stepId }
    : { currentStepID: stepId, params: {}, variables: {} };

  // Apply overrides
  if (Object.keys(overrides).length > 0 && checkpoint.variables) {
    Object.assign(checkpoint.variables, overrides);
  }

  // Clear statuses for this node and all subsequent
  const stepIdx = state.nodes.findIndex(n => n.id === stepId);
  if (stepIdx >= 0) {
    // Clear this node and downstream
    const toClear = new Set();
    function markDownstream(id) {
      if (toClear.has(id)) return;
      toClear.add(id);
      for (const c of state.connections) {
        if (c.from === id) markDownstream(c.to);
      }
    }
    markDownstream(stepId);
    for (const n of state.nodes) {
      if (toClear.has(n.id)) n.status = null;
    }
    render();
  }

  _executing = true;
  $('runBtn').style.display = 'none';
  $('pauseBtn').style.display = '';
  $('stopBtn').style.display = '';
  $('statusLabel').textContent = `Re-running from ${stepId}...`;

  try {
    const res = await fetch('/api/workflow/rerun', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        workflowName: _currentWorkflowJson.name || 'ephemeral',
        checkpoint,
        stepID: stepId,
        overrides
      })
    });

    if (!res.ok) {
      // Fallback: try execute with fromStep
      const res2 = await fetch('/api/workflow/execute', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          workflowName: _currentWorkflowJson.name,
          params: {},
          fromStep: stepId,
          checkpoint,
          overrides
        })
      });
      if (!res2.ok) throw new Error('Rerun failed');
      await streamSSE(res2);
    } else {
      await streamSSE(res);
    }
  } catch (err) {
    toast('Re-run error: ' + err.message);
  }

  _executing = false;
  $('runBtn').style.display = '';
  $('pauseBtn').style.display = 'none';
  $('stopBtn').style.display = 'none';
  $('statusLabel').textContent = 'Done';
  fetchCheckpoint();
}

async function streamSSE(res) {
  const reader = res.body.getReader();
  const decoder = new TextDecoder();
  let buf = '';
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    buf += decoder.decode(value, { stream: true });
    let lines = buf.split('\n');
    buf = lines.pop();
    for (const line of lines) {
      if (line.startsWith('data: ')) {
        try { handleExecEvent(JSON.parse(line.slice(6))); } catch {}
      }
    }
  }
}

// ===== Context Menu =====
function showContextMenu(e, node) {
  e.preventDefault();
  _ctxTargetNode = node;
  const menu = $('ctxMenu');
  $('ctxNodeIdHint').textContent = node.id;

  // Enable/disable based on state
  const hasCheckpoint = !!_lastCheckpoint;
  const rerunItem = $('ctxRerun');
  const editItem = $('ctxEditRerun');
  if (hasCheckpoint || _currentWorkflowJson) {
    rerunItem.classList.remove('disabled');
    editItem.classList.remove('disabled');
  } else {
    rerunItem.classList.add('disabled');
    editItem.classList.add('disabled');
  }

  const reviewItem = $('ctxReview');
  if (canReviewNode(node)) {
    reviewItem.classList.remove('disabled');
  } else {
    reviewItem.classList.add('disabled');
  }

  if (DEBUG_ONLY) {
    $('ctxBreakpointLabel').textContent = _runtimeBreakpoints.has(node.id) ? 'Clear breakpoint' : 'Set breakpoint';
    setRuntimeDebugCapabilities([..._runtimeDebugCapabilities]);
  }

  // Position
  menu.style.left = Math.min(e.clientX, window.innerWidth - 200) + 'px';
  menu.style.top = Math.min(e.clientY, window.innerHeight - 160) + 'px';
  menu.classList.add('show');
}

function hideContextMenu() {
  $('ctxMenu').classList.remove('show');
  _ctxTargetNode = null;
}

function ctxRerunFromHere() {
  const node = _ctxTargetNode;
  hideContextMenu();
  if (!node) return;
  rerunFromStep(node.id);
}

function ctxEditAndRerun() {
  const node = _ctxTargetNode;
  hideContextMenu();
  if (!node) return;
  openEditor(node);
}

function ctxToggleBreakpoint() {
  const node = _ctxTargetNode;
  hideContextMenu();
  if (!node || !DEBUG_ONLY) return;
  const breakpoints = new Set(_runtimeBreakpoints);
  if (breakpoints.has(node.id)) breakpoints.delete(node.id);
  else breakpoints.add(node.id);
  requestRuntimeDebugAction('toggle-breakpoint', {
    nodeId: node.id,
    enabled: breakpoints.has(node.id),
    breakpoints: [...breakpoints]
  });
}

function ctxReviewAcceptance() {
  const node = _ctxTargetNode;
  hideContextMenu();
  if (!node || !canReviewNode(node)) return;
  openAcceptanceModal(node);
}

function ctxViewDetails() {
  const node = _ctxTargetNode;
  hideContextMenu();
  if (!node) return;
  if (HOST_CONTROLLED) requestRuntimeDebugAction('inspect', { nodeId: node.id });
  emitNodeSelection(node);
}

function ctxCopyId() {
  const node = _ctxTargetNode;
  hideContextMenu();
  if (!node) return;
  navigator.clipboard?.writeText(node.id);
  toast('Copied: ' + node.id);
}

function canReviewNode(node) {
  if (!node) return false;
  if (node.type !== 'Review') return false;
  return !!findPendingReviewForNode(node);
}

function resolveCheckpointEnvelope(envelopeId) {
  const envelopes = Array.isArray(_lastCheckpoint?.resultEnvelopes) ? _lastCheckpoint.resultEnvelopes : [];
  if (!envelopeId) return envelopes[envelopes.length - 1] || null;
  return envelopes.find(item => item.envelopeId === envelopeId) || null;
}

function findPendingReviewForNode(nodeOrId) {
  const nodeId = typeof nodeOrId === 'string' ? nodeOrId : nodeOrId?.id;
  if (!nodeId) return null;
  if (_pendingReviewState?.nodeId === nodeId) return _pendingReviewState;
  if (_lastCheckpoint?.reviewState?.nodeID === nodeId) {
    return {
      nodeId,
      token: _lastCheckpoint.reviewState.token || null,
      envelopeId: _lastCheckpoint.reviewState.envelopeId || null,
      allowedDecisions: _lastCheckpoint.reviewState.gate?.allowedDecisions || ['approve', 'reject', 'revise'],
      gateId: _lastCheckpoint.reviewState.gate?.gateId || null,
      summary: _lastCheckpoint.reviewState.sourceEnvelope?.summary || '',
      sourceEnvelope: _lastCheckpoint.reviewState.sourceEnvelope || null
    };
  }
  return null;
}

function findReviewEnvelopeForNode(nodeOrId) {
  const pending = findPendingReviewForNode(nodeOrId);
  if (pending?.envelopeId) {
    return resolveCheckpointEnvelope(pending.envelopeId) || pending.sourceEnvelope || null;
  }
  return pending?.sourceEnvelope || resolveCheckpointEnvelope(null);
}

function openAcceptanceModal(node) {
  const pending = findPendingReviewForNode(node);
  if (!pending) return toast('No pending acceptance for this node');

  _acceptanceNode = typeof node === 'string'
    ? state.nodes.find(item => item.id === node)
    : node;
  const envelope = findReviewEnvelopeForNode(node);
  const allowedDecisions = Array.isArray(pending.allowedDecisions) && pending.allowedDecisions.length > 0
    ? pending.allowedDecisions
    : ['approve', 'reject', 'revise'];

  $('acceptanceTitle').textContent = `Review Acceptance: ${_acceptanceNode?.data?.meta?.title || _acceptanceNode?.id || 'Review'}`;
  $('acceptanceBody').innerHTML = `
    <div class="editor-field">
      <div class="editor-label">Envelope</div>
      <div class="editor-hint">${esc(envelope?.summary || pending.summary || 'Pending review')}</div>
      <div class="editor-hint">Actor: ${esc(envelope?.actorId || pending.actorId || 'unknown')} | Session: ${esc(envelope?.sessionId || pending.sessionId || 'unknown')}</div>
    </div>
    <div class="editor-field">
      <div class="editor-label">Decision</div>
      <select class="editor-input" id="acceptanceDecision">
        ${allowedDecisions.map(decision => `<option value="${esc(decision)}">${esc(decision)}</option>`).join('')}
      </select>
    </div>
    <div class="editor-field">
      <div class="editor-label">Reviewer</div>
      <input class="editor-input" id="acceptanceReviewer" placeholder="human:lead" value="">
    </div>
    <div class="editor-field">
      <div class="editor-label">Note</div>
      <textarea class="editor-json" id="acceptanceNote" style="min-height:100px"></textarea>
    </div>
    <div class="editor-field">
      <div class="editor-label">Requested Changes</div>
      <textarea class="editor-json" id="acceptanceRequestedChanges" style="min-height:80px" placeholder='["Tighten validation", "Add tests"]'></textarea>
      <div class="editor-hint">Use JSON array or one change per line.</div>
      <div class="editor-error" id="acceptanceRequestedChangesErr"></div>
    </div>
  `;
  $('acceptanceOverlay').classList.add('show');
}

function closeAcceptanceModal() {
  $('acceptanceOverlay').classList.remove('show');
  _acceptanceNode = null;
}

function parseRequestedChangesInput(value) {
  const text = String(value || '').trim();
  if (!text) return [];
  try {
    const parsed = JSON.parse(text);
    if (Array.isArray(parsed)) return parsed.map(item => String(item).trim()).filter(Boolean);
  } catch {}
  return text.split('\n').map(item => item.trim()).filter(Boolean);
}

function parseJsonOrModeString(value, emptyFallback = {}) {
  const text = String(value || '').trim();
  if (!text) return emptyFallback;
  const looksLikeJSON = text.startsWith('{')
    || text.startsWith('[')
    || text.startsWith('"')
    || text === 'true'
    || text === 'false'
    || text === 'null'
    || /^-?\d+(\.\d+)?$/.test(text);
  if (looksLikeJSON) return JSON.parse(text);
  return text;
}

async function submitAcceptanceReview() {
  if (HOST_CONTROLLED) return;
  if (!_acceptanceNode) return;
  const pending = findPendingReviewForNode(_acceptanceNode);
  if (!pending?.token) return toast('Missing review token');

  let requestedChanges = [];
  try {
    requestedChanges = parseRequestedChangesInput($('acceptanceRequestedChanges')?.value || '');
    const errEl = $('acceptanceRequestedChangesErr');
    if (errEl) errEl.style.display = 'none';
  } catch (error) {
    const errEl = $('acceptanceRequestedChangesErr');
    if (errEl) { errEl.textContent = 'Invalid requested changes: ' + error.message; errEl.style.display = 'block'; }
    return;
  }

  const payload = {
    token: pending.token,
    decision: $('acceptanceDecision')?.value || 'approve',
    by: $('acceptanceReviewer')?.value || 'human:reviewer',
    note: $('acceptanceNote')?.value || '',
    requestedChanges
  };

  window.parent.postMessage({
    type: 'acceptanceReviewSubmitted',
    nodeId: _acceptanceNode.id,
    payload
  }, '*');

  try {
    if (_currentRunID) {
      let res = await fetch(`/api/workflow/${_currentRunID}/review`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(payload)
      });
      if (!res.ok) {
        res = await fetch('/api/workflow/review', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ runID: _currentRunID, ...payload })
        });
      }
      if (!res.ok) throw new Error('Review submission failed');
    }
    closeAcceptanceModal();
    toast(`Review submitted: ${payload.decision}`);
  } catch (error) {
    toast('Review submit failed: ' + error.message);
  }
}

// Close context menu on click elsewhere
document.addEventListener('click', (e) => {
  if (!e.target.closest('.ctx-menu')) hideContextMenu();
});

// ===== Node Editor Modal =====
function openEditor(node) {
  _editorNode = node;
  const data = node.data || {};
  const type = node.type;

  $('editorTitle').textContent = `Edit: ${data.meta?.title || node.id} (${type})`;

  let html = '';

  // Status indicator
  const statusText = node.status || 'pending';
  html += `<div class="editor-status">
    <div class="editor-status-dot ${statusText}"></div>
    <span class="editor-status-text">Status: ${statusText} | Node: ${esc(node.id)}</span>
  </div>`;

  // Type-specific editor fields
  if (type === 'LLM') {
    const model = data.in?.model || data.model || '';
    const msgs = data.in?.messages ? JSON.stringify(data.in.messages, null, 2) : '[]';
    const maxTokens = data.in?.max_tokens || '';
    html += `<div class="editor-field"><div class="editor-label">Model</div>
      <input class="editor-input" id="edit_model" value="${esc(model)}" placeholder="e.g. anthropic/claude-opus-4-6"></div>`;
    html += `<div class="editor-field"><div class="editor-label">Max Tokens</div>
      <input class="editor-input" id="edit_max_tokens" value="${esc(String(maxTokens))}" type="number" placeholder="4096"></div>`;
    html += `<div class="editor-field"><div class="editor-label">Messages (JSON)</div>
      <textarea class="editor-json" id="edit_messages">${esc(msgs)}</textarea>
      <div class="editor-error" id="edit_messages_err"></div></div>`;
    html += `<div class="editor-field"><div class="editor-label">Tools (JSON)</div>
      <textarea class="editor-json" id="edit_tools">${esc(JSON.stringify(data.tools || [], null, 2))}</textarea>
      <div class="editor-error" id="edit_tools_err"></div></div>`;
    html += `<div class="editor-field"><div class="editor-label">Tool Scope (JSON or mode string)</div>
      <textarea class="editor-json" id="edit_toolScope">${esc(typeof data.toolScope === 'string' ? data.toolScope : JSON.stringify(data.toolScope || {}, null, 2))}</textarea>
      <div class="editor-error" id="edit_toolScope_err"></div></div>`;
  } else if (type === 'Service' || type === 'API' || type === 'Component') {
    const inData = data.in ? JSON.stringify(data.in, null, 2) : '{}';
    html += `<div class="editor-field"><div class="editor-label">Input Parameters (JSON)</div>
      <textarea class="editor-json" id="edit_in">${esc(inData)}</textarea>
      <div class="editor-error" id="edit_in_err"></div></div>`;
  } else if (type === 'Actor') {
    const inData = data.in ? JSON.stringify(data.in, null, 2) : '{}';
    const domainData = data.domain ? JSON.stringify(data.domain, null, 2) : '{}';
    html += `<div class="editor-field"><div class="editor-label">Actor ID</div>
      <input class="editor-input" id="edit_actorId" value="${esc(data.actorId || data.role || '')}"></div>`;
    html += `<div class="editor-field"><div class="editor-label">Task Title</div>
      <input class="editor-input" id="edit_title" value="${esc(data.title || data.meta?.title || node.id)}"></div>`;
    html += `<div class="editor-field"><div class="editor-label">Input (JSON)</div>
      <textarea class="editor-json" id="edit_in">${esc(inData)}</textarea>
      <div class="editor-error" id="edit_in_err"></div></div>`;
    html += `<div class="editor-field"><div class="editor-label">Domain (JSON)</div>
      <textarea class="editor-json" id="edit_domain">${esc(domainData)}</textarea>
      <div class="editor-error" id="edit_domain_err"></div></div>`;
    html += `<div class="editor-field"><div class="editor-label">Tools (JSON)</div>
      <textarea class="editor-json" id="edit_tools">${esc(JSON.stringify(data.tools || [], null, 2))}</textarea>
      <div class="editor-error" id="edit_tools_err"></div></div>`;
    html += `<div class="editor-field"><div class="editor-label">Tool Scope (JSON or mode string)</div>
      <textarea class="editor-json" id="edit_toolScope">${esc(typeof data.toolScope === 'string' ? data.toolScope : JSON.stringify(data.toolScope || {}, null, 2))}</textarea>
      <div class="editor-error" id="edit_toolScope_err"></div></div>`;
  } else if (type === 'Subflow') {
    const inData = data.in ? JSON.stringify(data.in, null, 2) : '{}';
    const optionsData = data.options ? JSON.stringify(data.options, null, 2) : '{}';
    html += `<div class="editor-field"><div class="editor-label">Workflow Ref</div>
      <input class="editor-input" id="edit_workflowRef" value="${esc(data.workflowRef || data.workflowPath || data.subflow || data.workflow?.name || data.workflow || '')}"></div>`;
    html += `<div class="editor-field"><div class="editor-label">Alias</div>
      <input class="editor-input" id="edit_alias" value="${esc(data.alias || data.childRunAlias || '')}"></div>`;
    html += `<div class="editor-field"><div class="editor-label">Input (JSON)</div>
      <textarea class="editor-json" id="edit_in">${esc(inData)}</textarea>
      <div class="editor-error" id="edit_in_err"></div></div>`;
    html += `<div class="editor-field"><div class="editor-label">Options (JSON)</div>
      <textarea class="editor-json" id="edit_options">${esc(optionsData)}</textarea>
      <div class="editor-error" id="edit_options_err"></div></div>`;
    html += `<div class="editor-field"><div class="editor-label">Tools (JSON)</div>
      <textarea class="editor-json" id="edit_tools">${esc(JSON.stringify(data.tools || [], null, 2))}</textarea>
      <div class="editor-error" id="edit_tools_err"></div></div>`;
    html += `<div class="editor-field"><div class="editor-label">Tool Scope (JSON or mode string)</div>
      <textarea class="editor-json" id="edit_toolScope">${esc(typeof data.toolScope === 'string' ? data.toolScope : JSON.stringify(data.toolScope || {}, null, 2))}</textarea>
      <div class="editor-error" id="edit_toolScope_err"></div></div>`;
  } else if (type === 'Review') {
    const gateData = data.gate || data.acceptance ? JSON.stringify(data.gate || data.acceptance, null, 2) : '{}';
    const reviewData = data.review ? JSON.stringify(data.review, null, 2) : '{}';
    html += `<div class="editor-field"><div class="editor-label">Source Expression</div>
      <input class="editor-input" id="edit_source" value="${esc(data.source || data.envelope || '=_resultEnvelope')}"></div>`;
    html += `<div class="editor-field"><div class="editor-label">Gate (JSON)</div>
      <textarea class="editor-json" id="edit_gate">${esc(gateData)}</textarea>
      <div class="editor-error" id="edit_gate_err"></div></div>`;
    html += `<div class="editor-field"><div class="editor-label">Verifier Review Input (JSON)</div>
      <textarea class="editor-json" id="edit_review">${esc(reviewData)}</textarea>
      <div class="editor-error" id="edit_review_err"></div></div>`;
  } else if (type === 'GraphPatch') {
    const patchData = JSON.stringify(data.patch || data.graphPatch || { operations: data.operations || [] }, null, 2);
    html += `<div class="editor-field"><div class="editor-label">Patch (JSON)</div>
      <textarea class="editor-json" id="edit_patch">${esc(patchData)}</textarea>
      <div class="editor-error" id="edit_patch_err"></div></div>`;
    html += `<div class="editor-field"><div class="editor-label">Result Envelope (JSON)</div>
      <textarea class="editor-json" id="edit_resultEnvelope">${esc(JSON.stringify(data.resultEnvelope || {}, null, 2))}</textarea>
      <div class="editor-error" id="edit_resultEnvelope_err"></div></div>`;
  } else if (type === 'Set') {
    html += `<div class="editor-field"><div class="editor-label">Target Variable</div>
      <input class="editor-input" id="edit_target" value="${esc(data.target || '')}"></div>`;
    html += `<div class="editor-field"><div class="editor-label">Value (expression)</div>
      <input class="editor-input" id="edit_value" value="${esc(String(data.value || ''))}"></div>`;
  } else if (type === 'Write') {
    html += `<div class="editor-field"><div class="editor-label">Target Path</div>
      <input class="editor-input" id="edit_target" value="${esc(data.target || '')}"></div>`;
    html += `<div class="editor-field"><div class="editor-label">Value (expression)</div>
      <input class="editor-input" id="edit_value" value="${esc(String(data.value || ''))}"></div>`;
  } else if (type === 'Download') {
    const src = typeof data.source === 'object' ? JSON.stringify(data.source, null, 2) : String(data.source || '');
    html += `<div class="editor-field"><div class="editor-label">Source URL / Config</div>
      <textarea class="editor-json" id="edit_source">${esc(src)}</textarea></div>`;
    if (data.target != null) {
      html += `<div class="editor-field"><div class="editor-label">Target Path</div>
        <input class="editor-input" id="edit_target" value="${esc(data.target || '')}"></div>`;
    }
  } else if (type === 'Loop') {
    if (data.while) {
      html += `<div class="editor-field"><div class="editor-label">While Expression</div>
        <input class="editor-input" id="edit_while" value="${esc(data.while)}"></div>`;
      html += `<div class="editor-field"><div class="editor-label">Max Iterations</div>
        <input class="editor-input" id="edit_maxIterations" value="${esc(String(data.maxIterations || ''))}" type="number"></div>`;
    } else {
      html += `<div class="editor-field"><div class="editor-label">Source Expression</div>
        <input class="editor-input" id="edit_source" value="${esc(data.source || '')}"></div>`;
    }
  } else {
    // Generic: show full step JSON
    const stepJson = JSON.stringify(data, null, 2);
    html += `<div class="editor-field"><div class="editor-label">Step Data (JSON)</div>
      <textarea class="editor-json" id="edit_raw" style="min-height:200px">${esc(stepJson)}</textarea>
      <div class="editor-error" id="edit_raw_err"></div></div>`;
  }

  // Variable overrides section
  html += `<div class="editor-field" style="margin-top:12px; border-top:1px solid var(--border); padding-top:10px;">
    <div class="editor-label">Variable Overrides (optional)</div>
    <textarea class="editor-json" id="edit_overrides" placeholder='{ "$varName": "newValue" }'>{}</textarea>
    <div class="editor-hint">Override pipeline variables before re-running. Use $varName keys.</div>
    <div class="editor-error" id="edit_overrides_err"></div>
  </div>`;

  $('editorBody').innerHTML = html;
  $('editorOverlay').classList.add('show');
}

function closeEditor() {
  $('editorOverlay').classList.remove('show');
  _editorNode = null;
}

function editorRerun() {
  if (!_editorNode) return;
  const node = _editorNode;

  // Collect overrides
  let overrides = {};
  const overridesEl = $('edit_overrides');
  if (overridesEl) {
    try {
      overrides = JSON.parse(overridesEl.value || '{}');
      const errEl = $('edit_overrides_err');
      if (errEl) errEl.style.display = 'none';
    } catch (e) {
      const errEl = $('edit_overrides_err');
      if (errEl) { errEl.textContent = 'Invalid JSON: ' + e.message; errEl.style.display = 'block'; }
      return;
    }
  }

  // Collect edited step data and merge into overrides
  const type = node.type;
  const data = HOST_CONTROLLED ? cloneRuntimeValue(node.data || {}) : (node.data || {});

  if (type === 'LLM') {
    const model = $('edit_model')?.value;
    const maxTokens = $('edit_max_tokens')?.value;
    const msgsEl = $('edit_messages');
    if (msgsEl) {
      try { JSON.parse(msgsEl.value); } catch (e) {
        const err = $('edit_messages_err');
        if (err) { err.textContent = 'Invalid JSON: ' + e.message; err.style.display = 'block'; }
        return;
      }
    }
    // Update step data in memory for visual accuracy
    if (model && data.in) data.in.model = model;
    if (data.model !== undefined && model) data.model = model;
    if (maxTokens && data.in) data.in.max_tokens = parseInt(maxTokens);
    if (msgsEl && data.in) { try { data.in.messages = JSON.parse(msgsEl.value); } catch {} }
    try {
      if ($('edit_tools')) data.tools = JSON.parse($('edit_tools').value || '[]');
      if ($('edit_toolScope')) data.toolScope = parseJsonOrModeString($('edit_toolScope').value, {});
    } catch (e) {
      const err = $('edit_toolScope_err') || $('edit_tools_err');
      if (err) { err.textContent = 'Invalid JSON: ' + e.message; err.style.display = 'block'; }
      return;
    }
  } else if ((type === 'Service' || type === 'API' || type === 'Component') && $('edit_in')) {
    try {
      const newIn = JSON.parse($('edit_in').value);
      data.in = newIn;
    } catch (e) {
      const err = $('edit_in_err');
      if (err) { err.textContent = 'Invalid JSON: ' + e.message; err.style.display = 'block'; }
      return;
    }
  } else if (type === 'Actor' && $('edit_in')) {
    try {
      data.in = JSON.parse($('edit_in').value);
      data.domain = $('edit_domain')?.value ? JSON.parse($('edit_domain').value) : {};
      if ($('edit_actorId')) data.actorId = $('edit_actorId').value;
      if ($('edit_title')) data.title = $('edit_title').value;
      if ($('edit_tools')) data.tools = JSON.parse($('edit_tools').value || '[]');
      if ($('edit_toolScope')) data.toolScope = parseJsonOrModeString($('edit_toolScope').value, {});
    } catch (e) {
      const err = $('edit_toolScope_err') || $('edit_tools_err') || $('edit_domain_err') || $('edit_in_err');
      if (err) { err.textContent = 'Invalid JSON: ' + e.message; err.style.display = 'block'; }
      return;
    }
  } else if (type === 'Subflow' && $('edit_in')) {
    try {
      data.in = JSON.parse($('edit_in').value);
      data.options = $('edit_options')?.value ? JSON.parse($('edit_options').value) : {};
      if ($('edit_workflowRef')) data.workflowRef = $('edit_workflowRef').value;
      if ($('edit_alias')) data.alias = $('edit_alias').value;
      if ($('edit_tools')) data.tools = JSON.parse($('edit_tools').value || '[]');
      if ($('edit_toolScope')) data.toolScope = parseJsonOrModeString($('edit_toolScope').value, {});
    } catch (e) {
      const err = $('edit_toolScope_err') || $('edit_tools_err') || $('edit_options_err') || $('edit_in_err');
      if (err) { err.textContent = 'Invalid JSON: ' + e.message; err.style.display = 'block'; }
      return;
    }
  } else if (type === 'Review' && $('edit_gate')) {
    try {
      if ($('edit_source')) data.source = $('edit_source').value;
      data.gate = JSON.parse($('edit_gate').value || '{}');
      data.review = JSON.parse($('edit_review')?.value || '{}');
    } catch (e) {
      const err = $('edit_review_err') || $('edit_gate_err');
      if (err) { err.textContent = 'Invalid JSON: ' + e.message; err.style.display = 'block'; }
      return;
    }
  } else if (type === 'GraphPatch' && $('edit_patch')) {
    try {
      data.patch = JSON.parse($('edit_patch').value || '{}');
      data.resultEnvelope = JSON.parse($('edit_resultEnvelope')?.value || '{}');
    } catch (e) {
      const err = $('edit_resultEnvelope_err') || $('edit_patch_err');
      if (err) { err.textContent = 'Invalid JSON: ' + e.message; err.style.display = 'block'; }
      return;
    }
  } else if ((type === 'Set' || type === 'Write') && $('edit_target')) {
    data.target = $('edit_target').value;
    data.value = $('edit_value')?.value || data.value;
  } else if (type === 'Download' && $('edit_source')) {
    try {
      data.source = JSON.parse($('edit_source').value);
    } catch { data.source = $('edit_source').value; }
    if ($('edit_target')) data.target = $('edit_target').value;
  } else if (type === 'Loop') {
    if ($('edit_while')) data.while = $('edit_while').value;
    if ($('edit_maxIterations')) data.maxIterations = parseInt($('edit_maxIterations').value);
    if ($('edit_source')) data.source = $('edit_source').value;
  } else if ($('edit_raw')) {
    try {
      const raw = JSON.parse($('edit_raw').value);
      Object.assign(data, raw);
    } catch (e) {
      const err = $('edit_raw_err');
      if (err) { err.textContent = 'Invalid JSON: ' + e.message; err.style.display = 'block'; }
      return;
    }
  }

  closeEditor();

  if (HOST_CONTROLLED) {
    requestRuntimeDebugAction('edit-rerun', {
      nodeId: node.id,
      nodeData: data,
      overrides
    });
    return;
  }

  // Notify parent of edits
  window.parent.postMessage({
    type: 'nodeEdited',
    nodeId: node.id,
    nodeData: data,
    overrides
  }, '*');

  // Re-run
  rerunFromStep(node.id, overrides);
}

// ===== PostMessage API =====
window.addEventListener('message', (e) => {
  if (!e.data?.type) return;
  if (HOST_CONTROLLED) {
    if (e.source !== window.parent || e.origin !== window.location.origin) return;
    if (e.data.source !== RUNTIME_HOST_SOURCE || !HOST_MESSAGE_TYPES.has(e.data.type)) return;
  }
  switch (e.data.type) {
    case 'loadWorkflow':
      parseWorkflow(e.data.data, { label: e.data.workflowName || e.data.title || null });
      if (HOST_CONTROLLED) requestAnimationFrame(() => fitWorkflowView());
      break;
    case 'loadBundle':
      if (e.data.bundlePath || e.data.zipBase64) {
        loadVflowBundleFromBody({ bundlePath: e.data.bundlePath, zipBase64: e.data.zipBase64 }, e.data.label || e.data.bundlePath || 'bundle')
          .catch((err) => toast('Open bundle failed: ' + err.message));
      }
      break;
    case 'highlightNode':
      state.selectedNodeId = e.data.nodeId;
      render();
      const el = $(`node-${e.data.nodeId}`);
      if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
      break;
    case 'updateNodeStatus': {
      setNodeStatus(e.data.nodeId, e.data.status, e.data.runtimeStatus);
      break;
    }
    case 'clearStatus':
      for (const n of state.nodes) n.status = null;
      _lastCheckpoint = null;
      render();
      break;
    case 'setCheckpoint':
      _lastCheckpoint = e.data.checkpoint;
      _currentRunID = e.data.runID || _currentRunID;
      saveCheckpointToStorage();
      break;
    case 'setBreakpoints':
      _runtimeBreakpoints = new Set(
        (Array.isArray(e.data.breakpoints) ? e.data.breakpoints : [])
          .map(id => String(id || ''))
          .filter(id => state.nodes.some(node => node.id === id))
      );
      render();
      break;
    case 'setRuntimeControls':
      setRuntimeControls(e.data.runtime || {});
      break;
    case 'setDebugCapabilities':
      setRuntimeDebugCapabilities(e.data.capabilities);
      break;
    case 'layoutWorkflow':
      autoLayout({ preserveManual: true }); render(); break;
    case 'setLayoutDensity':
      if (Number.isFinite(e.data.value) && e.data.value >= 0 && e.data.value <= 100) setLayoutDensity(e.data.value / 100, { relayout: true, preserveManual: true, persist: true });
      break;
    case 'exportWorkflowPNG':
      downloadPNG(); break;
    case 'fitWorkflow':
      fitWorkflowView();
      break;
    case 'rerunFromStep':
      rerunFromStep(e.data.stepId || e.data.nodeId, e.data.overrides || {});
      break;
    case 'editNode': {
      const node = state.nodes.find(n => n.id === (e.data.nodeId || e.data.stepId));
      if (node) openEditor(node);
      break;
    }
    case 'openAcceptance': {
      const node = state.nodes.find(n => n.id === (e.data.nodeId || e.data.stepId));
      if (node) openAcceptanceModal(node);
      break;
    }
    case 'setPendingReview':
      _pendingReviewState = e.data.reviewState || null;
      break;
    case 'saveBundle':
      saveVflowBundle({
        saveAs: !!e.data.saveAs,
        bundleName: e.data.bundleName,
      }).catch((err) => toast('Save bundle failed: ' + err.message));
      break;
  }
});

// ===== Init =====
initDrag();
syncLayoutControls();
if (!HOST_CONTROLLED) loadBundleSidebarState();
else {
  state.sidebarHidden = true;
  syncBundleSidebarVisibility();
  setRuntimeDebugCapabilities([]);
}
$('layoutDensity')?.addEventListener('input', (e) => {
  setLayoutDensity(e?.target?.value, { relayout: true, preserveManual: true, persist: true });
});
if (HOST_CONTROLLED) postRuntimeMessage({ type: RUNTIME_READY });
else window.parent.postMessage({ type: 'ready' }, '*');
if (!HOST_CONTROLLED) loadWorkflowFromLocation();

/* ===== View input =====
 * Pan is the canvas-wrap's native scroll (the content layers are inside it, so
 * scrolling can never detach an edge from its port). Zoom is Ctrl/Cmd + wheel
 * plus the HUD, anchored on the pointer.
 */
$('canvasWrap')?.addEventListener('wheel', (e) => {
  if (!e.ctrlKey && !e.metaKey) return;
  if (!state.nodes.length) return;
  e.preventDefault();
  const wrap = $('canvasWrap');
  const rect = wrap.getBoundingClientRect();
  zoomAtPoint(e.deltaY < 0 ? 1.1 : 1 / 1.1, { x: e.clientX - rect.left, y: e.clientY - rect.top });
}, { passive: false });

/* The host can resize the frame without the window resizing at all — the
 * Assistant dock opening/closing and the inline Task pane both do exactly
 * that. Observe the viewport itself so fit tracks the space actually left. */
let _viewportResizeRAF = null;
const scheduleViewportResize = () => {
  if (_viewportResizeRAF) return;
  _viewportResizeRAF = requestAnimationFrame(() => {
    _viewportResizeRAF = null;
    handleViewportResize();
  });
};
if (typeof ResizeObserver === 'function' && $('canvasWrap')) {
  new ResizeObserver(scheduleViewportResize).observe($('canvasWrap'));
}
window.addEventListener('resize', scheduleViewportResize);
</script>
</body>
</html>
