Time-travel scrubber
Rewind a graph run. Drag the timeline through every step and watch the highlights replay — green = done, yellow = the step under inspection. The inspector shows that step’s inputs and outputs.
import { useEffect, useMemo, 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 { DemoStage } from '../Layout.js'
// Time-travel debugger: auto-run the StepDebugger to completion, then let the user SCRUB
// through history with a slider. As the slider moves, each node up to index N lights as 'ok'
// and node-at-N pulses 'running'; the inspector shows that step's inputs/outputs. Same core
// (StepDebugger) — purely a UI projection over `dbg.history`.
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: 'Display', title: 'Display', category: 'utility',
pins: [{ kind: 'data', direction: 'in', type: 'number', label: 'In' }] },
]
const executor: StepExecutor = ({ node, inputs }) => {
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]])
}
return new Map()
}
function build(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 out = editor.insertNode('Display', { x: 960, y: 160 })!
editor.setWidgetValue(a.id, 'value', 2)
editor.setWidgetValue(b.id, 'value', 3)
editor.setWidgetValue(c.id, 'value', 4)
const o0 = (n: typeof a) => n.pins.find((p) => p.direction === 'out')!.id
const iAt = (n: typeof add, i: number) => n.pins.filter((p) => p.direction === 'in')[i]!.id
editor.addEdge({ id: crypto.randomUUID(), from: { node: a.id, pin: o0(a) }, to: { node: add.id, pin: iAt(add, 0) } } as never)
editor.addEdge({ id: crypto.randomUUID(), from: { node: b.id, pin: o0(b) }, to: { node: add.id, pin: iAt(add, 1) } } as never)
editor.addEdge({ id: crypto.randomUUID(), from: { node: add.id, pin: o0(add) }, to: { node: mul.id, pin: iAt(mul, 0) } } as never)
editor.addEdge({ id: crypto.randomUUID(), from: { node: c.id, pin: o0(c) }, to: { node: mul.id, pin: iAt(mul, 1) } } as never)
editor.addEdge({ id: crypto.randomUUID(), from: { node: mul.id, pin: o0(mul) }, to: { node: out.id, pin: iAt(out, 0) } } as never)
editor.view.fitView({ padding: 80 })
}
function ScrubPanel() {
const editor = useEditor()
const debuggerRef = useRef<StepDebugger | null>(null)
const [history, setHistory] = useState<StepRecord[]>([])
const [scrub, setScrub] = useState(0)
const [playing, setPlaying] = useState(false)
// Construct + auto-run the debugger once per editor instance.
useEffect(() => {
let cancelled = false
const dbg = new StepDebugger(editor.readGraph(), executor)
debuggerRef.current = dbg
;(async () => {
await dbg.start()
await dbg.continue()
if (cancelled) return
setHistory([...dbg.history])
setScrub(dbg.history.length)
;(window as unknown as { __xenoTimeTravel?: { editor: XenolithEditor; debugger: StepDebugger; nodeStatuses: Record<string, string> } }).__xenoTimeTravel = {
editor, debugger: dbg, nodeStatuses: {},
}
})()
return () => {
cancelled = true
debuggerRef.current = null
;(window as unknown as { __xenoTimeTravel?: unknown }).__xenoTimeTravel = undefined
}
}, [editor])
// Project the scrubber value onto the editor's node statuses: 0..scrub-1 → 'ok',
// scrub-1 → 'running' (the inspected step). Past `scrub`: 'idle'.
useEffect(() => {
if (history.length === 0) return
const statuses: Record<string, string> = {}
const allIds = new Set<string>()
for (const r of history) allIds.add(String(r.nodeId))
for (const id of allIds) { editor.setNodeStatus(id as NodeId, 'idle') }
for (let i = 0; i < Math.min(scrub, history.length); i++) {
const id = history[i]!.nodeId
const s = i === scrub - 1 ? 'running' : 'ok'
editor.setNodeStatus(id, s)
statuses[String(id)] = s
}
const w = (window as unknown as { __xenoTimeTravel?: { nodeStatuses: Record<string, string> } }).__xenoTimeTravel
if (w) w.nodeStatuses = statuses
}, [editor, scrub, history])
// Auto-play through history at 600ms/step.
useEffect(() => {
if (!playing) return
if (scrub >= history.length) { setPlaying(false); return }
const t = setTimeout(() => setScrub((s) => Math.min(s + 1, history.length)), 600)
return () => clearTimeout(t)
}, [playing, scrub, history])
const current = scrub > 0 ? history[scrub - 1] : null
const node = useMemo<Node | undefined>(() => {
if (!current) return undefined
return editor.getGraphReadonly().nodes.find((n) => n.id === current.nodeId) as Node | undefined
}, [editor, current])
return (
<>
<XenolithPanel position="top-left" style={{ display: 'flex', flexDirection: 'column', gap: 8, maxWidth: 360, padding: 10 }}>
<div style={{ fontSize: 12, color: 'var(--xeno-text)', fontWeight: 600 }}>Time-travel scrubber</div>
<div style={{ fontSize: 11, color: 'var(--xeno-muted)', lineHeight: 1.4 }}>
The graph ran <strong>(2 + 3) × 4 = 20</strong> from start to finish. Drag the slider
to rewind through {history.length} steps — green = done, yellow = the step you're
inspecting. Press Play to auto-advance.
</div>
<input
type="range" min={0} max={history.length} value={scrub} step={1}
onChange={(e) => { setPlaying(false); setScrub(Number(e.target.value)) }}
data-testid="scrub"
style={{ width: '100%' }}
/>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', fontSize: 11, color: 'var(--xeno-text)' }}>
<XenolithButton active={playing} onClick={() => { if (scrub >= history.length) setScrub(0); setPlaying(!playing) }}>
{playing ? '⏸ Pause' : '▶ Play'}
</XenolithButton>
<XenolithButton onClick={() => { setPlaying(false); setScrub(0) }}>⟲ Reset</XenolithButton>
<span style={{ marginLeft: 'auto', color: 'var(--xeno-muted)' }}>{scrub}/{history.length}</span>
</div>
</XenolithPanel>
{current && node && (
<XenolithPanel position="top-right" style={{ minWidth: 240, maxWidth: 320, padding: 10 }}>
<div style={{ fontSize: 10, color: 'var(--xeno-muted)', textTransform: 'uppercase' }}>Step {scrub}</div>
<div style={{ fontSize: 14, color: 'var(--xeno-text)', fontWeight: 600 }}>{node.type}</div>
<div style={{ fontSize: 10, color: 'var(--xeno-muted)', marginTop: 8 }}>Outputs</div>
{[...current.outputs.entries()].map(([k, v], i) => {
const pin = node.pins.find((p) => p.id === k)
return (
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', fontSize: 11, padding: '2px 0' }}>
<span style={{ color: 'var(--xeno-muted)' }}>{pin?.label ?? k}</span>
<span style={{ color: 'var(--xeno-text)', fontFamily: 'ui-monospace, monospace' }}>{JSON.stringify(v)}</span>
</div>
)
})}
<div style={{ fontSize: 10, color: 'var(--xeno-muted)', marginTop: 6 }}>{current.durationMs.toFixed(2)}ms</div>
</XenolithPanel>
)}
</>
)
}
export function TimeTravelDemo() {
return (
<DemoStage>
<XenolithGraph className="xeno" resizeToWindow={false} onReady={build}>
<ScrubPanel />
</XenolithGraph>
</DemoStage>
)
} <script setup lang="ts">
import { XenolithGraph } from '@xenolithengine/graph-vue'
import ScrubPanel from './ScrubPanel.vue'
</script>
<template>
<div class="app" style="position:absolute;inset:0;">
<XenolithGraph class="xeno" :resize-to-window="false">
<ScrubPanel />
</XenolithGraph>
</div>
</template> <script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue'
import { XenolithPanel, XenolithButton, useEditor } from '@xenolithengine/graph-vue'
import type { StepRecord } from '@xenolithengine/graph-editor'
import { buildTimeTravel, runTimeTravel, showTimeTravelStep } from '@xenolithengine/demo/time-travel'
const editor = useEditor()
const history = ref<StepRecord[]>([])
const scrub = ref(0)
const playing = ref(false)
let timer: ReturnType<typeof setTimeout> | undefined
watch(editor, (ed) => {
history.value = []
scrub.value = 0
playing.value = false
if (!ed) return
buildTimeTravel(ed)
void runTimeTravel(ed).then((steps) => {
if (editor.value !== ed) return
history.value = steps
scrub.value = steps.length
})
}, { immediate: true })
watch([history, scrub], () => {
const ed = editor.value
if (!ed || history.value.length === 0) return
showTimeTravelStep(ed, history.value, scrub.value)
})
watch([playing, scrub, history], () => {
clearTimeout(timer)
if (!playing.value) return
if (scrub.value >= history.value.length) { playing.value = false; return }
timer = setTimeout(() => { scrub.value = Math.min(scrub.value + 1, history.value.length) }, 600)
})
onUnmounted(() => clearTimeout(timer))
const current = computed(() => (scrub.value > 0 ? history.value[scrub.value - 1] : undefined))
const node = computed(() => {
const rec = current.value
const ed = editor.value
if (!rec || !ed) return undefined
return ed.getNode(rec.nodeId)
})
const outputRows = computed(() => {
const rec = current.value
const n = node.value
if (!rec || !n) return []
return [...rec.outputs.entries()].map(([pinId, value]) => ({
label: n.pins.find((p) => p.id === pinId)?.label ?? pinId,
value: JSON.stringify(value),
}))
})
function onScrub(event: Event): void {
playing.value = false
scrub.value = Number((event.target as HTMLInputElement).value)
}
function togglePlay(): void {
if (scrub.value >= history.value.length) scrub.value = 0
playing.value = !playing.value
}
function reset(): void {
playing.value = false
scrub.value = 0
}
</script>
<template>
<XenolithPanel position="top-left">
<div style="display:flex;flex-direction:column;gap:8px;max-width:360px;">
<div style="font-size:12px;font-weight:600;">Time-travel scrubber</div>
<div style="font-size:11px;color:var(--xeno-muted);line-height:1.4;">
The graph ran <strong>(2 + 3) × 4 = 20</strong> from start to finish. Drag the slider
to rewind through {{ history.length }} steps — green = done, yellow = the step you're
inspecting. Press Play to auto-advance.
</div>
<input data-testid="scrub" type="range" min="0" :max="history.length" :value="scrub" step="1" style="width:100%" @input="onScrub" />
<div style="display:flex;gap:6px;align-items:center;font-size:11px;">
<XenolithButton :active="playing" @click="togglePlay">{{ playing ? '⏸ Pause' : '▶ Play' }}</XenolithButton>
<XenolithButton @click="reset">⟲ Reset</XenolithButton>
<span style="margin-left:auto;color:var(--xeno-muted);">{{ scrub }}/{{ history.length }}</span>
</div>
</div>
</XenolithPanel>
<XenolithPanel v-if="current && node" position="top-right">
<div style="min-width:240px;max-width:320px;">
<div style="font-size:10px;color:var(--xeno-muted);text-transform:uppercase;">Step {{ scrub }}</div>
<div style="font-size:14px;font-weight:600;">{{ node.type }}</div>
<div style="font-size:10px;color:var(--xeno-muted);margin-top:8px;">Outputs</div>
<div v-for="(row, i) in outputRows" :key="i" style="display:flex;justify-content:space-between;font-size:11px;padding:2px 0;">
<span style="color:var(--xeno-muted);">{{ row.label }}</span>
<span style="font-family:ui-monospace,monospace;">{{ row.value }}</span>
</div>
<div style="font-size:10px;color:var(--xeno-muted);margin-top:6px;">{{ current.durationMs.toFixed(2) }}ms</div>
</div>
</XenolithPanel>
</template> // Time-travel scrubber core. The React island keeps its own copy (Playwright reads
// `window.__xenoTimeTravel` there). Hosts call build, run to completion, then project
// a scrub index onto node statuses.
import type { NodeId, 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: 'Display', title: 'Display', category: 'utility',
pins: [{ kind: 'data', direction: 'in', type: 'number', label: 'In' }] },
]
const executor: StepExecutor = ({ node, inputs }) => {
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]])
}
return new Map()
}
export function buildTimeTravel(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 out = editor.insertNode('Display', { x: 960, y: 160 })!
editor.setWidgetValue(a.id, 'value', 2)
editor.setWidgetValue(b.id, 'value', 3)
editor.setWidgetValue(c.id, 'value', 4)
const o0 = (n: typeof a) => n.pins.find((p) => p.direction === 'out')!.id
const iAt = (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: o0(from) },
to: { node: to.id, pin: iAt(to, index) },
} as never)
}
wire(a, add, 0)
wire(b, add, 1)
wire(add, mul, 0)
wire(c, mul, 1)
wire(mul, out, 0)
editor.view.fitView({ padding: 80 })
}
/** Run the graph to the end and return the step history. */
export async function runTimeTravel(editor: XenolithEditor): Promise<StepRecord[]> {
const dbg = new StepDebugger(editor.readGraph(), executor)
await dbg.start()
await dbg.continue()
return [...dbg.history]
}
/** Paint statuses for a scrub index. Steps before it are done; the index itself is the one under inspection. */
export function showTimeTravelStep(editor: XenolithEditor, history: readonly StepRecord[], scrub: number): void {
const ids = new Set(history.map((r) => String(r.nodeId)))
for (const id of ids) editor.setNodeStatus(id as NodeId, 'idle')
for (let i = 0; i < Math.min(scrub, history.length); i++) {
const id = history[i]!.nodeId
editor.setNodeStatus(id, i === scrub - 1 ? 'running' : 'ok')
}
} <script lang="ts">
import { onDestroy } from 'svelte'
import { xenolith, createXenolithEditorContext } from '@xenolithengine/graph-svelte'
import { XenolithPanel, XenolithButton } from '@xenolithengine/graph-svelte/components'
import type { StepRecord, XenolithEditor } from '@xenolithengine/graph-editor'
import { buildTimeTravel, runTimeTravel, showTimeTravelStep } from '@xenolithengine/demo/time-travel'
const panelEditor = createXenolithEditorContext()
let editor = $state<XenolithEditor | null>(null)
let history = $state<StepRecord[]>([])
let scrub = $state(0)
let playing = $state(false)
let timer: ReturnType<typeof setTimeout> | undefined
$effect(() => {
const live = editor
const steps = history
const index = scrub
if (!live || steps.length === 0) return
showTimeTravelStep(live, steps, index)
})
$effect(() => {
const on = playing
const index = scrub
const total = history.length
clearTimeout(timer)
if (!on) return
if (index >= total) { playing = false; return }
timer = setTimeout(() => { scrub = Math.min(scrub + 1, history.length) }, 600)
})
onDestroy(() => clearTimeout(timer))
const current = $derived(scrub > 0 ? history[scrub - 1] : undefined)
const node = $derived(current && editor ? editor.getNode(current.nodeId) : undefined)
const outputRows = $derived(!current || !node ? [] : [...current.outputs.entries()].map(([pinId, value]) => ({
label: node.pins.find((p) => p.id === pinId)?.label ?? pinId,
value: JSON.stringify(value),
})))
function onScrub(event: Event): void {
playing = false
scrub = Number((event.target as HTMLInputElement).value)
}
function togglePlay(): void {
if (scrub >= history.length) scrub = 0
playing = !playing
}
</script>
<div
use:xenolith={{ resizeToWindow: false }}
onready={(e) => {
editor = e.detail
panelEditor.set(e.detail)
buildTimeTravel(e.detail)
void runTimeTravel(e.detail).then((steps) => {
if (editor !== e.detail) return
history = steps
scrub = steps.length
})
}}
style="position:absolute;inset:0"
></div>
<XenolithPanel position="top-left">
<div style="display:flex;flex-direction:column;gap:8px;max-width:360px;">
<div style="font-size:12px;font-weight:600;">Time-travel scrubber</div>
<div style="font-size:11px;color:var(--xeno-muted);line-height:1.4;">
The graph ran <strong>(2 + 3) × 4 = 20</strong> from start to finish. Drag the slider
to rewind through {history.length} steps — green = done, yellow = the step you're
inspecting. Press Play to auto-advance.
</div>
<input data-testid="scrub" type="range" min="0" max={history.length} value={scrub} step="1" style="width:100%" oninput={onScrub} />
<div style="display:flex;gap:6px;align-items:center;font-size:11px;">
<XenolithButton active={playing} onclick={togglePlay}>{playing ? '⏸ Pause' : '▶ Play'}</XenolithButton>
<XenolithButton onclick={() => { playing = false; scrub = 0 }}>⟲ Reset</XenolithButton>
<span style="margin-left:auto;color:var(--xeno-muted);">{scrub}/{history.length}</span>
</div>
</div>
</XenolithPanel>
{#if current && node}
<XenolithPanel position="top-right">
<div style="min-width:240px;max-width:320px;">
<div style="font-size:10px;color:var(--xeno-muted);text-transform:uppercase;">Step {scrub}</div>
<div style="font-size:14px;font-weight:600;">{node.type}</div>
<div style="font-size:10px;color:var(--xeno-muted);margin-top:8px;">Outputs</div>
{#each outputRows as row}
<div style="display:flex;justify-content:space-between;font-size:11px;padding:2px 0;">
<span style="color:var(--xeno-muted);">{row.label}</span>
<span style="font-family:ui-monospace,monospace;">{row.value}</span>
</div>
{/each}
<div style="font-size:10px;color:var(--xeno-muted);margin-top:6px;">{current.durationMs.toFixed(2)}ms</div>
</div>
</XenolithPanel>
{/if} // Time-travel scrubber core. The React island keeps its own copy (Playwright reads
// `window.__xenoTimeTravel` there). Hosts call build, run to completion, then project
// a scrub index onto node statuses.
import type { NodeId, 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: 'Display', title: 'Display', category: 'utility',
pins: [{ kind: 'data', direction: 'in', type: 'number', label: 'In' }] },
]
const executor: StepExecutor = ({ node, inputs }) => {
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]])
}
return new Map()
}
export function buildTimeTravel(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 out = editor.insertNode('Display', { x: 960, y: 160 })!
editor.setWidgetValue(a.id, 'value', 2)
editor.setWidgetValue(b.id, 'value', 3)
editor.setWidgetValue(c.id, 'value', 4)
const o0 = (n: typeof a) => n.pins.find((p) => p.direction === 'out')!.id
const iAt = (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: o0(from) },
to: { node: to.id, pin: iAt(to, index) },
} as never)
}
wire(a, add, 0)
wire(b, add, 1)
wire(add, mul, 0)
wire(c, mul, 1)
wire(mul, out, 0)
editor.view.fitView({ padding: 80 })
}
/** Run the graph to the end and return the step history. */
export async function runTimeTravel(editor: XenolithEditor): Promise<StepRecord[]> {
const dbg = new StepDebugger(editor.readGraph(), executor)
await dbg.start()
await dbg.continue()
return [...dbg.history]
}
/** Paint statuses for a scrub index. Steps before it are done; the index itself is the one under inspection. */
export function showTimeTravelStep(editor: XenolithEditor, history: readonly StepRecord[], scrub: number): void {
const ids = new Set(history.map((r) => String(r.nodeId)))
for (const id of ids) editor.setNodeStatus(id as NodeId, 'idle')
for (let i = 0; i < Math.min(scrub, history.length); i++) {
const id = history[i]!.nodeId
editor.setNodeStatus(id, i === scrub - 1 ? 'running' : 'ok')
}
} import { createEffect, createMemo, createSignal, onCleanup } from 'solid-js'
import { xenolith } from '@xenolithengine/graph-solid'
import type { StepRecord, XenolithEditor } from '@xenolithengine/graph-editor'
import { buildTimeTravel, runTimeTravel, showTimeTravelStep } from '@xenolithengine/demo/time-travel'
export function TimeTravelDemo() {
let editor: XenolithEditor | null = null
const [history, setHistory] = createSignal<StepRecord[]>([])
const [scrub, setScrub] = createSignal(0)
const [playing, setPlaying] = createSignal(false)
let timer: ReturnType<typeof setTimeout> | undefined
onCleanup(() => clearTimeout(timer))
createEffect(() => {
const live = editor
const steps = history()
const index = scrub()
if (!live || steps.length === 0) return
showTimeTravelStep(live, steps, index)
})
createEffect(() => {
const on = playing()
const index = scrub()
const total = history().length
clearTimeout(timer)
if (!on) return
if (index >= total) { setPlaying(false); return }
timer = setTimeout(() => setScrub((s) => Math.min(s + 1, history().length)), 600)
})
const current = createMemo(() => (scrub() > 0 ? history()[scrub() - 1] : undefined))
const node = createMemo(() => {
const rec = current()
if (!rec || !editor) return undefined
return editor.getNode(rec.nodeId)
})
const outputRows = createMemo(() => {
const rec = current()
const n = node()
if (!rec || !n) return []
return [...rec.outputs.entries()].map(([pinId, value]) => ({
label: n.pins.find((p) => p.id === pinId)?.label ?? pinId,
value: JSON.stringify(value),
}))
})
const btn = (on: boolean): string =>
`font:inherit;font-size:13px;padding:7px 12px;border-radius:8px;cursor:pointer;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)'};`
return (
<div
use:xenolith={{ resizeToWindow: false }}
on:ready={(e) => {
editor = e.detail
buildTimeTravel(e.detail)
void runTimeTravel(e.detail).then((steps) => {
if (editor !== e.detail) return
setHistory(steps)
setScrub(steps.length)
})
}}
style="position:absolute;inset:0"
>
<div style="position:absolute;top:12px;left:12px;z-index:5;display:flex;flex-direction:column;gap:8px;max-width:360px;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;">
<div style="font-size:12px;font-weight:600;">Time-travel scrubber</div>
<div style="font-size:11px;color:var(--xeno-muted);line-height:1.4;">
The graph ran <strong>(2 + 3) × 4 = 20</strong> from start to finish. Drag the slider
to rewind through {history().length} steps — green = done, yellow = the step you're
inspecting. Press Play to auto-advance.
</div>
<input data-testid="scrub" type="range" min="0" max={history().length} value={scrub()} step="1" style="width:100%" onInput={(ev) => { setPlaying(false); setScrub(Number(ev.currentTarget.value)) }} />
<div style="display:flex;gap:6px;align-items:center;font-size:11px;">
<button type="button" style={btn(playing())} onClick={() => { if (scrub() >= history().length) setScrub(0); setPlaying(!playing()) }}>{playing() ? '⏸ Pause' : '▶ Play'}</button>
<button type="button" style={btn(false)} onClick={() => { setPlaying(false); setScrub(0) }}>⟲ Reset</button>
<span style="margin-left:auto;color:var(--xeno-muted);">{scrub()}/{history().length}</span>
</div>
</div>
{current() && node() && (
<div style="position:absolute;top:12px;right:12px;z-index:5;min-width:240px;max-width:320px;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;">
<div style="font-size:10px;color:var(--xeno-muted);text-transform:uppercase;">Step {scrub()}</div>
<div style="font-size:14px;font-weight:600;">{node()!.type}</div>
<div style="font-size:10px;color:var(--xeno-muted);margin-top:8px;">Outputs</div>
{outputRows().map((row) => (
<div style="display:flex;justify-content:space-between;font-size:11px;padding:2px 0;">
<span style="color:var(--xeno-muted);">{row.label}</span>
<span style="font-family:ui-monospace,monospace;">{row.value}</span>
</div>
))}
<div style="font-size:10px;color:var(--xeno-muted);margin-top:6px;">{current()!.durationMs.toFixed(2)}ms</div>
</div>
)}
</div>
)
} // Time-travel scrubber core. The React island keeps its own copy (Playwright reads
// `window.__xenoTimeTravel` there). Hosts call build, run to completion, then project
// a scrub index onto node statuses.
import type { NodeId, 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: 'Display', title: 'Display', category: 'utility',
pins: [{ kind: 'data', direction: 'in', type: 'number', label: 'In' }] },
]
const executor: StepExecutor = ({ node, inputs }) => {
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]])
}
return new Map()
}
export function buildTimeTravel(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 out = editor.insertNode('Display', { x: 960, y: 160 })!
editor.setWidgetValue(a.id, 'value', 2)
editor.setWidgetValue(b.id, 'value', 3)
editor.setWidgetValue(c.id, 'value', 4)
const o0 = (n: typeof a) => n.pins.find((p) => p.direction === 'out')!.id
const iAt = (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: o0(from) },
to: { node: to.id, pin: iAt(to, index) },
} as never)
}
wire(a, add, 0)
wire(b, add, 1)
wire(add, mul, 0)
wire(c, mul, 1)
wire(mul, out, 0)
editor.view.fitView({ padding: 80 })
}
/** Run the graph to the end and return the step history. */
export async function runTimeTravel(editor: XenolithEditor): Promise<StepRecord[]> {
const dbg = new StepDebugger(editor.readGraph(), executor)
await dbg.start()
await dbg.continue()
return [...dbg.history]
}
/** Paint statuses for a scrub index. Steps before it are done; the index itself is the one under inspection. */
export function showTimeTravelStep(editor: XenolithEditor, history: readonly StepRecord[], scrub: number): void {
const ids = new Set(history.map((r) => String(r.nodeId)))
for (const id of ids) editor.setNodeStatus(id as NodeId, 'idle')
for (let i = 0; i < Math.min(scrub, history.length); i++) {
const id = history[i]!.nodeId
editor.setNodeStatus(id, i === scrub - 1 ? 'running' : 'ok')
}
} import { AfterViewInit, Component, ElementRef, OnDestroy, inject, signal, viewChild } from '@angular/core'
import { XenolithGraphService } from '@xenolithengine/graph-angular'
import type { StepRecord, XenolithEditor } from '@xenolithengine/graph-editor'
import { buildTimeTravel, runTimeTravel, showTimeTravelStep } from '@xenolithengine/demo/time-travel'
@Component({
selector: 'time-travel-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="title">Time-travel scrubber</div>
<span class="note">
The graph ran <strong>(2 + 3) × 4 = 20</strong> from start to finish. Drag the slider
to rewind through {{ history().length }} steps — green = done, yellow = the step you're
inspecting. Press Play to auto-advance.
</span>
<input data-testid="scrub" type="range" min="0" [max]="history().length" [value]="scrub()" step="1" (input)="onScrub($event)" />
<div class="row">
<button type="button" class="btn" [class.on]="playing()" (click)="togglePlay()">{{ playing() ? '⏸ Pause' : '▶ Play' }}</button>
<button type="button" class="btn" (click)="reset()">⟲ Reset</button>
<span class="count">{{ scrub() }}/{{ history().length }}</span>
</div>
</div>
@if (current() && inspected()) {
<div class="inspect">
<div class="kicker">Step {{ scrub() }}</div>
<div class="title">{{ inspected()!.type }}</div>
<div class="kicker">Outputs</div>
@for (row of outputRows(); track row.label) {
<div class="kv"><span>{{ row.label }}</span><span class="mono">{{ row.value }}</span></div>
}
<div class="kicker">{{ current()!.durationMs.toFixed(2) }}ms</div>
</div>
}
</div>
`,
styles: [`
.card, .inspect { position:absolute; top:12px; 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; display:flex; flex-direction:column; gap:8px; }
.card { left:12px; max-width:360px; }
.inspect { right:12px; min-width:240px; max-width:320px; }
.title { font-size:12px; font-weight:600; }
.inspect .title { font-size:14px; }
.note, .kicker { color:var(--xeno-muted,#9a9a9a); font-size:11px; line-height:1.4; }
.kicker { font-size:10px; text-transform:uppercase; }
.row { display:flex; gap:6px; align-items:center; }
.count { margin-left:auto; color:var(--xeno-muted,#9a9a9a); font-size:11px; }
.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); }
input[type=range] { width:100%; }
.kv { display:flex; justify-content:space-between; font-size:11px; }
.mono { font-family:ui-monospace,monospace; }
`],
})
export class TimeTravelDemoComponent implements AfterViewInit, OnDestroy {
private graph = inject(XenolithGraphService)
private host = viewChild.required<ElementRef<HTMLDivElement>>('host')
private editor: XenolithEditor | null = null
private timer: ReturnType<typeof setTimeout> | undefined
history = signal<StepRecord[]>([])
scrub = signal(0)
playing = signal(false)
current(): StepRecord | undefined {
const steps = this.history()
const index = this.scrub()
return index > 0 ? steps[index - 1] : undefined
}
inspected() {
const rec = this.current()
return rec && this.editor ? this.editor.getNode(rec.nodeId) : undefined
}
outputRows(): Array<{ label: string; value: string }> {
const rec = this.current()
const node = this.inspected()
if (!rec || !node) return []
return [...rec.outputs.entries()].map(([pinId, value]) => ({
label: node.pins.find((p) => p.id === pinId)?.label ?? pinId,
value: JSON.stringify(value),
}))
}
async ngAfterViewInit(): Promise<void> {
const editor = await this.graph.mount(this.host().nativeElement, { resizeToWindow: false })
this.editor = editor
buildTimeTravel(editor)
const steps = await runTimeTravel(editor)
if (this.editor !== editor) return
this.history.set(steps)
this.scrub.set(steps.length)
this.paint()
}
onScrub(event: Event): void {
this.playing.set(false)
this.arm()
this.scrub.set(Number((event.target as HTMLInputElement).value))
this.paint()
}
togglePlay(): void {
if (this.scrub() >= this.history().length) this.scrub.set(0)
this.playing.update((on) => !on)
this.arm()
this.paint()
}
reset(): void {
this.playing.set(false)
this.arm()
this.scrub.set(0)
this.paint()
}
private paint(): void {
const editor = this.editor
const steps = this.history()
if (!editor || steps.length === 0) return
showTimeTravelStep(editor, steps, this.scrub())
}
private arm(): void {
clearTimeout(this.timer)
if (!this.playing()) return
if (this.scrub() >= this.history().length) { this.playing.set(false); return }
this.timer = setTimeout(() => {
this.scrub.update((s) => Math.min(s + 1, this.history().length))
this.paint()
this.arm()
}, 600)
}
ngOnDestroy(): void {
clearTimeout(this.timer)
this.graph.destroy()
}
} // Time-travel scrubber core. The React island keeps its own copy (Playwright reads
// `window.__xenoTimeTravel` there). Hosts call build, run to completion, then project
// a scrub index onto node statuses.
import type { NodeId, 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: 'Display', title: 'Display', category: 'utility',
pins: [{ kind: 'data', direction: 'in', type: 'number', label: 'In' }] },
]
const executor: StepExecutor = ({ node, inputs }) => {
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]])
}
return new Map()
}
export function buildTimeTravel(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 out = editor.insertNode('Display', { x: 960, y: 160 })!
editor.setWidgetValue(a.id, 'value', 2)
editor.setWidgetValue(b.id, 'value', 3)
editor.setWidgetValue(c.id, 'value', 4)
const o0 = (n: typeof a) => n.pins.find((p) => p.direction === 'out')!.id
const iAt = (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: o0(from) },
to: { node: to.id, pin: iAt(to, index) },
} as never)
}
wire(a, add, 0)
wire(b, add, 1)
wire(add, mul, 0)
wire(c, mul, 1)
wire(mul, out, 0)
editor.view.fitView({ padding: 80 })
}
/** Run the graph to the end and return the step history. */
export async function runTimeTravel(editor: XenolithEditor): Promise<StepRecord[]> {
const dbg = new StepDebugger(editor.readGraph(), executor)
await dbg.start()
await dbg.continue()
return [...dbg.history]
}
/** Paint statuses for a scrub index. Steps before it are done; the index itself is the one under inspection. */
export function showTimeTravelStep(editor: XenolithEditor, history: readonly StepRecord[], scrub: number): void {
const ids = new Set(history.map((r) => String(r.nodeId)))
for (const id of ids) editor.setNodeStatus(id as NodeId, 'idle')
for (let i = 0; i < Math.min(scrub, history.length); i++) {
const id = history[i]!.nodeId
editor.setNodeStatus(id, i === scrub - 1 ? 'running' : 'ok')
}
}