app
Fusion - the Fusion 360 Bridge
Public Made by Adomby adom
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.
← Commit history
v1.9.42: fusion_state + save-prompt sentinel (#30 wedge fix) + classified blockingDialogs + Recovered Documents auto-close sweep + THE DASHBOARD (fusion-dashboard container CLI, dock.json, pkg installs bin/fusion-dashboard - the three-artifact pattern)
19 files changed
+966−17
BRIDGE_VERSION+1−1CHANGELOG.md+31−3adom-bridge-fusion-manifest.json+5−5bin/fusion-dashboardbridge.json+1−1dashboard/.gitignore+1dashboard/Cargo.toml+13dashboard/src/main.rs+242dashboard/ui/index.html+227describe.py+1dev-skills/fusion-dashboard/SKILL.md+92dock.json+26docs/img/dock-fusion.svg+1install.sh+11package.json+2−2page.json+12−5server.py+259skills/fusion-dashboard/SKILL.md+38tests/run_verb_tests.py+3BRIDGE_VERSION+1−1@@ -1 +1 @@-1.9.41+1.9.42
CHANGELOG.md+31−3@@ -1,7 +1,35 @@ # Changelog -All notable changes to the Adom Desktop Fusion 360 bridge, newest first.-Canonical: https://wiki.adom.inc/adom/adom-desktop-fusion-bridge · GitHub backup: `adom-inc/adom-desktop-fusion-bridge`+All notable changes to the Adom Bridge Fusion 360 bridge, newest first.+Canonical: https://wiki.adom.inc/adom/fusion-bridge · GitHub backup: `adom-inc/fusion-bridge`++## 1.9.42 - 2026-08-16++- **THE DASHBOARD (John's Launch Page spec).** New `dashboard/` Rust CLI (`fusion-dashboard`) served+ INTO THE CONTAINER by this page's pkg tarball - the first bridge with a container-side binary (the+ three-artifact pattern: bridge zip -> ab, pkg -> container skills + `bin/fusion-dashboard`, page+ media -> wiki). Serves a house-style web app (LED status per facet: Fusion running, sign-in, APS+ cloud search, MCP server, Data Hub, pending update; Setup flow for first-time users; Settings with+ the sticky auto-update toggle; copy-paste AI prompts; the demo tour with a live progress bar; a+ wiki basic-part import demo). Status aggregates by shelling `adom-bridge` verbs - the bridge stays+ the single source of truth - and every API response carries `_hint`s for the calling AI. `dock.json`+ (Dock Bar SDK 1.1.0) makes it a dock candidate: pkg install + `serve --print-url`.+- **`fusion_state` shipped (#28, ab's top ask on #30).** kicad_state parity: running + window + rect ++ dialogs + CLASSIFIED blockingDialogs + main-thread busy + best-effort activeDocument + a background+ screenshot (imageAs:base64 inlines up to 4MB). Server-side only ON PURPOSE - it answers even while+ the add-in main thread is busy or wedged, exactly the moments a render verb is needed.+- **Save-prompt sentinel (#30 wedge defect).** Fusion's unsaved-doc "Save / Don't Save / Cancel" modal,+ popped mid doc-switch, orphaned the in-flight add-in call and wedged the main thread past recovery.+ Doc-switching add-in calls now run under a watcher that auto-clicks "Don't Save" IN THE BACKGROUND -+ but ONLY when the dialog text names a document the bridge itself created this session (_BRIDGE_DOCS);+ a user's own unsaved design is never auto-answered. The watcher lingers ~12s past the call return to+ cover the prompt-after-timeout case. Add-in-side command watchdog remains tracked on #30.+- **`fusion_window_info` now returns classified `blockingDialogs[]`** (cause + resolution), the key+ ab's demo runner reads - previously only raw titles.+- **Recovered Documents dialog auto-closed by the 5-min background sweep** (John: "anytime it comes up,+ just ignore it and close it"). Launch-time relocation already prevented it on bridge-driven starts;+ the sweep now covers mid-session appearances and user-launched Fusion. Info-only dialog: File >+ Recover Documents keeps the list, and dismissal archives recovery files to ~/.adom/recovery. ## 1.6.58 - 2026-07-08 @@ -215,6 +243,6 @@ Canonical: https://wiki.adom.inc/adom/adom-desktop-fusion-bridge · GitHub backu ## Older versions -Entries for **1.6.37 and earlier** live in [`CHANGELOG_ARCHIVE.md`](https://wiki.adom.inc/adom/adom-desktop-fusion-bridge) (full history,+Entries for **1.6.37 and earlier** live in [`CHANGELOG_ARCHIVE.md`](https://wiki.adom.inc/adom/fusion-bridge) (full history, kept on the wiki page's Files tab, not shipped in the install tarball). The per-release changelog for every version is also on the page's **Releases** tab.
adom-bridge-fusion-manifest.json+5−5@@ -1,13 +1,13 @@ { "manifest_version": 1, "name": "fusion360",- "version": "1.9.41",- "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.41/adom-bridge-fusion-v1.9.41.zip",- "sha256": "eae894d94a8c4d977300b7524114a91ec4690babe0d819bdda7d675f94acc9ea",- "size": 386088,+ "version": "1.9.42",+ "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.42/adom-bridge-fusion-v1.9.42.zip",+ "sha256": "dba598f8af903ab8884d969d4736ef94c8b42cf7e9492ee49f76d1a53d68d269",+ "size": 390596, "verbPrefixes": [ "fusion_" ], "healthEndpoint": "/status",- "released_at": "2026-08-08T00:00:00Z"+ "released_at": "2026-08-16T00:00:00Z" }
bin/fusion-dashboardadded⋯ 1 unchanged line ⋯
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "fusion360", "displayName": "Autodesk Fusion 360",- "version": "1.9.41",+ "version": "1.9.42", "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/.gitignoreadded+1@@ -0,0 +1 @@+target/
dashboard/Cargo.tomladded+13@@ -0,0 +1,13 @@+[package]+name = "fusion-dashboard"+version = "1.9.42"+edition = "2021"++[dependencies]+tiny_http = "0.12"+serde_json = "1"++[profile.release]+opt-level = "z"+lto = true+strip = true
dashboard/src/main.rsadded+242@@ -0,0 +1,242 @@+// fusion-dashboard - the fusion bridge's Launch Page (John's Dashboard spec, 2026-08-16).+// The FIRST container-side bridge binary: installed by the wiki pkg tarball, serves the+// Dashboard web app, and aggregates status by shelling the adom-bridge CLI (the bridge+// stays the single source of truth; this is a thin, honest aggregator).+//+// AI HINTS ARE THE PRODUCT as much as the UI: every JSON response carries _hint fields+// teaching a calling AI what it can automate next. The dashboard is a surface for+// humans AND a discovery surface for AIs.++use std::process::Command;+use std::sync::{Arc, Mutex};+use tiny_http::{Header, Method, Response, Server};++const UI_HTML: &str = include_str!("../ui/index.html");+const VERSION: &str = env!("CARGO_PKG_VERSION");++fn ok(msg: &str) {+ println!("OK: {}", msg);+}+fn err_hint(msg: &str, hint: &str) {+ eprintln!("ERROR: {}", msg);+ eprintln!("Hint: {}", hint);+}++/// Shell an adom-bridge verb and parse its JSON. The ai-thread names this surface so+/// every call is attributed in the user's Activity Log.+fn bridge(target: &str, verb: &str, args: &str) -> serde_json::Value {+ let out = Command::new("adom-bridge")+ .args(["--target", target, "--ai-thread", "fusion-dashboard", verb, args])+ .output();+ match out {+ Ok(o) => serde_json::from_slice(&o.stdout).unwrap_or_else(|_| {+ serde_json::json!({"error": String::from_utf8_lossy(&o.stdout).chars().take(200).collect::<String>()})+ }),+ Err(e) => serde_json::json!({"error": format!("adom-bridge CLI not runnable: {}", e),+ "_hint": "The container needs the adom-bridge CLI on PATH (it ships with every Adom container)."}),+ }+}++fn unwrap_data(v: &serde_json::Value) -> serde_json::Value {+ // ab envelopes drift across versions - look through data, tolerate flat.+ if let Some(d) = v.get("data") {+ if d.is_object() && d.as_object().map(|m| !m.is_empty()).unwrap_or(false) {+ return d.clone();+ }+ }+ v.clone()+}++fn default_target() -> String {+ if let Ok(o) = Command::new("adom-bridge").arg("targets").output() {+ if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&o.stdout) {+ if let Some(t) = v["targets"].as_array().and_then(|a| a.first()) {+ return t["name"].as_str().unwrap_or("").to_string();+ }+ }+ }+ String::new()+}++/// The LED payload: one call per facet, each with state + an AI hint on how to act.+fn gather_status(target: &str) -> serde_json::Value {+ let rd = unwrap_data(&bridge(target, "fusion_readiness", "{}"));+ let aps = unwrap_data(&bridge(target, "fusion_aps_status", "{}"));+ let mcp = unwrap_data(&bridge(target, "fusion_mcp_status", "{}"));+ let auto_upd = unwrap_data(&bridge(target, "fusion_set_auto_update", "{}"));++ let running = rd["fusionRunning"].as_bool().or(rd["running"].as_bool()).unwrap_or(false);+ let ready = rd["ready"].as_bool().unwrap_or(false);+ let needs_signin = rd["needsSignin"].as_bool().unwrap_or(false);+ let addin = rd["addinVersion"].as_str().unwrap_or("").to_string();+ let aps_ok = aps["signedIn"].as_bool().or(aps["configured"].as_bool()).unwrap_or(false)+ || aps["ready"].as_bool().unwrap_or(false);+ let mcp_on = mcp["enabled"].as_bool().unwrap_or(false);+ let upd_offered = rd["fusionUpdateOffered"].as_bool().unwrap_or(false);+ let upd_handled = rd["fusionUpdateHandled"].as_bool().unwrap_or(false);++ serde_json::json!({+ "target": target,+ "dashboardVersion": VERSION,+ "bridgeVersion": rd["bridgeVersion"],+ "leds": {+ "fusionRunning": {"on": running, "label": "Fusion running",+ "_hint": if running {"Fusion is up."} else {"fusion_start launches it in the BACKGROUND (never steals focus)."}},+ "addinReady": {"on": ready, "label": "Bridge add-in", "version": addin,+ "_hint": if ready {"Add-in serving - all fusion_* verbs live."} else {"Not serving. If needsSignin, run fusion_signin - it self-drives to the password step."}},+ "signedIn": {"on": running && !needs_signin, "label": "Autodesk sign-in",+ "_hint": if needs_signin {"fusion_signin {email} self-drives the whole wall: launches, clicks, types the email, toasts the user ONLY at their password."} else {"Signed in."}},+ "aps": {"on": aps_ok, "label": "APS cloud search",+ "_hint": if aps_ok {"fusion_aps_search gives ~2s cloud lookups."} else {"Not configured: fusion_aps_set_client_id then fusion_aps_signin (silent when the browser session is warm)."}},+ "mcp": {"on": mcp_on, "label": "Fusion MCP server",+ "_hint": if mcp_on {"fusion_mcp_call drives Autodesk's own MCP tools."} else {"fusion_mcp_enable turns it on (~10s, drives the Preferences dialog with a caption)."}},+ "datahub": {"on": aps_ok, "label": "Data Hub API",+ "_hint": "Data Hub rides the APS auth: file listings, versions, urn resolution via fusion_aps_get."},+ "update": {"on": !upd_offered || upd_handled, "label": "Fusion up to date",+ "pending": upd_offered && !upd_handled,+ "_hint": if upd_offered && !upd_handled {"An update is WAITING. Auto-update normally clicks it silently; offer to run it now."} else {"No pending update (auto-update clicks nags silently by default; sticky opt-out via fusion_set_auto_update)."}},+ },+ "settings": {+ "autoUpdateFusion": auto_upd["autoUpdateFusion"].as_bool().unwrap_or(true)+ },+ "_hint": "This is the Dashboard status payload. Every LED carries its own _hint naming the verb that fixes it. POST /api/action {action, args} to act; GET /api/status to re-read."+ })+}++fn action(target: &str, name: &str, args: &serde_json::Value) -> serde_json::Value {+ let a = args.to_string();+ match name {+ "start" => bridge(target, "fusion_start", "{}"),+ "stop" => bridge(target, "fusion_stop", "{\"reason\":\"dashboard: user clicked Stop\"}"),+ "signin" => bridge(target, "fusion_signin", &a),+ "tour" => bridge(target, "fusion_demo", "{\"mode\":\"tour\"}"),+ "tour_progress" => bridge(target, "fusion_demo_progress", &a),+ "set_auto_update" => bridge(target, "fusion_set_auto_update", &a),+ "mcp_enable" => bridge(target, "fusion_mcp_enable", "{}"),+ "aps_signin" => bridge(target, "fusion_aps_signin", "{}"),+ "import_basic_part" => import_basic_part(target, args),+ _ => serde_json::json!({"error": format!("unknown action {}", name),+ "_hint": "Actions: start, stop, signin, tour, tour_progress, set_auto_update, mcp_enable, aps_signin, import_basic_part."}),+ }+}++/// The wiki-to-Fusion demo, end to end from the container: clone the part page,+/// send its .lbr to the box, open it in Fusion's Library Manager. All background.+fn import_basic_part(target: &str, args: &serde_json::Value) -> serde_json::Value {+ let page = args["page"].as_str().unwrap_or("aravk/in-s42ata");+ let tmp = format!("/tmp/dash-part-{}", std::process::id());+ let _ = std::fs::remove_dir_all(&tmp);+ let clone = Command::new("adom-wiki")+ .args(["repo", "clone", page, "--dir", &tmp]).output();+ if clone.is_err() {+ return serde_json::json!({"error": "adom-wiki CLI missing",+ "_hint": "The container ships adom-wiki; check PATH."});+ }+ let lbr = std::fs::read_dir(&tmp).ok().and_then(|rd| {+ rd.filter_map(|e| e.ok()).map(|e| e.path())+ .find(|p| p.extension().map(|x| x == "lbr").unwrap_or(false))+ });+ let Some(lbr) = lbr else {+ return serde_json::json!({"error": format!("{} has no .lbr file", page),+ "_hint": "Pick an adom basic part page (it publishes <PART>.lbr + <PART>.step)."});+ };+ let dest = "C:/Users/john/AppData/Local/Temp/fusion-dashboard";+ let send = bridge(target, "send_files",+ &serde_json::json!({"filePaths": [lbr.to_string_lossy()], "dest": dest}).to_string());+ let name = lbr.file_name().unwrap().to_string_lossy().to_string();+ let open = bridge(target, "fusion_open_lbr",+ &serde_json::json!({"filePath": format!("{}/{}", dest, name)}).to_string());+ serde_json::json!({"page": page, "sent": unwrap_data(&send), "opened": unwrap_data(&open),+ "_hint": "Wiki part -> Fusion Library Manager, all background. Next steps an AI can run: fusion_show_symbol / fusion_show_footprint {name}, fusion_bind_library_3d for the 3D, fusion_make_test_project for a solo demo board."})+}++fn serve(port: u16, target: String, print_url: bool) {+ let server = match Server::http(("0.0.0.0", port)) {+ Ok(s) => s,+ Err(e) => {+ err_hint(&format!("cannot bind port {}: {}", port, e),+ &format!("Try `fusion-dashboard serve --port {}`", port + 1));+ std::process::exit(1);+ }+ };+ let console: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));+ let log = |c: &Arc<Mutex<Vec<String>>>, m: String| {+ let mut g = c.lock().unwrap();+ g.push(m);+ let overflow = g.len().saturating_sub(500);+ if overflow > 0 { g.drain(0..overflow); }+ };+ if print_url {+ // Hydrogen dock await:url contract - the proxied URL a webview can reach.+ println!("http://127.0.0.1:{}/", port);+ }+ ok(&format!("fusion-dashboard v{} serving on port {} (target {})", VERSION, port,+ if target.is_empty() { "auto" } else { &target }));+ for mut req in server.incoming_requests() {+ let url = req.url().to_string();+ let method = req.method().clone();+ let tgt = if target.is_empty() { default_target() } else { target.clone() };+ let json_hdr = Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap();+ let resp = match (method, url.split('?').next().unwrap_or("")) {+ (Method::Get, "/") | (Method::Get, "/index.html") => {+ let h = Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..]).unwrap();+ req.respond(Response::from_string(UI_HTML).with_header(h)).ok();+ continue;+ }+ (Method::Get, "/api/status") | (Method::Get, "/state") => {+ log(&console, format!("status target={}", tgt));+ Response::from_string(gather_status(&tgt).to_string()).with_header(json_hdr)+ }+ (Method::Get, "/console") => {+ let body = serde_json::json!({"lines": *console.lock().unwrap()}).to_string();+ Response::from_string(body).with_header(json_hdr)+ }+ (Method::Get, "/health") => {+ Response::from_string(serde_json::json!({"ok": true, "version": VERSION}).to_string())+ .with_header(json_hdr)+ }+ (Method::Post, "/api/action") => {+ let mut body = String::new();+ req.as_reader().read_to_string(&mut body).ok();+ let v: serde_json::Value = serde_json::from_str(&body).unwrap_or(serde_json::json!({}));+ let name = v["action"].as_str().unwrap_or("").to_string();+ log(&console, format!("action {} target={}", name, tgt));+ let out = action(&tgt, &name, &v["args"]);+ Response::from_string(out.to_string()).with_header(json_hdr)+ }+ _ => Response::from_string(+ serde_json::json!({"error": "not found",+ "_hint": "GET /api/status, GET /state, GET /console, GET /health, POST /api/action {action,args}"}).to_string())+ .with_header(json_hdr),+ };+ req.respond(resp).ok();+ }+}++fn main() {+ let args: Vec<String> = std::env::args().collect();+ let cmd = args.get(1).map(|s| s.as_str()).unwrap_or("help");+ let get_flag = |name: &str| -> Option<String> {+ args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).cloned()+ };+ match cmd {+ "serve" => {+ let port: u16 = get_flag("--port").and_then(|p| p.parse().ok()).unwrap_or(8930);+ let target = get_flag("--target").unwrap_or_default();+ let print_url = args.iter().any(|a| a == "--print-url");+ serve(port, target, print_url);+ }+ "status" => {+ let target = get_flag("--target").unwrap_or_else(default_target);+ println!("{}", serde_json::to_string_pretty(&gather_status(&target)).unwrap());+ }+ "health" => ok(&format!("fusion-dashboard v{}", VERSION)),+ _ => {+ println!("fusion-dashboard v{} - the fusion bridge's Launch Page", VERSION);+ println!(" serve [--port 8930] [--target <box>] [--print-url] serve the Dashboard");+ println!(" status [--target <box>] one-shot LED JSON");+ println!(" health liveness");+ println!("Hint: `fusion-dashboard serve --print-url` is the Hydrogen dock launch step.");+ }+ }+}
dashboard/ui/index.htmladded+227@@ -0,0 +1,227 @@+<!doctype html>+<html lang="en">+<head>+<meta charset="utf-8">+<meta name="viewport" content="width=device-width, initial-scale=1">+<title>Fusion Dashboard</title>+<style>+:root{+ --bg:#0d1117;--surface:#161b22;--elevated:#1c2128;--overlay:#21262d;--border:#30363d;+ --text:#e6edf3;--text-body:#c9d1d9;--text-2:#8b949e;+ --accent:#00b8b1;--accent-hover:#00d4cb;--green:#3fb950;--red:#f85149;--yellow:#d29922;--blue:#64abff;+}+*{box-sizing:border-box;margin:0;padding:0}+@font-face{font-family:'Familjen Grotesk';src:url('https://adom.inc/fonts/FamiljenGrotesk-SemiBold.woff2') format('woff2');font-weight:600}+@font-face{font-family:'Satoshi';src:url('https://adom.inc/fonts/Satoshi-Regular.woff2') format('woff2');font-weight:400}+@font-face{font-family:'Satoshi';src:url('https://adom.inc/fonts/Satoshi-Medium.woff2') format('woff2');font-weight:500}+body{background:var(--bg);color:var(--text-body);font:15px/1.5 'Satoshi',sans-serif;min-height:100vh}+header{height:44px;display:flex;align-items:center;gap:12px;padding:0 16px;background:var(--surface);border-bottom:1px solid var(--border)}+.brand{display:flex;align-items:center;gap:8px;font-family:'Familjen Grotesk',sans-serif;font-weight:600;color:var(--accent);white-space:nowrap}+.brand svg{width:16px;height:16px;stroke:var(--text)}+.vsep{width:1px;height:20px;background:var(--border)}+.subject{font-weight:600;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}+.meta{font:12px 'JetBrains Mono',monospace;color:var(--text-2)}+.actions{margin-left:auto;display:flex;gap:8px}+.iconbtn{width:32px;height:32px;display:flex;align-items:center;justify-content:center;background:none;border:1px solid transparent;border-radius:8px;color:var(--text-body);cursor:pointer}+.iconbtn:hover{background:var(--elevated);border-color:var(--border)}+main{max-width:1080px;margin:0 auto;padding:20px 16px 60px}+h2{font-family:'Familjen Grotesk',sans-serif;font-size:17px;color:var(--text);margin:26px 0 12px}+.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:12px}+.card{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:14px}+.led-row{display:flex;align-items:center;gap:10px}+.led{width:12px;height:12px;border-radius:999px;background:var(--text-2);flex:none;transition:background .15s ease}+.led.on{background:var(--green);box-shadow:0 0 8px rgba(63,185,80,.5)}+.led.off{background:var(--red)}+.led.warn{background:var(--yellow)}+.led-label{font-weight:500;color:var(--text)}+.led-sub{font-size:12.5px;color:var(--text-2);margin-top:6px}+.btn{display:inline-flex;align-items:center;gap:8px;min-height:36px;padding:0 14px;border-radius:8px;border:1px solid var(--border);background:var(--elevated);color:var(--text);font:500 14px 'Satoshi',sans-serif;cursor:pointer;transition:background .15s ease}+.btn:hover{background:var(--overlay)}+.btn.primary{background:var(--accent);border-color:var(--accent);color:#05221f}+.btn.primary:hover{background:var(--accent-hover)}+.btnrow{display:flex;gap:10px;flex-wrap:wrap;margin-top:12px}+.prompt{display:flex;align-items:center;gap:10px;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:10px 12px;margin-bottom:8px}+.prompt code{font:13px 'JetBrains Mono',monospace;color:var(--text-body);flex:1;white-space:normal}+.copy{flex:none}+.bar{height:8px;background:var(--elevated);border:1px solid var(--border);border-radius:999px;overflow:hidden;margin-top:10px;display:none}+.bar>div{height:100%;width:0;background:var(--accent);transition:width .5s ease}+.barlabel{font-size:12.5px;color:var(--text-2);margin-top:6px}+#toasts{position:fixed;left:50%;bottom:18px;transform:translateX(-50%);display:flex;flex-direction:column-reverse;gap:8px;z-index:99999}+.toast{background:var(--overlay);border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:8px;padding:10px 14px;color:var(--text);font-size:14px;box-shadow:0 8px 24px rgba(0,0,0,.4)}+.toast.ok{border-left-color:var(--green)}.toast.err{border-left-color:var(--red)}+dialog{background:var(--overlay);color:var(--text-body);border:1px solid var(--border);border-radius:12px;padding:20px;max-width:520px;width:92vw}+dialog::backdrop{background:rgba(0,0,0,.55)}+.setrow{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:12px 0;border-bottom:1px solid var(--border)}+.toggle{width:42px;height:24px;border-radius:999px;background:var(--elevated);border:1px solid var(--border);position:relative;cursor:pointer;flex:none}+.toggle::after{content:'';position:absolute;top:2px;left:2px;width:18px;height:18px;border-radius:999px;background:var(--text-2);transition:all .15s ease}+.toggle.is-on{background:var(--accent);border-color:var(--accent)}+.toggle.is-on::after{left:21px;background:#05221f}+@media (max-width:720px){.meta{display:none}.grid{grid-template-columns:1fr 1fr}}+@media (max-width:480px){.grid{grid-template-columns:1fr}}+</style>+</head>+<body>+<header>+ <div class="brand">+ <svg viewBox="0 0 24 24" fill="none" stroke-width="1.8"><rect x="3" y="3" width="18" height="18" rx="3"/><path d="M8 12h8M12 8v8"/></svg>+ Fusion Dashboard <span style="color:var(--text-2)">· bridge launch page</span>+ </div>+ <div class="vsep"></div>+ <div class="subject" id="subject">connecting...</div>+ <div class="meta" id="meta"></div>+ <div class="actions">+ <button class="iconbtn" id="btnSettings" title="Settings" aria-label="Settings"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 1.55V21a2 2 0 1 1-4 0v-.09A1.7 1.7 0 0 0 9 19.4a1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-1.55-1H3a2 2 0 1 1 0-4h.09A1.7 1.7 0 0 0 4.6 9a1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1-1.55V3a2 2 0 1 1 4 0v.09a1.7 1.7 0 0 0 1 1.51 1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.7 1.7 0 0 0 19.4 9c.26.6.86 1 1.51 1H21a2 2 0 1 1 0 4h-.09c-.65 0-1.25.4-1.51 1z"/></svg></button>+ <button class="iconbtn" id="btnRefresh" title="Refresh status" aria-label="Refresh"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M21 12a9 9 0 1 1-2.64-6.36M21 3v6h-6"/></svg></button>+ </div>+</header>+<main>+ <h2>Status</h2>+ <div class="grid" id="ledGrid"></div>+ <div class="btnrow">+ <button class="btn primary" id="btnSetup">Setup</button>+ <button class="btn" id="btnStart">Start Fusion</button>+ <button class="btn" id="btnStop">Close Fusion</button>+ </div>++ <h2>Demos</h2>+ <div class="card">+ <div class="led-label">Guided tour</div>+ <div class="led-sub">Walks every surface on a sample part: library, symbol, footprint, 3D chip, a generated solo board in 2D and 3D. Runs entirely in the background.</div>+ <div class="btnrow"><button class="btn primary" id="btnTour">Run the tour</button></div>+ <div class="bar" id="tourBar"><div></div></div>+ <div class="barlabel" id="tourLabel"></div>+ </div>+ <div class="card" style="margin-top:12px">+ <div class="led-label">Import a wiki part into Fusion</div>+ <div class="led-sub">Pulls an adom basic part page from the wiki and opens its library in Fusion's Library Manager.</div>+ <div class="btnrow">+ <input id="partPage" value="aravk/in-s42ata" style="flex:1;min-width:180px;background:var(--elevated);border:1px solid var(--border);border-radius:8px;color:var(--text);padding:8px 10px;font:13px 'JetBrains Mono',monospace">+ <button class="btn" id="btnImportPart">Import part</button>+ </div>+ </div>+ <div class="card" style="margin-top:12px">+ <div class="led-label">Open a wiki molecule in Fusion</div>+ <div class="led-sub">Coming next: pulls a molecule page and opens its model in Fusion. Ask your AI: "open the molecule from wiki page owner/slug in Fusion".</div>+ </div>++ <h2>Ask your AI</h2>+ <div id="prompts"></div>+</main>++<dialog id="dlgSettings">+ <h2 style="margin-top:0">Settings</h2>+ <div class="setrow">+ <div><div class="led-label">Auto-update Fusion</div>+ <div class="led-sub">Silently clicks Fusion's update nag in the background so you always run the latest build. Sticky.</div></div>+ <div class="toggle" id="tglAutoUpdate" role="switch" aria-checked="true"></div>+ </div>+ <div class="btnrow" style="justify-content:flex-end"><button class="btn" id="btnCloseSettings">Close</button></div>+</dialog>++<dialog id="dlgSetup">+ <h2 style="margin-top:0">First-time setup</h2>+ <div id="setupBody" class="led-sub" style="font-size:14px"></div>+ <div class="btnrow" style="justify-content:flex-end">+ <button class="btn" id="btnSetupNext">Run next step</button>+ <button class="btn" id="btnCloseSetup">Close</button>+ </div>+</dialog>++<div id="toasts"></div>+<script>+const $=s=>document.querySelector(s);+const consoleBuf=[];+["log","warn","error"].forEach(k=>{const o=console[k].bind(console);console[k]=(...a)=>{consoleBuf.push(k+": "+a.join(" "));if(consoleBuf.length>300)consoleBuf.shift();o(...a);};});+window.onerror=(m)=>{consoleBuf.push("onerror: "+m);};+function toast(msg,kind){const t=document.createElement('div');t.className='toast '+(kind||'');t.textContent=msg;$('#toasts').appendChild(t);setTimeout(()=>t.remove(),kind==='err'?9000:4000);}+async function api(path,opts){const r=await fetch(path,opts);return r.json();}+async function act(action,args){return api('api/action',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action,args:args||{}})});}++let lastStatus=null;+function renderLeds(st){+ const g=$('#ledGrid');g.innerHTML='';+ const leds=st.leds||{};+ for(const key of Object.keys(leds)){+ const l=leds[key];+ const cls=l.pending?'warn':(l.on?'on':'off');+ const el=document.createElement('div');el.className='card';+ el.innerHTML=`<div class="led-row"><span class="led ${cls}"></span><span class="led-label">${l.label}</span></div><div class="led-sub">${l._hint||''}</div>`;+ g.appendChild(el);+ }+ $('#subject').textContent=st.target?('target: '+st.target):'no bridge target';+ $('#meta').textContent='bridge '+(st.bridgeVersion||'?')+' | dashboard '+(st.dashboardVersion||'');+}+async function refresh(){try{lastStatus=await api('api/status');renderLeds(lastStatus);}catch(e){toast('status failed: '+e,'err');}}+$('#btnRefresh').onclick=()=>{toast('refreshing status');refresh();};+$('#btnStart').onclick=async()=>{toast('starting Fusion in the background');await act('start');setTimeout(refresh,4000);};+$('#btnStop').onclick=async()=>{toast('closing Fusion');await act('stop');setTimeout(refresh,4000);};++// Tour with a REAL progress bar (the harness contract: percent / stepLabel / done).+$('#btnTour').onclick=async()=>{+ toast('starting the guided tour (background)');+ const s=await act('tour');const d=s.data||s;const id=d.demoId;+ if(!id){toast('tour failed to start: '+(d.error||'unknown'),'err');return;}+ $('#tourBar').style.display='block';+ const poll=setInterval(async()=>{+ const p=await act('tour_progress',{demoId:id,imagesAs:'paths'});const q=p.data||p;+ $('#tourBar>div').style.width=(q.percent||0)+'%';+ $('#tourLabel').textContent=(q.percent||0)+'% '+(q.stepLabel||'');+ if(q.done){clearInterval(poll);toast(q.failed?('tour failed: '+(q.error||'')):'tour complete: '+(q.shots||[]).length+' views captured',q.failed?'err':'ok');}+ },2000);+};+$('#btnImportPart').onclick=async()=>{+ const page=$('#partPage').value.trim();+ toast('importing '+page+' from the wiki into Fusion');+ const r=await act('import_basic_part',{page});const d=r.data||r;+ if(d.error){toast('import failed: '+d.error,'err');}else{toast('library opened in Fusion (background)','ok');}+};++// Settings+$('#btnSettings').onclick=()=>{const t=$('#tglAutoUpdate');const on=(lastStatus&&lastStatus.settings&&lastStatus.settings.autoUpdateFusion)!==false;t.classList.toggle('is-on',on);t.setAttribute('aria-checked',String(on));$('#dlgSettings').showModal();};+$('#btnCloseSettings').onclick=()=>$('#dlgSettings').close();+$('#tglAutoUpdate').onclick=async(e)=>{+ const t=e.currentTarget;const now=!t.classList.contains('is-on');+ t.classList.toggle('is-on',now);t.setAttribute('aria-checked',String(now));+ await act('set_auto_update',{enabled:now});+ toast('auto-update '+(now?'on':'off')+' (sticky)','ok');+};++// Setup: walk the not-green facets in order, one action per click.+const SETUP_ORDER=[+ {led:'fusionRunning',action:'start',label:'Start Fusion (background)'},+ {led:'signedIn',action:'signin',label:'Drive the Autodesk sign-in (you only type your password)'},+ {led:'aps',action:'aps_signin',label:'Set up APS cloud search'},+ {led:'mcp',action:'mcp_enable',label:'Enable the Fusion MCP server'},+];+function setupPending(){if(!lastStatus)return[];return SETUP_ORDER.filter(s=>{const l=lastStatus.leds[s.led];return l&&!l.on;});}+function renderSetup(){+ const p=setupPending();+ $('#setupBody').innerHTML=p.length?('Remaining steps:<br>'+p.map((s,i)=>`${i+1}. ${s.label}`).join('<br>')):'Everything is configured. You are ready to go.';+ $('#btnSetupNext').style.display=p.length?'':'none';+}+$('#btnSetup').onclick=async()=>{await refresh();renderSetup();$('#dlgSetup').showModal();};+$('#btnCloseSetup').onclick=()=>$('#dlgSetup').close();+$('#btnSetupNext').onclick=async()=>{+ const p=setupPending();if(!p.length)return;+ toast('running: '+p[0].label);+ await act(p[0].action, p[0].action==='signin'?{}:{});+ setTimeout(async()=>{await refresh();renderSetup();},5000);+};++// Copy-paste AI prompts (teach the user to drive the bridge through their AI).+const PROMPTS=[+ 'run the fusion demo tour and show me the gallery',+ 'import the basic part aravk/in-s42ata from the wiki and open it in Fusion',+ 'show me the symbol, footprint and 3D chip for the part I just imported',+ 'make a solo test project for this part and show me the 3D board',+ 'is Fusion signed in and is APS cloud search working? fix whatever is not',+ 'search my Fusion cloud files for BQ25792',+ 'export gerbers, BOM and CPL for the open board to a temp folder',+ 'keep Fusion updated automatically and never show me the update nag',+];+$('#prompts').innerHTML=PROMPTS.map(p=>`<div class="prompt"><code>${p}</code><button class="btn copy" onclick="navigator.clipboard.writeText('${p.replace(/'/g,"\\'")}');toast('copied','ok')">Copy</button></div>`).join('');++refresh();setInterval(refresh,15000);+</script>+</body>+</html>
describe.py+1@@ -207,6 +207,7 @@ _T = [ ("fusion_bind_library_3d", "The CORRECT library in ONE call (the Library button's real bar: the 3D chip renders in the Content Manager preview): mints the part's 3D as a cloud f3d (make_3d_package), injects the wip_urn binding into the .lbr ON DISK (packages3d + package3dinstances), and reopens the library. Needs a signed-in Fusion session - the one cloud-dependent step in the wiki-to-EE circle. Autodesk's thumbnail preview lags ~2-3 min behind a fresh mint. NOTE: distinct from the older fusion_build_library_3d (multi-part batch pipeline).", {"libraryPath": "required str (WINDOWS .lbr path)", "stepPath": "required str (WINDOWS .step path)", "deviceset": "optional str (defaults to the library's first)"}, {"success": "bool", "wip_urn": "str", "bound": "bool", "reopened": "bool"}, 480, None, True, {"libraryPath": "C:/Temp/PART.lbr", "stepPath": "C:/Temp/PART.step"}), ("fusion_show_library", "Eight-surfaces contract: the LIBRARY surface. libraryPath (WINDOWS .lbr) opens it; name alone handles the already-open case (symmetric with kicad_show_library). Background; returns a screenshot path.", {"libraryPath": "optional str (WINDOWS .lbr)", "name": "optional str (already-open library name)"}, {"success": "bool", "view": "str", "screenshot": "str"}, 240, None, True, {"libraryPath": "C:/Temp/PART.lbr"}), ("fusion_show_project", "Eight-surfaces contract: bring the PROJECT/design to its top level. No args = activate this session's generated test project (fusion_make_test_project). query = cloud design name (already-open match wins, else fire-and-poll open). urn = open_by_urn passthrough. Background; returns a screenshot path.", {"query": "optional str (cloud design name)", "urn": "optional str", "fileName": "alias of query"}, {"success": "bool", "view": "str", "document": "str", "screenshot": "str"}, 240, "fusion_get_app_state", True, {"query": "BQ25792"}),+ ("fusion_state", "The RENDER-ME verb (kicad_state parity, issues #28/#30): running + window + dialog + busy state PLUS a fresh background screenshot of whatever Fusion is showing - the one verb that makes every background fusion_show_* result visible. SERVER-SIDE ONLY by design: it answers even while the add-in main thread is busy or wedged (activeDocument is best-effort with a short budget and skipped when busy). blockingDialogs carries the CLASSIFIED modal list (cause + resolution). imageAs:base64 inlines the PNG (auto-refused over 4MB - pull the screenshot path via pull_file instead).", {"screenshot": "optional bool (default true)", "imageAs": "optional str path|base64 (default path)"}, {"success": "bool", "running": "bool", "activeDocument": "str", "mainThreadBusy": "dict|null", "blockingDialogs": "list", "screenshot": "str path", "screenshotBase64": "str (imageAs:base64 only)"}, 60, None, False, {}), ("fusion_export_iges", "Export to IGES.", {"outputPath": "required str"}, {"format": "str"}, 300, None, True, {"outputPath": "C:/out/board.iges"}), ("fusion_export_stl", "Export to STL mesh.", {"outputPath": "required str"}, {"format": "str"}, 300, None, True, {"outputPath": "C:/out/part.stl"}), ("fusion_export_3mf", "Export to 3MF mesh.", {"outputPath": "required str"}, {"format": "str"}, 300, None, True, {"outputPath": "C:/out/part.3mf"}),
dev-skills/fusion-dashboard/SKILL.mdadded+92@@ -0,0 +1,92 @@+---+name: fusion-dashboard+description: THE Dashboard - the fusion bridge's web surface (Launch Page) that opens from the Adom Dock Bar / Web Control / Hydrogen webview / pup, showing LED status for every bridge facet, a first-run Setup flow, a Settings panel, copy-paste AI prompts, demo launchers, and wiki part/molecule import demos. Served by a RUST BINARY CLI installed into the Linux container via the wiki pkg tarball - the FIRST bridge to ship a container-side binary (the new three-artifact pattern the Bridge SDK adopts and kicad mimics). Read BEFORE touching dashboard/ or the pkg layout.+---++# fusion-dashboard - the bridge's Launch Page++## The gold (John, 2026-08-16, the founding spec - do not lose ANY of this)++> "we need you to create a new surface which i'll call your Dashboard ... something that+> should and can open in a browser like the built-in webview in hydrogen or a pup window+> or some other browser surface and its sort of like your Launch Page that gives the adom+> user a summary of what's going on with you."++- **Trigger**: Adom is implementing a **Dock Bar** (documented on the wiki). Clicking the+ dock bar entry, or triggering from Web Control, opens this Dashboard.+- **Status LEDs** - a full high-level summary of all Fusion features:+ - Fusion running or not+ - APS configured AND working or not (cloud search capability, explicitly)+ - MCP server into Fusion configured / running or not+ - Other APIs (Data Hub) configured or not+ - Pending Fusion update sitting waiting for install or not+ - (Pattern: every facet the bridge knows about gets an LED; readiness is the source)+- **Setup button** for first-time users - walks through EVERYTHING configurable in the+ bridge (sign-in, APS, MCP, auto-update, demo samples).+- **Settings panel** - "most of our apps have a Settings panel" - toggles for key items+ with today's defaults (auto-update ON/sticky-opt-out, etc.).+- **Sample AI prompts** - copy/paste prompts so the user LEARNS how to drive the bridge.+- **Demo tour** - the web-control tour equally exists here (start + progress bar ++ gallery via fusion_demo {mode:"tour"} / fusion_demo_progress).+- **Sample components and boards** to show off the demos.+- **Wiki import demos**: (a) import an adom basic part from the wiki and open in Fusion;+ (b) open a MOLECULE from the wiki into Fusion.+- **AI hints galore in the code** - every API response teaches the calling AI.+- Built with the house **ui-design + app-creator** skill patterns (dark default,+ AI-responsive, responsive layout).++## The NEW three-artifact pattern (this bridge blazes the trail; kicad mimics)++A bridge's wiki page repo now ships THREE installable artifacts:++| Artifact | Installs into | Contents |+|---|---|---|+| Release zip + manifest | ab on Windows (`bridge_install`) | server.py, add-in, resources |+| pkg tarball (adompkg) | the Linux container | skills AND NOW `bin/fusion-dashboard` |+| (unchanged) page media/docs | the wiki page itself | README, heroes, proofs |++The pkg previously carried ONLY skills. The dashboard adds the first **Rust binary CLI**+to the container payload: `fusion-dashboard` - it serves and manages the Dashboard.++### Why a Rust CLI in the container (not a Windows-side page)++The user's AI lives in the container; the container talks to every ab box via the relay.+A container-served dashboard can aggregate MULTIPLE machines, reuse the `adom-bridge`+CLI for all status (no new protocol), and install/update through the exact pkg flow+users already have. Ports proxy to the user's browser through Hydrogen's port mapping.++### fusion-dashboard CLI contract++```+fusion-dashboard serve [--port 8930] [--target <box>] # serve UI + status API+fusion-dashboard open [--pup|--webview] # open it on a surface+fusion-dashboard status # one-shot JSON (the LED payload)+```++- Status is gathered by SHELLING `adom-bridge --target <box> fusion_readiness` (++ aps_status, mcp_status) - the CLI is a thin aggregator, the bridge stays the truth.+- The web app polls `/api/status`; actions POST to `/api/action` which shells the+ matching verb (fusion_start, fusion_set_auto_update, fusion_demo tour, ...).+- EVERY API response carries `_hint` fields teaching the AI what it can automate -+ the dashboard is a surface for humans AND a discovery surface for AIs.++## Serving surfaces++- Hydrogen webview: `adom-cli hydrogen workspace add-tab` on the proxied port.+- pup: `pup_open_window` at the proxied URL (background per etiquette).+- Dock Bar: registers the dashboard URL as this bridge's dock entry (see the wiki Dock+ Bar docs; the dock click = open the URL on the user's preferred surface).++## Build + release ritual (added to the publish loop)++1. `cd dashboard && cargo build --release` (musl static if possible - the container is+ the target; pin the binary in `pkg/bin/`).+2. The pkg tarball = skills/ + bin/fusion-dashboard + pkg.json postinstall that links it+ onto PATH. `adom-wiki pkg publish` as before.+3. The BRIDGE zip is unchanged by dashboard work - do not bloat it.+4. Version the dashboard with the bridge (one BRIDGE_VERSION everywhere).++## SDK evangelism++ab's Bridge SDK gets this pattern as "the container binary facet" - filed on the ab/SDK+threads so kicad-bridge can mimic. Keep the contract doc in sync when the CLI grows.
dock.jsonadded+26@@ -0,0 +1,26 @@+{+ "dock_manifest": "1.1.0",+ "webview": { "title": "Fusion Dashboard", "favicon": "docs/img/dock-fusion.svg" },+ "launch": {+ "mode": "container_command",+ "console": "show",+ "steps": [+ { "label": "Installing the Fusion bridge",+ "run": "adom-wiki pkg install adom/fusion-bridge",+ "skip_if": "command -v fusion-dashboard",+ "timeout_seconds": 180 },+ { "label": "Starting the Fusion Dashboard",+ "run": "fusion-dashboard serve --print-url",+ "await": "url",+ "timeout_seconds": 60 }+ ],+ "on_ready": "navigate_placeholder",+ "ai": {+ "prompt": "The user just opened the Fusion Dashboard from the dock. It shows LED status for every Fusion bridge facet. If any LED is red, offer to fix it (the /api/status payload carries a _hint per LED naming the exact verb). Do not steal their foreground.",+ "thread": "existing",+ "thread_fallback": "new",+ "submit": "staged",+ "typed": false+ }+ }+}
docs/img/dock-fusion.svgadded+1@@ -0,0 +1 @@+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#00b8b1" stroke-width="1.8"><rect x="3" y="3" width="18" height="18" rx="3"/><circle cx="8" cy="9" r="1.6" fill="#3fb950" stroke="none"/><circle cx="8" cy="15" r="1.6" fill="#3fb950" stroke="none"/><path d="M12 9h6M12 15h6"/></svg>
install.sh+11@@ -14,3 +14,14 @@ for skill_dir in "$PKG_DIR/skills"/*/; do cp -r "$skill_dir"* "$SKILLS_DIR/$name/" done echo "Installed adom-desktop-fusion-bridge skills: $(ls "$PKG_DIR/skills" 2>/dev/null | tr '\n' ' ')"++# The Dashboard CLI (the bridge's Launch Page server) - first container-side bridge+# binary (the three-artifact pattern: bridge zip -> ab, THIS pkg -> container, page media).+if [ -f "$PKG_DIR/bin/fusion-dashboard" ]; then+ DEST=""+ if [ -w /usr/local/bin ]; then DEST=/usr/local/bin+ else mkdir -p "$HOME/.local/bin"; DEST="$HOME/.local/bin"; fi+ cp "$PKG_DIR/bin/fusion-dashboard" "$DEST/fusion-dashboard"+ chmod +x "$DEST/fusion-dashboard"+ echo "Installed fusion-dashboard CLI -> $DEST/fusion-dashboard ($("$DEST/fusion-dashboard" health 2>/dev/null || echo 'installed'))"+fi
package.json+2−2@@ -1,6 +1,6 @@ { "slug": "adom-desktop-fusion-bridge",- "version": "1.8.3",+ "version": "1.9.42", "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": {@@ -67,4 +67,4 @@ "prompt": "How much material does each printed part in this assembly use?" } ]-}+}
page.json+12−5@@ -1,6 +1,6 @@ { "slug": "fusion-bridge",- "version": "1.8.3",+ "version": "1.9.42", "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": {@@ -25,7 +25,7 @@ ], "org": "adom", "title": "Fusion - the Fusion 360 Bridge",- "changelog": "1.6.47-1.6.51: seat/licensing dialog now detected by owned-popup + screenshot-verify with readiness self-heal (no more foreground steals or stuck launches); tarball hygiene - declared the adom/adom-desktop dependency, dropped the 1MB hero + PUBLISHING.md from the install payload (1.18MB -> ~100KB), de-duplicated the library docs (canonical copies live in the fusion-libraries / fusion-multipart-libraries skills), and fixed cross-doc references.",+ "changelog": "1.9.42: THE DASHBOARD - the bridge's Launch Page: a web app with LED status for every facet (Fusion, sign-in, APS, MCP, Data Hub, pending update), Setup flow, Settings, copy-paste AI prompts, demo tour, and wiki part import - served by the NEW fusion-dashboard Rust CLI this pkg now installs into the container (first bridge with a container binary; dock.json makes it a Dock Bar candidate). Bridge side: fusion_state (kicad_state parity - renders any background show verb, works even mid-wedge), save-prompt sentinel (auto-dismisses Fusion's unsaved-doc modal for bridge-created docs so doc switches can never wedge the main thread), classified blockingDialogs on fusion_window_info, and the Recovered Documents dialog is now auto-closed by the 5-min background sweep.", "updated_at": "2026-07-06T16:30:00.000Z", "hero": { "type": "image",@@ -53,7 +53,11 @@ "skills/fusion-molecule-export/**", "skills/pcb-stackup/**", "skills/fusion-demo/**",- "skills/fusion-multiprofile-signin/**"+ "skills/fusion-multiprofile-signin/**",+ "bin/fusion-dashboard",+ "dock.json",+ "docs/img/dock-fusion.svg",+ "skills/fusion-dashboard/**" ], "discovery_triggers": [ "drive fusion 360",@@ -71,7 +75,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.",@@ -101,4 +108,4 @@ "prompt": "How much material does each printed part in this assembly use?" } ]-}+}
server.py+259@@ -1101,6 +1101,12 @@ def _handle_window_info(fusion_info: dict, args: dict) -> dict: result = get_fusion_window_info() if result.get("success"): dialogs = result.get("dialogs", [])+ # ab's demo runner reads blockingDialogs[] off this verb (issue #30) - give it the+ # CLASSIFIED list (cause + resolution hint), not just raw titles.+ try:+ result["blockingDialogs"] = classify_blocking_dialogs() or []+ except Exception:+ result["blockingDialogs"] = [] dialog_info = [f" hwnd={d['hwnd']}: {d['title']}" for d in dialogs] output_parts = [ f"Main: hwnd={result['hwnd']}, title='{result['title']}'",@@ -1121,6 +1127,7 @@ def _handle_window_info(fusion_info: dict, args: dict) -> dict: "title": result["title"], "rect": result["rect"], "dialogs": dialogs,+ "blockingDialogs": result.get("blockingDialogs", []), "message": "\n".join(output_parts), "_hint": hint, })@@ -2316,6 +2323,16 @@ def _proxy_to_addin(command: str, args: dict, timeout: int = 30) -> dict: headers={"Content-Type": "application/json"}, method="POST", )+ # Save-prompt sentinel (issue #30): doc-switching commands can pop Fusion's unsaved-doc+ # modal mid-flight, orphaning this very call and wedging the add-in main thread. While+ # the call runs, a watcher auto-dismisses the prompt (bridge-created docs ONLY).+ _sentinel = None+ try:+ if command in _DOC_SWITCHING_ADDIN:+ _sentinel = _SavePromptSentinel(timeout + 10)+ _sentinel.__enter__()+ except Exception:+ _sentinel = None try: with urllib.request.urlopen(req, timeout=timeout) as resp: result = json.loads(resp.read())@@ -2361,6 +2378,14 @@ def _proxy_to_addin(command: str, args: dict, timeout: int = 30) -> dict: if "timed out" in str(e).lower() or "timeout" in str(e).lower(): return _diagnose_addin_timeout(command, {"error": str(e)}) return {"success": False, "error": f"Add-in request failed: {e}"}+ finally:+ # Grace-stop, not hard-stop: the wedge case is the prompt popping right AFTER the+ # caller's budget expired, so the watcher lingers ~12s past the return.+ if _sentinel is not None:+ try:+ _sentinel.soft_stop(12.0)+ except Exception:+ pass def _diagnose_addin_timeout(command: str, original_result: dict) -> dict:@@ -2487,6 +2512,10 @@ def _orchestrate_open_lbr(args: dict) -> dict: return {"success": False, "error": "No filePath specified"} file_path = file_path.replace("\\", "/")+ try:+ _note_bridge_doc(file_path) # save-prompt sentinel may auto-discard this doc's edits+ except Exception:+ pass results = [] # Step 1: Open the .lbr via Document.newDesignFromLocal.@@ -4186,6 +4215,218 @@ def _handle_set_auto_update(fusion_info: dict, args: dict) -> dict: COMMAND_HANDLERS["set_auto_update"] = _handle_set_auto_update +# ── SAVE-PROMPT SENTINEL (issue #30, 2026-08-16) ─────────────────────────────────────────────+# ab's live demo run proved that Fusion's unsaved-doc "Save / Don't Save / Cancel" modal,+# popped mid document-switch by our own show_* verbs, orphans the in-flight add-in call and+# can wedge the add-in main thread PERMANENTLY (only fusion_close {force} + fusion_start+# recovers). The show verbs themselves create the modified-doc state, so for BRIDGE-CREATED+# documents discarding is always correct: while a doc-switching command runs, a watcher+# scans for the prompt and clicks "Don't Save" IN THE BACKGROUND (UIA invoke, no focus).+#+# ⚠ DATA-SAFETY RULE: we only auto-discard when the dialog text names a document the bridge+# itself created this session (_BRIDGE_DOCS). A user's own unsaved design NEVER gets its+# Save prompt auto-answered - that would destroy their work. Unrecognized prompts are left+# standing and classified as before (classify_blocking_dialogs -> save_changes hint).++_BRIDGE_DOCS: set = set() # lowercase doc-name fragments the bridge created+_DONT_SAVE_LABELS = ("Don't Save", "Don’t Save")+++def _note_bridge_doc(name: str):+ """Register a document/library name as BRIDGE-CREATED (safe to auto-discard)."""+ n = str(name or "").rsplit("/", 1)[-1].rsplit("\\", 1)[-1]+ n = n.rsplit(".", 1)[0].strip().lower()+ if len(n) >= 3:+ _BRIDGE_DOCS.add(n)+ while len(_BRIDGE_DOCS) > 24:+ _BRIDGE_DOCS.pop()+++def _find_save_prompt() -> dict:+ """Is Fusion's unsaved-doc Save prompt up? Precise: it is the ONLY Fusion dialog+ with a "Don't Save" button. Returns {found, hwnd, button, text} - never raises."""+ try:+ info = get_fusion_window_info() or {}+ for d in info.get("dialogs") or []:+ h = d.get("hwnd")+ if not h:+ continue+ for label in _DONT_SAVE_LABELS:+ r = _find_controls_any(_ad_call("desktop_find_control",+ {"hwnd": int(h), "name": label}, timeout=10))+ best = r.get("best") or {}+ if best.get("name") and best.get("invokable"):+ # Pull the dialog's static text so the caller can see WHICH doc it names.+ text = ""+ try:+ t = _find_controls_any(_ad_call("desktop_find_control",+ {"hwnd": int(h), "name": "save"}, timeout=10))+ for c in ([t.get("best") or {}] + list(t.get("controls") or [])):+ nm = str(c.get("name") or "")+ if len(nm) > len(text):+ text = nm+ except Exception:+ pass+ return {"found": True, "hwnd": int(h), "button": best.get("name"), "text": text}+ except Exception:+ pass+ return {"found": False}+++def _dismiss_save_prompt_if_bridge_doc() -> dict:+ """Auto-click "Don't Save" ONLY when the prompt names a bridge-created doc.+ Returns {handled, why, ...} - never raises."""+ prompt = _find_save_prompt()+ if not prompt.get("found"):+ return {"handled": False, "why": "no save prompt"}+ text = (prompt.get("text") or "").lower()+ matched = next((n for n in _BRIDGE_DOCS if n in text), None)+ if not matched and text:+ return {"handled": False, "why": "prompt names a non-bridge document - NOT auto-discarding",+ "text": prompt["text"], "hwnd": prompt["hwnd"]}+ if not matched and not text:+ # No readable text (CEF-opaque build): only trust it if EVERY doc this session is ours+ # is unknowable, so stay conservative.+ return {"handled": False, "why": "prompt text unreadable - NOT auto-discarding", "hwnd": prompt["hwnd"]}+ r = _unwrap(_ad_call("desktop_ui_click",+ {"hwnd": prompt["hwnd"], "name": prompt["button"]}, timeout=15))+ ok = bool(r.get("invoked") or r.get("success"))+ if ok:+ print("[Fusion Bridge] save-prompt sentinel: clicked %r for bridge doc %r (background)"+ % (prompt["button"], matched))+ return {"handled": ok, "why": "clicked '%s' for bridge doc '%s'" % (prompt["button"], matched),+ "doc": matched}+++class _SavePromptSentinel:+ """Context manager: while a doc-switching add-in call runs, poll for the Save prompt+ and auto-dismiss it (bridge docs only) so the call never wedges behind the modal."""++ def __init__(self, max_seconds: float):+ import time as _t+ self.max_seconds = max(5.0, float(max_seconds))+ self._deadline = _t.time() + self.max_seconds+ self._stop = threading.Event()+ self._hits = []++ def soft_stop(self, grace_seconds: float = 12.0):+ """Shrink the watch window instead of killing it: keeps covering the just-returned+ call for a short grace period (the post-timeout prompt is the wedge case)."""+ import time as _t+ self._deadline = min(self._deadline, _t.time() + grace_seconds)++ def __enter__(self):+ def _watch():+ import time as _t+ while not self._stop.is_set() and _t.time() < self._deadline:+ try:+ info = get_fusion_window_info() or {}+ if info.get("dialogs"):+ got = _dismiss_save_prompt_if_bridge_doc()+ if got.get("handled"):+ self._hits.append(got)+ except Exception:+ pass+ self._stop.wait(1.0)+ self._thread = threading.Thread(target=_watch, daemon=True)+ self._thread.start()+ return self++ def __exit__(self, *exc):+ self._stop.set()+ return False+++# Add-in commands that switch/open documents and can pop the Save prompt mid-flight.+_DOC_SWITCHING_ADDIN = {+ "open_lbr", "open_cloud_file", "open_by_urn", "open_schematic", "open_board",+ "show_schematic", "show_2d_board", "show_3d_board", "import_electronics",+ "execute_text_command", "electron_run", "new_electronics_from_eagle",+}+++def _handle_fusion_state(fusion_info: dict, args: dict) -> dict:+ """fusion_state - the render-me verb (#28; kicad_state is the reference shape).++ SERVER-SIDE ONLY on purpose: it must answer even when the add-in main thread is busy+ or wedged (ab's demo falls back to window_info + desktop screenshots today exactly+ because a state verb has to work in those moments). Returns running/window/dialog+ state + a fresh background screenshot of the Fusion window; activeDocument is+ best-effort via the add-in with a short budget.++ args: screenshot (default true) | imageAs: "path" (default) | "base64" (inline PNG,+ auto-refused over ~4MB - pull the path via read_file instead)."""+ running = _is_fusion_running()+ result = {"success": True, "running": running}+ if not running:+ result["_hint"] = ("Fusion is not running - nothing to render. fusion_start launches it "+ "in the background; fusion_demo {mode:'tour'} cold-starts it itself.")+ result["output"] = json.dumps({"running": False})+ return result+ info = get_fusion_window_info() or {}+ result["hwnd"] = info.get("hwnd")+ result["title"] = info.get("title")+ result["rect"] = info.get("rect")+ result["dialogs"] = info.get("dialogs") or []+ try:+ result["blockingDialogs"] = classify_blocking_dialogs() or []+ except Exception:+ result["blockingDialogs"] = []+ # Busy state (the add-in's own report; 3s budget, never blocks the state read).+ busy = None+ try:+ st = _check_addin_status(timeout=3.0)+ if st:+ result["addinResponding"] = True+ if st.get("busy"):+ busy = {"command": st.get("busyCommand"), "elapsedSeconds": st.get("elapsedSeconds")}+ else:+ result["addinResponding"] = False+ except Exception:+ result["addinResponding"] = False+ result["mainThreadBusy"] = busy+ # Active document, best-effort (skip when busy - it would just be rejected).+ if result.get("addinResponding") and not busy:+ try:+ st = _proxy_to_addin("get_app_state", {}, timeout=5)+ result["activeDocument"] = (st.get("data") or {}).get("activeDocument")+ except Exception:+ pass+ # The screenshot: PrintWindow-based, background, no focus taken.+ if args.get("screenshot", True):+ try:+ shot = screenshot_fusion_window()+ if shot.get("success"):+ result["screenshot"] = shot.get("savedTo")+ if args.get("imageAs") == "base64":+ import base64 as _b64+ raw = open(shot["savedTo"], "rb").read()+ if len(raw) <= 4 * 1024 * 1024:+ result["screenshotBase64"] = _b64.b64encode(raw).decode("ascii")+ else:+ result["screenshotNote"] = ("PNG is %dKB (>4MB inline cap) - pull "+ "result.screenshot via read_file." % (len(raw) // 1024))+ except Exception as e:+ result["screenshotError"] = str(e)+ hints = []+ if busy:+ hints.append("Main thread busy (%s, %ss) - add-in verbs are rejected right now; "+ "window/screenshot verbs still work." % (busy["command"], busy["elapsedSeconds"]))+ if result["blockingDialogs"]:+ hints.append("A modal is up - see blockingDialogs for the classified cause + resolution.")+ if not hints:+ hints.append("Fusion is clear. The screenshot shows the current surface - this is the verb "+ "to render any fusion_show_* result (they open in the background by design).")+ result["_hint"] = " ".join(hints)+ result["output"] = json.dumps({"running": True, "activeDocument": result.get("activeDocument"),+ "mainThreadBusy": busy, "blockingDialogs": result["blockingDialogs"],+ "screenshot": result.get("screenshot")})+ return result+++COMMAND_HANDLERS["state"] = _handle_fusion_state++ # ── AUTODESK FUSION MCP SERVER PROXY (John, 2026-07-23) ────────────────────────────────────── # Autodesk + Anthropic shipped a Fusion MCP server: a LOCAL HTTP/JSON-RPC endpoint at # http://127.0.0.1:27182/mcp that exposes Fusion's own text-to-CAD surface (read geometry,@@ -4757,6 +4998,16 @@ def _signin_janitor_loop(): try: if _unwrap(_handle_fusion_readiness({}, {})).get("running"): _apply_fusion_update_silently()+ # Same sweep also clears the Recovered Documents dialog whenever it+ # shows up MID-SESSION or on a user-launched Fusion (launch-time+ # relocation only covers bridge-driven starts). Info-only dialog:+ # closing loses nothing (File > Recover Documents keeps the list),+ # so it is ALWAYS safe to close in the background. (John, 2026-08-16:+ # "anytime it comes up, just ignore it and close it".)+ try:+ dismiss_recovery_dialog()+ except Exception:+ pass except Exception: pass with _signin_windows_lock:@@ -5629,6 +5880,10 @@ def _lbr_wrap_write(name, symbol_xml=None, package_xml=None): d = os.path.join(tempfile.gettempdir(), "adom-fusion-wrap") os.makedirs(d, exist_ok=True) path = os.path.join(d, safe + ".lbr").replace("\\", "/")+ try:+ _note_bridge_doc(safe) # generated wrapper = bridge doc (sentinel may discard its edits)+ except Exception:+ pass try: with open(path, "w", encoding="utf-8") as f: f.write(_lbr_wrap_build(name, symbol_xml, package_xml))@@ -6495,6 +6750,10 @@ def _orchestrate_make_test_project(args: dict) -> dict: global _TEST_PROJECT _TEST_PROJECT = {"sch": sch_path, "brd": brd_path, "docBase": base.lower()}+ try:+ _note_bridge_doc(base) # generated test docs = bridge docs (sentinel may discard)+ except Exception:+ pass out = {"success": True, "schPath": sch_path, "brdPath": brd_path, "deviceset": deviceset, "steps": steps, "circuit": ("JUST %s on a small board (solo demo layout)" % deviceset) if solo
skills/fusion-dashboard/SKILL.mdadded+38@@ -0,0 +1,38 @@+---+name: fusion-dashboard+description: The Fusion bridge's Dashboard (Launch Page) - a web app showing LED status for every bridge facet (Fusion running, sign-in, APS cloud search, MCP server, Data Hub, pending update), a first-run Setup flow, a Settings panel, copy-paste AI prompts, the demo tour, and a wiki part import demo. Served by the fusion-dashboard CLI installed in this container. Use when the user asks for the fusion dashboard, fusion status page, fusion launch page, "what's going on with fusion", or clicks the Fusion dock entry.+---++# fusion-dashboard - the bridge's Launch Page++A Rust CLI in THIS container serving the Dashboard web app. Status comes from shelling+`adom-bridge` (the bridge stays the single source of truth); every API response carries+`_hint` fields telling you what to automate next.++## CLI++```+fusion-dashboard serve [--port 8930] [--target <box>] [--print-url] # serve the app+fusion-dashboard status [--target <box>] # one-shot LED JSON+fusion-dashboard health # liveness+```++- `serve --print-url` prints the local URL first - the Hydrogen dock's `await:"url"` step.+- No `--target` = first box from `adom-bridge targets`.+- HTTP: `GET /api/status` (the LED payload), `POST /api/action {action, args}` (start,+ stop, signin, tour, tour_progress, set_auto_update, mcp_enable, aps_signin,+ import_basic_part), `GET /state`, `GET /console`, `GET /health`.++## Displaying this app (REQUIRED - read before showing anything)++Reach it from outside the container ONLY via the proxy URL:+`https://<slug>.adom.cloud/proxy/8930/` (a webview/pup renders OUTSIDE the container, so+`127.0.0.1:8930` is unreachable there).++- **NEVER open this app in VS Code's "Simple Browser"** - not a webview in the Adom+ ecosystem: no live channel, no console read-back, no AI drivability. Using it is a bug.+- Surfaces: **wv** - Hydrogen webview panel (`webview` skill), **pup** (`pup` skill,+ background etiquette: never steal foreground), the user's **real signed-in browser**+ (Adom browser extension), and the **Dock Bar** (dock.json in this repo launches it).+- The `/api/status` LEDs each carry a `_hint` naming the verb that fixes a red LED - act+ on those instead of guessing.
tests/run_verb_tests.py+3@@ -233,6 +233,8 @@ t("fusion_import_step", "weekly", {"_dynamic": "staged_step"}, {"ok": True}, t("fusion_make_test_project", "weekly", {"_dynamic": "staged_lbr_mtp"}, {"ok": True, "fields": ["schPath", "brdPath"]}, "from-scratch project generation around the staged sample part (John's no-BQ25792 rule)") t("fusion_screenshot_fusion", "daily", {}, {"ok": True, "fields": ["savedTo"]}, "background main-window capture")+t("fusion_state", "daily", {}, {"ok": True, "fields": ["running"]},+ "the render-me verb (#28/#30): window+dialog+busy state + background screenshot; must answer even mid-wedge") t("fusion_show_project", "weekly", {}, {"ok": True, "fields": ["document"]}, "no-args form: activates the generated test project from make_test_project just above") t("fusion_demo#tour", "weekly", {"mode": "tour", "includeDesign": False, "_pollTour": 300}, {"ok": True},@@ -278,6 +280,7 @@ _SECTION_BY_VERB = { "fusion_build_library_3d": "gui-driving", "fusion_detect_layers": "gui-driving", "fusion_new_electronics_from_eagle": "gui-driving", "fusion_update_libraries": "gui-driving", "fusion_signin": "gui-driving", "fusion_demo": "gui-driving", "fusion_notify": "gui-driving",+ "fusion_state": "gui-driving", "focus_invariance": "gui-driving", "fusion_inspect_bodies": "cad-3d-bom", "fusion_physical_properties": "cad-3d-bom", "fusion_assembly_bom": "cad-3d-bom", "fusion_get_design_info": "cad-3d-bom",