← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.11.40\ No newline at end of file+1.11.41\ No newline at end of file
addin/AdomBridge/commands/activity_palette.py+63−22
@@ -254,28 +254,10 @@ def handle_activity_banner(app: adsk.core.Application, args: dict) -> dict:             except Exception:                 pass             # Fusion's electronics editors leave it FLOATING (dockingState reads 0 after the-            # set, 2026-09-05) and drop it over the top right of the canvas, where it hid the-            # symbol's right pins in the tour. A floating palette is kept small and parked over-            # the right-hand panel column, off the drawing.-            # John, 2026-10-08: on a screen recording it still sat over the board ("Switching to the 2D-            # board" covering the silkscreen being drawn), so it is smaller again and parked low on the-            # right, over the layer list, where nothing is being drawn.-            try:-                if int(_palette.dockingState) == 0:-                    W_, H_ = 260, 120-                    _palette.setSize(W_, H_)-                    import ctypes-                    sw = int(ctypes.windll.user32.GetSystemMetrics(0))-                    sh = int(ctypes.windll.user32.GetSystemMetrics(1))-                    try:-                        scale = float(ctypes.windll.shcore.GetScaleFactorForDevice(0)) / 100.0-                    except Exception:-                        scale = 1.0-                    if scale <= 0:-                        scale = 1.0-                    _palette.setPosition(int(sw / scale) - W_ - 24, int(sh / scale) - H_ - 140)-            except Exception:-                pass+            # set, 2026-09-05) and drop it over the canvas, where it hid the symbol's right pins+            # in the tour and, on a 2026-10-08 recording, the silkscreen being drawn. Park it in+            # the bottom-right corner of Fusion's own window, off the drawing.+            _park_floating(_palette)         else:             if _user_closed[0]:                 # The user closed it during this run: honour that, keep the content current@@ -283,6 +265,8 @@ def handle_activity_banner(app: adsk.core.Application, args: dict) -> dict:                 pass             elif not _palette.isVisible:                 _palette.isVisible = True+            if action == "show":+                _park_floating(_palette)   # a new run: back to the corner, even if dragged last run         # DATA, NOT A RELOAD: the page applies the payload in place. Right after creation         # the page may not have run its script yet; retry briefly, and only if the message         # never lands fall back to one reload of the shell (never per update).@@ -327,6 +311,63 @@ def handle_activity_banner(app: adsk.core.Application, args: dict) -> dict:                 "advisory": True}  +def _park_floating(palette):+    """Move a FLOATING palette to the bottom-right corner of the Fusion window that owns it.+    Never raises.++    Measured on hardware (arav-rog, 1.11.40, 2026-10-17), the two API calls the old code relied+    on do not work for a floating electronics palette:+    - setSize is ignored, and so are setMinimumSize/setMaximumSize: the window stays 380x410+      logical whatever is asked, and a Win32 resize snaps back at once. So the size is a given+      and only the position is ours.+    - setPosition throws InternalValidationError (setAreaPosition) for a negative X, which is+      every position on a monitor left of the primary one. The old math also aimed at the+      PRIMARY screen (GetSystemMetrics), not the screen Fusion is on.+    So: find the palette's own top-level window (titled ADOM, in this process), read its owner's+    rect, and SetWindowPos it there without resizing. Both rects come from the same Win32 calls+    in the same process, so they share one coordinate space whatever the DPI setup."""+    try:+        if int(palette.dockingState) != 0:+            return+        palette.setSize(260, 120)   # harmless where ignored; honoured by builds that allow it+        import ctypes+        from ctypes import wintypes+        u = ctypes.windll.user32+        pid = ctypes.windll.kernel32.GetCurrentProcessId()+        found = []++        @ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HWND, wintypes.LPARAM)+        def _cb(h, _l):+            p = wintypes.DWORD()+            u.GetWindowThreadProcessId(h, ctypes.byref(p))+            if p.value == pid and u.IsWindowVisible(h):+                b = ctypes.create_unicode_buffer(64)+                u.GetWindowTextW(h, b, 64)+                if b.value.strip().lower() == "adom":+                    found.append(h)+            return True+        u.EnumWindows(_cb, 0)+        if not found:+            return+        h = found[0]+        owner = u.GetWindow(h, 4)   # GW_OWNER: the Fusion main window+        if not owner:+            return++        def _rect(w):+            r = wintypes.RECT()+            u.GetWindowRect(w, ctypes.byref(r))+            return r.left, r.top, r.right, r.bottom+        pl, pt, pr, pb = _rect(h)+        ol, ot, orr, ob = _rect(owner)+        w, hh = pr - pl, pb - pt+        x = max(ol, orr - w - 16)+        y = max(ot, ob - hh - 48)   # clear of Fusion's bottom toolbar+        u.SetWindowPos(h, 0, x, y, 0, 0, 0x0001 | 0x0004 | 0x0010)   # NOSIZE | NOZORDER | NOACTIVATE+    except Exception:+        pass++ def teardown():     """Called from the add-in's stop(): remove the palette so a reload does not stack them."""     global _palette
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "fusion360",   "displayName": "Autodesk Fusion 360",-  "version": "1.11.40",+  "version": "1.11.41",   "description": "Drive Autodesk Fusion 360 from the cloud: launch Fusion, electronics board layout, design rules, exports (STEP/IGES/STL/3MF/USDZ/OBJ/DXF/DWG/Gerbers/BOM/CPL), fast APS server-indexed cloud search plus browse/recent/file-info/versions, cloud file download/upload and folder creation, and in-app parametric modeling (fusion_run_modeling_script). Never-charge: APS calls are capped to the free tier.",   "homepage": "https://wiki.adom.inc/adom/fusion-bridge",   "author": "Adom Inc.",
dashboard/Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "fusion-dashboard"-version = "1.11.40"+version = "1.11.41" dependencies = [  "serde_json", ]
dashboard/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.11.40"+version = "1.11.41" edition = "2021"  [dependencies]
dashboard/ui/index.html+10−7
@@ -450,13 +450,14 @@ if(REMOTE_ID){       '</div><div class="btnrow" style="margin-top:8px">'+       '<button class="btn" id="rExit" style="border-color:rgba(248,81,73,.5);color:var(--red)" data-tip="End the tour">&#9209; Exit tour</button>'+       '</div></div>';-    let rp=false;-    const rctl=async(a)=>{try{+    let rp=false, rBusy=false, rExited=false;+    const rctl=async(a)=>{if(rBusy||rExited)return;rBusy=true;try{       let ok=await act('tour_control',{demoId:REMOTE_ID,action:a}).catch(()=>null);       if(!ok)await act('tour_control',{demoId:REMOTE_ID,action:a});+      if(a==='exit')rExited=true;       if(a==='pause'){rp=true;$('#rPause').innerHTML='&#9654;';}       if(a==='resume'){rp=false;$('#rPause').innerHTML='&#9208;';}-    }catch(e){}};+    }catch(e){}finally{rBusy=false;}};     $('#rPause').onclick=()=>rctl(rp?'resume':'pause');     $('#rNext').onclick=()=>rctl('next');     $('#rPrev').onclick=()=>rctl('prev');@@ -591,8 +592,10 @@ async function startFusionDemo(){   const tt=$('#tourTransport');tt.style.display='flex';   tourSetMuted(tourMuted());   // paint the mute button from the persisted state   $('#ttMute').onclick=()=>tourSetMuted(!tourMuted());-  let ttPaused=false;-  const ctl=async(action)=>{try{+  let ttPaused=false, ctlBusy=false, ctlExited=false;+  // one control in flight at a time, nothing after Exit (issue #121: repeated Back/Exit clicks+  // queued a replay loop); the bridge debounces too+  const ctl=async(action)=>{if(ctlBusy||ctlExited)return;ctlBusy=true;try{     let r=await act('tour_control',{demoId:id,action}).catch(()=>null);     if(!r)r=await act('tour_control',{demoId:id,action});   // one silent retry - a slow relay hop must not eat a pause click     const x=r.data||r;@@ -601,8 +604,8 @@ async function startFusionDemo(){     if(action==='resume'){ttPaused=false;$('#ttPause').innerHTML='&#9208;';if(tourAudioEl&&!tourMuted())tourAudioEl.play().catch(()=>{});toast('resuming');}     if(action==='next')toast('skipping the upcoming step');     if(action==='prev')toast('replaying the previous view');-    if(action==='exit'){if(tourAudioEl)tourAudioEl.pause();tourAudioQueue=[];toast('exiting the demo (documents will be cleaned up)');}-  }catch(e){toast('control failed: '+e,'err');}};+    if(action==='exit'){ctlExited=true;if(tourAudioEl)tourAudioEl.pause();tourAudioQueue=[];toast('exiting the demo (documents will be cleaned up)');}+  }catch(e){toast('control failed: '+e,'err');}finally{ctlBusy=false;}};   $('#ttPause').onclick=()=>ctl(ttPaused?'resume':'pause');   $('#ttNext').onclick=()=>ctl('next');   $('#ttPrev').onclick=()=>ctl('prev');
install.sh+1−1
@@ -21,7 +21,7 @@ echo "Installed fusion-bridge skills: $(ls "$PKG_DIR/skills" 2>/dev/null | tr '\ # binary (the three-artifact pattern: bridge zip -> ab, THIS pkg -> container skills + # this download, page media -> wiki). Per the wiki's PKG_VS_RELEASE policy, binaries are # platform-tagged RELEASE assets, never tarball content - the installer fetches them.-DASH_VERSION="1.11.40"+DASH_VERSION="1.11.41" DASH_URL="https://wiki.adom.inc/download/adom/fusion-bridge/${DASH_VERSION}/fusion-dashboard" DEST="" if [ -w /usr/local/bin ]; then DEST=/usr/local/bin
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "fusion-bridge",-  "version": "1.11.40",+  "version": "1.11.41",   "type": "app",   "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.",   "dependencies": {
page.json+10−26
@@ -1,6 +1,6 @@ {   "slug": "fusion-bridge",-  "version": "1.11.40",+  "version": "1.11.41",   "type": "app",   "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.",   "dependencies": {@@ -11,6 +11,10 @@     "uninstall": "./uninstall.sh"   },   "tags": [+    "ab",+    "bridge",+    "fusion",+    "desktop-app",     "fusion360",     "autodesk",     "cad",@@ -76,6 +80,9 @@     "add a 3d model to a component",     "make a fusion 360 library with 3d",     "set up the fusion bridge",+    "fusion dashboard",+    "fusion status page",+    "open the fusion launch page",     "aps not configured",     "share aps client id across my org",     "set up fusion cloud search for my company",@@ -108,28 +115,5 @@       "label": "Part volume",       "prompt": "How much material does each printed part in this assembly use?"     }-  ],-  "agent_permissions": {-    "allow_bash": [],-    "allow": [-      "adom-desktop-fusion-bridge read-only verbs (via the adom-bridge CLI): fusion_readiness, fusion_get_app_state, fusion_addin_status, fusion_check_dialogs - state queries; they change nothing.",-      "adom-desktop-fusion-bridge read-only verbs: fusion_alerts (reads Fusion's own log for toasts), fusion_document_info, fusion_window_info, fusion_board_info, fusion_library_parts - inspect only.",-      "adom-desktop-fusion-bridge read-only verbs: fusion_prove_result (a persisted run report), fusion_plan_library_import (report-only preflight), fusion_list_cloud_projects, fusion_describe.",-      "adom-desktop-fusion-bridge evidence captures fusion_screenshot_fusion and fusion_capture_library_views read pixels and write local image files only."-    ],-    "soft_deny": [-      "adom-desktop-fusion-bridge lifecycle verbs fusion_stop, fusion_kill, fusion_restart_for_addin close or kill the Fusion application - keep the human prompt.",-      "adom-desktop-fusion-bridge fusion_install_fusion silently installs Autodesk Fusion on the user's machine.",-      "adom-desktop-fusion-bridge fusion_cleanup_cloud_files deletes files from the user's Autodesk cloud hub.",-      "adom-desktop-fusion-bridge managed-library verbs (fusion_managed_library_push/pull/create/link/unlink) publish to or overwrite the user's library.io libraries."-    ],-    "environment": [-      "adom-desktop-fusion-bridge is the Fusion 360 bridge for Adom Bridge; its fusion_* verbs are invoked through the adom-bridge CLI and drive Autodesk Fusion on the user's own desktop.",-      "adom-desktop-fusion-bridge desktop actions also pass Adom Bridge's own human-approval gate (caller identity, per-call reason, Activity Log) - a container approval is the first of two gates."-    ]-  },-  "author": {-    "name": "John Lauer",-    "email": "[email protected]"-  }-}\ No newline at end of file+  ]+}
rust/Cargo.toml+1−1
@@ -4,7 +4,7 @@ members = ["core", "lbr", "os-win", "os-mac", "bridge"]  # One version for the whole bridge, in lockstep with BRIDGE_VERSION (tools/release.sh bumps both). [workspace.package]-version = "1.11.40"+version = "1.11.41" edition = "2021" license = "MIT" 
rust/bridge/src/tour.rs+197−16
@@ -58,6 +58,14 @@ static PAUSED: AtomicBool = AtomicBool::new(false); static GENERIC_USED: Mutex<Vec<String>> = Mutex::new(Vec::new()); /// The tour's state dir, for detached helpers (the in-Fusion palette) that need their own Ctx. static STATE_DIR: Mutex<Option<std::path::PathBuf>> = Mutex::new(None);+/// Set once this run's remote is stopped or hidden (by the tour, the user or an agent): nothing+/// shows the panel again until the next run (issue #121: it came back after a hide).+static PANEL_CLOSED: AtomicBool = AtomicBool::new(false);+/// When each transport command was last accepted or taken, for the debounce below.+static LAST_CMD: Mutex<Vec<(String, Instant)>> = Mutex::new(Vec::new());+/// A repeat of a transport command inside this window of the last one is dropped (issue #121:+/// eleven Back presses in 15 s replayed beats in a loop).+const DEBOUNCE: Duration = Duration::from_millis(3000);  /// One timestamped event in the record (post-production cuts on these). fn event(name: &str, detail: Value) { let at = since_start(); rec(|s| push(s, "events", json!({"at": at, "event": name, "beat": s["step"].clone(), "detail": detail}))); }@@ -173,6 +181,7 @@ fn narrator_loop(pass: &dyn Fn(&str, Value) -> Value) {         let q = *quiet_since.get_or_insert_with(Instant::now);         if q.elapsed() < Duration::from_millis(900) { continue; }   // a breath between sentences         let Some((beat, n)) = NARR.lock().unwrap().clone() else { continue };+        if !BEAT_BUSY.load(Ordering::Relaxed) { continue; }   // a failed beat cut the narration meanwhile         let candidate = format!("{beat}-f{}", n + 1);         if audio_dir().join(format!("{candidate}.mp3")).is_file() {             *NARR.lock().unwrap() = Some((beat.clone(), n + 1));@@ -193,24 +202,52 @@ fn apply_mute(m: bool, source: &str) {  // ── the remote: one FIFO for every source ──────────────────────────────────── fn queue(cmd: &str) { let mut q = CTRL.lock().unwrap(); if cmd == "exit" { q.clear(); q.push_back("exit".into()); } else if q.len() < 8 { q.push_back(cmd.to_string()); } }+/// A press repeats one being handled: the same command is still queued, or was accepted or+/// taken less than DEBOUNCE ago.+fn is_repeat(cmd: &str, queued: &VecDeque<String>, last: &[(String, Instant)], now: Instant) -> bool {+    queued.iter().any(|c| c == cmd) || last.iter().any(|(c, t)| c == cmd && now.saturating_duration_since(*t) < DEBOUNCE)+}+fn stamp_cmd(cmd: &str) { let mut l = LAST_CMD.lock().unwrap(); l.retain(|(c, _)| c != cmd); l.push((cmd.to_string(), Instant::now())); } fn pending_interrupt() -> bool { EXIT.load(Ordering::Relaxed) || CTRL.lock().unwrap().iter().any(|c| matches!(c.as_str(), "prev" | "next" | "pause" | "exit")) } /// One press from any source: record, ack, apply mute at once, queue the transport command. pub fn on_press(button: &str, at: Value, via: &str) -> Value {     let b = button.to_lowercase();     let beat = snap();     rec(|s| { push(s, "presses", json!({"button": b, "at": at, "via": via, "beat": beat["beatIndex"], "step": beat["step"]})); s["lastPress"] = json!({"button": b, "at": at, "via": via}); });+    let cmd = match b.as_str() { "back" | "prev" => "prev", "play" | "resume" => "play", "pause" => "pause", "next" => "next", "stop" | "exit" => "exit", _ => "" };+    // a press for a run that is over (or stopping) is dropped, and so is a repeat of one in hand+    if !cmd.is_empty() {+        let over = beat["done"].as_bool() != Some(false) || beat["stopping"].as_bool() == Some(true) || EXIT.load(Ordering::Relaxed);+        if over || is_repeat(cmd, &CTRL.lock().unwrap(), &LAST_CMD.lock().unwrap(), Instant::now()) {+            rec(|s| s["debouncedPresses"] = json!(s["debouncedPresses"].as_u64().unwrap_or(0) + 1));+            return json!({"debounced": cmd, "tourOver": over});+        }+    }     let ack_text = match b.as_str() { "mute" => "Remote: muted", "unmute" => "Remote: narration back on", "pause" => "Remote: pausing", "play" | "resume" => "Remote: playing", "next" => "Remote: next", "back" | "prev" => "Remote: back", "stop" | "exit" => "Remote: stopping the tour", _ => "" };     if !ack_text.is_empty() { ack(ack_text); }     match b.as_str() {         "mute" | "unmute" => { apply_mute(b == "mute", via); return json!({"applied": b}); }         _ => {}     }-    let cmd = match b.as_str() { "back" | "prev" => "prev", "play" | "resume" => "play", "pause" => "pause", "next" => "next", "stop" | "exit" => "exit", _ => return json!({"ignored": b}) };+    if cmd.is_empty() { return json!({"ignored": b}); }     if matches!(cmd, "pause" | "exit" | "next" | "prev") { audio_stop(); }-    if cmd == "exit" { EXIT.store(true, Ordering::Relaxed); caption_clear(); rec(|s| { s["stopping"] = json!(true); s["stepLabel"] = json!("Closing the demo tour"); }); }+    if cmd == "exit" { stop_now(); }+    stamp_cmd(cmd);     queue(cmd);     json!({"queued": cmd}) }+/// Exit ends the tour NOW (issue #121: eleven Exit presses over 28 s did not): the narration and+/// caption go, the remote comes down for good, and the record reads stopped at once. The tour+/// thread's `pass` refuses every verb from here, so the step in flight returns at its next call;+/// its teardown then closes the documents (tourDocsClosed "closing" holds a new tour off until then).+fn stop_now() {+    EXIT.store(true, Ordering::Relaxed);+    BEAT_BUSY.store(false, Ordering::Relaxed); *NARR.lock().unwrap() = None;+    audio_stop(); caption_clear();+    PANEL_CLOSED.store(true, Ordering::Relaxed);+    rec(|s| { s["stopping"] = json!(true); s["done"] = json!(true); s["exited"] = json!(true); s["step"] = json!("done"); s["stepLabel"] = json!("Tour stopped"); s["tourDocsClosed"] = json!("closing"); });+    std::thread::spawn(panel_hide);+} /// ab's instant callback (POST /demo/event): {id, button, at, state, stage, muted}. pub fn on_panel_event(ev: &Value) -> Value {     if ev["id"].as_str().map(|i| i != PANEL_ID).unwrap_or(false) { return json!({"ignored": true}); }@@ -219,7 +256,12 @@ pub fn on_panel_event(ev: &Value) -> Value {     if let Some(m) = ev["muted"].as_bool() { if m != MUTED.load(Ordering::Relaxed) && !matches!(b, "mute" | "unmute") { apply_mute(m, "panel"); } }     on_press(b, ev["at"].clone(), if ev["synthetic"].as_bool() == Some(true) { "callback-synthetic" } else { "callback" }) }-fn panel_show(stage: usize, state: Option<&str>) -> bool {+/// Only the live, running run `run_id` may show its remote, and never once it was stopped or hidden.+fn panel_may_show(run_id: &str, live: &Value, closed: bool) -> bool {+    !closed && !run_id.is_empty() && live["demoId"].as_str() == Some(run_id) && live["done"].as_bool() == Some(false) && live["stopping"].as_bool() != Some(true)+}+fn panel_show(run_id: &str, stage: usize, state: Option<&str>) -> bool {+    if !panel_may_show(run_id, &snap(), PANEL_CLOSED.load(Ordering::Relaxed) || EXIT.load(Ordering::Relaxed)) { return false; }     let mut args = json!({"action": "show", "id": PANEL_ID, "stage": stage, "reason": PANEL_REASON});     if !PANEL_SHOWN.load(Ordering::Relaxed) {         args["title"] = json!("Fusion bridge tour"); args["stops"] = json!(BEATS.iter().map(|b| b.1).collect::<Vec<_>>()); args["state"] = json!(state.unwrap_or("playing")); args["position"] = json!("bottom-right"); args["muted"] = json!(MUTED.load(Ordering::Relaxed));@@ -234,13 +276,15 @@ fn panel_show(stage: usize, state: Option<&str>) -> bool { /// Backstop drain of the panel's own queue; presses the callback already delivered are skipped by `at`. fn panel_poll() {     let r = ab("desktop_demo_panel", json!({"action": "poll", "id": PANEL_ID}), 6);-    if r["state"].as_str() == Some("gone") { PANEL_SHOWN.store(false, Ordering::Relaxed); let st = snap(); if st["done"].as_bool() == Some(false) && !EXIT.load(Ordering::Relaxed) { panel_show(st["panel"]["stage"].as_u64().unwrap_or(0) as usize, None); } return; }     if let Some(m) = r["muted"].as_bool() { if m != MUTED.load(Ordering::Relaxed) { apply_mute(m, "panel-poll"); } }     for ev in r["events"].as_array().cloned().unwrap_or_default() {         let at = ev["at"].as_str().map(str::to_string).or_else(|| ev["at"].as_f64().map(|f| f.to_string())).unwrap_or_default();         if !at.is_empty() { let mut seen = SEEN_AT.lock().unwrap(); if seen.contains(&at) { continue; } seen.push(at.clone()); }         on_press(ev["button"].as_str().unwrap_or(""), ev["at"].clone(), if ev["synthetic"].as_bool() == Some(true) { "poll-synthetic" } else { "poll" });     }+    // gone or stopped after this run showed it means someone took the remote down: it stays+    // down for this run (issue #121). Before the first show, the state is the last run's.+    if matches!(r["state"].as_str(), Some("gone") | Some("stopped")) && PANEL_SHOWN.load(Ordering::Relaxed) { PANEL_SHOWN.store(false, Ordering::Relaxed); PANEL_CLOSED.store(true, Ordering::Relaxed); rec(|s| s["panel"]["closedBy"] = json!(r["state"])); } } fn drain_stale_presses() { for _ in 0..3 { let r = ab("desktop_demo_panel", json!({"action": "poll", "id": PANEL_ID}), 6); if r["events"].as_array().map(|a| a.is_empty()).unwrap_or(true) { break; } } } /// Hide the remote and PROVE it: poll once (the tour is over, so the drain is harmless), hide again@@ -259,10 +303,10 @@ fn panel_hide() {         rep["stateAfter"] = json!(st);         if st.is_empty() || st == "stopped" || st == "gone" { break; }     }-    PANEL_SHOWN.store(false, Ordering::Relaxed);+    PANEL_SHOWN.store(false, Ordering::Relaxed); PANEL_CLOSED.store(true, Ordering::Relaxed);     rec(|s| s["panelHidden"] = rep.clone()); }-fn take_cmd() -> Option<String> { panel_poll(); CTRL.lock().unwrap().pop_front() }+fn take_cmd() -> Option<String> { panel_poll(); let c = CTRL.lock().unwrap().pop_front(); if let Some(c) = &c { stamp_cmd(c); } c }  // ── layout ──────────────────────────────────────────────────────────────────── fn primary_work_area() -> Option<Value> {@@ -582,15 +626,19 @@ fn pass_patient(pass: &dyn Fn(&str, Value) -> Value, verb: &str, args: Value) ->     } } fn run_show(ctx: &Ctx, pass_raw: &dyn Fn(&str, Value) -> Value, args: Value, env: &Env) {-    let pass_owned = |verb: &str, a: Value| pass_patient(pass_raw, verb, a);+    // once the user exits, every beat verb is refused so the step in flight unwinds at its next+    // call (issue #121); the teardown lifts this to close the tour's documents+    let teardown = AtomicBool::new(false);+    let pass_owned = |verb: &str, a: Value| if EXIT.load(Ordering::Relaxed) && !teardown.load(Ordering::Relaxed) { json!({"success": false, "error": "the user stopped the tour"}) } else { pass_patient(pass_raw, verb, a) };     let pass: &dyn Fn(&str, Value) -> Value = &pass_owned;+    let run_id = snap()["demoId"].as_str().unwrap_or("").to_string();     rec(|s| s["bridgeVersion"] = json!(env.version));     let sd = ctx.state_dir.clone();     let t0 = Instant::now();     let total_est: u64 = BEATS.iter().map(|b| b.2).sum();     let mut done_est: u64 = 0;     // clean slate per tour-    EXIT.store(false, Ordering::Relaxed); PANEL_SHOWN.store(false, Ordering::Relaxed); SEEN_AT.lock().unwrap().clear(); CTRL.lock().unwrap().clear();+    EXIT.store(false, Ordering::Relaxed); PANEL_SHOWN.store(false, Ordering::Relaxed); PANEL_CLOSED.store(false, Ordering::Relaxed); SEEN_AT.lock().unwrap().clear(); CTRL.lock().unwrap().clear(); LAST_CMD.lock().unwrap().clear();     MUTED.store(args["muted"].as_bool().unwrap_or(false), Ordering::Relaxed);     BEAT_BUSY.store(false, Ordering::Relaxed); PAUSED.store(false, Ordering::Relaxed); *NARR.lock().unwrap() = None; GENERIC_USED.lock().unwrap().clear();     drain_stale_presses();@@ -619,6 +667,7 @@ fn run_show(ctx: &Ctx, pass_raw: &dyn Fn(&str, Value) -> Value, args: Value, env     let mut fusion: Option<(i64, String)> = None;     let mut bound_urn = String::new();     let mut bound = false;+    let mut chip_cache: Option<Value> = None;     let mut project_base = String::new();     let mut hub_board = false;     let board_name = args["board"].as_str().unwrap_or(DEMO_BOARD).to_string();@@ -630,7 +679,7 @@ fn run_show(ctx: &Ctx, pass_raw: &dyn Fn(&str, Value) -> Value, args: Value, env         // transport between beats: pause holds, exit leaves, prev is TWO back (i is past the beat on screen)         let mut cmd = take_cmd();         if cmd.as_deref() == Some("pause") {-            audio_stop(); PAUSED.store(true, Ordering::Relaxed); event("paused", json!({})); panel_show(i.max(1), Some("paused")); caption("Fusion tour paused", 300000); rec(|s| { s["paused"] = json!(true); s["stepLabel"] = json!("Paused"); });+            audio_stop(); PAUSED.store(true, Ordering::Relaxed); event("paused", json!({})); panel_show(&run_id, i.max(1), Some("paused")); caption("Fusion tour paused", 300000); rec(|s| { s["paused"] = json!(true); s["stepLabel"] = json!("Paused"); });             loop { sleep_s(1.0); cmd = take_cmd(); if matches!(cmd.as_deref(), Some("play") | Some("next") | Some("prev") | Some("exit")) { break; } }             PAUSED.store(false, Ordering::Relaxed); event("resumed", json!({})); rec(|s| s["paused"] = json!(false));         }@@ -639,7 +688,7 @@ fn run_show(ctx: &Ctx, pass_raw: &dyn Fn(&str, Value) -> Value, args: Value, env         let (key, label, dur) = BEATS[i];         rec(|s| { s["step"] = json!(key); s["stepLabel"] = json!(label); s["beatIndex"] = json!(i + 1); s["beatCount"] = json!(n); });         update_pct(done_est, 0);-        panel_show(i, None);+        panel_show(&run_id, i, None);         BEAT_BUSY.store(true, Ordering::Relaxed);         event("beat-start", json!({"index": i + 1, "key": key}));         event("part-a", json!({"key": key}));@@ -676,16 +725,17 @@ fn run_show(ctx: &Ctx, pass_raw: &dyn Fn(&str, Value) -> Value, args: Value, env                 expected = "AdomDemoSample".into(); kind = "2d";             }             "chip" => {-                caption(&format!("Fusion tour 4/{n}: minting the 3D chip from its STEP and binding it (about two minutes)"), 60000);-                audio_play("chip");+                // a replay (Back) shows the chip this run already bound; the minting lines would narrate work not being done+                if chip_cache.is_some() { caption(&format!("Fusion tour 4/{n}: the 3D chip this tour already minted and bound"), 30000); *NARR.lock().unwrap() = None; }+                else { caption(&format!("Fusion tour 4/{n}: minting the 3D chip from its STEP and binding it (about two minutes)"), 60000); audio_play("chip"); }                 let was = active_doc(pass);                 match (&sample.step, sample.package.is_empty()) {                     (Some(step), false) => {                         let on_part = |_p: &Value, k: usize, total: usize| caption(&format!("3D chip: part {k} of {total} minted and bound"), 20000);-                        let br = crate::mint::build_library_3d_core(ctx, &json!({"lbrPath": sample.lbr, "parts": [{"package": sample.package, "lbrPath": sample.lbr, "modelPath": step}], "outLbrPath": sample.lbr, "capture": false}), pass, &on_part);+                        let (br, reused) = chip_bind_once(&mut chip_cache, || crate::mint::build_library_3d_core(ctx, &json!({"lbrPath": sample.lbr, "parts": [{"package": sample.package, "lbrPath": sample.lbr, "modelPath": step}], "outLbrPath": sample.lbr, "capture": false}), pass, &on_part));                         bound = br["success"].as_bool() == Some(true) && br["partsBound"].as_u64().unwrap_or(0) >= 1;                         bound_urn = br["parts"][0]["wip_urn"].as_str().unwrap_or("").to_string();-                        r["bind"] = json!({"success": bound, "partsBound": br["partsBound"], "error": br["error"], "urn": bound_urn, "dims_mm": br["parts"][0]["dims_mm"]});+                        r["bind"] = json!({"success": bound, "partsBound": br["partsBound"], "error": br["error"], "urn": bound_urn, "dims_mm": br["parts"][0]["dims_mm"], "reused": reused});                         rec(|s| s["bound3d"] = json!(bound));                         if bound && !bound_urn.is_empty() && !EXIT.load(Ordering::Relaxed) {                             let o = pass("open_by_urn", json!({"urn": bound_urn}));@@ -780,6 +830,8 @@ fn run_show(ctx: &Ctx, pass_raw: &dyn Fn(&str, Value) -> Value, args: Value, env             }             _ => {}         }+        // a failed action narrates nothing more of its beat: the clip and fillers stop now+        if r["ok"].as_bool() != Some(true) { cut_narration(); }         if key == "library" && r["ok"].as_bool() != Some(true) {             let why = r["error"].as_str().unwrap_or("the sample library did not open").to_string();             caption(&format!("Fusion tour stopped: {why}"), 8000);@@ -812,13 +864,31 @@ fn run_show(ctx: &Ctx, pass_raw: &dyn Fn(&str, Value) -> Value, args: Value, env                     event("motion-start", json!({"kind": kind, "plan": plan.iter().map(|p| p.0).collect::<Vec<_>>(), "parts": parts.len()}));                     idle_boxes = plan.iter().filter(|p| p.1["action"] == "pan").map(|p| p.1.clone()).collect();                     idle_kind = kind.to_string();-                    r["motion"] = motion(*h, kind, pass, plan);+                    r["motion"] = motion(*h, kind, pass, plan.clone());                     event("motion-done", json!({"gestures": r["motion"]["gestures"], "viewChanged": r["motion"]["viewChanged"], "aborted": r["motion"]["aborted"]}));+                    // a motion that never moved the view gets one more pass (issue #121), then the beat fails below+                    if motion_needs_retry(&r["motion"]) && !EXIT.load(Ordering::Relaxed) {+                        let first = r["motion"].clone();+                        r["motion"] = motion(*h, kind, pass, plan);+                        r["motion"]["firstPass"] = first;+                        event("motion-retry", json!({"gestures": r["motion"]["gestures"], "viewChanged": r["motion"]["viewChanged"], "aborted": r["motion"]["aborted"]}));+                    }                 } else if kind != "none" {                     r["motion"] = json!({"skipped": format!("not verified: front={} title={} painted={} (top window: {})", v["front"], v["title"], v["painted"], v["frontTitle"])});                 } else if v["ok"].as_bool() == Some(true) { event("part-b", json!({"key": key})); audio_play_b(key); }             } else { r["verified"] = json!({"why": "no Fusion window"}); }         }+        // issue #121: a beat that failed, was not on screen, or never moved says so briefly and+        // moves on, instead of narrating over a view that does not show it+        if !EXIT.load(Ordering::Relaxed) { if let Some(why) = beat_failure(&r) {+            if r["ok"].as_bool() == Some(true) { r["ok"] = json!(false); r["error"] = json!(why.clone()); }+            cut_narration();+            caption(&failed_caption(i + 1, n, label, &why), 6000);+            r["failedNarration"] = json!(true);+            event("beat-failed", json!({"key": key, "why": why}));+            let hold = Instant::now();+            while hold.elapsed() < Duration::from_secs(3) && !pending_interrupt() { sleep_s(0.3); }+        } }         // evidence frame at the moment the beat completes         if let Some((h, _)) = &fusion { if let Some(p) = shot_window(*h) { if let Ok(b) = std::fs::read(&p) { if b.len() <= 4 * 1024 * 1024 { let img = crate::etiquette::b64_pub(&b); rec(|s| { push(s, "shots", json!({"label": label, "title": label, "path": p, "image": img})); }); } } } }         r["seconds"] = json!(beat_t0.elapsed().as_secs_f64());@@ -848,6 +918,7 @@ fn run_show(ctx: &Ctx, pass_raw: &dyn Fn(&str, Value) -> Value, args: Value, env         i += 1;     }     BEAT_BUSY.store(false, Ordering::Relaxed);+    teardown.store(true, Ordering::Relaxed);     event("tour-end", json!({"exited": exited}));     // ---- teardown: audio off, panel down, windows back, the tour marked done; THEN the tour's     // documents close (closing takes a minute and must not read as dead air) ----@@ -980,6 +1051,9 @@ fn idle_step(kind: &str, tick: u64, boxes: &[Value], pass: &dyn Fn(&str, Value) fn motion(hwnd: i64, kind: &str, pass: &dyn Fn(&str, Value) -> Value, plan: Vec<(&str, Value)>) -> Value {     let mut rep = json!({"kind": kind, "gestures": 0, "aborted": null, "plan": plan.iter().map(|p| p.0).collect::<Vec<_>>()});     let before = shot_window(hwnd).and_then(|p| digest(&p));+    // a plan may end where it began (iso, fit), so the view counts as changed once ANY frame+    // after a gesture differs from the first, not only the last one+    let mut changed = false;     for (name, body) in plan {         if pending_interrupt() { rep["aborted"] = json!(format!("stop requested before {name}")); break; }         if !front_ok(hwnd) { rep["aborted"] = json!(format!("window not in front before {name} (user switched away)")); break; }@@ -1001,12 +1075,45 @@ fn motion(hwnd: i64, kind: &str, pass: &dyn Fn(&str, Value) -> Value, plan: Vec<         // let the eye land before the next move; a 2D fit needs longer, the editor is still         // laying it out and a box sent inside that window is dropped (2026-09-05)         sleep_s(if kind == "2d" && body["action"] == "fit" { 3.0 } else if kind == "2d" { 1.2 } else { 1.0 });+        if !changed && before.is_some() { changed = shot_window(hwnd).and_then(|p| digest(&p)).map(|d| Some(d) != before).unwrap_or(false); }     }     let after = shot_window(hwnd).and_then(|p| digest(&p));-    rep["viewChanged"] = json!(before.is_some() && after.is_some() && before != after);+    rep["viewChanged"] = json!(changed || (before.is_some() && after.is_some() && before != after));+    rep["viewMeasured"] = json!(before.is_some());     rep } +// ── failed beats (issue #121) ─────────────────────────────────────────────────+/// Stop the beat's narration at once: no clip on air, no next filler.+fn cut_narration() { BEAT_BUSY.store(false, Ordering::Relaxed); *NARR.lock().unwrap() = None; audio_stop(); }+/// A motion that ran, was measured, was not cut short by the remote, and never moved the view.+fn motion_needs_retry(m: &Value) -> bool {+    m["viewChanged"].as_bool() == Some(false) && m["viewMeasured"].as_bool() != Some(false) && m["gestures"].as_u64().unwrap_or(0) > 0 && m["aborted"].is_null()+}+/// Why the beat must not be narrated as shown, or None when its view is on screen: the action+/// failed, the view could not be proven on screen, or its motion never moved the view (after the retry).+fn beat_failure(r: &Value) -> Option<String> {+    if r["ok"].as_bool() != Some(true) { return Some(short_why(r["error"].as_str().unwrap_or("the step did not complete"))); }+    if r["verified"]["ok"].as_bool() == Some(false) { return Some("its view could not be confirmed on screen".into()); }+    if motion_needs_retry(&r["motion"]) { return Some("the view did not move".into()); }+    None+}+/// The first sentence of an error, at most 80 characters, for a one-line caption.+fn short_why(e: &str) -> String {+    let e = e.trim();+    let first = e.split(". ").next().unwrap_or(e).trim_end_matches('.');+    if first.chars().count() <= 80 { first.to_string() } else { format!("{}...", first.chars().take(77).collect::<String>().trim_end()) }+}+fn failed_caption(index: usize, n: usize, label: &str, why: &str) -> String { format!("Fusion tour {index}/{n}: {label} - this step failed ({why}); moving on") }+/// The chip beat mints at most once per tour run (issue #121: Back over it minted a second STEP+/// binding with a new URN). A replay reuses the first successful bind; a failed mint is not kept.+fn chip_bind_once(cache: &mut Option<Value>, mint: impl FnOnce() -> Value) -> (Value, bool) {+    if let Some(br) = cache.as_ref() { return (br.clone(), true); }+    let br = mint();+    if br["success"].as_bool() == Some(true) && br["partsBound"].as_u64().unwrap_or(0) >= 1 && br["parts"][0]["wip_urn"].as_str().map(|u| !u.is_empty()).unwrap_or(false) { *cache = Some(br.clone()); }+    (br, false)+}+ /// `fusion_demo {mode:"tour"}`: start the show detached; poll fusion_demo_progress {demoId}. pub fn start(ctx: &Ctx, args: &Value, env: Env) -> Value {     if snap()["done"].as_bool() == Some(false) { let s = snap(); return json!({"success": false, "error": "a tour is already running", "demoId": s["demoId"], "_hint": "Poll fusion_demo_progress with that demoId, or fusion_demo_control {action:'exit'} to stop it."}); }@@ -1039,6 +1146,8 @@ pub fn start(ctx: &Ctx, args: &Value, env: Env) -> Value {         // only THIS tour's record: a later tour may own the live record by now         let live = snap();         if live["demoId"].as_str() == Some(my_id.as_str()) && live["done"].as_bool() != Some(true) { rec(|s| { s["done"] = json!(true); s["failed"] = json!(true); s["error"] = json!("the tour thread ended unexpectedly"); }); panel_hide(); }+        // an Exit marks the record closing before the teardown runs; a thread that died first must not hold the next tour off+        if snap()["demoId"].as_str() == Some(my_id.as_str()) && snap()["tourDocsClosed"].as_str() == Some("closing") { rec(|s| s["tourDocsClosed"] = json!([])); }     });     json!({"success": true, "demoId": id, "estimatedSec": est, "steps": BEATS.iter().map(|b| json!({"step": b.0, "label": b.1, "estimatedSec": b.2})).collect::<Vec<_>>(), "statusVerb": "fusion_demo_progress",         "_hint": format!("Tour started in the background. Poll fusion_demo_progress {{\"demoId\": \"{id}\"}} every 2-3 s: percent, stepLabel, per-step verified/placed/motion, presses[], captions[], panel, and a per-beat evidence frame in shots[]. Drive it with fusion_demo_control {{demoId, action: play|pause|next|prev|exit|mute|unmute}} or the glass remote on screen.")})@@ -1061,7 +1170,79 @@ pub fn progress(args: &Value) -> Value { pub fn control(args: &Value) -> Value {     let a = args["action"].as_str().unwrap_or("").to_lowercase();     if !matches!(a.as_str(), "play" | "resume" | "pause" | "next" | "prev" | "back" | "exit" | "stop" | "mute" | "unmute") { return json!({"success": false, "error": "action must be play|pause|next|prev|exit|mute|unmute"}); }+    // a repeat Exit while the first one winds the tour down is already handled, not an error+    if matches!(a.as_str(), "exit" | "stop") && snap()["stopping"].as_bool() == Some(true) && snap()["tourDocsClosed"].as_str() == Some("closing") { return json!({"success": true, "action": a, "alreadyStopping": true, "_hint": "The tour is already stopped; its documents are closing."}); }     if snap()["done"].as_bool() != Some(false) && !matches!(a.as_str(), "mute" | "unmute") { return json!({"success": false, "error": "no running tour", "_hint": "Controls only apply while a tour is running."}); }     let r = on_press(&a, json!(now()), "dashboard");     json!({"success": true, "action": a, "result": r, "muted": MUTED.load(Ordering::Relaxed), "paused": snap()["paused"], "_hint": "Honoured at the running beat's next gesture (a document mid-open finishes first); exit stops the tour, silences narration and clears the captions."}) }++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn repeat_presses_are_debounced() {+        let t0 = Instant::now();+        let mut q = VecDeque::new();+        assert!(!is_repeat("prev", &q, &[], t0), "a first Back is taken");+        q.push_back("prev".to_string());+        assert!(is_repeat("prev", &q, &[], t0), "a Back while one is still queued is dropped");+        assert!(!is_repeat("next", &q, &[], t0), "another command is not a repeat");+        let q = VecDeque::new();+        let last = vec![("prev".to_string(), t0)];+        assert!(is_repeat("prev", &q, &last, t0 + Duration::from_millis(1500)), "a Back right after one was taken is dropped");+        assert!(!is_repeat("prev", &q, &last, t0 + DEBOUNCE + Duration::from_millis(1)), "a Back after the window is taken");+        assert!(!is_repeat("exit", &q, &last, t0 + Duration::from_millis(100)));+    }++    #[test]+    fn stopped_or_stale_runs_never_show_the_panel() {+        let live = json!({"demoId": "show-1", "done": false, "stopping": false});+        assert!(panel_may_show("show-1", &live, false));+        assert!(!panel_may_show("show-1", &live, true), "hidden or stopped: closed for this run");+        assert!(!panel_may_show("show-0", &live, false), "a late update from an older run is dropped");+        assert!(!panel_may_show("", &live, false));+        assert!(!panel_may_show("show-1", &json!({"demoId": "show-1", "done": true}), false), "a finished run");+        assert!(!panel_may_show("show-1", &json!({"demoId": "show-1", "done": false, "stopping": true}), false), "a run the user exited");+        assert!(!panel_may_show("show-1", &Value::Null, false));+    }++    #[test]+    fn chip_is_minted_once_per_run() {+        let mints = std::cell::Cell::new(0);+        let mint = || { mints.set(mints.get() + 1); json!({"success": true, "partsBound": 1, "parts": [{"wip_urn": format!("urn:vf.{}", mints.get())}]}) };+        let mut cache = None;+        let (a, reused_a) = chip_bind_once(&mut cache, mint);+        let (b, reused_b) = chip_bind_once(&mut cache, || panic!("a replay must not mint again"));+        assert_eq!(mints.get(), 1);+        assert!(!reused_a && reused_b);+        assert_eq!(a["parts"][0]["wip_urn"], b["parts"][0]["wip_urn"], "the replay reuses the first URN");+        // a failed mint is not kept, so the next pass may try again+        let mut cache = None;+        let (f, _) = chip_bind_once(&mut cache, || json!({"success": false, "partsBound": 0, "error": "no cloud"}));+        assert_eq!(f["success"], json!(false));+        assert!(cache.is_none());+    }++    #[test]+    fn failed_beats_are_not_narrated() {+        let ok = json!({"ok": true, "verified": {"ok": true}, "motion": {"gestures": 6, "viewChanged": true, "viewMeasured": true, "aborted": null}});+        assert_eq!(beat_failure(&ok), None);+        let failed = json!({"ok": false, "error": "Could not make AdomDemoSample.lbr the ACTIVE document (active: 'None'), so EDIT was not issued. Retry later."});+        let why = beat_failure(&failed).unwrap();+        assert!(why.starts_with("Could not make AdomDemoSample.lbr") && why.chars().count() <= 80, "{why}");+        assert!(failed_caption(2, 8, "Symbol: the full pinout", &why).contains("2/8: Symbol: the full pinout - this step failed"));+        assert_eq!(beat_failure(&json!({"ok": false})).as_deref(), Some("the step did not complete"));+        assert!(beat_failure(&json!({"ok": true, "verified": {"ok": false}, "motion": {"skipped": "not verified"}})).is_some(), "not on screen");+        // a still view after the motion: retried once, then failed+        let still = json!({"ok": true, "verified": {"ok": true}, "motion": {"gestures": 9, "viewChanged": false, "viewMeasured": true, "aborted": null}});+        assert!(motion_needs_retry(&still["motion"]));+        assert_eq!(beat_failure(&still).as_deref(), Some("the view did not move"));+        // the remote cutting the motion short, or no capture to measure with, is not a failure+        assert!(!motion_needs_retry(&json!({"gestures": 2, "viewChanged": false, "viewMeasured": true, "aborted": "stop requested before orbit-2"})));+        assert!(!motion_needs_retry(&json!({"gestures": 9, "viewChanged": false, "viewMeasured": false, "aborted": null})));+        assert!(!motion_needs_retry(&json!({"gestures": 0, "viewChanged": false, "viewMeasured": true, "aborted": null})));+        assert_eq!(beat_failure(&json!({"ok": true})), None, "the cloud-search beat has no view to check");+    }+}
tools/check-naming.sh

  
tools/check-portable.sh

  
tools/promote.sh

  
tools/release.sh