← Commit history

rust: phase 5 demo tour (six beats, panel callback, narration, recording, exact progress projection); 96 verbs, 199 tests

John Lauer ·120a639452 ·27d ago ·parent 1c85c26
5 files changed +3797−8
rust/crates/kicad-bridge/src/main.rs+6
@@ -187,6 +187,12 @@ fn handle(mut req: tiny_http::Request, started: Instant, request_count: u64, ver                 }             }         }+        (Method::Post, "/demo/event") => {+            // ab's demo panel posts every button press here (phase 5); the tour applies it.+            let mut raw = String::new();+            let _ = req.as_reader().read_to_string(&mut raw);+            (verbs_demo::panel_event(&raw), 200)+        }         _ => (json!({"success": false, "error": "not found"}), 404),     };     let _ = req.respond(json_response(body, code));
rust/crates/kicad-bridge/src/verbs.rs+4−1
@@ -27,8 +27,11 @@ pub fn dispatch(state: &mut State, command_in: &str, args: &Value, _caller: &Cal     // One KiCad UI thread, one etiquette ledger: window, IPC and show verbs run one at a     // time. Read-only and file verbs do not take the lock (a poisoned lock is still usable:     // a verb that panicked mid-way must not wedge the bridge).+    // kicad_demo is the one Window verb that takes the lock ITSELF: its background job holds+    // GUI_LOCK per beat and releases it between beats, and its start, progress and control+    // calls must answer while a beat is in flight (the dashboard polls it every 2-3 s).     let _serial = match verb.mechanism {-        catalog::Mechanism::Window | catalog::Mechanism::Ipc => Some(GUI_LOCK.lock().unwrap_or_else(|e| e.into_inner())),+        catalog::Mechanism::Window | catalog::Mechanism::Ipc if command != "kicad_demo" => Some(GUI_LOCK.lock().unwrap_or_else(|e| e.into_inner())),         _ => None,     };     // The ab callback client forwards this request's caller identity (headers first, args.caller as fallback).
rust/crates/kicad-bridge/src/verbs_demo.rs+1907−6
@@ -1,11 +1,1912 @@-//! Verb group "demo". Placeholder until phase 5 lands.-use serde_json::Value;+//! Verb group "demo": `kicad_demo`, the bridge's first-class, self-contained showcase.+//! Phase 5 of docs/rust-port-plan.md, ported from `handlers/demo.py` and+//! `handlers/demo_motion.py`. The OS-neutral half (beats, titles, text work, layout math,+//! transport, job record, motion plans, narration timing) is `kicad_core::demo`; this+//! file is the part that touches windows.+//!+//! Adom Bridge demos this bridge during Hydrogen's install, so an AI with no context+//! must be able to run ONE verb and narrate a convincing tour. Six beats, in the order a+//! hardware person thinks: symbol, footprint, the chip in 3D, schematic, 2D board, 3D+//! board, on a real Adom board from the wiki (adom/esc-g431).+//!+//! Four ways in, one verb:+//! - `{all:true, background:true}`: the tour as a background job with structured progress+//!   (`{started, jobId, statusVerb}`), polled with `{progress:true}`; `kicad_status` and+//!   `kicad_progress` report the same job as the `kicad.demo` phase.+//! - `{control: play|pause|next|prev|exit|mute|unmute}`: the dashboard's transport+//!   buttons; ab's demo panel lands on `POST /demo/event` (`panel_event`) with the poll as+//!   the backstop. Presses are honoured at the running beat's next gesture.+//! - `{step}` or no args: the staged, resumable flow (one beat per call, `data.nextCall`).+//! - `{installKiCad:true}` when KiCad is absent: install silently, then tour.+//!+//! Etiquette: the tour is the one place the bridge DOES foreground KiCad ("you must+//! foreground otherwise the user doesn't know what window you're demoing"), exactly ONCE+//! per beat, with a reason, through ab, reported only as the z-order measures it, and+//! never re-raised: if the user alt-tabs away mid-beat, that is their desktop. Real input+//! (wheel, drags, Home) goes only through `native().tour_*`, which refuse unless the beat's+//! window is the measured foreground; the 3D orbit prefers WM_COMMAND on the viewer's+//! own View menu, which needs no focus at all. The job takes `GUI_LOCK` while it drives+//! windows and releases it between beats and during narration, so ordinary window verbs+//! serialise behind a beat and status polls never wait. -use crate::catalog::Verb;-use crate::util::State;+use std::collections::BTreeMap;+use std::path::{Path, PathBuf};+use std::sync::Mutex;+use std::time::{Duration, Instant}; -pub static VERBS: &[Verb] = &[];+use serde_json::{json, Map, Value}; -pub fn dispatch(_state: &mut State, _command: &str, _args: &Value) -> Option<Value> {+use crate::catalog::{Mechanism, Verb};+use crate::util::*;+use crate::verbs_windows as win;+use kicad_core::demo::{self, Beat, CanvasContent, Cmd, Gesture, MotionKind};+use kicad_core::detect::KicadInfo;+use kicad_core::libraries::LibCtx;+use kicad_core::windows_model::{self as wm, WindowKind};+use kicad_core::{ab, install, progress};+use kicad_platform::{native, Placement};++// ── Catalog ───────────────────────────────────────────────────────────────────++pub static VERBS: &[Verb] = &[Verb {+    name: "kicad_demo",+    summary: "Six-beat KiCad showcase tour: staged, or a background job with structured progress (percent/ETA), an ab demo-panel remote, captions, narration, per-beat verification and a kicad_state final payload.",+    mechanism: Mechanism::Window,+    risk: "input",+    timeout_sec: 130,+    input: "{\"all\"?: true, \"background\"?: true, \"muted\"?: false, \"narrate\"?: true, \"record\"?: false, \"speed\"?: 1.0, \"steps\"?: [\"symbol\",...], \"project\"?: \"C:/.../x.kicad_pro\", \"chipRef\"?: \"U5\"} | {\"progress\": true} | {\"control\": \"play|pause|next|prev|exit|mute|unmute\"} | {\"step\"?: \"symbol\"} | {\"installKiCad\": true} | {\"layoutProbe\": true}",+    example: "kicad_demo {\"all\":true,\"background\":true}",+    hint: "The demo verb. BACKGROUND (the dashboard's path): {all:true,background:true} answers {started, jobId, statusVerb} at once; poll kicad_demo {progress:true} every 2-3 s and draw a real bar from active[0]; the final poll carries result.state (every KiCad window as base64 PNG) and result.narrate. STAGED + RESUMABLE (same contract as fusion_demo): {} or {step} runs one beat, speak `narrate`, show screenshots[{label,path}], call data.nextCall until done:true. Every step returns say (speak it), pointOut (what is on screen) and window (its title). Transport: the ab demo panel on the desktop, or kicad_demo {control}. record:true records the primary monitor with system audio through ab and returns the mp4 path in result.recording.",+    related: &["kicad_progress", "kicad_status", "kicad_screenshot_all", "kicad_close", "kicad_upgrade"],+    pitfalls: &[+        "if KiCad is not installed it returns an actionable offer (data.acceptWith): call kicad_demo {installKiCad:true}, never send the user to a download page",+        "the tour foregrounds each beat's window exactly once, with a reason, and never again: the user keeps their desktop between beats",+        "never run or publish a tour on a box while someone is using it; kicad_state.userIdleSeconds is the presence gate",+        "one job at a time: a second {all,background} start while one runs is refused with the running job's progress",+        "a bulk staged run ({all:true} without background) can outlive one request budget; prefer the background job or one step per call",+        "narration clips are pre-rendered for the ESC board: with a custom project the tour runs muted and says so in prepSummary",+    ],+}];++pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {+    if command != "kicad_demo" {+        return None;+    }+    // args.caller wins over the request headers (the shape ab relays).+    ab::set_caller_from_args(args);+    Some(handle_demo(state, args))+}++// ── Small helpers ─────────────────────────────────────────────────────────────++fn sleep_ms(ms: u64) {+    std::thread::sleep(Duration::from_millis(ms));+}++fn is_true(v: &Value, key: &str) -> bool {+    v.get(key).and_then(Value::as_bool).unwrap_or(false)+}++fn lib_ctx(info: &KicadInfo) -> LibCtx {+    let inst = info.primary();+    let version = inst.map(|i| i.version.clone()).unwrap_or_default();+    let mut ctx = LibCtx {+        version: version.clone(),+        base_dir: inst.map(|i| i.base_dir.clone()).filter(|b| !b.is_empty()),+        config_dir: if version.is_empty() { None } else { native().config_dir(&version) },+        user_dir: if version.is_empty() { None } else { native().user_dir(&version) },+        kicad_cli: inst.map(|i| PathBuf::from(&i.kicad_cli)).filter(|p| p.is_file()),+        env_overrides: Default::default(),+    };+    ctx.load_env_overrides();+    ctx+}++/// Documents/adom-kicad-demo (created).+fn demo_dir() -> PathBuf {+    let base = native().documents_dir().or_else(|| std::env::var_os("USERPROFILE").or_else(|| std::env::var_os("HOME")).map(|h| PathBuf::from(h).join("Documents"))).unwrap_or_else(std::env::temp_dir);+    let d = base.join(demo::DEMO_DIR_NAME);+    let _ = std::fs::create_dir_all(&d);+    d+}++/// Re-apply the identity of the request that STARTED the tour on this thread. John's+/// dashboard run, 2026-09-03: the tour thread called ab with no caller identity, so ab+/// REFUSED the demo panel, every caption, every raise and every window close. Identity+/// is mandatory on the desktop; the tour carries the starter's.+fn adopt_identity() {+    let fwd = demo::job_get("callerFwd");+    let s = |k: &str| fwd.get(k).and_then(Value::as_str).unwrap_or("").to_string();+    if !s("thread").is_empty() {+        ab::set_caller(ab::Caller { thread: s("thread"), container: s("container"), reason: s("reason") });+    }+}++fn caller_json() -> Value {+    match ab::current_caller() {+        Some(c) => json!({"thread": c.thread, "container": c.container, "reason": c.reason}),+        None => json!({}),+    }+}++// ── Window lookups ────────────────────────────────────────────────────────────++fn hwnd_for_title(expected: &str) -> Option<u64> {+    win::invalidate();+    win::kicad_windows().iter().find(|w| demo::title_matches(&w.title, expected)).map(|w| w.hwnd)+}++/// hwnds of every "3D Viewer" window, top of the z-order first.+fn viewers_3d() -> Vec<u64> {+    win::invalidate();+    win::kicad_windows().iter().filter(|w| w.title.to_lowercase().contains("3d viewer")).map(|w| w.hwnd).collect()+}++/// The hwnd that appears in `getter()` and was not in `before`, within `wait`.+fn new_window(before: &[u64], getter: impl Fn() -> Vec<u64>, wait: Duration) -> Option<u64> {+    let t0 = Instant::now();+    while t0.elapsed() < wait {+        if let Some(h) = getter().into_iter().find(|h| !before.contains(h)) {+            return Some(h);+        }+        sleep_ms(500);+    }+    None+}++/// The window in front as the user sees it: ab's z-order (with the shell and our own+/// overlays ignored), else the OS foreground. Returns (hwnd, title).+fn front_window() -> (u64, String) {+    if let Ok(l) = ab::desktop_list_windows() {+        if l.windows.iter().any(|w| w.z.is_some()) {+            let rows: Vec<demo::ZRow> = l.windows.iter().map(|w| demo::ZRow { hwnd: w.hwnd, z: w.z, minimized: w.minimized, title: w.title.clone(), image: w.image.clone() }).collect();+            let fh = demo::front_hwnd(&rows);+            let title = rows.iter().find(|r| r.hwnd == fh).map(|r| r.title.clone()).unwrap_or_default();+            return (fh, title);+        }+    }+    let fh = native().foreground().unwrap_or(0);+    (fh, if fh != 0 { win::title_of(fh) } else { String::new() })+}++fn is_front(hwnd: u64) -> bool {+    native().foreground().map(|f| f == hwnd).unwrap_or(false)+}++fn window_rect(hwnd: u64) -> Option<(i32, i32, i32, i32)> {+    native().window_info(hwnd).ok().flatten().map(|i| i.rect)+}++// ── Captions and the transport panel ──────────────────────────────────────────++const CAPTION_REASON: &str = "Narrate the KiCad tour the user started, one line per beat";++/// Paint/replace the demo caption on the user's screen (bottom-left, medium; the demo+/// panel owns bottom-right). Best-effort: a caption must never be able to fail a beat.+fn caption(text: &str, duration_ms: u64) {+    if demo::exit_requested() {+        return;+    }+    let r = ab::desktop_caption_at(demo::CAPTION_ID, text, duration_ms, "medium", "bottom-left", CAPTION_REASON);+    demo::job_push("captions", json!({"text": text.chars().take(60).collect::<String>(), "ok": r.is_ok(), "error": r.err().map(|e| e.message())}), 200);+}++/// Immediate acknowledgement of a remote press, at the TOP so it never fights the beat+/// caption (John: "show me an alternate caption immediately indicating you got my signal").+fn caption_ack(text: &str) {+    let r = ab::desktop_caption_at(demo::NOTE_CAPTION_ID, text, 3500, "small", "top", "Acknowledge a press on the KiCad tour remote the instant it arrives");+    demo::job_push("captions", json!({"text": text.chars().take(60).collect::<String>(), "ack": true, "ok": r.is_ok(), "error": r.err().map(|e| e.message())}), 200);+}++/// Small caption at the TOP for what the tour is doing to the desktop right now (John,+/// 2026-09-05: "put ab captions (small) at the top indicating each time you're+/// foregrounding a kicad window"). Shares the id with the press acks: same strip.+fn caption_note(text: &str, duration_ms: u64) {+    let r = ab::desktop_caption_at(demo::NOTE_CAPTION_ID, text, duration_ms, "small", "top", "Tell the user which KiCad window the tour is bringing forward");+    demo::job_push("captions", json!({"text": text.chars().take(60).collect::<String>(), "note": true, "ok": r.is_ok(), "error": r.err().map(|e| e.message())}), 200);+}++fn caption_clear() {+    let _ = ab::desktop_caption_hide(demo::CAPTION_ID, "The user stopped the KiCad tour; clear its caption");+}++/// Loopback URL ab POSTs panel presses to. None when the port is not known (an ephemeral+/// `--port 0`, or a harness that did not pass one): the poll is then the only channel.+fn callback_url() -> Option<String> {+    let host = std::env::var("ADOM_KICAD_BRIDGE_HOST").ok().filter(|h| !h.trim().is_empty()).or_else(|| std::env::var("ADOM_BIND_HOST").ok()).unwrap_or_else(|| "127.0.0.1".into());+    let mut port: Option<u16> = std::env::var("ADOM_KICAD_BRIDGE_PORT").ok().and_then(|p| p.trim().parse().ok());+    if port.is_none() {+        let argv: Vec<String> = std::env::args().collect();+        let mut i = 1;+        while i < argv.len() {+            if argv[i] == "--port" && i + 1 < argv.len() {+                port = argv[i + 1].parse().ok();+                break;+            }+            if let Some(v) = argv[i].strip_prefix("--port=") {+                port = v.parse().ok();+                break;+            }+            i += 1;+        }+    }+    port.filter(|p| *p != 0).map(|p| format!("http://{host}:{p}/demo/event"))+}++/// show at stage 0 carries everything (title, stops, reason, callback URL); later calls+/// send ONLY {id, stage}: title/stops/state/muted persist on ab's side, and resending+/// state would un-pause or un-mute a user who pressed those. Pass state only when the+/// SCRIPT changes it (our own pause).+fn panel_show(stage: usize, state: Option<&str>, stops: &[String]) -> bool {+    if demo::panel().ok == Some(false) {+        return false;+    }+    let mut args = json!({"action": "show", "id": demo::PANEL_ID, "stage": stage, "reason": demo::PANEL_REASON});+    let first = !demo::panel().shown_once;+    if first {+        args["title"] = json!(demo::PANEL_TITLE);+        args["stops"] = json!(stops);+        args["state"] = json!(state.unwrap_or("playing"));+        args["position"] = json!("bottom-right");+        args["muted"] = json!(demo::is_muted());+        if let Some(cb) = callback_url() {+            args["eventCallbackUrl"] = json!(cb);+        }+    } else if let Some(s) = state.filter(|s| *s == "playing" || *s == "paused") {+        if demo::panel().script_state.as_deref() != Some(s) {+            args["state"] = json!(s);+        }+    }+    let mut attempt = ab::desktop_demo_panel(args.clone());+    if let Err(e) = &attempt {+        if e.is_unavailable() {+            // ab restarts around installs: retry once after a beat rather than falling back+            // to the toast for good.+            sleep_ms(2000);+            attempt = ab::desktop_demo_panel(args.clone());+        }+    }+    let (shown, err, reply) = match &attempt {+        Ok(r) => {+            let shown = r.get_bool("shown").unwrap_or(true);+            let mut reply = Map::new();+            for k in ["shown", "id", "state", "stage", "callbackAccepted", "error"] {+                if let Some(v) = r.get(k) {+                    reply.insert(k.into(), v.clone());+                }+            }+            (shown, r.get_str("error").map(str::to_string), Value::Object(reply))+        }+        Err(e) => (false, Some(e.message()), Value::Null),+    };+    if let Some(e) = &err {+        if e.to_lowercase().contains("unknown verb") {+            demo::panel().ok = Some(false); // this ab predates the panel: toast for the rest of the tour+            return false;+        }+    }+    let prev_cb = demo::job_get("panel").get("callbackUrl").cloned().unwrap_or(Value::Null);+    demo::job_update(json!({"panel": {"shown": shown, "stage": stage, "callbackUrl": args.get("eventCallbackUrl").cloned().unwrap_or(prev_cb), "error": err, "reply": reply}}));+    if shown {+        let mut p = demo::panel();+        p.ok = Some(true);+        p.shown_once = true;+        if let Some(s) = state.filter(|s| *s == "playing" || *s == "paused") {+            p.script_state = Some(s.to_string());+        }+        if let Ok(r) = &attempt {+            if let Some(v) = r.get_bool("callbackAccepted") {+                p.callback_accepted = Some(v);+            }+        }+    }+    shown+}++fn panel_hide() {+    if demo::panel().ok != Some(true) {+        return;+    }+    let _ = ab::desktop_demo_panel(json!({"action": "hide", "id": demo::PANEL_ID}));+}++fn record_press(button: &str, at: Option<&Value>, via: &str) {+    demo::job_push("presses", json!({"button": button, "at": at, "via": via, "beat": demo::job_get("beatIndex"), "step": demo::job_get("step")}), 200);+}++/// Backstop drain of the panel's press queue (the instant callback is the fast path;+/// presses it already delivered are skipped by their `at` stamp). Returns the next command,+/// or Some(Pause) held while the panel says paused.+enum Poll {+    Cmd(Cmd),+    PauseHeld,+    Nothing,+}++fn panel_poll(stops: &[String]) -> Poll {+    if demo::panel().ok != Some(true) {+        return Poll::Nothing;+    }+    let Ok(r) = ab::desktop_demo_panel(json!({"action": "poll", "id": demo::PANEL_ID})) else { return Poll::Nothing };+    if r.get_str("state") == Some("gone") {+        // hid on idle or replaced: re-show once at the current stage+        demo::panel().shown_once = false;+        let stage = demo::job_get("beatIndex").as_u64().unwrap_or(0) as usize;+        panel_show(stage, None, stops);+        return Poll::Nothing;+    }+    if let Some(m) = r.get_bool("muted") {+        if m != demo::is_muted() {+            demo::apply_mute(m, "panel-poll");+        }+    }+    let events = r.get("events").and_then(Value::as_array).cloned().unwrap_or_default();+    for ev in events {+        let at = ev.get("at").map(|a| a.to_string()).unwrap_or_default();+        if demo::panel().seen.contains(&at) {+            continue;+        }+        demo::panel().seen.insert(at);+        let b = ev.get("button").and_then(Value::as_str).unwrap_or("").to_lowercase();+        record_press(&b, ev.get("at"), "poll");+        match b.as_str() {+            "mute" => demo::apply_mute(true, "panel-poll"),+            "unmute" => demo::apply_mute(false, "panel-poll"),+            _ => {+                if let Some(c) = demo::map_button(&b) {+                    demo::set_control(c);+                }+            }+        }+    }+    if let Some(c) = demo::ctrl().pop() {+        return Poll::Cmd(c);+    }+    if r.get_str("state") == Some("paused") {+        return Poll::PauseHeld;+    }+    Poll::Nothing+}++/// Empty the panel's press queue before a tour starts: presses queued against the+/// previous show (same id) must not steer this one.+fn drain_stale_presses() {+    for _ in 0..3 {+        match ab::desktop_demo_panel(json!({"action": "poll", "id": demo::PANEL_ID})) {+            Ok(r) if r.get("events").and_then(Value::as_array).map(|a| !a.is_empty()).unwrap_or(false) => continue,+            _ => break,+        }+    }+}++/// The toast transport, only when ab's demo panel is not on screen.+fn post_transport(body: &str, paused: bool) {+    if demo::panel().ok == Some(true) {+        return;+    }+    let _ = ab::notify_user_buttons(demo::CTL_TOAST_ID, "KiCad demo", body, &[if paused { "Play" } else { "Pause" }, "Prev", "Next", "Exit"], true);+}++/// One pending control command, from the verb channel, the panel, or a toast click.+fn drain_ctrl(stops: &[String]) -> Option<Cmd> {+    if let Some(c) = demo::ctrl().pop() {+        return Some(c);+    }+    match panel_poll(stops) {+        Poll::Cmd(c) => return Some(c),+        Poll::PauseHeld => return if is_true(&Value::Object(demo::job_snapshot()), "paused") { None } else { Some(Cmd::Pause) },+        Poll::Nothing => {}+    }+    if demo::panel().ok == Some(true) {+        return None; // the panel is the transport; the toast is not shown+    }+    let since = demo::ctrl().seq;+    if let Ok((events, next)) = ab::notify_events(since) {+        if let Some(n) = next {+            let mut c = demo::ctrl();+            c.seq = if n < c.seq { 0 } else { n }; // stream trimmed: reset per the contract+        }+        for ev in events.iter().rev() {+            if ev.get("id").and_then(Value::as_str) == Some(demo::CTL_TOAST_ID) {+                if let Some(a) = ev.get("action").and_then(Value::as_str) {+                    if let Some(c) = demo::map_button(a) {+                        return Some(c);+                    }+                }+            }+        }+    }     None }++/// ab's instant callback (POST /demo/event) for one press: applies mute at once, stops+/// narration at once on pause/stop, and queues the transport command for the beat+/// boundary (a beat in flight cannot be interrupted). Returns the HTTP reply body.+pub fn panel_event(body: &str) -> Value {+    adopt_identity();+    let ev: Value = serde_json::from_str(body).unwrap_or(Value::Null);+    let Some(obj) = ev.as_object() else { return json!({"ok": true, "ignored": true}) };+    if obj.get("id").and_then(Value::as_str).map(|id| id != demo::PANEL_ID).unwrap_or(false) {+        return json!({"ok": true, "ignored": true});+    }+    if let Some(at) = obj.get("at") {+        demo::panel().seen.insert(at.to_string());+    }+    let b = obj.get("button").and_then(Value::as_str).unwrap_or("").to_lowercase();+    record_press(&b, obj.get("at"), "callback");+    if let Some(t) = demo::ack_text(&b) {+        caption_ack(t);+    }+    if b == "mute" || b == "unmute" {+        demo::apply_mute(b == "mute", "panel");+        return json!({"ok": true, "applied": b});+    }+    if let Some(m) = obj.get("muted").and_then(Value::as_bool) {+        if m != demo::is_muted() {+            demo::apply_mute(m, "panel");+        }+    }+    let Some(cmd) = demo::map_button(&b) else { return json!({"ok": true, "ignored": b}) };+    if matches!(cmd, Cmd::Pause | Cmd::Exit | Cmd::Next | Cmd::Prev) {+        demo::stop_narration(); // the user's hand: silence within the beat+    }+    if cmd == Cmd::Exit {+        // John, 2026-09-03: on the panel's X "exit immediately, remove the captions and+        // stop the audio". The beat loop, the narration wait and the motion all poll EXIT.+        demo::set_exit(true);+        caption_clear();+        demo::job_update(json!({"stopping": true, "stepLabel": "Closing the demo tour"}));+    }+    demo::set_control(cmd);+    demo::job_update(json!({"lastPress": {"button": b, "at": obj.get("at"), "via": "callback"}}));+    json!({"ok": true, "queued": cmd.as_str()})+}++// ── Layout: 98% of the primary screen, saved and restored ─────────────────────++static SAVED_BOUNDS: Mutex<BTreeMap<u64, (String, Placement)>> = Mutex::new(BTreeMap::new());+static PRIMARY_ERR: Mutex<Option<String>> = Mutex::new(None);++fn leftovers_path() -> PathBuf {+    demo_dir().join("window-bounds-to-restore.json")+}++fn primary_monitor() -> Option<(i32, i32, i32, i32)> {+    match ab::desktop_list_monitors("KiCad tour: size the windows to the primary screen") {+        Ok(m) => {+            let p = demo::primary_monitor(&m);+            if p.is_none() {+                *PRIMARY_ERR.lock().unwrap_or_else(|e| e.into_inner()) = Some(format!("no usable monitors in {}", m.to_string().chars().take(200).collect::<String>()));+            }+            p+        }+        Err(e) => {+            *PRIMARY_ERR.lock().unwrap_or_else(|e| e.into_inner()) = Some(e.message());+            None+        }+    }+}++fn write_leftovers() {+    let g = SAVED_BOUNDS.lock().unwrap_or_else(|e| e.into_inner());+    let m: Map<String, Value> = g.iter().map(|(h, (t, p))| (h.to_string(), json!({"title": t, "x": p.x, "y": p.y, "w": p.width, "h": p.height}))).collect();+    let _ = std::fs::write(leftovers_path(), Value::Object(m).to_string());+}++/// KiCad remembers a frame's size when it closes, so every bound the tour touches is+/// saved first and restored at the end (or at stop, or on the next tour start via the+/// leftovers file after a crash).+fn save_bounds(hwnd: u64, title: &str) {+    if SAVED_BOUNDS.lock().map(|g| g.contains_key(&hwnd)).unwrap_or(true) {+        return;+    }+    if let Ok(p) = native().placement(hwnd) {+        if p.width > 0 && p.height > 0 {+            SAVED_BOUNDS.lock().unwrap_or_else(|e| e.into_inner()).insert(hwnd, (title.to_string(), p));+            write_leftovers();+        }+    }+}++fn set_bounds(hwnd: u64, x: i32, y: i32, w: i32, h: i32, reason: &str) -> Result<(), String> {+    match ab::desktop_set_window_bounds(hwnd, x, y, w, h, reason) {+        Ok(_) => Ok(()),+        Err(e) => native().set_placement(hwnd, &Placement { x, y, width: w, height: h, maximized: false }).map_err(|pe| format!("{}; local: {pe}", e.message())),+    }+}++/// 98% of the primary screen, centred; returns what was applied.+fn place_window(hwnd: u64, title: &str) -> Value {+    let Some(mon) = primary_monitor() else {+        return json!({"placed": false, "why": "no monitor bounds", "error": PRIMARY_ERR.lock().ok().and_then(|g| g.clone())});+    };+    let (x, y, w, h) = demo::place_rect(mon, (0.5, 0.5), demo::LAYOUT_FRACTION);+    save_bounds(hwnd, title);+    match set_bounds(hwnd, x, y, w, h, &format!("KiCad tour: place the {title} at 98% of the screen, centred")) {+        Ok(()) => json!({"placed": true, "x": x, "y": y, "w": w, "h": h}),+        Err(e) => json!({"placed": false, "x": x, "y": y, "w": w, "h": h, "error": e}),+    }+}++/// Put ONE moved window back before the tour closes it: KiCad writes a frame's geometry+/// to its sticky settings on close (measured on ConfRoomROG 2026-09-04).+fn restore_one(hwnd: u64) -> bool {+    let Some((_, p)) = SAVED_BOUNDS.lock().unwrap_or_else(|e| e.into_inner()).remove(&hwnd) else { return false };+    write_leftovers();+    if p.width > 0 && p.height > 0 && set_bounds(hwnd, p.x, p.y, p.width, p.height, "KiCad tour: restore this window to where the user had it before closing it").is_ok() {+        sleep_ms(400); // let wx see the new geometry before the close saves it+        return true;+    }+    false+}++/// Put every window the tour moved back where the user had it.+fn restore_bounds(source: &str) -> usize {+    let mut saved: Vec<(u64, Placement)> = SAVED_BOUNDS.lock().unwrap_or_else(|e| e.into_inner()).iter().map(|(h, (_, p))| (*h, p.clone())).collect();+    if saved.is_empty() {+        if let Ok(text) = std::fs::read_to_string(leftovers_path()) {+            if let Ok(Value::Object(m)) = serde_json::from_str::<Value>(&text) {+                for (h, b) in m {+                    if let (Ok(h), Some(x), Some(y), Some(w), Some(hh)) = (h.parse::<u64>(), b["x"].as_i64(), b["y"].as_i64(), b["w"].as_i64(), b["h"].as_i64()) {+                        saved.push((h, Placement { x: x as i32, y: y as i32, width: w as i32, height: hh as i32, maximized: false }));+                    }+                }+            }+        }+    }+    let mut n = 0;+    if !saved.is_empty() {+        win::invalidate();+        let live: Vec<u64> = win::kicad_windows().iter().map(|w| w.hwnd).collect();+        for (h, p) in saved {+            if live.contains(&h) && p.width > 0 && p.height > 0 && set_bounds(h, p.x, p.y, p.width, p.height, &format!("KiCad tour {source}: restore this window to where the user had it")).is_ok() {+                n += 1;+            }+        }+    }+    SAVED_BOUNDS.lock().unwrap_or_else(|e| e.into_inner()).clear();+    let _ = std::fs::remove_file(leftovers_path());+    n+}++fn close_hwnd(hwnd: u64, reason: &str) {+    if hwnd == 0 {+        return;+    }+    restore_one(hwnd);+    if ab::desktop_close_window(hwnd, reason).is_err() {+        let _ = native().post_close(hwnd);+    }+    win::invalidate();+}++/// A clean slate for the windows the tour will drive: every 3D Viewer and the Footprint+/// Editor, which otherwise sit on last time's part with a stale library tree.+fn close_stale_tour_windows() {+    win::invalidate();+    let stale: Vec<u64> = win::kicad_windows().iter().filter(|w| demo::is_stale_tour_window(&w.title)).map(|w| w.hwnd).collect();+    for h in stale {+        close_hwnd(h, "KiCad tour: close a stale viewer or editor from an earlier tour before starting");+    }+    sleep_ms(1500);+}++// ── The one sanctioned foreground, and the four measured facts ────────────────++/// Bring the window this beat opened to the front ONCE, then hands off. Lift the+/// etiquette loop's guard for this window, ask ab (which owns the desktop's input and has+/// the foreground unlock) to bring it forward, and REPORT ONLY WHAT THE Z-ORDER SAYS+/// afterwards. Never re-raised later: if the user alt-tabs away mid-beat, that is their+/// desktop.+fn foreground_once(window_title: &str, hwnd: Option<u64>) -> bool {+    if window_title.is_empty() {+        return false;+    }+    let Some(target) = hwnd.or_else(|| hwnd_for_title(window_title)) else { return false };+    native().etiquette_sanction(target, 120.0);+    caption_note(&format!("Bringing the {window_title} to the front"), 4000);+    let reason = format!("KiCad tour: show the user the {window_title} this beat opened, once");+    // ab first (it owns the desktop's input and the foreground unlock); the platform's own+    // sanctioned raise only when ab is not there to ask.+    if let Err(e) = ab::desktop_bring_to_front(target, &reason) {+        if e.message().to_lowercase().contains("unknown verb") || e.is_unavailable() {+            let _ = native().bring_to_front(target);+        }+    }+    for _ in 0..4 {+        sleep_ms(250); // up to ~1 s for the z-order to settle+        if front_window().0 == target {+            caption_note(&format!("Brought the {window_title} to the front"), 4000);+            return true;+        }+    }+    caption_note(&format!("The {window_title} stayed behind another window; leaving your desktop alone"), 5000);+    false+}++/// One local capture of the window, sampled and discarded: is the GL canvas painted?+fn canvas_probe(hwnd: u64) -> Value {+    let Ok(cap) = native().capture_window(hwnd) else { return Value::Null };+    let rect = native().gl_canvas_rect(hwnd).ok().flatten().map(|(x, y, w, h)| (x, y, x + w, y + h));+    wm::canvas_uniformity(&cap.rgb, cap.width, cap.height, rect)+}++fn canvas_rendered(hwnd: u64) -> bool {+    let p = canvas_probe(hwnd);+    !is_true(&p, "checked") || is_true(&p, "rendered")+}++/// Where the drawing sits on the canvas, in screen pixels.+fn canvas_measure(hwnd: u64) -> Option<CanvasContent> {+    let cap = native().capture_window(hwnd).ok()?;+    let (x, y, w, h) = native().gl_canvas_rect(hwnd).ok().flatten()?;+    let (wl, wt, _, _) = window_rect(hwnd)?;+    demo::canvas_content(&cap.rgb, cap.width, cap.height, (x, y, x + w, y + h), (wl, wt))+}++fn capture_hash(hwnd: u64) -> Option<u64> {+    native().capture_window(hwnd).ok().map(|c| demo::fnv1a(&c.rgb))+}++/// MEASURE, never assume (John, 2026-09-03: "you have to tool your bridge code to+/// definitively know that the correct thing is showing"). Four facts per beat, each read+/// from the desktop: front (the z-order says our hwnd is the top window), title (the live+/// title carries the document we expect), painted (the GL canvas is not a uniform blank),+/// stable (two captures 0.8 s apart are identical, retried for up to 8 s). Measured+/// twice when not in front (the z-order can lag a raise); never re-raised here, because+/// the beat's one sanctioned foreground already happened.+fn verify_beat(hwnd: Option<u64>, expected_title: &str) -> Value {+    let mut v = json!({"hwnd": hwnd, "expected": expected_title, "front": false, "title": false, "painted": Value::Null, "stable": Value::Null, "frontTitle": Value::Null, "attempts": 0});+    let Some(hwnd) = hwnd else {+        v["why"] = json!("no window handle for this beat");+        return v;+    };+    for attempt in 0..2 {+        v["attempts"] = json!(attempt + 1);+        let (fh, ft) = front_window();+        v["frontTitle"] = json!(ft);+        v["front"] = json!(fh == hwnd);+        let live = win::title_of(hwnd);+        v["liveTitle"] = json!(live);+        v["title"] = json!(demo::title_matches(&live, expected_title));+        if fh == hwnd {+            break;+        }+        sleep_ms(600);+    }+    let probe = canvas_probe(hwnd);+    v["painted"] = if is_true(&probe, "checked") { json!(is_true(&probe, "rendered")) } else { Value::Null };+    if probe.is_object() {+        let mut c = json!({});+        for k in ["checked", "rendered", "distinctColors", "region"] {+            if let Some(x) = probe.get(k) {+                c[k] = x.clone();+            }+        }+        v["canvas"] = c;+    }+    // "perfectly knowing when a window is done loading" is a measurement with a bound.+    match capture_hash(hwnd) {+        Some(mut prev) => {+            v["stable"] = json!(false);+            let t0 = Instant::now();+            while t0.elapsed() < Duration::from_secs(8) {+                if demo::pending_interrupt() {+                    v["interrupted"] = json!(true);+                    break;+                }+                sleep_ms(800);+                match capture_hash(hwnd) {+                    Some(cur) if cur == prev => {+                        v["stable"] = json!(true);+                        break;+                    }+                    Some(cur) => prev = cur,+                    None => break,+                }+            }+            v["settleMs"] = json!(t0.elapsed().as_millis() as u64);+        }+        None => v["stable"] = Value::Null,+    }+    let ok = is_true(&v, "front") && is_true(&v, "title") && v["painted"] != json!(false);+    v["ok"] = json!(ok);+    v+}++/// The per-step EVIDENCE frame for the web-control page: {step, title, image (base64+/// PNG, at most 640 px wide)}. Only the bridge knows the moment a step completes and+/// which window is its surface.+fn beat_shot(step_label: &str, window_title: &str, hwnd: Option<u64>) -> Option<Value> {+    if window_title.is_empty() && hwnd.is_none() {+        return None;+    }+    let hwnd = hwnd.or_else(|| hwnd_for_title(window_title))?;+    let shot = win::screenshot_hwnd(hwnd, "stepshot", Some(640));+    if !is_true(&shot, "success") {+        return None;+    }+    let path = shot.get("safePath").and_then(Value::as_str).filter(|p| !p.is_empty() && Path::new(p).is_file()).or_else(|| shot.get("fullPath").and_then(Value::as_str))?.to_string();+    if !path.to_lowercase().ends_with(".png") {+        return None;+    }+    let bytes = std::fs::read(&path).ok()?;+    Some(json!({"step": step_label, "title": win::title_of(hwnd), "image": win::b64_encode(&bytes), "path": path}))+}++// ── Prepare: fetch the board, lift the MCU, install it ────────────────────────++#[derive(Clone, Debug, Default)]+struct Prep {+    installed: Vec<String>,+    warnings: Vec<String>,+    board: Option<String>,+    schematic: Option<String>,+    project: Option<String>,+    project_dir: String,+    stem: String,+    focus: BTreeMap<String, (f64, f64)>,+    models3d_attached: Value,+    footprint_was_new: bool,+    footprint_path: Option<String>,+    has3d: bool,+    custom_project: bool,+    part_beats_possible: bool,+    cache_restart: Value,+    warm: Value,+}++impl Prep {+    fn summary(&self) -> Value {+        json!({"installed": self.installed, "warnings": self.warnings, "footprintWasNew": self.footprint_was_new, "cacheRestart": self.cache_restart, "models3dAttached": self.models3d_attached, "focus": self.focus, "customProject": self.custom_project})+    }+    fn titles(&self) -> demo::TourTitles {+        demo::tour_titles(&self.stem)+    }+}++/// Download the wiki board's schematic, board and project into the demo dir (once a day;+/// the page is public, no token needed on the desktop).+fn fetch_board(dir: &Path) -> Result<BTreeMap<&'static str, PathBuf>, String> {+    let mut out = BTreeMap::new();+    for (key, rel) in demo::BOARD_FILES {+        let dst = dir.join(Path::new(rel).file_name().unwrap());+        let fresh = std::fs::metadata(&dst).ok().map(|m| m.len() > 1000 && m.modified().ok().and_then(|t| t.elapsed().ok()).map(|e| e.as_secs() < demo::FETCH_FRESH_SECS).unwrap_or(false)).unwrap_or(false);+        if !fresh {+            let url = demo::WIKI_FILES.replace("{page}", demo::BOARD_PAGE).replace("{path}", rel);+            let data = demo::fetch(&url).map_err(|e| format!("{url}: {e}"))?;+            std::fs::write(&dst, data).map_err(|e| format!("cannot write {}: {e}", dst.display()))?;+        }+        out.insert(*key, dst);+    }+    Ok(out)+}++/// Earliest process start time (epoch seconds) among the live KiCad processes.+fn kicad_started_at() -> Option<u64> {+    native().processes(wm::KICAD_EXES).ok()?.iter().filter_map(|p| p.started_at).min()+}++fn file_mtime(p: &str) -> Option<u64> {+    std::fs::metadata(p).ok()?.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok().map(|d| d.as_secs())+}++/// Fetch the wiki board (or take the caller's project), lift the MCU symbol and footprint+/// into the Adom library, and hand back the paths every beat opens.+fn prepare(info: &KicadInfo, opts: &Opts) -> Prep {+    let mut p = Prep::default();+    let d = demo_dir();+    p.project_dir = norm(&d);+    if let Ok(rd) = std::fs::read_dir(&d) {+        for e in rd.flatten() {+            if e.path().extension().map(|x| x == "lck").unwrap_or(false) {+                let _ = std::fs::remove_file(e.path());+            }+        }+    }+    let (sch_path, pcb_path, pro_path) = if let Some(proj) = &opts.project {+        let pp = PathBuf::from(proj);+        let dir = pp.parent().map(|d| d.to_path_buf()).unwrap_or_default();+        let stem = pp.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();+        p.custom_project = true;+        (dir.join(format!("{stem}.kicad_sch")), dir.join(format!("{stem}.kicad_pcb")), if pp.extension().map(|e| e == "kicad_pro").unwrap_or(false) { pp.clone() } else { dir.join(format!("{stem}.kicad_pro")) })+    } else {+        match fetch_board(&d) {+            Ok(files) => (files["schematic"].clone(), files["board"].clone(), files["project"].clone()),+            Err(e) => {+                p.warnings.push(format!("could not fetch {} from the wiki: {e}", demo::BOARD_PAGE));+                return p;+            }+        }+    };+    p.schematic = Some(norm(&sch_path));+    p.board = Some(norm(&pcb_path));+    p.project = if pro_path.is_file() { Some(norm(&pro_path)) } else { None };+    p.stem = demo::doc_stem(p.board.as_deref(), p.schematic.as_deref());+    let sch_text = std::fs::read_to_string(&sch_path).unwrap_or_default();+    let mut pcb_text = std::fs::read_to_string(&pcb_path).unwrap_or_default();+    if sch_text.is_empty() || pcb_text.is_empty() {+        p.warnings.push(format!("schematic or board unreadable: {} / {}", sch_path.display(), pcb_path.display()));+    }+    // 3D bodies: the wiki copy only (never a user's own project file).+    let base = info.primary().map(|i| i.base_dir.clone()).unwrap_or_default();+    let root = if base.is_empty() { PathBuf::new() } else { PathBuf::from(&base).join("share").join("kicad").join("3dmodels") };+    if !p.custom_project && !pcb_text.is_empty() {+        if root.is_dir() {+            let exists = |rel: &str| root.join(rel).exists();+            let (patched, rep) = demo::attach_standard_models(&pcb_text, &exists);+            let mut rep_json = rep.to_json(&norm(&root));+            if (rep.attached > 0 || rep.dead_dropped > 0) && patched != pcb_text {+                match std::fs::write(&pcb_path, &patched) {+                    Ok(()) => {+                        pcb_text = patched;+                        // pcbnew keeps the OLD copy in memory; close the tour's board and its+                        // viewers so warm_kicad reopens the patched file (arav-rog 2026-09-05).+                        let tt = p.titles();+                        for h in viewers_3d() {+                            close_hwnd(h, "KiCad tour: close the 3D viewer so the board reloads with its 3D bodies");+                        }+                        if let Some(bh) = hwnd_for_title(&tt.board) {+                            close_hwnd(bh, "KiCad tour: close the board so it reloads with its 3D bodies");+                            rep_json["boardReloaded"] = json!(true);+                        }+                    }+                    Err(e) => rep_json["writeError"] = json!(e.to_string()),+                }+            }+            // The open PCB editor holds whatever copy it loaded: if the file on disk is+            // newer than the running pcbnew (patched by this or an earlier tour), reload.+            if rep_json.get("boardReloaded").is_none() {+                if let (Some(started), Some(mtime)) = (kicad_started_at(), file_mtime(&norm(&pcb_path))) {+                    if mtime > started {+                        let tt = p.titles();+                        for h in viewers_3d() {+                            close_hwnd(h, "KiCad tour: close the 3D viewer so the board reloads from disk");+                        }+                        if let Some(bh) = hwnd_for_title(&tt.board) {+                            close_hwnd(bh, "KiCad tour: close the board so it reloads from disk");+                            rep_json["boardReloaded"] = json!("file newer than the running KiCad");+                        }+                    }+                }+            }+            p.models3d_attached = rep_json;+        } else {+            p.models3d_attached = json!({"attached": 0, "skipped": 0, "why": format!("no 3dmodels dir under {base:?}")});+        }+    }+    // The chip: the ESC's U5 by default, or the caller's reference in their own project.+    let (chip_lib_id, chip_fp_id) = if p.custom_project {+        demo::chip_ids_for_ref(&sch_text, &pcb_text, &opts.chip_ref)+    } else {+        (Some(demo::CHIP_LIB_ID.to_string()), Some(demo::CHIP_FOOTPRINT.to_string()))+    };+    p.focus = demo::focus_fractions(&sch_text, &pcb_text, chip_lib_id.as_deref().unwrap_or(demo::CHIP_LIB_ID), &opts.chip_ref);+    let ctx = lib_ctx(info);+    match chip_lib_id.as_deref().ok_or_else(|| format!("no symbol with reference {} in the schematic", opts.chip_ref)).and_then(|id| demo::extract_symbol(&sch_text, id)) {+        Ok(sym) => {+            let sym_file = d.join(format!("{}.kicad_sym", demo::IC_SYM));+            match std::fs::write(&sym_file, sym) {+                Ok(()) => {+                    let r = install::install_symbol(&ctx, info.installed, &json!({"filePath": norm(&sym_file), "symbolName": demo::IC_SYM, "quietInstall": true}));+                    if is_true(&r, "success") {+                        p.installed.push(demo::IC_SYM.into());+                    } else {+                        p.warnings.push(format!("{}: {}", demo::IC_SYM, r["error"].as_str().unwrap_or("install failed")));+                    }+                }+                Err(e) => p.warnings.push(format!("{}: {e}", demo::IC_SYM)),+            }+        }+        Err(e) => p.warnings.push(format!("{}: {e}", demo::IC_SYM)),+    }+    match chip_fp_id.as_deref().ok_or_else(|| format!("no footprint with reference {} on the board", opts.chip_ref)).and_then(|id| demo::extract_footprint(&pcb_text, id, &opts.chip_ref, demo::IC_FP)) {+        Ok(fp) => {+            let fp_file = d.join(format!("{}.kicad_mod", demo::IC_FP));+            match std::fs::write(&fp_file, fp) {+                Ok(()) => {+                    let r = install::install_footprint(&ctx, info.installed, &json!({"filePath": norm(&fp_file), "footprintName": demo::IC_FP, "quietInstall": true}));+                    if is_true(&r, "success") {+                        p.installed.push(demo::IC_FP.into());+                        p.footprint_was_new = !is_true(&r, "existedBefore");+                        p.footprint_path = r.get("installedPath").and_then(Value::as_str).map(str::to_string);+                    } else {+                        p.warnings.push(format!("{}: {}", demo::IC_FP, r["error"].as_str().unwrap_or("install failed")));+                    }+                }+                Err(e) => p.warnings.push(format!("{}: {e}", demo::IC_FP)),+            }+        }+        Err(e) => p.warnings.push(format!("{}: {e}", demo::IC_FP)),+    }+    p.part_beats_possible = p.installed.len() == 2;+    p.has3d = [".wrl", ".step"].iter().any(|ext| root.join("Package_QFP.3dshapes").join(format!("LQFP-48_7x7mm_P0.5mm{ext}")).exists());+    if !p.has3d {+        p.warnings.push("KiCad's bundled 3D model library is not installed on this machine: the 3D views show pads and board, not component bodies.".into());+    }+    p+}++/// KiCad caches footprint libraries per PROCESS (#47). On a first-run box the tour's+/// footprint is brand new, and a running KiCad cannot see it until it restarts. Do it here,+/// under the "preparing" caption, before any window is on screen. Only a KiCad the bridge+/// owns is ever closed; a user-launched one (etiquette ledger) is left alone and the+/// footprint beat falls back to the editor's tree refresh.+fn restart_for_new_footprint(state: &mut State, prep: &Prep) -> Value {+    let mut needs = prep.footprint_was_new;+    let mut why = "footprint is new";+    if let (Some(fp), Some(started)) = (&prep.footprint_path, kicad_started_at()) {+        if file_mtime(fp).map(|m| m > started).unwrap_or(false) {+            needs = true;+            why = "footprint file is newer than the running KiCad";+        }+    }+    if !needs {+        return json!({"restarted": false, "reason": "footprint already known to KiCad"});+    }+    win::invalidate();+    let wins = win::kicad_windows();+    if wins.is_empty() {+        return json!({"restarted": false, "reason": "KiCad not running; a fresh launch sees the new footprint"});+    }+    let foreign: Vec<String> = wins.iter().filter(|w| native().etiquette_owned(w.hwnd) == Some(false)).map(|w| w.title.clone()).collect();+    if !foreign.is_empty() {+        return json!({"restarted": false, "reason": "user-owned KiCad present", "userWindows": foreign.iter().take(4).collect::<Vec<_>>(), "why": why});+    }+    if wins.iter().any(|w| native().etiquette_owned(w.hwnd).is_none()) {+        // No ownership ledger on this build: never close a KiCad that might be the user's.+        // The footprint beat refreshes the editor's library tree instead (#51).+        return json!({"restarted": false, "reason": "ownership unknown (no etiquette ledger): leaving the running KiCad alone; the footprint beat refreshes the editor's library tree instead", "why": why});+    }+    caption("KiCad tour 1/7: restarting KiCad so it sees the new footprint", 40000);+    restore_bounds("restart");+    let closed = win::dispatch(state, "kicad_close", &json!({})).unwrap_or_else(|| json!({}));+    adopt_identity();+    let t0 = Instant::now();+    while t0.elapsed() < Duration::from_secs(20) {+        win::invalidate();+        if win::kicad_windows().is_empty() {+            break;+        }+        sleep_ms(1000);+    }+    win::invalidate();+    let gone = win::kicad_windows().is_empty();+    json!({"restarted": gone, "why": why, "close": closed.get("output").cloned().unwrap_or(closed.get("error").cloned().unwrap_or(Value::Null)), "errorCode": closed.get("errorCode").cloned().unwrap_or(Value::Null),+        "reason": if gone { "closed the bridge's KiCad; the warm-up relaunches it" } else { "KiCad did not close (a save prompt or a busy editor); the footprint beat will refresh the editor's tree instead" }})+}++/// Get KiCad up and settled BEFORE the tour's first beat. The demo is by definition the+/// cold-start case. Launch the PROJECT MANAGER first, then open BOTH documents up front:+/// the Symbol Editor is hosted inside eeschema and the Footprint Editor inside pcbnew, so+/// every later beat opens from a warm host frame's own menu. Windows still open in the+/// background; this only costs a few seconds.+fn warm_kicad(state: &mut State, prep: &Prep) -> Value {+    let tt = prep.titles();+    if hwnd_for_title(&tt.schematic).is_some() && hwnd_for_title(&tt.board).is_some() {+        return json!({"warmed": true, "eeschema": true, "pcbnew": true, "reused": true});+    }+    let (Some(sch), Some(brd)) = (&prep.schematic, &prep.board) else {+        return json!({"warmed": false, "eeschema": false, "pcbnew": false, "why": "no schematic or board to open"});+    };+    let mut launch_args = json!({"waitSeconds": 15});+    if let Some(p) = &prep.project {+        launch_args["project"] = json!(p);+    }+    let launched = win::launch(state, &launch_args);+    let s = win::open_file(state, &json!({"filePath": sch}), WindowKind::SchematicEditor);+    sleep_ms(6000);+    let b = win::open_file(state, &json!({"filePath": brd}), WindowKind::PcbEditor);+    sleep_ms(8000); // pcbnew is the slower of the two to paint+    json!({"warmed": is_true(&s, "success") && is_true(&b, "success"), "eeschema": is_true(&s, "success"), "pcbnew": is_true(&b, "success"),+        "raw": {"launch": {"success": launched["success"], "alreadyRunning": launched["alreadyRunning"]}, "schematic": {"success": s["success"], "error": s["error"], "hwnd": s["hwnd"]}, "board": {"success": b["success"], "error": b["error"], "hwnd": b["hwnd"]}}})+}++// ── One beat ──────────────────────────────────────────────────────────────────++fn record(ok: bool, window: &str, hwnd: Option<u64>, beat: &Beat, has3d: bool, raw: Value) -> Value {+    json!({"ok": ok, "window": window, "hwnd": hwnd, "title": beat.title, "say": demo::say(beat.step, has3d), "pointOut": demo::point_out(beat.step, has3d), "raw": raw, "step": beat.step})+}++/// Run one tour step. Returns narration-ready info, never fails the caller.+fn run_step(state: &mut State, beat: &'static Beat, prep: &Prep) -> Value {+    let tt = prep.titles();+    let has3d = prep.has3d;+    match beat.step {+        "symbol" => {+            let r = win::open_symbol_editor(state, &json!({"libraryName": demo::DEMO_LIB, "symbolName": demo::IC_SYM}));+            // open() succeeding is NOT the same as the part being on the canvas: a "[no+            // symbol loaded]" editor shows an empty sheet and the demo would narrate over a+            // blank window (caught live on AdomLapper 2026-08-08).+            let mut loaded = false;+            if is_true(&r, "success") {+                for _ in 0..3 {+                    win::invalidate();+                    if let Some(w) = win::kicad_windows().iter().find(|w| w.title.to_lowercase().contains("symbol editor")) {+                        if !w.title.to_lowercase().contains("no symbol loaded") {+                            loaded = true;+                            break;+                        }+                    }+                    sleep_ms(2000);+                }+            }+            let h = hwnd_for_title(&tt.symbol);+            record(is_true(&r, "success") && loaded, &tt.symbol, h, beat, has3d, r)+        }+        "footprint" => {+            // The Footprint Editor is hosted by pcbnew: make sure the board is open, then try+            // twice (a fresh editor's tree can miss a footprint installed after it started:+            // refresh the tree between attempts).+            if hwnd_for_title(&tt.board).is_none() {+                let _ = warm_kicad(state, prep);+            }+            let mut r = json!({});+            let mut fh = None;+            for attempt in 0..2 {+                r = win::open_footprint_editor(state, &json!({"libraryName": demo::DEMO_LIB, "footprintName": demo::IC_FP}));+                fh = hwnd_for_title(&tt.footprint);+                if is_true(&r, "success") && fh.is_some() {+                    break;+                }+                if demo::exit_requested() {+                    break;+                }+                if attempt == 0 {+                    let refreshed = win::dispatch(state, "kicad_rescan_libraries", &json!({"footprints": [format!("{}:{}", demo::DEMO_LIB, demo::IC_FP)]}));+                    adopt_identity();+                    r["treeRefresh"] = json!(refreshed.map(|v| json!({"success": v["success"], "verified": v["verified"], "method": v["method"]})).unwrap_or(Value::Null));+                    let wait = if r.get("errorCode").and_then(Value::as_str) == Some("kicad_restarted_retry") { r.get("retryAfterMs").and_then(Value::as_u64).unwrap_or(4000).min(45000) } else { 4000 };+                    sleep_ms(wait);+                }+            }+            record(is_true(&r, "success") && fh.is_some(), &tt.footprint, fh, beat, has3d, r)+        }+        "footprint3d" => {+            sleep_ms(2000);+            // KiCad's 3D viewers are UNOWNED top-level windows (measured 2026-09-03), so+            // "owned by the Footprint Editor" never matches. The viewer that APPEARS after+            // this open is the chip's; remember it so the board beat can close it first.+            let before = viewers_3d();+            let fh = hwnd_for_title(&tt.footprint);+            let mut a = json!({"editor": "footprint"});+            if let Some(h) = fh {+                a["fpHwnd"] = json!(h);+            }+            let r = win::open_3d_viewer(&a);+            let v = new_window(&before, viewers_3d, Duration::from_secs(8));+            demo::job_update(json!({"chipViewer": v}));+            record(is_true(&r, "success") && v.is_some(), demo::VIEWER_TITLE, v, beat, has3d, r)+        }+        "schematic" => {+            let existing = hwnd_for_title(&tt.schematic);+            let r = match existing {+                Some(_) => json!({"success": true, "reused": true}),+                None => match &prep.schematic {+                    Some(p) => win::open_file(state, &json!({"filePath": p}), WindowKind::SchematicEditor),+                    None => json!({"success": false, "error": "no schematic to open"}),+                },+            };+            let h = existing.or_else(|| new_window(&[], || hwnd_for_title(&tt.schematic).into_iter().collect(), Duration::from_secs(20)));+            record(is_true(&r, "success") && h.is_some(), &tt.schematic, h, beat, has3d, r)+        }+        "board" => {+            let existing = hwnd_for_title(&tt.board);+            let r = match existing {+                Some(_) => json!({"success": true, "reused": true}),+                None => match &prep.board {+                    Some(p) => win::open_file(state, &json!({"filePath": p}), WindowKind::PcbEditor),+                    None => json!({"success": false, "error": "no board to open"}),+                },+            };+            let h = existing.or_else(|| new_window(&[], || hwnd_for_title(&tt.board).into_iter().collect(), Duration::from_secs(25)));+            record(is_true(&r, "success") && h.is_some(), &tt.board, h, beat, has3d, r)+        }+        "board3d" => {+            // Wait for the PCB editor to actually be findable: the board beat can return+            // while pcbnew is mid-transition (title swap / repaint), and the 3D open's+            // precheck then misses it (5/6 flake, 2026-08-16).+            for _ in 0..15 {+                if hwnd_for_title(&tt.board).is_some() {+                    break;+                }+                sleep_ms(1000);+            }+            sleep_ms(2000);+            if let Some(chip) = demo::job_get("chipViewer").as_u64() {+                close_hwnd(chip, "KiCad tour: close the chip's 3D viewer before showing the board's"); // must not win the title match+                sleep_ms(1500);+            }+            let before = viewers_3d();+            let bh = hwnd_for_title(&tt.board);+            let mut a = json!({"editor": "pcb"});+            if let Some(h) = bh {+                a["pcbHwnd"] = json!(h);+            }+            let r = win::open_3d_viewer(&a);+            let v = new_window(&before, viewers_3d, Duration::from_secs(12)).or_else(|| viewers_3d().first().copied()); // fallback: the topmost viewer (the one KiCad just raised)+            record(is_true(&r, "success") && v.is_some(), demo::VIEWER_TITLE, v, beat, has3d, r)+        }+        other => json!({"ok": false, "error": format!("unknown step '{other}'"), "validSteps": demo::STEPS, "step": other}),+    }+}++// ── Motion (demo_motion.py) ───────────────────────────────────────────────────++fn dwell(ms: u64, speed: f64) {+    sleep_ms(((ms as f64) / speed.max(0.25)) as u64);+}++/// Input only goes to OUR window while it is the foreground window; GetForegroundWindow can+/// lag ab's z-order by a beat right after a raise (John's laptop, 2026-09-03), so give the+/// OS up to 1.5 s to agree before calling it a user switch.+fn gate(hwnd: u64, name: &str, rep: &mut Value) -> bool {+    if demo::pending_interrupt() {+        rep["aborted"] = json!(format!("stop requested before {name}"));+        return false;+    }+    for _ in 0..6 {+        if is_front(hwnd) {+            return true;+        }+        sleep_ms(250);+    }+    rep["aborted"] = json!(format!("window not in front before {name} (user switched away)"));+    false+}++fn gesture(hwnd: u64, g: &Gesture, speed: f64) -> Result<(), String> {+    match g {+        Gesture::Key(vk) => native().tour_key(hwnd, *vk),+        Gesture::Wheel { clicks, x, y } => {+            native().tour_mouse_move(hwnd, *x, *y)?;+            sleep_ms(50);+            for _ in 0..clicks.abs() {+                native().tour_wheel(hwnd, if *clicks > 0 { 1 } else { -1 })?;+                dwell(160, speed);+            }+            Ok(())+        }+        Gesture::Drag { button, x0, y0, x1, y1, steps, dt_ms } => {+            native().tour_mouse_move(hwnd, *x0, *y0)?;+            sleep_ms(60);+            native().tour_mouse_button(hwnd, button, true)?;+            sleep_ms(50);+            for i in 1..=*steps {+                let x = x0 + (x1 - x0) * i as i32 / *steps as i32;+                let y = y0 + (y1 - y0) * i as i32 / *steps as i32;+                native().tour_mouse_move(hwnd, x, y)?;+                dwell(*dt_ms, speed);+            }+            native().tour_mouse_button(hwnd, button, false)?;+            sleep_ms(50);+            Ok(())+        }+    }+}++/// Deterministic 3D motion through KiCad's own View menu (WM_COMMAND, no mouse, no+/// foreground needed). None when the menu could not be resolved (caller falls back).+fn choreograph_3d_menu(hwnd: u64, speed: f64) -> Option<Value> {+    let items = native().menu_tree(hwnd).ok()?;+    let leaves: Vec<(String, u32)> = items.iter().filter_map(|it| it.path.last().map(|l| (l.clone(), it.id))).collect();+    let ids = demo::menu_ids_from(&leaves);+    let plan = demo::plan_3d_menu(&ids)?;+    let mut rep = json!({"kind": "3d", "method": "menu", "gestures": 0, "aborted": Value::Null, "ids": ids});+    let mut gestures = 0u64;+    for step in plan {+        for _ in 0..step.reps {+            if demo::pending_interrupt() {+                rep["aborted"] = json!(format!("stop requested during {}", step.name));+                rep["gestures"] = json!(gestures);+                return Some(rep);+            }+            if native().menu_invoke(hwnd, step.id).is_err() {+                rep["aborted"] = json!(format!("menu command refused during {}", step.name));+                rep["gestures"] = json!(gestures);+                return Some(rep);+            }+            gestures += 1;+            dwell(step.dwell_ms, speed);+        }+        dwell(400, speed);+        // Framing check (John, 2026-09-05): after every step look at the canvas.+        if step.name != "extents" {+            if let Some(m) = canvas_measure(hwnd) {+                if let Some(key) = demo::framing_correction(&m) {+                    if let Some(id) = ids.get(key) {+                        let _ = native().menu_invoke(hwnd, *id);+                        dwell(500, speed);+                        let note = if key == "zoom_out" { format!("{}: clipped {:?} -> zoom out", step.name, m.touches) } else { format!("{}: coverage {} -> zoom in", step.name, m.coverage) };+                        if rep["framed"].is_null() {+                            rep["framed"] = json!([]);+                        }+                        rep["framed"].as_array_mut().unwrap().push(json!(note));+                    }+                }+                rep["lastCoverage"] = json!(m.coverage);+            }+        }+    }+    rep["gestures"] = json!(gestures);+    Some(rep)+}++/// Run the beat's motion on `hwnd`. Returns a report; never fails the beat. Stops the+/// instant the window is not in front or a press is pending.+fn choreograph(hwnd: u64, kind: MotionKind, focus: Option<(f64, f64)>, zoom: (i32, i32), speed: f64) -> Value {+    let mut rep = json!({"kind": if kind == MotionKind::ThreeD { "3d" } else { "2d" }, "gestures": 0, "aborted": Value::Null});+    if kind == MotionKind::ThreeD {+        dwell(1200, speed); // let the STEP bodies load before the first turn+        if let Some(r) = choreograph_3d_menu(hwnd, speed) {+            return r;+        }+        rep["menuError"] = json!("the viewer's View menu could not be resolved; using the mouse");+    }+    let Some(rect) = window_rect(hwnd) else {+        rep["aborted"] = json!("no window rect");+        return rep;+    };+    let (cx, cy) = demo::canvas_center(rect);+    let (w, h) = (rect.2, rect.3);+    let plan: Vec<(&'static str, Gesture)> = if kind == MotionKind::ThreeD {+        demo::plan_3d_mouse(cx, cy, w, h)+    } else {+        // Extents first, then measure where the drawing sits and aim at the featured part.+        if !gate(hwnd, "extents", &mut rep) {+            return rep;+        }+        if let Err(e) = native().tour_key(hwnd, demo::VK_HOME) {+            rep["error"] = json!(e);+            return rep;+        }+        rep["gestures"] = json!(1);+        dwell(700, speed);+        let mut aim = (cx, cy);+        if let Some(f) = focus {+            if let Some(m) = canvas_measure(hwnd) {+                aim = demo::aim_point(m.bbox, Some(f), (cx, cy));+                rep["aim"] = json!({"x": aim.0, "y": aim.1, "bbox": m.bbox, "coverage": m.coverage});+            }+        }+        demo::plan_2d(cx, cy, w, h, aim, zoom)+    };+    let mut gestures = rep["gestures"].as_u64().unwrap_or(0);+    for (name, g) in plan {+        if !gate(hwnd, name, &mut rep) {+            break;+        }+        if let Err(e) = gesture(hwnd, &g, speed) {+            rep["error"] = json!(format!("{name}: {e}"));+            break;+        }+        gestures += 1;+        dwell(450, speed);+        // After every 2D gesture that can empty the view, look at the canvas; a blank one+        // means we panned off the content: recenter on extents.+        if kind == MotionKind::TwoD && (name.starts_with("pan") || name.starts_with("zoom-in") || name == "centre") && !canvas_rendered(hwnd) {+            let _ = native().tour_key(hwnd, demo::VK_HOME);+            dwell(450, speed);+            if rep["recentered"].is_null() {+                rep["recentered"] = json!([]);+            }+            rep["recentered"].as_array_mut().unwrap().push(json!(name));+        }+    }+    rep["gestures"] = json!(gestures);+    rep+}++// ── The background job ────────────────────────────────────────────────────────++#[derive(Clone, Debug)]+struct Opts {+    muted: bool,+    record: bool,+    speed: f64,+    steps: Option<Vec<String>>,+    project: Option<String>,+    chip_ref: String,+}++impl Opts {+    fn from_args(args: &Value) -> Result<Opts, Value> {+        let narrate = args.get("narrate").and_then(Value::as_bool).unwrap_or(true);+        let muted = is_true(args, "muted") || !narrate;+        let speed = args.get("speed").and_then(Value::as_f64).unwrap_or(1.0);+        if !(0.25..=4.0).contains(&speed) {+            return Err(fail("bad_arg", format!("speed {speed} is out of range"), "speed is a multiplier on the motion and narration pacing, 0.25 to 4.0 (1.0 = as recorded)."));+        }+        let steps = match args.get("steps") {+            Some(Value::Array(a)) => Some(a.iter().filter_map(Value::as_str).map(|s| s.trim().to_lowercase()).collect::<Vec<_>>()),+            Some(Value::String(s)) => Some(s.split(',').map(|x| x.trim().to_lowercase()).filter(|x| !x.is_empty()).collect()),+            _ => None,+        };+        if let Some(s) = &steps {+            demo::beat_plan(Some(s)).map_err(|e| json!({"success": false, "errorCode": "bad_arg", "error": e, "data": {"validSteps": demo::STEPS}}))?;+        }+        let project = arg_str(args, "project").map(str::to_string);+        if let Some(p) = &project {+            if !Path::new(p).exists() {+                return Err(fail("project_not_found", format!("Project not found: {p}"), "Absolute path to a .kicad_pro (its .kicad_sch and .kicad_pcb siblings are toured); omit project for the ESC G431 from the wiki."));+            }+        }+        Ok(Opts { muted, record: is_true(args, "record"), speed, steps, project, chip_ref: arg_str(args, "chipRef").unwrap_or(demo::CHIP_REF).to_string() })+    }+    fn to_json(&self) -> Value {+        json!({"muted": self.muted, "record": self.record, "speed": self.speed, "steps": self.steps, "project": self.project, "chipRef": self.chip_ref})+    }+}++fn job_step(label: &str, percent: i64, t0: Instant) {+    demo::job_update(json!({"stepLabel": label, "percent": percent, "elapsedSec": t0.elapsed().as_secs()}));+    let caller = demo::job_get("callerName").as_str().unwrap_or(progress::ANONYMOUS).to_string();+    progress::step_for(&caller, JOB_PHASE, label, Some(percent), None);+}++/// The job's own phase in the progress registry (`kicad.demo_tour`). Not `demo`: every+/// `kicad_demo {progress:true}` poll begins and retires a `demo` frame of its own at the+/// dispatch chokepoint, and a shared key would retire the job's frame with it.+const JOB_PHASE: &str = "demo_tour";++/// Thread body: the six beats with live progress accounting.+fn run_job(info: KicadInfo, opts: Opts) {+    let mut state = State::new();+    adopt_identity();+    let t0 = Instant::now();+    let caller = demo::job_get("callerName").as_str().unwrap_or(progress::ANONYMOUS).to_string();+    let start_kind = progress::start_kind(crate::verbs_show::kicad_running());+    progress::begin_phase(JOB_PHASE, start_kind, "Starting the tour", &caller);+    let plan = demo::beat_plan(opts.steps.as_deref()).unwrap_or_else(|_| demo::BEATS.iter().collect());+    let stops = demo::panel_stops(&plan);+    let count = stops.len();+    let total_est: u64 = demo::beat_est("prepare") + plan.iter().map(|b| demo::beat_est(b.step)).sum::<u64>();+    let mut done_weight = 0u64;+    let mut results: Vec<Value> = Vec::new();+    let mut exited = false;+    let mut recording: Option<ab::Recording> = None;+    // A mute, a queued press or a failed panel show from an EARLIER tour must not steer+    // this one; ab keeps undrained presses for a panel id across shows.+    demo::reset_for_run(opts.muted);+    demo::job_update(json!({"muted": opts.muted, "muteSource": "start"}));+    drain_stale_presses();+    restore_bounds("leftovers"); // a crashed earlier tour never leaves windows moved+    close_stale_tour_windows();+    if opts.record {+        match ab::desktop_record_start("Recording the KiCad bridge tour the user started, with its narration", 30, total_est * 1000 + 180_000) {+            Ok(r) => {+                demo::job_update(json!({"recording": {"recordingId": r.recording_id, "path": r.path, "encoder": r.encoder, "hardwareEncoder": r.hardware_encoder, "audio": r.audio, "startedAt": demo::epoch_f64()}}));+                recording = Some(r);+            }+            Err(e) => demo::job_update(json!({"recording": {"error": e.message()}})),+        }+    }+    panel_show(0, Some("playing"), &stops);+    caption(&demo::caption_a(1, count, demo::PREPARE_CAPTION), demo::PREPARE_CAPTION_MS);+    demo::play_narration("prepare");+    let mut prep = prepare(&info, &opts);+    if prep.custom_project && !demo::is_muted() {+        // The clips describe the ESC; a custom project runs without them.+        demo::apply_mute(true, "custom-project");+        prep.warnings.push("narration muted: the pre-rendered clips describe the ESC G431, not this project".into());+    }+    demo::job_update(json!({"step": "prepare"}));+    job_step("Preparing the sample project", (100 * demo::beat_est("prepare") / 2 / total_est) as i64, t0);+    {+        let _gui = GUI_LOCK.lock().unwrap_or_else(|e| e.into_inner());+        prep.cache_restart = restart_for_new_footprint(&mut state, &prep);+        prep.warm = warm_kicad(&mut state, &prep);+    }+    done_weight += demo::beat_est("prepare");+    if let Some(shot) = beat_shot("Preparing the sample project", "KiCad", None) {+        demo::job_push("pendingShots", shot, 20);+    }+    post_transport(&format!("Running - beat 1 of {}. Buttons work from the toast or the Action Center.", plan.len()), false);+    let mut i = 0usize;+    while i < plan.len() {+        adopt_identity();+        // Apply any pending transport command BETWEEN beats; while paused, keep polling.+        let mut cmd = drain_ctrl(&stops);+        if cmd == Some(Cmd::Pause) {+            demo::stop_narration();+            panel_show(i.max(1), Some("paused"), &stops); // the beat on screen, not the next+            caption(&format!("KiCad tour paused{}", if demo::panel().ok == Some(true) { "" } else { " - use the toast buttons or the dashboard to continue" }), 300000);+            post_transport(&format!("Paused before beat {} of {}.", i + 1, plan.len()), true);+            demo::job_update(json!({"stepLabel": "Paused", "paused": true}));+            loop {+                sleep_ms(1000);+                cmd = drain_ctrl(&stops);+                if matches!(cmd, Some(Cmd::Play | Cmd::Next | Cmd::Prev | Cmd::Exit)) {+                    break;+                }+            }+            demo::job_update(json!({"paused": false}));+            post_transport(&format!("Running - beat {} of {}.", i + 1, plan.len()), false);+        }+        let (ni, ex) = demo::apply_between_beats(i, cmd);+        if ex {+            exited = true;+            break;+        }+        if ni < i {+            results.truncate(ni);+            i = ni;+        }+        let beat = plan[i];+        let beat_t0 = demo::epoch_f64(); // box epoch: lets a recording be synced to the beats afterwards+        demo::job_update(json!({"step": beat.step, "beatIndex": i + 1, "beatCount": plan.len()}));+        job_step(&format!("Opening {}", beat.step), demo::percent_for(done_weight, total_est), t0);+        panel_show(i + 1, Some("playing"), &stops);+        caption(&demo::caption_a(i + 2, count, beat.caption_a), beat.caption_a_ms);+        let clip_a = format!("{}-a", beat.step);+        demo::play_narration(if demo::narration_file(&clip_a).is_some() { &clip_a } else { beat.step }); // part A: loading+        let mut r = {+            let _gui = GUI_LOCK.lock().unwrap_or_else(|e| e.into_inner());+            let mut r = run_step(&mut state, beat, &prep);+            if !is_true(&r, "ok") && !demo::exit_requested() {+                // 10.0.5 regression (found live 2026-08-16): a modal dialog at editor-open+                // stalled the first beats. Sweep before retrying, and log what blocked.+                if let Some(swept) = win::dispatch(&mut state, "kicad_dismiss_dialogs", &json!({"all": true, "screenshot": false})) {+                    if swept.get("dismissed").and_then(Value::as_array).map(|a| !a.is_empty()).unwrap_or(false) {+                        eprintln!("[demo] beat '{}' blocked by dialog(s): {}", beat.step, swept["dismissed"].to_string().chars().take(300).collect::<String>());+                    }+                }+                adopt_identity();+                sleep_ms(3000);+                r = run_step(&mut state, beat, &prep);+                r["retried"] = json!(true);+            }+            adopt_identity();+            r+        };+        r["startedAt"] = json!(beat_t0);+        let part_a_at = demo::narr().started_at;+        r["partAAt"] = json!(part_a_at);+        r["narrationAt"] = json!(part_a_at);+        if !is_true(&r, "ok") {+            eprintln!("[demo] beat '{}' FAILED: raw={}", beat.step, r["raw"].to_string().chars().take(400).collect::<String>());+        }+        if demo::exit_requested() {+            results.push(r);+            exited = true;+            break;+        }+        if is_true(&r, "ok") {+            let _gui = GUI_LOCK.lock().unwrap_or_else(|e| e.into_inner());+            let window = r["window"].as_str().unwrap_or("").to_string();+            let target = r["hwnd"].as_u64().or_else(|| hwnd_for_title(&window));+            if let Some(h) = target {+                r["placed"] = place_window(h, &window);+            }+            r["foregrounded"] = json!(foreground_once(&window, target));+            // MEASURE before moving: the right window, in front, painted, settled.+            let verified = verify_beat(target, &window);+            demo::job_update(json!({"verified": verified}));+            r["verified"] = verified.clone();+            if !is_true(&verified, "ok") {+                r["motion"] = json!({"skipped": format!("not verified: front={} title={} painted={} (top window: {})", verified["front"], verified["title"], verified["painted"], verified["frontTitle"].as_str().unwrap_or(""))});+            } else {+                // Part B: the window is verified on screen; let part A finish (lock released:+                // nothing touches a window while the line plays), then the explaining line+                // and the motion together.+                drop(_gui);+                demo::wait_narration(Duration::from_secs_f64(12.0 / opts.speed));+                let _gui = GUI_LOCK.lock().unwrap_or_else(|e| e.into_inner());+                r["partBAt"] = json!(demo::epoch_f64());+                caption(beat.caption_b, 25000);+                let clip_b = format!("{}-b", beat.step);+                if demo::narration_file(&clip_b).is_some() {+                    demo::play_narration(&clip_b);+                }+                let focus = prep.focus.get(beat.step).copied();+                r["motion"] = match target {+                    Some(h) => choreograph(h, beat.motion, focus, beat.zoom, opts.speed),+                    None => json!({"aborted": "no hwnd"}),+                };+            }+        }+        if demo::exit_requested() {+            results.push(r);+            exited = true;+            break;+        }+        if let Some(shot) = beat_shot(&format!("Opening {}", beat.step), r["window"].as_str().unwrap_or(""), r["hwnd"].as_u64()) {+            demo::job_push("pendingShots", shot, 20);+        }+        done_weight += demo::beat_est(beat.step);+        // Sync contract (dash-demo): a step advances at max(action done, audio done); wait+        // out the narration, bounded, honouring exit and keeping a press for the boundary.+        for _ in 0..40 {+            if !demo::narration_playing() {+                break;+            }+            let c = drain_ctrl(&stops);+            if c == Some(Cmd::Exit) || demo::exit_requested() {+                exited = true;+                break;+            }+            if let Some(c) = c {+                demo::ctrl().push_front(c);+                break;+            }+            sleep_ms(500);+        }+        r["partBEndAt"] = json!(demo::epoch_f64());+        results.push(r);+        if exited {+            break;+        }+        i += 1;+    }+    // Final payload = the kicad_state shape: every window, base64.+    adopt_identity();+    demo::stop_narration();+    panel_hide();+    let state_payload = if exited { Value::Null } else {+        let _gui = GUI_LOCK.lock().unwrap_or_else(|e| e.into_inner());+        win::dispatch(&mut state, "kicad_state", &json!({"maxWidth": 800})).unwrap_or_else(|| json!({"success": false, "error": "kicad_state unavailable"}))+    };+    adopt_identity();+    let restored = restore_bounds("end");+    demo::job_update(json!({"windowsRestored": restored, "prepSummary": prep.summary()}));+    let _ = ab::notify_dismiss(demo::CTL_TOAST_ID);+    if let Some(rec) = recording {+        let stopped = ab::desktop_record_stop(&rec.recording_id);+        let mut r = demo::job_get("recording");+        match stopped {+            Ok(v) => {+                if let Some(o) = v.as_object() {+                    for (k, x) in o {+                        r[k] = x.clone();+                    }+                }+                r["stoppedAt"] = json!(demo::epoch_f64());+            }+            Err(e) => r["stopError"] = json!(e.message()),+        }+        demo::job_update(json!({"recording": r}));+    }+    let elapsed = t0.elapsed().as_secs_f64();+    progress::end_phase(JOB_PHASE, &caller);+    let result = demo::final_result(&results, plan.len(), exited, state_payload);+    if exited {+        caption("KiCad tour stopped", 4000);+        demo::job_update(json!({"active": false, "done": true, "exited": true, "percent": 100, "elapsedSec": elapsed.round(), "stepLabel": "Stopped", "result": result}));+        return;+    }+    progress::record(JOB_PHASE, elapsed, start_kind);+    let ok_ct = result["openedBeats"].as_u64().unwrap_or(0);+    demo::play_narration("done");+    caption(&format!("KiCad tour done: {ok_ct} of {} views of the {}, each brought to you once", plan.len(), demo::BOARD_NAME), 6000);+    demo::job_update(json!({"active": false, "done": true, "percent": 100, "elapsedSec": elapsed.round(), "stepLabel": "Done", "result": result}));+}++// ── The verb ──────────────────────────────────────────────────────────────────++fn handle_demo(state: &mut State, args: &Value) -> Value {+    // Transport (John, 2026-08-24): the dashboard's buttons land here; ab's panel lands in+    // panel_event. Same record, same ack caption, same FIFO.+    if let Some(control) = arg_str(args, "control") {+        let cmd = control.trim().to_lowercase();+        if !["play", "pause", "next", "prev", "exit", "mute", "unmute"].contains(&cmd.as_str()) {+            return json!({"success": false, "error": format!("unknown control '{cmd}'"), "_hint": "Controls: play, pause, next, prev, exit, mute, unmute."});+        }+        if cmd == "mute" || cmd == "unmute" {+            demo::apply_mute(cmd == "mute", "dashboard");+            if demo::job_active() {+                caption_ack(demo::ack_text(&cmd).unwrap_or(""));+            }+            return json!({"success": true, "control": cmd, "muted": demo::is_muted()});+        }+        if !demo::job_active() {+            return json!({"success": false, "error": "no demo job is running", "_hint": "Start one with kicad_demo {\"all\":true,\"background\":true}."});+        }+        let at = json!(demo::epoch_secs());+        record_press(&cmd, Some(&at), "dashboard");+        caption_ack(demo::ack_text(&cmd).unwrap_or(""));+        let c = Cmd::parse(&cmd).unwrap();+        if matches!(c, Cmd::Pause | Cmd::Exit | Cmd::Next | Cmd::Prev) {+            demo::stop_narration();+        }+        if c == Cmd::Exit {+            demo::set_exit(true);+            caption_clear();+            demo::job_update(json!({"stopping": true, "stepLabel": "Closing the demo tour"}));+        }+        demo::set_control(c);+        demo::job_update(json!({"lastPress": {"button": cmd, "at": at, "via": "dashboard"}}));+        return json!({"success": true, "control": cmd, "_hint": "Honoured at the running beat's next gesture (a window mid-open finishes first). 'exit' stops the tour, silences narration and clears the caption."});+    }+    if is_true(args, "layoutProbe") {+        let raw = ab::desktop_list_monitors("KiCad tour layout probe");+        return json!({"success": true, "raw": raw.as_ref().ok().cloned(), "primary": primary_monitor(), "error": raw.err().map(|e| e.message()).or_else(|| PRIMARY_ERR.lock().ok().and_then(|g| g.clone()))});+    }+    // ---- progress poll (cheap, no side effects) ----+    if is_true(args, "progress") {+        let snap = demo::job_snapshot();+        if snap.is_empty() || (!snap.get("active").and_then(Value::as_bool).unwrap_or(false) && !snap.contains_key("done")) {+            return json!({"success": false, "error": "no demo job has been started", "_hint": "Start one with kicad_demo {\"all\":true,\"background\":true}."});+        }+        let shot = demo::job_pop_shot();+        return demo::progress_reply(&snap, shot);+    }+    let info = state.kicad_info();+    // ---- background start ----+    if is_true(args, "all") && is_true(args, "background") {+        let opts = match Opts::from_args(args) {+            Ok(o) => o,+            Err(e) => return e,+        };+        if !info.installed {+            return json!({"success": false, "error": "KiCad is not installed", "_hint": "Foreground kicad_demo {} offers the install flow; run that first."});+        }+        let plan = demo::beat_plan(opts.steps.as_deref()).unwrap_or_default();+        let est = demo::beat_est("prepare") + plan.iter().map(|b| demo::beat_est(b.step)).sum::<u64>();+        let job_id = match demo::job_start(opts.muted, est, plan.len(), caller_json(), opts.to_json()) {+            Ok(id) => id,+            Err(progress) => return json!({"success": false, "error": "a demo job is already running", "progress": progress, "_hint": "Poll kicad_demo {\"progress\":true} instead of starting another."}),+        };+        demo::job_update(json!({"callerName": progress::caller_name()}));+        let spawned = std::thread::Builder::new().name("kicad-demo-job".into()).spawn(move || run_job(info, opts));+        if let Err(e) = spawned {+            demo::job_update(json!({"active": false, "done": true, "error": format!("could not start the job thread: {e}")}));+            return fail("job_thread", format!("could not start the tour thread: {e}"), "Retry; if it repeats, kicad_diagnostics and the bridge log carry the reason.");+        }+        return json!({+            "success": true, "started": true, "jobId": job_id, "statusVerb": "kicad_demo", "statusArgs": {"progress": true}, "estimatedSec": est,+            "poll": "kicad_demo {\"progress\": true}",+            "_hint": format!("Background tour started (~{est}s). Poll kicad_demo {{\"progress\":true}} every 2-3s for percent/stepLabel and draw a real progress bar, not a spinner; kicad_progress and kicad_status.operations carry the same job as the kicad.demo phase. The final poll carries result.state (every KiCad window as base64 PNG) + result.narrate."),+        });+    }+    // ---- staged / foreground flow: one beat per call, resumable ----+    let _gui = GUI_LOCK.lock().unwrap_or_else(|e| e.into_inner());+    staged(state, args, info)+}++/// The staged flow: prepare, warm on the first beat, run the requested beat(s) with one+/// retry, capture each beat's window, narrate, hand the next call back.+fn staged(state: &mut State, args: &Value, mut info: KicadInfo) -> Value {+    // KiCad absent is the LIKELY case during a Hydrogen install demo. Never dead-end the+    // user with "install it yourself": OFFER, and install it for them on the spot.+    let mut installed_now = false;+    if !info.installed {+        if !is_true(args, "installKiCad") {+            return json!({+                "success": false, "stage": "not_installed", "done": false,+                "narrate": "You don't have KiCad yet. Want me to install it for you? It's the free, fully open-source EDA suite: no trial clock, no personal-use tier, no feature limits. Everything in this demo works forever, on any machine you own. It's a large download, so give it a few minutes.",+                "screenshots": [], "error": "KiCad is not installed",+                "data": {"offer": "I can install KiCad for you right now.", "acceptWith": "kicad_demo {\"installKiCad\": true}", "installVerb": "kicad_upgrade", "steps": demo::STEPS},+                "_hint": "ASK, then do it for them: never hand a first-time user a download page or an installer to click through. Speak `narrate`, and on yes call kicad_demo {\"installKiCad\": true}: it installs the official build silently (per-user, no UAC) and then runs the tour in the SAME call. Watching the AI install their EDA tool is itself the best demo beat, so narrate it rather than going quiet for several minutes. Unlike Fusion there is no trial or tier caveat to disclose: KiCad is free and unrestricted, which is worth saying.",+            });+        }+        let up = crate::groups::dispatch(state, "kicad_upgrade", &json!({})).unwrap_or_else(|| json!({"success": false, "error": "kicad_upgrade unavailable"}));+        info = state.refresh();+        if !info.installed {+            return json!({+                "success": false, "error": "KiCad install did not complete", "data": {"upgrade": up},+                "_hint": "The silent install ran but KiCad still isn't detected. Tell the user plainly, then check kicad_upgrade {\"diagnoseOnly\":true}: the usual causes are SmartScreen/AV blocking the downloaded installer or a pending reboot. Don't pretend the demo succeeded.",+            });+        }+        installed_now = true;+    }+    let step = arg_str(args, "step").map(|s| s.trim().to_lowercase());+    if let Some(s) = &step {+        if !demo::STEPS.contains(&s.as_str()) {+            return json!({"success": false, "error": format!("unknown step '{s}'"), "data": {"validSteps": demo::STEPS}});+        }+    }+    let opts = match Opts::from_args(args) {+        Ok(o) => o,+        Err(e) => return e,+    };+    let mut prep = prepare(&info, &opts);+    // DEFAULT = one beat at a time. The full six-window tour takes ~90 s, which blows the+    // per-request budget. {"all": true} still runs the whole thing for non-interactive use.+    let run_all = is_true(args, "all");+    let steps: Vec<&'static Beat> = if run_all { demo::BEATS.iter().collect() } else { vec![demo::beat(step.as_deref().unwrap_or(demo::STEPS[0])).unwrap()] };+    if run_all || steps[0].step == demo::STEPS[0] {+        prep.cache_restart = restart_for_new_footprint(state, &prep);+        prep.warm = warm_kicad(state, &prep);+    }+    let mut results = Vec::new();+    let mut failed: Vec<&str> = Vec::new();+    for b in &steps {+        let mut r = run_step(state, b, &prep);+        if !is_true(&r, "ok") {+            sleep_ms(3000); // one retry: a cold editor often misses its first budget+            r = run_step(state, b, &prep);+            r["retried"] = json!(true);+        }+        if !is_true(&r, "ok") {+            failed.push(b.step);+        }+        results.push(r);+    }+    let last = steps.last().unwrap().step;+    let nxt = demo::next_step(last);+    // Capture what each beat put on screen, so the caller can SHOW the tour instead of+    // hunting for windows (same contract as fusion_demo).+    let mut shots = Vec::new();+    for r in results.iter_mut() {+        if is_true(r, "ok") {+            if let Some(h) = r["hwnd"].as_u64().or_else(|| hwnd_for_title(r["window"].as_str().unwrap_or(""))) {+                let shot = win::screenshot_hwnd(h, "demo", None);+                if let Some(p) = shot.get("fullPath").or_else(|| shot.get("savedTo")).and_then(Value::as_str) {+                    let step = r["step"].as_str().unwrap_or("");+                    shots.push(json!({"label": demo::step_label(step), "path": p, "title": win::title_of(h)}));+                    r["screenshot"] = json!(p);+                }+            }+        }+    }+    let narrate = demo::staged_narration(&results, installed_now);+    let ok_ct = results.iter().filter(|r| is_true(r, "ok")).count();+    let idx = demo::STEPS.iter().position(|s| *s == last).unwrap_or(0);+    json!({+        "success": true, // a skipped beat is not a failed demo; `failedSteps` carries the detail+        "stage": last, "done": nxt.is_none(), "narrate": narrate, "screenshots": shots,+        "steps": results.iter().map(|r| json!({"step": r["step"], "title": r["title"], "ok": r["ok"], "pointOut": r["pointOut"], "window": r["window"], "retried": r.get("retried").and_then(Value::as_bool).unwrap_or(false), "say": r["say"]})).collect::<Vec<_>>(),+        "say": results.last().map(|r| r["say"].clone()).unwrap_or(Value::Null),+        "output": format!("Demo {last}: {ok_ct}/{} opened{}", results.len(), if failed.is_empty() { String::new() } else { format!("; skipped {}", failed.join(", ")) }),+        "data": {+            "nextCall": nxt.map(|n| format!("kicad_demo {{\"step\": \"{n}\"}}")),+            "remainingSteps": demo::remaining_steps(last), "failedSteps": failed, "installedKiCadJustNow": installed_now,+            "warmedUp": {"warmed": prep.warm["warmed"], "eeschema": prep.warm["eeschema"], "pcbnew": prep.warm["pcbnew"]},+            "projectDir": prep.project_dir,+            "progress": {"beatIndex": idx + 1, "beatCount": demo::STEPS.len(), "percent": (100 * (idx + 1) / demo::STEPS.len()) as i64, "estimatedSecTotal": demo::total_est()},+            "installedParts": prep.installed, "models3d": {"lqfp48": prep.has3d, "soic8": prep.has3d}, "warnings": prep.warnings,+            "cleanup": "kicad_close {\"force\":true}",+        },+        "_hint": format!("{}STAGED + RESUMABLE, same contract as fusion_demo: speak `narrate` (it's written for a newcomer, not an engineer), SHOW every entry in `screenshots` ({{label,path}}, already captured for you), then call `data.nextCall` and repeat until `done` is true. Mention `steps[].pointOut` while the image is up. The board is fetched from the wiki and its MCU installed into the user's real KiCad. Windows open in the BACKGROUND and all input is window-targeted, so the user can keep working throughout; say that, it's a genuine differentiator. If a beat was skipped, don't dwell: say what you tried and move on. Finish by offering next steps in THEIR words ('export the Gerbers', 'run DRC', 'make a part with my number etched on it'), never verb names. Tear down with kicad_close {{\"force\":true}}.",+            if installed_now { "You just installed KiCad for them: say so, it lands well. " } else { "" }),+    })+}++#[cfg(test)]+mod tests {+    use super::*;++    /// The job, transport and narration records are process-wide; the tests that touch+    /// them run one at a time.+    static TEST_LOCK: Mutex<()> = Mutex::new(());++    fn serial() -> std::sync::MutexGuard<'static, ()> {+        TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner())+    }++    #[test]+    fn catalog_entry_matches_the_contract() {+        assert_eq!(VERBS.len(), 1);+        let v = &VERBS[0];+        assert_eq!(v.name, "kicad_demo");+        assert_eq!(v.mechanism, Mechanism::Window);+        assert_eq!(v.risk, "input");+        assert_eq!(v.timeout_sec, 130);+        for text in [v.summary, v.hint, v.input, v.example].into_iter().chain(v.pitfalls.iter().copied()) {+            assert!(!text.contains('\u{2014}'), "kicad_demo carries an em dash");+        }+        let mut state = State::new();+        assert!(dispatch(&mut state, "kicad_status", &json!({})).is_none());+        assert!(dispatch(&mut state, "kicad_show_symbol", &json!({})).is_none());+    }++    #[test]+    fn control_and_progress_answer_without_a_job() {+        let _g = serial();+        let mut state = State::new();+        std::env::remove_var("ADOM_DIRECT_API_URL");+        {+            let mut g = demo::JOB.lock().unwrap_or_else(|e| e.into_inner());+            *g = None;+        }+        let r = dispatch(&mut state, "kicad_demo", &json!({"progress": true})).unwrap();+        assert_eq!(r["success"], json!(false));+        assert!(r["_hint"].as_str().unwrap().contains("background"));+        let r = dispatch(&mut state, "kicad_demo", &json!({"control": "dance"})).unwrap();+        assert_eq!(r["success"], json!(false));+        let r = dispatch(&mut state, "kicad_demo", &json!({"control": "next"})).unwrap();+        assert_eq!(r["success"], json!(false));+        assert_eq!(r["error"], json!("no demo job is running"));+        let r = dispatch(&mut state, "kicad_demo", &json!({"control": "mute"})).unwrap();+        assert_eq!(r["success"], json!(true));+        assert_eq!(r["muted"], json!(true));+        let r = dispatch(&mut state, "kicad_demo", &json!({"control": "unmute"})).unwrap();+        assert_eq!(r["muted"], json!(false));+    }++    #[test]+    fn options_validate_and_second_start_is_refused() {+        let _g = serial();+        let o = Opts::from_args(&json!({"muted": true, "speed": 2, "steps": "board, board3d", "record": true})).unwrap();+        assert!(o.muted && o.record);+        assert_eq!(o.speed, 2.0);+        assert_eq!(o.steps, Some(vec!["board".to_string(), "board3d".to_string()]));+        assert_eq!(o.chip_ref, "U5");+        let o = Opts::from_args(&json!({"narrate": false})).unwrap();+        assert!(o.muted, "narrate:false is a mute");+        assert_eq!(Opts::from_args(&json!({"speed": 9})).unwrap_err()["errorCode"], json!("bad_arg"));+        assert_eq!(Opts::from_args(&json!({"steps": ["nope"]})).unwrap_err()["errorCode"], json!("bad_arg"));+        assert_eq!(Opts::from_args(&json!({"project": "/definitely/not/here.kicad_pro"})).unwrap_err()["errorCode"], json!("project_not_found"));+        {+            let mut g = demo::JOB.lock().unwrap_or_else(|e| e.into_inner());+            *g = None;+        }+        demo::job_start(false, 125, 6, json!({}), json!({})).unwrap();+        let mut state = State::new();+        std::env::remove_var("ADOM_DIRECT_API_URL");+        // With KiCad absent the start refuses before the job check; with it present the+        // running job is what refuses. Either way no second thread starts.+        let r = dispatch(&mut state, "kicad_demo", &json!({"all": true, "background": true})).unwrap();+        assert_eq!(r["success"], json!(false));+        assert!(r["error"].as_str().unwrap().contains("already running") || r["error"].as_str().unwrap().contains("not installed"));+        let r = dispatch(&mut state, "kicad_demo", &json!({"progress": true})).unwrap();+        assert_eq!(r["running"], json!(true));+        assert_eq!(r["active"][0]["phase"], json!("kicad.demo"));+        // A dashboard press is recorded and queued.+        let r = dispatch(&mut state, "kicad_demo", &json!({"control": "prev"})).unwrap();+        assert_eq!(r["success"], json!(true));+        assert_eq!(demo::ctrl().pop(), Some(Cmd::Prev));+        assert_eq!(demo::job_get("lastPress")["via"], json!("dashboard"));+        demo::job_update(json!({"active": false, "done": true}));+        demo::reset_for_run(false);+    }++    #[test]+    fn panel_event_applies_mute_and_queues_transport() {+        let _g = serial();+        {+            let mut g = demo::JOB.lock().unwrap_or_else(|e| e.into_inner());+            *g = None;+        }+        demo::reset_for_run(false);+        std::env::remove_var("ADOM_DIRECT_API_URL");+        assert_eq!(panel_event("not json")["ignored"], json!(true));+        assert_eq!(panel_event(r#"{"id":"other-tour","button":"next"}"#)["ignored"], json!(true));+        let r = panel_event(r#"{"id":"kicad-demo","button":"mute","at":1}"#);+        assert_eq!(r["applied"], json!("mute"));+        assert!(demo::is_muted());+        let r = panel_event(r#"{"id":"kicad-demo","button":"next","at":2,"muted":false}"#);+        assert_eq!(r["queued"], json!("next"));+        assert!(!demo::is_muted(), "the muted flag on the event is honoured");+        assert!(demo::panel().seen.contains("2"));+        let r = panel_event(r#"{"id":"kicad-demo","button":"stop","at":3}"#);+        assert_eq!(r["queued"], json!("exit"));+        assert!(demo::exit_requested());+        assert_eq!(demo::job_get("stopping"), json!(true));+        assert_eq!(demo::ctrl().pop(), Some(Cmd::Exit), "exit jumped the queue");+        assert_eq!(panel_event(r#"{"id":"kicad-demo","button":"wave"}"#)["ignored"], json!("wave"));+        assert_eq!(demo::job_get("presses").as_array().unwrap().len(), 4);+        demo::reset_for_run(false);+    }++    #[test]+    fn callback_url_needs_a_real_port() {+        std::env::remove_var("ADOM_KICAD_BRIDGE_PORT");+        // The test binary has no --port argument.+        assert_eq!(callback_url(), None);+        std::env::set_var("ADOM_KICAD_BRIDGE_PORT", "47731");+        assert_eq!(callback_url().as_deref(), Some("http://127.0.0.1:47731/demo/event"));+        std::env::set_var("ADOM_KICAD_BRIDGE_PORT", "0");+        assert_eq!(callback_url(), None);+        std::env::remove_var("ADOM_KICAD_BRIDGE_PORT");+    }++    #[test]+    fn staged_flow_without_kicad_offers_the_install() {+        let _g = serial();+        let mut state = State::new();+        let info = state.kicad_info();+        if info.installed {+            return; // a dev box with KiCad would start a real tour+        }+        std::env::remove_var("ADOM_DIRECT_API_URL");+        let r = dispatch(&mut state, "kicad_demo", &json!({})).unwrap();+        assert_eq!(r["success"], json!(false));+        assert_eq!(r["stage"], json!("not_installed"));+        assert_eq!(r["data"]["acceptWith"], json!("kicad_demo {\"installKiCad\": true}"));+        assert!(r["narrate"].as_str().unwrap().contains("install it for you"));+        let r = dispatch(&mut state, "kicad_demo", &json!({"all": true, "background": true})).unwrap();+        assert_eq!(r["success"], json!(false));+        assert!(r["error"].as_str().unwrap().contains("not installed"));+    }+}
rust/crates/kicad-core/src/ab.rs+103
@@ -623,6 +623,109 @@ pub fn desktop_wait_for_window(title_contains: &str, state: Option<&str>, timeou     Ok(WaitOutcome { appeared: r.get_bool("appeared").unwrap_or(false), waited_ms: r.get_u64("waitedMs").unwrap_or(0), window }) } +// ── Typed helpers for the demo tour (phase 5) ─────────────────────────────────++/// `desktop_list_monitors`: the raw `monitors[]` payload (x/y/width/height, primary,+/// workArea). The direct API hands it back unwrapped (measured on ConfRoomROG 2026-09-04:+/// top-level `monitors`); older shapes wrap it, and `AbReply::get` covers both.+pub fn desktop_list_monitors(reason: &str) -> Result<Value, AbError> {+    let r = call("desktop_list_monitors", json!({"reason": reason}), Duration::from_secs(6))?;+    Ok(r.get("monitors").cloned().unwrap_or(Value::Null))+}++/// `desktop_set_window_bounds {hwnd, x, y, w, h, reason}`.+pub fn desktop_set_window_bounds(hwnd: u64, x: i32, y: i32, w: i32, h: i32, reason: &str) -> Result<Value, AbError> {+    let r = call("desktop_set_window_bounds", json!({"hwnd": hwnd, "x": x, "y": y, "w": w, "h": h, "reason": reason}), Duration::from_secs(8))?;+    Ok(r.body)+}++/// A caption with an explicit size and position (the tour paints its beat line bottom-left+/// at medium size, its acknowledgements and foreground notes small at the top). Fleet+/// ruling (dash-demo, 2026-08-24): only id, text, duration, size and position are passed;+/// reason is REQUIRED (ab refuses a caption without one).+pub fn desktop_caption_at(id: &str, text: &str, duration_ms: u64, size: &str, position: &str, reason: &str) -> Result<Value, AbError> {+    let r = call("desktop_caption", json!({"id": id, "text": text, "duration": duration_ms, "size": size, "position": position, "reason": reason}), Duration::from_secs(6))?;+    Ok(r.body)+}++/// `desktop_caption {action:"hide", id}`.+pub fn desktop_caption_hide(id: &str, reason: &str) -> Result<Value, AbError> {+    let r = call("desktop_caption", json!({"action": "hide", "id": id, "reason": reason}), Duration::from_secs(6))?;+    Ok(r.body)+}++/// `desktop_demo_panel` with whatever action the caller built (show / poll / hide / press).+/// The reply body is returned unwrapped so `shown`, `state`, `stage`, `muted`, `events`+/// and `callbackAccepted` can be read wherever ab put them.+pub fn desktop_demo_panel(args: Value) -> Result<AbReply, AbError> {+    call("desktop_demo_panel", args, Duration::from_secs(8))+}++/// A toast with buttons (the transport fallback for an ab older than the demo panel).+pub fn notify_user_buttons(id: &str, title: &str, body: &str, buttons: &[&str], duration_long: bool) -> Result<Value, AbError> {+    let b: Vec<Value> = buttons.iter().map(|l| json!({"label": l})).collect();+    let r = call("notify_user", json!({"id": id, "title": title, "body": body, "message": body, "buttons": b, "durationLong": duration_long}), Duration::from_secs(6))?;+    Ok(r.body)+}++/// `notify_events {sinceSeq}`: (events, nextSeq) of toast button clicks.+pub fn notify_events(since_seq: i64) -> Result<(Vec<Value>, Option<i64>), AbError> {+    let r = call("notify_events", json!({"sinceSeq": since_seq}), Duration::from_secs(6))?;+    let events = r.get("events").and_then(Value::as_array).cloned().unwrap_or_default();+    let next = r.get("nextSeq").and_then(num_i64);+    Ok((events, next))+}++pub fn notify_dismiss(id: &str) -> Result<Value, AbError> {+    let r = call("notify_dismiss", json!({"id": id}), Duration::from_secs(4))?;+    Ok(r.body)+}++/// A started desktop recording (ab 2.1.35+ native recorder).+#[derive(Clone, Debug, Default)]+pub struct Recording {+    pub recording_id: String,+    pub path: String,+    pub encoder: String,+    pub hardware_encoder: Option<bool>,+    pub audio: Option<bool>,+    pub raw: Value,+}++/// `desktop_record_start {monitor:"primary", fps, audio:true, maxDurationMs, reason,+/// confirmDesktopNotTabRecording:true}`. The reason is required: a recording can never+/// start silently, the user sees it in the recording caption.+pub fn desktop_record_start(reason: &str, fps: u32, max_duration_ms: u64) -> Result<Recording, AbError> {+    let args = json!({"monitor": "primary", "fps": fps, "audio": true, "maxDurationMs": max_duration_ms, "reason": reason, "confirmDesktopNotTabRecording": true});+    let r = call("desktop_record_start", args, Duration::from_secs(20))?;+    Ok(Recording {+        recording_id: r.get_str("recordingId").unwrap_or("").to_string(),+        path: r.get_str("path").or_else(|| r.get_str("filePath")).unwrap_or("").replace('\\', "/"),+        encoder: r.get_str("encoder").unwrap_or("").to_string(),+        hardware_encoder: r.get_bool("hardwareEncoder"),+        audio: r.get_bool("audio"),+        raw: r.body.clone(),+    })+}++/// `desktop_record_stop {recordingId}`: the finalized file (path, bytes, frames, audioFrames).+pub fn desktop_record_stop(recording_id: &str) -> Result<Value, AbError> {+    let args = if recording_id.is_empty() { json!({}) } else { json!({"recordingId": recording_id}) };+    let r = call("desktop_record_stop", args, Duration::from_secs(60))?;+    let mut out = json!({"recordingId": recording_id});+    for k in ["path", "filePath", "bytes", "frames", "audioFrames", "elapsedMs", "durationMs", "sizeKB"] {+        if let Some(v) = r.get(k) {+            out[k] = v.clone();+        }+    }+    if out.get("path").is_none() {+        if let Some(p) = out.get("filePath").cloned() {+            out["path"] = p;+        }+    }+    Ok(out)+}+ #[cfg(test)] mod tests {     use super::*;
rust/crates/kicad-core/src/demo.rs+1777−1
@@ -1 +1,1777 @@-//! Placeholder: the six-beat demo tour, filled in by phase 5.+//! The six-beat demo tour, phase 5 of docs/rust-port-plan.md: the OS-neutral half of+//! `handlers/demo.py` and `handlers/demo_motion.py`.+//!+//! What lives here: the beat table (order, titles, captions, narration clips, what each+//! beat opens and verifies), the tour's window-title expectations, the pure text work on+//! the wiki board (lift the MCU symbol out of the schematic's `lib_symbols`, lift the+//! placed LQFP-48 out of the board as a library footprint, attach KiCad's own 3D bodies+//! to footprints that have none, find where the featured part sits so motion aims at+//! it), the window layout math, the front-window rule, the transport state machine+//! (play, pause, next, back, mute, stop from the ab demo panel, the toast or the+//! dashboard), the background job record that `kicad_demo {progress:true}` reads, the+//! canvas measurements the motion is gated on, the motion plans themselves as data, and+//! the narration clip timing (the clips are constant-bitrate mp3s, so their length is+//! read from the frame header without a decoder).+//!+//! What does not live here: anything that touches a window. The verb group+//! (`kicad-bridge/src/verbs_demo.rs`) drives KiCad through the window verbs and ab, and+//! synthesises input only through `kicad_platform::native().tour_*`, which refuse unless+//! the window is the measured foreground.+//!+//! Beat ORDER is a reliability decision as much as a story decision (2026-08-16): the+//! part beats (symbol, footprint, chip in 3D) come first because the story goes from one+//! part to the finished board; the design beats reuse the schematic and board that+//! `warm_kicad` opened up front. The tour foregrounds each beat's window exactly once,+//! with a reason, through ab, and reports only what the z-order says afterwards.++use std::collections::{BTreeMap, BTreeSet};+use std::path::{Path, PathBuf};+use std::sync::atomic::{AtomicBool, Ordering};+use std::sync::Mutex;+use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};++use serde_json::{json, Map, Value};++use kicad_platform::native;++// ── The board and the parts ───────────────────────────────────────────────────++pub const DEMO_LIB: &str = "Adom";+/// John, 2026-09-03: "make it a nice sexy cool board, not a lame board". The tour walks a+/// REAL Adom board from the wiki, adom/esc-g431 (a brushless motor controller: 149+/// footprints, 227 schematic symbols, an STM32G431 MCU and six MOSFETs), and the chip+/// beats show that board's own MCU: the symbol is lifted from the schematic's embedded+/// lib_symbols and the footprint from the board's own U5, complete with KiCad's standard+/// LQFP-48 3D body.+pub const BOARD_PAGE: &str = "adom/esc-g431";+pub const BOARD_NAME: &str = "Adom ESC G431";+pub const BOARD_BLURB: &str = "a brushless motor controller";+pub const BOARD_FILES: &[(&str, &str)] = &[("schematic", "esc-g431.kicad_sch"), ("board", "esc-g431.kicad_pcb"), ("project", "src/esc-g431.kicad_pro")];+pub const CHIP_LIB_ID: &str = "MCU_ST_STM32G4:STM32G431C_6-8-B_Tx";+pub const CHIP_REF: &str = "U5";+pub const CHIP_FOOTPRINT: &str = "Package_QFP:LQFP-48_7x7mm_P0.5mm";+pub const IC_SYM: &str = "STM32G431C_6-8-B_Tx";+pub const IC_FP: &str = "LQFP-48_STM32G431";+pub const WIKI_FILES: &str = "https://wiki.adom.inc/api/pages/{page}/files/{path}";+pub const DEMO_DIR_NAME: &str = "adom-kicad-demo";+/// A fetched copy younger than this is reused (the page is public, no token needed).+pub const FETCH_FRESH_SECS: u64 = 86400;++/// Footprint id -> KiCad standard body when the name does not match a KiCad model.+pub const MODEL_ALIASES: &[(&str, &str)] = &[("MOSFET_CDT:TDSON-8-1_BSC016N06NS", "Package_TO_SOT_SMD.3dshapes/TDSON-8-1")];++/// GET a wiki file (rustls with bundled roots: no OS CA store involved).+#[cfg(feature = "net")]+pub fn fetch(url: &str) -> Result<Vec<u8>, String> {+    let config = ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(60))).http_status_as_error(true).build();+    let agent = ureq::Agent::new_with_config(config);+    let mut resp = agent.get(url).header("User-Agent", "adom-bridge-kicad demo").call().map_err(|e| e.to_string())?;+    resp.body_mut().with_config().limit(50 * 1024 * 1024).read_to_vec().map_err(|e| e.to_string())+}++#[cfg(not(feature = "net"))]+pub fn fetch(_url: &str) -> Result<Vec<u8>, String> {+    Err("the net feature is off in this build: no HTTPS client".into())+}++// ── Beats ─────────────────────────────────────────────────────────────────────++pub const STEPS: &[&str] = &["symbol", "footprint", "footprint3d", "schematic", "board", "board3d"];++/// Screenshot labels. `schematic` / `board_2d` / `board_3d` deliberately match the labels+/// fusion_demo returns, so an AI driving BOTH bridges sees one vocabulary.+pub fn step_label(step: &str) -> &'static str {+    match step {+        "symbol" => "symbol",+        "footprint" => "footprint",+        "footprint3d" => "part_3d",+        "schematic" => "schematic",+        "board" => "board_2d",+        "board3d" => "board_3d",+        _ => "step",+    }+}++/// Seconds per beat, the ESC is a real board: bigger files (Python `_BEAT_EST`).+pub const BEAT_EST: &[(&str, u64)] = &[("prepare", 25), ("symbol", 12), ("footprint", 12), ("part_3d", 16), ("schematic", 15), ("board_2d", 15), ("board_3d", 30)];++pub fn beat_est(step: &str) -> u64 {+    let key = if step == "prepare" { "prepare" } else { step_label(step) };+    BEAT_EST.iter().find(|(k, _)| *k == key).map(|(_, s)| *s).unwrap_or(10)+}++pub fn total_est() -> u64 {+    BEAT_EST.iter().map(|(_, s)| *s).sum()+}++#[derive(Clone, Copy, Debug, PartialEq, Eq)]+pub enum MotionKind {+    TwoD,+    ThreeD,+}++/// One beat of the tour: everything the job needs to know about it that is not a window.+#[derive(Clone, Debug)]+pub struct Beat {+    pub step: &'static str,+    /// "1/6 · The MCU symbol" (the `title` on the step record and in the staged reply).+    pub title: &'static str,+    /// The on-screen caption for part A (loading), without the "KiCad tour n/7:" prefix.+    pub caption_a: &'static str,+    pub caption_a_ms: u64,+    /// Part B: the window is verified on screen; this line explains what is shown.+    pub caption_b: &'static str,+    /// The panel stop name.+    pub stop: &'static str,+    pub motion: MotionKind,+    /// Wheel clicks for the 2D zoom (first zoom, then the zoom after centring).+    pub zoom: (i32, i32),+}++pub static BEATS: &[Beat] = &[+    Beat { step: "symbol", title: "1/6 \u{b7} The MCU symbol", caption_a: "the STM32G431 symbol, lifted from the board's schematic", caption_a_ms: 30000, caption_b: "Zooming in on the STM32 pins, then back out to the whole symbol", stop: "The MCU symbol", motion: MotionKind::TwoD, zoom: (1, 0) },+    Beat { step: "footprint", title: "2/6 \u{b7} Its footprint", caption_a: "its LQFP-48 footprint", caption_a_ms: 30000, caption_b: "48 pads on 0.5 mm pitch: zooming in, then back out", stop: "Its footprint", motion: MotionKind::TwoD, zoom: (1, 0) },+    Beat { step: "footprint3d", title: "3/6 \u{b7} The chip in 3D", caption_a: "the chip in 3D", caption_a_ms: 35000, caption_b: "The LQFP body on its pads, tilted to isometric and orbited", stop: "The chip in 3D", motion: MotionKind::ThreeD, zoom: (2, 1) },+    Beat { step: "schematic", title: "4/6 \u{b7} The ESC schematic", caption_a: "the ESC schematic, 227 symbols", caption_a_ms: 25000, caption_b: "Zooming in on the microcontroller block, then back out to the sheet", stop: "The ESC schematic", motion: MotionKind::TwoD, zoom: (2, 1) },+    Beat { step: "board", title: "5/6 \u{b7} The board in 2D", caption_a: "the board in 2D, 149 footprints", caption_a_ms: 25000, caption_b: "Zooming in on the MCU and its decoupling, then back out", stop: "The board in 2D", motion: MotionKind::TwoD, zoom: (2, 1) },+    Beat { step: "board3d", title: "6/6 \u{b7} The board in 3D", caption_a: "the board in 3D", caption_a_ms: 40000, caption_b: "Isometric orbit around the MOSFETs and the MCU", stop: "The board in 3D", motion: MotionKind::ThreeD, zoom: (2, 1) },+];++pub const PREPARE_CAPTION: &str = "fetching the Adom ESC G431 board and installing its STM32 into your library";+pub const PREPARE_CAPTION_MS: u64 = 35000;+pub const PREPARE_STOP: &str = "Fetching the ESC board";++pub fn beat(step: &str) -> Option<&'static Beat> {+    BEATS.iter().find(|b| b.step == step)+}++/// The beats to run, in tour order. `steps` None or empty = all six; otherwise the subset+/// named (unknown names are an error naming the valid ones). Order is always the tour's,+/// because the design beats reuse what the earlier ones opened.+pub fn beat_plan(steps: Option<&[String]>) -> Result<Vec<&'static Beat>, String> {+    let Some(want) = steps.filter(|s| !s.is_empty()) else { return Ok(BEATS.iter().collect()) };+    for s in want {+        if !STEPS.contains(&s.as_str()) {+            return Err(format!("unknown step '{s}'; valid steps: {}", STEPS.join(", ")));+        }+    }+    Ok(BEATS.iter().filter(|b| want.iter().any(|w| w == b.step)).collect())+}++/// The caption for part A of beat number `n` of `count` beats (prepare is 1/N).+pub fn caption_a(n: usize, count: usize, text: &str) -> String {+    format!("KiCad tour {n}/{count}: {text}")+}++/// The line the AI can speak for a completed beat.+pub fn say(step: &str, has3d: bool) -> String {+    match step {+        "symbol" => format!("This is the STM32G431, the microcontroller on the board you are about to see, in your own KiCad Symbol Editor. The bridge lifted it from the board's schematic and installed it into your {DEMO_LIB} library a moment ago."),+        "footprint" => "Same chip, now its land pattern: a 48 pin LQFP on half millimetre pitch, taken from the board itself. The symbol is the idea, the footprint is the copper.".into(),+        "footprint3d" => format!("And the same chip in three dimensions{}", if has3d { ", the LQFP-48 body from KiCad's own 3D library sitting on its pads." } else { ". Pads only here: KiCad's 3D model library is not installed on this machine." }),+        "schematic" => "Now the real schematic: the Adom ESC G431, a brushless motor controller, straight from its wiki page. Over two hundred symbols: the STM32 you just saw, six power MOSFETs, gate drivers, sensing and the connectors.".into(),+        "board" => "The layout of that board: one hundred and forty nine footprints, the MOSFETs around the edge, the MCU in the middle, copper, silkscreen and courtyards all real.".into(),+        "board3d" => "And the finished ESC in 3D. This is the payoff: from one symbol to a rendered assembly, every step driven on your own machine, and each window brought to you once, then left alone.".into(),+        _ => String::new(),+    }+}++/// What is actually on screen worth mentioning.+pub fn point_out(step: &str, has3d: bool) -> Vec<String> {+    let v: Vec<&str> = match step {+        "symbol" => vec!["48 pins across the MCU's units", "the body and pin numbering", "registered in your 'Adom' library, not a scratch file"],+        "footprint" => vec!["48 pads on 0.5 mm pitch", "silkscreen outline and the pin-1 mark", "the courtyard (the assembler's keep-out)"],+        "footprint3d" => if has3d { vec!["the LQFP-48 body on its 48 pads", "gold pad plating"] } else { vec!["the bare pads (KiCad's 3D library is not installed here)"] },+        "schematic" => vec!["U5, the STM32G431 from the first beats", "the six MOSFET half-bridges", "the whole sheet came from wiki.adom.inc/adom/esc-g431"],+        "board" => vec!["the six TDSON MOSFET land patterns", "the LQFP-48 in the middle", "the pad count in the status bar"],+        "board3d" => {+            let mut v = if has3d { vec!["the STM32, capacitors, resistors and connectors with real bodies", "the MOSFET pads (their vendor bodies are not in KiCad's library)"] } else { vec!["the board and copper (component bodies need KiCad's 3D model library)"] };+            v.push("orbit it with the mouse: it is your real KiCad");+            v+        }+        _ => vec![],+    };+    v.into_iter().map(String::from).collect()+}++// ── Window titles ─────────────────────────────────────────────────────────────++/// The exact window titles this tour drives. Generic titles ("PCB Editor", "3D Viewer")+/// matched the wrong window on John's laptop (an empty untitled PCB Editor, an old demo+/// board); every beat targets these instead. KiCad separates document and editor with an+/// em dash in its titles (`\u{2014}`), so the expectation carries one; `title_matches`+/// also accepts a plain hyphen so an older KiCad still matches.+#[derive(Clone, Debug, PartialEq, Eq)]+pub struct TourTitles {+    pub schematic: String,+    pub board: String,+    pub symbol: String,+    pub footprint: String,+}++pub const VIEWER_TITLE: &str = "3D Viewer";++pub fn tour_titles(stem: &str) -> TourTitles {+    let stem = if stem.trim().is_empty() { "esc-g431" } else { stem };+    TourTitles {+        schematic: format!("{stem} \u{2014} Schematic Editor"),+        board: format!("{stem} \u{2014} PCB Editor"),+        symbol: format!("{DEMO_LIB}:{IC_SYM} \u{2014} Symbol Editor"),+        footprint: format!("{DEMO_LIB}:{IC_FP} \u{2014} Footprint Editor"),+    }+}++fn norm_title(t: &str) -> String {+    t.to_lowercase().replace('\u{2014}', "-").replace('\u{2013}', "-").split_whitespace().collect::<Vec<_>>().join(" ")+}++/// Case-insensitive substring match with the dash spelling normalised.+pub fn title_matches(live: &str, expected: &str) -> bool {+    if expected.trim().is_empty() {+        return !live.trim().is_empty();+    }+    norm_title(live).contains(&norm_title(expected))+}++/// The stem the titles are built from: the board or schematic file name without extension.+pub fn doc_stem(board: Option<&str>, schematic: Option<&str>) -> String {+    board.or(schematic).and_then(|p| Path::new(p).file_stem().map(|s| s.to_string_lossy().to_string())).unwrap_or_else(|| "esc-g431".into())+}++/// The window the tour is to close before it starts: every 3D Viewer and Footprint Editor+/// (they sit on last time's part with a stale library tree), any adom-demo window, and an+/// untitled PCB Editor.+pub fn is_stale_tour_window(title: &str) -> bool {+    let t = title.to_lowercase();+    t.contains("3d viewer") || t.contains("footprint editor") || t.starts_with("adom-demo") || t.trim() == "pcb editor"+}++// ── S-expression text work ────────────────────────────────────────────────────++/// The balanced s-expression starting at `text[start] == '('`. Quote-aware.+pub fn sexpr_block(text: &str, start: usize) -> &str {+    let bytes = text.as_bytes();+    let mut depth = 0i32;+    let mut inq = false;+    let mut k = start;+    while k < bytes.len() {+        let c = bytes[k];+        if c == b'"' && (k == 0 || bytes[k - 1] != b'\\') {+            inq = !inq;+        } else if !inq {+            if c == b'(' {+                depth += 1;+            } else if c == b')' {+                depth -= 1;+                if depth == 0 {+                    return &text[start..k + 1];+                }+            }+        }+        k += 1;+    }+    &text[start..]+}++/// One embedded library symbol (lib_symbols block) as a standalone .kicad_sym.+pub fn extract_symbol(sch_text: &str, lib_id: &str) -> Result<String, String> {+    let i = sch_text.find("(lib_symbols").ok_or("no lib_symbols block in the schematic")?;+    let lib = sexpr_block(sch_text, i);+    let needle = format!("(symbol \"{lib_id}\"");+    let j = lib.find(&needle).ok_or_else(|| format!("{lib_id} not in lib_symbols"))?;+    let block = sexpr_block(lib, j);+    let bare = lib_id.split_once(':').map(|(_, b)| b).unwrap_or(lib_id);+    let block = block.replacen(&needle, &format!("(symbol \"{bare}\""), 1);+    Ok(format!("(kicad_symbol_lib (version 20231120) (generator \"adom-demo\")\n{block}\n)\n"))+}++fn line_is(line: &str, indent: &str, head: &str) -> bool {+    line.strip_prefix(indent).map(|rest| rest.starts_with(head) && !rest.starts_with('\t')).unwrap_or(false)+}++/// The board's placed footprint `reference` as a library .kicad_mod (placement, uuid,+/// net assignments and sheet paths stripped; pads, silk, courtyard and the 3D model kept).+pub fn extract_footprint(pcb_text: &str, fp_id: &str, reference: &str, new_name: &str) -> Result<String, String> {+    let head = format!("(footprint \"{fp_id}\"");+    let ref_prop = format!("(property \"Reference\" \"{reference}\"");+    let mut pos = 0;+    let block = loop {+        let j = pcb_text[pos..].find(&head).map(|k| k + pos).ok_or_else(|| format!("{fp_id} {reference} not on the board"))?;+        let block = sexpr_block(pcb_text, j);+        if block.contains(&ref_prop) {+            break block;+        }+        pos = j + 10;+    };+    let block = block.replacen(&head, &format!("(footprint \"{new_name}\""), 1);+    let mut out: Vec<String> = Vec::new();+    let mut placement_dropped = false;+    let mut ref_done = false;+    let mut val_done = false;+    for line in block.split('\n') {+        let trimmed = line.trim_start();+        if !placement_dropped && line_is(line, "\t\t", "(at ") {+            placement_dropped = true;+            continue;+        }+        if line_is(line, "\t\t", "(uuid \"") || line_is(line, "\t\t", "(path \"") || line_is(line, "\t\t", "(sheetname \"") || line_is(line, "\t\t", "(sheetfile \"") {+            continue;+        }+        if trimmed.starts_with("(net ") || trimmed.starts_with("(pinfunction \"") || trimmed.starts_with("(pintype \"") {+            continue;+        }+        let mut l = line.to_string();+        if !ref_done && l.contains("(property \"Reference\" \"") {+            l = replace_prop_value(&l, "Reference", "REF**");+            ref_done = true;+        }+        if !val_done && l.contains("(property \"Value\" \"") {+            l = replace_prop_value(&l, "Value", new_name);+            val_done = true;+        }+        out.push(l);+    }+    let mut text = out.join("\n");+    let header = format!("(footprint \"{new_name}\"\n\t(version 20240108)\n\t(generator \"adom-demo\")");+    text = text.replacen(&format!("(footprint \"{new_name}\""), &header, 1);+    text.push('\n');+    Ok(text)+}++fn replace_prop_value(line: &str, prop: &str, value: &str) -> String {+    let head = format!("(property \"{prop}\" \"");+    let Some(i) = line.find(&head) else { return line.to_string() };+    let rest = &line[i + head.len()..];+    let Some(end) = rest.find('"') else { return line.to_string() };+    format!("{}{head}{value}{}", &line[..i], &rest[end..])+}++/// A model path KiCad cannot load on THIS box: the author's own disk (C:/..., ../, a+/// private ${VAR}) or a ${KICAD10_3DMODEL_DIR} file that is not installed here.+/// `exists` answers for a path relative to the 3dmodels root.+pub fn model_is_dead(path: &str, exists: &dyn Fn(&str) -> bool) -> bool {+    let b = path.as_bytes();+    if b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':' && (b.get(2) == Some(&b'/') || b.get(2) == Some(&b'\\')) {+        return true;+    }+    if path.starts_with("../") || path.starts_with("${ADOM_") {+        return true;+    }+    if let Some(rel) = path.strip_prefix("${KICAD10_3DMODEL_DIR}/") {+        return !exists(rel);+    }+    false+}++/// Report of `attach_standard_models`.+#[derive(Clone, Debug, Default)]+pub struct ModelReport {+    pub attached: usize,+    pub skipped: usize,+    pub dead_dropped: usize,+    pub missing_models: Vec<String>,+}++impl ModelReport {+    pub fn to_json(&self, root: &str) -> Value {+        json!({"attached": self.attached, "skipped": self.skipped, "deadDropped": self.dead_dropped, "missingModels": self.missing_models, "root": root})+    }+}++/// John, 2026-09-05: "why don't you have all the chips loaded in that final 3d board?+/// there's no mosfets or microcontroller". The wiki board's author left many footprints+/// with NO (model ...) at all, and a few with bodies from his own disk. For each+/// footprint: drop every dead model block, then, when none is left, attach KiCad's own+/// body when one exists on this box for the same Lib:Name (or an alias), so the 3D beat+/// shows chips instead of bare pads. Only the tour's fetched copy is touched.+pub fn attach_standard_models(pcb: &str, exists: &dyn Fn(&str) -> bool) -> (String, ModelReport) {+    let mut rep = ModelReport::default();+    let mut out = String::with_capacity(pcb.len() + 4096);+    let mut pos = 0usize;+    let mut search = 0usize;+    while let Some(k) = pcb[search..].find("(footprint \"") {+        let start = search + k;+        let block_src = sexpr_block(pcb, start);+        let end = start + block_src.len();+        let fp_id = block_src["(footprint \"".len()..].split('"').next().unwrap_or("").to_string();+        let mut block = block_src.to_string();+        // Drop dead model blocks (balanced, with the whitespace before them).+        let mut pos_m = 0usize;+        while let Some(dm) = block[pos_m..].find("(model \"") {+            let at = pos_m + dm;+            let path = block[at + "(model \"".len()..].split('"').next().unwrap_or("").to_string();+            if model_is_dead(&path, exists) {+                let mb_len = sexpr_block(&block, at).len();+                let mut cut0 = at;+                while cut0 > 0 && matches!(block.as_bytes()[cut0 - 1], b' ' | b'\t' | b'\r' | b'\n') {+                    cut0 -= 1;+                }+                block.replace_range(cut0..at + mb_len, "");+                rep.dead_dropped += 1;+                pos_m = cut0;+            } else {+                pos_m = at + "(model \"".len();+            }+        }+        if !block.contains("(model ") && fp_id.contains(':') {+            let (lib, name) = fp_id.split_once(':').unwrap();+            let cand = MODEL_ALIASES.iter().find(|(k, _)| *k == fp_id).map(|(_, v)| v.to_string()).unwrap_or_else(|| format!("{lib}.3dshapes/{name}"));+            let rel = [".step", ".wrl"].iter().map(|ext| format!("{cand}{ext}")).find(|r| exists(r));+            if let Some(rel) = rel {+                let model = format!("\n\t\t(model \"${{KICAD10_3DMODEL_DIR}}/{rel}\"\n\t\t\t(offset (xyz 0 0 0))\n\t\t\t(scale (xyz 1 1 1))\n\t\t\t(rotate (xyz 0 0 0))\n\t\t)");+                let body = block[..block.len() - 1].trim_end().to_string();+                block = format!("{body}{model}\n\t)");+                rep.attached += 1;+            } else {+                rep.skipped += 1;+                if rep.missing_models.len() < 12 && !rep.missing_models.contains(&fp_id) {+                    rep.missing_models.push(fp_id.clone());+                }+            }+        }+        out.push_str(&pcb[pos..start]);+        out.push_str(&block);+        pos = end;+        search = end;+    }+    out.push_str(&pcb[pos..]);+    (out, rep)+}++fn two_numbers_after(text: &str, head: &str) -> Option<(f64, f64)> {+    let i = text.find(head)?;+    let rest = &text[i + head.len()..];+    let mut it = rest.split(|c: char| c.is_whitespace() || c == ')').filter(|s| !s.is_empty());+    let x = it.next()?.parse::<f64>().ok()?;+    let y = it.next()?.parse::<f64>().ok()?;+    Some((x, y))+}++/// The lib_id and footprint id of the part referenced `reference` in a schematic and its+/// board, so a project other than the ESC can be toured (its own U5, or whatever ref the+/// caller names).+pub fn chip_ids_for_ref(sch_text: &str, pcb_text: &str, reference: &str) -> (Option<String>, Option<String>) {+    let ref_prop = format!("(property \"Reference\" \"{reference}\"");+    let mut lib_id = None;+    let mut pos = 0;+    while let Some(k) = sch_text[pos..].find("(symbol") {+        let start = pos + k;+        // Only placed symbols carry a lib_id; library definitions carry a quoted name.+        let head = &sch_text[start..];+        if head.starts_with("(symbol\n") || head.starts_with("(symbol (lib_id") || head.starts_with("(symbol\r\n") {+            let block = sexpr_block(sch_text, start);+            if block.contains(&ref_prop) {+                if let Some(i) = block.find("(lib_id \"") {+                    lib_id = block[i + "(lib_id \"".len()..].split('"').next().map(String::from);+                    break;+                }+            }+        }+        pos = start + 7;+    }+    let mut fp_id = None;+    let mut pos = 0;+    while let Some(k) = pcb_text[pos..].find("(footprint \"") {+        let start = pos + k;+        let block = sexpr_block(pcb_text, start);+        if block.contains(&ref_prop) {+            fp_id = block["(footprint \"".len()..].split('"').next().map(String::from);+            break;+        }+        pos = start + block.len().max(12);+    }+    (lib_id, fp_id)+}++/// Where the featured part sits, as fractions of the drawn content: the sheet for the+/// schematic, the Edge.Cuts box for the board. Motion aims its zoom there instead of the+/// canvas centre (John, 2026-09-05: "you still don't end up showing important stuff").+/// Measured for esc-g431: schematic (0.20, 0.54) on A3, board (0.23, 0.66).+pub fn focus_fractions(sch_text: &str, pcb_text: &str, chip_lib_id: &str, chip_ref: &str) -> BTreeMap<String, (f64, f64)> {+    let mut out = BTreeMap::new();+    // Schematic: the placed symbol with this lib_id, its (at x y).+    let needle = format!("(lib_id \"{chip_lib_id}\")");+    if let Some(i) = sch_text.find(&needle) {+        if let Some((x, y)) = two_numbers_after(&sch_text[i..], "(at ") {+            let paper = sch_text.find("(paper \"").map(|p| sch_text[p + 8..].split('"').next().unwrap_or("A4").to_string()).unwrap_or_else(|| "A4".into());+            let (mut pw, mut ph) = match paper.as_str() {+                "A4" => (297.0, 210.0),+                "A3" => (420.0, 297.0),+                "A2" => (594.0, 420.0),+                "A1" => (841.0, 594.0),+                "A0" => (1189.0, 841.0),+                "A" => (279.4, 215.9),+                "B" => (431.8, 279.4),+                "C" => (558.8, 431.8),+                "D" => (863.6, 558.8),+                "E" => (1117.6, 863.6),+                _ => (420.0, 297.0),+            };+            if sch_text.contains(&format!("(paper \"{paper}\" portrait)")) {+                std::mem::swap(&mut pw, &mut ph);+            }+            out.insert("schematic".into(), (round3(x / pw), round3(y / ph)));+        }+    }+    // Board: the footprint with this reference against the Edge.Cuts bounding box.+    let ref_prop = format!("(property \"Reference\" \"{chip_ref}\"");+    if let Some(i) = pcb_text.find(&ref_prop) {+        if let Some(j) = pcb_text[..i].rfind("(footprint ") {+            if let Some((x, y)) = two_numbers_after(&pcb_text[j..i], "(at ") {+                let (mut xs, mut ys): (Vec<f64>, Vec<f64>) = (Vec::new(), Vec::new());+                for head in ["(gr_line", "(gr_arc", "(gr_rect", "(gr_circle"] {+                    let mut pos = 0;+                    while let Some(k) = pcb_text[pos..].find(head) {+                        let start = pos + k;+                        let block = sexpr_block(pcb_text, start);+                        if block.contains("(layer \"Edge.Cuts\")") {+                            for key in ["(start ", "(end ", "(mid "] {+                                let mut p = 0;+                                while let Some(q) = block[p..].find(key) {+                                    if let Some((px, py)) = two_numbers_after(&block[p + q..], key) {+                                        xs.push(px);+                                        ys.push(py);+                                    }+                                    p += q + key.len();+                                }+                            }+                        }+                        pos = start + block.len().max(head.len());+                    }+                }+                let (minx, maxx) = (xs.iter().cloned().fold(f64::INFINITY, f64::min), xs.iter().cloned().fold(f64::NEG_INFINITY, f64::max));+                let (miny, maxy) = (ys.iter().cloned().fold(f64::INFINITY, f64::min), ys.iter().cloned().fold(f64::NEG_INFINITY, f64::max));+                if !xs.is_empty() && maxx > minx && maxy > miny {+                    let fx = ((x - minx) / (maxx - minx)).clamp(0.0, 1.0);+                    let fy = ((y - miny) / (maxy - miny)).clamp(0.0, 1.0);+                    out.insert("board".into(), (round3(fx), round3(fy)));+                }+            }+        }+    }+    out+}++fn round3(v: f64) -> f64 {+    (v * 1000.0).round() / 1000.0+}++// ── Layout ────────────────────────────────────────────────────────────────────++/// John, 2026-09-05: "make all of them be 95% covering the entire display and center it",+/// then "go 98% sizing". Every editor and viewer window lands here, centred on the+/// primary monitor's work area; the KiCad manager stays where KiCad opens it.+pub const LAYOUT_FRACTION: f64 = 0.98;++/// Where a beat's window goes: `mon` is (x, y, width, height) of the primary work area,+/// `slot` the anchor fractions (0.5, 0.5 = centred). Returns (x, y, w, h).+pub fn place_rect(mon: (i32, i32, i32, i32), slot: (f64, f64), fraction: f64) -> (i32, i32, i32, i32) {+    let (mx, my, mw, mh) = mon;+    let w = (mw as f64 * fraction) as i32;+    let h = (mh as f64 * fraction) as i32;+    let x = mx + (slot.0 * (mw - w) as f64) as i32;+    let y = my + (slot.1 * (mh - h) as f64) as i32;+    (x, y, w, h)+}++/// The primary monitor's work area (or bounds) from a `desktop_list_monitors` payload.+pub fn primary_monitor(monitors: &Value) -> Option<(i32, i32, i32, i32)> {+    let mons = monitors.as_array()?;+    let prim = mons.iter().find(|m| m.get("primary").and_then(Value::as_bool).unwrap_or(false)).or_else(|| mons.first())?;+    let area = prim.get("workArea").or_else(|| prim.get("bounds"))?;+    let n = |k: &str| area.get(k).and_then(Value::as_f64).map(|f| f as i32);+    Some((n("x").or_else(|| n("left")).unwrap_or(0), n("y").or_else(|| n("top")).unwrap_or(0), n("width")?, n("height")?))+}++// ── The front window ──────────────────────────────────────────────────────────++pub const FRONT_IGNORE_TITLES: &[&str] = &["popuphost", "adom bridge recorder", "adom demo panel"];+pub const FRONT_IGNORE_IMAGES: &[&str] = &["explorer.exe", "shellexperiencehost.exe", "searchhost.exe", "startmenuexperiencehost.exe", "textinputhost.exe", "applicationframehost.exe"];++/// One z-ordered row for `front_hwnd`.+#[derive(Clone, Debug, Default)]+pub struct ZRow {+    pub hwnd: u64,+    pub z: Option<i64>,+    pub minimized: bool,+    pub title: String,+    pub image: String,+}++/// The hwnd a user would call "in front": lowest z among windows that are not minimized,+/// not Windows shell popups, not our own overlays.+pub fn front_hwnd(rows: &[ZRow]) -> u64 {+    rows.iter()+        .filter(|r| r.z.is_some() && !r.minimized)+        .filter(|r| {+            let t = r.title.to_lowercase();+            let im = r.image.to_lowercase();+            !FRONT_IGNORE_TITLES.iter().any(|k| t.contains(k)) && !FRONT_IGNORE_IMAGES.contains(&im.as_str())+        })+        .min_by_key(|r| r.z.unwrap_or(i64::MAX))+        .map(|r| r.hwnd)+        .unwrap_or(0)+}++// ── Transport ─────────────────────────────────────────────────────────────────++/// "to have a good demo you need a play/pause/next/prev/exit" (John, 2026-08-24). Two ways+/// in, ONE control channel: ab's demo panel (callback plus poll), the toast buttons, and+/// `kicad_demo {control}` from the dashboard. Commands are applied between beats (a beat+/// handler cannot be interrupted mid-verb), except that `pending_interrupt` lets the+/// motion and the narration wait cut short at their next gesture.+#[derive(Clone, Copy, Debug, PartialEq, Eq)]+pub enum Cmd {+    Play,+    Pause,+    Next,+    Prev,+    Exit,+}++impl Cmd {+    pub fn parse(s: &str) -> Option<Cmd> {+        Some(match s.trim().to_lowercase().as_str() {+            "play" => Cmd::Play,+            "pause" => Cmd::Pause,+            "next" => Cmd::Next,+            "prev" => Cmd::Prev,+            "exit" => Cmd::Exit,+            _ => return None,+        })+    }+    pub fn as_str(self) -> &'static str {+        match self {+            Cmd::Play => "play",+            Cmd::Pause => "pause",+            Cmd::Next => "next",+            Cmd::Prev => "prev",+            Cmd::Exit => "exit",+        }+    }+}++/// A panel button (back/play/pause/next/stop) or a toast label onto a transport command.+pub fn map_button(button: &str) -> Option<Cmd> {+    Some(match button.trim().to_lowercase().as_str() {+        "back" | "prev" => Cmd::Prev,+        "play" => Cmd::Play,+        "pause" => Cmd::Pause,+        "next" => Cmd::Next,+        "stop" | "exit" => Cmd::Exit,+        _ => return None,+    })+}++/// The acknowledgement caption for a remote press.+pub fn ack_text(button: &str) -> Option<&'static str> {+    Some(match button.trim().to_lowercase().as_str() {+        "mute" => "Remote: muted",+        "unmute" => "Remote: narration back on",+        "pause" => "Remote: pausing",+        "play" => "Remote: playing",+        "next" => "Remote: next",+        "back" | "prev" => "Remote: back",+        "stop" | "exit" => "Remote: stopping the tour",+        _ => return None,+    })+}++/// FIFO of presses not yet honoured (measured on ConfRoomROG 2026-09-04: a single slot+/// lost a 'next' when 'back' arrived during the same beat's un-interruptible window+/// open). 'exit' jumps the queue. Capacity 8.+#[derive(Clone, Debug, Default)]+pub struct Transport {+    queue: Vec<Cmd>,+    /// The toast event stream cursor (`notify_events sinceSeq`).+    pub seq: i64,+}++impl Transport {+    pub fn push(&mut self, cmd: Cmd) {+        if cmd == Cmd::Exit {+            self.queue.clear();+            self.queue.push(Cmd::Exit);+        } else if self.queue.len() < 8 {+            self.queue.push(cmd);+        }+    }+    /// Put a command back at the head (kept for the between-beat check).+    pub fn push_front(&mut self, cmd: Cmd) {+        self.queue.insert(0, cmd);+    }+    pub fn pop(&mut self) -> Option<Cmd> {+        if self.queue.is_empty() { None } else { Some(self.queue.remove(0)) }+    }+    pub fn head(&self) -> Option<Cmd> {+        self.queue.first().copied()+    }+    pub fn clear(&mut self) {+        self.queue.clear();+    }+    /// A press that must cut the beat in flight (John, 2026-09-04: "i would expect your+    /// prev/next to be instant, not wait for next beat").+    pub fn pending_interrupt(&self) -> bool {+        matches!(self.head(), Some(Cmd::Prev | Cmd::Next | Cmd::Pause | Cmd::Exit))+    }+}++pub static CTRL: Mutex<Transport> = Mutex::new(Transport { queue: Vec::new(), seq: 0 });+/// Set by a STOP press; polled by the beat loop, the narration wait and the motion.+pub static EXIT: AtomicBool = AtomicBool::new(false);++pub fn ctrl() -> std::sync::MutexGuard<'static, Transport> {+    CTRL.lock().unwrap_or_else(|e| e.into_inner())+}++pub fn exit_requested() -> bool {+    EXIT.load(Ordering::SeqCst)+}++pub fn set_exit(v: bool) {+    EXIT.store(v, Ordering::SeqCst);+}++/// Queue a transport command (`set_demo_control`).+pub fn set_control(cmd: Cmd) {+    ctrl().push(cmd);+}++/// The beat-cutting check: exit, or a prev/next/pause/exit at the head of the queue.+pub fn pending_interrupt() -> bool {+    exit_requested() || ctrl().pending_interrupt()+}++/// Where the beat loop lands after a between-beat command. `i` is the index of the NEXT+/// beat (it advanced at the bottom of the loop), so "back" is two steps: measured on+/// John's laptop 2026-09-04, i - 1 replayed the same footprint beat twice.+pub fn apply_between_beats(i: usize, cmd: Option<Cmd>) -> (usize, bool) {+    match cmd {+        Some(Cmd::Exit) => (i, true),+        Some(Cmd::Prev) => (i.saturating_sub(2), false),+        _ => (i, false),+    }+}++// ── The panel ─────────────────────────────────────────────────────────────────++pub const PANEL_ID: &str = "kicad-demo";+pub const PANEL_TITLE: &str = "KiCad bridge tour";+pub const PANEL_REASON: &str = "Transport for the KiCad tour the user started: play, pause, next, back, mute, stop";+pub const CAPTION_ID: &str = "kicad-demo";+pub const NOTE_CAPTION_ID: &str = "kicad-demo-note";+pub const CTL_TOAST_ID: &str = "kicad-demo-ctl";++/// The panel's stops for a plan: prepare first, then one per beat.+pub fn panel_stops(plan: &[&Beat]) -> Vec<String> {+    let mut v = vec![PREPARE_STOP.to_string()];+    v.extend(plan.iter().map(|b| b.stop.to_string()));+    v+}++/// ab's demo panel bookkeeping. `ok`: None = untried, Some(true) = panel shown,+/// Some(false) = this ab predates the panel, use the toast for the rest of the tour.+#[derive(Clone, Debug, Default)]+pub struct PanelState {+    pub ok: Option<bool>,+    pub shown_once: bool,+    pub script_state: Option<String>,+    pub callback_accepted: Option<bool>,+    /// `at` stamps already honoured via the instant callback.+    pub seen: BTreeSet<String>,+}++pub static PANEL: Mutex<PanelState> = Mutex::new(PanelState { ok: None, shown_once: false, script_state: None, callback_accepted: None, seen: BTreeSet::new() });++pub fn panel() -> std::sync::MutexGuard<'static, PanelState> {+    PANEL.lock().unwrap_or_else(|e| e.into_inner())+}++// ── The job record ────────────────────────────────────────────────────────────++/// One job at a time. The record is a JSON map, exactly the Python `_JOB` dict, so the+/// progress reply is a projection of it.+pub static JOB: Mutex<Option<Map<String, Value>>> = Mutex::new(None);++pub fn job_snapshot() -> Map<String, Value> {+    JOB.lock().unwrap_or_else(|e| e.into_inner()).clone().unwrap_or_default()+}++pub fn job_get(key: &str) -> Value {+    JOB.lock().unwrap_or_else(|e| e.into_inner()).as_ref().and_then(|j| j.get(key).cloned()).unwrap_or(Value::Null)+}++pub fn job_update(fields: Value) {+    let mut g = JOB.lock().unwrap_or_else(|e| e.into_inner());+    let j = g.get_or_insert_with(Map::new);+    if let Some(m) = fields.as_object() {+        for (k, v) in m {+            j.insert(k.clone(), v.clone());+        }+    }+}++/// Append to an array field (capped, oldest dropped).+pub fn job_push(key: &str, item: Value, cap: usize) {+    let mut g = JOB.lock().unwrap_or_else(|e| e.into_inner());+    let j = g.get_or_insert_with(Map::new);+    let arr = j.entry(key).or_insert_with(|| json!([]));+    if let Some(a) = arr.as_array_mut() {+        a.push(item);+        if a.len() > cap {+            let n = a.len() - cap;+            a.drain(..n);+        }+    }+}++/// Pop the first pending evidence shot (one rides each progress poll).+pub fn job_pop_shot() -> Option<Value> {+    let mut g = JOB.lock().unwrap_or_else(|e| e.into_inner());+    let a = g.as_mut()?.get_mut("pendingShots")?.as_array_mut()?;+    if a.is_empty() { None } else { Some(a.remove(0)) }+}++pub fn job_active() -> bool {+    job_get("active").as_bool().unwrap_or(false)+}++pub fn epoch_secs() -> u64 {+    SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)+}++pub fn epoch_f64() -> f64 {+    SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0)+}++/// Claim the job slot for a new tour. Err carries the running job's progress triple.+pub fn job_start(muted: bool, est: u64, beat_count: usize, caller: Value, opts: Value) -> Result<String, Value> {+    let mut g = JOB.lock().unwrap_or_else(|e| e.into_inner());+    if let Some(j) = g.as_ref() {+        if j.get("active").and_then(Value::as_bool).unwrap_or(false) {+            return Err(json!({"percent": j.get("percent"), "step": j.get("step"), "elapsedSec": j.get("elapsedSec")}));+        }+    }+    let job_id = format!("demo-{}", SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or(0));+    let mut j = Map::new();+    for (k, v) in [+        ("active", json!(true)), ("done", json!(false)), ("step", json!("starting")), ("stepLabel", json!("Starting the tour")),+        ("beatIndex", json!(0)), ("beatCount", json!(beat_count)), ("percent", json!(0)), ("elapsedSec", json!(0)), ("estimatedSec", json!(est)),+        ("mutedRequested", json!(muted)), ("jobId", json!(job_id)), ("callerFwd", caller), ("options", opts), ("startedAt", json!(epoch_f64())),+    ] {+        j.insert(k.into(), v);+    }+    *g = Some(j);+    Ok(job_id)+}++/// The `kicad_demo {progress:true}` reply from a snapshot (pure; the shot pop is separate).+pub fn progress_reply(snap: &Map<String, Value>, shot: Option<Value>) -> Value {+    let mut out = json!({"success": true});+    for k in ["done", "step", "stepLabel", "beatIndex", "beatCount", "percent", "elapsedSec", "estimatedSec", "error", "panel", "muted", "muteSource", "lastPress", "presses", "paused", "stopping", "verified", "captions", "windowsRestored", "prepSummary", "jobId", "recording", "exited"] {+        out[k] = snap.get(k).cloned().unwrap_or(Value::Null);+    }+    // 0.9.199 (wiki issue #39, John): `active` used to be a BOOLEAN here and an ARRAY in+    // kicad_progress, the same key with two types from one bridge. `active` is the canonical+    // ARRAY of progress blocks everywhere; the boolean moved to `running`.+    let running = snap.get("active").and_then(Value::as_bool).unwrap_or(false);+    out["running"] = json!(running);+    let est = snap.get("estimatedSec").and_then(Value::as_f64);+    let el = snap.get("elapsedSec").and_then(Value::as_f64);+    out["active"] = if running {+        json!([{+            "phase": "kicad.demo",+            "stepLabel": snap.get("stepLabel").and_then(Value::as_str).or_else(|| snap.get("step").and_then(Value::as_str)).unwrap_or("running"),+            "percent": snap.get("percent"),+            "elapsedSec": snap.get("elapsedSec"),+            "estimatedSec": snap.get("estimatedSec"),+            "etaSec": match (est, el) { (Some(e), Some(l)) => json!(((e - l).max(0.0) * 10.0).round() / 10.0), _ => Value::Null },+            "confidence": "typical",+            "startKind": "cold",+            "beatIndex": snap.get("beatIndex"),+            "beatCount": snap.get("beatCount"),+        }])+    } else {+        json!([])+    };+    if let Some(s) = shot {+        out["stepShot"] = s;+    }+    let done = snap.get("done").and_then(Value::as_bool).unwrap_or(false);+    if done && snap.get("result").map(|r| !r.is_null()).unwrap_or(false) {+        out["result"] = snap["result"].clone();+        out["_hint"] = json!("Demo complete: result.state.shots[] is every KiCad window as base64 PNG (render them all); speak result.narrate. Tear down with kicad_close {\"force\":true} when the user is done looking.");+    } else {+        out["_hint"] = json!("Long-running job: render a real progress bar from active[0] (percent/elapsedSec/estimatedSec/stepLabel). Poll every 2-3s. `active` is an ARRAY (canonical contract); the boolean still-running flag is `running`. stepShot (when present) is the completed step's own window as base64 PNG: pin it as that step's evidence.");+    }+    out+}++/// Percent of the whole tour done, capped at 95 until the end.+pub fn percent_for(done_weight: u64, total: u64) -> i64 {+    if total == 0 {+        return 0;+    }+    ((100 * done_weight / total) as i64).min(95)+}++// ── Narration ─────────────────────────────────────────────────────────────────++/// The clips are synthesized at RELEASE BUILD time with adom-tts and ship in+/// resources/demo-audio/, so a beat's voice line starts instantly on the box. Playback+/// goes through the platform's audio method (no window, no PowerShell).+#[derive(Debug, Default)]+pub struct Narration {+    pub muted: bool,+    pub step: Option<String>,+    pub handle: Option<u64>,+    pub started_at: f64,+    pub ends_at: Option<Instant>,+    pub last_error: Option<String>,+}++pub static NARR: Mutex<Narration> = Mutex::new(Narration { muted: false, step: None, handle: None, started_at: 0.0, ends_at: None, last_error: None });++pub fn narr() -> std::sync::MutexGuard<'static, Narration> {+    NARR.lock().unwrap_or_else(|e| e.into_inner())+}++/// Where the clips are: `resources/demo-audio` next to the exe (the native zip lays the+/// binary at the root), else walking up from the exe for a source checkout.+pub fn audio_dir() -> Option<PathBuf> {+    if let Ok(env) = std::env::var("ADOM_KICAD_DEMO_AUDIO") {+        let p = PathBuf::from(env);+        if p.is_dir() {+            return Some(p);+        }+    }+    let exe = std::env::current_exe().ok()?;+    let mut dir = exe.parent();+    for _ in 0..6 {+        let d = dir?;+        let cand = d.join("resources").join("demo-audio");+        if cand.join("manifest.json").is_file() || cand.join("prepare.mp3").is_file() {+            return Some(cand);+        }+        dir = d.parent();+    }+    None+}++pub fn narration_file(step: &str) -> Option<PathBuf> {+    let p = audio_dir()?.join(format!("{step}.mp3"));+    if p.is_file() { Some(p) } else { None }+}++/// Every clip the native zip must ship, from the manifest (else the directory listing).+pub fn narration_files() -> Vec<String> {+    let Some(dir) = audio_dir() else { return Vec::new() };+    let mut v: Vec<String> = std::fs::read_dir(&dir).map(|rd| rd.flatten().filter(|e| e.path().extension().map(|x| x == "mp3").unwrap_or(false)).map(|e| e.file_name().to_string_lossy().to_string()).collect()).unwrap_or_default();+    v.sort();+    v+}++const MP3_BITRATES_V1_L3: [u32; 16] = [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0];+const MP3_BITRATES_V2_L3: [u32; 16] = [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0];++/// Length of a constant-bitrate MPEG layer III file from its first frame header (the+/// adom-tts clips are CBR 48 kbps at 24 kHz; measured against ffprobe to within 10 ms).+/// None when no frame header is found.+pub fn mp3_duration_secs(bytes: &[u8]) -> Option<f64> {+    let mut i = 0usize;+    if bytes.len() > 10 && &bytes[0..3] == b"ID3" {+        let size = ((bytes[6] as usize & 0x7f) << 21) | ((bytes[7] as usize & 0x7f) << 14) | ((bytes[8] as usize & 0x7f) << 7) | (bytes[9] as usize & 0x7f);+        i = 10 + size;+    }+    while i + 4 <= bytes.len() {+        if bytes[i] == 0xff && (bytes[i + 1] & 0xe0) == 0xe0 {+            let version = (bytes[i + 1] >> 3) & 0x3; // 3 = MPEG1, 2 = MPEG2, 0 = MPEG2.5+            let layer = (bytes[i + 1] >> 1) & 0x3; // 1 = layer III+            let br_idx = (bytes[i + 2] >> 4) as usize;+            let sr_idx = ((bytes[i + 2] >> 2) & 0x3) as usize;+            if layer == 1 && br_idx != 0 && br_idx != 15 && sr_idx != 3 && version != 1 {+                let kbps = if version == 3 { MP3_BITRATES_V1_L3[br_idx] } else { MP3_BITRATES_V2_L3[br_idx] };+                if kbps > 0 {+                    let audio_bytes = (bytes.len() - i) as f64;+                    return Some(audio_bytes * 8.0 / (kbps as f64 * 1000.0));+                }+            }+        }+        i += 1;+    }+    None+}++/// Start a clip (unless muted); remembers the step so an unmute can replay it.+pub fn play_narration(step: &str) -> Option<f64> {+    let mut n = narr();+    n.started_at = epoch_f64();+    n.step = Some(step.to_string());+    if n.muted {+        return None;+    }+    let Some(path) = narration_file(step) else { return None };+    stop_narration_locked(&mut n);+    let secs = std::fs::read(&path).ok().and_then(|b| mp3_duration_secs(&b)).unwrap_or(8.0);+    match native().tour_audio_play(&path, 0.9) {+        Ok(h) => {+            n.handle = Some(h);+            n.ends_at = Some(Instant::now() + Duration::from_secs_f64(secs + 0.3));+            n.last_error = None;+            Some(secs)+        }+        Err(e) => {+            n.handle = None;+            n.ends_at = None;+            n.last_error = Some(e);+            None+        }+    }+}++fn stop_narration_locked(n: &mut Narration) {+    if let Some(h) = n.handle.take() {+        let _ = native().tour_audio_stop(h);+    }+    n.ends_at = None;+}++pub fn stop_narration() {+    stop_narration_locked(&mut narr());+}++pub fn narration_playing() -> bool {+    let n = narr();+    n.handle.is_some() && n.ends_at.map(|t| Instant::now() < t).unwrap_or(false)+}++/// Let the current line finish (bounded), honouring exit and pending presses.+pub fn wait_narration(max: Duration) {+    let t0 = Instant::now();+    while t0.elapsed() < max {+        if !narration_playing() || pending_interrupt() {+            return;+        }+        std::thread::sleep(Duration::from_millis(200));+    }+}++/// The user's mute is the law: stop the sound within the beat, keep the show and captions+/// running; on unmute, replay the current beat's line.+pub fn apply_mute(muted: bool, source: &str) {+    let (was, step) = {+        let n = narr();+        (n.muted, n.step.clone())+    };+    narr().muted = muted;+    if muted && !was {+        stop_narration();+    } else if was && !muted {+        if let Some(s) = step {+            play_narration(&s);+        }+    }+    job_update(json!({"muted": muted, "muteSource": source}));+}++pub fn is_muted() -> bool {+    narr().muted+}++// ── Canvas measurements ───────────────────────────────────────────────────────++/// Where the DRAWN content sits on the GL canvas, in SCREEN pixels: background = the+/// most common sampled colour, content = samples that differ from it. `touches` says+/// which canvas edges the content reaches, so a pan or rotation that pushed the content+/// off the view is noticed (John, 2026-09-05: "don't be dumb about your zooming").+#[derive(Clone, Debug, Default, PartialEq)]+pub struct CanvasContent {+    pub canvas: (i32, i32, i32, i32),+    pub bbox: Option<(i32, i32, i32, i32)>,+    pub coverage: f64,+    pub touches: Vec<&'static str>,+}++impl CanvasContent {+    pub fn to_json(&self) -> Value {+        json!({"canvas": self.canvas, "bbox": self.bbox, "coverage": self.coverage, "touches": self.touches})+    }+}++/// `rgb` is the window capture (top-down RGB8), `gl` the wxGLCanvas rect in window pixels+/// (left, top, right, bottom), `origin` the window's screen position.+pub fn canvas_content(rgb: &[u8], width: u32, height: u32, gl: (i32, i32, i32, i32), origin: (i32, i32)) -> Option<CanvasContent> {+    let (w, h) = (width as i64, height as i64);+    if w <= 0 || h <= 0 || rgb.len() < (w * h * 3) as usize {+        return None;+    }+    let (l, t, r, b) = (gl.0 as i64, gl.1 as i64, gl.2 as i64, gl.3 as i64);+    let (ins_x, ins_y) = ((r - l) * 3 / 100, (b - t) * 3 / 100);+    let (x0, x1, y0, y1) = ((l + ins_x).max(0), (r - ins_x).min(w), (t + ins_y).max(0), (b - ins_y).min(h));+    if x1 <= x0 || y1 <= y0 {+        return None;+    }+    let n = 140;+    let (sx, sy) = (((x1 - x0) / n).max(1), ((y1 - y0) / n).max(1));+    let mut samples: Vec<(i64, i64, [u8; 3])> = Vec::new();+    let mut y = y0;+    while y < y1 {+        let mut x = x0;+        while x < x1 {+            let o = ((y * w + x) * 3) as usize;+            samples.push((x, y, [rgb[o] >> 4, rgb[o + 1] >> 4, rgb[o + 2] >> 4]));+            x += sx;+        }+        y += sy;+    }+    if samples.is_empty() {+        return None;+    }+    let mut counts: BTreeMap<[u8; 3], usize> = BTreeMap::new();+    for (_, _, q) in &samples {+        *counts.entry(*q).or_insert(0) += 1;+    }+    let bg = *counts.iter().max_by_key(|(_, c)| **c).map(|(k, _)| k).unwrap();+    let is_bg = |q: &[u8; 3]| (q[0] as i32 - bg[0] as i32).abs() <= 1 && (q[1] as i32 - bg[1] as i32).abs() <= 1 && (q[2] as i32 - bg[2] as i32).abs() <= 1;+    let cont: Vec<(i64, i64)> = samples.iter().filter(|(_, _, q)| !is_bg(q)).map(|(x, y, _)| (*x, *y)).collect();+    let cov = cont.len() as f64 / samples.len() as f64;+    let (wl, wt) = (origin.0 as i64, origin.1 as i64);+    let canvas = ((wl + l) as i32, (wt + t) as i32, (wl + r) as i32, (wt + b) as i32);+    if cont.is_empty() {+        return Some(CanvasContent { canvas, bbox: None, coverage: 0.0, touches: vec![] });+    }+    let mut xs: Vec<i64> = cont.iter().map(|c| c.0).collect();+    let mut ys: Vec<i64> = cont.iter().map(|c| c.1).collect();+    xs.sort_unstable();+    ys.sort_unstable();+    // trim 2% outliers (stray grid/axis pixels) before taking the box+    let k = (xs.len() as f64 * 0.02) as usize;+    let (bx0, bx1, by0, by1) = (xs[k], xs[xs.len() - 1 - k], ys[k], ys[ys.len() - 1 - k]);+    let mut touches = Vec::new();+    if bx0 <= x0 + 2 * sx { touches.push("left") }+    if bx1 >= x1 - 2 * sx { touches.push("right") }+    if by0 <= y0 + 2 * sy { touches.push("top") }+    if by1 >= y1 - 2 * sy { touches.push("bottom") }+    Some(CanvasContent { canvas, bbox: Some(((wl + bx0) as i32, (wt + by0) as i32, (wl + bx1) as i32, (wt + by1) as i32)), coverage: round3(cov), touches })+}++/// FNV-1a over a capture, for the "two captures 0.8 s apart are identical" settle test.+pub fn fnv1a(bytes: &[u8]) -> u64 {+    let mut h: u64 = 0xcbf29ce484222325;+    for b in bytes {+        h ^= *b as u64;+        h = h.wrapping_mul(0x100000001b3);+    }+    h+}++// ── Motion plans (demo_motion.py) ─────────────────────────────────────────────++pub const VK_HOME: u16 = 0x24;++/// The 3D viewer's View-menu commands, resolved from the live menu bar: labels are stable+/// across KiCad 8/9/10, ids are not. Input: every leaf (label, id) of the menu tree.+pub fn menu_ids_from(items: &[(String, u32)]) -> BTreeMap<&'static str, u32> {+    let wants: [(&str, [&str; 2]); 7] = [+        ("fit", ["zoom", "fit"]), ("zoom_in", ["zoom", "in"]), ("zoom_out", ["zoom", "out"]),+        ("rotx_cw", ["rotate x", "clockwise"]), ("rotx_ccw", ["rotate x", "counter"]),+        ("rotz_cw", ["rotate z", "clockwise"]), ("rotz_ccw", ["rotate z", "counter"]),+    ];+    let mut ids = BTreeMap::new();+    for (key, words) in wants {+        let hit = items.iter().find(|(label, id)| {+            let low = label.replace('&', "").to_lowercase();+            *id != 0 && words.iter().all(|w| low.contains(w))+        });+        if let Some((_, id)) = hit {+            ids.insert(key, *id);+        }+    }+    // "clockwise" is a substring of "counterclockwise": guard the pairs.+    if ids.get("rotx_cw").is_some() && ids.get("rotx_cw") == ids.get("rotx_ccw") {+        ids.remove("rotx_ccw");+    }+    if ids.get("rotz_cw").is_some() && ids.get("rotz_cw") == ids.get("rotz_ccw") {+        ids.remove("rotz_ccw");+    }+    ids+}++/// One 3D step through the menu: (name, command id, repetitions, dwell after each).+#[derive(Clone, Debug, PartialEq, Eq)]+pub struct MenuStep {+    pub name: &'static str,+    pub id: u32,+    pub reps: u32,+    pub dwell_ms: u64,+}++/// Deterministic 3D motion through KiCad's own View menu (WM_COMMAND, 10 degrees per+/// rotate, no mouse, no foreground needed): mouse drags over-rotated the chip viewer and+/// did nothing in the board viewer on 2026-09-03. NO trailing Zoom to Fit: in the 3D+/// viewer it also RESETS the orientation, so the beat ended top-down on John's laptop.+/// Zoom in/out net to zero, so the board is still framed at the opening fit, now+/// isometric. None when the menu lacks the four commands the plan needs.+pub fn plan_3d_menu(ids: &BTreeMap<&'static str, u32>) -> Option<Vec<MenuStep>> {+    let need = ["rotx_cw", "rotz_cw", "zoom_in", "zoom_out"];+    if !need.iter().all(|k| ids.contains_key(k)) {+        return None;+    }+    let g = |k: &str| ids[k];+    let mut plan = Vec::new();+    if let Some(fit) = ids.get("fit") {+        plan.push(MenuStep { name: "extents", id: *fit, reps: 1, dwell_ms: 600 });+    }+    plan.extend([+        MenuStep { name: "tilt", id: g("rotx_cw"), reps: 3, dwell_ms: 280 },+        MenuStep { name: "turn", id: g("rotz_cw"), reps: 4, dwell_ms: 280 },+        MenuStep { name: "zoom-in", id: g("zoom_in"), reps: 1, dwell_ms: 500 },+        MenuStep { name: "orbit", id: g("rotz_cw"), reps: 6, dwell_ms: 320 },+        MenuStep { name: "zoom-out", id: g("zoom_out"), reps: 1, dwell_ms: 500 },+        MenuStep { name: "orbit-back", id: *ids.get("rotz_ccw").unwrap_or(&g("rotz_cw")), reps: 4, dwell_ms: 320 },+    ]);+    Some(plan)+}++/// A framing correction after a 3D step (John, 2026-09-05): board clipped on two opposite+/// edges -> one zoom-out; board tiny -> one zoom-in. Returns the menu key to fire.+pub fn framing_correction(m: &CanvasContent) -> Option<&'static str> {+    let t: BTreeSet<&str> = m.touches.iter().copied().collect();+    if (t.contains("left") && t.contains("right")) || (t.contains("top") && t.contains("bottom")) {+        return Some("zoom_out");+    }+    if m.coverage < 0.10 && m.bbox.is_some() {+        return Some("zoom_in");+    }+    None+}++/// A real-input gesture (the mouse fallback and every 2D beat).+#[derive(Clone, Debug, PartialEq, Eq)]+pub enum Gesture {+    Key(u16),+    /// clicks > 0 zooms in (wheel up), < 0 out, at screen (x, y).+    Wheel { clicks: i32, x: i32, y: i32 },+    Drag { button: &'static str, x0: i32, y0: i32, x1: i32, y1: i32, steps: u32, dt_ms: u64 },+}++/// The drawing canvas sits right of the editors' left panels: aim at 62% across and 55%+/// down the window, which is canvas in every KiCad editor and in the 3D viewer.+pub fn canvas_center(rect: (i32, i32, i32, i32)) -> (i32, i32) {+    let (l, t, w, h) = rect;+    (l + (w as f64 * 0.62) as i32, t + (h as f64 * 0.55) as i32)+}++/// John, 2026-09-03: "your zooming is too close ... zoom extents, then be smart about how+/// close". After extents (Home, done by the caller, who measures the drawing to find+/// `aim`), wheel-zoom AT the featured part (KiCad zooms about the cursor), pan it to the+/// centre, one more step in for a whole sheet or board, a short pan and back, zoom back+/// out, and END on extents so the frame the user is left with is the whole thing.+pub fn plan_2d(cx: i32, cy: i32, w: i32, h: i32, aim: (i32, i32), zoom: (i32, i32)) -> Vec<(&'static str, Gesture)> {+    let (dx, dy) = ((w as f64 * 0.10) as i32, (h as f64 * 0.08) as i32);+    let (z1, z2) = zoom;+    let mut plan = vec![+        ("zoom-in", Gesture::Wheel { clicks: z1, x: aim.0, y: aim.1 }),+        ("centre", Gesture::Drag { button: "middle", x0: aim.0, y0: aim.1, x1: cx, y1: cy, steps: 18, dt_ms: 30 }),+    ];+    if z2 != 0 {+        plan.push(("zoom-in-2", Gesture::Wheel { clicks: z2, x: cx, y: cy }));+    }+    plan.extend([+        ("pan-look", Gesture::Drag { button: "middle", x0: cx, y0: cy, x1: cx - dx, y1: cy - dy, steps: 14, dt_ms: 30 }),+        ("pan-back", Gesture::Drag { button: "middle", x0: cx - dx, y0: cy - dy, x1: cx, y1: cy, steps: 14, dt_ms: 30 }),+        ("zoom-out", Gesture::Wheel { clicks: -(z1 + z2), x: cx, y: cy }),+        ("extents-2", Gesture::Key(VK_HOME)),+    ]);+    plan+}++/// The mouse fallback for a 3D viewer whose menu could not be resolved. Measured+/// 2026-09-03 on a 3840 px wide viewer: KiCad's trackball is roughly 0.1 degree per pixel;+/// isometric wants about 30 degrees of tilt and 40 of yaw, then gentle orbits.+pub fn plan_3d_mouse(cx: i32, cy: i32, w: i32, h: i32) -> Vec<(&'static str, Gesture)> {+    let tilt = (h as f64 * 0.12) as i32;+    let yaw = (w as f64 * 0.03) as i32;+    vec![+        ("fit", Gesture::Key(VK_HOME)),+        ("isometric", Gesture::Drag { button: "left", x0: cx, y0: cy, x1: cx - (w as f64 * 0.035) as i32, y1: cy - tilt, steps: 28, dt_ms: 35 }),+        ("orbit-1", Gesture::Drag { button: "left", x0: cx, y0: cy, x1: cx + yaw, y1: cy, steps: 30, dt_ms: 40 }),+        ("zoom-in", Gesture::Wheel { clicks: 2, x: cx, y: cy }),+        ("orbit-2", Gesture::Drag { button: "left", x0: cx, y0: cy, x1: cx + yaw, y1: cy, steps: 30, dt_ms: 40 }),+        ("zoom-out", Gesture::Wheel { clicks: -2, x: cx, y: cy }),+        ("orbit-3", Gesture::Drag { button: "left", x0: cx, y0: cy, x1: cx - yaw, y1: cy, steps: 30, dt_ms: 40 }),+    ]+}++/// The aim point for the 2D zoom: the featured part's fraction mapped onto the measured+/// drawing box, else the canvas centre.+pub fn aim_point(bbox: Option<(i32, i32, i32, i32)>, focus: Option<(f64, f64)>, center: (i32, i32)) -> (i32, i32) {+    match (bbox, focus) {+        (Some((l, t, r, b)), Some((fx, fy))) => (l + (fx * (r - l) as f64) as i32, t + (fy * (b - t) as f64) as i32),+        _ => center,+    }+}++// ── The final result ──────────────────────────────────────────────────────────++/// The `result.steps[]` row for one beat record (a JSON object with the per-beat fields).+pub fn step_row(r: &Value) -> Value {+    let ok = r["ok"].as_bool().unwrap_or(false);+    let raw = &r["raw"];+    json!({+        "step": r["step"], "title": r["title"], "ok": ok, "pointOut": r["pointOut"],+        "foregrounded": r["foregrounded"], "motion": r["motion"], "hwnd": r["hwnd"], "window": r["window"],+        "verified": r["verified"], "placed": r["placed"],+        "startedAt": r["startedAt"], "narrationAt": r["narrationAt"],+        "partAAt": r["partAAt"], "partBAt": r["partBAt"], "partBEndAt": r["partBEndAt"],+        "failReason": if ok { Value::Null } else { json!({+            "handlerSuccess": raw.get("success").and_then(Value::as_bool).unwrap_or(false),+            "error": raw.get("error").map(|e| e.to_string().trim_matches('"').chars().take(200).collect::<String>()).unwrap_or_default(),+            "output": raw.get("output").map(|e| e.to_string().trim_matches('"').chars().take(200).collect::<String>()).unwrap_or_default(),+        }) },+    })+}++/// `result` for a finished (or exited) background tour.+pub fn final_result(results: &[Value], total_beats: usize, exited: bool, state: Value) -> Value {+    let ok_ct = results.iter().filter(|r| r["ok"].as_bool().unwrap_or(false)).count();+    let failed: Vec<Value> = results.iter().filter(|r| !r["ok"].as_bool().unwrap_or(false)).map(|r| r["step"].clone()).collect();+    if exited {+        return json!({"openedBeats": ok_ct, "totalBeats": total_beats, "exited": true, "failedSteps": failed});+    }+    json!({+        "openedBeats": ok_ct, "totalBeats": total_beats, "failedSteps": failed,+        "steps": results.iter().map(step_row).collect::<Vec<_>>(),+        "narrate": results.iter().filter(|r| r["ok"].as_bool().unwrap_or(false)).map(|r| r["say"].as_str().unwrap_or("").to_string()).collect::<Vec<_>>().join(" "),+        "state": state,+    })+}++/// The staged reply's narration: a failed beat must STILL say something useful and hand+/// the user forward ("never end a demo on an error screen").+pub fn staged_narration(results: &[Value], installed_now: bool) -> String {+    let mut lines: Vec<String> = Vec::new();+    if installed_now {+        lines.push("I installed KiCad for you first: that is the bridge doing real work on your machine, not a video.".into());+    }+    for r in results {+        if r["ok"].as_bool().unwrap_or(false) {+            lines.push(r["say"].as_str().unwrap_or("").to_string());+        } else {+            lines.push(format!("{} didn't open on this machine, so I'll skip it rather than leave you staring at nothing; the rest of the tour still works.", r["title"].as_str().unwrap_or(r["step"].as_str().unwrap_or("that beat"))));+        }+    }+    lines.into_iter().filter(|l| !l.is_empty()).collect::<Vec<_>>().join(" ")+}++/// The step after `last` in the tour, if any.+pub fn next_step(last: &str) -> Option<&'static str> {+    let i = STEPS.iter().position(|s| *s == last)?;+    STEPS.get(i + 1).copied()+}++pub fn remaining_steps(last: &str) -> Vec<&'static str> {+    match STEPS.iter().position(|s| *s == last) {+        Some(i) => STEPS[i + 1..].to_vec(),+        None => Vec::new(),+    }+}++/// Reset every piece of process-wide tour state before a run (a mute, a queued press or a+/// failed panel show from an EARLIER tour must not steer this one).+pub fn reset_for_run(muted: bool) {+    set_exit(false);+    {+        let mut p = panel();+        p.ok = None;+        p.shown_once = false;+        p.script_state = None;+        p.callback_accepted = None;+        p.seen.clear();+    }+    {+        let mut n = narr();+        stop_narration_locked(&mut n);+        n.muted = muted;+        n.step = None;+    }+    ctrl().clear();+}++#[cfg(test)]+mod tests {+    use super::*;++    const SCH: &str = r#"(kicad_sch (version 20231120) (generator "eeschema")+  (paper "A3")+  (lib_symbols+    (symbol "MCU_ST_STM32G4:STM32G431C_6-8-B_Tx"+      (pin_names (offset 1.016))+      (property "Reference" "U" (at 0 0 0))+      (symbol "STM32G431C_6-8-B_Tx_1_1"+        (pin bidirectional line (at -12.7 5.08 0) (length 2.54) (name "VDD") (number "1"))+      )+    )+    (symbol "Device:R" (property "Reference" "R" (at 0 0 0)))+  )+  (symbol+    (lib_id "MCU_ST_STM32G4:STM32G431C_6-8-B_Tx")+    (at 84 160.38 0)+    (property "Reference" "U5" (at 1 2 0))+  )+  (symbol (lib_id "Device:R") (at 10 10 0) (property "Reference" "R1" (at 1 2 0)))+)"#;++    const PCB: &str = "(kicad_pcb (version 20240108)\n\t(gr_line\n\t\t(start 100 60)\n\t\t(end 170 60)\n\t\t(layer \"Edge.Cuts\")\n\t)\n\t(gr_line\n\t\t(start 170 100)\n\t\t(end 100 100)\n\t\t(layer \"Edge.Cuts\")\n\t)\n\t(footprint \"Package_QFP:LQFP-48_7x7mm_P0.5mm\"\n\t\t(layer \"F.Cu\")\n\t\t(uuid \"aaaa\")\n\t\t(at 121 86.4 90)\n\t\t(path \"/x/y\")\n\t\t(sheetname \"Root\")\n\t\t(property \"Reference\" \"U5\"\n\t\t\t(at 0 -4 0)\n\t\t)\n\t\t(property \"Value\" \"STM32G431C_6-8-B_Tx\"\n\t\t\t(at 0 4 0)\n\t\t)\n\t\t(pad \"1\" smd roundrect\n\t\t\t(at -3.75 -2.75)\n\t\t\t(net 12 \"GND\")\n\t\t\t(pinfunction \"VBAT\")\n\t\t\t(pintype \"power_in\")\n\t\t\t(uuid \"pad1\")\n\t\t)\n\t\t(model \"C:/Users/caleb/parts/lqfp.step\"\n\t\t\t(offset (xyz 0 0 0))\n\t\t)\n\t)\n\t(footprint \"MOSFET_CDT:TDSON-8-1_BSC016N06NS\"\n\t\t(at 150 70)\n\t\t(property \"Reference\" \"Q1\"\n\t\t\t(at 0 0 0)\n\t\t)\n\t\t(model \"${KICAD10_3DMODEL_DIR}/Nope.3dshapes/missing.step\"\n\t\t\t(offset (xyz 0 0 0))\n\t\t)\n\t)\n\t(footprint \"Capacitor_SMD:C_0402\"\n\t\t(at 1 1)\n\t\t(property \"Reference\" \"C1\"\n\t\t\t(at 0 0 0)\n\t\t)\n\t\t(model \"${KICAD10_3DMODEL_DIR}/Capacitor_SMD.3dshapes/C_0402.wrl\"\n\t\t\t(offset (xyz 0 0 0))\n\t\t)\n\t)\n\t(footprint \"Weird:NoModelAnywhere\"\n\t\t(at 2 2)\n\t\t(property \"Reference\" \"J9\"\n\t\t\t(at 0 0 0)\n\t\t)\n\t)\n)\n";++    #[test]+    fn beat_table_and_plan() {+        assert_eq!(BEATS.len(), 6);+        assert_eq!(BEATS.iter().map(|b| b.step).collect::<Vec<_>>(), STEPS);+        assert_eq!(beat_plan(None).unwrap().len(), 6);+        let sub = beat_plan(Some(&["board3d".to_string(), "symbol".to_string()])).unwrap();+        assert_eq!(sub.iter().map(|b| b.step).collect::<Vec<_>>(), vec!["symbol", "board3d"], "tour order, not caller order");+        assert!(beat_plan(Some(&["nope".to_string()])).unwrap_err().contains("valid steps"));+        assert_eq!(beat_est("prepare"), 25);+        assert_eq!(beat_est("board3d"), 30);+        assert_eq!(beat_est("footprint3d"), 16);+        assert_eq!(total_est(), 125);+        assert_eq!(step_label("footprint3d"), "part_3d");+        assert_eq!(caption_a(2, 7, BEATS[0].caption_a), "KiCad tour 2/7: the STM32G431 symbol, lifted from the board's schematic");+        assert!(say("footprint3d", false).contains("Pads only"));+        assert!(say("footprint3d", true).contains("LQFP-48"));+        assert_eq!(point_out("board3d", false).len(), 2);+        assert_eq!(point_out("board3d", true).len(), 3);+        assert_eq!(panel_stops(&beat_plan(None).unwrap()).len(), 7);+        assert_eq!(next_step("symbol"), Some("footprint"));+        assert_eq!(next_step("board3d"), None);+        assert_eq!(remaining_steps("board"), vec!["board3d"]);+        for b in BEATS {+            for t in [b.title, b.caption_a, b.caption_b, b.stop] {+                assert!(!t.contains('\u{2014}'), "{} carries an em dash", b.step);+            }+        }+    }++    #[test]+    fn titles_match_with_either_dash() {+        let t = tour_titles("esc-g431");+        assert_eq!(t.board, "esc-g431 \u{2014} PCB Editor");+        assert!(title_matches("esc-g431 \u{2014} PCB Editor", &t.board));+        assert!(title_matches("*esc-g431 - PCB Editor", &t.board), "an older KiCad spells the separator with a hyphen");+        assert!(title_matches("ADOM:STM32G431C_6-8-B_Tx \u{2014} Symbol Editor", &t.symbol));+        assert!(!title_matches("PCB Editor", &t.board));+        assert!(title_matches("anything", ""));+        assert_eq!(doc_stem(Some("C:/x/esc-g431.kicad_pcb"), None), "esc-g431");+        assert_eq!(doc_stem(None, None), "esc-g431");+        assert!(is_stale_tour_window("3D Viewer"));+        assert!(is_stale_tour_window("Adom:X \u{2014} Footprint Editor"));+        assert!(is_stale_tour_window(" PCB Editor "));+        assert!(!is_stale_tour_window("esc-g431 \u{2014} PCB Editor"));+    }++    #[test]+    fn extracts_symbol_and_footprint() {+        let sym = extract_symbol(SCH, CHIP_LIB_ID).unwrap();+        assert!(sym.starts_with("(kicad_symbol_lib (version 20231120) (generator \"adom-demo\")"));+        assert!(sym.contains("(symbol \"STM32G431C_6-8-B_Tx\"\n"));+        assert!(!sym.contains("MCU_ST_STM32G4:"));+        assert!(sym.contains("(number \"1\")"));+        assert!(!sym.contains("Device:R"));+        assert!(extract_symbol(SCH, "Nope:X").unwrap_err().contains("not in lib_symbols"));+        let fp = extract_footprint(PCB, CHIP_FOOTPRINT, CHIP_REF, IC_FP).unwrap();+        assert!(fp.starts_with("(footprint \"LQFP-48_STM32G431\"\n\t(version 20240108)\n\t(generator \"adom-demo\")"));+        assert!(!fp.contains("(at 121 86.4 90)"), "placement stripped");+        assert!(fp.contains("(at -3.75 -2.75)"), "pad positions kept");+        assert!(!fp.contains("(net "));+        assert!(!fp.contains("(pinfunction"));+        assert!(!fp.contains("(path "));+        assert!(!fp.contains("(uuid \"aaaa\")"));+        assert!(fp.contains("(uuid \"pad1\")"), "pad uuids stay (only the two-tab footprint uuid goes)");+        assert!(fp.contains("(property \"Reference\" \"REF**\""));+        assert!(fp.contains("(property \"Value\" \"LQFP-48_STM32G431\""));+        assert!(fp.contains("(model "), "the 3D model reference is kept");+        assert!(extract_footprint(PCB, CHIP_FOOTPRINT, "U99", IC_FP).is_err());+        let (lib, fpid) = chip_ids_for_ref(SCH, PCB, "U5");+        assert_eq!(lib.as_deref(), Some(CHIP_LIB_ID));+        assert_eq!(fpid.as_deref(), Some(CHIP_FOOTPRINT));+        assert_eq!(chip_ids_for_ref(SCH, PCB, "R1"), (Some("Device:R".into()), None));+    }++    #[test]+    fn attaches_standard_models_and_drops_dead_ones() {+        let have = ["Package_QFP.3dshapes/LQFP-48_7x7mm_P0.5mm.step", "Package_TO_SOT_SMD.3dshapes/TDSON-8-1.step", "Capacitor_SMD.3dshapes/C_0402.wrl"];+        let exists = |rel: &str| have.contains(&rel);+        let (out, rep) = attach_standard_models(PCB, &exists);+        assert_eq!(rep.dead_dropped, 2, "the author's disk path and the missing standard body");+        assert_eq!(rep.attached, 2, "the LQFP and the aliased MOSFET");+        assert_eq!(rep.skipped, 1);+        assert_eq!(rep.missing_models, vec!["Weird:NoModelAnywhere".to_string()]);+        assert!(!out.contains("caleb"));+        assert!(out.contains("${KICAD10_3DMODEL_DIR}/Package_QFP.3dshapes/LQFP-48_7x7mm_P0.5mm.step"));+        assert!(out.contains("${KICAD10_3DMODEL_DIR}/Package_TO_SOT_SMD.3dshapes/TDSON-8-1.step"));+        assert!(out.contains("Capacitor_SMD.3dshapes/C_0402.wrl"), "a resolvable standard body is left alone");+        assert_eq!(out.matches("(model ").count(), 3);+        // Balanced: every footprint block still closes.+        for h in ["Package_QFP", "MOSFET_CDT", "Capacitor_SMD", "Weird:"] {+            let i = out.find(&format!("(footprint \"{h}")).unwrap();+            assert!(sexpr_block(&out, i).ends_with(')'));+        }+        // Idempotent on the patched text.+        let (again, rep2) = attach_standard_models(&out, &exists);+        assert_eq!(again, out);+        assert_eq!(rep2.attached, 0);+        assert_eq!(rep2.dead_dropped, 0);+        assert!(model_is_dead("D:\\parts\\x.step", &exists));+        assert!(model_is_dead("../x.step", &exists));+        assert!(model_is_dead("${ADOM_MODELS}/x.step", &exists));+        assert!(!model_is_dead("${KICAD10_3DMODEL_DIR}/Capacitor_SMD.3dshapes/C_0402.wrl", &exists));+        assert!(!model_is_dead("${KIPRJMOD}/x.step", &exists));+    }++    #[test]+    fn focus_fractions_find_the_mcu() {+        let f = focus_fractions(SCH, PCB, CHIP_LIB_ID, CHIP_REF);+        assert_eq!(f["schematic"], (0.2, 0.54));+        assert_eq!(f["board"], (0.3, 0.66));+        let portrait = SCH.replace("(paper \"A3\")", "(paper \"A3\" portrait)");+        let f = focus_fractions(&portrait, PCB, CHIP_LIB_ID, CHIP_REF);+        assert_eq!(f["schematic"], (0.283, 0.382));+        assert!(focus_fractions("", "", CHIP_LIB_ID, CHIP_REF).is_empty());+    }++    #[test]+    fn layout_and_front_window() {+        assert_eq!(place_rect((0, 0, 1000, 500), (0.5, 0.5), 0.98), (10, 5, 980, 490));+        assert_eq!(place_rect((100, 50, 1000, 500), (0.0, 1.0), 0.8), (100, 150, 800, 400));+        let mons = json!([{"primary": false, "bounds": {"x": -1920, "y": 0, "width": 1920, "height": 1080}}, {"primary": true, "workArea": {"x": 0, "y": 0, "width": 3840, "height": 2100}, "bounds": {"x": 0, "y": 0, "width": 3840, "height": 2160}}]);+        assert_eq!(primary_monitor(&mons), Some((0, 0, 3840, 2100)), "workArea keeps the windows off the taskbar");+        let one = json!([{"bounds": {"left": 5, "top": 6, "width": 100, "height": 50}}]);+        assert_eq!(primary_monitor(&one), Some((5, 6, 100, 50)));+        assert_eq!(primary_monitor(&json!([])), None);+        let rows = vec![+            ZRow { hwnd: 1, z: Some(0), minimized: false, title: "PopupHost".into(), image: "x.exe".into() },+            ZRow { hwnd: 2, z: Some(1), minimized: false, title: "Program Manager".into(), image: "explorer.exe".into() },+            ZRow { hwnd: 3, z: Some(2), minimized: true, title: "esc-g431 - PCB Editor".into(), image: "pcbnew.exe".into() },+            ZRow { hwnd: 4, z: Some(3), minimized: false, title: "3D Viewer".into(), image: "pcbnew.exe".into() },+            ZRow { hwnd: 5, z: None, minimized: false, title: "no z".into(), image: "".into() },+        ];+        assert_eq!(front_hwnd(&rows), 4);+        assert_eq!(front_hwnd(&[]), 0);+    }++    #[test]+    fn transport_is_fifo_and_exit_jumps_the_queue() {+        let mut t = Transport::default();+        t.push(Cmd::Next);+        t.push(Cmd::Prev);+        assert!(t.pending_interrupt());+        assert_eq!(t.pop(), Some(Cmd::Next));+        assert_eq!(t.pop(), Some(Cmd::Prev));+        assert_eq!(t.pop(), None);+        for _ in 0..20 {+            t.push(Cmd::Play);+        }+        assert_eq!(t.queue.len(), 8, "capped");+        t.push(Cmd::Exit);+        assert_eq!(t.queue, vec![Cmd::Exit]);+        t.clear();+        t.push(Cmd::Play);+        assert!(!t.pending_interrupt(), "play does not cut a beat");+        t.push_front(Cmd::Pause);+        assert_eq!(t.head(), Some(Cmd::Pause));+        assert_eq!(map_button("Back"), Some(Cmd::Prev));+        assert_eq!(map_button("stop"), Some(Cmd::Exit));+        assert_eq!(map_button("mute"), None);+        assert_eq!(Cmd::parse("PREV"), Some(Cmd::Prev));+        assert_eq!(Cmd::parse("mute"), None);+        assert_eq!(ack_text("unmute"), Some("Remote: narration back on"));+        assert_eq!(apply_between_beats(3, Some(Cmd::Prev)), (1, false));+        assert_eq!(apply_between_beats(1, Some(Cmd::Prev)), (0, false));+        assert_eq!(apply_between_beats(2, Some(Cmd::Exit)), (2, true));+        assert_eq!(apply_between_beats(2, Some(Cmd::Next)), (2, false));+        assert_eq!(apply_between_beats(2, None), (2, false));+        // The process-wide queue.+        reset_for_run(false);+        set_control(Cmd::Next);+        assert!(pending_interrupt());+        assert_eq!(ctrl().pop(), Some(Cmd::Next));+        assert!(!pending_interrupt());+        set_exit(true);+        assert!(pending_interrupt());+        reset_for_run(true);+        assert!(!exit_requested());+        assert!(is_muted());+        reset_for_run(false);+    }++    #[test]+    fn job_record_and_progress_reply() {+        {+            let mut g = JOB.lock().unwrap_or_else(|e| e.into_inner());+            *g = None;+        }+        let id = job_start(true, 125, 6, json!({}), json!({"record": false})).unwrap();+        assert!(id.starts_with("demo-"));+        assert!(job_active());+        assert!(job_start(false, 125, 6, json!({}), json!({})).is_err(), "one job at a time");+        job_update(json!({"step": "symbol", "stepLabel": "Opening symbol", "beatIndex": 1, "percent": 20, "elapsedSec": 30}));+        job_push("pendingShots", json!({"step": "a"}), 10);+        job_push("pendingShots", json!({"step": "b"}), 10);+        for i in 0..30 {+            job_push("presses", json!({"n": i}), 8);+        }+        assert_eq!(job_get("presses").as_array().unwrap().len(), 8);+        let shot = job_pop_shot();+        let r = progress_reply(&job_snapshot(), shot);+        assert_eq!(r["running"], json!(true));+        assert_eq!(r["active"][0]["phase"], json!("kicad.demo"));+        assert_eq!(r["active"][0]["etaSec"], json!(95.0));+        assert_eq!(r["active"][0]["beatIndex"], json!(1));+        assert_eq!(r["stepShot"]["step"], json!("a"));+        assert_eq!(r["muted"], Value::Null, "the thread sets muted when it applies it");+        assert_eq!(r["jobId"], json!(id));+        assert!(r["_hint"].as_str().unwrap().contains("progress bar"));+        job_update(json!({"active": false, "done": true, "percent": 100, "result": {"openedBeats": 6}}));+        let r = progress_reply(&job_snapshot(), job_pop_shot());+        assert_eq!(r["running"], json!(false));+        assert_eq!(r["active"], json!([]));+        assert_eq!(r["stepShot"]["step"], json!("b"));+        assert_eq!(r["result"]["openedBeats"], json!(6));+        assert!(r["_hint"].as_str().unwrap().contains("Demo complete"));+        assert!(job_pop_shot().is_none());+        assert_eq!(percent_for(25, 125), 20);+        assert_eq!(percent_for(125, 125), 95);+        assert_eq!(percent_for(0, 0), 0);+    }++    #[test]+    fn mp3_duration_from_the_frame_header() {+        // A 48 kbps MPEG-2 layer III header (24 kHz): 0xFF 0xF3 0x64 0xC4, no ID3 tag.+        let mut b = vec![0xffu8, 0xf3, 0x64, 0xc4];+        b.resize(48_000 / 8 * 10, 0);+        let d = mp3_duration_secs(&b).unwrap();+        assert!((d - 10.0).abs() < 0.01, "{d}");+        // With an ID3v2 tag of 100 bytes in front.+        let mut tagged = vec![b'I', b'D', b'3', 3, 0, 0, 0, 0, 0, 100];+        tagged.resize(110, 0);+        tagged.extend_from_slice(&b);+        let d = mp3_duration_secs(&tagged).unwrap();+        assert!((d - 10.0).abs() < 0.01, "{d}");+        // MPEG-1 128 kbps at 44.1 kHz: 0xFF 0xFB 0x90 0x00.+        let mut m1 = vec![0xffu8, 0xfb, 0x90, 0x00];+        m1.resize(128_000 / 8 * 3, 0);+        assert!((mp3_duration_secs(&m1).unwrap() - 3.0).abs() < 0.01);+        assert_eq!(mp3_duration_secs(b"not audio at all"), None);+        // The shipped clips, when the checkout is at hand: symbol-a is 9.9 s per ffprobe.+        let here = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../resources/demo-audio/symbol-a.mp3");+        if let Ok(bytes) = std::fs::read(&here) {+            let d = mp3_duration_secs(&bytes).unwrap();+            assert!((d - 9.9).abs() < 0.2, "{d}");+        }+    }++    #[test]+    fn canvas_content_finds_the_drawing_and_its_edges() {+        // 400x300 window, canvas at (100, 0)-(400, 300), grey background, a red box in+        // the lower-left quarter of the canvas.+        let (w, h) = (400u32, 300u32);+        let mut rgb = vec![60u8; (w * h * 3) as usize];+        for y in 150..290 {+            for x in 110..250 {+                let o = ((y * w + x) * 3) as usize;+                rgb[o] = 220;+                rgb[o + 1] = 30;+                rgb[o + 2] = 30;+            }+        }+        let c = canvas_content(&rgb, w, h, (100, 0, 400, 300), (1000, 500)).unwrap();+        assert_eq!(c.canvas, (1100, 500, 1400, 800));+        let bb = c.bbox.unwrap();+        assert!(bb.0 >= 1105 && bb.0 <= 1115, "{bb:?}");+        assert!(bb.2 >= 1240 && bb.2 <= 1252, "{bb:?}");+        assert!(bb.1 >= 648 && bb.1 <= 660, "{bb:?}");+        assert!(bb.3 >= 780 && bb.3 <= 790, "{bb:?}");+        assert!(c.coverage > 0.15 && c.coverage < 0.30, "{}", c.coverage);+        assert_eq!(c.touches, vec!["left", "bottom"]);+        assert_eq!(framing_correction(&c), None);+        let blank = canvas_content(&vec![60u8; (w * h * 3) as usize], w, h, (100, 0, 400, 300), (0, 0)).unwrap();+        assert_eq!(blank.bbox, None);+        assert_eq!(blank.coverage, 0.0);+        let clipped = CanvasContent { canvas: (0, 0, 1, 1), bbox: Some((0, 0, 1, 1)), coverage: 0.9, touches: vec!["left", "right"] };+        assert_eq!(framing_correction(&clipped), Some("zoom_out"));+        let tiny = CanvasContent { canvas: (0, 0, 1, 1), bbox: Some((0, 0, 1, 1)), coverage: 0.05, touches: vec![] };+        assert_eq!(framing_correction(&tiny), Some("zoom_in"));+        assert!(canvas_content(&rgb, w, h, (395, 0, 390, 5), (0, 0)).is_none(), "an inverted canvas rect answers None");+        assert_ne!(fnv1a(b"a"), fnv1a(b"b"));+        assert_eq!(fnv1a(b""), 0xcbf29ce484222325);+    }++    #[test]+    fn motion_plans_are_data() {+        let items: Vec<(String, u32)> = vec![+            ("Zoom to &Fit".into(), 100), ("Zoom &In".into(), 101), ("Zoom &Out".into(), 102),+            ("Rotate X Clockwise".into(), 110), ("Rotate X Counterclockwise".into(), 111),+            ("Rotate Z Clockwise".into(), 120), ("Rotate Z Counterclockwise".into(), 121), ("Dead".into(), 0),+        ];+        let ids = menu_ids_from(&items);+        assert_eq!(ids["fit"], 100);+        assert_eq!(ids["rotx_ccw"], 111);+        assert_eq!(ids["rotz_ccw"], 121);+        let plan = plan_3d_menu(&ids).unwrap();+        assert_eq!(plan[0].name, "extents");+        assert_eq!(plan.last().unwrap().id, 121, "orbit-back uses the counterclockwise id when present");+        assert_eq!(plan.iter().filter(|s| s.name == "zoom-in").count(), 1);+        assert_eq!(plan.iter().map(|s| s.reps).sum::<u32>(), 20);+        // KiCad 8 spells only "Rotate X Clockwise" with the ccw id colliding: the pair guard.+        let dup: Vec<(String, u32)> = vec![("Zoom In".into(), 1), ("Zoom Out".into(), 2), ("Rotate X Clockwise / Counterclockwise".into(), 5), ("Rotate Z Clockwise / Counterclockwise".into(), 6)];+        let ids = menu_ids_from(&dup);+        assert!(!ids.contains_key("rotx_ccw"));+        let plan = plan_3d_menu(&ids).unwrap();+        assert_eq!(plan[0].name, "tilt", "no fit item, no extents step");+        assert_eq!(plan.last().unwrap().id, 6, "orbit-back falls back to the clockwise id");+        assert!(plan_3d_menu(&menu_ids_from(&[("Zoom In".to_string(), 1)])).is_none());+        let p = plan_2d(500, 400, 1000, 800, (300, 200), (2, 1));+        assert_eq!(p[0], ("zoom-in", Gesture::Wheel { clicks: 2, x: 300, y: 200 }));+        assert!(matches!(p[1].1, Gesture::Drag { button: "middle", x0: 300, y0: 200, x1: 500, y1: 400, .. }));+        assert_eq!(p[2].0, "zoom-in-2");+        assert_eq!(p.last().unwrap(), &("extents-2", Gesture::Key(VK_HOME)));+        assert!(matches!(p[p.len() - 2].1, Gesture::Wheel { clicks: -3, .. }));+        let p1 = plan_2d(500, 400, 1000, 800, (500, 400), (1, 0));+        assert!(p1.iter().all(|(n, _)| *n != "zoom-in-2"), "a single part gets one zoom step");+        assert_eq!(plan_3d_mouse(500, 400, 1000, 800).len(), 7);+        assert_eq!(canvas_center((100, 100, 1000, 1000)), (720, 650));+        assert_eq!(aim_point(Some((0, 0, 200, 100)), Some((0.25, 0.5)), (9, 9)), (50, 50));+        assert_eq!(aim_point(None, Some((0.25, 0.5)), (9, 9)), (9, 9));+    }++    #[test]+    fn results_and_narration_text() {+        let results = vec![+            json!({"step": "symbol", "title": "1/6 \u{b7} The MCU symbol", "ok": true, "say": "one.", "pointOut": ["a"], "hwnd": 5, "window": "x", "raw": {"success": true}}),+            json!({"step": "footprint", "title": "2/6 \u{b7} Its footprint", "ok": false, "say": "two.", "raw": {"success": false, "error": "boom", "output": ""}}),+        ];+        let r = final_result(&results, 6, false, json!({"running": true}));+        assert_eq!(r["openedBeats"], json!(1));+        assert_eq!(r["failedSteps"], json!(["footprint"]));+        assert_eq!(r["narrate"], json!("one."));+        assert_eq!(r["steps"][1]["failReason"]["error"], json!("boom"));+        assert_eq!(r["steps"][0]["failReason"], Value::Null);+        assert_eq!(r["state"]["running"], json!(true));+        let e = final_result(&results, 6, true, Value::Null);+        assert_eq!(e["exited"], json!(true));+        assert!(e.get("steps").is_none());+        let n = staged_narration(&results, true);+        assert!(n.starts_with("I installed KiCad for you first"));+        assert!(n.contains("one."));+        assert!(n.contains("2/6 \u{b7} Its footprint didn't open"));+        assert!(!n.contains('\u{2014}'));+    }++    #[test]+    fn sexpr_block_is_quote_aware() {+        let t = r#"(a (b "x)y") (c))(d)"#;+        assert_eq!(sexpr_block(t, 0), r#"(a (b "x)y") (c))"#);+        assert_eq!(sexpr_block("(unbalanced (x", 0), "(unbalanced (x");+    }+}