← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.9.61\ No newline at end of file+1.9.62\ No newline at end of file
CHANGELOG.md+24
@@ -3,6 +3,30 @@ 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.62 - 2026-08-18++- **AUTO-GREEN: opening the Dashboard now MAKES everything ready (John's ruling).** "The meaning+  of opening the dashboard is that you open fusion and all the other services." On open (and on+  Setup) the Dashboard drives every facet to green in the background: launches Fusion if closed,+  self-drives the Autodesk sign-in to the password wall, enables the MCP server, signs in to APS+  when a client id is configured - narrating each step as a toast and stopping honestly at any+  wall that needs a human. Sticky opt-out toggle returns the old status-only behavior.+  Live-verified: closed Fusion to all seven LEDs green in 86s.+- **SURFACES demo on a DIFFERENT part**: eight content-type buttons (symbol, footprint, 3D+  package, library, schematic, 2D board, 3D board, project) driving aravk/in-s63btg (green 0603)+  instead of web control's IN-S42ATR (red 0402), so the click-through proves the flow generalizes.+  First click stages the part (clone -> send_files -> bind_library_3d -> make_test_project),+  later clicks just switch surfaces; each card renders its own fusion_state capture.+- **Driving badge: race fixed + user toggle.** `fusion_start` is dispatched BEFORE Fusion's window+  exists, so the single badge attempt found no hwnd and the badge never appeared after an+  auto-launch (John caught it). The ensure path now retries for ~90s and the janitor self-heals a+  missing badge whenever driving is recent and Fusion is up. New sticky `fusion_set_driving_badge`+  verb + Settings toggle (off clears it immediately).+- **Settings restyled as ah settings cards** (new adom-ui-design `settings-layout` skill): elevated+  cards with a teal left accent, inline live status per card, and descriptive prose that explains+  the MECHANISM of each group (what auto-green actually does step by step, how the seat take-over+  answers Autodesk, how the badge is painted and when it sheds), plus the storage-location footer.+ ## 1.9.61 - 2026-08-18  - **windowEtiquette annotation now covers ORCHESTRATED verbs** (show_*, make_test_project, tour,
adom-bridge-fusion-manifest.json+4−4
@@ -1,10 +1,10 @@ {     "manifest_version": 1,     "name": "fusion360",-    "version": "1.9.61",-    "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.61/adom-bridge-fusion-v1.9.61.zip",-    "sha256": "b511771ebbb10ce1e1570c4064ae23160c32c6da7a713ba9463a58c49e97f879",-    "size": 404984,+    "version": "1.9.62",+    "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.62/adom-bridge-fusion-v1.9.62.zip",+    "sha256": "5f28232cd79b3ee8094ebaacf4be3716f771709e95cfedac0da7505423f40d1a",+    "size": 405827,     "verbPrefixes": [         "fusion_"     ],
bin/fusion-dashboard
⋯ 1 unchanged line ⋯
dashboard/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.9.61"+version = "1.9.62" edition = "2021"  [dependencies]
dashboard/src/main.rs+293−1
@@ -85,6 +85,7 @@ fn gather_status(ai_thread: &str, target: &str) -> serde_json::Value {     // No-args form is a pure READ of the sticky pref (verified in server.py).     let auto_upd = unwrap_data(&bridge(ai_thread, target, "fusion_set_auto_update", "{}"));     let seat = unwrap_data(&bridge(ai_thread, target, "fusion_set_seat_takeover", "{}"));+    let badge = unwrap_data(&bridge(ai_thread, target, "fusion_set_driving_badge", "{}"));      let running = rd["fusionRunning"].as_bool().or(rd["running"].as_bool()).unwrap_or(false);     let ready = rd["ready"].as_bool().unwrap_or(false);@@ -134,8 +135,11 @@ fn gather_status(ai_thread: &str, target: &str) -> serde_json::Value {         },         "settings": {             "autoUpdateFusion": auto_upd["autoUpdateFusion"].as_bool().unwrap_or(true),-            "autoTakeOverSeat": seat["autoTakeOverSeat"].as_bool().unwrap_or(true)+            "autoTakeOverSeat": seat["autoTakeOverSeat"].as_bool().unwrap_or(true),+            "autoGreenOnLaunch": read_settings()["autoGreenOnLaunch"].as_bool().unwrap_or(true),+            "showDrivingBadge": badge["showDrivingBadge"].as_bool().unwrap_or(true)         },+        "demoPart": read_settings()["demoPart"],         "_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; subscribe to /events (SSE) for live pushes; POST /ui/toast {message,kind} to narrate on top of the app."     }) }@@ -150,6 +154,7 @@ fn action(ai_thread: &str, target: &str, name: &str, args: &serde_json::Value) -         "tour_progress" => bridge(ai_thread, target, "fusion_demo_progress", &a),         "set_auto_update" => bridge(ai_thread, target, "fusion_set_auto_update", &a),         "set_seat_takeover" => bridge(ai_thread, target, "fusion_set_seat_takeover", &a),+        "set_driving_badge" => bridge(ai_thread, target, "fusion_set_driving_badge", &a),         "mcp_enable" => bridge(ai_thread, target, "fusion_mcp_enable", "{}"),         "aps_signin" => bridge(ai_thread, target, "fusion_aps_signin", "{}"),         "import_basic_part" => import_basic_part(ai_thread, target, args),@@ -211,6 +216,41 @@ struct App {     hub: Hub,     console: Mutex<Vec<String>>,     last_activity: Mutex<Instant>,+    // Auto-green: John's ruling 2026-08-18 - "the meaning of opening the dashboard is that you+    // open fusion and all the other services", with a sticky opt-out toggle.+    auto_green_running: Mutex<bool>,+    auto_green_last: Mutex<Option<Instant>>,+    // Surfaces demo staging, keyed BY TARGET: staging sends files to one specific box, so a+    // single global slot made a switch to another box reuse paths that box never received+    // (caught live 2026-08-18).+    staged: Mutex<std::collections::HashMap<String, (String, String)>>,+}++// ── Dashboard-own settings (container-side; bridge prefs stay in the bridge) ──────────────+fn settings_path() -> std::path::PathBuf {+    let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());+    let dir = std::path::Path::new(&home).join(".adom").join("fusion-dashboard");+    let _ = std::fs::create_dir_all(&dir);+    dir.join("settings.json")+}++fn read_settings() -> serde_json::Value {+    let mut v = std::fs::read_to_string(settings_path()).ok()+        .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())+        .unwrap_or_else(|| serde_json::json!({}));+    if v.get("autoGreenOnLaunch").is_none() {+        v["autoGreenOnLaunch"] = serde_json::Value::Bool(true);   // ON by default, per John+    }+    if v.get("demoPart").is_none() {+        // Deliberately NOT web control's IN-S42ATR: a different adom basic part (green 0603 vs+        // red 0402) proves the flow generalizes instead of re-proving one file.+        v["demoPart"] = serde_json::Value::String("aravk/in-s63btg".into());+    }+    v+}++fn write_settings(v: &serde_json::Value) {+    let _ = std::fs::write(settings_path(), serde_json::to_string_pretty(v).unwrap_or_default()); } impl App {     fn log(&self, m: String) {@@ -226,6 +266,217 @@ impl App {     } } ++// ── AUTO-GREEN (John, 2026-08-18) ────────────────────────────────────────────────────────+// "when i launch the dashboard, the default behavior should be to get all those led's green,+//  meaning if fusion isn't launched, you literally launch it ... but i need a toggle to make+//  this not be the default, and if i turn it off you just show led status and don't act."+//+// So: opening the Dashboard = an INTENT to work, not just to look. The sequence does every+// step it can do WITHOUT the human, narrates each one as a toast on the live channel, and+// stops honestly at any wall that genuinely needs a person (a password, an unconfigured APS+// client id) rather than pretending. All of it background - never steals focus.+fn auto_green(app: &Arc<App>, target: &str) {+    {   // single-flight + 3-minute debounce: several tabs/reloads must not stack launches+        let mut running = app.auto_green_running.lock().unwrap();+        if *running { return; }+        let last = app.auto_green_last.lock().unwrap();+        if let Some(t) = *last {+            if t.elapsed() < Duration::from_secs(180) { return; }+        }+        *running = true;+    }+    let t = target.to_string();+    let ai = app.ai_thread.clone();+    let app2 = app.clone();+    std::thread::spawn(move || {+        let toast = |msg: &str, kind: &str| {+            app2.hub.broadcast("toast", &serde_json::json!({"message": msg, "kind": kind}));+            app2.log(format!("auto-green: {}", msg));+        };+        let push_state = || {+            let st = gather_status(&ai, &t);+            app2.hub.broadcast("state", &st);+            st+        };+        let mut st = push_state();+        let led = |st: &serde_json::Value, k: &str| st["leds"][k]["on"].as_bool().unwrap_or(false);++        if led(&st, "fusionRunning") && led(&st, "addinReady") && led(&st, "aps") && led(&st, "mcp") {+            toast("Everything is already green on this box", "ok");+        } else {+            toast(&format!("Getting {} ready - launching Fusion and its services in the background", t), "");++            // 1. Fusion itself.+            if !led(&st, "fusionRunning") {+                bridge(&ai, &t, "fusion_start", "{}");+                for _ in 0..30 {+                    std::thread::sleep(Duration::from_secs(10));+                    st = push_state();+                    if led(&st, "addinReady") { break; }+                    // A sign-in wall shows up as running-but-never-ready: handle it below.+                    if led(&st, "fusionRunning") && st["leds"]["signedIn"]["on"].as_bool() == Some(false)+                        && st["leds"]["signedIn"]["state"].as_str() == Some("off") { break; }+                }+                if led(&st, "addinReady") { toast("Fusion is up and the bridge add-in is serving", "ok"); }+            }++            // 2. The Autodesk sign-in wall - self-drive as far as code can, then hand off.+            st = push_state();+            if led(&st, "fusionRunning") && !led(&st, "addinReady") {+                let si = unwrap_data(&bridge(&ai, &t, "fusion_signin", "{}"));+                if si["signinWall"].is_string() {+                    toast("Fusion needs your Autodesk password - I drove the sign-in to that step for you", "err");+                } else {+                    for _ in 0..12 { std::thread::sleep(Duration::from_secs(10)); st = push_state();+                                     if led(&st, "addinReady") { break; } }+                }+            }++            // 3. Autodesk's MCP server (a Fusion preference; needs Fusion up).+            st = push_state();+            if led(&st, "addinReady") && !led(&st, "mcp") {+                toast("Turning on the Fusion MCP server", "");+                bridge(&ai, &t, "fusion_mcp_enable", "{}");+                st = push_state();+                if led(&st, "mcp") { toast("Fusion MCP server enabled", "ok"); }+            }++            // 4. APS cloud search. Signing in is only attempted when a client id is already+            //    configured (the warm case that can complete silently); an unconfigured APS is+            //    a real setup task for the human, so we say so instead of opening browsers.+            st = push_state();+            if !led(&st, "aps") {+                let aps = unwrap_data(&bridge(&ai, &t, "fusion_aps_status", "{}"));+                if aps["configured"].as_bool().unwrap_or(false) {+                    toast("Signing in to APS cloud search", "");+                    bridge(&ai, &t, "fusion_aps_signin", "{}");+                    st = push_state();+                    if led(&st, "aps") { toast("APS cloud search is live", "ok"); }+                } else {+                    toast("APS cloud search needs a one-time setup (client id) - use Setup when you want it", "err");+                }+            }+        }++        let st = push_state();+        let reds: Vec<String> = st["leds"].as_object().map(|o| o.iter()+            .filter(|(_, v)| !v["on"].as_bool().unwrap_or(false) && v["state"].as_str() != Some("dormant"))+            .map(|(k, _)| k.clone()).collect()).unwrap_or_default();+        if reds.is_empty() { toast("All green - ready to go", "ok"); }+        else { toast(&format!("Ready except: {} (see each card for the exact next step)", reds.join(", ")), ""); }++        *app2.auto_green_last.lock().unwrap() = Some(Instant::now());+        *app2.auto_green_running.lock().unwrap() = false;+    });+}++// ── SURFACES DEMO (John, 2026-08-18) ─────────────────────────────────────────────────────+// The same eight-surface showcase the ab web-control demo runs, but on a DIFFERENT adom basic+// part (default aravk/in-s63btg, a green 0603 LED - web control uses the red 0402 IN-S42ATR),+// so clicking through proves the flow generalizes to a new part rather than re-proving one file.+// Staging is lazy and cached: the first surface click clones the wiki page, sends the .lbr/.step+// to the box, binds the 3D, and generates a solo test project; later clicks just switch surfaces.+const SURFACES: [(&str, &str, &str); 8] = [+    ("symbol",    "fusion_show_symbol",     "Symbol"),+    ("footprint", "fusion_show_footprint",  "Footprint"),+    ("package3d", "fusion_show_3d_package", "3D package"),+    ("library",   "fusion_show_library",    "Library"),+    ("schematic", "fusion_show_schematic",  "Schematic"),+    ("board2d",   "fusion_show_2d_board",   "2D board"),+    ("board3d",   "fusion_show_3d_board",   "3D board"),+    ("project",   "fusion_show_project",    "Project"),+];++fn stage_demo_part(app: &Arc<App>, target: &str, page: &str) -> Result<(String, String), String> {+    let cache_key = format!("{}|{}", target, page);+    if let Some(hit) = app.staged.lock().unwrap().get(&cache_key).cloned() { return Ok(hit); }+    let ai = app.ai_thread.clone();+    let toast = |msg: &str, kind: &str| {+        app.hub.broadcast("toast", &serde_json::json!({"message": msg, "kind": kind}));+        app.log(format!("surfaces: {}", msg));+    };+    // Fail FAST and clearly if the box is not ready - otherwise we stage files onto a machine+    // whose Fusion is closed and every surface returns a confusing "not running".+    let rd = unwrap_data(&bridge(&ai, target, "fusion_readiness", "{}"));+    if rd["ready"].as_bool() != Some(true) {+        return Err(format!("Fusion is not ready on {} - press Setup (or turn on 'Get everything ready') and try again", target));+    }+    toast(&format!("Staging {} from the wiki (clone -> send -> bind 3D -> generate project)", page), "");+    let tmp = format!("/tmp/fd-surfaces-{}", std::process::id());+    let _ = std::fs::remove_dir_all(&tmp);+    let cl = Command::new("adom-wiki").args(["repo", "clone", page, "--dir", &tmp]).output();+    if cl.is_err() { return Err("adom-wiki CLI not runnable".into()); }+    let mut lbr = None; let mut step = None;+    if let Ok(rd) = std::fs::read_dir(&tmp) {+        for e in rd.filter_map(|e| e.ok()) {+            let p = e.path();+            match p.extension().and_then(|x| x.to_str()).map(|s| s.to_lowercase()) {+                Some(ref x) if x == "lbr" => lbr = Some(p.to_string_lossy().to_string()),+                Some(ref x) if x == "step" || x == "stp" => step = Some(p.to_string_lossy().to_string()),+                _ => {}+            }+        }+    }+    let lbr = lbr.ok_or_else(|| format!("{} publishes no .lbr", page))?;+    let dest = "C:/Users/john/AppData/Local/Temp/fusion-dashboard-surfaces";+    let mut files = vec![lbr.clone()];+    if let Some(s) = &step { files.push(s.clone()); }+    bridge(&ai, target, "send_files",+           &serde_json::json!({"filePaths": files, "dest": dest}).to_string());+    let base = std::path::Path::new(&lbr).file_name().unwrap().to_string_lossy().to_string();+    let win_lbr = format!("{}/{}", dest, base);+    let deviceset = base.rsplit_once('.').map(|(a, _)| a.to_string()).unwrap_or(base.clone());++    if let Some(s) = &step {+        let sb = std::path::Path::new(s).file_name().unwrap().to_string_lossy().to_string();+        toast("Binding the part's real 3D model into the library", "");+        bridge(&ai, target, "fusion_bind_library_3d", &serde_json::json!({+            "libraryPath": win_lbr, "stepPath": format!("{}/{}", dest, sb)}).to_string());+    }+    toast("Generating a solo test project around the part", "");+    let mp = unwrap_data(&bridge(&ai, target, "fusion_make_test_project",+                                 &serde_json::json!({"libraryPath": win_lbr}).to_string()));+    if mp["success"].as_bool() != Some(true) {+        // Component surfaces still work without the design half - report, do not abort.+        toast("Design surfaces unavailable (test project generation failed) - component surfaces still work", "err");+    }+    app.staged.lock().unwrap().insert(cache_key, (win_lbr.clone(), deviceset.clone()));+    toast(&format!("{} staged - every surface button is live", deviceset), "ok");+    Ok((win_lbr, deviceset))+}++fn show_surface(app: &Arc<App>, target: &str, key: &str, page: &str) -> serde_json::Value {+    let (verb, label) = match SURFACES.iter().find(|(k, _, _)| *k == key) {+        Some((_, v, l)) => (*v, *l),+        None => return serde_json::json!({"error": format!("unknown surface {}", key)}),+    };+    let (lib, dev) = match stage_demo_part(app, target, page) {+        Ok(x) => x,+        Err(e) => return serde_json::json!({"error": e}),+    };+    let ai = app.ai_thread.clone();+    let args = match key {+        "symbol" | "footprint" | "package3d" => serde_json::json!({"name": dev, "libraryPath": lib}),+        "library" => serde_json::json!({"libraryPath": lib}),+        _ => serde_json::json!({}),      // design surfaces act on the generated project+    };+    let r = unwrap_data(&bridge(&ai, target, verb, &args.to_string()));+    let ok = r["success"].as_bool().unwrap_or(false);+    // Render it: fusion_state is the render-me verb (the show verbs open in the background).+    let mut shot = serde_json::Value::Null;+    if ok {+        let st = unwrap_data(&bridge(&ai, target, "fusion_state",+                                     "{\"imageAs\":\"base64\"}"));+        shot = st["screenshotBase64"].clone();+    }+    serde_json::json!({+        "surface": key, "label": label, "ok": ok, "deviceset": dev,+        "error": r["error"].clone(), "image": shot,+        "_hint": format!("{} surface via {} on the staged part - opened in the BACKGROUND and rendered with fusion_state.", label, verb)+    })+}+ // ── Minimal HTTP/1.1 with explicit flushes ─────────────────────────────────────────────── fn respond(stream: &mut TcpStream, status: &str, ctype: &str, body: &[u8]) {     let head = format!(@@ -357,6 +608,44 @@ fn handle_conn(app: &Arc<App>, mut stream: TcpStream) {             let out = action(&app.ai_thread, &t, &name, &body["args"]);             respond_json(&mut stream, &out);         }+        ("GET", "/settings") => {+            respond_json(&mut stream, &serde_json::json!({"settings": read_settings(),+                "_hint": "Dashboard-own settings (container-side). autoGreenOnLaunch: opening the Dashboard actively drives every facet to green; off = passive status only. demoPart: the wiki part the Surfaces demo uses."}));+        }+        ("POST", "/settings") => {+            let mut s = read_settings();+            if let Some(o) = body.as_object() {+                for (k, v) in o { s[k.clone()] = v.clone(); }+            }+            write_settings(&s);+            app.log(format!("settings {}", s));+            respond_json(&mut stream, &serde_json::json!({"ok": true, "settings": s}));+        }+        ("POST", "/api/auto-green") => {+            // Fired by the page on load when the toggle is on (and by the Setup button).+            let t = qs_target.clone().unwrap_or_else(|| app.tgt());+            if read_settings()["autoGreenOnLaunch"].as_bool().unwrap_or(true)+                || body["force"].as_bool().unwrap_or(false) {+                auto_green(app, &t);+                respond_json(&mut stream, &serde_json::json!({"ok": true, "started": true,+                    "_hint": "Auto-green is running in the BACKGROUND; watch /events for per-step toasts and state pushes."}));+            } else {+                respond_json(&mut stream, &serde_json::json!({"ok": true, "started": false,+                    "_hint": "autoGreenOnLaunch is off - the Dashboard is showing status only (the user's choice, sticky)."}));+            }+        }+        ("POST", "/api/surface") => {+            let key = body["surface"].as_str().unwrap_or("").to_string();+            // Honor the user's selected box (pill) - body target, then ?target=, then default.+            let t = body["target"].as_str().map(|s| s.to_string())+                .or_else(|| qs_target.clone())+                .unwrap_or_else(|| app.tgt());+            let page = body["page"].as_str().map(|s| s.to_string())+                .unwrap_or_else(|| read_settings()["demoPart"].as_str().unwrap_or("aravk/in-s63btg").to_string());+            app.log(format!("surface {} ({})", key, page));+            let out = show_surface(app, &t, &key, &page);+            respond_json(&mut stream, &out);+        }         ("POST", "/ui/toast") => {             // The day-one narration hook: the AI (or CLI) raises a toast OVER the app.             let msg = body["message"].as_str().unwrap_or("").to_string();@@ -407,6 +696,9 @@ fn serve(port: u16, target: String, ai_thread: String, print_url: bool) {         hub: Hub { clients: Mutex::new(Vec::new()) },         console: Mutex::new(Vec::new()),         last_activity: Mutex::new(Instant::now()),+        auto_green_running: Mutex::new(false),+        auto_green_last: Mutex::new(None),+        staged: Mutex::new(std::collections::HashMap::new()),     });     if print_url {         // Hydrogen dock await:url contract - the proxied URL a webview can reach.
dashboard/ui/index.html+144−16
@@ -83,6 +83,25 @@ dialog::backdrop{background:rgba(0,0,0,.55)} .tdot{width:8px;height:8px;border-radius:999px;background:var(--text-2)} .tdot.on{background:var(--green)} .seatnote{font-size:12.5px;color:var(--text-2);margin:-6px 0 12px}+.sgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:12px}+.scard{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:12px;transition:border-color .15s ease}+.scard:hover{border-color:#4b5563}+.scard .btn{width:100%;justify-content:center}+.sstate{font-size:12.5px;color:var(--text-2);margin-top:8px}+.sstate.ok{color:var(--green)}.sstate.err{color:var(--red)}+.sshot{margin-top:8px;width:100%;border-radius:8px;border:1px solid var(--border);display:none;cursor:zoom-in}+.sshot.show{display:block}+dialog.settings{max-width:560px;max-height:70vh;overflow:auto;padding:18px}+.settings-card{background:var(--elevated);border:1px solid var(--border);border-left:3px solid color-mix(in srgb, var(--accent) 45%, transparent);border-radius:10px;padding:.9rem 1.1rem 1rem;margin-bottom:1rem}+.card-title{font-size:13px;font-weight:600;color:var(--text);display:flex;align-items:center;gap:10px;font-family:'Familjen Grotesk',sans-serif}+.card-status{font-size:11px;color:var(--text-2);font-weight:400}+.card-status.ok{color:var(--green)}+.card-desc{font-size:12px;line-height:1.55;color:var(--text-2);margin-top:.5rem}+.card-desc code{font-size:11px;background:rgba(110,118,129,.2);padding:1px 4px;border-radius:4px}+.divider{height:1px;background:var(--border);margin:.55rem 0}+.ctlrow{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:.5rem 0}+.ctlrow .lbl{font-size:12.5px;color:var(--text)}+.setfoot{font-size:11.5px;color:var(--text-2);margin-top:.2rem} </style> </head> <body>@@ -131,22 +150,53 @@ dialog::backdrop{background:rgba(0,0,0,.55)}     <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>Surfaces: every content type, one real part</h2>+  <div class="seatnote" id="surfnote">Loading the demo part...</div>+  <div class="sgrid" id="surfaces"></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>+<dialog id="dlgSettings" class="settings">+  <h2 style="margin:0 0 14px">Fusion Dashboard Settings</h2>++  <div class="settings-card">+    <div class="card-title">Opening the Dashboard <span class="card-status" id="stAutoGreen">reading...</span></div>+    <div class="card-desc">Opening this page can mean two different things, and this switch decides which. With it ON, the Dashboard treats your visit as an intent to work: it launches Fusion on the selected box if it is closed, drives the Autodesk sign-in as far as code can (stopping at your password and telling you), turns on Autodesk's <code>MCP</code> server, and signs in to APS cloud search when a client id is already configured. Every step runs in the BACKGROUND and narrates itself as a toast; nothing steals your focus. With it OFF, the Dashboard only reports status and acts on nothing until you press a button.</div>+    <div class="divider"></div>+    <div class="ctlrow"><span class="lbl">Get everything ready when I open the Dashboard</span>+      <div class="toggle" id="tglAutoGreen" role="switch" aria-checked="true"></div></div>+  </div>++  <div class="settings-card">+    <div class="card-title">Fusion upkeep <span class="card-status" id="stUpkeep"></span></div>+    <div class="card-desc">Two chores the bridge does for you on the machine itself. <b>Auto-update</b> clicks Fusion's own "Update Now" nag in the background whenever Autodesk shows it, so you always run the latest build and never see the prompt. <b>Seat take-over</b> answers Autodesk's "Active Sessions Exceeded" dialog when your one seat is busy on another machine: it clicks through both dialogs in the background, taking the seat here while the other machine's Fusion suspends RESUMABLY, and toasts you once so you know. Both are checked by a background sweep every 20 seconds, and both choices are sticky per user.</div>+    <div class="divider"></div>+    <div class="ctlrow"><span class="lbl">Silently install Fusion updates</span>+      <div class="toggle" id="tglAutoUpdate" role="switch" aria-checked="true"></div></div>+    <div class="divider"></div>+    <div class="ctlrow"><span class="lbl">Take over the Autodesk seat when it is busy elsewhere</span>+      <div class="toggle" id="tglSeatTakeover" role="switch" aria-checked="true"></div></div>+  </div>++  <div class="settings-card">+    <div class="card-title">Taskbar identity <span class="card-status" id="stBadge"></span></div>+    <div class="card-desc">While Adom is driving Fusion, its Windows taskbar button wears a small Adom mark in the corner, so a glance at the taskbar tells you why Fusion is busy. The badge is painted cross-process through Adom Bridge's <code>desktop_taskbar</code> verb, appears on the first driving verb, and sheds itself after five minutes with no driving activity (status polls do not count, or it would never clear). Turning this off removes the badge immediately and permanently.</div>+    <div class="divider"></div>+    <div class="ctlrow"><span class="lbl">Show the "Adom is driving" badge on Fusion's taskbar button</span>+      <div class="toggle" id="tglBadge" role="switch" aria-checked="true"></div></div>   </div>-  <div class="setrow">-    <div><div class="led-label">Auto take-over of the Fusion seat</div>-    <div class="led-sub">When Autodesk pops "Active Sessions Exceeded", click through BOTH dialogs in the background: this machine takes the seat, the other machine's Fusion suspends resumably, you get one toast. Sticky.</div></div>-    <div class="toggle" id="tglSeatTakeover" role="switch" aria-checked="true"></div>++  <div class="settings-card">+    <div class="card-title">Surfaces demo <span class="card-status" id="stPart"></span></div>+    <div class="card-desc">The Surfaces section walks one real adom basic part through every content type. It defaults to a DIFFERENT part than the Adom Bridge web-control demo uses, so clicking through proves the flow generalizes to a new component rather than re-proving one file. Change the wiki page below to demo any basic part that publishes a <code>.lbr</code> (and ideally a <code>.step</code> for the real 3D body).</div>+    <div class="divider"></div>+    <div class="ctlrow"><span class="lbl">Wiki part page</span>+      <input id="inpPart" style="flex:0 1 260px;background:var(--surface);border:1px solid var(--border);border-radius:8px;color:var(--text);padding:7px 10px;font:12.5px 'JetBrains Mono',monospace"></div>   </div>++  <div class="setfoot">Settings are stored on this machine: the Dashboard's own choices in <code>~/.adom/fusion-dashboard/settings.json</code>, the Fusion upkeep and badge choices as sticky bridge preferences on the target box.</div>   <div class="btnrow" style="justify-content:flex-end"><button class="btn" id="btnCloseSettings">Close</button></div> </dialog> @@ -232,10 +282,26 @@ $('#btnImportPart').onclick=async()=>{ };  // Settings-$('#btnSettings').onclick=()=>{const s=(lastStatus&&lastStatus.settings)||{};-  const t=$('#tglAutoUpdate');const on=s.autoUpdateFusion!==false;t.classList.toggle('is-on',on);t.setAttribute('aria-checked',String(on));-  const t2=$('#tglSeatTakeover');const on2=s.autoTakeOverSeat!==false;t2.classList.toggle('is-on',on2);t2.setAttribute('aria-checked',String(on2));-  $('#dlgSettings').showModal();};+$('#btnSettings').onclick=async()=>{+  const s=(lastStatus&&lastStatus.settings)||{};+  await loadDashSettings();+  const set=(sel,on)=>{const t=$(sel);t.classList.toggle('is-on',on);t.setAttribute('aria-checked',String(on));};+  const ag=dashSettings.autoGreenOnLaunch!==false;+  set('#tglAutoGreen',ag);+  $('#stAutoGreen').textContent=ag?'active - drives every facet to green on open':'status only';+  $('#stAutoGreen').className='card-status'+(ag?' ok':'');+  const au=s.autoUpdateFusion!==false, sk=s.autoTakeOverSeat!==false;+  set('#tglAutoUpdate',au);set('#tglSeatTakeover',sk);+  $('#stUpkeep').textContent=(au?'updates on':'updates off')+' | '+(sk?'seat take-over on':'seat take-over off');+  $('#stUpkeep').className='card-status'+((au&&sk)?' ok':'');+  const bd=s.showDrivingBadge!==false;+  set('#tglBadge',bd);+  $('#stBadge').textContent=bd?'badge on while driving':'badge off';+  $('#stBadge').className='card-status'+(bd?' ok':'');+  $('#inpPart').value=dashSettings.demoPart||'aravk/in-s63btg';+  $('#stPart').textContent=(dashSettings.demoPart||'').split('/').pop().toUpperCase();+  $('#dlgSettings').showModal();+}; $('#btnCloseSettings').onclick=()=>$('#dlgSettings').close(); function wireToggle(sel,actionName,label){$(sel).onclick=async(e)=>{   const t=e.currentTarget;const now=!t.classList.contains('is-on');@@ -245,6 +311,15 @@ function wireToggle(sel,actionName,label){$(sel).onclick=async(e)=>{ };} wireToggle('#tglAutoUpdate','set_auto_update','auto-update'); wireToggle('#tglSeatTakeover','set_seat_takeover','seat take-over');+wireToggle('#tglBadge','set_driving_badge','driving badge');+$('#inpPart').onchange=async(e)=>{const v=e.target.value.trim();if(!v)return;await saveDashSettings({demoPart:v});renderSurfaces();toast('Surfaces demo part set to '+v,'ok');};+$('#tglAutoGreen').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 saveDashSettings({autoGreenOnLaunch:now});+  toast(now?'I will get everything ready whenever you open the Dashboard':'Status only - I will not act on open','ok');+  if(now)kickAutoGreen();+};  // Setup: walk the not-green facets in order, one action per click. const SETUP_ORDER=[@@ -260,7 +335,7 @@ function renderSetup(){   $('#setupBody').innerHTML=p.length?('Remaining steps:<br>'+p.map((s,i)=>`${i+1}. ${s.label}`).join('<br>')):(dormant?'Everything is configured - Fusion just is not running on '+(curTarget||'this box')+'. Start Fusion (or start it on another box; the Autodesk seat follows automatically).':'Everything is configured. You are ready to go.');   $('#btnSetupNext').style.display=p.length?'':'none'; }-$('#btnSetup').onclick=async()=>{await refresh();renderSetup();$('#dlgSetup').showModal();};+$('#btnSetup').onclick=async()=>{await refresh();renderSetup();$('#dlgSetup').showModal();kickAutoGreen(true);}; $('#btnCloseSetup').onclick=()=>$('#dlgSetup').close(); $('#btnSetupNext').onclick=async()=>{   const p=setupPending();if(!p.length)return;@@ -269,6 +344,58 @@ $('#btnSetupNext').onclick=async()=>{   setTimeout(async()=>{await refresh();renderSetup();},5000); }; +// ── Dashboard-own settings (container-side) + AUTO-GREEN on open ──+let dashSettings={autoGreenOnLaunch:true,demoPart:'aravk/in-s63btg'};+async function loadDashSettings(){+  try{const d=await api('settings');dashSettings=d.settings||dashSettings;}catch(_){}+  return dashSettings;+}+async function saveDashSettings(patch){+  dashSettings=Object.assign({},dashSettings,patch);+  try{await api('settings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(patch)});}catch(_){}+}+async function kickAutoGreen(force){+  try{+    const r=await api('api/auto-green'+(curTarget?('?target='+encodeURIComponent(curTarget)):''),+      {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({force:!!force})});+    if(r.started===false&&force!==true){console.log('auto-green off by setting');}+  }catch(_){}+}++// ── Surfaces demo: eight content types on a DIFFERENT part than web control uses ──+const SURFACES=[['symbol','Symbol'],['footprint','Footprint'],['package3d','3D package'],['library','Library'],+                ['schematic','Schematic'],['board2d','2D board'],['board3d','3D board'],['project','Project']];+function renderSurfaces(){+  const part=(dashSettings.demoPart||'aravk/in-s63btg');+  const name=part.split('/').pop().toUpperCase();+  $('#surfnote').textContent='The same part on every surface: '+name+' from wiki.adom.inc/'+part+    +'. The first click stages it (clone the page, send the .lbr + .step, bind the real 3D, generate a solo project); after that each button just switches surfaces. All background - rendered here with fusion_state.';+  const g=$('#surfaces');g.innerHTML='';+  for(const [key,label] of SURFACES){+    const c=document.createElement('div');c.className='scard';+    c.innerHTML='<button class="btn" data-surface="'+key+'" data-tip="Open the '+label.toLowerCase()++      ' surface for '+name+' in the background, then render it here">'+label+'</button>'++      '<div class="sstate" id="ss-'+key+'">not run yet</div>'++      '<img class="sshot" id="si-'+key+'" alt="'+label+'">';+    g.appendChild(c);+  }+  g.querySelectorAll('button[data-surface]').forEach(b=>{+    b.onclick=async()=>{+      const key=b.getAttribute('data-surface');+      const st=$('#ss-'+key);st.className='sstate';st.textContent='opening (staging on first run - up to a couple of minutes)...';+      try{+        const r=await api('api/surface',{method:'POST',headers:{'Content-Type':'application/json'},+          body:JSON.stringify({surface:key,target:curTarget||undefined})});+        if(r.ok){st.className='sstate ok';st.textContent='shown on '+(r.deviceset||'the part');+          if(r.image){const im=$('#si-'+key);im.src='data:image/png;base64,'+r.image;im.classList.add('show');+            im.onclick=()=>window.open(im.src,'_blank');}+          toast(r.label+' surface opened','ok');+        }else{st.className='sstate err';st.textContent=(r.error||'failed');toast((r.label||key)+': '+(r.error||'failed'),'err');}+      }catch(e){st.className='sstate err';st.textContent=String(e);}+    };+  });+}+ // 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',@@ -336,7 +463,8 @@ brand.addEventListener('mouseleave',()=>{clearTimeout(aiShow);aiHide=setTimeout( ai.addEventListener('mouseenter',()=>clearTimeout(aiHide)); ai.addEventListener('mouseleave',()=>{aiHide=setTimeout(()=>ai.classList.remove('show'),220);}); -loadTargets().then(refresh);setInterval(refresh,15000);setInterval(loadTargets,60000);+loadDashSettings().then(()=>{renderSurfaces();return loadTargets();}).then(()=>{refresh();kickAutoGreen();});+setInterval(refresh,15000);setInterval(loadTargets,60000); </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_set_driving_badge", "Read/toggle the 'Adom is driving Fusion' TASKBAR OVERLAY BADGE (STICKY per user, default ON). While the bridge drives Fusion, its Windows taskbar button wears a small Adom mark so the user knows at a glance who is in control; it sheds automatically after 5 minutes of driving-verb idle. No args = read. Turning it off clears any badge immediately.", {"enabled": "optional bool (omit to read)"}, {"success": "bool", "showDrivingBadge": "bool"}, 15, "fusion_state", False, {}),     ("fusion_set_seat_takeover", "Read/toggle the mid-session Autodesk seat-takeover sweep (STICKY per user, default ON). When Autodesk pops 'Active Sessions Exceeded' mid-session, the bridge clicks Continue through BOTH dialogs in the background: this machine takes the seat, the other machine's Fusion suspends RESUMABLY, the user gets one toast. No args = read current value. Gates only the passive sweep - a verb-driven fusion_start still grabs the seat (the user asked for Fusion, so the seat is implied).", {"enabled": "optional bool (omit to read)"}, {"success": "bool", "autoTakeOverSeat": "bool"}, 15, "fusion_readiness", False, {}),     ("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"}),
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.61"+DASH_VERSION="1.9.62" 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.61",+  "version": "1.9.62",   "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−8
@@ -1,16 +1,20 @@ {   "slug": "fusion-bridge",-  "version": "1.9.61",+  "version": "1.9.62",   "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": {-    "adom/adom-desktop": "*"+    "adom/adom-desktop": "^1.9.0"   },   "scripts": {     "install": "./install.sh",     "uninstall": "./uninstall.sh"   },   "tags": [+    "ab",+    "bridge",+    "fusion",+    "desktop-app",     "fusion360",     "autodesk",     "cad",@@ -70,7 +74,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.",@@ -99,9 +106,5 @@       "label": "Part volume",       "prompt": "How much material does each printed part in this assembly use?"     }-  ],-  "author": {-    "name": "John Lauer",-    "email": "[email protected]"-  }+  ] }\ No newline at end of file
server.py+57−4
@@ -5158,19 +5158,59 @@ def _note_driving_activity(command: str):     _LAST_DRIVE_AT[0] = _t.time()      def _ensure():+        # RETRY, do not fire-and-forget: `start` is a driving verb dispatched BEFORE Fusion's+        # window exists, so a single attempt always found no hwnd and the badge never landed+        # (John caught exactly this after auto-green launched Fusion, 2026-08-18).         try:             from handlers.taskbar_overlay import set_driving_badge             from fusion_detect import _fusion_main_hwnd_quick-            if _is_fusion_running():-                hw = _fusion_main_hwnd_quick()-                if hw:-                    set_driving_badge(hw)+            import time as _tt+            if not _driving_badge_enabled():+                return+            for _ in range(30):          # ~90s, covers a cold Fusion launch+                if _is_fusion_running():+                    hw = _fusion_main_hwnd_quick()+                    if hw and set_driving_badge(hw):+                        return+                _tt.sleep(3)         except Exception:             pass     threading.Thread(target=_ensure, daemon=True).start()   +def _driving_badge_enabled() -> bool:+    """Default TRUE. The taskbar overlay is identity, not noise - but a user who dislikes it+    can switch it off permanently (John, 2026-08-18)."""+    return bool(_get_bridge_pref("showDrivingBadge", True))+++def _handle_set_driving_badge(fusion_info: dict, args: dict) -> dict:+    """Read/toggle the 'Adom is driving Fusion' taskbar overlay badge. STICKY."""+    if "enabled" not in (args or {}):+        cur = _driving_badge_enabled()+        return {"success": True, "showDrivingBadge": cur,+                "_hint": ("The driving badge is currently %s. ON BY DEFAULT: while Adom drives "+                          "Fusion, its taskbar button wears a small Adom mark so the user knows "+                          "at a glance who is in control; it sheds after 5 minutes idle. Opt out: "+                          "fusion_set_driving_badge {\"enabled\": false} - STICKY."+                          % ("ON" if cur else "OFF")),+                "statusVerb": "fusion_state"}+    enabled = bool(args.get("enabled"))+    _set_bridge_pref("showDrivingBadge", enabled)+    if not enabled:+        try:+            from handlers.taskbar_overlay import clear_driving_badge+            clear_driving_badge()+        except Exception:+            pass+    return {"success": True, "showDrivingBadge": enabled,+            "narrate": ("I'll badge Fusion's taskbar button while I'm driving it."+                        if enabled else "I'll stop badging Fusion's taskbar button."),+            "_hint": "Sticky (~/.adom/fusion-bridge/prefs.json). Cleared immediately when turned off.",+            "statusVerb": "fusion_state"}++ def _seat_takeover_enabled() -> bool:     """Default TRUE (John: "always just take over the session"). Sticky opt-out for users     who'd rather resolve seat conflicts themselves - mirrors autoUpdateFusion."""@@ -5208,6 +5248,7 @@ def _handle_set_seat_takeover(fusion_info: dict, args: dict) -> dict: # Registered HERE, next to the def - naming it in the COMMAND_HANDLERS literal (built ~3000 # lines earlier) is the import-time NameError that crash-looped the bridge on 2026-07-22. COMMAND_HANDLERS["set_seat_takeover"] = _handle_set_seat_takeover+COMMAND_HANDLERS["set_driving_badge"] = _handle_set_driving_badge   def _seat_sweep_tick():@@ -5231,6 +5272,18 @@ def _seat_sweep_tick():     except Exception:         pass +    # Self-heal the badge: if driving is RECENT and Fusion is up but the badge is missing+    # (launch race, respawn, a manual clear), put it back on the next tick.+    try:+        from handlers.taskbar_overlay import badge_active as _ba, set_driving_badge as _sb+        from fusion_detect import _fusion_main_hwnd_quick as _hw+        if (_driving_badge_enabled() and not _ba() and _LAST_DRIVE_AT[0]+                and (_t2.time() - _LAST_DRIVE_AT[0]) <= _BADGE_IDLE_SECONDS and _is_fusion_running()):+            h = _hw()+            if h:+                _sb(h)+    except Exception:+        pass     # Shed the driving badge after idle (John: "so the user knows we are DONE controlling").     try:         from handlers.taskbar_overlay import badge_active, clear_driving_badge