Skip to main content

logicaffeine_web/ui/pages/
studio_workbench.rs

1//! The Studio's workbench: the toolbar, the Files tree, the editor and output columns, the Socratic
2//! guide and the status bar, with every mode's handlers.
3//!
4//! Two pages draw it. The Studio page ([`Host::Page`]) wraps it with its head, its nav, the reader's own
5//! store and the address bar, which the workbench never reaches. The front page ([`Host::Frame`])
6//! frames it over a shelf of shipped examples. Its state is [`WorkbenchState`], which the host holds
7//! ([`use_workbench`]), so both pages show one Studio: one markup, one stylesheet, one set of handlers.
8
9use crate::ui::components::ast_tree::AstTree;
10use crate::ui::components::code_editor::{CodeEditor, CodeView, Language};
11use crate::ui::components::collapsible::Collapsible;
12use crate::ui::components::context_view::ContextView;
13use crate::ui::components::debug_drawer::{DebugDrawer, IC_BUG};
14use crate::ui::components::file_browser::FileBrowser;
15use crate::ui::components::indent::Indentation;
16use crate::ui::components::logic_output::{LogicOutput, OutputFormat};
17use crate::ui::components::proof_panel::{dock_prompt, ProofNode, ProofPanel, ProofStatus, Walkthrough};
18use crate::ui::components::repl_output::ReplOutput;
19use crate::ui::components::socratic_guide::{logic_guide, GuideMode, SocraticGuide};
20use crate::ui::components::status_bar::{EditorStatus, StatusBar};
21use crate::ui::components::symbol_dictionary::{symbols_in, SymbolGroups};
22use crate::ui::examples::{compiled_runtime_notice, planted_bug_notice};
23use crate::ui::fault::{contain, ActionFault};
24use crate::ui::hooks::{use_keyboard_inset, use_media, use_viewport_width};
25use crate::ui::pages::heal;
26use crate::ui::pages::hardware_report::{
27    analyze as analyze_hardware, compiled as compile_hardware, kind_of as hardware_kind_of,
28    Compiled, HardwareKind, HardwareReport,
29};
30use crate::ui::pages::studio::{
31    attempt, auto_view, formula_on_screen, kg_svg, parse_math_statements, run_logic, run_math,
32    traffic_svg, waveform_svg, CodeOutputMode, LogicRun, LogicView, MathContext, TheoremView,
33    COLLAPSE_FILES_SVG, FILES_SVG, STUDIO_STYLE,
34};
35use crate::ui::pages::studio_actions::{actions_for, Actions, Offer};
36use crate::ui::pages::studio_files::example_text;
37use crate::ui::pages::studio_layout::{first_pane, Columns, MobileTab, Resizer, Shown, DRAWER_INITIAL, PROOF_DRAWER_INITIAL};
38use crate::ui::pages::studio_opening::StudioOpening;
39use crate::ui::responsive::media;
40use crate::ui::router::studio_file_url;
41use crate::ui::state::{FileNode, ReplLine, StudioMode};
42use dioxus::prelude::*;
43#[cfg(target_arch = "wasm32")]
44use logicaffeine_compile::interpret_for_ui_baseline;
45use logicaffeine_compile::{
46    extract_logic_rust, extract_math_rust, generate_rust_code, interpret_streaming_with_vfs,
47    interpreter::InterpreterResult, CompileResult, TheoremCompileResult,
48};
49use logicaffeine_kernel::interface::Repl;
50use logicaffeine_language::symbol_dict::SymbolEntry;
51use logicaffeine_proof::ProofExpr;
52use logicaffeine_system::fs::Vfs;
53use std::cell::RefCell;
54use std::collections::HashMap;
55use std::rc::Rc;
56use std::sync::Arc;
57
58/// The Web preview's name, for a screen reader.
59pub const PREVIEW_TITLE: &str = "The page, built from the code";
60
61/// Where a workbench is drawn, and so what it may reach beyond itself.
62#[derive(Clone, Copy, PartialEq, Eq, Debug)]
63pub enum Host {
64    /// The Studio page: the whole window, columns the reader drags, the debugger, and the reader's own
65    /// files through the page.
66    Page,
67    /// A frame on another page: a fixed height, the shelf it is given, columns its stylesheet lays
68    /// out until the reader drags a bar, nothing kept, and a link that opens the open file in the
69    /// Studio.
70    Frame,
71}
72
73/// What the reader's store says beside the files: an error reading it, whether it lasts only for the
74/// browser session, and why New file cannot make a file yet.
75#[derive(Clone, PartialEq, Default)]
76pub struct StoreNote {
77    pub error: Option<String>,
78    pub private_mode: bool,
79    pub new_file_waits: Option<String>,
80}
81
82/// The files a frame shows: shipped examples, in the Studio's own folders, and the one it opens on.
83pub struct Shelf {
84    pub opens: &'static str,
85    pub files: &'static [&'static str],
86}
87
88/// The state of a frame over `shelf`, for a page to hold: opened on `shelf.opens`, its tree the
89/// shelf's files. A page calls it once, as it calls any hook, and hands it to [`StudioFrame`].
90pub fn use_shelf(shelf: &'static Shelf) -> WorkbenchState {
91    let opening = use_hook(|| StudioOpening::for_route(Some(shelf.opens)));
92    use_workbench(&opening, || FileNode::from_paths(shelf.files.iter().copied()))
93}
94
95/// Where the site serves a shipped Web page's prebuilt preview document: `/previews/<path>.html`
96/// for a page under `/examples/`, written at release by `web_previews_lock`'s regenerator from
97/// [`preview_document`]. `None` for anything that is not a shipped page, so a reader's own file or
98/// an edited page is built in the browser as before.
99pub fn prebuilt_preview_path(vfs_path: &str) -> Option<String> {
100    let text = example_text(vfs_path)?;
101    (StudioMode::for_file(vfs_path, text) == Some(StudioMode::Web))
102        .then(|| format!("/previews{}.html", vfs_path.strip_prefix("/examples").unwrap_or(vfs_path)))
103}
104
105/// What the Studio adds to a page's preview document, in its head: the page stands in the middle of
106/// the frame, across and down, however small it is, and from the top once it is taller than the
107/// frame (`safe center`). A page served by largo gets none of this: it is the Studio's frame, not
108/// the page, that centers.
109pub const PREVIEW_CENTERING_STYLE: &str = "<style data-studio=\"preview-centering\">html{min-height:100%;display:flex;flex-direction:column;justify-content:safe center}</style>";
110
111/// The document the Studio's Web preview frames for `source`: the page as `largo serve` would paint
112/// it, first paint included, with [`PREVIEW_CENTERING_STYLE`] in its head; or, when the page does not
113/// build, the themed overlay that says why.
114pub fn preview_document(source: &str) -> String {
115    match logicaffeine_compile::web_host::assemble_preview_painted(source) {
116        Ok(doc) => match doc.find("</head>") {
117            Some(at) => format!("{}{}{}", &doc[..at], PREVIEW_CENTERING_STYLE, &doc[at..]),
118            None => doc,
119        },
120        Err(pe) => logicaffeine_compile::web_host::error_overlay_html(&pe, source, ""),
121    }
122}
123
124/// The Studio in a frame on another page: the workbench with the Studio's own stylesheet, and
125/// [`FRAME_STYLE`] for what a frame lays out itself.
126/// The id of the frame's element on its page: what a Try it button names, and scrolls to.
127pub const FRAME_ID: &str = "studio-frame";
128
129#[component]
130pub fn StudioFrame(state: WorkbenchState) -> Element {
131    rsx! {
132        div { id: FRAME_ID, class: "studio-frame",
133            style { "{STUDIO_STYLE}" }
134            style { "{crate::ui::drag::DRAG_STYLE}" }
135            style { "{FRAME_STYLE}" }
136            Workbench { state, host: Host::Frame }
137        }
138    }
139}
140
141/// What a frame lays out itself, every rule inside the frame: its box on the page and its height in
142/// each band, its columns in the proportions the Studio first opens with, which hold until the
143/// reader drags a column bar (from then on the frame draws the columns as the Studio page does, in
144/// its own width), and the link into the Studio.
145pub const FRAME_STYLE: &str = r#"
146.studio-frame {
147    position: relative;
148    display: flex;
149    flex-direction: column;
150    width: calc(100% - 32px);
151    max-width: 1600px;
152    height: 720px;
153    margin: 0 auto;
154    background: var(--studio-bg);
155    color: var(--studio-text);
156    border: 1px solid var(--studio-border);
157    border-radius: var(--radius-lg);
158    overflow: hidden;
159    scrollbar-color: var(--studio-border-hover) transparent;
160}
161
162.studio-frame .studio-content {
163    --col-sidebar: 240px;
164    --col-left: 35%;
165    --col-right: 25%;
166}
167
168.studio-frame .studio-sidebar {
169    border-right: 1px solid var(--studio-border);
170}
171
172.studio-frame .studio-main > .col-left,
173.studio-frame .studio-main > .col-right {
174    min-width: 200px;
175}
176
177/* The way into the Studio, at the toolbar's end, in the toolbar's quieter text. */
178.studio-frame .studio-open {
179    display: inline-flex;
180    align-items: center;
181    min-height: 32px;
182    padding: 0 12px;
183    border: 1px solid var(--accent-primary);
184    border-radius: 6px;
185    color: var(--studio-text);
186    font-size: 13px;
187    font-weight: 600;
188    text-decoration: none;
189    white-space: nowrap;
190}
191
192.studio-frame .studio-open:hover {
193    background: var(--accent-primary);
194    color: #fff;
195}
196
197@media (min-width: 720px) {
198    .studio-frame {
199        width: calc(100% - 48px);
200    }
201}
202
203/* The way into the Studio is a finger wide wherever a finger may press it. */
204@media (max-width: 768px), (pointer: coarse), (max-height: 500px) and (orientation: landscape) {
205    .studio-frame .studio-open {
206        min-height: 44px;
207    }
208}
209
210/* The frame's height in each band, set by the stylesheet alone, so the server's render is the first
211   frame at every width: some page always shows above or below it on a phone, where a swipe scrolls
212   the page. */
213@media (min-width: 769px) and (min-height: 501px) and (pointer: coarse) {
214    .studio-frame {
215        height: min(720px, 85svh);
216    }
217}
218
219@media (min-width: 601px) and (max-width: 768px), (min-width: 769px) and (max-width: 1024px) and (orientation: portrait) and (pointer: coarse) {
220    .studio-frame {
221        height: min(960px, 85svh);
222    }
223}
224
225@media (max-width: 600px) {
226    .studio-frame {
227        height: min(640px, 75svh);
228    }
229}
230
231@media (max-height: 500px) and (orientation: landscape) {
232    .studio-frame {
233        height: 85svh;
234    }
235}
236"#;
237
238/// Everything the workbench shows and edits: what is open, each mode's editor and what each mode last
239/// made, and how the columns are laid out. The host holds it ([`use_workbench`]), so the Studio page's
240/// own effects, its store and its address bar, and the workbench's handlers work on one state; it is
241/// signals, so it is `Copy`.
242#[derive(Clone, Copy, PartialEq)]
243pub struct WorkbenchState {
244    // What is open
245    /// The mode the open file is in: the Studio has no mode switch, opening a file is choosing it.
246    pub(super) mode: Signal<StudioMode>,
247    /// Whether the Files tree shows as a column, where it is one.
248    pub(super) sidebar_open: Signal<bool>,
249    /// Whether the Files tree is a drawer over the workbench on this screen (`media::FILES_DRAWER`).
250    pub(super) drawer: Signal<bool>,
251    /// Whether the reader opened that drawer. It starts folded, so no tree covers the editor.
252    pub(super) drawer_open: Signal<bool>,
253    /// Whether the panes show one at a time or in halves (`media::PANES_FIXED`), so a pane's header
254    /// folds nothing.
255    pub(super) panes_fixed: Signal<bool>,
256    /// How much of the window an on-screen keyboard covers.
257    pub(super) keyboard_inset: Signal<f64>,
258    /// The Files tree.
259    pub(super) file_tree: Signal<FileNode>,
260    /// The file open in the editor, by path.
261    pub(super) current_file: Signal<Option<String>>,
262    /// The text the reader left each file at this visit, by path, so opening it again shows it. Nothing
263    /// of it is written to a store.
264    pub(super) edits: Signal<HashMap<String, String>>,
265    // Logic
266    pub(super) input: Signal<String>,
267    pub(super) result: Signal<CompileResult>,
268    pub(super) format: Signal<OutputFormat>,
269    pub(super) proof_text: Signal<String>,
270    /// The derivation, as the drawer's tree, once a proof has one.
271    pub(super) proof_tree: Signal<Option<ProofNode>>,
272    pub(super) proof_status: Signal<ProofStatus>,
273    pub(super) proof_hint: Signal<Option<String>>,
274    pub(super) current_proof_expr: Signal<Option<ProofExpr>>,
275    pub(super) knowledge_base: Signal<Vec<ProofExpr>>,
276    pub(super) logic_output_mode: Signal<LogicView>,
277    pub(super) generated_logic_rust: Signal<String>,
278    pub(super) dictionary_open: Signal<bool>,
279    pub(super) tree_open: Signal<bool>,
280    pub(super) dictionary: Memo<Option<Vec<SymbolEntry>>>,
281    /// The proof drawer's height, which its grip changes, and whether the drawer is open.
282    pub(super) proof_height: Signal<f64>,
283    pub(super) proof_open: Signal<bool>,
284    // Code
285    /// Code and Web share one editor.
286    pub(super) code_input: Signal<String>,
287    pub(super) code_output_mode: Signal<CodeOutputMode>,
288    pub(super) interpreter_result: Signal<InterpreterResult>,
289    pub(super) generated_rust: Signal<String>,
290    pub(super) debugging: Signal<bool>,
291    pub(super) debug_output: Signal<InterpreterResult>,
292    pub(super) drawer_height: Signal<f64>,
293    /// What a program's file statements read and write: the reader's store on the Studio page, none in a frame.
294    pub(super) program_store: Signal<Option<Arc<dyn Vfs>>>,
295    // Math
296    pub(super) math_input: Signal<String>,
297    pub(super) math_context: Signal<MathContext>,
298    pub(super) math_output: Signal<Vec<ReplLine>>,
299    pub(super) math_output_mode: Signal<CodeOutputMode>,
300    pub(super) generated_math_rust: Signal<String>,
301    // Hardware
302    pub(super) hw_input: Signal<String>,
303    pub(super) hw_output_mode: Signal<CodeOutputMode>,
304    pub(super) hw_report: Signal<Option<(HardwareKind, HardwareReport)>>,
305    pub(super) generated_hw_rust: Signal<String>,
306    pub(super) hw_compiled: Signal<Option<Compiled>>,
307    // Web
308    pub(super) debounced_code: Signal<String>,
309    pub(super) web_edit_gen: Signal<u64>,
310    pub(super) web_preview: Memo<String>,
311    // Every mode
312    pub(super) fault: Signal<Option<ActionFault>>,
313    pub(super) actions: Memo<Actions>,
314    pub(super) editor_status: Signal<EditorStatus>,
315    /// LLM Heal: where the fairy is and what her card shows (work/LLM_HEAL.md).
316    pub(super) heal: Signal<heal::Flight>,
317    /// What each file's text was when it opened or was last kept, by path: a heal mends only the
318    /// lines a person wrote since (the user, 2026-11-02).
319    pub(super) heal_baseline: Signal<std::collections::HashMap<String, String>>,
320    // Layout
321    pub(super) column_pref: Signal<Option<Columns>>,
322    pub(super) viewport_width: Signal<f64>,
323    pub(super) active_tab: Signal<MobileTab>,
324    pub(super) editor_expanded: Signal<bool>,
325    pub(super) output_expanded: Signal<bool>,
326    pub(super) mobile_split: Signal<f64>,
327}
328
329/// The workbench's state, for a host to hold: what `opening` opens on, over the files `tree` lists. The
330/// host calls it once, as it calls any hook, and hands what it returns to [`Workbench`].
331pub fn use_workbench(opening: &StudioOpening, tree: impl FnOnce() -> FileNode) -> WorkbenchState {
332    // Mode state
333    let mode = use_signal(|| opening.mode);
334
335    // File browser state
336    let sidebar_open = use_signal(|| true);
337    let drawer = use_media(media::FILES_DRAWER);
338    let drawer_open = use_signal(|| false);
339    let panes_fixed = use_media(media::PANES_FIXED);
340    let keyboard_inset = use_keyboard_inset();
341    // The examples' part of the tree is the site's own, there from the first frame. The reader's
342    // files join it once their store answers.
343    let file_tree = use_signal(tree);
344    let current_file = use_signal(|| opening.current_file());
345    let edits = use_signal(HashMap::new);
346
347    // Logic mode state
348    let input = use_signal(|| opening.editor_text(StudioMode::Logic));
349    let result = use_signal(|| CompileResult {
350        logic: None,
351        simple_logic: None,
352        kripke_logic: None,
353        ast: None,
354        readings: Vec::new(),
355        simple_readings: Vec::new(),
356        kripke_readings: Vec::new(),
357        tokens: Vec::new(),
358        error: None,
359    });
360    let format = use_signal(|| OutputFormat::SimpleFOL);
361
362    // Proof panel state for Logic mode
363    let proof_text = use_signal(String::new);
364    let proof_tree = use_signal(|| None::<ProofNode>);
365    let proof_status = use_signal(|| ProofStatus::Idle);
366    let proof_hint = use_signal(|| None::<String>);
367    // The current ProofExpr for the proof engine
368    let current_proof_expr = use_signal(|| None::<ProofExpr>);
369    // Knowledge base (axioms/premises) for the proof engine
370    let knowledge_base = use_signal(Vec::<ProofExpr>::new);
371    // Logic mode output view: FOL (Logic) vs extracted Rust
372    let logic_output_mode = use_signal(|| LogicView::Logic);
373    let generated_logic_rust = use_signal(String::new);
374    // The Logic column's blocks under the formula, open or closed as the reader left them — across
375    // Executes, errors and files — until the page is left.
376    let dictionary_open = use_signal(|| true);
377    let tree_open = use_signal(|| false);
378    // What the Symbol Dictionary lists: the symbols of the formula on screen, in its view.
379    let dictionary = use_memo(move || {
380        formula_on_screen(&result.read(), *format.read())
381            .map(|(logic, view)| symbols_in(&logic, view))
382            .filter(|symbols| !symbols.is_empty())
383    });
384
385    // Code mode state (imperative .logos)
386    let code_input = use_signal(|| opening.editor_text(StudioMode::Code));
387    let code_output_mode = use_signal(|| CodeOutputMode::Interpret);
388    let interpreter_result = use_signal(|| InterpreterResult {
389        lines: vec![],
390        error: None,
391    });
392    let generated_rust = use_signal(String::new);
393    // Code-mode debugger drawer (bottom-docked, additive — see `DebugDrawer`).
394    let debugging = use_signal(|| false);
395
396    // Math mode state (vernacular/theorem proving)
397    let math_input = use_signal(|| opening.editor_text(StudioMode::Math));
398    // The environment the last run built, as the Context panel lists it.
399    let math_context = use_signal(MathContext::prelude);
400    let math_output = use_signal(Vec::<ReplLine>::new);
401    // Math mode output view: REPL output (Interpret) vs extracted Rust
402    let math_output_mode = use_signal(|| CodeOutputMode::Interpret);
403    let generated_math_rust = use_signal(String::new);
404
405    // Hardware mode state (English hardware spec -> SVA + in-browser proving)
406    let hw_input = use_signal(|| opening.editor_text(StudioMode::Hardware));
407    // Output view: SVA (Interpret) vs extracted Rust runtime monitor.
408    let hw_output_mode = use_signal(|| CodeOutputMode::Interpret);
409    // What Hardware mode made of the editor's text, from one reading (`hardware_report::analyze`):
410    // its kind, the result panel's sections and verdict, the trace, signals and knowledge graph.
411    let mut hw_report = use_signal(|| None::<(HardwareKind, HardwareReport)>);
412    let generated_hw_rust = use_signal(String::new);
413    // What Compile said when it made no Rust: a design with nothing to compile, or one it could
414    // not read. Cleared whenever the design is read again.
415    let hw_compiled = use_signal(|| None::<Compiled>);
416
417    // What stopped the last action, if it failed in a way nobody planned for: the toolbar's
418    // orange note (see `attempt`).
419    let fault = use_signal(|| None::<ActionFault>);
420
421    // The editor each mode reads and writes: Code and Web share one.
422    let editor = move |mode: StudioMode| match mode {
423        StudioMode::Logic => input,
424        StudioMode::Code | StudioMode::Web => code_input,
425        StudioMode::Math => math_input,
426        StudioMode::Hardware => hw_input,
427    };
428
429    // What each toolbar action can do for the file in the editor (studio_actions.rs): an action it
430    // cannot take stays in place, disabled, and says why. Read behind the fault boundary — a fault
431    // in reading the file leaves every action live, to report the fault itself when pressed.
432    let actions = use_memo(move || {
433        let mode = *mode.read();
434        let path = current_file.read().clone();
435        let content = editor(mode).read().clone();
436        contain(|| actions_for(mode, path.as_deref(), &content)).unwrap_or(Actions {
437            run: Offer::Ready,
438            compile: Offer::Ready,
439            debug: Offer::Ready,
440        })
441    });
442
443    // The column layout the user has shaped by dragging — none until the first drag — drawn
444    // fitted to the window's width (see studio_layout.rs).
445    let column_pref = use_signal(|| None::<Columns>);
446    let viewport_width = use_viewport_width();
447    // The debugger drawer's height, kept across closing and reopening it.
448    let drawer_height = use_signal(|| DRAWER_INITIAL);
449    // What the debugged program has printed at the cursor, shown in the Output column while the
450    // debugger is open.
451    let debug_output = use_signal(|| InterpreterResult {
452        lines: Vec::new(),
453        error: None,
454    });
455    // Where the editor's caret is and what its language service found, for the status bar.
456    let editor_status = use_signal(EditorStatus::default);
457    let proof_height = use_signal(|| PROOF_DRAWER_INITIAL);
458    let proof_open = use_signal(|| true);
459    // The pane a phone shows, the one that shows the file first.
460    let active_tab = use_signal(|| first_pane(opening.mode));
461
462    // Mobile panel collapse state
463    let editor_expanded = use_signal(|| true);
464    let output_expanded = use_signal(|| true);
465
466    // Mobile vertical split: Panel 1 height as % of studio-main
467    let mobile_split = use_signal(|| 50.0f64);
468
469    // The store a program's file statements read and write: the reader's, once the Studio page
470    // has opened it. A frame has none.
471    let program_store = use_signal(|| None::<Arc<dyn Vfs>>);
472
473    // Debounce the Web preview: recompiling wasm on every keystroke makes the iframe
474    // flicker and steals cycles while you type. We commit the editor buffer into
475    // `debounced_code` only after ~300ms of quiet (a generation counter voids stale
476    // spawns — the last edit wins), and the preview reads the settled buffer. A page the
477    // workbench opens on is settled from the start, so the first frame, the server's render
478    // included, already shows it.
479    let mut debounced_code = use_signal(|| {
480        if opening.mode == StudioMode::Web {
481            opening.editor_text(StudioMode::Web)
482        } else {
483            String::new()
484        }
485    });
486    let mut web_edit_gen = use_signal(|| 0u64);
487    // The file the settled source came from: a source that arrives with another file is a file
488    // opened, not a keystroke, and shows at once.
489    let mut settled_file = use_signal(|| opening.current_file());
490    use_effect(move || {
491        if *mode.read() != StudioMode::Web {
492            return;
493        }
494        let src = code_input.read().clone();
495        let file = current_file.read().clone();
496        if *debounced_code.peek() == src {
497            return;
498        }
499        // A page opened after another mode shows instantly — nothing to debounce against
500        // yet — and so does a file opened from the tree; only the *edits* wait for the quiet window.
501        if debounced_code.peek().is_empty() || *settled_file.peek() != file {
502            settled_file.set(file);
503            debounced_code.set(src);
504            return;
505        }
506        let my_gen = *web_edit_gen.peek() + 1;
507        web_edit_gen.set(my_gen);
508        spawn(async move {
509            gloo_timers::future::TimeoutFuture::new(300).await;
510            if *web_edit_gen.peek() == my_gen {
511                debounced_code.set(src);
512            }
513        });
514    });
515
516    // The live "Web" preview: compile the LOGOS source to wasm in-browser and assemble a
517    // self-contained iframe `srcdoc` that carries the page's first paint (a build error becomes
518    // the themed overlay), so a rebuild never flashes blank. Recomputes only when the debounced
519    // source settles and only in Web mode — the edit-and-see loop.
520    // A page built once in this session is not built again: coming back to a shelf file shows its
521    // preview at once (the build is the slow part, the LOGOS compiler running in the browser).
522    let built = use_hook(|| std::rc::Rc::new(std::cell::RefCell::new(std::collections::HashMap::<String, String>::new())));
523    // A shipped page's document comes prebuilt from the site (`/previews/...`, written at release
524    // by `web_previews_lock`'s regenerator) rather than built in the browser on every open (the
525    // user, 2026-11-02: the web files were slow to load). Keyed by the file's path, filled by the
526    // fetch below, read by the memo; `last_doc` is what the frame keeps showing while a fetch is on
527    // its way, so nothing is built that the fetch will bring.
528    let prebuilt = use_signal(std::collections::HashMap::<String, String>::new);
529    let last_doc = use_hook(|| std::rc::Rc::new(std::cell::RefCell::new(String::new())));
530    // The server's render already holds the open page's document in the frame on the page: take
531    // it before the first render, so hydration shows it instead of building it again (the user,
532    // 2026-11-02: a web file took two seconds to open; that was the compiler running once more).
533    // Whether the first render has a server render to match: a prerendered page has the frame
534    // on the page already; the dev server and a route the site does not prerender have nothing,
535    // so a shipped page there waits the few milliseconds for its prebuilt document instead of
536    // building it (the dev build opened a web file a second slower than a code file).
537    #[cfg(target_arch = "wasm32")]
538    let hydrating = use_hook(|| {
539        let served = web_sys::window()
540            .and_then(|w| w.document())
541            .and_then(|d| d.query_selector(&format!("iframe[title=\"{PREVIEW_TITLE}\"]")).ok().flatten())
542            .and_then(|frame| frame.get_attribute("srcdoc"))
543            .filter(|d| !d.is_empty());
544        let Some(doc) = served else { return false };
545        if opening.mode == StudioMode::Web {
546            built.borrow_mut().insert(opening.editor_text(StudioMode::Web), doc.clone());
547            *last_doc.borrow_mut() = doc;
548        }
549        true
550    });
551    #[cfg(target_arch = "wasm32")]
552    {
553        let in_flight = use_signal(std::collections::HashSet::<String>::new);
554        // Copies of the signals inside, so the closure is `Fn` and copies into each effect.
555        let fetch = move |path: String, url: String| {
556            let mut in_flight = in_flight;
557            let mut prebuilt = prebuilt;
558            if prebuilt.peek().contains_key(&path) || in_flight.peek().contains(&path) {
559                return;
560            }
561            in_flight.write().insert(path.clone());
562            spawn(async move {
563                if let Ok(response) = gloo_net::http::Request::get(&url).send().await {
564                    if response.ok() {
565                        if let Ok(doc) = response.text().await {
566                            prebuilt.write().insert(path.clone(), doc);
567                        }
568                    }
569                }
570                in_flight.write().remove(&path);
571            });
572        };
573        // The open page first, then every shipped page once the app is up, so a click on a web
574        // file finds its document already here.
575        use_effect(move || {
576            if *mode.read() != StudioMode::Web {
577                return;
578            }
579            let Some(path) = current_file.read().clone() else { return };
580            let Some(url) = prebuilt_preview_path(&path) else { return };
581            fetch(path, url);
582        });
583        use_hook(|| {
584            spawn(async move {
585                gloo_timers::future::TimeoutFuture::new(1500).await;
586                for spec in crate::ui::examples::seeded_examples() {
587                    if let Some(url) = prebuilt_preview_path(spec.vfs_path) {
588                        fetch(spec.vfs_path.to_string(), url);
589                    }
590                }
591            });
592        });
593    }
594    let web_preview = use_memo(move || {
595        if *mode.read() != StudioMode::Web {
596            return String::new();
597        }
598        let src = debounced_code.read().clone();
599        if let Some(doc) = built.borrow().get(&src) {
600            return doc.clone();
601        }
602        if let Some(path) = current_file.read().as_deref() {
603            if example_text(path).is_some_and(|text| text == src) && prebuilt_preview_path(path).is_some() {
604                if let Some(doc) = prebuilt.read().get(path) {
605                    built.borrow_mut().insert(src, doc.clone());
606                    *last_doc.borrow_mut() = doc.clone();
607                    return doc.clone();
608                }
609                // In the browser the fetch brings it, and the frame keeps its last document until
610                // then; with no document yet (the first render, which must match the server's,
611                // or a cold open) the page is built here. The server always builds it.
612                #[cfg(target_arch = "wasm32")]
613                {
614                    let last = last_doc.borrow().clone();
615                    if !last.is_empty() || !hydrating {
616                        return last;
617                    }
618                }
619            }
620        }
621        let doc = preview_document(&src);
622        built.borrow_mut().insert(src, doc.clone());
623        *last_doc.borrow_mut() = doc.clone();
624        doc
625    });
626
627    let heal = use_signal(|| heal::Flight::Idle);
628    let heal_baseline = use_signal(|| {
629        let mut baselines = std::collections::HashMap::new();
630        if let Some(path) = opening.current_file() {
631            baselines.insert(path, opening.editor_text(opening.mode));
632        }
633        baselines
634    });
635
636    let state = WorkbenchState {
637        mode,
638        sidebar_open,
639        drawer,
640        drawer_open,
641        panes_fixed,
642        keyboard_inset,
643        file_tree,
644        current_file,
645        edits,
646        input,
647        result,
648        format,
649        proof_text,
650        proof_tree,
651        proof_status,
652        proof_hint,
653        current_proof_expr,
654        knowledge_base,
655        logic_output_mode,
656        generated_logic_rust,
657        dictionary_open,
658        tree_open,
659        dictionary,
660        proof_height,
661        proof_open,
662        code_input,
663        code_output_mode,
664        interpreter_result,
665        generated_rust,
666        debugging,
667        debug_output,
668        drawer_height,
669        program_store,
670        math_input,
671        math_context,
672        math_output,
673        math_output_mode,
674        generated_math_rust,
675        hw_input,
676        hw_output_mode,
677        hw_report,
678        generated_hw_rust,
679        hw_compiled,
680        debounced_code,
681        web_edit_gen,
682        web_preview,
683        fault,
684        actions,
685        editor_status,
686        heal,
687        heal_baseline,
688        column_pref,
689        viewport_width,
690        active_tab,
691        editor_expanded,
692        output_expanded,
693        mobile_split,
694    };
695
696    // The pigeonhole and register-allocation specs are read as they are typed, as their pictures
697    // always were; the heavier designs wait for Execute.
698    use_effect(move || {
699        let content = hw_input.read().clone();
700        if matches!(
701            hardware_kind_of(&content),
702            Some(HardwareKind::Pigeonhole | HardwareKind::RegisterAlloc)
703        ) {
704            hw_report.set(analyze_hardware(&content));
705        }
706    });
707
708    // The file the workbench opened with runs at once when the site carries its text: Logic reads it,
709    // a program runs, a hardware design is analysed.
710    #[cfg(target_arch = "wasm32")]
711    {
712        let opening = opening.clone();
713        let mut started = use_signal(|| false);
714        use_effect(move || {
715            if *started.read() {
716                return;
717            }
718            started.set(true);
719            if let Some(text) = opening.text {
720                let (opened_mode, path) = (opening.mode, opening.path.clone());
721                spawn(async move {
722                    // Let the first frame paint before the work.
723                    gloo_timers::future::TimeoutFuture::new(0).await;
724                    state.run_opened(opened_mode, path, text.to_string()).await;
725                });
726            }
727        });
728    }
729
730    state
731}
732
733impl WorkbenchState {
734    /// The editor `mode` reads and writes: Code and Web share one.
735    pub fn editor(self, mode: StudioMode) -> Signal<String> {
736        match mode {
737            StudioMode::Logic => self.input,
738            StudioMode::Code | StudioMode::Web => self.code_input,
739            StudioMode::Math => self.math_input,
740            StudioMode::Hardware => self.hw_input,
741        }
742    }
743
744    /// A compiled `## Theorem:` block, shown the one way opening its file and Execute share: its goal
745    /// and premises loaded for the tactics and its proof panel filled, or why it did not compile.
746    pub(super) fn show_theorem(self, theorem: Result<(TheoremCompileResult, TheoremView), String>) {
747        let WorkbenchState { mut result, mut proof_text, mut proof_tree, mut proof_status, mut proof_hint, mut current_proof_expr, mut knowledge_base, .. } = self;
748        match theorem {
749            Err(err) => {
750                result.set(CompileResult {
751                    logic: None,
752                    simple_logic: None,
753                    kripke_logic: None,
754                    ast: None,
755                    readings: Vec::new(),
756                    simple_readings: Vec::new(),
757                    kripke_readings: Vec::new(),
758                    tokens: Vec::new(),
759                    error: Some(err.clone()),
760                });
761                proof_status.set(ProofStatus::Failed(err));
762                current_proof_expr.set(None);
763                knowledge_base.write().clear();
764            }
765            Ok((theorem, view)) => {
766                result.set(CompileResult {
767                    logic: theorem.goal_string.clone(),
768                    simple_logic: theorem.goal_string.clone(),
769                    kripke_logic: None,
770                    ast: None,
771                    readings: Vec::new(),
772                    simple_readings: Vec::new(),
773                    kripke_readings: Vec::new(),
774                    tokens: Vec::new(),
775                    error: None,
776                });
777                knowledge_base.set(theorem.premises);
778                if let Some(goal) = theorem.goal {
779                    current_proof_expr.set(Some(goal));
780                }
781                proof_text.set(view.html);
782                proof_tree.set(view.tree);
783                proof_status.set(view.status);
784                proof_hint.set(Some(view.hint));
785            }
786        }
787    }
788
789    /// What opening a file does once its text is in the editor: Logic reads its sentences or theorem,
790    /// a program runs, a hardware design is analysed. Math waits for Execute, and a Web page's preview
791    /// effect builds it from the editor.
792    #[cfg(target_arch = "wasm32")]
793    async fn run_opened(self, file_mode: StudioMode, path: String, content: String) {
794        let WorkbenchState { mut result, mut current_proof_expr, mut interpreter_result, mut hw_report, mut hw_compiled, fault, .. } = self;
795        match file_mode {
796            StudioMode::Logic => match attempt(fault, "Execute", || run_logic(&content)) {
797                Some(LogicRun::Theorem(theorem)) => self.show_theorem(theorem),
798                Some(LogicRun::Sentences(compiled, proof)) => {
799                    result.set(compiled);
800                    if let Some(expr) = proof.proof_expr {
801                        current_proof_expr.set(Some(expr));
802                    }
803                }
804                Some(LogicRun::Empty) | None => {}
805            },
806            // A native-only example opens to its notice: run here, it would only pretend.
807            StudioMode::Code => {
808                interpreter_result.set(if compiled_runtime_notice(&path).is_some() {
809                    InterpreterResult {
810                        lines: Vec::new(),
811                        error: None,
812                    }
813                } else {
814                    interpret_for_ui_baseline(&content).await
815                })
816            }
817            StudioMode::Hardware => {
818                hw_report.set(analyze_hardware(&content));
819                hw_compiled.set(None);
820            }
821            StudioMode::Math | StudioMode::Web => {}
822        }
823    }
824
825    /// Opening a file is choosing its mode: the Studio has no mode switch. The file picks the mode
826    /// (`StudioMode::for_file`; a file with no LOGOS extension keeps the current one), its content loads
827    /// into that mode's editor (or the text the reader left it at, this visit), and it runs. The store's answer on mount, a choice in the Files tree and
828    /// a host opening a file all come through here, so they can never disagree about a file.
829    #[cfg(target_arch = "wasm32")]
830    pub async fn open(self, path: String, content: String) {
831        let WorkbenchState { mut mode, mut fault, .. } = self;
832        let file_mode = StudioMode::for_file(&path, &content).unwrap_or(*mode.peek());
833        mode.set(file_mode);
834        // A note about the last file's run says nothing about this one.
835        if fault.peek().is_some() {
836            fault.set(None);
837        }
838        let content = self.opening_text(&path, content);
839        self.editor(file_mode).set(content.clone());
840        // A heal mends what the reader writes from here.
841        let mut baseline = self.heal_baseline;
842        baseline.write().insert(path.clone(), content.clone());
843        self.run_opened(file_mode, path, content).await;
844    }
845
846    /// The text a file opens with: what the reader left it at this visit, or else `content`.
847    #[cfg(any(target_arch = "wasm32", test))]
848    pub(super) fn opening_text(self, path: &str, content: String) -> String {
849        self.edits.peek().get(path).cloned().unwrap_or(content)
850    }
851
852    /// Keep the open file's text as the reader left it, for when they come back to it.
853    fn keep_edit(self) {
854        let WorkbenchState { mode, current_file, mut edits, .. } = self;
855        let open = current_file.peek().clone();
856        if let Some(path) = open {
857            let text = self.editor(*mode.peek()).peek().clone();
858            edits.write().insert(path, text);
859        }
860    }
861
862    /// A file chosen in the Files tree, or by the host: it becomes the open file, the tree folds away on
863    /// a phone, and a shipped example opens at once, from the site itself, even before any store has
864    /// answered. A reader's own file is the host's to read.
865    pub fn choose(self, path: String) {
866        let mut current_file = self.current_file;
867        let mut drawer_open = self.drawer_open;
868        if *self.drawer.peek() && *drawer_open.peek() {
869            drawer_open.set(false);
870        }
871        self.keep_edit();
872        current_file.set(Some(path.clone()));
873        #[cfg(target_arch = "wasm32")]
874        if let Some(text) = example_text(&path) {
875            let mut active_tab = self.active_tab;
876            let file_mode = StudioMode::for_file(&path, text).unwrap_or(*self.mode.peek());
877            spawn(async move {
878                active_tab.set(first_pane(file_mode));
879                self.open(path, text.to_string()).await;
880            });
881        }
882    }
883}
884
885/// The closest element matching `selector` to the one `evt` pressed: a drag works on the bars and panels
886/// of the workbench it began in, wherever else the page holds a copy of them (the prerendered copy,
887/// while the app takes over from it).
888#[cfg(target_arch = "wasm32")]
889fn pressed(evt: &PointerEvent, selector: &str) -> Option<web_sys::Element> {
890    use wasm_bindgen::JsCast;
891    let target = evt.data().downcast::<web_sys::PointerEvent>()?.target()?;
892    target.dyn_into::<web_sys::Element>().ok()?.closest(selector).ok().flatten()
893}
894
895/// The Studio's working area for `state`, drawn for `host`.
896#[component]
897pub fn Workbench(
898    state: WorkbenchState,
899    host: Host,
900    /// What the reader's store says beside the files. A frame has no store.
901    #[props(default)]
902    store: StoreNote,
903    /// New file, where the host keeps the reader's files. With none, the Files header offers no New
904    /// file.
905    #[props(default)]
906    on_new_file: Option<EventHandler<()>>,
907    /// A file the reader chose in the Files tree, after the workbench has opened it when the site
908    /// carries it. The Studio page keeps the address bar on it and reads a reader's own file.
909    #[props(default)]
910    on_choose: Option<EventHandler<String>>,
911) -> Element {
912    let WorkbenchState { mode, mut sidebar_open, drawer, mut drawer_open, panes_fixed, keyboard_inset, mut file_tree, current_file, mut input, mut result, mut format, mut proof_text, mut proof_tree, mut proof_status, mut proof_hint, mut current_proof_expr, mut knowledge_base, mut logic_output_mode, mut generated_logic_rust, dictionary_open, tree_open, dictionary, proof_height, mut proof_open, mut code_input, mut code_output_mode, mut interpreter_result, mut generated_rust, mut debugging, debug_output, drawer_height, program_store, mut math_input, mut math_context, mut math_output, mut math_output_mode, mut generated_math_rust, mut hw_input, mut hw_output_mode, mut hw_report, mut generated_hw_rust, mut hw_compiled, web_preview, fault, actions, editor_status, mut heal, mut heal_baseline, column_pref, viewport_width, mut active_tab, mut editor_expanded, mut output_expanded, mobile_split, .. } = state;
913
914    // The width of a frame's panel row, measured in the browser once the frame is on the page and
915    // again whenever the window changes size; `None` on the server and before the first measure.
916    let mut frame_row_width = use_signal(|| None::<f64>);
917    use_effect(move || {
918        let _ = viewport_width.read();
919        if host != Host::Frame {
920            return;
921        }
922        #[cfg(target_arch = "wasm32")]
923        {
924            let measured = web_sys::window()
925                .and_then(|w| w.document())
926                .and_then(|d| d.query_selector(".studio-frame .studio-content").ok().flatten())
927                .map(|row| row.client_width() as f64)
928                .filter(|w| *w > 0.0);
929            if measured.is_some() {
930                frame_row_width.set(measured);
931            }
932        }
933        #[cfg(not(target_arch = "wasm32"))]
934        {
935            let _ = &mut frame_row_width;
936        }
937    });
938    let show_theorem = move |theorem| state.show_theorem(theorem);
939    // Whether a tap on the fault note opened its whole story.
940    let mut fault_open = use_signal(|| false);
941
942
943    // Logic mode input handler - compiles for both UI and proof engine
944    // Logic mode keystroke handler: just store the text. The heavy compile/prove/grid runs
945    // on the Execute button (`handle_logic_execute`), mirroring Code mode's Run — so typing
946    // never triggers a per-keystroke solve.
947    let handle_logic_input = move |new_value: String| {
948        input.set(new_value);
949    };
950
951    // Code mode: Run button handler (interpret) with streaming output
952    let handle_code_run = move |_| {
953        let code = code_input.read().clone();
954        // Switch to Output tab (Panel2) on mobile and switch to Output mode
955        active_tab.set(MobileTab::Panel2);
956        code_output_mode.set(CodeOutputMode::Interpret);
957        // Clear previous output
958        interpreter_result.set(InterpreterResult {
959            lines: vec![],
960            error: None,
961        });
962        spawn(async move {
963            // Create streaming callback that updates the signal as output arrives
964            let callback = Rc::new(RefCell::new(move |line: String| {
965                // Update the signal with the new line
966                interpreter_result.write().lines.push(line);
967            }));
968
969            // Route the interpreter's file I/O to the store the host gave the workbench (the
970            // Studio page's OPFS/IndexedDB), so the standard-library I/O vocabulary works in the
971            // browser. A frame, and the native build, have none.
972            let vfs = program_store.read().clone();
973            let result = interpret_streaming_with_vfs(&code, callback, vfs).await;
974            // Set final result (includes any error)
975            interpreter_result.set(result);
976        });
977    };
978
979    // Code mode: Compile button handler (generate Rust)
980    // Uses generate_rust_code which works on WASM
981    let handle_code_compile = move |_| {
982        let code = code_input.read().clone();
983        // Switch to Output tab (Panel2) on mobile
984        active_tab.set(MobileTab::Panel2);
985        spawn(async move {
986            // Yield once so the switch to the output renders before the work.
987            #[cfg(target_arch = "wasm32")]
988            gloo_timers::future::TimeoutFuture::new(0).await;
989            match generate_rust_code(&code) {
990                Ok(rust_code) => {
991                    generated_rust.set(rust_code);
992                    code_output_mode.set(CodeOutputMode::Rust);
993                }
994                Err(e) => {
995                    interpreter_result.set(InterpreterResult {
996                        lines: vec![],
997                        error: Some(format!("Compile error: {:?}", e)),
998                    });
999                    code_output_mode.set(CodeOutputMode::Interpret);
1000                }
1001            }
1002        });
1003    };
1004
1005    // Math mode execute handler (vernacular REPL).
1006    // Re-runs from a fresh kernel each press so output is REPLACED, not appended
1007    // (mirrors Code mode's Run — no manual Clear needed). The editor holds the
1008    // full program, so a fresh run is idempotent — and the Context panel, which
1009    // lists the same environment in the same order, holds still.
1010    let handle_math_execute = move |_| {
1011        active_tab.set(MobileTab::Panel2);
1012        math_output_mode.set(CodeOutputMode::Interpret);
1013        let code = math_input.read().clone();
1014        spawn(async move {
1015            // Yield once so the switch to the output renders before the work.
1016            #[cfg(target_arch = "wasm32")]
1017            gloo_timers::future::TimeoutFuture::new(0).await;
1018            let Some(run) = attempt(fault, "Execute", || run_math(&code)) else {
1019                return;
1020            };
1021            if let Some(context) = run.context {
1022                if *math_context.peek() != context {
1023                    math_context.set(context);
1024                }
1025            }
1026            math_output.set(run.lines);
1027        });
1028    };
1029
1030    // Math mode compile handler: rebuild the kernel context from the editor, then
1031    // extract every user-defined Definition/Inductive into one Rust module.
1032    let handle_math_compile = move |_| {
1033        active_tab.set(MobileTab::Panel2);
1034        let code = math_input.read().clone();
1035        spawn(async move {
1036            // Yield once so the switch to the output renders before the work.
1037            #[cfg(target_arch = "wasm32")]
1038            gloo_timers::future::TimeoutFuture::new(0).await;
1039            let compiled = attempt(fault, "Compile", || {
1040                let mut repl = Repl::new();
1041                for stmt in parse_math_statements(&code) {
1042                    let _ = repl.execute(&stmt);
1043                }
1044                let rust = match extract_math_rust(repl.context()) {
1045                    Ok(rust) => rust,
1046                    Err(e) => format!("// extraction error: {e}"),
1047                };
1048                (MathContext::of(repl.context()), rust)
1049            });
1050            let Some((context, rust)) = compiled else {
1051                return;
1052            };
1053            if *math_context.peek() != context {
1054                math_context.set(context);
1055            }
1056            generated_math_rust.set(rust);
1057            math_output_mode.set(CodeOutputMode::Rust);
1058        });
1059    };
1060
1061    // Logic mode execute handler: compile the theorem (or sentences), prove it, and render
1062    // the result — the solved grid, the wh-question answer, or the certified derivation.
1063    // Like Code mode's Run, the heavy work happens on this button, not on every keystroke.
1064    let handle_logic_execute = move |_| {
1065        active_tab.set(MobileTab::Panel2);
1066        logic_output_mode.set(LogicView::Logic);
1067        let text = input.read().clone();
1068        match attempt(fault, "Execute", || run_logic(&text)) {
1069            Some(LogicRun::Theorem(theorem)) => show_theorem(theorem),
1070            Some(LogicRun::Sentences(compiled, proof)) => {
1071                result.set(compiled);
1072                knowledge_base.write().clear();
1073                if let Some(expr) = proof.proof_expr {
1074                    current_proof_expr.set(Some(expr));
1075                    proof_status.set(ProofStatus::Idle);
1076                    proof_hint.set(Some("Press Prove to check the first sentence, or give it Given lines in a ## Theorem: block.".to_string()));
1077                } else if let Some(err) = proof.error {
1078                    current_proof_expr.set(None);
1079                    proof_status.set(ProofStatus::Failed(err));
1080                }
1081            }
1082            Some(LogicRun::Empty) => {
1083                result.set(CompileResult {
1084                    logic: None,
1085                    simple_logic: None,
1086                    kripke_logic: None,
1087                    ast: None,
1088                    readings: Vec::new(),
1089                    simple_readings: Vec::new(),
1090                    kripke_readings: Vec::new(),
1091                    tokens: Vec::new(),
1092                    error: None,
1093                });
1094                current_proof_expr.set(None);
1095                knowledge_base.write().clear();
1096                proof_text.set(String::new());
1097                proof_tree.set(None);
1098                proof_status.set(ProofStatus::Idle);
1099            }
1100            None => {}
1101        }
1102    };
1103
1104    // Logic mode compile handler: extract Rust where there's constructive content
1105    // (a `## Theorem:` block / definitions); honest note otherwise.
1106    let handle_logic_compile = move |_| {
1107        active_tab.set(MobileTab::Panel2);
1108        let text = input.read().clone();
1109        spawn(async move {
1110            // Yield once so the switch to the output renders before the work.
1111            #[cfg(target_arch = "wasm32")]
1112            gloo_timers::future::TimeoutFuture::new(0).await;
1113            let extracted = attempt(fault, "Compile", || match extract_logic_rust(&text) {
1114                Ok(rust) => rust,
1115                Err(e) => format!("// extraction error: {e}"),
1116            });
1117            let Some(rust) = extracted else { return };
1118            generated_logic_rust.set(rust);
1119            logic_output_mode.set(LogicView::Rust);
1120        });
1121    };
1122
1123    // Logic mode: Prove asks the kernel for a derivation of the goal from the knowledge base
1124    // (`auto_view`), opens the proof drawer, and shows what came of it. The prover searches on its
1125    // own, so there is no tactic for a reader to choose.
1126    let handle_prove = use_callback(move |_: ()| {
1127        let maybe_goal = current_proof_expr.read().clone();
1128        let kb = knowledge_base.read().clone();
1129        // Prove checks the first sentence, so the goal it reports is that sentence's formula, not
1130        // the numbered list of every sentence's.
1131        let logic_str = result.read().simple_logic.as_deref().and_then(|all| all.lines().next()).map(|first| {
1132            first.trim_start_matches(|c: char| c.is_ascii_digit() || c == ')' || c == ' ').to_string()
1133        });
1134        proof_open.set(true);
1135        match maybe_goal {
1136            Some(goal) => {
1137                let before = proof_status.peek().clone();
1138                proof_status.set(ProofStatus::Proving);
1139                let shown = attempt(fault, "Prove", || auto_view(&kb, &goal, logic_str.as_deref().unwrap_or("(no expression)")));
1140                match shown {
1141                    Some(view) => {
1142                        // A sentence with no givens proves only if it is a logical truth: say what
1143                        // was missing, not just that the search failed.
1144                        let hint = if kb.is_empty() && matches!(view.status, ProofStatus::Failed(_)) {
1145                            "No givens to prove from. Add Given lines in a ## Theorem: block, as prover-demo.logic does.".to_string()
1146                        } else {
1147                            view.hint
1148                        };
1149                        proof_text.set(view.html);
1150                        proof_tree.set(view.tree);
1151                        proof_status.set(view.status);
1152                        proof_hint.set(Some(hint));
1153                    }
1154                    // Prove faulted: the proof drawer keeps what it showed before.
1155                    None => proof_status.set(before),
1156                }
1157            }
1158            None => {
1159                proof_status.set(ProofStatus::Failed("No logic expression to prove. Enter a sentence first.".to_string()));
1160                proof_text.set(String::new());
1161                proof_tree.set(None);
1162                proof_hint.set(Some("Enter an English sentence above to generate a logical formula.".to_string()));
1163            }
1164        }
1165    });
1166
1167    // Hardware mode handlers: Execute reads the design again; Compile makes its Rust monitor, or
1168    // says why the design has none.
1169    let handle_hardware_execute = move |_| {
1170        hw_report.set(analyze_hardware(&hw_input.read()));
1171        hw_compiled.set(None);
1172        hw_output_mode.set(CodeOutputMode::Interpret);
1173    };
1174
1175    let handle_hardware_compile = move |_| match compile_hardware(&hw_input.read()) {
1176        Compiled::Rust(rust) => {
1177            generated_hw_rust.set(rust);
1178            hw_compiled.set(None);
1179            hw_output_mode.set(CodeOutputMode::Rust);
1180        }
1181        said => {
1182            generated_hw_rust.set(String::new());
1183            hw_compiled.set(Some(said));
1184            hw_output_mode.set(CodeOutputMode::Interpret);
1185        }
1186    };
1187
1188    // Logic mode guide
1189    let current_result = result.read();
1190    let guide_mode = if *mode.read() == StudioMode::Logic {
1191        logic_guide(
1192            current_result.error.as_deref(),
1193            current_result.logic.as_deref(),
1194            current_result.readings.len(),
1195            &input.read(),
1196            &proof_status.read(),
1197        )
1198    } else {
1199        GuideMode::Idle
1200    };
1201
1202    // Determine if Panel 3 should be shown based on mode and content. Hardware mode's panels all
1203    // follow the one reading in `hw_report`.
1204    let hw_kind = hw_report.read().as_ref().map(|(kind, _)| *kind);
1205    let show_panel3 = match *mode.read() {
1206        StudioMode::Code => interpreter_result.read().error.is_some(),
1207        StudioMode::Math => !math_context.read().is_empty(),
1208        StudioMode::Hardware => match hw_report.read().as_ref() {
1209            Some((HardwareKind::Pigeonhole | HardwareKind::RegisterAlloc, report)) => {
1210                report.error.is_none()
1211            }
1212            Some((_, report)) => {
1213                !report.signals.is_empty()
1214                    || !report.trace.is_empty()
1215                    || !report.kg.nodes.is_empty()
1216            }
1217            None => false,
1218        },
1219        // Logic keeps its Syntax Tree in a block of the Logic Output column, and Web mode is two
1220        // panels — source + live preview — so neither has a third panel.
1221        StudioMode::Logic | StudioMode::Web => false,
1222    };
1223
1224    let shown = Shown {
1225        sidebar: *sidebar_open.read() && !*drawer.read(),
1226        right: show_panel3,
1227    };
1228    // A frame's row is as wide as the frame, not the window: measured once it is on the page and
1229    // again on every resize (`frame_row_width`); on the server, and until then, the window stands in.
1230    let viewport = match (host, *frame_row_width.read()) {
1231        (Host::Frame, Some(width)) => width,
1232        _ => *viewport_width.read(),
1233    };
1234    let columns = column_pref
1235        .read()
1236        .unwrap_or_else(|| Columns::initial(viewport, shown))
1237        .fit(viewport, shown);
1238    // The Studio page draws the columns the reader shaped, fitted to the window. A frame lays them out
1239    // in its own stylesheet until the reader drags a bar, so its first paint depends on nothing
1240    // measured; from that drag on it draws them as the page does, fitted to its own width.
1241    let columns_style = (host == Host::Page || column_pref.read().is_some()).then(|| {
1242        format!(
1243            "--col-sidebar: {}px; --col-left: {}px; --col-right: {}px;",
1244            columns.sidebar, columns.left, columns.right
1245        )
1246    });
1247
1248    // A column bar paints each frame of its drag straight onto the row's custom properties and
1249    // commits the layout once, on release (see ui::drag) — one render per drag, not one per
1250    // pointer event.
1251    let start_column_drag = move |evt: PointerEvent, resizer: Resizer| {
1252        #[cfg(target_arch = "wasm32")]
1253        {
1254            use dioxus::html::input_data::MouseButton;
1255            use wasm_bindgen::JsCast;
1256            let mut column_pref = column_pref;
1257            if evt
1258                .data()
1259                .trigger_button()
1260                .is_some_and(|b| b != MouseButton::Primary)
1261            {
1262                return;
1263            }
1264            evt.prevent_default();
1265            let Some(document) = web_sys::window().and_then(|w| w.document()) else {
1266                return;
1267            };
1268            let find = |selector: &str| document.query_selector(selector).ok().flatten();
1269            let Some(handle) = pressed(&evt, "[data-resizer]")
1270                .or_else(|| find(&format!("[data-resizer=\"{}\"]", resizer.attr())))
1271            else {
1272                return;
1273            };
1274            let Some(row) = handle
1275                .closest(".studio-content")
1276                .ok()
1277                .flatten()
1278                .and_then(|el| el.dyn_into::<web_sys::HtmlElement>().ok())
1279            else {
1280                return;
1281            };
1282            let point = evt.data().client_coordinates();
1283            let _ = handle.class_list().add_1("active");
1284            let released = handle.clone();
1285            crate::ui::drag::begin(
1286                handle,
1287                evt.data().pointer_id(),
1288                (point.x, point.y),
1289                crate::ui::drag::Axis::Columns,
1290                move |dx, _| {
1291                    let c = columns.dragged(resizer, dx, viewport, shown);
1292                    let style = row.style();
1293                    let _ = style.set_property("--col-sidebar", &format!("{}px", c.sidebar));
1294                    let _ = style.set_property("--col-left", &format!("{}px", c.left));
1295                    let _ = style.set_property("--col-right", &format!("{}px", c.right));
1296                },
1297                move |dx, _| {
1298                    let _ = released.class_list().remove_1("active");
1299                    column_pref.set(Some(columns.dragged(resizer, dx, viewport, shown)));
1300                },
1301            );
1302        }
1303        #[cfg(not(target_arch = "wasm32"))]
1304        let _ = (evt, resizer);
1305    };
1306
1307    // On a phone the panels stack, and the bar between them drags the same way, one axis over.
1308    let start_split_drag = move |evt: PointerEvent| {
1309        #[cfg(target_arch = "wasm32")]
1310        {
1311            use crate::ui::pages::studio_layout::split_percent;
1312            use wasm_bindgen::JsCast;
1313            let mut mobile_split = mobile_split;
1314            evt.prevent_default();
1315            let Some(document) = web_sys::window().and_then(|w| w.document()) else {
1316                return;
1317            };
1318            let find = |selector: &str| document.query_selector(selector).ok().flatten();
1319            let handle = pressed(&evt, ".mobile-panel-resizer").or_else(|| find(".mobile-panel-resizer"));
1320            let stack = handle.as_ref().and_then(|handle| handle.closest(".studio-main").ok().flatten());
1321            let panel = |selector: &str| {
1322                stack
1323                    .as_ref()
1324                    .and_then(|stack| stack.query_selector(selector).ok().flatten())
1325                    .and_then(|el| el.dyn_into::<web_sys::HtmlElement>().ok())
1326            };
1327            let (Some(handle), Some(upper), Some(lower)) =
1328                (handle, panel(":scope > .col-left"), panel(":scope > .col-center"))
1329            else {
1330                return;
1331            };
1332            let Some(stack) = stack else { return };
1333            let rect = stack.get_bounding_client_rect();
1334            let (top, height) = (rect.top(), rect.height());
1335            let point = evt.data().client_coordinates();
1336            let _ = handle.class_list().add_1("active");
1337            let released = handle.clone();
1338            crate::ui::drag::begin(
1339                handle,
1340                evt.data().pointer_id(),
1341                (point.x, point.y),
1342                crate::ui::drag::Axis::Rows,
1343                move |_, dy| {
1344                    let pct = split_percent(point.y + dy, top, height);
1345                    let _ = upper
1346                        .style()
1347                        .set_property("--panel-flex", &format!("{}", pct / 50.0));
1348                    let _ = lower
1349                        .style()
1350                        .set_property("--panel-flex", &format!("{}", (100.0 - pct) / 50.0));
1351                },
1352                move |_, dy| {
1353                    let _ = released.class_list().remove_1("active");
1354                    mobile_split.set(split_percent(point.y + dy, top, height));
1355                },
1356            );
1357        }
1358        #[cfg(not(target_arch = "wasm32"))]
1359        let _ = evt;
1360    };
1361
1362    let current_format = *format.read();
1363    let current_mode = *mode.read();
1364    // The pane a phone shows: the one its switch names, the output when it names a third panel the
1365    // file does not have.
1366    let current_tab = match *active_tab.read() {
1367        MobileTab::Panel3 if !show_panel3 => MobileTab::Panel2,
1368        tab => tab,
1369    };
1370    let on_phone = |tab: MobileTab| if current_tab == tab { " phone-shown" } else { "" };
1371    // Where the panes share the screen with the editor (a phone on its side, an upright tablet), the
1372    // other half shows the output or, when the switch names it, the third panel.
1373    let right_half = if current_tab == MobileTab::Panel3 { MobileTab::Panel3 } else { MobileTab::Panel2 };
1374    let on_right = |tab: MobileTab| if right_half == tab { " phone-right" } else { "" };
1375
1376    // Panel classes: mobile-expanded/collapsed for the stacked layout, phone-shown for the one pane a
1377    // phone shows; the desktop ignores them.
1378    let editor_exp = *editor_expanded.read();
1379    let output_exp = *output_expanded.read();
1380    let panel1_class = format!("studio-panel {}{}", if editor_exp { "mobile-expanded" } else { "mobile-collapsed" }, on_phone(MobileTab::Panel1));
1381    let output_fold = if output_exp { "mobile-expanded" } else { "mobile-collapsed" };
1382    let panel2_class = format!("studio-panel {output_fold}{}{}", on_phone(MobileTab::Panel2), on_right(MobileTab::Panel2));
1383    let panel3_class = format!("studio-panel {output_fold}{}{}", on_phone(MobileTab::Panel3), on_right(MobileTab::Panel3));
1384
1385    // Mobile vertical split: compute panel flex proportions
1386    let mobile_pct = *mobile_split.read();
1387    let panel1_flex = mobile_pct / 50.0;
1388    let panel2_flex = (100.0 - mobile_pct) / 50.0;
1389    let both_expanded = editor_exp && output_exp;
1390
1391    let panel1_style = if both_expanded {
1392        format!("--panel-flex: {panel1_flex};")
1393    } else {
1394        String::new()
1395    };
1396    let panel2_style = if both_expanded {
1397        format!("--panel-flex: {panel2_flex};")
1398    } else {
1399        String::new()
1400    };
1401
1402    // Read output view modes for rendering
1403    let current_code_output_mode = *code_output_mode.read();
1404    let current_math_output_mode = *math_output_mode.read();
1405    let current_logic_output_mode = *logic_output_mode.read();
1406    let current_hw_output_mode = *hw_output_mode.read();
1407
1408    // Mobile tab labels based on mode
1409    let (tab1_icon, tab1_label, tab2_icon, tab2_label, tab3_icon, tab3_label) = match current_mode {
1410        StudioMode::Logic => ("\u{270F}", "Input", "\u{2200}", "Logic", "", ""),
1411        StudioMode::Code => (
1412            "\u{03BB}",
1413            "Editor",
1414            "\u{276F}",
1415            "Output",
1416            "\u{1F4CB}",
1417            "Console",
1418        ),
1419        StudioMode::Math => (
1420            "\u{2200}",
1421            "Editor",
1422            "\u{276F}",
1423            "Output",
1424            "\u{1F4CB}",
1425            "Context",
1426        ),
1427        StudioMode::Hardware => ("\u{270F}", "Spec", "\u{22A8}", "SVA", "\u{25A6}", "Graph"),
1428        StudioMode::Web => ("\u{03BB}", "Source", "\u{25C9}", "Preview", "", ""),
1429    };
1430
1431    // The status bar names the editor's language and says how it indents; Hardware by what its
1432    // text is (Verilog or a spec).
1433    let (status_language, status_source) = match current_mode {
1434        StudioMode::Logic => (Language::Logic, String::new()),
1435        StudioMode::Code | StudioMode::Web => (Language::Logos, String::new()),
1436        StudioMode::Math => (Language::Vernacular, String::new()),
1437        StudioMode::Hardware => (Language::Hardware, hw_input.read().clone()),
1438    };
1439
1440    // Where a first visit stands, for the proof drawer's walkthrough and the dock bar's line.
1441    let walkthrough = Walkthrough { wrote: !input.read().trim().is_empty(), executed: result.read().logic.is_some() };
1442    let dock_text = match current_mode {
1443        StudioMode::Logic => dock_prompt(walkthrough, proof_hint.read().as_deref()),
1444        StudioMode::Code if debugging() => "Stepping through the program, one instruction at a time.".to_string(),
1445        StudioMode::Code => "Debug runs the program one instruction at a time, every variable in view.".to_string(),
1446        _ => String::new(),
1447    };
1448    // LLM Heal: the fairy mends the editor's English (work/LLM_HEAL.md). Code and Logic only; the
1449    // worker answers, the card shows both texts, and nothing lands in the editor until Keep.
1450    let heal_source: Signal<String> = if current_mode == StudioMode::Logic { input } else { code_input };
1451    let heal_mode = if current_mode == StudioMode::Logic { heal::Mode::Logic } else { heal::Mode::Code };
1452    let handle_heal = use_callback(move |_: ()| {
1453        let text = heal_source.read().clone();
1454        let begun = heal::Flight::begin(&text);
1455        let flying = begun.busy();
1456        heal.set(begun);
1457        if !flying {
1458            return;
1459        }
1460        // Only what the reader wrote since the file opened, or since the last Keep.
1461        let baseline = current_file.peek().as_deref().and_then(|path| heal_baseline.peek().get(path).cloned()).unwrap_or_default();
1462        let regions = heal::edited_regions(&baseline, &text);
1463        if regions.is_empty() {
1464            heal.set(heal::Flight::Failed(heal::NOTHING_NEW.to_string()));
1465            return;
1466        }
1467        heal.set(heal::Flight::Flying { to: regions[0].start });
1468        spawn(async move {
1469            let mut mended = Vec::with_capacity(regions.len());
1470            for region in &regions {
1471                let fragment = heal::region_text(&text, region);
1472                match heal::send(heal_mode, &fragment, true).await {
1473                    Ok(lines) => mended.push(heal::reindent(&fragment, &lines)),
1474                    Err(reason) => {
1475                        heal.set(heal::Flight::Failed(reason));
1476                        return;
1477                    }
1478                }
1479            }
1480            let after = heal::splice(&text, &regions, &mended);
1481            heal.set(heal::Flight::answered(text, Ok(after)));
1482        });
1483    });
1484    let heal_button = move || {
1485        let busy = heal.read().busy();
1486        rsx! {
1487            button {
1488                class: "execute-btn heal-btn",
1489                disabled: busy,
1490                title: "Mend the English in the editor, then keep or undo the mend",
1491                onclick: move |_| handle_heal.call(()),
1492                img { class: "heal-btn-icon", src: heal::FAIRY, alt: "" }
1493                span { class: "desktop-label", "Heal" }
1494                span { class: "mobile-label", "Heal" }
1495            }
1496        }
1497    };
1498
1499    let dock_text = heal::narration(&heal.read()).unwrap_or(dock_text);
1500
1501    let panels = rsx! {
1502        // Panel 1
1503        section {
1504            class: "{panel1_class} col-left",
1505            style: "{panel1_style}",
1506
1507            // LLM Heal: the fairy and her card, over the editor, while a heal is under way.
1508            if matches!(current_mode, StudioMode::Code | StudioMode::Logic | StudioMode::Web) {
1509                HealOverlay { flight: heal, editor: heal_source, file: current_file, baseline: heal_baseline }
1510            }
1511
1512            match current_mode {
1513                StudioMode::Logic => rsx! {
1514                    div {
1515                        class: "panel-header",
1516                        onclick: move |_| {
1517                            if !*panes_fixed.peek() {
1518                                let v = *editor_expanded.read();
1519                                editor_expanded.set(!v);
1520                            }
1521                        },
1522                        span { "English Input" }
1523                    }
1524                    div { class: "panel-content",
1525                        CodeEditor {
1526                            value: input.read().clone(),
1527                            on_change: handle_logic_input,
1528                            language: Language::Logic,
1529                            language_server: true,
1530                            status: editor_status,
1531                            placeholder: "Type an English sentence...".to_string(),
1532                        }
1533                    }
1534                },
1535                // Web mode edits LOGOS source in the same editor as Code mode.
1536                StudioMode::Code | StudioMode::Web => rsx! {
1537                    div {
1538                        class: "panel-header",
1539                        onclick: move |_| {
1540                            if !*panes_fixed.peek() {
1541                                let v = *editor_expanded.read();
1542                                editor_expanded.set(!v);
1543                            }
1544                        },
1545                        span { "Code Editor" }
1546                    }
1547                    div { class: "panel-content",
1548                        CodeEditor {
1549                            value: code_input.read().clone(),
1550                            on_change: move |v| code_input.set(v),
1551                            language: Language::Logos,
1552                            language_server: true,
1553                            status: editor_status,
1554                            placeholder: "-- Imperative LOGOS code\n\n## Main\n\nLet x be 1.\nLet y be 2.\nShow x + y.".to_string(),
1555                        }
1556                    }
1557                },
1558                StudioMode::Math => rsx! {
1559                    div {
1560                        class: "panel-header",
1561                        onclick: move |_| {
1562                            if !*panes_fixed.peek() {
1563                                let v = *editor_expanded.read();
1564                                editor_expanded.set(!v);
1565                            }
1566                        },
1567                        span { "Theorem Editor" }
1568                    }
1569                    div { class: "panel-content",
1570                        CodeEditor {
1571                            value: math_input.read().clone(),
1572                            on_change: move |v| math_input.set(v),
1573                            language: Language::Vernacular,
1574                            status: editor_status,
1575                            placeholder: "-- Define natural numbers\nInductive Nat := Zero : Nat | Succ : Nat -> Nat.\n\nDefinition one : Nat := Succ Zero.\n\nCheck one.".to_string(),
1576                        }
1577                    }
1578                },
1579                StudioMode::Hardware => rsx! {
1580                    div {
1581                        class: "panel-header",
1582                        onclick: move |_| {
1583                            if !*panes_fixed.peek() {
1584                                let v = *editor_expanded.read();
1585                                editor_expanded.set(!v);
1586                            }
1587                        },
1588                        span { "Hardware Spec" }
1589                    }
1590                    div { class: "panel-content",
1591                        CodeEditor {
1592                            value: hw_input.read().clone(),
1593                            on_change: move |v| hw_input.set(v),
1594                            language: Language::Hardware,
1595                            language_server: true,
1596                            status: editor_status,
1597                            placeholder: "Describe hardware in English (\"Always, if request is high, then acknowledge is high.\"), design a signal plan (\"NS-left conflicts with the EW crossing.\"), or paste Verilog (module \u{2026} endmodule) to model-check.".to_string(),
1598                        }
1599                    }
1600                },
1601            }
1602        }
1603
1604        // Mobile vertical resizer between Panel 1 and Panel 2
1605        if both_expanded {
1606            div {
1607                class: "mobile-panel-resizer",
1608                onpointerdown: move |evt| start_split_drag(evt),
1609            }
1610        }
1611
1612        div {
1613            class: "panel-resizer",
1614            "data-resizer": Resizer::LeftCenter.attr(),
1615            onpointerdown: move |evt| start_column_drag(evt, Resizer::LeftCenter),
1616        }
1617
1618        // Panel 2
1619        section {
1620            class: "{panel2_class} col-center",
1621            style: "{panel2_style}",
1622
1623            match current_mode {
1624                StudioMode::Logic => rsx! {
1625                    div {
1626                        class: "panel-header",
1627                        onclick: move |_| {
1628                            if !*panes_fixed.peek() {
1629                                let v = *output_expanded.read();
1630                                output_expanded.set(!v);
1631                            }
1632                        },
1633                        span { "Logic Output" }
1634                        div {
1635                            class: "output-mode-toggle",
1636                            onclick: move |evt| evt.stop_propagation(),
1637                            button {
1638                                class: if current_logic_output_mode == LogicView::Logic { "output-mode-btn active" } else { "output-mode-btn" },
1639                                onclick: move |_| logic_output_mode.set(LogicView::Logic),
1640                                "Logic"
1641                            }
1642                            button {
1643                                class: if current_logic_output_mode == LogicView::Rust { "output-mode-btn active" } else { "output-mode-btn" },
1644                                onclick: move |_| logic_output_mode.set(LogicView::Rust),
1645                                "Rust"
1646                            }
1647                        }
1648                        if current_logic_output_mode == LogicView::Logic {
1649                            div {
1650                                class: "format-toggle",
1651                                onclick: move |evt| evt.stop_propagation(),
1652                                button {
1653                                    class: if current_format == OutputFormat::SimpleFOL { "format-btn active" } else { "format-btn" },
1654                                    onclick: move |_| format.set(OutputFormat::SimpleFOL),
1655                                    "Simple"
1656                                }
1657                                button {
1658                                    class: if current_format == OutputFormat::Unicode { "format-btn active" } else { "format-btn" },
1659                                    onclick: move |_| format.set(OutputFormat::Unicode),
1660                                    "Full"
1661                                }
1662                                button {
1663                                    class: if current_format == OutputFormat::LaTeX { "format-btn active" } else { "format-btn" },
1664                                    onclick: move |_| format.set(OutputFormat::LaTeX),
1665                                    "LaTeX"
1666                                }
1667                                button {
1668                                    class: if current_format == OutputFormat::Kripke { "format-btn active" } else { "format-btn" },
1669                                    onclick: move |_| format.set(OutputFormat::Kripke),
1670                                    "Deep"
1671                                }
1672                            }
1673                        }
1674                    }
1675                    div { class: "panel-content",
1676                        if current_logic_output_mode == LogicView::Logic {
1677                            {
1678                                rsx! {
1679                                    LogicOutput {
1680                                        logic: current_result.logic.clone(),
1681                                        simple_logic: current_result.simple_logic.clone(),
1682                                        kripke_logic: current_result.kripke_logic.clone(),
1683                                        readings: current_result.readings.clone(),
1684                                        simple_readings: current_result.simple_readings.clone(),
1685                                        kripke_readings: current_result.kripke_readings.clone(),
1686                                        error: current_result.error.clone(),
1687                                        format: current_format,
1688                                    }
1689                                    // Under the formula, what it is made of: its symbols, then its tree.
1690                                    if let Some(symbols) = dictionary() {
1691                                        Collapsible {
1692                                            title: "Symbol Dictionary",
1693                                            open: dictionary_open,
1694                                            SymbolGroups { symbols }
1695                                        }
1696                                    }
1697                                    if let Some(ast) = current_result.ast.clone() {
1698                                        Collapsible {
1699                                            title: "Syntax Tree",
1700                                            open: tree_open,
1701                                            AstTree { ast }
1702                                        }
1703                                    }
1704                                }
1705                            }
1706                        } else {
1707                            {
1708                                let rust_code = generated_logic_rust.read().clone();
1709                                rsx! {
1710                                    if rust_code.is_empty() {
1711                                        div { class: "interpreter-empty",
1712                                            "Compile to see generated Rust"
1713                                        }
1714                                    } else {
1715                                        CodeView {
1716                                            code: rust_code,
1717                                            language: Language::Rust,
1718                                        }
1719                                    }
1720                                }
1721                            }
1722                        }
1723                    }
1724                },
1725                StudioMode::Code => rsx! {
1726                    div {
1727                        class: "panel-header",
1728                        onclick: move |_| {
1729                            if !*panes_fixed.peek() {
1730                                let v = *output_expanded.read();
1731                                output_expanded.set(!v);
1732                            }
1733                        },
1734                        span { "Output" }
1735                        div {
1736                            class: "output-mode-toggle",
1737                            onclick: move |evt| evt.stop_propagation(),
1738                            button {
1739                                class: if current_code_output_mode == CodeOutputMode::Interpret { "output-mode-btn active" } else { "output-mode-btn" },
1740                                onclick: move |_| code_output_mode.set(CodeOutputMode::Interpret),
1741                                "Output"
1742                            }
1743                            button {
1744                                class: if current_code_output_mode == CodeOutputMode::Rust { "output-mode-btn active" } else { "output-mode-btn" },
1745                                onclick: move |_| code_output_mode.set(CodeOutputMode::Rust),
1746                                "Rust"
1747                            }
1748                        }
1749                    }
1750                    div { class: "panel-content",
1751                        if current_code_output_mode == CodeOutputMode::Interpret {
1752                            {
1753                                // While the debugger is open the column follows it: what the program
1754                                // has printed at the cursor, not the last Run.
1755                                let stepping = debugging();
1756                                let result = if stepping { debug_output.read() } else { interpreter_result.read() };
1757                                let runtime_notice = current_file.read().as_deref().and_then(compiled_runtime_notice);
1758                                rsx! {
1759                                    div { class: "interpreter-output",
1760                                        if let Some(notice) = &runtime_notice {
1761                                            div { class: "panel-verdict", "{notice}" }
1762                                        }
1763                                        if result.lines.is_empty() && result.error.is_none() {
1764                                            if stepping {
1765                                                div { class: "interpreter-empty",
1766                                                    "Nothing printed yet. What the program shows appears here."
1767                                                }
1768                                            } else if runtime_notice.is_none() {
1769                                                div { class: "interpreter-empty",
1770                                                    "Run your code to see output"
1771                                                }
1772                                            }
1773                                        } else {
1774                                            for line in result.lines.iter() {
1775                                                div { class: "interpreter-line", "{line}" }
1776                                            }
1777                                            if let Some(ref err) = result.error {
1778                                                div { class: "interpreter-error", "{err}" }
1779                                            }
1780                                        }
1781                                    }
1782                                }
1783                            }
1784                        } else {
1785                            {
1786                                let rust_code = generated_rust.read().clone();
1787                                rsx! {
1788                                    if rust_code.is_empty() {
1789                                        div { class: "interpreter-empty",
1790                                            "Compile your code to see generated Rust"
1791                                        }
1792                                    } else {
1793                                        CodeView {
1794                                            code: rust_code,
1795                                            language: Language::Rust,
1796                                        }
1797                                    }
1798                                }
1799                            }
1800                        }
1801                    }
1802                },
1803                StudioMode::Math => rsx! {
1804                    div {
1805                        class: "panel-header",
1806                        onclick: move |_| {
1807                            if !*panes_fixed.peek() {
1808                                let v = *output_expanded.read();
1809                                output_expanded.set(!v);
1810                            }
1811                        },
1812                        span { "Output" }
1813                        div {
1814                            class: "panel-header-actions",
1815                            onclick: move |evt| evt.stop_propagation(),
1816                            if current_math_output_mode == CodeOutputMode::Interpret && !math_output.read().is_empty() {
1817                                div { class: "output-mode-toggle",
1818                                    button {
1819                                        class: "output-mode-btn",
1820                                        onclick: move |_| {
1821                                            math_output.write().clear();
1822                                            // The Context panel goes back to the bare standard library too.
1823                                            math_context.set(MathContext::prelude());
1824                                        },
1825                                        "Clear"
1826                                    }
1827                                }
1828                            }
1829                            div { class: "output-mode-toggle",
1830                                button {
1831                                    class: if current_math_output_mode == CodeOutputMode::Interpret { "output-mode-btn active" } else { "output-mode-btn" },
1832                                    onclick: move |_| math_output_mode.set(CodeOutputMode::Interpret),
1833                                    "Output"
1834                                }
1835                                button {
1836                                    class: if current_math_output_mode == CodeOutputMode::Rust { "output-mode-btn active" } else { "output-mode-btn" },
1837                                    onclick: move |_| math_output_mode.set(CodeOutputMode::Rust),
1838                                    "Rust"
1839                                }
1840                            }
1841                        }
1842                    }
1843                    div { class: "panel-content",
1844                        if current_math_output_mode == CodeOutputMode::Interpret {
1845                            ReplOutput {
1846                                lines: math_output.read().clone(),
1847                            }
1848                        } else {
1849                            {
1850                                let rust_code = generated_math_rust.read().clone();
1851                                rsx! {
1852                                    if rust_code.is_empty() {
1853                                        div { class: "interpreter-empty",
1854                                            "Compile to see generated Rust"
1855                                        }
1856                                    } else {
1857                                        CodeView {
1858                                            code: rust_code,
1859                                            language: Language::Rust,
1860                                        }
1861                                    }
1862                                }
1863                            }
1864                        }
1865                    }
1866                },
1867                StudioMode::Hardware => {
1868                    let report = hw_report.read().clone();
1869                    let title = hw_kind.map(HardwareKind::result_title).unwrap_or("SVA");
1870                    let rust_view = hw_kind.map_or(true, HardwareKind::compiles);
1871                    let planted = current_file.read().as_deref().and_then(planted_bug_notice);
1872                    let compiled = hw_compiled.read().clone();
1873                    rsx! {
1874                        div {
1875                            class: "panel-header",
1876                            onclick: move |_| {
1877                                if !*panes_fixed.peek() {
1878                                    let v = *output_expanded.read();
1879                                    output_expanded.set(!v);
1880                                }
1881                            },
1882                            span { "{title}" }
1883                            if rust_view {
1884                                div {
1885                                    class: "output-mode-toggle",
1886                                    onclick: move |evt| evt.stop_propagation(),
1887                                    button {
1888                                        class: if current_hw_output_mode == CodeOutputMode::Interpret { "output-mode-btn active" } else { "output-mode-btn" },
1889                                        onclick: move |_| hw_output_mode.set(CodeOutputMode::Interpret),
1890                                        "SVA"
1891                                    }
1892                                    button {
1893                                        class: if current_hw_output_mode == CodeOutputMode::Rust { "output-mode-btn active" } else { "output-mode-btn" },
1894                                        onclick: move |_| hw_output_mode.set(CodeOutputMode::Rust),
1895                                        "Rust"
1896                                    }
1897                                }
1898                            }
1899                        }
1900                        div { class: "panel-content",
1901                            if current_hw_output_mode == CodeOutputMode::Rust && rust_view {
1902                                {
1903                                    let rust_code = generated_hw_rust.read().clone();
1904                                    rsx! {
1905                                        if rust_code.is_empty() {
1906                                            div { class: "interpreter-empty",
1907                                                "Compile to see the Rust runtime monitor"
1908                                            }
1909                                        } else {
1910                                            CodeView {
1911                                                code: rust_code,
1912                                                language: Language::Rust,
1913                                            }
1914                                        }
1915                                    }
1916                                }
1917                            } else {
1918                                div { class: "interpreter-output",
1919                                    match compiled {
1920                                        Some(Compiled::Nothing(note)) => rsx! { div { class: "panel-verdict", "{note}" } },
1921                                        Some(Compiled::Failed(e)) => rsx! { div { class: "interpreter-error", "{e}" } },
1922                                        Some(Compiled::Rust(_)) | None => rsx! {},
1923                                    }
1924                                    if let Some(notice) = &planted {
1925                                        div { class: "panel-verdict", "{notice}" }
1926                                    }
1927                                    match report {
1928                                        None => rsx! {
1929                                            div { class: "interpreter-empty",
1930                                                "Execute to analyze: synthesize an assertion, design a signal plan, or model-check Verilog"
1931                                            }
1932                                        },
1933                                        Some((_, report)) => rsx! {
1934                                            if let Some(e) = &report.error {
1935                                                div { class: "interpreter-error", "{e}" }
1936                                            }
1937                                            for section in report.sections.iter() {
1938                                                div { class: "panel-section-label", "{section.label}" }
1939                                                pre { class: "panel-code", "{section.body}" }
1940                                            }
1941                                            if let Some((text, ok)) = &report.verdict {
1942                                                div {
1943                                                    class: match ok {
1944                                                        Some(true) => "panel-verdict proved",
1945                                                        Some(false) => "panel-verdict refuted",
1946                                                        None => "panel-verdict",
1947                                                    },
1948                                                    "{text}"
1949                                                }
1950                                            }
1951                                        },
1952                                    }
1953                                }
1954                            }
1955                        }
1956                    }
1957                },
1958                // Web mode: the LIVE PREVIEW — a sandboxed iframe rendering the
1959                // in-browser-compiled reactive app (or the themed build overlay).
1960                StudioMode::Web => {
1961                    let doc = web_preview.read().clone();
1962                    rsx! {
1963                        div { class: "panel-header", span { "Preview" } }
1964                        div { class: "panel-content", style: "padding: 0; overflow: hidden;",
1965                            iframe {
1966                                title: PREVIEW_TITLE,
1967                                srcdoc: "{doc}",
1968                                "sandbox": "allow-scripts",
1969                                style: "width: 100%; height: 100%; border: 0; background: #faf9f5;",
1970                            }
1971                        }
1972                    }
1973                },
1974            }
1975        }
1976
1977        // Right resizer and Panel 3 (only shown when there's content)
1978        if show_panel3 {
1979            div {
1980                class: "panel-resizer",
1981                "data-resizer": Resizer::CenterRight.attr(),
1982                onpointerdown: move |evt| start_column_drag(evt, Resizer::CenterRight),
1983            }
1984
1985            aside {
1986                class: "{panel3_class} col-right",
1987                style: "{panel2_style}",
1988
1989                match current_mode {
1990                    StudioMode::Code => rsx! {
1991                        div { class: "panel-header",
1992                            span { "Console" }
1993                        }
1994                        div { class: "panel-content",
1995                            div { class: "interpreter-output",
1996                                {
1997                                    let result = interpreter_result.read();
1998                                    rsx! {
1999                                        if let Some(ref err) = result.error {
2000                                            div { class: "interpreter-error", "{err}" }
2001                                        }
2002                                    }
2003                                }
2004                            }
2005                        }
2006                    },
2007                    StudioMode::Math => rsx! {
2008                        div { class: "panel-header",
2009                            span { "Context" }
2010                        }
2011                        div { class: "panel-content",
2012                            ContextView {
2013                                definitions: math_context.read().definitions.clone(),
2014                                inductives: math_context.read().inductives.clone(),
2015                            }
2016                        }
2017                    },
2018                    // Logic and Web have no third panel (`show_panel3` is false).
2019                    StudioMode::Logic | StudioMode::Web => rsx! {},
2020                    StudioMode::Hardware => {
2021                        let report = hw_report.read().clone().map(|(_, report)| report).unwrap_or_default();
2022                        let violated = report.verdict.as_ref().is_some_and(|(_, ok)| *ok == Some(false));
2023                        if let (Some(HardwareKind::Pigeonhole), Some(pspec)) = (hw_kind, crate::ui::pages::pigeonhole_viz::parse_pigeonhole_spec(&hw_input.read())) {
2024                            // Pigeonhole easter egg: n pigeons fly into n-1 holes, one left
2025                            // out — solved live (matching Hall witness + certified symmetry
2026                            // breaking) by our prover in the browser, no Z3.
2027                            let (svg, caption) = crate::ui::pages::pigeonhole_viz::render(&pspec);
2028                            rsx! {
2029                                div { class: "panel-header", span { "Pigeonhole" } }
2030                                div { class: "panel-content",
2031                                    div { class: "panel-body wide",
2032                                        div { dangerous_inner_html: "{svg}" }
2033                                        div { class: "panel-caption", "{caption}" }
2034                                    }
2035                                }
2036                            }
2037                        } else if let (Some(HardwareKind::RegisterAlloc), Some(spec)) = (hw_kind, crate::ui::pages::register_alloc_viz::parse_register_spec(&hw_input.read())) {
2038                            // Register-allocation easter egg: live-range timeline coloured
2039                            // by the certified linear-scan allocation (spill clique in red).
2040                            let (svg, _) = crate::ui::pages::register_alloc_viz::render(&spec);
2041                            rsx! {
2042                                div { class: "panel-header", span { "Register Allocation" } }
2043                                div { class: "panel-content",
2044                                    div { class: "panel-body wide",
2045                                        div { dangerous_inner_html: "{svg}" }
2046                                        div { class: "panel-caption",
2047                                            "The sweep line is the program counter. Each register lane lights up while it holds a value and goes dark when it's freed. The pressure histogram counts how many values are live at each instruction, and the bars above the dashed budget line turn red: the exact points where spilling is forced and a value drops to the MEM lane. The interference graph is what allocation really colours: an edge joins two values whose lifetimes overlap, nodes are coloured by their register, and a red clique is a set that mutually conflicts and provably cannot share."
2048                                        }
2049                                    }
2050                                }
2051                            }
2052                        } else if !report.trace.is_empty() {
2053                            // A counterexample (or a proven design's witness run), drawn as a waveform.
2054                            let wf = logicaffeine_compile::codegen_sva::hw_pipeline::counterexample_waveform(&report.trace);
2055                            let traffic = traffic_svg(&wf);
2056                            let osc = waveform_svg(&wf);
2057                            let header = match (traffic.is_some(), violated) {
2058                                (true, true) => "Intersection: Conflict Found",
2059                                (true, false) => "Intersection: Proven Safe (witness run)",
2060                                (false, true) => "Counterexample Waveform",
2061                                (false, false) => "Witness Waveform",
2062                            };
2063                            rsx! {
2064                                div { class: "panel-header", span { "{header}" } }
2065                                div { class: "panel-content",
2066                                    div { class: "panel-body wide",
2067                                        if let Some(t) = traffic.as_ref() {
2068                                            div { dangerous_inner_html: "{t}" }
2069                                        }
2070                                        div { dangerous_inner_html: "{osc}" }
2071                                    }
2072                                }
2073                            }
2074                        } else {
2075                            let svg = kg_svg(&report.kg);
2076                            rsx! {
2077                                div { class: "panel-header", span { if svg.is_empty() { "Signals" } else { "Knowledge Graph" } } }
2078                                div { class: "panel-content",
2079                                    if !svg.is_empty() {
2080                                        div { class: "panel-body",
2081                                            div { dangerous_inner_html: "{svg}" }
2082                                            div { style: "display:flex; gap:12px; flex-wrap:wrap; justify-content:center; margin-top:4px; font-size:10px;",
2083                                                span { style: "color:#60a5fa;", "● input" }
2084                                                span { style: "color:#4ade80;", "● output" }
2085                                                span { style: "color:#a78bfa;", "● internal" }
2086                                                span { style: "color:#fbbf24;", "● clock" }
2087                                            }
2088                                        }
2089                                    } else if !report.signals.is_empty() {
2090                                        div { class: "panel-body",
2091                                            for s in report.signals.iter() {
2092                                                div { style: "font-family: ui-monospace, monospace; font-size: 13px; color: #e5e7eb; padding: 2px 0;", "{s}" }
2093                                            }
2094                                        }
2095                                    } else {
2096                                        div { class: "interpreter-empty",
2097                                            "Execute to extract the hardware knowledge graph"
2098                                        }
2099                                    }
2100                                }
2101                            }
2102                        }
2103                    },
2104                }
2105            }
2106        }
2107    };
2108
2109    // What a touch screen cannot hover to read: each action the toolbar shows disabled, with why.
2110    let withheld: Vec<(&'static str, String)> = {
2111        let actions = actions();
2112        let run = if current_mode == StudioMode::Code { "Run" } else { "Execute" };
2113        let mut shown = vec![(run, &actions.run), ("Compile", &actions.compile)];
2114        if current_mode == StudioMode::Code && host == Host::Page {
2115            shown.push(("Debug", &actions.debug));
2116        }
2117        if current_mode == StudioMode::Web {
2118            shown.clear();
2119        }
2120        shown.into_iter().filter_map(|(name, offer)| offer.reason().map(|why| (name, why.to_string()))).collect()
2121    };
2122    let inset = *keyboard_inset.read();
2123
2124    rsx! {
2125        div { class: "studio-workbench", style: "--keyboard-inset: {inset}px;",
2126        div { class: "studio-toolbar",
2127            div { class: "studio-toolbar-left",
2128                {
2129                    let open = if *drawer.read() { *drawer_open.read() } else { *sidebar_open.read() };
2130                    let label = if open { "Hide files" } else { "Show files" };
2131                    rsx! {
2132                        button {
2133                            class: "sidebar-toggle-btn",
2134                            onclick: move |_| if *drawer.peek() { drawer_open.set(!open) } else { sidebar_open.set(!open) },
2135                            title: "{label}",
2136                            aria_label: "{label}",
2137                            aria_expanded: "{open}",
2138                            span {
2139                                class: "toggle-icon",
2140                                dangerous_inner_html: if open { COLLAPSE_FILES_SVG } else { FILES_SVG },
2141                            }
2142                        }
2143                        // Folded, the square button says what it opens.
2144                        if !open {
2145                            span { class: "files-label", "Files" }
2146                        }
2147                    }
2148                }
2149            }
2150            div { class: "studio-toolbar-right",
2151                if let Some(stopped) = fault() {
2152                    span {
2153                        class: "studio-fault",
2154                        role: "alert",
2155                        title: "{stopped.detail()}",
2156                        onclick: move |_| fault_open.set(!fault_open()),
2157                        "\u{26A0} {stopped.headline()}"
2158                    }
2159                }
2160                if current_mode == StudioMode::Code {
2161                    button {
2162                        class: "execute-btn",
2163                        disabled: !actions().run.is_ready(),
2164                        title: actions().run.reason().map(str::to_string),
2165                        onclick: handle_code_run,
2166                        span { class: "desktop-label", "\u{25B6} Run" }
2167                        span { class: "mobile-label", "\u{25B6} Run" }
2168                    }
2169                    {heal_button()}
2170                    button {
2171                        class: "execute-btn compile-btn",
2172                        style: "background: linear-gradient(135deg, #56b6c2 0%, #61afef 100%);",
2173                        disabled: !actions().compile.is_ready(),
2174                        title: actions().compile.reason().map(str::to_string),
2175                        onclick: handle_code_compile,
2176                        span { class: "desktop-label", "\u{1F980} Compile" }
2177                        span { class: "mobile-label", "\u{1F980} Compile to Rust" }
2178                    }
2179                    if host == Host::Page {
2180                        button {
2181                            class: "execute-btn",
2182                            style: "background: linear-gradient(135deg, #f59e0b 0%, #ef4444 100%); display:inline-flex; align-items:center; gap:6px;",
2183                            disabled: !actions().debug.is_ready(),
2184                            title: actions().debug.reason().map(str::to_string),
2185                            onclick: move |_| {
2186                                code_output_mode.set(CodeOutputMode::Interpret);
2187                                debugging.set(true);
2188                            },
2189                            span { style: "display:inline-flex;", dangerous_inner_html: IC_BUG }
2190                            span { class: "desktop-label", "Debug" }
2191                            span { class: "mobile-label", "Debug" }
2192                        }
2193                    }
2194                }
2195                if current_mode == StudioMode::Math {
2196                    button {
2197                        class: "execute-btn",
2198                        disabled: !actions().run.is_ready(),
2199                        title: actions().run.reason().map(str::to_string),
2200                        onclick: handle_math_execute,
2201                        span { class: "desktop-label", "\u{25B6} Execute" }
2202                        span { class: "mobile-label", "\u{25B6} Execute" }
2203                    }
2204                    button {
2205                        class: "execute-btn compile-btn",
2206                        style: "background: linear-gradient(135deg, #56b6c2 0%, #61afef 100%);",
2207                        disabled: !actions().compile.is_ready(),
2208                        title: actions().compile.reason().map(str::to_string),
2209                        onclick: handle_math_compile,
2210                        span { class: "desktop-label", "\u{1F980} Compile" }
2211                        span { class: "mobile-label", "\u{1F980} Compile to Rust" }
2212                    }
2213                }
2214                if current_mode == StudioMode::Logic {
2215                    button {
2216                        class: "execute-btn",
2217                        disabled: !actions().run.is_ready(),
2218                        title: actions().run.reason().map(str::to_string),
2219                        onclick: handle_logic_execute,
2220                        span { class: "desktop-label", "\u{25B6} Execute" }
2221                        span { class: "mobile-label", "\u{25B6} Execute" }
2222                    }
2223                    {heal_button()}
2224                    button {
2225                        class: "execute-btn compile-btn",
2226                        style: "background: linear-gradient(135deg, #56b6c2 0%, #61afef 100%);",
2227                        disabled: !actions().compile.is_ready(),
2228                        title: actions().compile.reason().map(str::to_string),
2229                        onclick: handle_logic_compile,
2230                        span { class: "desktop-label", "\u{1F980} Compile" }
2231                        span { class: "mobile-label", "\u{1F980} Compile to Rust" }
2232                    }
2233                }
2234                if current_mode == StudioMode::Hardware {
2235                    button {
2236                        class: "execute-btn",
2237                        disabled: !actions().run.is_ready(),
2238                        title: actions().run.reason().map(str::to_string),
2239                        onclick: handle_hardware_execute,
2240                        span { class: "desktop-label", "\u{25B6} Execute" }
2241                        span { class: "mobile-label", "\u{25B6} Execute" }
2242                    }
2243                    button {
2244                        class: "execute-btn compile-btn",
2245                        style: "background: linear-gradient(135deg, #56b6c2 0%, #61afef 100%);",
2246                        disabled: !actions().compile.is_ready(),
2247                        title: actions().compile.reason().map(str::to_string),
2248                        onclick: handle_hardware_compile,
2249                        span { class: "desktop-label", "\u{1F980} Compile" }
2250                        span { class: "mobile-label", "\u{1F980} Compile to Rust" }
2251                    }
2252                }
2253                // A web page is LOGOS code with no Run of its own: Heal is its one action.
2254                if current_mode == StudioMode::Web {
2255                    {heal_button()}
2256                }
2257                // A frame on another page leads into the Studio, on the file it shows.
2258                if host == Host::Frame {
2259                    if let Some(open) = current_file.read().clone() {
2260                        Link { to: studio_file_url(&open), class: "studio-open", "Open in Studio" }
2261                    }
2262                }
2263            }
2264        }
2265
2266        // Where nothing hovers, a disabled action's reason and a fault's whole story are lines of their
2267        // own under the toolbar.
2268        if let Some(stopped) = fault().filter(|_| fault_open()) {
2269            div { class: "studio-fault-detail", "{stopped.detail()}" }
2270        }
2271        if !withheld.is_empty() {
2272            div { class: "studio-reasons",
2273                for (name, why) in withheld {
2274                    span { key: "{name}", "{name}: {why}" }
2275                }
2276            }
2277        }
2278
2279        // A phone's switch between its panes, one shown at a time.
2280        nav { class: if show_panel3 { "mobile-tabs has-third" } else { "mobile-tabs" }, role: "tablist", "aria-label": "Panes",
2281            for (tab, icon, label) in [(MobileTab::Panel1, tab1_icon, tab1_label), (MobileTab::Panel2, tab2_icon, tab2_label), (MobileTab::Panel3, tab3_icon, tab3_label)] {
2282                if tab != MobileTab::Panel3 || show_panel3 {
2283                    button {
2284                        class: if current_tab == tab { "mobile-tab active" } else { "mobile-tab" },
2285                        role: "tab",
2286                        "aria-selected": if current_tab == tab { "true" } else { "false" },
2287                        onclick: move |_| active_tab.set(tab),
2288                        span { class: "mobile-tab-icon", "aria-hidden": "true", "{icon}" }
2289                        span { class: "mobile-tab-label", "{label}" }
2290                    }
2291                }
2292            }
2293        }
2294
2295        // Socratic Guide (Logic mode only)
2296        if current_mode == StudioMode::Logic {
2297            div { class: "studio-guide",
2298                SocraticGuide {
2299                    mode: guide_mode.clone(),
2300                    on_hint_request: None,
2301                }
2302            }
2303        }
2304
2305        // Main content with optional sidebar
2306        div { class: "studio-content", style: columns_style,
2307            // File browser sidebar
2308            // Mobile overlay to close sidebar when clicking outside
2309            if *sidebar_open.read() || *drawer_open.read() {
2310                div {
2311                    class: if *drawer_open.read() { "sidebar-overlay drawer-open" } else { "sidebar-overlay" },
2312                    onclick: move |_| drawer_open.set(false),
2313                }
2314            }
2315
2316            if *sidebar_open.read() || *drawer_open.read() {
2317                div {
2318                    class: if *drawer_open.read() { "studio-sidebar drawer-open" } else { "studio-sidebar" },
2319
2320                    // Show VFS error if any (critical errors only)
2321                    if let Some(err) = store.error.as_ref() {
2322                        div {
2323                            style: "background: #ff4444; color: white; padding: 6px 12px; font-size: 11px; border-radius: 0;",
2324                            "VFS Error: {err}"
2325                        }
2326                    }
2327
2328                    FileBrowser {
2329                    show_private_mode: store.private_mode,
2330                    new_file_waits: store.new_file_waits.clone(),
2331                    tree: file_tree.read().clone(),
2332                    selected_path: current_file.read().clone(),
2333                    on_select: EventHandler::new(move |path: String| {
2334                        // Opening a shipped example is the workbench's, at once; the host keeps
2335                        // the address bar on it and reads a reader's own file from their store.
2336                        state.choose(path.clone());
2337                        if let Some(chosen) = on_choose {
2338                            chosen.call(path);
2339                        }
2340                    }),
2341                    on_toggle_dir: EventHandler::new(move |path: String| {
2342                        if let Some(node) = file_tree.write().find_mut(&path) {
2343                            node.toggle_expanded();
2344                        }
2345                    }),
2346                    on_new_file,
2347                }
2348                }
2349                if *sidebar_open.read() {
2350                    div {
2351                        class: "panel-resizer",
2352                        "data-resizer": Resizer::SidebarLeft.attr(),
2353                        onpointerdown: move |evt| start_column_drag(evt, Resizer::SidebarLeft),
2354                    }
2355                }
2356            }
2357
2358            // The panels, content by mode. The panel row is the Studio page's main landmark; on another page it sits in that page's.
2359            if host == Host::Page {
2360                main { class: "studio-main", {panels} }
2361            } else {
2362                div { class: "studio-main", {panels} }
2363            }
2364        }
2365
2366        // The drawers rise from the dock bar below them, on either host: Logic's proof and Code's
2367        // debugger, each bottom-docked and dragged by its grip.
2368        if current_mode == StudioMode::Logic && *proof_open.read() {
2369            ProofPanel {
2370                proof_text: proof_text.read().clone(),
2371                tree: proof_tree.read().clone(),
2372                status: proof_status.read().clone(),
2373                steps: walkthrough,
2374                height: proof_height,
2375                on_prove: move |_| handle_prove.call(()),
2376                on_close: move |_| proof_open.set(false),
2377            }
2378        }
2379        if debugging() && current_mode == StudioMode::Code {
2380            DebugDrawer {
2381                source: code_input.read().clone(),
2382                height: drawer_height,
2383                output: debug_output,
2384                on_close: move |_| debugging.set(false),
2385            }
2386        }
2387        // The dock bar: the one amber line along the bottom that says what to do next and holds
2388        // the way into the drawer above it.
2389        if current_mode == StudioMode::Logic {
2390            div { class: "studio-dock",
2391                span { class: "studio-dock-icon", "aria-hidden": "true", "\u{1F4A1}" }
2392                span { class: "studio-dock-text", role: "status", "{dock_text}" }
2393                button {
2394                    class: "studio-dock-btn primary",
2395                    title: "Ask the kernel whether the first sentence follows",
2396                    onclick: move |_| handle_prove.call(()),
2397                    "\u{25B6} Prove"
2398                }
2399                button {
2400                    class: "studio-dock-btn",
2401                    aria_expanded: if *proof_open.read() { "true" } else { "false" },
2402                    onclick: move |_| {
2403                        let open = *proof_open.read();
2404                        proof_open.set(!open);
2405                    },
2406                    if *proof_open.read() { "Hide proof" } else { "Show proof" }
2407                }
2408            }
2409        }
2410        if current_mode == StudioMode::Code {
2411            div { class: "studio-dock",
2412                span { class: "studio-dock-icon", "aria-hidden": "true", dangerous_inner_html: IC_BUG }
2413                span { class: "studio-dock-text", role: "status", "{dock_text}" }
2414                if debugging() {
2415                    button { class: "studio-dock-btn", onclick: move |_| debugging.set(false), "Stop debugging" }
2416                } else {
2417                    button {
2418                        class: "studio-dock-btn primary",
2419                        disabled: !actions().debug.is_ready(),
2420                        title: actions().debug.reason().map(str::to_string),
2421                        onclick: move |_| {
2422                            code_output_mode.set(CodeOutputMode::Interpret);
2423                            debugging.set(true);
2424                        },
2425                        "Debug"
2426                    }
2427                }
2428            }
2429        }
2430
2431        StatusBar {
2432            status: editor_status,
2433            language: status_language.name(&status_source).to_string(),
2434            indent: Indentation::of(status_language, &status_source).step.len(),
2435            problems: current_mode != StudioMode::Math,
2436        }
2437        }
2438    }
2439}
2440
2441#[cfg(test)]
2442#[path = "../../../../../crates/logicaffeine_tests/unit/logicaffeine_web/ui/pages/studio_workbench/tests.rs"]
2443mod tests;
2444
2445/// The fairy and her card (work/LLM_HEAL.md): nothing while she is home; the fairy alone, flying
2446/// to the line, while the worker thinks and mends; the card beside her with Before, After, Keep
2447/// and Undo once the mend is back, or the reason there is none with a way to close it. Keep writes
2448/// the mend into the editor; Undo leaves the editor as it was.
2449#[component]
2450fn HealOverlay(flight: Signal<heal::Flight>, editor: Signal<String>, file: Signal<Option<String>>, baseline: Signal<std::collections::HashMap<String, String>>) -> Element {
2451    let state = flight.read().clone();
2452    if state == heal::Flight::Idle {
2453        return rsx! {};
2454    }
2455    let line = state.line();
2456    let mended = match &state {
2457        heal::Flight::Review { lines, .. } => format!("{} place{} mended. Nothing changes until Keep.", lines.len(), if lines.len() == 1 { "" } else { "s" }),
2458        _ => String::new(),
2459    };
2460    let phase = match &state {
2461        heal::Flight::Flying { .. } => "flying",
2462        heal::Flight::Mending => "mending",
2463        heal::Flight::Review { .. } => "review",
2464        heal::Flight::Failed(_) => "failed",
2465        heal::Flight::Idle => "idle",
2466    };
2467    // Escape closes the card, as a dialog should: the same as Undo, the editor untouched.
2468    #[cfg(target_arch = "wasm32")]
2469    {
2470        let closable = matches!(state, heal::Flight::Review { .. } | heal::Flight::Failed(_));
2471        use_effect(move || {
2472            if !closable {
2473                return;
2474            }
2475            use wasm_bindgen::{closure::Closure, JsCast};
2476            let Some(window) = web_sys::window() else { return };
2477            let on_key = Closure::<dyn FnMut(web_sys::KeyboardEvent)>::new(move |event: web_sys::KeyboardEvent| {
2478                if event.key() == "Escape" {
2479                    let next = flight.peek().clone().undo();
2480                    flight.set(next);
2481                }
2482            });
2483            let _ = window.add_event_listener_with_callback("keydown", on_key.as_ref().unchecked_ref());
2484            on_key.forget();
2485        });
2486    }
2487    rsx! {
2488        div { class: "heal-overlay", style: "--heal-line: {line};",
2489            img { class: "heal-fairy {phase}", src: heal::FAIRY, alt: "The fairy", "aria-hidden": "true" }
2490            match &state {
2491                heal::Flight::Review { before, after, diff, .. } => rsx! {
2492                    div { class: "heal-card", role: "dialog", "aria-label": "Review the mend",
2493                        div { class: "heal-card-cols",
2494                            section { class: "heal-card-col",
2495                                h3 { "Before" }
2496                                pre { class: "heal-before", {marked_lines(before, after, &diff.before, &diff.after, "replaced")} }
2497                            }
2498                            section { class: "heal-card-col",
2499                                h3 { "After" }
2500                                pre { class: "heal-after", {marked_lines(after, before, &diff.after, &diff.before, "mended")} }
2501                            }
2502                        }
2503                        div { class: "heal-card-actions",
2504                            span { class: "heal-card-note", "{mended}" }
2505                            button {
2506                                class: "studio-dock-btn",
2507                                onclick: move |_| { let next = flight.peek().clone().undo(); flight.set(next); },
2508                                "Undo"
2509                            }
2510                            button {
2511                                class: "studio-dock-btn primary heal-keep",
2512                                onclick: move |_| {
2513                                    let (next, kept) = flight.peek().clone().keep();
2514                                    if let Some(text) = kept {
2515                                        editor.set(text.clone());
2516                                        if let Some(path) = file.peek().clone() {
2517                                            baseline.write().insert(path, text);
2518                                        }
2519                                    }
2520                                    flight.set(next);
2521                                },
2522                                "Keep"
2523                            }
2524                        }
2525                    }
2526                },
2527                heal::Flight::Failed(reason) => rsx! {
2528                    div { class: "heal-card heal-card-failed", role: "alert",
2529                        p { "{reason}" }
2530                        button {
2531                            class: "studio-dock-btn",
2532                            onclick: move |_| { let next = flight.peek().clone().undo(); flight.set(next); },
2533                            "Close"
2534                        }
2535                    }
2536                },
2537                _ => rsx! {},
2538            }
2539        }
2540    }
2541}
2542
2543/// `text` line by line for the card, the lines in `own` marked with `class`, and within a marked
2544/// line that replaces one line of the other side (its counterpart in `other`), the words that
2545/// differ marked stronger. So the reader sees the word that changed, not only the line.
2546fn marked_lines(text: &str, other_text: &str, own: &[std::ops::Range<usize>], other: &[std::ops::Range<usize>], class: &'static str) -> Element {
2547    let other_lines: Vec<&str> = other_text.lines().collect();
2548    let counterpart = |i: usize| -> Option<&str> {
2549        let (k, r) = own.iter().enumerate().find(|(_, r)| r.contains(&i))?;
2550        let o = other.get(k)?;
2551        (r.len() == 1 && o.len() == 1).then(|| other_lines.get(o.start).copied()).flatten()
2552    };
2553    rsx! {
2554        for (i, line) in text.lines().enumerate() {
2555            if own.iter().any(|r| r.contains(&i)) {
2556                span { class: "heal-line {class}",
2557                    if let Some(pair) = counterpart(i) {
2558                        for (piece, differs) in heal::word_marks(line, pair) {
2559                            span { class: if differs { "heal-word" } else { "" }, "{piece}" }
2560                        }
2561                    } else {
2562                        "{line}"
2563                    }
2564                    "\n"
2565                }
2566            } else {
2567                span { class: "heal-line", "{line}\n" }
2568            }
2569        }
2570    }
2571}