Skip to main content

logicaffeine_web/ui/components/
debug_drawer.rs

1//! The Studio bottom **debug drawer** — an IDE-style debugger for imperative
2//! (Code-mode) LOGOS, driven by the zero-cost bytecode debugger in
3//! `logicaffeine_compile::debug`. A self-contained, additive panel: it docks below
4//! the editor/output and slides away on Stop, leaving the rest of the Studio
5//! untouched.
6//!
7//! Anatomy (classic IDE Debug panel): a step toolbar (Step Into / Over / Out /
8//! Continue / **Step Back** / Restart / Stop), a status line (state · pc · current
9//! op · step counter), and Variables / Call Stack / Breakpoints / Bytecode tabs.
10//! Breakpoints are set on the bytecode tape (this is a bytecode-level debugger —
11//! you watch the actual VM). Step Back is true time-travel, free from the
12//! debugger's snapshot history.
13
14use dioxus::prelude::*;
15use logicaffeine_compile::debug::{CausalNode, DebugSnapshot, Debugger, ProofVerdict, VarTimeline};
16
17#[derive(Clone, Copy, PartialEq)]
18enum DebugTab {
19    Variables,
20    Stack,
21    Heap,
22    Timeline,
23    Prove,
24    CallStack,
25    Breakpoints,
26    Bytecode,
27}
28
29/// The bottom debug drawer. `source` is the Code-mode program; `on_close` stops the
30/// session (the Studio hides the drawer).
31#[component]
32pub fn DebugDrawer(source: String, on_close: EventHandler<()>) -> Element {
33    // The debugger is built once from the source the session opened with; `armed_source`
34    // remembers that source so we can tell when the editor/file has since changed.
35    let mut dbg = use_signal(|| Debugger::from_source(&source));
36    let mut armed_source = use_signal(|| source.clone());
37    let mut tab = use_signal(|| DebugTab::Variables);
38    // The variable whose causal provenance is being traced (click a value to set it).
39    let mut traced = use_signal(|| None::<u16>);
40    // The live-proof query typed into the Prove tab.
41    let mut prove_query = use_signal(String::new);
42    // Socratic mode (default on): the teaching line asks you to predict the step's
43    // outcome rather than just telling you what it does.
44    let mut socratic_mode = use_signal(|| true);
45    // The "virtual hardware" easter egg — off by default, bundle-light.
46    let mut hw_open = use_signal(|| false);
47    // Auto-play: a timer steps the program so you can watch it run.
48    let mut playing = use_signal(|| false);
49    let speed = use_signal(|| 650u32);
50    use_future(move || async move {
51        loop {
52            #[cfg(target_arch = "wasm32")]
53            {
54                gloo_timers::future::TimeoutFuture::new(speed().max(60)).await;
55                if playing() {
56                    let mut still = false;
57                    if let Ok(d) = dbg.write().as_mut() {
58                        if d.is_running() {
59                            d.step();
60                            still = d.is_running();
61                        }
62                    }
63                    if !still {
64                        playing.set(false);
65                    }
66                }
67            }
68            #[cfg(not(target_arch = "wasm32"))]
69            {
70                let _ = (speed, playing, dbg);
71                break;
72            }
73        }
74    });
75
76    // Compile error → a minimal error drawer (nothing to step).
77    if let Err(msg) = &*dbg.read() {
78        let msg = msg.clone();
79        return rsx! {
80            style { "{DEBUG_DRAWER_STYLE}" }
81            div { class: "dbg-drawer",
82                div { class: "dbg-bar",
83                    span { class: "dbg-title", "Debugger" }
84                    button { class: "dbg-btn dbg-stop", onclick: move |_| on_close.call(()), "Close" }
85                }
86                div { class: "dbg-error", "Cannot debug: {msg}" }
87            }
88        };
89    }
90
91    // Pull a fresh snapshot + disassembly for this render.
92    let snap = dbg.read().as_ref().ok().map(|d| d.snapshot());
93    let snap = match snap {
94        Some(s) => s,
95        None => return rsx! {},
96    };
97    let disasm = dbg
98        .read()
99        .as_ref()
100        .ok()
101        .map(|d| {
102            d.disassembly()
103                .iter()
104                .map(|l| (l.pc, l.text.clone()))
105                .collect::<Vec<_>>()
106        })
107        .unwrap_or_default();
108    let bps: std::collections::BTreeSet<usize> =
109        dbg.read().as_ref().ok().map(|d| d.breakpoints().into_iter().collect()).unwrap_or_default();
110
111    let paused = snap.state == "paused";
112    let at_start = snap.step == 0;
113    let cur_tab = *tab.read();
114
115    // ── step toolbar handlers (each mutates the debugger in place) ─────────────
116    let act = move |f: fn(&mut Debugger)| {
117        move |_| {
118            if let Ok(d) = dbg.write().as_mut() {
119                f(d);
120            }
121        }
122    };
123
124    // The editor/file changed since we armed this session (a different program).
125    let stale = armed_source() != source;
126    let reload_source = source.clone();
127
128    rsx! {
129        style { "{DEBUG_DRAWER_STYLE}" }
130        div { class: "dbg-drawer",
131            // Toolbar + status line
132            div { class: "dbg-bar",
133                span { class: "dbg-title",
134                    span { class: "dbg-bug", dangerous_inner_html: IC_BUG }
135                    "Debug"
136                }
137                div { class: "dbg-controls",
138                    button { class: "dbg-btn dbg-rev", disabled: at_start, title: "Reverse continue (back to the previous breakpoint)",
139                        onclick: act(Debugger::reverse_resume), dangerous_inner_html: IC_REVERSE }
140                    button { class: "dbg-btn dbg-rev", disabled: at_start, title: "Step back (time-travel one op)",
141                        onclick: act(Debugger::step_back), dangerous_inner_html: IC_STEP_BACK }
142                    span { class: "dbg-divider" }
143                    button { class: if playing() { "dbg-btn dbg-play on" } else { "dbg-btn dbg-play" },
144                        disabled: !paused && !playing(),
145                        title: if playing() { "Pause" } else { "Auto-play \u{2014} watch it run" },
146                        onclick: move |_| { let p = playing(); playing.set(!p); },
147                        dangerous_inner_html: if playing() { IC_PAUSE } else { IC_PLAY } }
148                    button { class: "dbg-btn dbg-go", disabled: !paused, title: "Continue",
149                        onclick: act(Debugger::resume), dangerous_inner_html: IC_CONTINUE }
150                    button { class: "dbg-btn", disabled: !paused, title: "Step over",
151                        onclick: act(Debugger::step_over), dangerous_inner_html: IC_STEP_OVER }
152                    button { class: "dbg-btn", disabled: !paused, title: "Step into",
153                        onclick: act(Debugger::step), dangerous_inner_html: IC_STEP_INTO }
154                    button { class: "dbg-btn", disabled: !paused, title: "Step out",
155                        onclick: act(Debugger::step_out), dangerous_inner_html: IC_STEP_OUT }
156                    span { class: "dbg-divider" }
157                    button { class: "dbg-btn", title: "Restart (rewind to the entry)",
158                        onclick: act(Debugger::restart), dangerous_inner_html: IC_RESTART }
159                    button { class: if hw_open() { "dbg-btn dbg-toggle on" } else { "dbg-btn dbg-toggle" },
160                        title: "Virtual hardware view",
161                        onclick: move |_| { let v = hw_open(); hw_open.set(!v); }, dangerous_inner_html: IC_GEAR }
162                }
163                div { class: "dbg-status",
164                    span { class: "dbg-state dbg-state-{snap.state}", "{snap.state}" }
165                    span { class: "dbg-sep", "\u{00B7}" }
166                    span { "pc {snap.pc}/{snap.total_ops}" }
167                    if !snap.op_text.is_empty() {
168                        span { class: "dbg-sep", "\u{00B7}" }
169                        span { class: "dbg-op", "{snap.op_text}" }
170                    }
171                }
172                button { class: "dbg-close", title: "Close the debugger",
173                    onclick: move |_| on_close.call(()), dangerous_inner_html: IC_CLOSE }
174            }
175
176            // The editor changed since this session armed (a file switch or an edit) —
177            // non-destructive: keep debugging the old program, or reload the new one.
178            if stale {
179                div { class: "dbg-reload",
180                    span { class: "dbg-reload-msg", "\u{26A0} The code changed \u{2014} this session is the previous version." }
181                    button { class: "dbg-reload-btn",
182                        onclick: move |_| {
183                            dbg.set(Debugger::from_source(&reload_source));
184                            armed_source.set(reload_source.clone());
185                            playing.set(false);
186                        },
187                        "Reload" }
188                }
189            }
190
191            // Time-travel scrubber — drag through every op you have run.
192            div { class: "dbg-scrub",
193                span { class: "dbg-scrub-cap", dangerous_inner_html: IC_CLOCK }
194                input {
195                    class: "dbg-scrub-range",
196                    r#type: "range",
197                    min: "0",
198                    max: "{snap.total_steps}",
199                    value: "{snap.step}",
200                    oninput: move |e| {
201                        if let Ok(v) = e.value().parse::<usize>() {
202                            if let Ok(d) = dbg.write().as_mut() { d.seek(v); }
203                        }
204                    },
205                }
206                span { class: "dbg-scrub-label", "{snap.step} / {snap.total_steps}" }
207            }
208
209            // Teaching line — Socratic (ask you to predict) or plain narration (tell you),
210            // toggleable. Socratic mode asks before the step; stepping reveals the answer.
211            {
212                let socratic_on = socratic_mode();
213                let ask = socratic_on && !snap.socratic.is_empty();
214                let text = if ask { snap.socratic.clone() } else { snap.narration.clone() };
215                if text.is_empty() {
216                    rsx! {}
217                } else {
218                    rsx! {
219                        div { class: if ask { "dbg-narr dbg-narr-socratic".to_string() } else { format!("dbg-narr dbg-narr-{}", snap.state) },
220                            span { class: "dbg-narr-ico", dangerous_inner_html: if ask { IC_SOCRATIC } else { IC_BULB } }
221                            span { class: "dbg-narr-text", "{text}" }
222                            button {
223                                class: if socratic_on { "dbg-narr-toggle on" } else { "dbg-narr-toggle" },
224                                title: if socratic_on { "Socratic mode — asking you to predict (click for plain explanations)" } else { "Plain mode (click for Socratic questions)" },
225                                onclick: move |_| { let v = socratic_mode(); socratic_mode.set(!v); },
226                                "?"
227                            }
228                        }
229                    }
230                }
231            }
232
233            // The first-order-logic meaning of this step — the formal companion to the
234            // English narration (sum = x + y, t ⟺ (i < n), ¬cond → goto L).
235            if !snap.fol.is_empty() {
236                div { class: "dbg-fol", title: "first-order-logic semantics of this step",
237                    span { class: "dbg-fol-tag", "\u{22A8}" }
238                    span { class: "dbg-fol-text", "{snap.fol}" }
239                }
240            }
241
242            // The "virtual hardware" easter egg — an animated datapath of the live VM.
243            if hw_open() {
244                div { class: "dbg-hw", dangerous_inner_html: "{datapath_svg(&snap)}" }
245            }
246
247            // Tabs
248            div { class: "dbg-tabs",
249                DebugTabBtn { label: "Variables", this: DebugTab::Variables, cur: cur_tab, tab }
250                DebugTabBtn { label: "Stack", this: DebugTab::Stack, cur: cur_tab, tab }
251                DebugTabBtn { label: "Heap", this: DebugTab::Heap, cur: cur_tab, tab }
252                DebugTabBtn { label: "Timeline", this: DebugTab::Timeline, cur: cur_tab, tab }
253                DebugTabBtn { label: "Prove", this: DebugTab::Prove, cur: cur_tab, tab }
254                DebugTabBtn { label: "Call Stack", this: DebugTab::CallStack, cur: cur_tab, tab }
255                DebugTabBtn { label: "Breakpoints", this: DebugTab::Breakpoints, cur: cur_tab, tab }
256                DebugTabBtn { label: "Bytecode", this: DebugTab::Bytecode, cur: cur_tab, tab }
257            }
258
259            div { class: "dbg-body",
260                match cur_tab {
261                    DebugTab::Variables => rsx! {
262                        if let Some(frame) = snap.frames.last() {
263                            div { class: "dbg-vars",
264                                if frame.registers.is_empty() {
265                                    div { class: "dbg-empty", "no locals in this frame" }
266                                }
267                                for reg in frame.registers.iter() {
268                                    {
269                                        let idx = reg.index;
270                                        let on = traced() == Some(idx);
271                                        rsx! {
272                                            div {
273                                                class: if on { "dbg-var traced" } else if reg.changed { "dbg-var changed" } else { "dbg-var" },
274                                                title: "Trace where this value came from",
275                                                onclick: move |_| {
276                                                    if traced() == Some(idx) { traced.set(None); } else { traced.set(Some(idx)); }
277                                                },
278                                                span { class: "dbg-var-name",
279                                                    { reg.name.clone().unwrap_or_else(|| format!("R{}", reg.index)) }
280                                                }
281                                                if !reg.kind.is_empty() {
282                                                    span { class: "dbg-var-type", "{reg.kind}" }
283                                                }
284                                                span { class: "dbg-var-eq", "=" }
285                                                span { class: "dbg-var-val", "{reg.value}" }
286                                                span { class: "dbg-why", dangerous_inner_html: IC_TRACE }
287                                            }
288                                        }
289                                    }
290                                }
291                            }
292                            // Causal provenance — "why is this value here?" — the exact data-flow
293                            // lineage, possible only because execution is deterministically recorded.
294                            if let Some(node) = traced().and_then(|r| dbg.read().as_ref().ok().and_then(|d| d.provenance(r))) {
295                                div { class: "dbg-prov",
296                                    div { class: "dbg-prov-h",
297                                        span { class: "dbg-prov-title",
298                                            span { class: "dbg-prov-ico", dangerous_inner_html: IC_TRACE }
299                                            "why \u{2192} causal lineage"
300                                        }
301                                        button { class: "dbg-prov-x", onclick: move |_| traced.set(None),
302                                            dangerous_inner_html: IC_CLOSE }
303                                    }
304                                    div { class: "dbg-prov-tree", dangerous_inner_html: "{causal_html(&node)}" }
305                                }
306                            }
307                            if !snap.globals.is_empty() {
308                                div { class: "dbg-globals-h", "globals" }
309                                for (name, val) in snap.globals.iter() {
310                                    div { class: "dbg-var",
311                                        span { class: "dbg-var-name", "{name}" }
312                                        span { class: "dbg-var-eq", "=" }
313                                        span { class: "dbg-var-val", "{val}" }
314                                    }
315                                }
316                            }
317                        }
318                    },
319                    DebugTab::Stack => rsx! {
320                        div { class: "dbg-stackmem",
321                            div { class: "dbg-mem-hint", "The register file \u{2014} the VM's stack memory. Each frame's slots at their addresses (current frame on top)." }
322                            for frame in snap.frames.iter().rev() {
323                                div { class: "dbg-sframe",
324                                    div { class: "dbg-sframe-h",
325                                        span { class: "dbg-sframe-fn", { frame.function.clone().unwrap_or_else(|| "Main".to_string()) } }
326                                        span { class: "dbg-sframe-base", "base @ {frame.base}" }
327                                    }
328                                    for reg in frame.registers.iter() {
329                                        {
330                                            let addr = frame.base + reg.index as usize;
331                                            let nm = reg.name.clone().unwrap_or_else(|| format!("R{}", reg.index));
332                                            rsx! {
333                                                div { class: if reg.changed { "dbg-slot changed" } else { "dbg-slot" },
334                                                    span { class: "dbg-slot-addr", "{addr:04}" }
335                                                    span { class: "dbg-slot-name", "{nm}" }
336                                                    if !reg.kind.is_empty() {
337                                                        span { class: "dbg-slot-type", "{reg.kind}" }
338                                                    }
339                                                    span { class: "dbg-slot-val", "{reg.value}" }
340                                                }
341                                            }
342                                        }
343                                    }
344                                }
345                            }
346                        }
347                    },
348                    DebugTab::Heap => rsx! {
349                        div { class: "dbg-heap",
350                            div { class: "dbg-mem-hint", "Heap objects (lists, maps, structs, text). A box shared by two variables is an alias \u{2014} one allocation, two names." }
351                            if snap.heap.is_empty() {
352                                div { class: "dbg-empty", "no heap objects \u{2014} only scalar values in scope" }
353                            }
354                            for obj in snap.heap.iter() {
355                                div { class: if obj.shared { "dbg-hobj shared" } else { "dbg-hobj" },
356                                    div { class: "dbg-hobj-h",
357                                        span { class: "dbg-hobj-id", "{obj.id}" }
358                                        span { class: "dbg-hobj-kind", "{obj.kind}" }
359                                        span { class: "dbg-hobj-store", title: "memory layout", "{obj.storage}" }
360                                        if obj.rc > 1 {
361                                            span { class: "dbg-hobj-rc", title: "reference count", "rc {obj.rc}" }
362                                        }
363                                        if obj.shared {
364                                            span { class: "dbg-hobj-alias", "aliased" }
365                                        }
366                                    }
367                                    div { class: "dbg-hobj-val", "{obj.summary}" }
368                                    div { class: "dbg-hobj-refs",
369                                        span { class: "dbg-refs-label", "\u{2190}" }
370                                        for r in obj.referenced_by.iter() {
371                                            span { class: "dbg-ref", "{r}" }
372                                        }
373                                    }
374                                }
375                            }
376                        }
377                    },
378                    DebugTab::Timeline => {
379                        let tl = dbg.read().as_ref().ok().map(|d| d.variable_timeline());
380                        let insights = dbg.read().as_ref().ok().map(|d| d.observed_invariants()).unwrap_or_default();
381                        let proven = dbg.read().as_ref().ok().map(|d| d.proven_invariants()).unwrap_or_default();
382                        match tl {
383                            Some(tl) if !tl.vars.is_empty() => rsx! {
384                                div { class: "dbg-osc-wrap",
385                                    div { class: "dbg-mem-hint", "Every variable's value across the whole run \u{2014} a logic-analyzer for your program. The pink playhead is your time-travel cursor; drag the scrubber to move through time." }
386                                    if tl.truncated {
387                                        div { class: "dbg-mem-hint dim", "showing the most recent {tl.steps} steps" }
388                                    }
389                                    div { class: "dbg-osc", dangerous_inner_html: "{timeline_svg(&tl)}" }
390                                    // PROVEN invariants — the Oracle's static guarantees (every run).
391                                    if !proven.is_empty() {
392                                        div { class: "dbg-insights",
393                                            div { class: "dbg-insights-h proven",
394                                                span { class: "dbg-proven-ico", dangerous_inner_html: IC_SHIELD }
395                                                "proven \u{2014} holds on every run"
396                                            }
397                                            for p in proven.iter() {
398                                                div { class: "dbg-insight",
399                                                    span { class: "dbg-insight-name", "{p.name}" }
400                                                    for f in p.facts.iter() {
401                                                        span { class: "dbg-insight-fact proven", "{f}" }
402                                                    }
403                                                }
404                                            }
405                                        }
406                                    }
407                                    // Daikon-style observed invariants — what held over THIS run.
408                                    if !insights.is_empty() {
409                                        div { class: "dbg-insights",
410                                            div { class: "dbg-insights-h", "observed this run" }
411                                            for ins in insights.iter() {
412                                                div { class: "dbg-insight",
413                                                    span { class: "dbg-insight-name", "{ins.name}" }
414                                                    for f in ins.facts.iter() {
415                                                        span { class: "dbg-insight-fact", "{f}" }
416                                                    }
417                                                }
418                                            }
419                                        }
420                                    }
421                                }
422                            },
423                            _ => rsx! {
424                                div { class: "dbg-empty", "no variables to plot yet \u{2014} step the program to record a timeline" }
425                            },
426                        }
427                    },
428                    DebugTab::Prove => {
429                        let q = prove_query();
430                        let result = if q.trim().is_empty() {
431                            None
432                        } else {
433                            dbg.read().as_ref().ok().map(|d| d.assert_at_cursor(&q))
434                        };
435                        rsx! {
436                            div { class: "dbg-prove",
437                                div { class: "dbg-mem-hint", "Assert a property of the variables. You get both lenses: whether it holds NOW (live values) and whether it's PROVEN for every run (the Oracle's static facts \u{2014} no Z3)." }
438                                input {
439                                    class: "dbg-prove-input",
440                                    r#type: "text",
441                                    placeholder: "e.g.  x < y    or    sum >= 0",
442                                    value: "{q}",
443                                    oninput: move |e| prove_query.set(e.value()),
444                                }
445                                if let Some(r) = result {
446                                    if r.parsed {
447                                        div { class: "dbg-prove-result",
448                                            div { class: "dbg-prove-row",
449                                                span { class: "dbg-prove-label", "now" }
450                                                match r.now {
451                                                    Some(true) => rsx! { span { class: "dbg-verdict yes", "true" } },
452                                                    Some(false) => rsx! { span { class: "dbg-verdict no", "false" } },
453                                                    None => rsx! { span { class: "dbg-verdict idk", "not a live value" } },
454                                                }
455                                                if !r.now_detail.is_empty() {
456                                                    span { class: "dbg-prove-detail", "{r.now_detail}" }
457                                                }
458                                            }
459                                            div { class: "dbg-prove-row",
460                                                span { class: "dbg-prove-label", "every run" }
461                                                {
462                                                    let (cls, txt) = match r.verdict {
463                                                        ProofVerdict::ProvenTrue => ("yes", "proven"),
464                                                        ProofVerdict::ProvenFalse => ("no", "refuted"),
465                                                        ProofVerdict::Unknown => ("idk", "unproven"),
466                                                    };
467                                                    rsx! { span { class: "dbg-verdict {cls}", "{txt}" } }
468                                                }
469                                                if !r.verdict_detail.is_empty() {
470                                                    span { class: "dbg-prove-detail", "{r.verdict_detail}" }
471                                                }
472                                            }
473                                        }
474                                    } else {
475                                        div { class: "dbg-prove-err", "{r.now_detail}" }
476                                    }
477                                }
478                            }
479                        }
480                    },
481                    DebugTab::CallStack => rsx! {
482                        div { class: "dbg-stack",
483                            for (i, frame) in snap.frames.iter().enumerate().rev() {
484                                div { class: if i + 1 == snap.frames.len() { "dbg-frame current" } else { "dbg-frame" },
485                                    span { class: "dbg-frame-fn",
486                                        { frame.function.clone().unwrap_or_else(|| "Main".to_string()) }
487                                    }
488                                }
489                            }
490                        }
491                    },
492                    DebugTab::Breakpoints => rsx! {
493                        div { class: "dbg-bps",
494                            if bps.is_empty() {
495                                div { class: "dbg-empty", "no breakpoints \u{2014} click a line in the Bytecode tab" }
496                            }
497                            for pc in bps.iter().copied() {
498                                {
499                                    let label = disasm.iter().find(|(p, _)| *p == pc)
500                                        .map(|(_, t)| t.clone()).unwrap_or_default();
501                                    rsx! {
502                                        div { class: "dbg-bp",
503                                            onclick: move |_| { if let Ok(d) = dbg.write().as_mut() { d.clear_breakpoint(pc); } },
504                                            span { class: "dbg-bp-dot", "\u{25CF}" }
505                                            span { class: "dbg-bp-pc", "{pc}" }
506                                            span { class: "dbg-bp-op", "{label}" }
507                                        }
508                                    }
509                                }
510                            }
511                        }
512                    },
513                    DebugTab::Bytecode => rsx! {
514                        div { class: "dbg-tape",
515                            for (pc, text) in disasm.iter() {
516                                {
517                                    let pc = *pc;
518                                    let is_cur = pc == snap.pc && snap.state != "done";
519                                    let has_bp = bps.contains(&pc);
520                                    let row = if is_cur { "dbg-row cur" } else { "dbg-row" };
521                                    rsx! {
522                                        div { class: "{row}",
523                                            onclick: move |_| { if let Ok(d) = dbg.write().as_mut() { d.toggle_breakpoint(pc); } },
524                                            span { class: if has_bp { "dbg-gutter on" } else { "dbg-gutter" },
525                                                if has_bp { "\u{25CF}" } else { "" }
526                                            }
527                                            span { class: "dbg-pc", "{pc}" }
528                                            span { class: "dbg-text", "{text}" }
529                                        }
530                                    }
531                                }
532                            }
533                        }
534                    },
535                }
536            }
537
538            // Output strip (the Show lines so far).
539            if !snap.output.is_empty() {
540                div { class: "dbg-out",
541                    for line in snap.output.iter() {
542                        div { class: "dbg-out-line", "{line}" }
543                    }
544                }
545            }
546        }
547    }
548}
549
550#[component]
551fn DebugTabBtn(label: String, this: DebugTab, cur: DebugTab, tab: Signal<DebugTab>) -> Element {
552    let active = this == cur;
553    rsx! {
554        button {
555            class: if active { "dbg-tab active" } else { "dbg-tab" },
556            onclick: move |_| tab.set(this),
557            "{label}"
558        }
559    }
560}
561
562/// XML-escape a value for embedding in the datapath SVG.
563fn esc(s: &str) -> String {
564    s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
565}
566
567/// The "virtual hardware" easter egg: an animated datapath of the paused VM — the
568/// register file wired into the engine/ALU, the current op lit up, changed registers
569/// pulsing. Pure string SVG + CSS keyframes (no deps; nothing renders until opened).
570fn datapath_svg(snap: &DebugSnapshot) -> String {
571    let regs: &[_] = match snap.frames.last() {
572        Some(f) => &f.registers,
573        None => &[],
574    };
575    let cw = 92.0_f64;
576    let gap = 8.0_f64;
577    let engine_x = 400.0_f64;
578    let engine_y = 104.0_f64;
579    let mut s = String::from(
580        "<svg viewBox='0 0 800 168' width='100%' preserveAspectRatio='xMidYMid meet' xmlns='http://www.w3.org/2000/svg'>",
581    );
582    s.push_str(DATAPATH_CSS);
583    let reads: std::collections::BTreeSet<u16> = snap.op_reads.iter().copied().collect();
584    let write = snap.op_writes;
585    let cell_cx = |pos: usize| 12.0 + pos as f64 * (cw + gap) + cw / 2.0;
586    // Operand wires: each SOURCE register of this op flows down into the engine.
587    for (pos, r) in regs.iter().take(8).enumerate() {
588        if reads.contains(&r.index) {
589            let sx = cell_cx(pos);
590            s.push_str(&format!(
591                "<path class='wire live' d='M{sx:.0},44 C{sx:.0},80 {engine_x:.0},80 {engine_x:.0},{ey:.0}'/>",
592                ey = engine_y - 4.0,
593            ));
594        }
595    }
596    // Result wire: the engine writes its result back up to the DESTINATION register.
597    if let Some(w) = write {
598        if let Some(pos) = regs.iter().take(8).position(|r| r.index == w) {
599            let dx = cell_cx(pos);
600            s.push_str(&format!(
601                "<path class='wire out' d='M{engine_x:.0},{eb:.0} C{engine_x:.0},{lo:.0} {dx:.0},{lo:.0} {dx:.0},44'/>",
602                eb = engine_y + 46.0,
603                lo = engine_y + 72.0,
604            ));
605        }
606    }
607    // Register cells — sources glow cyan, the destination glows green.
608    for (pos, r) in regs.iter().take(8).enumerate() {
609        let x = 12.0 + pos as f64 * (cw + gap);
610        let name = r.name.clone().unwrap_or_else(|| format!("R{}", r.index));
611        let cls = if Some(r.index) == write {
612            "rcell dst"
613        } else if reads.contains(&r.index) {
614            "rcell src"
615        } else if r.changed {
616            "rcell hot"
617        } else {
618            "rcell"
619        };
620        s.push_str(&format!(
621            "<g class='{cls}'><rect x='{x:.0}' y='14' width='{cw:.0}' height='30' rx='6'/>\
622             <text x='{tx:.0}' y='33' text-anchor='middle'>{n} = {v}</text></g>",
623            tx = x + cw / 2.0,
624            n = esc(&name),
625            v = esc(&r.value),
626        ));
627    }
628    // Engine box — the live operation, narrated in plain English.
629    let engine_cls = if snap.state == "paused" { "engine live" } else { "engine" };
630    let label = if !snap.narration.is_empty() { &snap.narration } else { &snap.op_text };
631    let label: String = if label.chars().count() > 48 {
632        format!("{}\u{2026}", label.chars().take(47).collect::<String>())
633    } else {
634        label.clone()
635    };
636    s.push_str(&format!(
637        "<g class='{engine_cls}'><rect x='{ex:.0}' y='{ey:.0}' width='340' height='46' rx='8'/>\
638         <text class='lbl' x='{tx:.0}' y='{ly:.0}' text-anchor='middle'>ALU \u{00B7} pc {pc}/{tot}</text>\
639         <text class='op' x='{tx:.0}' y='{oy:.0}' text-anchor='middle'>{op}</text></g>",
640        ex = engine_x - 170.0,
641        ey = engine_y,
642        tx = engine_x,
643        ly = engine_y + 18.0,
644        oy = engine_y + 37.0,
645        pc = snap.pc,
646        tot = snap.total_ops,
647        op = esc(&label),
648    ));
649    s.push_str("</svg>");
650    s
651}
652
653/// The **variable oscilloscope** — a logic-analyzer waveform of every variable's
654/// value across the whole recorded run, runs of equal value merged into held "bus"
655/// boxes, with a pink playhead pinned at the time-travel cursor. Reuses the studio's
656/// `waveform_svg` idiom; the program's entire observable past on one scope.
657fn timeline_svg(tl: &VarTimeline) -> String {
658    let gutter = 96.0_f64;
659    let col_w = 34.0_f64;
660    let row_h = 30.0_f64;
661    let top = 22.0_f64;
662    let n = tl.steps.max(1);
663    let lanes = tl.vars.len().max(1);
664    let width = gutter + n as f64 * col_w + 14.0;
665    let height = top + lanes as f64 * row_h + 16.0;
666    let bottom = top + lanes as f64 * row_h;
667
668    let mut s = format!(
669        "<svg viewBox='0 0 {width:.0} {height:.0}' width='{width:.0}' height='{height:.0}' xmlns='http://www.w3.org/2000/svg'>"
670    );
671    s.push_str(TIMELINE_CSS);
672
673    // Time grid + step labels (thinned so they stay readable on long runs).
674    let label_every = (n / 14).max(1);
675    for step in 0..=n {
676        let x = gutter + step as f64 * col_w;
677        s.push_str(&format!("<line class='tlg' x1='{x:.0}' y1='{top:.0}' x2='{x:.0}' y2='{bottom:.0}'/>"));
678        if step < n && step % label_every == 0 {
679            s.push_str(&format!(
680                "<text class='tltl' x='{:.0}' y='14'>{}</text>",
681                x + col_w / 2.0,
682                tl.start + step
683            ));
684        }
685    }
686
687    // Playhead at the cursor column.
688    let pcol = tl.cursor.saturating_sub(tl.start);
689    let px = gutter + pcol as f64 * col_w;
690    s.push_str(&format!("<rect class='tlph-bg' x='{px:.0}' y='{top:.0}' width='{col_w:.0}' height='{:.0}'/>", bottom - top));
691    s.push_str(&format!("<line class='tlph' x1='{px:.0}' y1='{:.0}' x2='{px:.0}' y2='{:.0}'/>", top - 6.0, bottom + 4.0));
692
693    for (ri, v) in tl.vars.iter().enumerate() {
694        let y = top + ri as f64 * row_h;
695        let label = if v.kind.is_empty() { v.name.clone() } else { format!("{} : {}", v.name, v.kind) };
696        s.push_str(&format!("<text class='tln' x='{:.0}' y='{:.0}'>{}</text>", gutter - 9.0, y + row_h * 0.62, esc(&label)));
697        // Merge consecutive equal samples into one held box (the waveform "bus value").
698        let mut i = 0usize;
699        while i < v.points.len() {
700            let p = &v.points[i];
701            let mut j = i + 1;
702            while j < v.points.len() && v.points[j].present == p.present && v.points[j].value == p.value {
703                j += 1;
704            }
705            if p.present {
706                let x = gutter + i as f64 * col_w;
707                let span = (j - i) as f64 * col_w;
708                let cls = if p.changed { "tlbox edge" } else { "tlbox" };
709                s.push_str(&format!(
710                    "<rect class='{cls}' x='{:.0}' y='{:.0}' width='{:.0}' height='{:.0}' rx='5'/>",
711                    x + 1.5,
712                    y + 4.0,
713                    (span - 3.0).max(2.0),
714                    row_h - 9.0
715                ));
716                let shown: String = p.value.chars().take((span / 7.0) as usize + 1).collect();
717                s.push_str(&format!(
718                    "<text class='tlv' x='{:.0}' y='{:.0}'>{}</text>",
719                    x + span / 2.0,
720                    y + row_h * 0.62,
721                    esc(&shown)
722                ));
723            }
724            i = j;
725        }
726    }
727    s.push_str("</svg>");
728    s
729}
730
731/// Render a [`CausalNode`] provenance tree to indented HTML — each value shown with
732/// the op that produced it and the step it happened, its inputs nested below.
733fn causal_html(node: &CausalNode) -> String {
734    let mut s = String::new();
735    causal_html_rec(node, 0, &mut s);
736    s
737}
738
739fn causal_html_rec(node: &CausalNode, depth: usize, out: &mut String) {
740    let name = node.name.clone().unwrap_or_else(|| format!("R{}", node.reg));
741    let how = if !node.narration.is_empty() {
742        node.narration.clone()
743    } else if !node.op_text.is_empty() {
744        node.op_text.clone()
745    } else {
746        "initial value".to_string()
747    };
748    out.push_str(&format!("<div class='cz-row' style='padding-left:{}px'>", depth * 18));
749    if depth > 0 {
750        out.push_str("<span class='cz-arm'>\u{2514}\u{2500}</span>");
751    }
752    out.push_str(&format!("<span class='cz-val'>{} = {}</span>", esc(&name), esc(&node.value)));
753    if !node.kind.is_empty() {
754        out.push_str(&format!("<span class='cz-kind'>{}</span>", esc(&node.kind)));
755    }
756    out.push_str(&format!("<span class='cz-how'>{}</span>", esc(&how)));
757    if node.step > 0 {
758        out.push_str(&format!("<span class='cz-step'>step {}</span>", node.step));
759    }
760    out.push_str("</div>");
761    for inp in &node.inputs {
762        causal_html_rec(inp, depth + 1, out);
763    }
764}
765
766const TIMELINE_CSS: &str = "<style>\
767.tlg{stroke:rgba(255,255,255,0.05);stroke-width:1}\
768.tltl{fill:rgba(255,255,255,0.4);font:9px ui-monospace,monospace;text-anchor:middle}\
769.tln{fill:#a5b4fc;font:11px ui-monospace,monospace;text-anchor:end}\
770.tlbox{fill:rgba(34,211,238,0.10);stroke:#22d3ee;stroke-width:1}\
771.tlbox.edge{fill:rgba(129,140,248,0.20);stroke:#818cf8;stroke-width:1.5}\
772.tlv{fill:#dbeafe;font:10px ui-monospace,monospace;text-anchor:middle}\
773.tlph{stroke:#f472b6;stroke-width:1.5;opacity:0.9}\
774.tlph-bg{fill:rgba(244,114,182,0.08)}\
775</style>";
776
777const IC_TRACE: &str = "<svg viewBox='0 0 16 16' width='12' height='12' fill='none' stroke='currentColor' stroke-width='1.3' stroke-linecap='round' stroke-linejoin='round'><circle cx='3.4' cy='3.4' r='1.6'/><circle cx='12.6' cy='3.4' r='1.6'/><circle cx='8' cy='12.6' r='1.6'/><path d='M3.4 5v2.2a2 2 0 0 0 2 2h5.2a2 2 0 0 0 2-2V5'/><path d='M8 9.4v1.6'/></svg>";
778
779const IC_SHIELD: &str = "<svg viewBox='0 0 16 16' width='12' height='12' fill='none' stroke='currentColor' stroke-width='1.3' stroke-linecap='round' stroke-linejoin='round'><path d='M8 1.6l5 1.8v4c0 3.2-2.2 5.4-5 6.4-2.8-1-5-3.2-5-6.4v-4z'/><path d='M5.8 8l1.5 1.5L10.4 6'/></svg>";
780
781const IC_SOCRATIC: &str = "<svg viewBox='0 0 16 16' width='14' height='14' fill='none' stroke='currentColor' stroke-width='1.3' stroke-linecap='round' stroke-linejoin='round'><path d='M3 2.8h10a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H7l-3 2.4V10.8H3a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1z'/><path d='M6.5 5.6a1.6 1.6 0 0 1 3 .5c0 1-1.5 1.3-1.5 2.2'/><path d='M8 9.9h.01'/></svg>";
782
783const DATAPATH_CSS: &str = "<style>\
784.rcell rect{fill:#161b22;stroke:rgba(255,255,255,0.12);stroke-width:1}\
785.rcell text{fill:#cbd5e1;font:11px ui-monospace,monospace}\
786.rcell.hot rect{stroke:#667eea;fill:#1e2540;animation:dpglow 1s ease-in-out infinite}\
787.rcell.hot text{fill:#a5b4fc}\
788.rcell.src rect{stroke:#22d3ee;fill:#0e2a33;animation:dpglow 1s ease-in-out infinite}\
789.rcell.src text{fill:#67e8f9}\
790.rcell.dst rect{stroke:#4ade80;fill:#0e2a1c;animation:dpglow 1s ease-in-out infinite}\
791.rcell.dst text{fill:#86efac}\
792@keyframes dpglow{0%,100%{stroke-opacity:.45}50%{stroke-opacity:1}}\
793.wire{fill:none;stroke:rgba(255,255,255,0.08);stroke-width:1.5}\
794.wire.live{stroke:#22d3ee;stroke-dasharray:5 4;animation:dpflow .7s linear infinite}\
795.wire.out{fill:none;stroke:#4ade80;stroke-width:1.5;stroke-dasharray:5 4;animation:dpflow .7s linear infinite}\
796@keyframes dpflow{to{stroke-dashoffset:-18}}\
797.engine rect{fill:#12161c;stroke:rgba(255,255,255,0.18);stroke-width:1.5}\
798.engine.live rect{stroke:#4ade80;animation:dpglow 1.4s ease-in-out infinite}\
799.engine .lbl{fill:#9ca3af;font:bold 11px ui-monospace,monospace}\
800.engine .op{fill:#e8eaed;font:12px ui-monospace,monospace}\
801</style>";
802
803// ── Debug-control icons — inline SVG (font-independent, crisp, `currentColor` so
804//    they inherit each button's colour). No tofu, unlike obscure Unicode glyphs.
805const IC_CONTINUE: &str = "<svg viewBox='0 0 16 16' width='15' height='15' fill='currentColor'><path d='M3.5 3v10l7-5z'/><rect x='11' y='3' width='1.8' height='10' rx='.6'/></svg>";
806const IC_REVERSE: &str = "<svg viewBox='0 0 16 16' width='15' height='15' fill='currentColor'><rect x='3.2' y='3' width='1.8' height='10' rx='.6'/><path d='M12.5 3v10l-7-5z'/></svg>";
807const IC_STEP_BACK: &str = "<svg viewBox='0 0 16 16' width='15' height='15' fill='currentColor'><rect x='3' y='3.2' width='1.7' height='9.6' rx='.6'/><path d='M12.5 3.2v9.6l-6.6-4.8z'/></svg>";
808const IC_STEP_INTO: &str = "<svg viewBox='0 0 16 16' width='15' height='15' fill='none' stroke='currentColor' stroke-width='1.4' stroke-linecap='round' stroke-linejoin='round'><path d='M8 2.5v5'/><path d='M5.4 5L8 7.6 10.6 5'/><circle cx='8' cy='12.2' r='1.5' fill='currentColor' stroke='none'/></svg>";
809const IC_STEP_OUT: &str = "<svg viewBox='0 0 16 16' width='15' height='15' fill='none' stroke='currentColor' stroke-width='1.4' stroke-linecap='round' stroke-linejoin='round'><path d='M8 8.1V3'/><path d='M5.4 5.6L8 3l2.6 2.6'/><circle cx='8' cy='12.2' r='1.5' fill='currentColor' stroke='none'/></svg>";
810const IC_STEP_OVER: &str = "<svg viewBox='0 0 16 16' width='15' height='15' fill='none' stroke='currentColor' stroke-width='1.4' stroke-linecap='round' stroke-linejoin='round'><path d='M3 8.6c.4-4 9.6-4 10 .4'/><path d='M11 6.7l2 1.1-1.7 1.8'/><circle cx='8' cy='12.2' r='1.5' fill='currentColor' stroke='none'/></svg>";
811const IC_RESTART: &str = "<svg viewBox='0 0 16 16' width='15' height='15' fill='none' stroke='currentColor' stroke-width='1.4' stroke-linecap='round' stroke-linejoin='round'><path d='M12.6 8a4.6 4.6 0 1 1-1.5-3.4'/><path d='M12.9 2.3l-.2 2.6-2.6-.4'/></svg>";
812const IC_STOP: &str = "<svg viewBox='0 0 16 16' width='15' height='15' fill='currentColor'><rect x='3.5' y='3.5' width='9' height='9' rx='1.6'/></svg>";
813const IC_CLOCK: &str = "<svg viewBox='0 0 16 16' width='13' height='13' fill='none' stroke='currentColor' stroke-width='1.3' stroke-linecap='round' stroke-linejoin='round'><circle cx='8' cy='8.6' r='4.8'/><path d='M8 5.6v3l2 1.4'/><path d='M6 1.6h4M8 1.6v2'/></svg>";
814const IC_CLOSE: &str = "<svg viewBox='0 0 16 16' width='15' height='15' fill='none' stroke='currentColor' stroke-width='1.7' stroke-linecap='round'><path d='M4 4l8 8M12 4l-8 8'/></svg>";
815const IC_BULB: &str = "<svg viewBox='0 0 16 16' width='14' height='14' fill='none' stroke='currentColor' stroke-width='1.3' stroke-linecap='round' stroke-linejoin='round'><path d='M6 12h4M6.4 13.6h3.2'/><path d='M5 7.4a3 3 0 1 1 6 0c0 1.3-.8 2-1.3 2.7-.2.3-.2.6-.2 1.4H6.5c0-.8 0-1.1-.2-1.4C5.8 9.4 5 8.7 5 7.4z'/></svg>";
816const IC_PLAY: &str = "<svg viewBox='0 0 16 16' width='15' height='15' fill='currentColor'><path d='M4.5 3v10l8-5z'/></svg>";
817const IC_PAUSE: &str = "<svg viewBox='0 0 16 16' width='15' height='15' fill='currentColor'><rect x='4' y='3.5' width='2.7' height='9' rx='.8'/><rect x='9.3' y='3.5' width='2.7' height='9' rx='.8'/></svg>";
818const IC_GEAR: &str = "<svg viewBox='0 0 16 16' width='15' height='15' fill='none' stroke='currentColor' stroke-width='1.2' stroke-linecap='round' stroke-linejoin='round'><circle cx='8' cy='8' r='2.1'/><path d='M8 1.7v1.6M8 12.7v1.6M14.3 8h-1.6M3.3 8H1.7M12.45 3.55l-1.13 1.13M4.68 11.32l-1.13 1.13M12.45 12.45l-1.13-1.13M4.68 4.68L3.55 3.55'/></svg>";
819
820/// A ladybug glyph as inline SVG, for the Debug button + drawer title (shared with
821/// the Studio toolbar). Explicitly sized so it needs no extra CSS.
822pub const IC_BUG: &str = "<svg viewBox='0 0 16 16' width='14' height='14' fill='none' stroke='currentColor' stroke-width='1.2' stroke-linecap='round' stroke-linejoin='round'><ellipse cx='8' cy='9' rx='2.9' ry='3.5'/><path d='M8 5.6v6.8'/><path d='M5.1 9H2.7M10.9 9h2.4M5.3 6.5L3.6 5.2M10.7 6.5l1.7-1.3M5.3 11.6L3.6 12.9M10.7 11.6l1.7 1.3'/><path d='M6.4 4.4a1.6 1.6 0 0 1 3.2 0'/></svg>";
823
824const DEBUG_DRAWER_STYLE: &str = r#"
825.dbg-drawer { display:flex; flex-direction:column; background:var(--studio-panel-bg,#12161c);
826  border-top:1px solid var(--studio-border,rgba(255,255,255,0.08)); color:var(--studio-text,#e8eaed);
827  font-size:13px; max-height:320px; min-height:160px; flex-shrink:0; }
828.dbg-bar { display:flex; align-items:center; gap:12px; padding:6px 12px; flex-wrap:wrap;
829  border-bottom:1px solid var(--studio-border,rgba(255,255,255,0.08)); }
830.dbg-title { font-weight:600; }
831.dbg-controls { display:flex; gap:4px; }
832.dbg-btn { background:rgba(255,255,255,0.05); border:1px solid var(--studio-border,rgba(255,255,255,0.08));
833  color:var(--studio-text,#e8eaed); border-radius:6px; padding:3px 8px; cursor:pointer; font-size:13px;
834  line-height:1; transition:all .12s ease; }
835.dbg-btn:hover:not(:disabled) { background:rgba(255,255,255,0.1); }
836.dbg-btn:disabled { opacity:0.35; cursor:default; }
837.dbg-go { color:#4ade80; }
838.dbg-stop { color:#f87171; }
839.dbg-toggle.on { background:rgba(102,126,234,0.3); border-color:var(--studio-accent,#667eea); }
840.dbg-status { display:flex; align-items:center; gap:6px; margin-left:auto; color:var(--studio-text-secondary,#9ca3af);
841  font-family:ui-monospace,monospace; font-size:12px; }
842.dbg-sep { opacity:0.4; }
843.dbg-op { color:var(--studio-text,#e8eaed); }
844.dbg-state { text-transform:uppercase; font-size:11px; letter-spacing:0.5px; padding:1px 6px; border-radius:4px; }
845.dbg-state-paused { background:rgba(102,126,234,0.25); color:#a5b4fc; }
846.dbg-state-done { background:rgba(74,222,128,0.2); color:#4ade80; }
847.dbg-state-blocked { background:rgba(251,191,36,0.2); color:#fbbf24; }
848.dbg-state-error { background:rgba(248,113,113,0.2); color:#f87171; }
849.dbg-tabs { display:flex; gap:2px; padding:4px 8px 0; border-bottom:1px solid var(--studio-border,rgba(255,255,255,0.08)); }
850.dbg-tab { background:transparent; border:none; color:var(--studio-text-muted,#6b7280); padding:5px 12px;
851  cursor:pointer; border-radius:5px 5px 0 0; font-size:12px; }
852.dbg-tab.active { color:var(--studio-text,#e8eaed); background:rgba(255,255,255,0.05); }
853.dbg-body { flex:1; min-height:0; overflow:auto; padding:8px 12px; font-family:ui-monospace,monospace; font-size:12px; }
854.dbg-empty { color:var(--studio-text-muted,#6b7280); font-style:italic; }
855.dbg-var { display:flex; gap:6px; padding:2px 4px; border-radius:4px; }
856.dbg-var.changed { background:rgba(102,126,234,0.18); }
857.dbg-var-name { color:#a5b4fc; min-width:48px; }
858.dbg-var-type { color:#f0abfc; font-size:11px; opacity:0.85; background:rgba(240,171,252,0.1); border-radius:4px; padding:0 5px; align-self:center; }
859.dbg-var-eq { opacity:0.4; }
860.dbg-var-val { color:#4ade80; }
861.dbg-var { cursor:pointer; border-radius:5px; }
862.dbg-var:hover { background:rgba(255,255,255,0.04); }
863.dbg-var:hover .dbg-why { opacity:0.7; }
864.dbg-var.traced { background:rgba(34,211,238,0.12); box-shadow:inset 2px 0 0 #22d3ee; }
865.dbg-var.traced .dbg-why { opacity:1; color:#22d3ee; }
866.dbg-why { margin-left:auto; display:inline-flex; align-items:center; color:#6b7280; opacity:0; transition:opacity 0.12s; }
867.dbg-prov { margin:8px 6px 4px; border:1px solid rgba(34,211,238,0.25); border-radius:8px; background:rgba(34,211,238,0.04); overflow:hidden; }
868.dbg-prov-h { display:flex; align-items:center; justify-content:space-between; padding:5px 10px; background:rgba(34,211,238,0.08); border-bottom:1px solid rgba(34,211,238,0.15); }
869.dbg-prov-title { display:flex; align-items:center; gap:6px; color:#67e8f9; font-size:11px; text-transform:uppercase; letter-spacing:0.5px; }
870.dbg-prov-ico { display:inline-flex; align-items:center; color:#22d3ee; }
871.dbg-prov-x { background:transparent; border:none; color:#6b7280; cursor:pointer; display:inline-flex; padding:2px; border-radius:4px; }
872.dbg-prov-x:hover { background:rgba(248,113,113,0.15); color:#f87171; }
873.dbg-prov-tree { padding:8px 10px; font-family:ui-monospace,monospace; font-size:12px; }
874.cz-row { display:flex; align-items:center; gap:8px; padding:2px 0; white-space:nowrap; }
875.cz-arm { color:#475569; }
876.cz-val { color:#4ade80; font-weight:600; }
877.cz-kind { color:#f0abfc; font-size:10px; opacity:0.8; }
878.cz-how { color:#cbd5e1; opacity:0.85; }
879.cz-step { color:#6b7280; font-size:10px; margin-left:auto; }
880.dbg-osc-wrap { display:flex; flex-direction:column; gap:4px; }
881.dbg-osc { overflow-x:auto; padding:4px 2px 8px; }
882.dbg-mem-hint.dim { opacity:0.6; font-size:11px; }
883.dbg-fol { display:flex; align-items:center; gap:8px; padding:5px 12px; background:rgba(167,139,250,0.07); border-bottom:1px solid var(--studio-border,rgba(255,255,255,0.06)); font-family:ui-monospace,monospace; }
884.dbg-fol-tag { color:#a78bfa; font-size:13px; }
885.dbg-fol-text { color:#ddd6fe; font-size:12.5px; }
886.dbg-prove { display:flex; flex-direction:column; gap:8px; padding:2px 4px; }
887.dbg-prove-input { background:var(--studio-panel-bg,#12161c); border:1px solid var(--studio-border,rgba(255,255,255,0.12)); border-radius:6px; color:#e8eaed; font-family:ui-monospace,monospace; font-size:13px; padding:7px 10px; outline:none; }
888.dbg-prove-input:focus { border-color:#a78bfa; }
889.dbg-prove-result { display:flex; flex-direction:column; gap:6px; padding:4px 2px; }
890.dbg-prove-row { display:flex; align-items:center; gap:9px; flex-wrap:wrap; }
891.dbg-prove-label { color:#6b7280; font-size:10px; text-transform:uppercase; letter-spacing:0.6px; min-width:62px; }
892.dbg-verdict { font-weight:600; font-size:12px; border-radius:10px; padding:1px 9px; }
893.dbg-verdict.yes { color:#86efac; background:rgba(74,222,128,0.14); border:1px solid rgba(74,222,128,0.3); }
894.dbg-verdict.no { color:#fca5a5; background:rgba(248,113,113,0.14); border:1px solid rgba(248,113,113,0.3); }
895.dbg-verdict.idk { color:#9ca3af; background:rgba(156,163,175,0.12); border:1px solid rgba(156,163,175,0.25); }
896.dbg-prove-detail { color:#94a3b8; font-family:ui-monospace,monospace; font-size:11px; }
897.dbg-prove-err { color:#fbbf24; font-size:12px; padding:4px 2px; }
898.dbg-insights { margin:2px 6px 6px; }
899.dbg-insights-h { color:#67e8f9; font-size:10px; text-transform:uppercase; letter-spacing:0.6px; margin-bottom:5px; }
900.dbg-insights-h.proven { color:#c4b5fd; display:flex; align-items:center; gap:5px; }
901.dbg-proven-ico { display:inline-flex; align-items:center; color:#a78bfa; }
902.dbg-insight { display:flex; align-items:center; gap:7px; flex-wrap:wrap; padding:2px 0; }
903.dbg-insight-name { color:#a5b4fc; font-family:ui-monospace,monospace; font-size:12px; min-width:48px; }
904.dbg-insight-fact { color:#86efac; background:rgba(74,222,128,0.1); border:1px solid rgba(74,222,128,0.2); border-radius:10px; padding:0 8px; font-size:11px; }
905.dbg-insight-fact.proven { color:#ddd6fe; background:rgba(167,139,250,0.12); border-color:rgba(167,139,250,0.3); font-family:ui-monospace,monospace; }
906.dbg-globals-h, .dbg-out { color:var(--studio-text-muted,#6b7280); margin-top:8px; text-transform:uppercase; font-size:10px; letter-spacing:0.5px; }
907.dbg-frame { padding:3px 6px; border-radius:4px; }
908.dbg-frame.current { background:rgba(102,126,234,0.18); }
909.dbg-frame-fn { color:#a5b4fc; }
910.dbg-bp, .dbg-row { display:flex; gap:8px; align-items:center; padding:2px 4px; cursor:pointer; border-radius:3px; }
911.dbg-bp:hover, .dbg-row:hover { background:rgba(255,255,255,0.05); }
912.dbg-row.cur { background:rgba(102,126,234,0.22); }
913.dbg-gutter { width:12px; color:#f87171; text-align:center; }
914.dbg-pc, .dbg-bp-pc { color:var(--studio-text-muted,#6b7280); min-width:28px; text-align:right; }
915.dbg-text, .dbg-bp-op { color:var(--studio-text,#e8eaed); }
916.dbg-bp-dot { color:#f87171; }
917.dbg-out { border-top:1px solid var(--studio-border,rgba(255,255,255,0.08)); padding:6px 12px; max-height:80px; overflow:auto; }
918.dbg-out-line { color:#4ade80; font-family:ui-monospace,monospace; font-size:12px; }
919.dbg-error { padding:12px; color:#f87171; }
920.dbg-hw { padding:10px 12px; border-bottom:1px solid var(--studio-border,rgba(255,255,255,0.08)); color:var(--studio-text-secondary,#9ca3af); font-style:italic; }
921.dbg-btn { display:inline-flex; align-items:center; justify-content:center; }
922.dbg-btn svg { display:block; }
923.dbg-title { display:inline-flex; align-items:center; gap:6px; }
924.dbg-bug { display:inline-flex; color:#f87171; }
925.dbg-divider { width:1px; height:18px; background:var(--studio-border,rgba(255,255,255,0.12)); margin:0 3px; align-self:center; }
926.dbg-rev { color:#c084fc; }
927.dbg-scrub { display:flex; align-items:center; gap:10px; padding:5px 12px; border-bottom:1px solid var(--studio-border,rgba(255,255,255,0.08)); }
928.dbg-scrub-cap { color:var(--studio-text-muted,#6b7280); font-size:13px; }
929.dbg-scrub-range { flex:1; accent-color:var(--studio-accent,#667eea); height:4px; cursor:pointer; }
930.dbg-scrub-label { color:var(--studio-text-secondary,#9ca3af); font-family:ui-monospace,monospace; font-size:11px; min-width:62px; text-align:right; }
931.dbg-narr { display:flex; align-items:center; gap:9px; padding:8px 14px; background:linear-gradient(90deg,rgba(102,126,234,0.12),rgba(102,126,234,0.03)); border-bottom:1px solid var(--studio-border,rgba(255,255,255,0.08)); }
932.dbg-narr-ico { display:inline-flex; color:#fbbf24; flex-shrink:0; }
933.dbg-narr-text { color:var(--studio-text,#e8eaed); font-size:13.5px; }
934.dbg-narr-done .dbg-narr-text, .dbg-narr-error .dbg-narr-text { font-style:italic; color:var(--studio-text-secondary,#9ca3af); }
935.dbg-narr-error .dbg-narr-ico { color:#f87171; }
936.dbg-narr-socratic { background:linear-gradient(90deg,rgba(34,211,238,0.13),rgba(34,211,238,0.03)); }
937.dbg-narr-socratic .dbg-narr-ico { color:#22d3ee; }
938.dbg-narr-socratic .dbg-narr-text { color:#cffafe; }
939.dbg-narr-toggle { margin-left:auto; flex-shrink:0; background:transparent; border:1px solid var(--studio-border,rgba(255,255,255,0.14)); color:var(--studio-text-muted,#6b7280); cursor:pointer; border-radius:50%; width:20px; height:20px; font-weight:700; font-size:12px; line-height:1; display:inline-flex; align-items:center; justify-content:center; }
940.dbg-narr-toggle:hover { border-color:#22d3ee; color:#22d3ee; }
941.dbg-narr-toggle.on { background:rgba(34,211,238,0.18); border-color:#22d3ee; color:#22d3ee; }
942.dbg-play { color:#4ade80; }
943.dbg-mem-hint { color:var(--studio-text-muted,#6b7280); font-size:11px; margin-bottom:8px; }
944.dbg-sframe { margin-bottom:8px; border:1px solid var(--studio-border,rgba(255,255,255,0.08)); border-radius:6px; overflow:hidden; }
945.dbg-sframe-h { display:flex; justify-content:space-between; padding:4px 8px; background:rgba(255,255,255,0.04); }
946.dbg-sframe-fn { color:#a5b4fc; font-weight:600; }
947.dbg-sframe-base { color:var(--studio-text-muted,#6b7280); font-family:ui-monospace,monospace; font-size:11px; }
948.dbg-slot { display:flex; gap:10px; padding:1px 8px; }
949.dbg-slot.changed { background:rgba(102,126,234,0.18); }
950.dbg-slot-addr { color:#6b7280; font-family:ui-monospace,monospace; min-width:36px; }
951.dbg-slot-name { color:#cbd5e1; min-width:50px; }
952.dbg-slot-type { color:#f0abfc; font-size:10px; opacity:0.75; min-width:42px; }
953.dbg-slot-val { color:#4ade80; }
954.dbg-heap { display:flex; flex-direction:column; gap:8px; }
955.dbg-hobj { border:1px solid var(--studio-border,rgba(255,255,255,0.12)); border-radius:7px; padding:6px 9px; background:rgba(255,255,255,0.02); }
956.dbg-hobj.shared { border-color:#fbbf24; background:rgba(251,191,36,0.06); }
957.dbg-hobj-h { display:flex; align-items:center; gap:8px; }
958.dbg-hobj-id { color:#22d3ee; font-family:ui-monospace,monospace; font-weight:600; }
959.dbg-hobj-kind { color:var(--studio-text-secondary,#9ca3af); text-transform:uppercase; font-size:10px; letter-spacing:0.5px; }
960.dbg-hobj-store { color:#22d3ee; font-family:ui-monospace,monospace; font-size:10px; background:rgba(34,211,238,0.1); padding:0 5px; border-radius:3px; }
961.dbg-hobj-rc { color:#9ca3af; font-size:10px; }
962.dbg-hobj-alias { color:#fbbf24; font-size:10px; text-transform:uppercase; letter-spacing:0.5px; }
963.dbg-hobj-val { color:#86efac; margin:3px 0; word-break:break-all; }
964.dbg-hobj-refs { display:flex; align-items:center; gap:5px; flex-wrap:wrap; }
965.dbg-refs-label { color:#6b7280; }
966.dbg-ref { background:rgba(102,126,234,0.2); color:#a5b4fc; padding:0 6px; border-radius:4px; font-size:11px; }
967.dbg-close { display:inline-flex; align-items:center; justify-content:center; background:transparent; border:none; color:var(--studio-text-muted,#6b7280); cursor:pointer; padding:4px 5px; border-radius:5px; margin-left:8px; }
968.dbg-close:hover { background:rgba(248,113,113,0.15); color:#f87171; }
969.dbg-reload { display:flex; align-items:center; gap:12px; padding:7px 14px; background:rgba(251,191,36,0.1); border-bottom:1px solid var(--studio-border,rgba(255,255,255,0.08)); }
970.dbg-reload-msg { color:#fbbf24; font-size:12.5px; flex:1; }
971.dbg-reload-btn { background:#fbbf24; color:#1a1f27; border:none; border-radius:5px; padding:3px 12px; font-weight:600; cursor:pointer; font-size:12px; }
972.dbg-reload-btn:hover { filter:brightness(1.1); }
973"#;
974
975#[cfg(test)]
976mod tests {
977    use super::*;
978
979    const PROG: &str = "## Main\n\nLet x be 6.\nLet y be 7.\nShow x + y.";
980
981    /// The datapath easter-egg renders the live register file from a real snapshot —
982    /// an SVG document with cells, and (when stepping) glow/wire classes for the op.
983    #[test]
984    fn datapath_svg_renders_from_a_real_snapshot() {
985        let mut dbg = Debugger::from_source(PROG).expect("compiles");
986        dbg.step();
987        dbg.step();
988        dbg.step();
989        let snap = dbg.snapshot();
990        let svg = datapath_svg(&snap);
991        assert!(svg.starts_with("<svg"), "produces an SVG document: {svg:.40}");
992        assert!(svg.ends_with("</svg>"), "well-formed SVG");
993        assert!(svg.contains("rcell"), "renders register cells");
994        assert!(svg.contains("ALU"), "renders the engine box");
995    }
996
997    /// Type labels flow all the way through to the UI snapshot — the Variables/Stack
998    /// tabs read `reg.kind`, so a register holding `6` must report `Int`.
999    #[test]
1000    fn snapshot_registers_carry_their_type() {
1001        let mut dbg = Debugger::from_source(PROG).expect("compiles");
1002        dbg.resume();
1003        let snap = dbg.snapshot();
1004        let kinds: Vec<&str> = snap
1005            .frames
1006            .last()
1007            .map(|f| f.registers.iter().map(|r| r.kind.as_str()).collect())
1008            .unwrap_or_default();
1009        assert!(kinds.contains(&"Int"), "an Int register is surfaced for the UI: {kinds:?}");
1010        assert!(kinds.iter().all(|k| !k.is_empty()), "no register is left untyped: {kinds:?}");
1011    }
1012
1013    /// `esc` keeps SVG/HTML text injection-safe — register values are interpolated
1014    /// into the datapath markup, so `<`/`&` must be entity-escaped.
1015    #[test]
1016    fn esc_escapes_markup_metacharacters() {
1017        assert_eq!(esc("a<b & c>d"), "a&lt;b &amp; c&gt;d");
1018    }
1019
1020    /// The variable oscilloscope plots a labelled, typed lane per variable with a
1021    /// playhead and held value boxes.
1022    #[test]
1023    fn timeline_svg_plots_each_variable() {
1024        let mut dbg = Debugger::from_source(PROG).expect("compiles");
1025        dbg.resume();
1026        let tl = dbg.variable_timeline();
1027        let svg = timeline_svg(&tl);
1028        assert!(svg.starts_with("<svg") && svg.ends_with("</svg>"), "well-formed SVG");
1029        assert!(svg.contains("tlph"), "renders a playhead");
1030        assert!(svg.contains("tlbox"), "renders held value boxes");
1031        assert!(svg.contains("x : Int"), "labels x's lane with its type");
1032        assert!(svg.contains("y : Int"), "labels y's lane with its type");
1033    }
1034
1035    /// The snapshot exposes a first-order-logic reading of the executing op for the
1036    /// overlay line, and the live-proof bridge answers a breakpoint assertion.
1037    #[test]
1038    fn fol_overlay_and_live_proof_reach_the_ui() {
1039        let mut dbg = Debugger::from_source(PROG).expect("compiles");
1040        dbg.resume();
1041        // Live proof: x < y is both true now and proven for every run.
1042        let r = dbg.assert_at_cursor("x < y");
1043        assert!(r.parsed && r.now == Some(true));
1044        assert_eq!(r.verdict, ProofVerdict::ProvenTrue);
1045        // FOL overlay: stepping surfaces a formula on the addition op.
1046        let mut dbg2 = Debugger::from_source(PROG).expect("compiles");
1047        let mut saw_fol = false;
1048        while dbg2.is_running() {
1049            if dbg2.snapshot().fol.contains("x + y") {
1050                saw_fol = true;
1051            }
1052            dbg2.step();
1053        }
1054        assert!(saw_fol, "the addition's FOL reaches the snapshot");
1055    }
1056
1057    /// The Socratic teaching line reaches the UI — a guiding question that asks the
1058    /// learner to predict the step's outcome.
1059    #[test]
1060    fn socratic_prompt_reaches_the_ui() {
1061        let mut dbg = Debugger::from_source(PROG).expect("compiles");
1062        let mut saw = false;
1063        while dbg.is_running() {
1064            let q = dbg.snapshot().socratic;
1065            if q.contains("sum") && q.ends_with('?') {
1066                saw = true;
1067            }
1068            dbg.step();
1069        }
1070        assert!(saw, "a Socratic question reaches the snapshot for the UI");
1071    }
1072
1073    /// Proven invariants surface from the Oracle without stepping — x is statically
1074    /// proven constant, with a finite range fact the UI can render.
1075    #[test]
1076    fn proven_invariants_are_available_to_the_ui() {
1077        let dbg = Debugger::from_source(PROG).expect("compiles");
1078        let proven = dbg.proven_invariants();
1079        let x = proven.iter().find(|p| p.name == "x").expect("x is proven");
1080        assert!(x.facts.iter().any(|f| f.contains("[6, 6]")), "x proven constant: {:?}", x.facts);
1081    }
1082
1083    /// The provenance render shows the traced value and its full input lineage as a
1084    /// structured tree.
1085    #[test]
1086    fn causal_html_renders_the_lineage() {
1087        let mut dbg = Debugger::from_source(PROG).expect("compiles");
1088        dbg.resume();
1089        let snap = dbg.snapshot();
1090        let sum = snap.frames.last().unwrap().registers.iter().find(|r| r.value == "13").unwrap().index;
1091        let node = dbg.provenance(sum).expect("13 has provenance");
1092        let html = causal_html(&node);
1093        assert!(html.contains("= 13"), "shows the traced value: {html}");
1094        assert!(html.contains("= 6") && html.contains("= 7"), "shows the input lineage: {html}");
1095        assert!(html.contains("cz-row"), "structured rows");
1096    }
1097}