Visual stepping debugger
Run a graph one node at a time. Yellow ring = paused; green = executed; red = breakpoint. The inspector shows live inputs/outputs and per-node timing. Debug your AI workflow on the canvas — like Chrome DevTools, but for nodes.
import { useEffect, useRef, useState } from 'react'
import { XenolithGraph, XenolithPanel, XenolithButton, useEditor } from '@xenolithengine/graph-react'
import { StepDebugger, type StepRecord, type XenolithEditor, type StepExecutor } from '@xenolithengine/graph-editor'
import type { Node, NodeId, NodeSchema } from '@xenolithengine/graph-core'
import { TEMPLATE_INSTANCE_TYPE } from '@xenolithengine/graph-core'
import { DemoStage } from '../Layout.js'
// Visual stepping debugger for graph execution. Click "Start" → debugger pauses on the first
// node and yellow-rings it. "Step" executes one node and pauses on the next. "Continue" runs
// to the end (or next breakpoint). Click any node while debugging to toggle a breakpoint.
//
// Single chain (a + b) × c piped through an Identity probe → Display. Add + Multiply are
// wrapped in macro "Compute"; Identity is wrapped in template "Probe". The two wrappings
// SHARE a boundary (Multiply.product → Identity.in) — this used to trigger an editor bug
// in #setMacroCollapsed (stale proxyMap.externalNode); the fix resolves the current external
// endpoint at toggle time. Toggle the macro to step Add + Multiply individually or as one Macro.
//
// Canon: graph SETUP lives in onReady (buildDemoGraph). Everything else — debugger instance,
// breakpoint DOM dots, animated-edge tracking, status mirrors, editor.on subscriptions — lives
// in DebuggerPanel via useEditor().
const SCHEMAS: NodeSchema[] = [
{ type: 'Const', title: 'Const', category: 'data',
pins: [{ kind: 'data', direction: 'out', type: 'number', label: 'Out' }],
widgets: [{ id: 'value', key: 'value', type: 'number', label: 'value', min: -999, max: 999, step: 1 }] },
{ type: 'Add', title: 'Add', category: 'logic',
pins: [
{ kind: 'data', direction: 'in', type: 'number', label: 'a' },
{ kind: 'data', direction: 'in', type: 'number', label: 'b' },
{ kind: 'data', direction: 'out', type: 'number', label: 'sum' },
] },
{ type: 'Multiply', title: 'Multiply', category: 'logic',
pins: [
{ kind: 'data', direction: 'in', type: 'number', label: 'a' },
{ kind: 'data', direction: 'in', type: 'number', label: 'b' },
{ kind: 'data', direction: 'out', type: 'number', label: 'product' },
] },
{ type: 'Identity', title: 'Identity', category: 'utility',
pins: [
{ kind: 'data', direction: 'in', type: 'number', label: 'in' },
{ kind: 'data', direction: 'out', type: 'number', label: 'out' },
] },
{ type: 'Display', title: 'Display', category: 'utility',
pins: [{ kind: 'data', direction: 'in', type: 'number', label: 'In' }] },
]
function evalNode(node: Node, inputs: Map<string, unknown>): Map<string, unknown> {
if (node.type === 'Const') {
return new Map([[node.pins[0]!.id, Number((node.state as { value?: number }).value ?? 0)]])
}
if (node.type === 'Add' || node.type === 'Multiply') {
const vals = [...inputs.values()].map(Number)
const r = node.type === 'Add' ? vals.reduce((s, v) => s + v, 0) : vals.reduce((s, v) => s * v, 1)
return new Map([[node.pins.find((p) => p.direction === 'out')!.id, r]])
}
if (node.type === 'Identity') {
const v = [...inputs.values()][0]
const outPin = node.pins.find((p) => p.direction === 'out')
if (outPin && v !== undefined) return new Map([[outPin.id, v]])
}
return new Map()
}
/** Build the executor as a closure over `editor` so the Macro case can look up its members.
* Two execution modes:
* - COLLAPSED macro: members are hidden, macro is the only step → walk the sub-graph
* internally and surface the last member's output on the macro's proxy out-pin.
* - EXPANDED macro: members already executed individually (with real inputs); use peek()
* to surface the LAST member's outputs as the macro's row — no double execution, no
* fake inputs, the trace just shows "Compute = 20" right after its internals. */
function makeExecutor(editor: XenolithEditor): StepExecutor {
return ({ node, inputs, peek }) => {
if (node.type === 'Macro') {
const state = node.state as { collapsed?: boolean; members?: NodeId[] }
const memberIds = state.members ?? []
const lastId = memberIds[memberIds.length - 1]
if (!state.collapsed) {
const out = lastId ? peek(lastId as NodeId) : undefined
if (!out) return new Map()
const macroOutPin = node.pins.find((p) => p.direction === 'out')
const value = [...out.values()][0]
if (macroOutPin && value !== undefined) return new Map([[macroOutPin.id, value]])
return out
}
const subValues = new Map<string, Map<string, unknown>>()
const subSnap = editor.getGraphReadonly()
const subNodeById = new Map(subSnap.nodes.map((n) => [String(n.id), n]))
for (const mid of memberIds) {
const member = subNodeById.get(String(mid))
if (!member) continue
const memberInputs = new Map<string, unknown>()
for (const e of subSnap.edges) {
if (e.to.node !== mid) continue
if (memberIds.includes(e.from.node as NodeId)) {
const upstream = subValues.get(e.from.node)
const v = upstream?.get(e.from.pin as string)
if (v !== undefined) memberInputs.set(e.to.pin as string, v)
} else {
const v = [...inputs.values()].shift()
if (v !== undefined) memberInputs.set(e.to.pin as string, v)
}
}
subValues.set(mid, evalNode(member as Node, memberInputs))
}
const lastOut = lastId ? subValues.get(lastId) : null
const macroOutPin = node.pins.find((p) => p.direction === 'out')
if (!macroOutPin || !lastOut) return new Map()
const value = [...lastOut.values()][0]
return new Map([[macroOutPin.id, value]])
}
if (node.type === TEMPLATE_INSTANCE_TYPE) {
const v = [...inputs.values()][0]
const outPin = node.pins.find((p) => p.direction === 'out')
if (outPin && v !== undefined) return new Map([[outPin.id, v]])
return new Map()
}
if (node.type === 'Display') return new Map()
return evalNode(node, inputs)
}
}
function buildDemoGraph(editor: XenolithEditor): void {
for (const s of SCHEMAS) editor.registry.register(s)
const a = editor.insertNode('Const', { x: 0, y: 0 })!
const b = editor.insertNode('Const', { x: 0, y: 140 })!
const c = editor.insertNode('Const', { x: 0, y: 280 })!
const add = editor.insertNode('Add', { x: 320, y: 60 })!
const mul = editor.insertNode('Multiply', { x: 640, y: 160 })!
const idn = editor.insertNode('Identity', { x: 880, y: 160 })!
const out = editor.insertNode('Display', { x: 1100, y: 160 })!
editor.setWidgetValue(a.id, 'value', 2)
editor.setWidgetValue(b.id, 'value', 3)
editor.setWidgetValue(c.id, 'value', 4)
const out0 = (n: typeof a) => n.pins.find((p) => p.direction === 'out')!.id
const inAt = (n: typeof add, i: number) => n.pins.filter((p) => p.direction === 'in')[i]!.id
editor.addEdge({ id: crypto.randomUUID(), from: { node: a.id, pin: out0(a) }, to: { node: add.id, pin: inAt(add, 0) } } as never)
editor.addEdge({ id: crypto.randomUUID(), from: { node: b.id, pin: out0(b) }, to: { node: add.id, pin: inAt(add, 1) } } as never)
editor.addEdge({ id: crypto.randomUUID(), from: { node: add.id, pin: out0(add) }, to: { node: mul.id, pin: inAt(mul, 0) } } as never)
editor.addEdge({ id: crypto.randomUUID(), from: { node: c.id, pin: out0(c) }, to: { node: mul.id, pin: inAt(mul, 1) } } as never)
editor.addEdge({ id: crypto.randomUUID(), from: { node: mul.id, pin: out0(mul) }, to: { node: idn.id, pin: inAt(idn, 0) } } as never)
editor.addEdge({ id: crypto.randomUUID(), from: { node: idn.id, pin: out0(idn) }, to: { node: out.id, pin: inAt(out, 0) } } as never)
editor.createMacroFromSelection([add.id, mul.id], 'Compute')
editor.createTemplateFromSelection([idn.id], 'Probe')
editor.view.fitView({ padding: 80 })
}
function findMacroId(editor: XenolithEditor): NodeId | null {
for (const n of editor.getGraphReadonly().nodes) if (n.type === 'Macro') return n.id as NodeId
return null
}
interface PausedInfo { nodeType: string; nodeId: string; inputs: Array<[string, unknown]> }
function DebuggerPanel() {
const editor = useEditor()
const debuggerRef = useRef<StepDebugger | null>(null)
const macroIdRef = useRef<NodeId | null>(null)
const breakpointDotsRef = useRef<Map<string, HTMLDivElement>>(new Map())
const animatedEdgesRef = useRef<Set<string>>(new Set())
const [status, setStatus] = useState<'idle' | 'paused' | 'running' | 'finished' | 'error'>('idle')
const [paused, setPaused] = useState<PausedInfo | null>(null)
const [history, setHistory] = useState<StepRecord[]>([])
const [breakpoints, setBreakpoints] = useState<Set<string>>(new Set())
const [macroExpanded, setMacroExpanded] = useState(false)
const [busy, setBusy] = useState(false)
const [planned, setPlanned] = useState<string[]>([])
const setNodeStatus = (id: NodeId, s: 'idle' | 'running' | 'ok' | 'error'): void => {
editor.setNodeStatus(id, s)
const map = ((window as unknown as { __xenoNodeStatus?: Record<string, string> }).__xenoNodeStatus ??= {})
if (s === 'idle') delete map[String(id)]
else map[String(id)] = s
}
useEffect(() => {
const macroId = findMacroId(editor)
macroIdRef.current = macroId
const dbg = new StepDebugger(editor.readGraph(), makeExecutor(editor))
debuggerRef.current = dbg
// Test surface — Playwright reads/drives the debugger via this handle.
;(window as unknown as { __xenoDebug?: { editor: XenolithEditor; debugger: StepDebugger; macroId: NodeId | null } }).__xenoDebug = {
editor, debugger: dbg, macroId,
}
const offs: Array<() => void> = []
dbg.on('paused', ({ nodeId, node, inputs }) => {
setNodeStatus(nodeId, 'running')
clearAnimatedEdges(editor, animatedEdgesRef.current)
for (const e of editor.getGraphReadonly().edges) {
if (e.to.node !== nodeId) continue
editor.setEdgeOptions(e.id as never, { animated: true })
animatedEdgesRef.current.add(String(e.id))
}
syncAnimatedMirror(animatedEdgesRef.current)
setPaused({
nodeType: node.type,
nodeId,
inputs: [...inputs.entries()].map(([pinId, v]) => {
const pin = node.pins.find((p) => p.id === pinId)
return [pin?.label ?? pinId, v]
}),
})
setStatus('paused')
})
dbg.on('stepped', (r) => {
setNodeStatus(r.nodeId, 'ok')
for (const n of editor.getGraphReadonly().nodes) {
if (n.type !== 'Macro') continue
const members = (((n.state ?? {}) as { members?: string[] }).members ?? []) as string[]
if (members.length === 0) continue
if (!members.includes(String(r.nodeId))) continue
const statuses = (window as unknown as { __xenoNodeStatus?: Record<string, string> }).__xenoNodeStatus ?? {}
const allOk = members.every((m) => statuses[m] === 'ok')
if (allOk) setNodeStatus(n.id as NodeId, 'ok')
}
setHistory([...dbg.history])
})
dbg.on('finished', () => { setStatus('finished'); setPaused(null); clearAnimatedEdges(editor, animatedEdgesRef.current) })
dbg.on('error', ({ nodeId }) => { setNodeStatus(nodeId, 'error'); setStatus('error'); clearAnimatedEdges(editor, animatedEdgesRef.current) })
offs.push(editor.on('node:click', ({ nodeId }) => {
const d = debuggerRef.current
if (!d || d.status === 'idle' || d.status === 'finished') return
const isOn = d.toggleBreakpoint(nodeId)
if (isOn) addBreakpointDot(editor, breakpointDotsRef.current, nodeId)
else removeBreakpointDot(breakpointDotsRef.current, nodeId)
setBreakpoints(new Set(d.breakpoints))
}))
offs.push(editor.on('viewport:changed', () => repositionBreakpointDots(editor, breakpointDotsRef.current)))
offs.push(editor.on('node:moved', () => repositionBreakpointDots(editor, breakpointDotsRef.current)))
return () => {
for (const off of offs) off()
dbg.stop()
clearAnimatedEdges(editor, animatedEdgesRef.current)
for (const [, dot] of breakpointDotsRef.current) dot.remove()
breakpointDotsRef.current.clear()
debuggerRef.current = null
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editor])
const clearStatuses = (): void => {
for (const n of editor.getGraphReadonly().nodes) setNodeStatus(n.id as NodeId, 'idle')
for (const id of breakpoints) setNodeStatus(id as never, 'error')
}
const run = async (fn: () => Promise<void>): Promise<void> => {
if (busy) return
setBusy(true)
try { await fn() } finally { setBusy(false) }
}
const start = (): Promise<void> => run(async () => {
clearStatuses()
setHistory([])
setPaused(null)
setStatus('running')
await debuggerRef.current!.start()
const plannedSnap = editor.getGraphReadonly()
const plannedNodeById = new Map(plannedSnap.nodes.map((nn) => [String(nn.id), nn]))
setPlanned(debuggerRef.current!.order.map((id) => {
const n = plannedNodeById.get(String(id))
return n ? `${n.type}` : String(id).slice(0, 6)
}))
})
const step = (): Promise<void> => run(async () => { await debuggerRef.current!.step() })
const cont = (): Promise<void> => run(async () => { setStatus('running'); await debuggerRef.current!.continue() })
const stop = (): void => {
debuggerRef.current!.stop()
setStatus('idle')
setPaused(null)
setHistory([])
setPlanned([])
clearStatuses()
}
const toggleMacro = async (): Promise<void> => {
const id = macroIdRef.current, dbg = debuggerRef.current
if (!id || !dbg) return
const wasActive = dbg.status === 'paused' || dbg.status === 'running'
const visited = new Set<string>(dbg.history.map((r) => String(r.nodeId)))
const macroMembers = ((editor.getGraphReadonly().nodes.find((n) => n.id === id)?.state as { members?: string[] } | undefined)?.members ?? [])
if (macroExpanded) {
if (macroMembers.length > 0 && macroMembers.every((m) => visited.has(String(m)))) visited.add(String(id))
} else {
visited.delete(String(id))
}
if (!wasActive) dbg.stop()
if (macroExpanded) editor.collapseMacro(id)
else editor.expandMacro(id)
setMacroExpanded(!macroExpanded)
if (!wasActive) {
setStatus('idle'); setHistory([]); setPaused(null); setPlanned([])
clearStatuses()
return
}
await dbg.start()
while (dbg.status === 'paused' && dbg.currentNodeId && visited.has(String(dbg.currentNodeId))) {
dbg.advance()
}
setStatus(dbg.status as never)
const expandSnap = editor.getGraphReadonly()
const expandNodeById = new Map(expandSnap.nodes.map((nn) => [String(nn.id), nn]))
setPlanned(dbg.order.map((nid) => expandNodeById.get(String(nid))?.type ?? '?'))
}
const canStart = status === 'idle' || status === 'finished' || status === 'error'
const canStep = status === 'paused'
const canContinue = status === 'paused'
const canStop = status !== 'idle'
return (
<>
<XenolithPanel position="top-left" style={{ display: 'flex', flexDirection: 'column', gap: 8, maxWidth: 320, padding: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={dotStyle(status)} />
<strong style={{ fontSize: 12, color: 'var(--xeno-text)' }}>Debugger {statusLabel(status)}</strong>
</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<XenolithButton active={canStart} disabled={!canStart || busy} onClick={() => void start()}>▶ Start</XenolithButton>
<XenolithButton disabled={!canStep || busy} onClick={() => void step()}>⤵ Step</XenolithButton>
<XenolithButton disabled={!canContinue || busy} onClick={() => void cont()}>⏩ Continue</XenolithButton>
<XenolithButton disabled={!canStop || busy} onClick={stop}>■ Stop</XenolithButton>
</div>
<XenolithButton onClick={toggleMacro} disabled={busy}>
{macroExpanded ? '⟲ Collapse macro' : '⤢ Expand macro'}
</XenolithButton>
<div style={hintStyle}>
Graph: <strong>(2 + 3) × 4 = 20</strong> piped through a template-wrapped Identity
into Display. Add + Multiply are inside macro <em>Compute</em>; Identity is inside
template <em>Probe</em>. Toggle the macro to step its members individually. Templates
are always one step. Click any node while debugging to toggle a breakpoint (red).
Yellow = paused, green = executed.
</div>
</XenolithPanel>
{paused && (
<XenolithPanel position="top-right" style={{ minWidth: 240, maxWidth: 320, padding: 10 }}>
<div style={{ fontSize: 10, color: 'var(--xeno-muted)', textTransform: 'uppercase', letterSpacing: 0.08 }}>Paused on</div>
<div style={{ fontSize: 14, color: 'var(--xeno-text)', fontWeight: 600 }}>{paused.nodeType}</div>
<div style={{ fontSize: 10, color: 'var(--xeno-muted)', marginTop: 8 }}>Inputs</div>
{paused.inputs.length === 0
? <div style={mutedRow}>— (no incoming values)</div>
: paused.inputs.map(([k, v], i) => (
<div key={i} style={kvRow}>
<span style={{ color: 'var(--xeno-muted)' }}>{k}</span>
<span style={{ color: 'var(--xeno-text)', fontFamily: 'ui-monospace, monospace' }}>{JSON.stringify(v)}</span>
</div>
))}
</XenolithPanel>
)}
<XenolithPanel position="bottom-left" style={{ minWidth: 280, maxWidth: 360, maxHeight: 260, padding: 8, overflow: 'auto' }}>
{planned.length > 0 && (
<>
<div style={{ fontSize: 10, color: 'var(--xeno-muted)', marginBottom: 4 }}>Planned walk ({planned.length} steps)</div>
<div style={{ fontSize: 10, color: 'var(--xeno-text)', fontFamily: 'ui-monospace, monospace', marginBottom: 8, lineHeight: 1.5 }}>
{planned.map((t, i) => <span key={i}>{i + 1}. {t}{i < planned.length - 1 ? ' → ' : ''}</span>)}
</div>
</>
)}
<div style={{ fontSize: 10, color: 'var(--xeno-muted)', marginBottom: 4 }}>Trace</div>
{history.length === 0
? <div style={mutedRow}>Press <strong>▶ Start</strong>, then <strong>⤵ Step</strong>.</div>
: history.map((r, i) => (
<div key={i} style={traceRow}>
<span style={{ color: 'var(--xeno-accent, #FCB400)' }}>{String(i + 1).padStart(2, '0')}</span>
<span style={{ color: 'var(--xeno-text)' }}>{r.type}</span>
<span style={{ color: 'var(--xeno-muted)' }}>{r.durationMs.toFixed(2)}ms</span>
<span style={{ color: 'var(--xeno-text)', fontFamily: 'ui-monospace, monospace' }}>
{[...r.outputs.values()].map((v) => JSON.stringify(v)).join(', ') || '—'}
</span>
</div>
))}
</XenolithPanel>
</>
)
}
/** Showcase: a visual stepping debugger over an actual graph computation. */
export function StepDebuggerDemo() {
return (
<DemoStage>
<XenolithGraph className="xeno" resizeToWindow={false} onReady={buildDemoGraph}>
<DebuggerPanel />
</XenolithGraph>
</DemoStage>
)
}
function clearAnimatedEdges(editor: XenolithEditor, animated: Set<string>): void {
for (const id of animated) editor.setEdgeOptions(id as never, { animated: false })
animated.clear()
syncAnimatedMirror(animated)
}
function syncAnimatedMirror(animated: Set<string>): void {
;(window as unknown as { __xenoAnimatedEdges?: string[] }).__xenoAnimatedEdges = [...animated]
}
function addBreakpointDot(editor: XenolithEditor, store: Map<string, HTMLDivElement>, nodeId: string): void {
if (store.has(nodeId)) return
const dot = document.createElement('div')
Object.assign(dot.style, {
position: 'absolute', width: '10px', height: '10px', borderRadius: '10px',
background: '#ff3b3b', boxShadow: '0 0 6px #ff3b3baa', pointerEvents: 'none',
transform: 'translate(-50%, -50%)', zIndex: '20',
} as Partial<CSSStyleDeclaration>)
dot.setAttribute('data-breakpoint', nodeId)
editor.chrome.overlayRoot.appendChild(dot)
store.set(nodeId, dot)
positionBreakpointDot(editor, dot, nodeId)
}
function removeBreakpointDot(store: Map<string, HTMLDivElement>, nodeId: string): void {
const el = store.get(nodeId)
if (!el) return
el.remove()
store.delete(nodeId)
}
function positionBreakpointDot(editor: XenolithEditor, dot: HTMLDivElement, nodeId: string): void {
const node = editor.getGraphReadonly().nodes.find((n) => String(n.id) === String(nodeId))
if (!node) { dot.style.display = 'none'; return }
const p = editor.view.worldToScreen({ x: node.position.x, y: node.position.y })
dot.style.left = `${p.x}px`
dot.style.top = `${p.y}px`
dot.style.display = ''
}
function repositionBreakpointDots(editor: XenolithEditor, store: Map<string, HTMLDivElement>): void {
for (const [id, dot] of store) positionBreakpointDot(editor, dot, id)
}
const dotStyle = (s: string): React.CSSProperties => ({
width: 8, height: 8, borderRadius: 8,
background: s === 'paused' ? '#fcb400' : s === 'running' ? '#3ddc97' : s === 'error' ? '#e25b5b' : s === 'finished' ? '#5b8def' : '#666',
boxShadow: s === 'paused' ? '0 0 8px #fcb40088' : undefined,
})
const statusLabel = (s: string): string => ({ idle: 'idle', paused: 'paused', running: 'running…', finished: 'finished', error: 'error' } as Record<string, string>)[s] ?? s
const hintStyle: React.CSSProperties = { fontSize: 11, color: 'var(--xeno-muted)', lineHeight: 1.4 }
const mutedRow: React.CSSProperties = { fontSize: 11, color: 'var(--xeno-muted)' }
const kvRow: React.CSSProperties = { display: 'flex', justifyContent: 'space-between', fontSize: 11, padding: '2px 0' }
const traceRow: React.CSSProperties = { display: 'grid', gridTemplateColumns: '24px 1fr auto auto', gap: 8, fontSize: 11, padding: '2px 0', alignItems: 'baseline' } <script setup lang="ts">
import { XenolithGraph } from '@xenolithengine/graph-vue'
import DebuggerPanel from './DebuggerPanel.vue'
</script>
<template>
<div class="app" style="position:absolute;inset:0;">
<XenolithGraph class="xeno" :resize-to-window="false">
<DebuggerPanel />
</XenolithGraph>
</div>
</template> <script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue'
import { XenolithPanel, XenolithButton, useEditor } from '@xenolithengine/graph-vue'
import {
attachStepDebugger, buildStepDemo,
type StepDebugController, type StepDebugSnapshot,
} from '@xenolithengine/demo/step-debugger'
const EMPTY: StepDebugSnapshot = {
status: 'idle', paused: null, history: [], planned: [], macroExpanded: false, busy: false,
}
const editor = useEditor()
const tick = ref(0)
let ctrl: StepDebugController | null = null
watch(editor, (ed) => {
ctrl?.dispose()
ctrl = null
if (!ed) return
buildStepDemo(ed)
ctrl = attachStepDebugger(ed, () => { tick.value++ })
tick.value++
}, { immediate: true })
onUnmounted(() => { ctrl?.dispose(); ctrl = null })
const snap = computed(() => { void tick.value; return ctrl?.snapshot() ?? EMPTY })
const canStart = computed(() => !snap.value.busy && (snap.value.status === 'idle' || snap.value.status === 'finished' || snap.value.status === 'error'))
const canStep = computed(() => !snap.value.busy && snap.value.status === 'paused')
const canStop = computed(() => !snap.value.busy && snap.value.status !== 'idle')
function dotColor(status: StepDebugSnapshot['status']): string {
if (status === 'paused') return '#fcb400'
if (status === 'running') return '#3ddc97'
if (status === 'error') return '#e25b5b'
if (status === 'finished') return '#5b8def'
return '#666'
}
function statusLabel(status: StepDebugSnapshot['status']): string {
return ({ idle: 'idle', paused: 'paused', running: 'running…', finished: 'finished', error: 'error' })[status]
}
function traceOutputs(record: StepDebugSnapshot['history'][number]): string {
return [...record.outputs.values()].map((v) => JSON.stringify(v)).join(', ') || '—'
}
</script>
<template>
<XenolithPanel position="top-left">
<div style="display:flex;flex-direction:column;gap:8px;max-width:320px;">
<div style="display:flex;align-items:center;gap:8px;">
<span :style="{ width:'8px', height:'8px', borderRadius:'8px', background:dotColor(snap.status), boxShadow: snap.status === 'paused' ? '0 0 8px #fcb40088' : 'none' }" />
<strong style="font-size:12px;">Debugger {{ statusLabel(snap.status) }}</strong>
</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;">
<XenolithButton :active="canStart" :disabled="!canStart" @click="ctrl?.start()">▶ Start</XenolithButton>
<XenolithButton :disabled="!canStep" @click="ctrl?.step()">⤵ Step</XenolithButton>
<XenolithButton :disabled="!canStep" @click="ctrl?.continue()">⏩ Continue</XenolithButton>
<XenolithButton :disabled="!canStop" @click="ctrl?.stop()">■ Stop</XenolithButton>
</div>
<XenolithButton :disabled="snap.busy" @click="ctrl?.toggleMacro()">
{{ snap.macroExpanded ? '⟲ Collapse macro' : '⤢ Expand macro' }}
</XenolithButton>
<div style="font-size:11px;color:var(--xeno-muted);line-height:1.4;">
Graph: <strong>(2 + 3) × 4 = 20</strong> piped through a template-wrapped Identity
into Display. Add + Multiply are inside macro <em>Compute</em>; Identity is inside
template <em>Probe</em>. Toggle the macro to step its members individually. Templates
are always one step. Click any node while debugging to toggle a breakpoint (red).
Yellow = paused, green = executed.
</div>
</div>
</XenolithPanel>
<XenolithPanel v-if="snap.paused" position="top-right">
<div style="min-width:240px;max-width:320px;">
<div style="font-size:10px;color:var(--xeno-muted);text-transform:uppercase;letter-spacing:.08em;">Paused on</div>
<div style="font-size:14px;font-weight:600;">{{ snap.paused.nodeType }}</div>
<div style="font-size:10px;color:var(--xeno-muted);margin-top:8px;">Inputs</div>
<div v-if="snap.paused.inputs.length === 0" style="font-size:11px;color:var(--xeno-muted);">— (no incoming values)</div>
<div v-for="(row, i) in snap.paused.inputs" :key="i" style="display:flex;justify-content:space-between;font-size:11px;padding:2px 0;">
<span style="color:var(--xeno-muted);">{{ row[0] }}</span>
<span style="font-family:ui-monospace,monospace;">{{ JSON.stringify(row[1]) }}</span>
</div>
</div>
</XenolithPanel>
<XenolithPanel position="bottom-left">
<div style="min-width:280px;max-width:360px;max-height:260px;overflow:auto;">
<template v-if="snap.planned.length > 0">
<div style="font-size:10px;color:var(--xeno-muted);margin-bottom:4px;">Planned walk ({{ snap.planned.length }} steps)</div>
<div style="font-size:10px;font-family:ui-monospace,monospace;margin-bottom:8px;line-height:1.5;">
<span v-for="(step, i) in snap.planned" :key="i">{{ i + 1 }}. {{ step }}{{ i < snap.planned.length - 1 ? ' → ' : '' }}</span>
</div>
</template>
<div style="font-size:10px;color:var(--xeno-muted);margin-bottom:4px;">Trace</div>
<div v-if="snap.history.length === 0" style="font-size:11px;color:var(--xeno-muted);">Press <strong>▶ Start</strong>, then <strong>⤵ Step</strong>.</div>
<div v-for="(record, i) in snap.history" :key="i" style="display:grid;grid-template-columns:24px 1fr auto auto;gap:8px;font-size:11px;padding:2px 0;align-items:baseline;">
<span style="color:var(--xeno-accent,#FCB400);">{{ String(i + 1).padStart(2, '0') }}</span>
<span>{{ record.type }}</span>
<span style="color:var(--xeno-muted);">{{ record.durationMs.toFixed(2) }}ms</span>
<span style="font-family:ui-monospace,monospace;">{{ traceOutputs(record) }}</span>
</div>
</div>
</XenolithPanel>
</template> // Stepping-debugger showcase. The React island keeps its own copy because Playwright
// drives `window.__xenoDebug` on that component. `attachStepDebugger` is the same
// session for every other host: the view re-renders from `snapshot()`.
import { TEMPLATE_INSTANCE_TYPE, type Node, type NodeId, type NodeSchema } from '@xenolithengine/graph-core'
import { StepDebugger, type StepExecutor, type StepRecord, type XenolithEditor } from '@xenolithengine/graph-editor'
const SCHEMAS: NodeSchema[] = [
{ type: 'Const', title: 'Const', category: 'data',
pins: [{ kind: 'data', direction: 'out', type: 'number', label: 'Out' }],
widgets: [{ id: 'value', key: 'value', type: 'number', label: 'value', min: -999, max: 999, step: 1 }] },
{ type: 'Add', title: 'Add', category: 'logic',
pins: [
{ kind: 'data', direction: 'in', type: 'number', label: 'a' },
{ kind: 'data', direction: 'in', type: 'number', label: 'b' },
{ kind: 'data', direction: 'out', type: 'number', label: 'sum' },
] },
{ type: 'Multiply', title: 'Multiply', category: 'logic',
pins: [
{ kind: 'data', direction: 'in', type: 'number', label: 'a' },
{ kind: 'data', direction: 'in', type: 'number', label: 'b' },
{ kind: 'data', direction: 'out', type: 'number', label: 'product' },
] },
{ type: 'Identity', title: 'Identity', category: 'utility',
pins: [
{ kind: 'data', direction: 'in', type: 'number', label: 'in' },
{ kind: 'data', direction: 'out', type: 'number', label: 'out' },
] },
{ type: 'Display', title: 'Display', category: 'utility',
pins: [{ kind: 'data', direction: 'in', type: 'number', label: 'In' }] },
]
function evalNode(node: Node, inputs: Map<string, unknown>): Map<string, unknown> {
if (node.type === 'Const') return new Map([[node.pins[0]!.id, Number((node.state as { value?: number }).value ?? 0)]])
if (node.type === 'Add' || node.type === 'Multiply') {
const vals = [...inputs.values()].map(Number)
const r = node.type === 'Add' ? vals.reduce((s, v) => s + v, 0) : vals.reduce((s, v) => s * v, 1)
return new Map([[node.pins.find((p) => p.direction === 'out')!.id, r]])
}
if (node.type === 'Identity') {
const v = [...inputs.values()][0]
const outPin = node.pins.find((p) => p.direction === 'out')
if (outPin && v !== undefined) return new Map([[outPin.id, v]])
}
return new Map()
}
function makeExecutor(editor: XenolithEditor): StepExecutor {
return ({ node, inputs, peek }) => {
if (node.type === 'Macro') {
const state = node.state as { collapsed?: boolean; members?: NodeId[] }
const memberIds = state.members ?? []
const lastId = memberIds[memberIds.length - 1]
if (!state.collapsed) {
const out = lastId ? peek(lastId) : undefined
if (!out) return new Map()
const macroOutPin = node.pins.find((p) => p.direction === 'out')
const value = [...out.values()][0]
if (macroOutPin && value !== undefined) return new Map([[macroOutPin.id, value]])
return out
}
const subValues = new Map<string, Map<string, unknown>>()
const subSnap = editor.getGraphReadonly()
const subNodeById = new Map(subSnap.nodes.map((n) => [String(n.id), n]))
for (const mid of memberIds) {
const member = subNodeById.get(String(mid))
if (!member) continue
const memberInputs = new Map<string, unknown>()
for (const e of subSnap.edges) {
if (e.to.node !== mid) continue
if (memberIds.includes(e.from.node as NodeId)) {
const v = subValues.get(e.from.node)?.get(e.from.pin as string)
if (v !== undefined) memberInputs.set(e.to.pin as string, v)
} else {
const v = [...inputs.values()].shift()
if (v !== undefined) memberInputs.set(e.to.pin as string, v)
}
}
subValues.set(mid, evalNode(member as Node, memberInputs))
}
const lastOut = lastId ? subValues.get(lastId) : null
const macroOutPin = node.pins.find((p) => p.direction === 'out')
if (!macroOutPin || !lastOut) return new Map()
const value = [...lastOut.values()][0]
return new Map([[macroOutPin.id, value]])
}
if (node.type === TEMPLATE_INSTANCE_TYPE) {
const v = [...inputs.values()][0]
const outPin = node.pins.find((p) => p.direction === 'out')
if (outPin && v !== undefined) return new Map([[outPin.id, v]])
return new Map()
}
if (node.type === 'Display') return new Map()
return evalNode(node, inputs)
}
}
export function buildStepDemo(editor: XenolithEditor): void {
for (const s of SCHEMAS) editor.registry.register(s)
const a = editor.insertNode('Const', { x: 0, y: 0 })!
const b = editor.insertNode('Const', { x: 0, y: 140 })!
const c = editor.insertNode('Const', { x: 0, y: 280 })!
const add = editor.insertNode('Add', { x: 320, y: 60 })!
const mul = editor.insertNode('Multiply', { x: 640, y: 160 })!
const idn = editor.insertNode('Identity', { x: 880, y: 160 })!
const out = editor.insertNode('Display', { x: 1100, y: 160 })!
editor.setWidgetValue(a.id, 'value', 2)
editor.setWidgetValue(b.id, 'value', 3)
editor.setWidgetValue(c.id, 'value', 4)
const out0 = (n: typeof a) => n.pins.find((p) => p.direction === 'out')!.id
const inAt = (n: typeof add, i: number) => n.pins.filter((p) => p.direction === 'in')[i]!.id
const wire = (from: typeof a, to: typeof add, index: number): void => {
editor.addEdge({
id: crypto.randomUUID(),
from: { node: from.id, pin: out0(from) },
to: { node: to.id, pin: inAt(to, index) },
} as never)
}
wire(a, add, 0)
wire(b, add, 1)
wire(add, mul, 0)
wire(c, mul, 1)
wire(mul, idn, 0)
wire(idn, out, 0)
editor.createMacroFromSelection([add.id, mul.id], 'Compute')
editor.createTemplateFromSelection([idn.id], 'Probe')
editor.view.fitView({ padding: 80 })
}
export interface StepDebugSnapshot {
status: 'idle' | 'paused' | 'running' | 'finished' | 'error'
paused: { nodeType: string; inputs: Array<[string, unknown]> } | null
history: StepRecord[]
planned: string[]
macroExpanded: boolean
busy: boolean
}
export interface StepDebugController {
snapshot(): StepDebugSnapshot
start(): Promise<void>
step(): Promise<void>
continue(): Promise<void>
stop(): void
toggleMacro(): Promise<void>
dispose(): void
}
function findMacroId(editor: XenolithEditor): NodeId | null {
for (const n of editor.getGraphReadonly().nodes) if (n.type === 'Macro') return n.id as NodeId
return null
}
export function attachStepDebugger(editor: XenolithEditor, onChange: () => void): StepDebugController {
const dbg = new StepDebugger(editor.readGraph(), makeExecutor(editor))
const macroId = findMacroId(editor)
const dots = new Map<string, HTMLDivElement>()
const animated = new Set<string>()
let status: StepDebugSnapshot['status'] = 'idle'
let paused: StepDebugSnapshot['paused'] = null
let history: StepRecord[] = []
let planned: string[] = []
let macroExpanded = false
let busy = false
let disposed = false
const emit = (): void => { if (!disposed) onChange() }
const setStatus = (id: NodeId, s: 'idle' | 'running' | 'ok' | 'error'): void => { editor.setNodeStatus(id, s) }
const clearAnimated = (): void => {
for (const id of animated) editor.setEdgeOptions(id as never, { animated: false })
animated.clear()
}
const placeDot = (nodeId: string): void => {
const dot = dots.get(nodeId)
if (!dot) return
const node = editor.getGraphReadonly().nodes.find((n) => String(n.id) === nodeId)
if (!node) { dot.style.display = 'none'; return }
const p = editor.view.worldToScreen({ x: node.position.x, y: node.position.y })
dot.style.left = `${p.x}px`
dot.style.top = `${p.y}px`
dot.style.display = ''
}
const addDot = (nodeId: string): void => {
if (dots.has(nodeId)) return
const dot = document.createElement('div')
Object.assign(dot.style, {
position: 'absolute', width: '10px', height: '10px', borderRadius: '10px',
background: '#ff3b3b', boxShadow: '0 0 6px #ff3b3baa', pointerEvents: 'none',
transform: 'translate(-50%, -50%)', zIndex: '20',
})
editor.chrome.overlayRoot.appendChild(dot)
dots.set(nodeId, dot)
placeDot(nodeId)
}
const removeDot = (nodeId: string): void => {
dots.get(nodeId)?.remove()
dots.delete(nodeId)
}
dbg.on('paused', ({ nodeId, node, inputs }) => {
setStatus(nodeId, 'running')
clearAnimated()
for (const e of editor.getGraphReadonly().edges) {
if (e.to.node !== nodeId) continue
editor.setEdgeOptions(e.id as never, { animated: true })
animated.add(String(e.id))
}
paused = {
nodeType: node.type,
inputs: [...inputs.entries()].map(([pinId, v]) => [node.pins.find((p) => p.id === pinId)?.label ?? pinId, v]),
}
status = 'paused'
emit()
})
dbg.on('stepped', (r) => {
setStatus(r.nodeId, 'ok')
for (const n of editor.getGraphReadonly().nodes) {
if (n.type !== 'Macro') continue
const members = ((n.state as { members?: string[] }).members ?? [])
if (members.length > 0 && members.every((m) => editor.getNode(m as NodeId) && dbg.history.some((h) => String(h.nodeId) === m && true))) {
const done = members.every((m) => dbg.history.some((h) => String(h.nodeId) === String(m)))
if (done) setStatus(n.id as NodeId, 'ok')
}
}
history = [...dbg.history]
emit()
})
dbg.on('finished', () => { status = 'finished'; paused = null; clearAnimated(); emit() })
dbg.on('error', ({ nodeId }) => { setStatus(nodeId, 'error'); status = 'error'; clearAnimated(); emit() })
const offs = [
editor.on('node:click', ({ nodeId }) => {
if (dbg.status === 'idle' || dbg.status === 'finished') return
const on = dbg.toggleBreakpoint(nodeId)
if (on) addDot(String(nodeId))
else removeDot(String(nodeId))
emit()
}),
editor.on('viewport:changed', () => { for (const id of dots.keys()) placeDot(id) }),
editor.on('node:moved', () => { for (const id of dots.keys()) placeDot(id) }),
]
const clearStatuses = (): void => {
for (const n of editor.getGraphReadonly().nodes) setStatus(n.id as NodeId, 'idle')
for (const id of dbg.breakpoints) setStatus(id as NodeId, 'error')
}
const run = async (fn: () => Promise<void>): Promise<void> => {
if (busy) return
busy = true
emit()
try { await fn() } finally { busy = false; emit() }
}
return {
snapshot: () => ({ status, paused, history, planned, macroExpanded, busy }),
start: () => run(async () => {
clearStatuses()
history = []
paused = null
status = 'running'
await dbg.start()
const byId = new Map(editor.getGraphReadonly().nodes.map((n) => [String(n.id), n]))
planned = dbg.order.map((id) => byId.get(String(id))?.type ?? String(id).slice(0, 6))
}),
step: () => run(async () => { await dbg.step() }),
continue: () => run(async () => { status = 'running'; emit(); await dbg.continue() }),
stop(): void {
dbg.stop()
status = 'idle'
paused = null
history = []
planned = []
clearStatuses()
emit()
},
async toggleMacro(): Promise<void> {
if (!macroId || busy) return
const wasActive = dbg.status === 'paused' || dbg.status === 'running'
const visited = new Set(dbg.history.map((r) => String(r.nodeId)))
const members = ((editor.getGraphReadonly().nodes.find((n) => n.id === macroId)?.state as { members?: string[] } | undefined)?.members ?? [])
if (macroExpanded) {
if (members.length > 0 && members.every((m) => visited.has(String(m)))) visited.add(String(macroId))
} else visited.delete(String(macroId))
if (!wasActive) dbg.stop()
if (macroExpanded) editor.collapseMacro(macroId)
else editor.expandMacro(macroId)
macroExpanded = !macroExpanded
if (!wasActive) {
status = 'idle'; history = []; paused = null; planned = []
clearStatuses()
emit()
return
}
await dbg.start()
while (dbg.status === 'paused' && dbg.currentNodeId && visited.has(String(dbg.currentNodeId))) dbg.advance()
status = dbg.status
const byId = new Map(editor.getGraphReadonly().nodes.map((n) => [String(n.id), n]))
planned = dbg.order.map((id) => byId.get(String(id))?.type ?? '?')
emit()
},
dispose(): void {
disposed = true
for (const off of offs) off()
dbg.stop()
clearAnimated()
for (const dot of dots.values()) dot.remove()
dots.clear()
},
}
} <script lang="ts">
import { onDestroy } from 'svelte'
import { xenolith, createXenolithEditorContext } from '@xenolithengine/graph-svelte'
import { XenolithPanel, XenolithButton } from '@xenolithengine/graph-svelte/components'
import {
attachStepDebugger, buildStepDemo,
type StepDebugController, type StepDebugSnapshot,
} from '@xenolithengine/demo/step-debugger'
const EMPTY: StepDebugSnapshot = {
status: 'idle', paused: null, history: [], planned: [], macroExpanded: false, busy: false,
}
const panelEditor = createXenolithEditorContext()
let ctrl: StepDebugController | null = null
let snap = $state<StepDebugSnapshot>(EMPTY)
const canStart = $derived(!snap.busy && (snap.status === 'idle' || snap.status === 'finished' || snap.status === 'error'))
const canStep = $derived(!snap.busy && snap.status === 'paused')
const canStop = $derived(!snap.busy && snap.status !== 'idle')
function dotColor(status: StepDebugSnapshot['status']): string {
if (status === 'paused') return '#fcb400'
if (status === 'running') return '#3ddc97'
if (status === 'error') return '#e25b5b'
if (status === 'finished') return '#5b8def'
return '#666'
}
function statusLabel(status: StepDebugSnapshot['status']): string {
return ({ idle: 'idle', paused: 'paused', running: 'running…', finished: 'finished', error: 'error' })[status]
}
function outputsOf(record: StepDebugSnapshot['history'][number]): string {
return [...record.outputs.values()].map((v) => JSON.stringify(v)).join(', ') || '—'
}
onDestroy(() => { ctrl?.dispose(); ctrl = null })
</script>
<div
use:xenolith={{ resizeToWindow: false }}
onready={(e) => {
panelEditor.set(e.detail)
buildStepDemo(e.detail)
ctrl = attachStepDebugger(e.detail, () => { snap = ctrl?.snapshot() ?? EMPTY })
snap = ctrl.snapshot()
}}
style="position:absolute;inset:0"
></div>
<XenolithPanel position="top-left">
<div style="display:flex;flex-direction:column;gap:8px;max-width:320px;">
<div style="display:flex;align-items:center;gap:8px;">
<span style="width:8px;height:8px;border-radius:8px;background:{dotColor(snap.status)};box-shadow:{snap.status === 'paused' ? '0 0 8px #fcb40088' : 'none'};"></span>
<strong style="font-size:12px;">Debugger {statusLabel(snap.status)}</strong>
</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;">
<XenolithButton active={canStart} disabled={!canStart} onclick={() => void ctrl?.start()}>▶ Start</XenolithButton>
<XenolithButton disabled={!canStep} onclick={() => void ctrl?.step()}>⤵ Step</XenolithButton>
<XenolithButton disabled={!canStep} onclick={() => void ctrl?.continue()}>⏩ Continue</XenolithButton>
<XenolithButton disabled={!canStop} onclick={() => ctrl?.stop()}>■ Stop</XenolithButton>
</div>
<XenolithButton disabled={snap.busy} onclick={() => void ctrl?.toggleMacro()}>
{snap.macroExpanded ? '⟲ Collapse macro' : '⤢ Expand macro'}
</XenolithButton>
<div style="font-size:11px;color:var(--xeno-muted);line-height:1.4;">
Graph: <strong>(2 + 3) × 4 = 20</strong> piped through a template-wrapped Identity
into Display. Add + Multiply are inside macro <em>Compute</em>; Identity is inside
template <em>Probe</em>. Toggle the macro to step its members individually. Templates
are always one step. Click any node while debugging to toggle a breakpoint (red).
Yellow = paused, green = executed.
</div>
</div>
</XenolithPanel>
{#if snap.paused}
<XenolithPanel position="top-right">
<div style="min-width:240px;max-width:320px;">
<div style="font-size:10px;color:var(--xeno-muted);text-transform:uppercase;letter-spacing:.08em;">Paused on</div>
<div style="font-size:14px;font-weight:600;">{snap.paused.nodeType}</div>
<div style="font-size:10px;color:var(--xeno-muted);margin-top:8px;">Inputs</div>
{#if snap.paused.inputs.length === 0}
<div style="font-size:11px;color:var(--xeno-muted);">— (no incoming values)</div>
{:else}
{#each snap.paused.inputs as row}
<div style="display:flex;justify-content:space-between;font-size:11px;padding:2px 0;">
<span style="color:var(--xeno-muted);">{row[0]}</span>
<span style="font-family:ui-monospace,monospace;">{JSON.stringify(row[1])}</span>
</div>
{/each}
{/if}
</div>
</XenolithPanel>
{/if}
<XenolithPanel position="bottom-left">
<div style="min-width:280px;max-width:360px;max-height:260px;overflow:auto;">
{#if snap.planned.length > 0}
<div style="font-size:10px;color:var(--xeno-muted);margin-bottom:4px;">Planned walk ({snap.planned.length} steps)</div>
<div style="font-size:10px;font-family:ui-monospace,monospace;margin-bottom:8px;line-height:1.5;">
{#each snap.planned as step, i}{i + 1}. {step}{i < snap.planned.length - 1 ? ' → ' : ''}{/each}
</div>
{/if}
<div style="font-size:10px;color:var(--xeno-muted);margin-bottom:4px;">Trace</div>
{#if snap.history.length === 0}
<div style="font-size:11px;color:var(--xeno-muted);">Press <strong>▶ Start</strong>, then <strong>⤵ Step</strong>.</div>
{:else}
{#each snap.history as record, i}
<div style="display:grid;grid-template-columns:24px 1fr auto auto;gap:8px;font-size:11px;padding:2px 0;align-items:baseline;">
<span style="color:var(--xeno-accent,#FCB400);">{String(i + 1).padStart(2, '0')}</span>
<span>{record.type}</span>
<span style="color:var(--xeno-muted);">{record.durationMs.toFixed(2)}ms</span>
<span style="font-family:ui-monospace,monospace;">{outputsOf(record)}</span>
</div>
{/each}
{/if}
</div>
</XenolithPanel> // Stepping-debugger showcase. The React island keeps its own copy because Playwright
// drives `window.__xenoDebug` on that component. `attachStepDebugger` is the same
// session for every other host: the view re-renders from `snapshot()`.
import { TEMPLATE_INSTANCE_TYPE, type Node, type NodeId, type NodeSchema } from '@xenolithengine/graph-core'
import { StepDebugger, type StepExecutor, type StepRecord, type XenolithEditor } from '@xenolithengine/graph-editor'
const SCHEMAS: NodeSchema[] = [
{ type: 'Const', title: 'Const', category: 'data',
pins: [{ kind: 'data', direction: 'out', type: 'number', label: 'Out' }],
widgets: [{ id: 'value', key: 'value', type: 'number', label: 'value', min: -999, max: 999, step: 1 }] },
{ type: 'Add', title: 'Add', category: 'logic',
pins: [
{ kind: 'data', direction: 'in', type: 'number', label: 'a' },
{ kind: 'data', direction: 'in', type: 'number', label: 'b' },
{ kind: 'data', direction: 'out', type: 'number', label: 'sum' },
] },
{ type: 'Multiply', title: 'Multiply', category: 'logic',
pins: [
{ kind: 'data', direction: 'in', type: 'number', label: 'a' },
{ kind: 'data', direction: 'in', type: 'number', label: 'b' },
{ kind: 'data', direction: 'out', type: 'number', label: 'product' },
] },
{ type: 'Identity', title: 'Identity', category: 'utility',
pins: [
{ kind: 'data', direction: 'in', type: 'number', label: 'in' },
{ kind: 'data', direction: 'out', type: 'number', label: 'out' },
] },
{ type: 'Display', title: 'Display', category: 'utility',
pins: [{ kind: 'data', direction: 'in', type: 'number', label: 'In' }] },
]
function evalNode(node: Node, inputs: Map<string, unknown>): Map<string, unknown> {
if (node.type === 'Const') return new Map([[node.pins[0]!.id, Number((node.state as { value?: number }).value ?? 0)]])
if (node.type === 'Add' || node.type === 'Multiply') {
const vals = [...inputs.values()].map(Number)
const r = node.type === 'Add' ? vals.reduce((s, v) => s + v, 0) : vals.reduce((s, v) => s * v, 1)
return new Map([[node.pins.find((p) => p.direction === 'out')!.id, r]])
}
if (node.type === 'Identity') {
const v = [...inputs.values()][0]
const outPin = node.pins.find((p) => p.direction === 'out')
if (outPin && v !== undefined) return new Map([[outPin.id, v]])
}
return new Map()
}
function makeExecutor(editor: XenolithEditor): StepExecutor {
return ({ node, inputs, peek }) => {
if (node.type === 'Macro') {
const state = node.state as { collapsed?: boolean; members?: NodeId[] }
const memberIds = state.members ?? []
const lastId = memberIds[memberIds.length - 1]
if (!state.collapsed) {
const out = lastId ? peek(lastId) : undefined
if (!out) return new Map()
const macroOutPin = node.pins.find((p) => p.direction === 'out')
const value = [...out.values()][0]
if (macroOutPin && value !== undefined) return new Map([[macroOutPin.id, value]])
return out
}
const subValues = new Map<string, Map<string, unknown>>()
const subSnap = editor.getGraphReadonly()
const subNodeById = new Map(subSnap.nodes.map((n) => [String(n.id), n]))
for (const mid of memberIds) {
const member = subNodeById.get(String(mid))
if (!member) continue
const memberInputs = new Map<string, unknown>()
for (const e of subSnap.edges) {
if (e.to.node !== mid) continue
if (memberIds.includes(e.from.node as NodeId)) {
const v = subValues.get(e.from.node)?.get(e.from.pin as string)
if (v !== undefined) memberInputs.set(e.to.pin as string, v)
} else {
const v = [...inputs.values()].shift()
if (v !== undefined) memberInputs.set(e.to.pin as string, v)
}
}
subValues.set(mid, evalNode(member as Node, memberInputs))
}
const lastOut = lastId ? subValues.get(lastId) : null
const macroOutPin = node.pins.find((p) => p.direction === 'out')
if (!macroOutPin || !lastOut) return new Map()
const value = [...lastOut.values()][0]
return new Map([[macroOutPin.id, value]])
}
if (node.type === TEMPLATE_INSTANCE_TYPE) {
const v = [...inputs.values()][0]
const outPin = node.pins.find((p) => p.direction === 'out')
if (outPin && v !== undefined) return new Map([[outPin.id, v]])
return new Map()
}
if (node.type === 'Display') return new Map()
return evalNode(node, inputs)
}
}
export function buildStepDemo(editor: XenolithEditor): void {
for (const s of SCHEMAS) editor.registry.register(s)
const a = editor.insertNode('Const', { x: 0, y: 0 })!
const b = editor.insertNode('Const', { x: 0, y: 140 })!
const c = editor.insertNode('Const', { x: 0, y: 280 })!
const add = editor.insertNode('Add', { x: 320, y: 60 })!
const mul = editor.insertNode('Multiply', { x: 640, y: 160 })!
const idn = editor.insertNode('Identity', { x: 880, y: 160 })!
const out = editor.insertNode('Display', { x: 1100, y: 160 })!
editor.setWidgetValue(a.id, 'value', 2)
editor.setWidgetValue(b.id, 'value', 3)
editor.setWidgetValue(c.id, 'value', 4)
const out0 = (n: typeof a) => n.pins.find((p) => p.direction === 'out')!.id
const inAt = (n: typeof add, i: number) => n.pins.filter((p) => p.direction === 'in')[i]!.id
const wire = (from: typeof a, to: typeof add, index: number): void => {
editor.addEdge({
id: crypto.randomUUID(),
from: { node: from.id, pin: out0(from) },
to: { node: to.id, pin: inAt(to, index) },
} as never)
}
wire(a, add, 0)
wire(b, add, 1)
wire(add, mul, 0)
wire(c, mul, 1)
wire(mul, idn, 0)
wire(idn, out, 0)
editor.createMacroFromSelection([add.id, mul.id], 'Compute')
editor.createTemplateFromSelection([idn.id], 'Probe')
editor.view.fitView({ padding: 80 })
}
export interface StepDebugSnapshot {
status: 'idle' | 'paused' | 'running' | 'finished' | 'error'
paused: { nodeType: string; inputs: Array<[string, unknown]> } | null
history: StepRecord[]
planned: string[]
macroExpanded: boolean
busy: boolean
}
export interface StepDebugController {
snapshot(): StepDebugSnapshot
start(): Promise<void>
step(): Promise<void>
continue(): Promise<void>
stop(): void
toggleMacro(): Promise<void>
dispose(): void
}
function findMacroId(editor: XenolithEditor): NodeId | null {
for (const n of editor.getGraphReadonly().nodes) if (n.type === 'Macro') return n.id as NodeId
return null
}
export function attachStepDebugger(editor: XenolithEditor, onChange: () => void): StepDebugController {
const dbg = new StepDebugger(editor.readGraph(), makeExecutor(editor))
const macroId = findMacroId(editor)
const dots = new Map<string, HTMLDivElement>()
const animated = new Set<string>()
let status: StepDebugSnapshot['status'] = 'idle'
let paused: StepDebugSnapshot['paused'] = null
let history: StepRecord[] = []
let planned: string[] = []
let macroExpanded = false
let busy = false
let disposed = false
const emit = (): void => { if (!disposed) onChange() }
const setStatus = (id: NodeId, s: 'idle' | 'running' | 'ok' | 'error'): void => { editor.setNodeStatus(id, s) }
const clearAnimated = (): void => {
for (const id of animated) editor.setEdgeOptions(id as never, { animated: false })
animated.clear()
}
const placeDot = (nodeId: string): void => {
const dot = dots.get(nodeId)
if (!dot) return
const node = editor.getGraphReadonly().nodes.find((n) => String(n.id) === nodeId)
if (!node) { dot.style.display = 'none'; return }
const p = editor.view.worldToScreen({ x: node.position.x, y: node.position.y })
dot.style.left = `${p.x}px`
dot.style.top = `${p.y}px`
dot.style.display = ''
}
const addDot = (nodeId: string): void => {
if (dots.has(nodeId)) return
const dot = document.createElement('div')
Object.assign(dot.style, {
position: 'absolute', width: '10px', height: '10px', borderRadius: '10px',
background: '#ff3b3b', boxShadow: '0 0 6px #ff3b3baa', pointerEvents: 'none',
transform: 'translate(-50%, -50%)', zIndex: '20',
})
editor.chrome.overlayRoot.appendChild(dot)
dots.set(nodeId, dot)
placeDot(nodeId)
}
const removeDot = (nodeId: string): void => {
dots.get(nodeId)?.remove()
dots.delete(nodeId)
}
dbg.on('paused', ({ nodeId, node, inputs }) => {
setStatus(nodeId, 'running')
clearAnimated()
for (const e of editor.getGraphReadonly().edges) {
if (e.to.node !== nodeId) continue
editor.setEdgeOptions(e.id as never, { animated: true })
animated.add(String(e.id))
}
paused = {
nodeType: node.type,
inputs: [...inputs.entries()].map(([pinId, v]) => [node.pins.find((p) => p.id === pinId)?.label ?? pinId, v]),
}
status = 'paused'
emit()
})
dbg.on('stepped', (r) => {
setStatus(r.nodeId, 'ok')
for (const n of editor.getGraphReadonly().nodes) {
if (n.type !== 'Macro') continue
const members = ((n.state as { members?: string[] }).members ?? [])
if (members.length > 0 && members.every((m) => editor.getNode(m as NodeId) && dbg.history.some((h) => String(h.nodeId) === m && true))) {
const done = members.every((m) => dbg.history.some((h) => String(h.nodeId) === String(m)))
if (done) setStatus(n.id as NodeId, 'ok')
}
}
history = [...dbg.history]
emit()
})
dbg.on('finished', () => { status = 'finished'; paused = null; clearAnimated(); emit() })
dbg.on('error', ({ nodeId }) => { setStatus(nodeId, 'error'); status = 'error'; clearAnimated(); emit() })
const offs = [
editor.on('node:click', ({ nodeId }) => {
if (dbg.status === 'idle' || dbg.status === 'finished') return
const on = dbg.toggleBreakpoint(nodeId)
if (on) addDot(String(nodeId))
else removeDot(String(nodeId))
emit()
}),
editor.on('viewport:changed', () => { for (const id of dots.keys()) placeDot(id) }),
editor.on('node:moved', () => { for (const id of dots.keys()) placeDot(id) }),
]
const clearStatuses = (): void => {
for (const n of editor.getGraphReadonly().nodes) setStatus(n.id as NodeId, 'idle')
for (const id of dbg.breakpoints) setStatus(id as NodeId, 'error')
}
const run = async (fn: () => Promise<void>): Promise<void> => {
if (busy) return
busy = true
emit()
try { await fn() } finally { busy = false; emit() }
}
return {
snapshot: () => ({ status, paused, history, planned, macroExpanded, busy }),
start: () => run(async () => {
clearStatuses()
history = []
paused = null
status = 'running'
await dbg.start()
const byId = new Map(editor.getGraphReadonly().nodes.map((n) => [String(n.id), n]))
planned = dbg.order.map((id) => byId.get(String(id))?.type ?? String(id).slice(0, 6))
}),
step: () => run(async () => { await dbg.step() }),
continue: () => run(async () => { status = 'running'; emit(); await dbg.continue() }),
stop(): void {
dbg.stop()
status = 'idle'
paused = null
history = []
planned = []
clearStatuses()
emit()
},
async toggleMacro(): Promise<void> {
if (!macroId || busy) return
const wasActive = dbg.status === 'paused' || dbg.status === 'running'
const visited = new Set(dbg.history.map((r) => String(r.nodeId)))
const members = ((editor.getGraphReadonly().nodes.find((n) => n.id === macroId)?.state as { members?: string[] } | undefined)?.members ?? [])
if (macroExpanded) {
if (members.length > 0 && members.every((m) => visited.has(String(m)))) visited.add(String(macroId))
} else visited.delete(String(macroId))
if (!wasActive) dbg.stop()
if (macroExpanded) editor.collapseMacro(macroId)
else editor.expandMacro(macroId)
macroExpanded = !macroExpanded
if (!wasActive) {
status = 'idle'; history = []; paused = null; planned = []
clearStatuses()
emit()
return
}
await dbg.start()
while (dbg.status === 'paused' && dbg.currentNodeId && visited.has(String(dbg.currentNodeId))) dbg.advance()
status = dbg.status
const byId = new Map(editor.getGraphReadonly().nodes.map((n) => [String(n.id), n]))
planned = dbg.order.map((id) => byId.get(String(id))?.type ?? '?')
emit()
},
dispose(): void {
disposed = true
for (const off of offs) off()
dbg.stop()
clearAnimated()
for (const dot of dots.values()) dot.remove()
dots.clear()
},
}
} import { createMemo, createSignal, onCleanup } from 'solid-js'
import { xenolith } from '@xenolithengine/graph-solid'
import {
attachStepDebugger, buildStepDemo,
type StepDebugController, type StepDebugSnapshot,
} from '@xenolithengine/demo/step-debugger'
const EMPTY: StepDebugSnapshot = {
status: 'idle', paused: null, history: [], planned: [], macroExpanded: false, busy: false,
}
function dotColor(status: StepDebugSnapshot['status']): string {
if (status === 'paused') return '#fcb400'
if (status === 'running') return '#3ddc97'
if (status === 'error') return '#e25b5b'
if (status === 'finished') return '#5b8def'
return '#666'
}
function statusLabel(status: StepDebugSnapshot['status']): string {
return ({ idle: 'idle', paused: 'paused', running: 'running…', finished: 'finished', error: 'error' })[status]
}
export function StepDebuggerDemo() {
let ctrl: StepDebugController | null = null
const [snap, setSnap] = createSignal<StepDebugSnapshot>(EMPTY)
onCleanup(() => { ctrl?.dispose(); ctrl = null })
const canStart = createMemo(() => {
const s = snap()
return !s.busy && (s.status === 'idle' || s.status === 'finished' || s.status === 'error')
})
const canStep = createMemo(() => !snap().busy && snap().status === 'paused')
const canStop = createMemo(() => !snap().busy && snap().status !== 'idle')
const btn = (on: boolean, off: boolean): string =>
`font:inherit;font-size:13px;padding:7px 12px;border-radius:8px;cursor:${off ? 'default' : 'pointer'};opacity:${off ? '0.4' : '1'};border:1px solid ${on ? 'var(--xeno-accent)' : 'var(--xeno-border)'};background:${on ? 'var(--xeno-accent)' : 'var(--xeno-elevated)'};color:${on ? 'var(--xeno-canvas)' : 'var(--xeno-text)'};`
const card = 'position:absolute;z-index:5;padding:10px;background:var(--xeno-panel);border:1px solid var(--xeno-border);border-radius:10px;color:var(--xeno-text);font:13px system-ui,sans-serif;'
return (
<div
use:xenolith={{ resizeToWindow: false }}
on:ready={(e) => {
buildStepDemo(e.detail)
ctrl = attachStepDebugger(e.detail, () => setSnap(ctrl?.snapshot() ?? EMPTY))
setSnap(ctrl.snapshot())
}}
style="position:absolute;inset:0"
>
<div style={card + 'top:12px;left:12px;display:flex;flex-direction:column;gap:8px;max-width:320px;'}>
<div style="display:flex;align-items:center;gap:8px;">
<span style={`width:8px;height:8px;border-radius:8px;background:${dotColor(snap().status)};`} />
<strong style="font-size:12px;">Debugger {statusLabel(snap().status)}</strong>
</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;">
<button type="button" style={btn(canStart(), !canStart())} disabled={!canStart()} onClick={() => void ctrl?.start()}>▶ Start</button>
<button type="button" style={btn(false, !canStep())} disabled={!canStep()} onClick={() => void ctrl?.step()}>⤵ Step</button>
<button type="button" style={btn(false, !canStep())} disabled={!canStep()} onClick={() => void ctrl?.continue()}>⏩ Continue</button>
<button type="button" style={btn(false, !canStop())} disabled={!canStop()} onClick={() => ctrl?.stop()}>■ Stop</button>
</div>
<button type="button" style={btn(false, snap().busy)} disabled={snap().busy} onClick={() => void ctrl?.toggleMacro()}>
{snap().macroExpanded ? '⟲ Collapse macro' : '⤢ Expand macro'}
</button>
<div style="font-size:11px;color:var(--xeno-muted);line-height:1.4;">
Graph: <strong>(2 + 3) × 4 = 20</strong> piped through a template-wrapped Identity
into Display. Add + Multiply are inside macro <em>Compute</em>; Identity is inside
template <em>Probe</em>. Toggle the macro to step its members individually. Templates
are always one step. Click any node while debugging to toggle a breakpoint (red).
Yellow = paused, green = executed.
</div>
</div>
{snap().paused && (
<div style={card + 'top:12px;right:12px;min-width:240px;max-width:320px;'}>
<div style="font-size:10px;color:var(--xeno-muted);text-transform:uppercase;">Paused on</div>
<div style="font-size:14px;font-weight:600;">{snap().paused!.nodeType}</div>
<div style="font-size:10px;color:var(--xeno-muted);margin-top:8px;">Inputs</div>
{snap().paused!.inputs.length === 0
? <div style="font-size:11px;color:var(--xeno-muted);">— (no incoming values)</div>
: snap().paused!.inputs.map((row) => (
<div style="display:flex;justify-content:space-between;font-size:11px;padding:2px 0;">
<span style="color:var(--xeno-muted);">{row[0]}</span>
<span style="font-family:ui-monospace,monospace;">{JSON.stringify(row[1])}</span>
</div>
))}
</div>
)}
<div style={card + 'bottom:12px;left:12px;min-width:280px;max-width:360px;max-height:260px;overflow:auto;'}>
{snap().planned.length > 0 && (
<>
<div style="font-size:10px;color:var(--xeno-muted);margin-bottom:4px;">Planned walk ({snap().planned.length} steps)</div>
<div style="font-size:10px;font-family:ui-monospace,monospace;margin-bottom:8px;line-height:1.5;">
{snap().planned.map((step, i) => `${i + 1}. ${step}${i < snap().planned.length - 1 ? ' → ' : ''}`).join('')}
</div>
</>
)}
<div style="font-size:10px;color:var(--xeno-muted);margin-bottom:4px;">Trace</div>
{snap().history.length === 0
? <div style="font-size:11px;color:var(--xeno-muted);">Press ▶ Start, then ⤵ Step.</div>
: snap().history.map((record, i) => (
<div style="display:grid;grid-template-columns:24px 1fr auto auto;gap:8px;font-size:11px;padding:2px 0;">
<span style="color:var(--xeno-accent,#FCB400);">{String(i + 1).padStart(2, '0')}</span>
<span>{record.type}</span>
<span style="color:var(--xeno-muted);">{record.durationMs.toFixed(2)}ms</span>
<span style="font-family:ui-monospace,monospace;">{[...record.outputs.values()].map((v) => JSON.stringify(v)).join(', ') || '—'}</span>
</div>
))}
</div>
</div>
)
} // Stepping-debugger showcase. The React island keeps its own copy because Playwright
// drives `window.__xenoDebug` on that component. `attachStepDebugger` is the same
// session for every other host: the view re-renders from `snapshot()`.
import { TEMPLATE_INSTANCE_TYPE, type Node, type NodeId, type NodeSchema } from '@xenolithengine/graph-core'
import { StepDebugger, type StepExecutor, type StepRecord, type XenolithEditor } from '@xenolithengine/graph-editor'
const SCHEMAS: NodeSchema[] = [
{ type: 'Const', title: 'Const', category: 'data',
pins: [{ kind: 'data', direction: 'out', type: 'number', label: 'Out' }],
widgets: [{ id: 'value', key: 'value', type: 'number', label: 'value', min: -999, max: 999, step: 1 }] },
{ type: 'Add', title: 'Add', category: 'logic',
pins: [
{ kind: 'data', direction: 'in', type: 'number', label: 'a' },
{ kind: 'data', direction: 'in', type: 'number', label: 'b' },
{ kind: 'data', direction: 'out', type: 'number', label: 'sum' },
] },
{ type: 'Multiply', title: 'Multiply', category: 'logic',
pins: [
{ kind: 'data', direction: 'in', type: 'number', label: 'a' },
{ kind: 'data', direction: 'in', type: 'number', label: 'b' },
{ kind: 'data', direction: 'out', type: 'number', label: 'product' },
] },
{ type: 'Identity', title: 'Identity', category: 'utility',
pins: [
{ kind: 'data', direction: 'in', type: 'number', label: 'in' },
{ kind: 'data', direction: 'out', type: 'number', label: 'out' },
] },
{ type: 'Display', title: 'Display', category: 'utility',
pins: [{ kind: 'data', direction: 'in', type: 'number', label: 'In' }] },
]
function evalNode(node: Node, inputs: Map<string, unknown>): Map<string, unknown> {
if (node.type === 'Const') return new Map([[node.pins[0]!.id, Number((node.state as { value?: number }).value ?? 0)]])
if (node.type === 'Add' || node.type === 'Multiply') {
const vals = [...inputs.values()].map(Number)
const r = node.type === 'Add' ? vals.reduce((s, v) => s + v, 0) : vals.reduce((s, v) => s * v, 1)
return new Map([[node.pins.find((p) => p.direction === 'out')!.id, r]])
}
if (node.type === 'Identity') {
const v = [...inputs.values()][0]
const outPin = node.pins.find((p) => p.direction === 'out')
if (outPin && v !== undefined) return new Map([[outPin.id, v]])
}
return new Map()
}
function makeExecutor(editor: XenolithEditor): StepExecutor {
return ({ node, inputs, peek }) => {
if (node.type === 'Macro') {
const state = node.state as { collapsed?: boolean; members?: NodeId[] }
const memberIds = state.members ?? []
const lastId = memberIds[memberIds.length - 1]
if (!state.collapsed) {
const out = lastId ? peek(lastId) : undefined
if (!out) return new Map()
const macroOutPin = node.pins.find((p) => p.direction === 'out')
const value = [...out.values()][0]
if (macroOutPin && value !== undefined) return new Map([[macroOutPin.id, value]])
return out
}
const subValues = new Map<string, Map<string, unknown>>()
const subSnap = editor.getGraphReadonly()
const subNodeById = new Map(subSnap.nodes.map((n) => [String(n.id), n]))
for (const mid of memberIds) {
const member = subNodeById.get(String(mid))
if (!member) continue
const memberInputs = new Map<string, unknown>()
for (const e of subSnap.edges) {
if (e.to.node !== mid) continue
if (memberIds.includes(e.from.node as NodeId)) {
const v = subValues.get(e.from.node)?.get(e.from.pin as string)
if (v !== undefined) memberInputs.set(e.to.pin as string, v)
} else {
const v = [...inputs.values()].shift()
if (v !== undefined) memberInputs.set(e.to.pin as string, v)
}
}
subValues.set(mid, evalNode(member as Node, memberInputs))
}
const lastOut = lastId ? subValues.get(lastId) : null
const macroOutPin = node.pins.find((p) => p.direction === 'out')
if (!macroOutPin || !lastOut) return new Map()
const value = [...lastOut.values()][0]
return new Map([[macroOutPin.id, value]])
}
if (node.type === TEMPLATE_INSTANCE_TYPE) {
const v = [...inputs.values()][0]
const outPin = node.pins.find((p) => p.direction === 'out')
if (outPin && v !== undefined) return new Map([[outPin.id, v]])
return new Map()
}
if (node.type === 'Display') return new Map()
return evalNode(node, inputs)
}
}
export function buildStepDemo(editor: XenolithEditor): void {
for (const s of SCHEMAS) editor.registry.register(s)
const a = editor.insertNode('Const', { x: 0, y: 0 })!
const b = editor.insertNode('Const', { x: 0, y: 140 })!
const c = editor.insertNode('Const', { x: 0, y: 280 })!
const add = editor.insertNode('Add', { x: 320, y: 60 })!
const mul = editor.insertNode('Multiply', { x: 640, y: 160 })!
const idn = editor.insertNode('Identity', { x: 880, y: 160 })!
const out = editor.insertNode('Display', { x: 1100, y: 160 })!
editor.setWidgetValue(a.id, 'value', 2)
editor.setWidgetValue(b.id, 'value', 3)
editor.setWidgetValue(c.id, 'value', 4)
const out0 = (n: typeof a) => n.pins.find((p) => p.direction === 'out')!.id
const inAt = (n: typeof add, i: number) => n.pins.filter((p) => p.direction === 'in')[i]!.id
const wire = (from: typeof a, to: typeof add, index: number): void => {
editor.addEdge({
id: crypto.randomUUID(),
from: { node: from.id, pin: out0(from) },
to: { node: to.id, pin: inAt(to, index) },
} as never)
}
wire(a, add, 0)
wire(b, add, 1)
wire(add, mul, 0)
wire(c, mul, 1)
wire(mul, idn, 0)
wire(idn, out, 0)
editor.createMacroFromSelection([add.id, mul.id], 'Compute')
editor.createTemplateFromSelection([idn.id], 'Probe')
editor.view.fitView({ padding: 80 })
}
export interface StepDebugSnapshot {
status: 'idle' | 'paused' | 'running' | 'finished' | 'error'
paused: { nodeType: string; inputs: Array<[string, unknown]> } | null
history: StepRecord[]
planned: string[]
macroExpanded: boolean
busy: boolean
}
export interface StepDebugController {
snapshot(): StepDebugSnapshot
start(): Promise<void>
step(): Promise<void>
continue(): Promise<void>
stop(): void
toggleMacro(): Promise<void>
dispose(): void
}
function findMacroId(editor: XenolithEditor): NodeId | null {
for (const n of editor.getGraphReadonly().nodes) if (n.type === 'Macro') return n.id as NodeId
return null
}
export function attachStepDebugger(editor: XenolithEditor, onChange: () => void): StepDebugController {
const dbg = new StepDebugger(editor.readGraph(), makeExecutor(editor))
const macroId = findMacroId(editor)
const dots = new Map<string, HTMLDivElement>()
const animated = new Set<string>()
let status: StepDebugSnapshot['status'] = 'idle'
let paused: StepDebugSnapshot['paused'] = null
let history: StepRecord[] = []
let planned: string[] = []
let macroExpanded = false
let busy = false
let disposed = false
const emit = (): void => { if (!disposed) onChange() }
const setStatus = (id: NodeId, s: 'idle' | 'running' | 'ok' | 'error'): void => { editor.setNodeStatus(id, s) }
const clearAnimated = (): void => {
for (const id of animated) editor.setEdgeOptions(id as never, { animated: false })
animated.clear()
}
const placeDot = (nodeId: string): void => {
const dot = dots.get(nodeId)
if (!dot) return
const node = editor.getGraphReadonly().nodes.find((n) => String(n.id) === nodeId)
if (!node) { dot.style.display = 'none'; return }
const p = editor.view.worldToScreen({ x: node.position.x, y: node.position.y })
dot.style.left = `${p.x}px`
dot.style.top = `${p.y}px`
dot.style.display = ''
}
const addDot = (nodeId: string): void => {
if (dots.has(nodeId)) return
const dot = document.createElement('div')
Object.assign(dot.style, {
position: 'absolute', width: '10px', height: '10px', borderRadius: '10px',
background: '#ff3b3b', boxShadow: '0 0 6px #ff3b3baa', pointerEvents: 'none',
transform: 'translate(-50%, -50%)', zIndex: '20',
})
editor.chrome.overlayRoot.appendChild(dot)
dots.set(nodeId, dot)
placeDot(nodeId)
}
const removeDot = (nodeId: string): void => {
dots.get(nodeId)?.remove()
dots.delete(nodeId)
}
dbg.on('paused', ({ nodeId, node, inputs }) => {
setStatus(nodeId, 'running')
clearAnimated()
for (const e of editor.getGraphReadonly().edges) {
if (e.to.node !== nodeId) continue
editor.setEdgeOptions(e.id as never, { animated: true })
animated.add(String(e.id))
}
paused = {
nodeType: node.type,
inputs: [...inputs.entries()].map(([pinId, v]) => [node.pins.find((p) => p.id === pinId)?.label ?? pinId, v]),
}
status = 'paused'
emit()
})
dbg.on('stepped', (r) => {
setStatus(r.nodeId, 'ok')
for (const n of editor.getGraphReadonly().nodes) {
if (n.type !== 'Macro') continue
const members = ((n.state as { members?: string[] }).members ?? [])
if (members.length > 0 && members.every((m) => editor.getNode(m as NodeId) && dbg.history.some((h) => String(h.nodeId) === m && true))) {
const done = members.every((m) => dbg.history.some((h) => String(h.nodeId) === String(m)))
if (done) setStatus(n.id as NodeId, 'ok')
}
}
history = [...dbg.history]
emit()
})
dbg.on('finished', () => { status = 'finished'; paused = null; clearAnimated(); emit() })
dbg.on('error', ({ nodeId }) => { setStatus(nodeId, 'error'); status = 'error'; clearAnimated(); emit() })
const offs = [
editor.on('node:click', ({ nodeId }) => {
if (dbg.status === 'idle' || dbg.status === 'finished') return
const on = dbg.toggleBreakpoint(nodeId)
if (on) addDot(String(nodeId))
else removeDot(String(nodeId))
emit()
}),
editor.on('viewport:changed', () => { for (const id of dots.keys()) placeDot(id) }),
editor.on('node:moved', () => { for (const id of dots.keys()) placeDot(id) }),
]
const clearStatuses = (): void => {
for (const n of editor.getGraphReadonly().nodes) setStatus(n.id as NodeId, 'idle')
for (const id of dbg.breakpoints) setStatus(id as NodeId, 'error')
}
const run = async (fn: () => Promise<void>): Promise<void> => {
if (busy) return
busy = true
emit()
try { await fn() } finally { busy = false; emit() }
}
return {
snapshot: () => ({ status, paused, history, planned, macroExpanded, busy }),
start: () => run(async () => {
clearStatuses()
history = []
paused = null
status = 'running'
await dbg.start()
const byId = new Map(editor.getGraphReadonly().nodes.map((n) => [String(n.id), n]))
planned = dbg.order.map((id) => byId.get(String(id))?.type ?? String(id).slice(0, 6))
}),
step: () => run(async () => { await dbg.step() }),
continue: () => run(async () => { status = 'running'; emit(); await dbg.continue() }),
stop(): void {
dbg.stop()
status = 'idle'
paused = null
history = []
planned = []
clearStatuses()
emit()
},
async toggleMacro(): Promise<void> {
if (!macroId || busy) return
const wasActive = dbg.status === 'paused' || dbg.status === 'running'
const visited = new Set(dbg.history.map((r) => String(r.nodeId)))
const members = ((editor.getGraphReadonly().nodes.find((n) => n.id === macroId)?.state as { members?: string[] } | undefined)?.members ?? [])
if (macroExpanded) {
if (members.length > 0 && members.every((m) => visited.has(String(m)))) visited.add(String(macroId))
} else visited.delete(String(macroId))
if (!wasActive) dbg.stop()
if (macroExpanded) editor.collapseMacro(macroId)
else editor.expandMacro(macroId)
macroExpanded = !macroExpanded
if (!wasActive) {
status = 'idle'; history = []; paused = null; planned = []
clearStatuses()
emit()
return
}
await dbg.start()
while (dbg.status === 'paused' && dbg.currentNodeId && visited.has(String(dbg.currentNodeId))) dbg.advance()
status = dbg.status
const byId = new Map(editor.getGraphReadonly().nodes.map((n) => [String(n.id), n]))
planned = dbg.order.map((id) => byId.get(String(id))?.type ?? '?')
emit()
},
dispose(): void {
disposed = true
for (const off of offs) off()
dbg.stop()
clearAnimated()
for (const dot of dots.values()) dot.remove()
dots.clear()
},
}
} import { AfterViewInit, Component, ElementRef, OnDestroy, inject, signal, viewChild } from '@angular/core'
import { XenolithGraphService } from '@xenolithengine/graph-angular'
import {
attachStepDebugger, buildStepDemo,
type StepDebugController, type StepDebugSnapshot,
} from '@xenolithengine/demo/step-debugger'
const EMPTY: StepDebugSnapshot = {
status: 'idle', paused: null, history: [], planned: [], macroExpanded: false, busy: false,
}
@Component({
selector: 'step-debugger-demo',
standalone: true,
providers: [XenolithGraphService],
template: `
<div style="position:absolute;inset:0;">
<div #host class="xeno" style="position:absolute;inset:0;"></div>
<div class="card">
<div class="row">
<span class="dot" [style.background]="dotColor()"></span>
<strong>Debugger {{ statusLabel() }}</strong>
</div>
<div class="row wrap">
<button type="button" class="btn" [class.on]="canStart()" [disabled]="!canStart()" (click)="ctrl?.start()">▶ Start</button>
<button type="button" class="btn" [disabled]="!canStep()" (click)="ctrl?.step()">⤵ Step</button>
<button type="button" class="btn" [disabled]="!canStep()" (click)="ctrl?.continue()">⏩ Continue</button>
<button type="button" class="btn" [disabled]="!canStop()" (click)="ctrl?.stop()">■ Stop</button>
</div>
<button type="button" class="btn" [disabled]="snap().busy" (click)="ctrl?.toggleMacro()">
{{ snap().macroExpanded ? '⟲ Collapse macro' : '⤢ Expand macro' }}
</button>
<span class="note">
Graph: <strong>(2 + 3) × 4 = 20</strong> piped through a template-wrapped Identity
into Display. Add + Multiply are inside macro <em>Compute</em>; Identity is inside
template <em>Probe</em>. Toggle the macro to step its members individually. Templates
are always one step. Click any node while debugging to toggle a breakpoint (red).
Yellow = paused, green = executed.
</span>
</div>
@if (snap().paused) {
<div class="inspect">
<div class="kicker">Paused on</div>
<div class="title">{{ snap().paused!.nodeType }}</div>
<div class="kicker">Inputs</div>
@if (snap().paused!.inputs.length === 0) {
<div class="note">— (no incoming values)</div>
} @else {
@for (row of snap().paused!.inputs; track $index) {
<div class="kv"><span>{{ row[0] }}</span><span class="mono">{{ json(row[1]) }}</span></div>
}
}
</div>
}
<div class="trace">
@if (snap().planned.length > 0) {
<div class="kicker">Planned walk ({{ snap().planned.length }} steps)</div>
<div class="mono walk">{{ plannedText() }}</div>
}
<div class="kicker">Trace</div>
@if (snap().history.length === 0) {
<div class="note">Press ▶ Start, then ⤵ Step.</div>
} @else {
@for (record of snap().history; track $index) {
<div class="trace-row">
<span class="idx">{{ pad($index) }}</span>
<span>{{ record.type }}</span>
<span class="note">{{ record.durationMs.toFixed(2) }}ms</span>
<span class="mono">{{ outputsOf(record) }}</span>
</div>
}
}
</div>
</div>
`,
styles: [`
.card, .inspect, .trace { position:absolute; z-index:5; padding:10px; background:var(--xeno-panel,#1d1d1d);
border:1px solid var(--xeno-border,#333); border-radius:10px; color:var(--xeno-text,#cfcfcf);
font:13px system-ui,sans-serif; }
.card { top:12px; left:12px; max-width:320px; display:flex; flex-direction:column; gap:8px; }
.inspect { top:12px; right:12px; min-width:240px; max-width:320px; }
.trace { bottom:12px; left:12px; min-width:280px; max-width:360px; max-height:260px; overflow:auto; }
.row { display:flex; align-items:center; gap:6px; }
.wrap { flex-wrap:wrap; }
.dot { width:8px; height:8px; border-radius:8px; }
.btn { font:inherit; font-size:13px; padding:7px 12px; cursor:pointer; border-radius:8px;
border:1px solid var(--xeno-border,#333); background:var(--xeno-elevated,#262626); color:var(--xeno-text,#cfcfcf); }
.btn.on { border-color:var(--xeno-accent,#FCB400); background:var(--xeno-accent,#FCB400); color:var(--xeno-canvas,#111); }
.btn:disabled { opacity:.4; cursor:default; }
.note, .kicker { color:var(--xeno-muted,#9a9a9a); font-size:11px; line-height:1.4; }
.kicker { font-size:10px; text-transform:uppercase; margin:6px 0 4px; }
.title { font-size:14px; font-weight:600; }
.kv, .trace-row { display:flex; justify-content:space-between; gap:8px; font-size:11px; padding:2px 0; }
.trace-row { display:grid; grid-template-columns:24px 1fr auto auto; }
.idx { color:var(--xeno-accent,#FCB400); }
.mono { font-family:ui-monospace,monospace; }
.walk { font-size:10px; line-height:1.5; margin-bottom:8px; }
`],
})
export class StepDebuggerDemoComponent implements AfterViewInit, OnDestroy {
private graph = inject(XenolithGraphService)
private host = viewChild.required<ElementRef<HTMLDivElement>>('host')
private tick = signal(0)
ctrl: StepDebugController | null = null
snap(): StepDebugSnapshot {
this.tick()
return this.ctrl?.snapshot() ?? EMPTY
}
canStart(): boolean {
const s = this.snap()
return !s.busy && (s.status === 'idle' || s.status === 'finished' || s.status === 'error')
}
canStep(): boolean { return !this.snap().busy && this.snap().status === 'paused' }
canStop(): boolean { return !this.snap().busy && this.snap().status !== 'idle' }
statusLabel(): string {
return ({ idle: 'idle', paused: 'paused', running: 'running…', finished: 'finished', error: 'error' })[this.snap().status]
}
dotColor(): string {
const s = this.snap().status
if (s === 'paused') return '#fcb400'
if (s === 'running') return '#3ddc97'
if (s === 'error') return '#e25b5b'
if (s === 'finished') return '#5b8def'
return '#666'
}
json(value: unknown): string { return JSON.stringify(value) }
pad(index: number): string { return String(index + 1).padStart(2, '0') }
plannedText(): string {
const planned = this.snap().planned
return planned.map((step, i) => `${i + 1}. ${step}${i < planned.length - 1 ? ' → ' : ''}`).join('')
}
outputsOf(record: StepDebugSnapshot['history'][number]): string {
return [...record.outputs.values()].map((v) => JSON.stringify(v)).join(', ') || '—'
}
async ngAfterViewInit(): Promise<void> {
const editor = await this.graph.mount(this.host().nativeElement, { resizeToWindow: false })
buildStepDemo(editor)
this.ctrl = attachStepDebugger(editor, () => this.tick.update((n) => n + 1))
this.tick.update((n) => n + 1)
}
ngOnDestroy(): void {
this.ctrl?.dispose()
this.ctrl = null
this.graph.destroy()
}
} // Stepping-debugger showcase. The React island keeps its own copy because Playwright
// drives `window.__xenoDebug` on that component. `attachStepDebugger` is the same
// session for every other host: the view re-renders from `snapshot()`.
import { TEMPLATE_INSTANCE_TYPE, type Node, type NodeId, type NodeSchema } from '@xenolithengine/graph-core'
import { StepDebugger, type StepExecutor, type StepRecord, type XenolithEditor } from '@xenolithengine/graph-editor'
const SCHEMAS: NodeSchema[] = [
{ type: 'Const', title: 'Const', category: 'data',
pins: [{ kind: 'data', direction: 'out', type: 'number', label: 'Out' }],
widgets: [{ id: 'value', key: 'value', type: 'number', label: 'value', min: -999, max: 999, step: 1 }] },
{ type: 'Add', title: 'Add', category: 'logic',
pins: [
{ kind: 'data', direction: 'in', type: 'number', label: 'a' },
{ kind: 'data', direction: 'in', type: 'number', label: 'b' },
{ kind: 'data', direction: 'out', type: 'number', label: 'sum' },
] },
{ type: 'Multiply', title: 'Multiply', category: 'logic',
pins: [
{ kind: 'data', direction: 'in', type: 'number', label: 'a' },
{ kind: 'data', direction: 'in', type: 'number', label: 'b' },
{ kind: 'data', direction: 'out', type: 'number', label: 'product' },
] },
{ type: 'Identity', title: 'Identity', category: 'utility',
pins: [
{ kind: 'data', direction: 'in', type: 'number', label: 'in' },
{ kind: 'data', direction: 'out', type: 'number', label: 'out' },
] },
{ type: 'Display', title: 'Display', category: 'utility',
pins: [{ kind: 'data', direction: 'in', type: 'number', label: 'In' }] },
]
function evalNode(node: Node, inputs: Map<string, unknown>): Map<string, unknown> {
if (node.type === 'Const') return new Map([[node.pins[0]!.id, Number((node.state as { value?: number }).value ?? 0)]])
if (node.type === 'Add' || node.type === 'Multiply') {
const vals = [...inputs.values()].map(Number)
const r = node.type === 'Add' ? vals.reduce((s, v) => s + v, 0) : vals.reduce((s, v) => s * v, 1)
return new Map([[node.pins.find((p) => p.direction === 'out')!.id, r]])
}
if (node.type === 'Identity') {
const v = [...inputs.values()][0]
const outPin = node.pins.find((p) => p.direction === 'out')
if (outPin && v !== undefined) return new Map([[outPin.id, v]])
}
return new Map()
}
function makeExecutor(editor: XenolithEditor): StepExecutor {
return ({ node, inputs, peek }) => {
if (node.type === 'Macro') {
const state = node.state as { collapsed?: boolean; members?: NodeId[] }
const memberIds = state.members ?? []
const lastId = memberIds[memberIds.length - 1]
if (!state.collapsed) {
const out = lastId ? peek(lastId) : undefined
if (!out) return new Map()
const macroOutPin = node.pins.find((p) => p.direction === 'out')
const value = [...out.values()][0]
if (macroOutPin && value !== undefined) return new Map([[macroOutPin.id, value]])
return out
}
const subValues = new Map<string, Map<string, unknown>>()
const subSnap = editor.getGraphReadonly()
const subNodeById = new Map(subSnap.nodes.map((n) => [String(n.id), n]))
for (const mid of memberIds) {
const member = subNodeById.get(String(mid))
if (!member) continue
const memberInputs = new Map<string, unknown>()
for (const e of subSnap.edges) {
if (e.to.node !== mid) continue
if (memberIds.includes(e.from.node as NodeId)) {
const v = subValues.get(e.from.node)?.get(e.from.pin as string)
if (v !== undefined) memberInputs.set(e.to.pin as string, v)
} else {
const v = [...inputs.values()].shift()
if (v !== undefined) memberInputs.set(e.to.pin as string, v)
}
}
subValues.set(mid, evalNode(member as Node, memberInputs))
}
const lastOut = lastId ? subValues.get(lastId) : null
const macroOutPin = node.pins.find((p) => p.direction === 'out')
if (!macroOutPin || !lastOut) return new Map()
const value = [...lastOut.values()][0]
return new Map([[macroOutPin.id, value]])
}
if (node.type === TEMPLATE_INSTANCE_TYPE) {
const v = [...inputs.values()][0]
const outPin = node.pins.find((p) => p.direction === 'out')
if (outPin && v !== undefined) return new Map([[outPin.id, v]])
return new Map()
}
if (node.type === 'Display') return new Map()
return evalNode(node, inputs)
}
}
export function buildStepDemo(editor: XenolithEditor): void {
for (const s of SCHEMAS) editor.registry.register(s)
const a = editor.insertNode('Const', { x: 0, y: 0 })!
const b = editor.insertNode('Const', { x: 0, y: 140 })!
const c = editor.insertNode('Const', { x: 0, y: 280 })!
const add = editor.insertNode('Add', { x: 320, y: 60 })!
const mul = editor.insertNode('Multiply', { x: 640, y: 160 })!
const idn = editor.insertNode('Identity', { x: 880, y: 160 })!
const out = editor.insertNode('Display', { x: 1100, y: 160 })!
editor.setWidgetValue(a.id, 'value', 2)
editor.setWidgetValue(b.id, 'value', 3)
editor.setWidgetValue(c.id, 'value', 4)
const out0 = (n: typeof a) => n.pins.find((p) => p.direction === 'out')!.id
const inAt = (n: typeof add, i: number) => n.pins.filter((p) => p.direction === 'in')[i]!.id
const wire = (from: typeof a, to: typeof add, index: number): void => {
editor.addEdge({
id: crypto.randomUUID(),
from: { node: from.id, pin: out0(from) },
to: { node: to.id, pin: inAt(to, index) },
} as never)
}
wire(a, add, 0)
wire(b, add, 1)
wire(add, mul, 0)
wire(c, mul, 1)
wire(mul, idn, 0)
wire(idn, out, 0)
editor.createMacroFromSelection([add.id, mul.id], 'Compute')
editor.createTemplateFromSelection([idn.id], 'Probe')
editor.view.fitView({ padding: 80 })
}
export interface StepDebugSnapshot {
status: 'idle' | 'paused' | 'running' | 'finished' | 'error'
paused: { nodeType: string; inputs: Array<[string, unknown]> } | null
history: StepRecord[]
planned: string[]
macroExpanded: boolean
busy: boolean
}
export interface StepDebugController {
snapshot(): StepDebugSnapshot
start(): Promise<void>
step(): Promise<void>
continue(): Promise<void>
stop(): void
toggleMacro(): Promise<void>
dispose(): void
}
function findMacroId(editor: XenolithEditor): NodeId | null {
for (const n of editor.getGraphReadonly().nodes) if (n.type === 'Macro') return n.id as NodeId
return null
}
export function attachStepDebugger(editor: XenolithEditor, onChange: () => void): StepDebugController {
const dbg = new StepDebugger(editor.readGraph(), makeExecutor(editor))
const macroId = findMacroId(editor)
const dots = new Map<string, HTMLDivElement>()
const animated = new Set<string>()
let status: StepDebugSnapshot['status'] = 'idle'
let paused: StepDebugSnapshot['paused'] = null
let history: StepRecord[] = []
let planned: string[] = []
let macroExpanded = false
let busy = false
let disposed = false
const emit = (): void => { if (!disposed) onChange() }
const setStatus = (id: NodeId, s: 'idle' | 'running' | 'ok' | 'error'): void => { editor.setNodeStatus(id, s) }
const clearAnimated = (): void => {
for (const id of animated) editor.setEdgeOptions(id as never, { animated: false })
animated.clear()
}
const placeDot = (nodeId: string): void => {
const dot = dots.get(nodeId)
if (!dot) return
const node = editor.getGraphReadonly().nodes.find((n) => String(n.id) === nodeId)
if (!node) { dot.style.display = 'none'; return }
const p = editor.view.worldToScreen({ x: node.position.x, y: node.position.y })
dot.style.left = `${p.x}px`
dot.style.top = `${p.y}px`
dot.style.display = ''
}
const addDot = (nodeId: string): void => {
if (dots.has(nodeId)) return
const dot = document.createElement('div')
Object.assign(dot.style, {
position: 'absolute', width: '10px', height: '10px', borderRadius: '10px',
background: '#ff3b3b', boxShadow: '0 0 6px #ff3b3baa', pointerEvents: 'none',
transform: 'translate(-50%, -50%)', zIndex: '20',
})
editor.chrome.overlayRoot.appendChild(dot)
dots.set(nodeId, dot)
placeDot(nodeId)
}
const removeDot = (nodeId: string): void => {
dots.get(nodeId)?.remove()
dots.delete(nodeId)
}
dbg.on('paused', ({ nodeId, node, inputs }) => {
setStatus(nodeId, 'running')
clearAnimated()
for (const e of editor.getGraphReadonly().edges) {
if (e.to.node !== nodeId) continue
editor.setEdgeOptions(e.id as never, { animated: true })
animated.add(String(e.id))
}
paused = {
nodeType: node.type,
inputs: [...inputs.entries()].map(([pinId, v]) => [node.pins.find((p) => p.id === pinId)?.label ?? pinId, v]),
}
status = 'paused'
emit()
})
dbg.on('stepped', (r) => {
setStatus(r.nodeId, 'ok')
for (const n of editor.getGraphReadonly().nodes) {
if (n.type !== 'Macro') continue
const members = ((n.state as { members?: string[] }).members ?? [])
if (members.length > 0 && members.every((m) => editor.getNode(m as NodeId) && dbg.history.some((h) => String(h.nodeId) === m && true))) {
const done = members.every((m) => dbg.history.some((h) => String(h.nodeId) === String(m)))
if (done) setStatus(n.id as NodeId, 'ok')
}
}
history = [...dbg.history]
emit()
})
dbg.on('finished', () => { status = 'finished'; paused = null; clearAnimated(); emit() })
dbg.on('error', ({ nodeId }) => { setStatus(nodeId, 'error'); status = 'error'; clearAnimated(); emit() })
const offs = [
editor.on('node:click', ({ nodeId }) => {
if (dbg.status === 'idle' || dbg.status === 'finished') return
const on = dbg.toggleBreakpoint(nodeId)
if (on) addDot(String(nodeId))
else removeDot(String(nodeId))
emit()
}),
editor.on('viewport:changed', () => { for (const id of dots.keys()) placeDot(id) }),
editor.on('node:moved', () => { for (const id of dots.keys()) placeDot(id) }),
]
const clearStatuses = (): void => {
for (const n of editor.getGraphReadonly().nodes) setStatus(n.id as NodeId, 'idle')
for (const id of dbg.breakpoints) setStatus(id as NodeId, 'error')
}
const run = async (fn: () => Promise<void>): Promise<void> => {
if (busy) return
busy = true
emit()
try { await fn() } finally { busy = false; emit() }
}
return {
snapshot: () => ({ status, paused, history, planned, macroExpanded, busy }),
start: () => run(async () => {
clearStatuses()
history = []
paused = null
status = 'running'
await dbg.start()
const byId = new Map(editor.getGraphReadonly().nodes.map((n) => [String(n.id), n]))
planned = dbg.order.map((id) => byId.get(String(id))?.type ?? String(id).slice(0, 6))
}),
step: () => run(async () => { await dbg.step() }),
continue: () => run(async () => { status = 'running'; emit(); await dbg.continue() }),
stop(): void {
dbg.stop()
status = 'idle'
paused = null
history = []
planned = []
clearStatuses()
emit()
},
async toggleMacro(): Promise<void> {
if (!macroId || busy) return
const wasActive = dbg.status === 'paused' || dbg.status === 'running'
const visited = new Set(dbg.history.map((r) => String(r.nodeId)))
const members = ((editor.getGraphReadonly().nodes.find((n) => n.id === macroId)?.state as { members?: string[] } | undefined)?.members ?? [])
if (macroExpanded) {
if (members.length > 0 && members.every((m) => visited.has(String(m)))) visited.add(String(macroId))
} else visited.delete(String(macroId))
if (!wasActive) dbg.stop()
if (macroExpanded) editor.collapseMacro(macroId)
else editor.expandMacro(macroId)
macroExpanded = !macroExpanded
if (!wasActive) {
status = 'idle'; history = []; paused = null; planned = []
clearStatuses()
emit()
return
}
await dbg.start()
while (dbg.status === 'paused' && dbg.currentNodeId && visited.has(String(dbg.currentNodeId))) dbg.advance()
status = dbg.status
const byId = new Map(editor.getGraphReadonly().nodes.map((n) => [String(n.id), n]))
planned = dbg.order.map((id) => byId.get(String(id))?.type ?? '?')
emit()
},
dispose(): void {
disposed = true
for (const off of offs) off()
dbg.stop()
clearAnimated()
for (const dot of dots.values()) dot.remove()
dots.clear()
},
}
}