1use 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#[component]
32pub fn DebugDrawer(source: String, on_close: EventHandler<()>) -> Element {
33 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 let mut traced = use_signal(|| None::<u16>);
40 let mut prove_query = use_signal(String::new);
42 let mut socratic_mode = use_signal(|| true);
45 let mut hw_open = use_signal(|| false);
47 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 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 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 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 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 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 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 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 {
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 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 if hw_open() {
244 div { class: "dbg-hw", dangerous_inner_html: "{datapath_svg(&snap)}" }
245 }
246
247 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 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 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 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 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
562fn esc(s: &str) -> String {
564 s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
565}
566
567fn 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 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 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 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 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
653fn 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 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 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 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
731fn 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
803const 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
820pub 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 #[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 #[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 #[test]
1016 fn esc_escapes_markup_metacharacters() {
1017 assert_eq!(esc("a<b & c>d"), "a<b & c>d");
1018 }
1019
1020 #[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 #[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 let r = dbg.assert_at_cursor("x < y");
1043 assert!(r.parsed && r.now == Some(true));
1044 assert_eq!(r.verdict, ProofVerdict::ProvenTrue);
1045 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 #[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 #[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 #[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}