← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.9.366\ No newline at end of file+1.9.367\ No newline at end of file
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "fusion360",   "displayName": "Autodesk Fusion 360",-  "version": "1.9.366",+  "version": "1.9.367",   "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.",
contracts/describe.json+16
@@ -1699,6 +1699,22 @@       "summary": "Full tool list from Autodesk's Fusion MCP server INCLUDING input schemas, so you can construct a valid fusion_mcp_call. These are Autodesk's tools, not this bridge's.",       "timeoutSeconds": 45     },+    {+      "example": {},+      "input": {+        "clip": "required str (clip name without .mp3)"+      },+      "longRunning": false,+      "name": "fusion_narrate",+      "output": {+        "clip": "str",+        "file": "str",+        "startedAt": "float"+      },+      "statusVerb": null,+      "summary": "Play one bundled narration clip on the box now (the guided tour's voice): dock, dashboard, or any <beat>[-a|-b] clip in resources/demo-audio. A driver speaks the dock bar and dashboard lines with it before the tour starts. Returns startedAt (epoch seconds) so a recording can be cut on it; the tour's own narration stops it when a beat begins.",+      "timeoutSeconds": 15+    },     {       "example": {         "brdPath": "C:/Users/me/adom-lib/MyBoard.brd",
dashboard/Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "fusion-dashboard"-version = "1.9.366"+version = "1.9.367" dependencies = [  "serde_json", ]
dashboard/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.9.366"+version = "1.9.367" edition = "2021"  [dependencies]
describe.py+2
@@ -137,6 +137,8 @@ _T = [      {"action": "str", "space": "str", "data": "{result}"}, 60, None, True, {}),     ("fusion_interactive", "Is Fusion taking input RIGHT NOW? Reads the add-in's busy flag (a long bridge command in flight), Fusion's own hasActiveJobs, and the main thread's latency on a trivial script (probeMainThread:false skips that and costs nothing). interactive:true means drive it; false means a load, a cloud open, or a bind is still running. The guided tour's narrator polls this every 1.5s and stamps fusion-busy / fusion-interactive transitions into the tour record, so a recording can be cut or sped up on them.",      {"probeMainThread": "optional bool (default true)"}, {"interactive": "bool", "addinUp": "bool", "addinBusy": "bool", "activeJobs": "bool|null", "mainThreadMs": "int|null"}, 20, None, False, {}),+    ("fusion_narrate", "Play one bundled narration clip on the box now (the guided tour's voice): dock, dashboard, or any <beat>[-a|-b] clip in resources/demo-audio. A driver speaks the dock bar and dashboard lines with it before the tour starts. Returns startedAt (epoch seconds) so a recording can be cut on it; the tour's own narration stops it when a beat begins.",+     {"clip": "required str (clip name without .mp3)"}, {"clip": "str", "startedAt": "float", "file": "str"}, 15, None, False, {}),     ("fusion_board_info", "Raw board geometry XML (copper/arcs/lines) for the open board.",      {}, {"rawXml": "str"}, 60, None, False, {}),     ("fusion_alerts", "Fusion's OWN alert toasts, read from its log. Fusion draws error/warning toasts in CEF: they are invisible to Win32 enumeration AND to UIA, so a verb can look successful while Fusion is visibly complaining. This reads them, newest last.",
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.9.366"+DASH_VERSION="1.9.367" 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.9.366",+  "version": "1.9.367",   "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+11−27
@@ -1,6 +1,6 @@ {   "slug": "fusion-bridge",-  "version": "1.9.366",+  "version": "1.9.367",   "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",@@ -75,7 +79,10 @@     "build a basic parts library",     "add a 3d model to a component",     "make a fusion 360 library with 3d",-    "set up the fusion bridge"+    "set up the fusion bridge",+    "fusion dashboard",+    "fusion status page",+    "open the fusion launch page"   ],   "discovery_pitch": "Use the Adom Bridge Fusion 360 bridge when the user wants to DRIVE Autodesk Fusion 360 from the cloud: open/search their team's Fusion cloud designs, lay out a PCB or do electronics, export STEP/IGES/STL/Gerbers/BOM, or build a component library with real rendering 3D. It runs Fusion on the user's own machine via Adom Bridge. NOT for web/browser/login/form-filling (that's the native-browser extension) and NOT for KiCad (that's the kicad bridge).",   "brief": "Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.",@@ -104,28 +111,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.9.366"+version = "1.9.367" edition = "2021" license = "MIT" 
rust/PORTING.md+7
@@ -575,3 +575,10 @@ port learned: - **A release restarts the exe under a running tour.** ab pulled 1.9.365 mid-run and the record   vanished (run 25). Pin the bridge cache on a demo box with `bridge_dev_mode` before recording,   and never release while a tour runs anywhere you cannot see.+- **The bridge's own palette was being searched as a dialog (2026-09-05, run 35).** Every+  doc-switching verb paid 13 s in the post-envelope: the activity palette is a 760x820 Qt tool+  window titled "Adom", the dialog filter took it for a candidate prompt, and each sweep ran a+  UIA control search over its HTML (EDIT 16.6 s, execute_text_command 15 s). `qt_dialog_rows`+  now skips `BRIDGE_PALETTE_TITLE`; EDIT is 3.3 s, execute_text_command under 2 s. Lesson: when a+  verb is slow, ask the verb. Slow pass-throughs (2 s and up) now answer `_timing`+  {preSec, addinSec, dialogsSec, postSec} so the envelope can never hide behind the work again.
rust/bridge/src/addin.rs+14−2
@@ -225,6 +225,8 @@ pub fn pass_through(cmd: &str, args: &Value, ctx: &Ctx) -> Value {     // a direct add-in hop for the etiquette's own probes (document_info, get_app_state)     let direct = |verb: &str, a: Value, t: u64| call_addin(verb, &a, t, sidecar);     let pre = crate::etiquette::pre(ctx.host, &ctx.state_dir, &ctx.caller, cmd, args, &ctx.pids, &direct);+    let t_pre = t0.elapsed().as_secs_f64();+    let t_call_done: std::cell::Cell<f64> = std::cell::Cell::new(0.0);     let args_owned: Value = pre.args.clone();     let args = &args_owned;     let ran_bg = pre.ran_backgrounded;@@ -255,9 +257,11 @@ pub fn pass_through(cmd: &str, args: &Value, ctx: &Ctx) -> Value {             return r;         }         let r = call_addin(cmd, args, timeout, sidecar);+        t_call_done.set(t0.elapsed().as_secs_f64());         if MUTATING.contains(&cmd) { return dialogs(r); }         r     })();+    let t_body = t0.elapsed().as_secs_f64();     let mut res = res;     if ran_bg && res.is_object() {         if res.get("windowEtiquette").is_none() { res["windowEtiquette"] = json!("background"); }@@ -266,8 +270,16 @@ pub fn pass_through(cmd: &str, args: &Value, ctx: &Ctx) -> Value {         res["_hint"] = json!(match h { Some(h) if !h.contains(note) => format!("{h} {note}"), Some(h) => h, None => note.to_string() });     }     let _ = window_raising;-    let res = crate::etiquette::post(&ctx.state_dir, &ctx.caller, cmd, args, res, &pre, since, &ctx.pids, &direct);-    let _ = t0;+    let mut res = crate::etiquette::post(&ctx.state_dir, &ctx.caller, cmd, args, res, &pre, since, &ctx.pids, &direct);+    // where a slow verb spent its time: the etiquette envelope around the add-in call is+    // ab traffic (window facts, dialog sweeps), and it must never dwarf the work itself+    let total = t0.elapsed().as_secs_f64();+    if total >= 2.0 && res.is_object() {+        let call_end = if t_call_done.get() > 0.0 { t_call_done.get() } else { t_body };+        let r2 = |x: f64| (x * 100.0).round() / 100.0;+        res["_timing"] = json!({"totalSec": r2(total), "preSec": r2(t_pre), "addinSec": r2(call_end - t_pre), "dialogsSec": r2(t_body - call_end), "postSec": r2(total - t_body)});+        eprintln!("[fusion-bridge] {cmd} took {:.1}s: pre {:.1}s, add-in {:.1}s, dialogs {:.1}s, post {:.1}s", total, t_pre, call_end - t_pre, t_body - call_end, total - t_body);+    }     res } 
rust/bridge/src/main.rs+2−2
@@ -400,10 +400,10 @@ fn main() {                     v @ ("fusion_show_symbol" | "show_symbol" | "fusion_show_footprint" | "show_footprint" | "fusion_show_library" | "show_library" | "fusion_show_project" | "show_project" | "fusion_show_schematic" | "show_schematic" | "fusion_show_2d_board" | "show_2d_board" | "fusion_show_3d_board" | "show_3d_board" | "fusion_capture_library_views" | "capture_library_views" | "fusion_open_cloud_file" | "open_cloud_file" | "fusion_save_lbr" | "save_lbr" | "fusion_cleanup_cloud_files" | "cleanup_cloud_files" | "fusion_inspect_bodies" | "inspect_bodies" | "fusion_board_stackup" | "board_stackup" | "fusion_export_optimized_glb" | "export_optimized_glb" | "fusion_fetch_optimized_glb" | "fetch_optimized_glb" | "fusion_set_design_rules" | "set_design_rules" | "fusion_make_test_project" | "make_test_project" | "fusion_open_design" | "open_design" | "fusion_new_electronics_from_eagle" | "new_electronics_from_eagle" | "fusion_aps_open" | "aps_open") if addin_owner_is_exe(&**h) => { let (sc, ca, ca2) = (sidecar.clone(), caller.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let sd = h.bridge_state_dir(); let pids: Vec<u32> = h.fusion_processes().iter().map(|p| p.pid).collect(); let dlg = |r: Value| small_verbs::merge_dialog_array(r, &ca2, &pids); let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids: pids.clone(), sidecar: &side, dialogs: &dlg }; let cmd = v.strip_prefix("fusion_").unwrap_or(v); let t0 = Instant::now(); let ind = timings::Indicator::start(&sd, cmd, &caller); let pass = |verb: &str, a: Value| addin::pass_through(verb, &a, &ctx); let r0 = match cmd { "show_symbol" => orchestrations::show_symbol(&ctx, &parsed["args"], &pass), "show_footprint" => orchestrations::show_footprint(&ctx, &parsed["args"], &pass), "show_library" => orchestrations::show_library(&ctx, &parsed["args"], &pass), "show_project" => match orchestrations::show_project(&ctx, &parsed["args"], &pass) { Some(r) => r, None => side("show_project", parsed["args"].clone(), 240) }, "capture_library_views" => small_verbs::apply_failure_dialogs(orchestrations::capture_library_views(&ctx, &parsed["args"], &pass), &caller, &pids), "open_cloud_file" => orchestrations::open_cloud_file(&ctx, &parsed["args"], &pass), "save_lbr" => small_verbs::merge_dialog_array(orchestrations::save_lbr(&ctx, &parsed["args"], &pass), &caller, &pids), "cleanup_cloud_files" => orchestrations::cleanup_cloud_files(&parsed["args"], &pass), "inspect_bodies" => orchestrations::inspect_bodies(&parsed["args"], &pass), "board_stackup" => services::board_stackup(&parsed["args"], &pass), "export_optimized_glb" => if parsed["args"]["async"].as_bool() == Some(true) { services::start_glb_export_async(&ctx, &parsed["args"]) } else { services::export_optimized_glb(&parsed["args"], &pass) }, "fetch_optimized_glb" => if parsed["args"]["exportId"].is_string() { services::glb_export_async_status(&parsed["args"]) } else { services::fetch_optimized_glb(&parsed["args"]) }, "set_design_rules" => services::set_design_rules(&ctx, &parsed["args"], &pass), "make_test_project" => small_verbs::merge_dialog_array(testproject::make_test_project(&ctx, &parsed["args"], &pass), &caller, &pids), "open_design" => orchestrations::open_design(&ctx, &parsed["args"], &pass), "new_electronics_from_eagle" => orchestrations::new_electronics_from_eagle(&ctx, &parsed["args"], &pass), "aps_open" => orchestrations::aps_open(&ctx, &parsed["args"], &pass), other => orchestrations::open_with_screenshot(&ctx, other, &parsed["args"], &pass) }; let mut r = small_verbs::merge_dialog_array(r0, &caller, &pids); ind.stop(r["success"].as_bool() == Some(false)); timings::log_run(&sd, cmd, t0.elapsed().as_secs_f64(), r["success"].as_bool() != Some(false)); r["shell"] = json!("rust"); r },                     "fusion_state" | "state" if addin_owner_is_exe(&**h) => { let (sc, ca) = (sidecar.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let dlg = |r: Value| r; let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: h.bridge_state_dir(), caller: caller.clone(), pids: h.fusion_processes().iter().map(|p| p.pid).collect(), sidecar: &side, dialogs: &dlg }; let mut r = orchestrations::fusion_state(&ctx, &parsed["args"]); r["shell"] = json!("rust"); r },                     v @ ("fusion_make_3d_package" | "make_3d_package" | "fusion_build_library_3d" | "build_library_3d" | "fusion_bind_library_3d" | "bind_library_3d" | "fusion_prove_library" | "prove_library" | "fusion_attach_3d_package" | "attach_3d_package" | "fusion_show_3d_package" | "show_3d_package") if addin_owner_is_exe(&**h) => { let (sc, ca, ca2) = (sidecar.clone(), caller.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let sd = h.bridge_state_dir(); let pids: Vec<u32> = h.fusion_processes().iter().map(|p| p.pid).collect(); let dlg = |r: Value| small_verbs::merge_dialog_array(r, &ca2, &pids); let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids: pids.clone(), sidecar: &side, dialogs: &dlg }; let cmd = v.strip_prefix("fusion_").unwrap_or(v); let t0 = Instant::now(); *LONG_OP.lock().unwrap() = Some(cmd.to_string()); *LONG_OP_SINCE.lock().unwrap() = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0); let pass = |verb: &str, a: Value| addin::pass_through(verb, &a, &ctx); let r0 = match cmd { "make_3d_package" => mint::make_3d_package(&ctx, &parsed["args"], &pass), "bind_library_3d" => mint::bind_library_3d(&ctx, &parsed["args"], &pass), "prove_library" => prove::prove_library(&ctx, &parsed["args"], &pass), "attach_3d_package" => mint::attach_3d_package(&ctx, &parsed["args"], &pass), "show_3d_package" => mint::show_3d_package(&ctx, &parsed["args"], &pass), _ => mint::build_library_3d(&ctx, &parsed["args"], &pass) }; *LONG_OP.lock().unwrap() = None; let mut r = small_verbs::apply_failure_dialogs(r0, &caller, &pids); timings::log_run(&sd, cmd, t0.elapsed().as_secs_f64(), r["success"].as_bool() != Some(false)); r["shell"] = json!("rust"); r },-                    v @ ("fusion_apply_fusion_update" | "apply_fusion_update" | "fusion_install_fusion" | "install_fusion" | "fusion_uninstall" | "uninstall" | "fusion_prefs_open" | "prefs_open" | "fusion_prefs_close" | "prefs_close" | "fusion_generate_package" | "generate_package" | "fusion_mcp_status" | "mcp_status" | "fusion_mcp_tools" | "mcp_tools" | "fusion_mcp_call" | "mcp_call" | "fusion_mcp_resources" | "mcp_resources" | "fusion_mcp_enable" | "mcp_enable" | "fusion_library_manager" | "library_manager" | "fusion_managed_library_create" | "managed_library_create" | "fusion_managed_library_link" | "managed_library_link" | "fusion_managed_library_pull" | "managed_library_pull" | "fusion_managed_library_push" | "managed_library_push" | "fusion_managed_library_unlink" | "managed_library_unlink" | "fusion_signin" | "signin" | "fusion_signin_2fa" | "signin_2fa" | "fusion_web_signin" | "web_signin" | "fusion_aps_signin" | "aps_signin" | "fusion_demo" | "demo" | "fusion_demo_progress" | "demo_progress" | "fusion_demo_control" | "demo_control" | "fusion_electronics_query" | "electronics_query" | "fusion_view" | "view" | "fusion_interactive" | "interactive") if addin_owner_is_exe(&**h) => { let (sc, ca) = (sidecar.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let dlg = |r: Value| r; let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: h.bridge_state_dir(), caller: caller.clone(), pids: h.fusion_processes().iter().map(|p| p.pid).collect(), sidecar: &side, dialogs: &dlg }; let pass = |verb: &str, a: Value| addin::pass_through(verb, &a, &ctx); let cmd = v.strip_prefix("fusion_").unwrap_or(v); let mut r = match cmd { "apply_fusion_update" => maintenance::apply_fusion_update(&ctx, &pass), "install_fusion" => maintenance::install_fusion(&ctx), "prefs_open" => maintenance::prefs_open(&ctx, &parsed["args"], &pass), "prefs_close" => maintenance::prefs_close(&ctx, &parsed["args"]), "generate_package" => epg::generate_package(&parsed["args"], VERSION, &pass),+                    v @ ("fusion_apply_fusion_update" | "apply_fusion_update" | "fusion_install_fusion" | "install_fusion" | "fusion_uninstall" | "uninstall" | "fusion_prefs_open" | "prefs_open" | "fusion_prefs_close" | "prefs_close" | "fusion_generate_package" | "generate_package" | "fusion_mcp_status" | "mcp_status" | "fusion_mcp_tools" | "mcp_tools" | "fusion_mcp_call" | "mcp_call" | "fusion_mcp_resources" | "mcp_resources" | "fusion_mcp_enable" | "mcp_enable" | "fusion_library_manager" | "library_manager" | "fusion_managed_library_create" | "managed_library_create" | "fusion_managed_library_link" | "managed_library_link" | "fusion_managed_library_pull" | "managed_library_pull" | "fusion_managed_library_push" | "managed_library_push" | "fusion_managed_library_unlink" | "managed_library_unlink" | "fusion_signin" | "signin" | "fusion_signin_2fa" | "signin_2fa" | "fusion_web_signin" | "web_signin" | "fusion_aps_signin" | "aps_signin" | "fusion_demo" | "demo" | "fusion_demo_progress" | "demo_progress" | "fusion_demo_control" | "demo_control" | "fusion_electronics_query" | "electronics_query" | "fusion_view" | "view" | "fusion_interactive" | "interactive" | "fusion_narrate" | "narrate") if addin_owner_is_exe(&**h) => { let (sc, ca) = (sidecar.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let dlg = |r: Value| r; let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: h.bridge_state_dir(), caller: caller.clone(), pids: h.fusion_processes().iter().map(|p| p.pid).collect(), sidecar: &side, dialogs: &dlg }; let pass = |verb: &str, a: Value| addin::pass_through(verb, &a, &ctx); let cmd = v.strip_prefix("fusion_").unwrap_or(v); let mut r = match cmd { "apply_fusion_update" => maintenance::apply_fusion_update(&ctx, &pass), "install_fusion" => maintenance::install_fusion(&ctx), "prefs_open" => maintenance::prefs_open(&ctx, &parsed["args"], &pass), "prefs_close" => maintenance::prefs_close(&ctx, &parsed["args"]), "generate_package" => epg::generate_package(&parsed["args"], VERSION, &pass),                             "signin" => { let readiness = || owned_readiness(&**h, &sidecar, &caller); let start = || launch::start(&ctx, &json!({})); let aps_state = || { let st = native_aps_status(&sidecar, &caller); (st["data"]["configured"].as_bool().unwrap_or(false), st["data"]["signedIn"].as_bool().unwrap_or(false)) }; let aps_signin = |p: &str| aps_signin::signin(&ctx, &json!({"profile": p})); signin::signin(&ctx, &parsed["args"], &signin::Env { readiness: &readiness, start: &start, aps_state: &aps_state, aps_signin: &aps_signin }) },                             "signin_2fa" => signin::signin_2fa(&ctx, &parsed["args"]), "aps_signin" => aps_signin::signin(&ctx, &parsed["args"]),-                            "electronics_query" => electronics::query(&ctx, &parsed["args"], VERSION, &pass), "view" => tour::view(&pass, &parsed["args"]), "interactive" => tour::interactive(&pass, parsed["args"]["probeMainThread"].as_bool().unwrap_or(true)), "demo_progress" => if tour::owns(parsed["args"]["demoId"].as_str().unwrap_or("")) || parsed["args"]["demoId"].as_str().map(|s| s.is_empty()).unwrap_or(true) { tour::progress(&parsed["args"]) } else { demo::demo_progress(&ctx.state_dir, &parsed["args"]) },+                            "electronics_query" => electronics::query(&ctx, &parsed["args"], VERSION, &pass), "view" => tour::view(&pass, &parsed["args"]), "narrate" => tour::narrate(&parsed["args"]), "interactive" => tour::interactive(&pass, parsed["args"]["probeMainThread"].as_bool().unwrap_or(true)), "demo_progress" => if tour::owns(parsed["args"]["demoId"].as_str().unwrap_or("")) || parsed["args"]["demoId"].as_str().map(|s| s.is_empty()).unwrap_or(true) { tour::progress(&parsed["args"]) } else { demo::demo_progress(&ctx.state_dir, &parsed["args"]) },                             "demo_control" => if tour::owns(parsed["args"]["demoId"].as_str().unwrap_or("")) || parsed["args"]["demoId"].as_str().map(|s| s.is_empty()).unwrap_or(true) { tour::control(&parsed["args"]) } else { demo::demo_control(&parsed["args"]) },                             "demo" if parsed["args"]["mode"].as_str() == Some("tour") && parsed["args"]["legacy"].as_bool() != Some(true) => tour::start(&ctx, &parsed["args"], tour::Env { version: VERSION }),                             "demo" => if parsed["args"]["mode"].as_str() == Some("tour") { let (sc1, sc2) = (sidecar.clone(), sidecar.clone()); let readiness: Arc<dyn Fn() -> Value + Send + Sync> = Arc::new(move || owned_readiness(&*host(), &sc1, &Caller::default())); let aps_state: Arc<dyn Fn() -> (bool, bool) + Send + Sync> = Arc::new(move || { let st = native_aps_status(&sc2, &Caller::default()); (st["data"]["configured"].as_bool().unwrap_or(false), st["data"]["signedIn"].as_bool().unwrap_or(false)) }); demo::demo_tour(&ctx, &parsed["args"], demo::TourEnv { readiness, aps_state, version: VERSION }, &pass) }
rust/bridge/src/palette.rs+17−3
@@ -16,7 +16,11 @@ use std::time::{Duration, Instant}; static WORKER: Once = Once::new(); static DIRTY: AtomicBool = AtomicBool::new(false); static HIDE: AtomicBool = AtomicBool::new(false);+/// While held (a tour in flight) the palette is never taken down by the idle timer: created once,+/// repainted in place, so no per-beat creation cost (the add-in retries delivery 3 s on creation).+static HOLD: AtomicBool = AtomicBool::new(false); static SEQ: AtomicU64 = AtomicU64::new(0);+static PAINTED_SEQ: AtomicU64 = AtomicU64::new(0); static LAST: Mutex<Option<(String, String, Option<f64>, Option<f64>, u64)>> = Mutex::new(None); /// What the worker last did, for fusion_interactive: (action, title, success, error, seconds ago). static LAST_RESULT: Mutex<Option<(String, String, bool, String, Instant)>> = Mutex::new(None);@@ -37,15 +41,18 @@ fn worker() {         }         if DIRTY.load(Ordering::Relaxed) {             let Some((t, d, p, e, seq)) = LAST.lock().unwrap().clone() else { DIRTY.store(false, Ordering::Relaxed); continue };+            if PAINTED_SEQ.load(Ordering::Relaxed) >= seq { DIRTY.store(false, Ordering::Relaxed); continue; }+            std::thread::sleep(Duration::from_secs(2));   // let the caller's verb go first             let r = send("show", &t, &d, p, e);             *LAST_RESULT.lock().unwrap() = Some(("show".into(), t.clone(), r["success"].as_bool() == Some(true), r["error"].as_str().unwrap_or("").chars().take(120).collect(), Instant::now()));             if r["success"].as_bool() == Some(true) {-                shown = true; painted_seq = seq; painted_at = Instant::now();+                shown = true; painted_seq = seq; painted_at = Instant::now(); PAINTED_SEQ.store(seq, Ordering::Relaxed);                 if SEQ.load(Ordering::Relaxed) == seq { DIRTY.store(false, Ordering::Relaxed); }             } else { std::thread::sleep(Duration::from_secs(2)); }             continue;         }-        if shown && painted_at.elapsed() > Duration::from_secs(25) && SEQ.load(Ordering::Relaxed) == painted_seq && !run_in_flight() {+        if !shown && PAINTED_SEQ.load(Ordering::Relaxed) > painted_seq { shown = true; painted_seq = PAINTED_SEQ.load(Ordering::Relaxed); painted_at = Instant::now(); }+        if shown && !HOLD.load(Ordering::Relaxed) && painted_at.elapsed() > Duration::from_secs(25) && SEQ.load(Ordering::Relaxed) == painted_seq && !run_in_flight() {             if send("hide", "", "", None, None)["success"].as_bool() == Some(true) { shown = false; *LAST.lock().unwrap() = None; }             else { painted_at = Instant::now(); }         }@@ -58,8 +65,15 @@ pub fn say(title: &str, detail: &str, percent: Option<f64>, eta: Option<f64>) {     WORKER.call_once(|| { std::thread::spawn(worker); });     let seq = SEQ.fetch_add(1, Ordering::Relaxed) + 1;     *LAST.lock().unwrap() = Some((title.to_string(), detail.to_string(), percent, eta, seq));-    DIRTY.store(true, Ordering::Relaxed);+    // Paint NOW, on the caller's thread, before the caller's own verb goes to the add-in: a+    // background paint raced the tour's Electron commands and each collision cost a 15 s retry+    // (ConfRoomROG, 2026-09-05). The worker only picks it up if this attempt did not land.+    let r = send("show", title, detail, percent, eta);+    *LAST_RESULT.lock().unwrap() = Some(("show".into(), title.to_string(), r["success"].as_bool() == Some(true), r["error"].as_str().unwrap_or("").chars().take(120).collect(), Instant::now()));+    if r["success"].as_bool() == Some(true) { PAINTED_SEQ.store(seq, Ordering::Relaxed); } else { DIRTY.store(true, Ordering::Relaxed); } }+/// Keep the palette up between messages (a tour in flight) or let the idle timer take it down.+pub fn hold(on: bool) { HOLD.store(on, Ordering::Relaxed); }  /// Take the palette down now (the tour's teardown, a cancelled run). pub fn hide() {
rust/bridge/src/tour.rs+79−13
@@ -105,7 +105,19 @@ fn audio_playing() -> bool { let mut g = AUDIO.lock().unwrap(); match g.as_mut() fn audio_play(beat: &str) {     rec(|s| s["narrationBeat"] = json!(beat));     *NARR.lock().unwrap() = Some((beat.to_string(), 0));-    play_file(beat, "main");+    // part A clip if the beat has one; else the main clip only when no part B clip exists+    let a = format!("{beat}-a"); let b = format!("{beat}-b");+    if audio_dir().join(format!("{a}.mp3")).is_file() { play_file(&a, "part-a"); }+    else if !audio_dir().join(format!("{b}.mp3")).is_file() { play_file(beat, "main"); }+}+/// Part B: the content is on screen and moving. Its clip replaces whatever filler is on air+/// (after letting a sentence end, briefly); falls back to the main clip when the beat has no B.+fn audio_play_b(beat: &str) {+    let b = format!("{beat}-b");+    let name = if audio_dir().join(format!("{b}.mp3")).is_file() { b } else { beat.to_string() };+    let t0 = Instant::now();+    while audio_playing() && t0.elapsed() < Duration::from_secs(3) { sleep_s(0.2); }+    play_file(&name, "part-b"); } /// Play one clip from resources/demo-audio (kind = main | filler); records the stamp and the file. fn play_file(name: &str, kind: &str) {@@ -364,6 +376,14 @@ fn is_front(hwnd: i64) -> bool { top_window().and_then(|t| t["hwnd"].as_i64()) = fn foreground_once(hwnd: i64, why: &str) -> bool {     let _ = ab("desktop_bring_to_front", json!({"hwnd": hwnd, "reason": format!("Fusion tour: {why}, once")}), 8);     for _ in 0..6 { sleep_s(0.25); if is_front(hwnd) { return true; } }+    // Windows keeps the foreground with the app the user last clicked (the dashboard in Hydrogen):+    // the user asked for the tour there, so that window steps behind Fusion, once+    if let Some(top) = top_window() { if let Some(th) = top["hwnd"].as_i64() { if th != hwnd && !top["title"].as_str().map(is_remote_viewer).unwrap_or(false) {+        let _ = ab("desktop_set_window_state", json!({"hwnd": th, "state": "bottom", "reason": format!("Fusion tour: {why}; the window in front steps behind Fusion once")}), 8);+        sleep_s(0.4);+        let _ = ab("desktop_bring_to_front", json!({"hwnd": hwnd, "reason": format!("Fusion tour: {why}, after lowering the window in front")}), 8);+        for _ in 0..6 { sleep_s(0.25); if is_front(hwnd) { return true; } }+    } } }     false } @@ -415,6 +435,18 @@ out={{'action':'style','style':'{st}'}}") }     if r["success"].as_bool() == Some(true) { r["_hint"] = json!("Verify by capture: desktop_screenshot_window before and after; the tour records viewChanged this way."); }     r }+/// `fusion_narrate {clip}`: play one bundled narration clip on the box now (the dock bar and+/// dashboard lines a driver speaks before the tour starts). Returns the start epoch and the+/// clip length so a recording can be cut on it.+pub fn narrate(args: &Value) -> Value {+    let clip = args["clip"].as_str().unwrap_or("").trim().trim_end_matches(".mp3").to_string();+    if clip.is_empty() || clip.contains('/') || clip.contains('\\') { return json!({"success": false, "error": "clip required: a bundled narration name such as dock or dashboard"}); }+    let f = audio_dir().join(format!("{clip}.mp3"));+    if !f.is_file() { return json!({"success": false, "error": format!("no bundled clip {clip}.mp3"), "_hint": "resources/demo-audio in the bridge cache lists the clips"}); }+    let at = now();+    play_file(&clip, "prelude");+    json!({"success": true, "clip": clip, "startedAt": at, "file": f.to_string_lossy().replace('\\', "/"), "_hint": "Playing on the box now; the tour's own narration stops it when a beat starts."})+} /// `fusion_interactive`: is Fusion ready for input right now? The add-in's /status busy flag (a /// long command of ours in flight), Fusion's own active-jobs flag, and how long the main thread /// took to answer a trivial script. The tour's narrator polls this to stamp busy stretches.@@ -489,7 +521,11 @@ fn pass_patient(pass: &dyn Fn(&str, Value) -> Value, verb: &str, args: Value) ->     loop {         let r = pass(verb, args.clone());         let busy = r["success"].as_bool() != Some(true) && r["error"].as_str().map(|e| e.contains("main thread busy") || e.contains("Fusion is busy") || e.contains("busy_")).unwrap_or(false);-        if !busy || t0.elapsed() > Duration::from_secs(20) || EXIT.load(Ordering::Relaxed) { return r; }+        if !busy || t0.elapsed() > Duration::from_secs(20) || EXIT.load(Ordering::Relaxed) {+            let secs = t0.elapsed().as_secs_f64();+            if secs > 1.5 { event("verb", json!({"verb": verb, "secs": (secs * 10.0).round() / 10.0, "ok": r["success"]})); }+            return r;+        }         sleep_s(0.4);     } }@@ -511,9 +547,19 @@ fn run_show(ctx: &Ctx, pass_raw: &dyn Fn(&str, Value) -> Value, args: Value, env     if let Some((h, _)) = fusion_hwnd() { let _ = ab("desktop_taskbar", json!({"hwnd": h, "progress": {"state": "none"}}), 6); }     let tp_base = crate::testproject::test_project(&sd).and_then(|t| t["docBase"].as_str().map(str::to_string)).unwrap_or_default();     *STATE_DIR.lock().unwrap() = Some(sd.clone());-    let stale = close_named_docs(pass, &["adomdemosample".into(), tp_base.clone()]);+    crate::palette::hold(true);+    let board_for_clean = args["board"].as_str().unwrap_or(DEMO_BOARD).to_lowercase();+    let stale = close_named_docs(pass, &["adomdemosample".into(), tp_base.clone(), "ads8588sipm_3d".into(), "adom-test-".into(), board_for_clean, "untitled".into()]);+    // Fusion takes the screen the instant the tour starts: forward once, placed at 95%+    if let Some((h, t)) = fusion_hwnd() { let placed = place_fusion(&sd, h, &t); let fg = foreground_once(h, "the tour is starting"); rec(|s| { s["placed"] = json!(placed["placed"].as_bool() == Some(true)); s["placedAtStart"] = placed; s["foregroundedAtStart"] = json!(fg); }); }     let docs_before = crate::orchestrations::open_doc_names(pass);     rec(|s| { s["staleDocsClosed"] = json!(stale); s["docsBefore"] = json!(docs_before.len()); });+    // the hub lookup for the board beat runs now, beside the first beats (run 36: it cost the+    // schematic beat 30 s of dead air when it ran inline)+    let mut board_prefetch: Option<std::thread::JoinHandle<Option<(String, String)>>> = if args["boardUrn"].is_string() { None } else {+        let bn = args["board"].as_str().unwrap_or(DEMO_BOARD).to_string();+        Some(std::thread::spawn(move || find_hub_design(&bn, DEMO_BOARD_PROJECT)))+    };     let mut results: Vec<Value> = Vec::new();     let mut i = 0usize;     let mut exited = false;@@ -544,6 +590,7 @@ fn run_show(ctx: &Ctx, pass_raw: &dyn Fn(&str, Value) -> Value, args: Value, env         panel_show(i, None);         BEAT_BUSY.store(true, Ordering::Relaxed);         event("beat-start", json!({"index": i + 1, "key": key}));+        event("part-a", json!({"key": key}));         let beat_t0 = Instant::now();         let mut r = json!({"step": key, "label": label, "ok": false});         let mut expected = String::new();@@ -608,7 +655,7 @@ fn run_show(ctx: &Ctx, pass_raw: &dyn Fn(&str, Value) -> Value, args: Value, env             }             "schematic" => {                 // a real board from the hub first; the generated solo board only when the hub has none-                let found = match args["boardUrn"].as_str() { Some(u) => Some((u.to_string(), String::new())), None => find_hub_design(&board_name, DEMO_BOARD_PROJECT) };+                let found = match args["boardUrn"].as_str() { Some(u) => Some((u.to_string(), String::new())), None => match board_prefetch.take() { Some(h) => h.join().ok().flatten(), None => find_hub_design(&board_name, DEMO_BOARD_PROJECT) } };                 if let Some((urn, proj)) = found.filter(|_| !EXIT.load(Ordering::Relaxed)) {                     caption(&format!("Fusion tour 5/{n}: opening the {board_name} board from your hub (about a minute)"), 90000);                     audio_play("schematic");@@ -700,6 +747,8 @@ fn run_show(ctx: &Ctx, pass_raw: &dyn Fn(&str, Value) -> Value, args: Value, env                     let parts = match key { "schematic" => part_positions(ctx, env, pass, "Instance"), "board2d" | "board3d" => part_positions(ctx, env, pass, "Element"), _ => Vec::new() };                     let plan = plan_for(key, &sample, &parts, &board_name);                     r["parts"] = json!(parts.len());+                    event("part-b", json!({"key": key}));+                    audio_play_b(key);                     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();@@ -707,7 +756,7 @@ fn run_show(ctx: &Ctx, pass_raw: &dyn Fn(&str, Value) -> Value, args: Value, env                     event("motion-done", 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"}); }         }         // evidence frame at the moment the beat completes@@ -752,13 +801,13 @@ fn run_show(ctx: &Ctx, pass_raw: &dyn Fn(&str, Value) -> Value, args: Value, env     let my_id = s["demoId"].as_str().unwrap_or("").to_string();     let close_t0 = Instant::now();     let closed = crate::mint::close_docs_opened_since(ctx, &docs_before, &[], pass);-    crate::palette::hide();+    crate::palette::hold(false); crate::palette::hide();     rec_for(&sd, &my_id, |s| { s["tourDocsClosed"] = json!(closed); s["closeSec"] = json!(close_t0.elapsed().as_secs()); });     if snap()["demoId"].as_str() == Some(my_id.as_str()) { let s = snap(); crate::demo::persist_pub(&sd, &s); } }  fn bbox(pts: &[(f64, f64)]) -> Option<(f64, f64, f64, f64)> { if pts.is_empty() { return None; } Some((pts.iter().map(|p| p.0).fold(f64::MAX, f64::min), pts.iter().map(|p| p.1).fold(f64::MAX, f64::min), pts.iter().map(|p| p.0).fold(f64::MIN, f64::max), pts.iter().map(|p| p.1).fold(f64::MIN, f64::max))) }-fn pan_box(x1: f64, y1: f64, x2: f64, y2: f64) -> Value { json!({"action": "pan", "x1": x1, "y1": y1, "x2": x2, "y2": y2, "steps": 18}) }+fn pan_box(x1: f64, y1: f64, x2: f64, y2: f64) -> Value { json!({"action": "pan", "x1": x1, "y1": y1, "x2": x2, "y2": y2, "steps": 12, "frameDelayMs": 10}) } /// Positions of the parts on the active board or schematic (mm), from the electronics read model. fn part_positions(ctx: &Ctx, env: &Env, pass: &dyn Fn(&str, Value) -> Value, entity: &str) -> Vec<(String, String, f64, f64)> {     let r = crate::electronics::query(ctx, &json!({"entity": entity, "fields": ["name", "value", "x", "y"], "limit": 600}), env.version, pass);@@ -778,16 +827,23 @@ fn plan_for(key: &str, sample: &Sample, parts: &[(String, String, f64, f64)], bo             let Some((x1, y1, x2, y2)) = bbox(&sample.pin_pts) else { return zoom_only() };             let (w, h) = (x2 - x1, y2 - y1); let m = 3.0;             vec![("fit", fit2()),-                 ("top-left pins", pan_box(x1 - m, y1 + h * 0.55, x1 + w * 0.5, y2 + m)),-                 ("bottom-left pins", pan_box(x1 - m, y1 - m, x1 + w * 0.5, y1 + h * 0.45)),-                 ("right pins", pan_box(x1 + w * 0.5, y1 + h * 0.35, x2 + m, y2 + m)),+                 ("top-left pins", pan_box(x1 - m, y1 + h * 0.6, x1 + w * 0.5, y2 + m)),+                 ("mid-left pins", pan_box(x1 - m, y1 + h * 0.3, x1 + w * 0.5, y1 + h * 0.7)),+                 ("bottom-left pins", pan_box(x1 - m, y1 - m, x1 + w * 0.5, y1 + h * 0.4)),+                 ("bottom-right pins", pan_box(x1 + w * 0.5, y1 - m, x2 + m, y1 + h * 0.4)),+                 ("top-right pins", pan_box(x1 + w * 0.5, y1 + h * 0.6, x2 + m, y2 + m)),+                 ("whole symbol", pan_box(x1 - m * 2.0, y1 - m * 2.0, x2 + m * 2.0, y2 + m * 2.0)),                  ("fit-2", fit2())]         }         "footprint" => {             let Some((x1, y1, x2, y2)) = bbox(&sample.pad_pts) else { return zoom_only() };             let (w, h) = (x2 - x1, y2 - y1); let m = 1.5;             let (p1x, p1y) = sample.pad_pts.first().cloned().unwrap_or((x1, y1));+            // after WINDOW FIT the footprint fills the canvas; the pointer sweeps the pad rows+            // (window fractions measured on the 4K box: canvas 0.25..0.77 x 0.19..0.87)+            let sweep = json!({"action": "sweep", "path": [[0.42, 0.32], [0.50, 0.32], [0.58, 0.32], [0.62, 0.45], [0.62, 0.60], [0.62, 0.72], [0.50, 0.77], [0.38, 0.70], [0.38, 0.50]]});             vec![("fit", fit2()),+                 ("pad sweep", sweep),                  ("pin 1", pan_box(p1x - 2.5, p1y - 2.5, p1x + 2.5, p1y + 2.5)),                  ("top row", pan_box(x1 - m, y2 - h * 0.25, x2 + m, y2 + m)),                  ("far corner", pan_box(x2 - w * 0.35, y1 - m, x2 + m, y1 + h * 0.35)),@@ -828,7 +884,7 @@ fn idle_step(kind: &str, tick: u64, boxes: &[Value], pass: &dyn Fn(&str, Value)         let args = if tick % 7 == 6 { json!({"action": "zoom", "factor": if (tick / 7) % 2 == 0 { 1.12 } else { 0.9 }}) } else { json!({"action": "orbit", "degrees": 6}) };         view(pass, &args)     } else if !boxes.is_empty() {-        let mut b = boxes[(tick as usize) % boxes.len()].clone(); b["space"] = json!("2d"); b["steps"] = json!(30);+        let mut b = boxes[(tick as usize) % boxes.len()].clone(); b["space"] = json!("2d"); b["steps"] = json!(10);         view(pass, &b)     } else { json!({"success": true}) } }@@ -840,10 +896,20 @@ fn motion(hwnd: i64, kind: &str, pass: &dyn Fn(&str, Value) -> Value, plan: Vec<     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; }-        let o = if kind == "2d" { let mut b = body.clone(); b["space"] = json!("2d"); view(pass, &b) } else { view(pass, &body) };+        let o = if body["action"] == "sweep" {+            // the real pointer, no overlay: pads highlight under it+            let mut ok = true;+            for pt in body["path"].as_array().cloned().unwrap_or_default() {+                if pending_interrupt() { break; }+                let r = ab("desktop_hover", json!({"hwnd": hwnd, "fx": pt[0], "fy": pt[1], "cursor": false, "reason": "Fusion tour: sweep the pointer across the pads so they highlight"}), 6);+                if r["success"].as_bool() != Some(true) { ok = false; break; }+                sleep_s(0.18);+            }+            json!({"success": ok, "error": if ok { Value::Null } else { json!("hover refused") }})+        } else if kind == "2d" { let mut b = body.clone(); b["space"] = json!("2d"); view(pass, &b) } else { view(pass, &body) };         if o["success"].as_bool() != Some(true) { rep["aborted"] = json!(format!("{name}: {}", o["error"].as_str().unwrap_or("script failed"))); break; }         rep["gestures"] = json!(rep["gestures"].as_u64().unwrap_or(0) + 1);-        sleep_s(if kind == "2d" { 1.4 } else { 1.1 });   // let the eye land before the next move+        sleep_s(if kind == "2d" { 0.9 } else { 1.0 });   // let the eye land before the next move     }     let after = shot_window(hwnd).and_then(|p| digest(&p));     rep["viewChanged"] = json!(before.is_some() && after.is_some() && before != after);
rust/core/src/dialogs.rs+21
@@ -34,6 +34,9 @@ pub fn category_set(name: &str) -> Vec<String> {  /// `_find_qt_dialog_windows` over ab's window rows: visible Qt-class windows that are not the /// main window, minus small / tall docked tool panels.+/// the title Fusion gives the bridge's activity palette (`ui.palettes.add(PALETTE_ID, "Adom", ...)`)+pub const BRIDGE_PALETTE_TITLE: &str = "Adom";+ pub fn qt_dialog_rows(rows: &[Value]) -> Vec<Value> {     let mut out = Vec::new();     for w in rows {@@ -41,6 +44,10 @@ pub fn qt_dialog_rows(rows: &[Value]) -> Vec<Value> {         let class = w["className"].as_str().or(w["class"].as_str()).unwrap_or("");         let title = w["title"].as_str().unwrap_or("");         if !class.contains("Qt") || title.contains("Autodesk Fusion") { continue; }+        // the bridge's own activity palette is a 760x820 Qt tool window titled "Adom": a UIA+        // control search over its HTML costs seconds, and it is never a dialog (2026-09-05:+        // every doc-switching verb paid ~13 s of post-envelope for exactly that search)+        if title.trim().eq_ignore_ascii_case(BRIDGE_PALETTE_TITLE) { continue; }         let rect = rect_of(&w["rect"]);         let (ww, hh) = (rect["width"].as_i64().unwrap_or(0), rect["height"].as_i64().unwrap_or(0));         if class.contains("Tool") && (ww < 400 || hh < 300 || hh > ww * 3 / 2) { continue; }@@ -62,3 +69,17 @@ pub fn rect_of(r: &Value) -> Value {     let (w, h) = (r["width"].as_i64().or_else(|| r["right"].as_i64().map(|x| x - l)).unwrap_or(0), r["height"].as_i64().or_else(|| r["bottom"].as_i64().map(|y| y - t)).unwrap_or(0));     serde_json::json!({"left": l, "top": t, "right": l + w, "bottom": t + h, "width": w, "height": h}) }++#[cfg(test)]+mod tests {+    #[test]+    fn own_palette_is_never_a_dialog() {+        let rows = vec![+            serde_json::json!({"className": "Qt683QWindowToolSaveBits", "hwnd": 1379664, "pid": 16004, "rect": [1900, 300, 2660, 1120], "title": "ADOM", "visible": true}),+            serde_json::json!({"className": "Qt683QWindow", "hwnd": 42, "pid": 16004, "rect": [100, 100, 700, 400], "title": "Save changes?", "visible": true}),+        ];+        let out = super::qt_dialog_rows(&rows);+        assert_eq!(out.len(), 1, "{out:?}");+        assert_eq!(out[0]["hwnd"], 42);+    }+}