← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.9.193\ No newline at end of file+1.9.194\ No newline at end of file
adom-bridge-fusion-manifest.json+4−4
@@ -1,10 +1,10 @@ {   "manifest_version": 1,   "name": "fusion360",-  "version": "1.9.193",-  "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.193/adom-bridge-fusion-v1.9.193.zip",-  "sha256": "ea433bcfe3d60332ecdd08e9ad4f6e812e8e0b9294d460cd35c11932163b773f",-  "size": 495930,+  "version": "1.9.194",+  "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.194/adom-bridge-fusion-v1.9.194.zip",+  "sha256": "db588fbf7d107871416640351a0b0fb9f8afee1a22d83cafacfeaa855a04d56c",+  "size": 495990,   "verbPrefixes": [     "fusion_"   ],
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "fusion360",   "displayName": "Autodesk Fusion 360",-  "version": "1.9.193",+  "version": "1.9.194",   "description": "Drive Autodesk Fusion 360 from the cloud: launch Fusion, electronics board layout, design rules, exports (STEP/IGES/STL/3MF/USDZ/OBJ/DXF/DWG/Gerbers/BOM/CPL), fast APS server-indexed cloud search plus browse/recent/file-info/versions, cloud file download/upload and folder creation, and in-app parametric modeling (fusion_run_modeling_script). Never-charge: APS calls are capped to the free tier.",   "homepage": "https://wiki.adom.inc/adom/fusion-bridge",   "author": "Adom Inc.",
dashboard/Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "fusion-dashboard"-version = "1.9.193"+version = "1.9.194" dependencies = [  "serde_json", ]
dashboard/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.9.193"+version = "1.9.194" edition = "2021"  [dependencies]
dashboard/src/main.rs+79−2
@@ -26,6 +26,9 @@ use std::sync::mpsc::{channel, RecvTimeoutError, Sender}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; +mod demo_audio;+use demo_audio::demo_audio;+ const UI_HTML: &str = include_str!("../ui/index.html"); const VERSION: &str = env!("CARGO_PKG_VERSION"); const IDLE_REAP_HOURS: u64 = 24;@@ -145,7 +148,7 @@ fn gather_status_ex(ai_thread: &str, target: &str, launching: bool) -> serde_jso         use std::time::{SystemTime, UNIX_EPOCH};         parse_epoch_lenient(last_ready).map(|t| {             let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) as i64;-            now - t > 2 * 3600+            now - t > 15 * 60      // status-leds contract: busy past 15 min reads "stuck?"         }).unwrap_or(true)     };     let mcp_last = mcp["lastEnabledAt"].as_str().unwrap_or("");@@ -205,7 +208,7 @@ fn gather_status_ex(ai_thread: &str, target: &str, launching: bool) -> serde_jso                          // the cutoff say what it actually is: the add-in has not answered since                          // that timestamp and a Fusion restart reloads it (which also picks up a                          // newer installed add-in version).-                         else if running && !last_ready.is_empty() && last_ready_stale {format!("Not serving since {} - too long ago to be a long verb. The add-in likely needs Fusion restarted to reload (fusion_stop + fusion_start; that also loads any newer installed add-in).", last_ready)}+                         else if running && !last_ready.is_empty() && last_ready_stale {format!("Stuck? Not serving since {} - a long verb never runs this long. Bounce it: fusion_stop then fusion_start (that also loads any newer installed add-in).", last_ready)}                          else if running && !last_ready.is_empty() {format!("Busy or still starting - Fusion is up and the add-in was serving at {}. A long verb (a tour, a 3D build) occupies its main thread, so readiness answers not-ready until it finishes.", last_ready)}                          else if !running && !last_ready.is_empty() {format!("Dormant - resumes when Fusion starts (last verified {}).", last_ready)}                          else {"Not serving. If needsSignin, run fusion_signin - it self-drives to the password step.".to_string()}},@@ -1305,6 +1308,17 @@ fn handle_conn(app: &Arc<App>, mut stream: TcpStream) {                 "delivered": app.hub.client_count(),                 "_hint": "delivered = live SSE viewers that showed it. 0 means nobody has the page open - open it first."}));         }+        ("GET", p2) if p2.starts_with("/demo/audio/") => {+            // Pre-created tour narration (dash-demo contract): one mp3 per step, rendered+            // by adom-tts at RELEASE time and embedded in this binary - never synthesized+            // at demo time. /api/tts below stays as the fallback for ad-hoc captions.+            let step = p2.trim_start_matches("/demo/audio/").trim_end_matches(".mp3");+            match demo_audio(step) {+                Some(bytes) => respond(&mut stream, "200 OK", "audio/mpeg", bytes),+                None => respond_json(&mut stream, &serde_json::json!({"ok": false,+                    "why": format!("no pre-built narration for step {:?}", step)})),+            }+        }         ("GET", "/api/tts") => {             // Tour audio narration (John, 2026-08-24: "add a checkbox to play audio and             // use adom-tts to create the audio clips that correspond to the ab captions").@@ -1334,6 +1348,27 @@ fn handle_conn(app: &Arc<App>, mut stream: TcpStream) {                 }             }         }+        ("POST", "/api/say-send") => {+            // Send to AI (say-strip contract, John's 2026-08-24 ruling): NEW threads only.+            // POST /ai-threads/new (staged name + dock tag) then run-prompt submit:true,+            // through the workspace control API named in ~/.adom/hydrogen-control-url.+            // Resume/existing threads stay parked; this never targets a census tid.+            // ⛔ EMERGENCY PARK (project-c3, 2026-08-24): the fleet Send-to-AI success+            // path CRASHED the ah dev shell during kicad's cold-verify (the historical+            // run-prompt kill). While parked this endpoint refuses BEFORE any control-API+            // call; the page falls back to copy + error toast; the two-button UI stays so+            // un-park is this one flag after the ah build thread's certification.+            const SAY_SEND_PARKED: bool = true;+            let phrase = body["phrase"].as_str().unwrap_or("").to_string();+            if SAY_SEND_PARKED {+                respond_json(&mut stream, &serde_json::json!({"ok": false, "parked": true,+                    "why": "Send to AI is parked fleet-wide (a run-prompt crash on ah is being certified). Copied instead - paste it into any AI thread."}));+            } else if phrase.is_empty() {+                respond_json(&mut stream, &serde_json::json!({"ok": false, "why": "phrase required"}));+            } else {+                respond_json(&mut stream, &say_send(&phrase));+            }+        }         ("POST", "/api/say-try") => {             // "Say it" strip Try-it (dock-bar `say` contract, John 2026-08-23: teach users to             // ASK for the app by its noun). Stages the phrase into this app's own AI thread@@ -1467,6 +1502,48 @@ fn parse_epoch_lenient(s: &str) -> Option<i64> {     Some(days * 86400 + h * 3600 + mi * 60 + sec) } +fn control_api_base() -> Option<String> {+    let p = std::env::var("HOME").ok().map(|h| format!("{}/.adom/hydrogen-control-url", h))?;+    std::fs::read_to_string(p).ok().map(|s| s.trim().trim_end_matches('/').to_string())+        .filter(|s| !s.is_empty())+}++fn say_send(phrase: &str) -> serde_json::Value {+    let base = match control_api_base() {+        Some(b) => b,+        None => return serde_json::json!({"ok": false,+            "why": "workspace control API not found (~/.adom/hydrogen-control-url missing)"}),+    };+    let post = |path: &str, payload: &serde_json::Value| -> Option<serde_json::Value> {+        let out = Command::new("curl")+            .args(["-s", "-m", "6", "-X", "POST", "-H", "Content-Type: application/json",+                   "-d", &payload.to_string(), &format!("{}{}", base, path)])+            .output().ok()?;+        if out.stdout.is_empty() { return None; }+        serde_json::from_slice(&out.stdout).ok()+    };+    let made = post("/ai-threads/new", &serde_json::json!({+        // Fleet ruling (adom-ui-design 1.9.3): the tag is `dock:` + the WIKI PAGE SLUG+        // (unique, identifies the package/issues/manifest), never the dock card id.+        "name": "Fusion - say it", "tags": ["dock:fusion-bridge"]}));+    let sid = made.as_ref().and_then(|m| m["sid"].as_str()+        .or(m["data"]["sid"].as_str())).map(|s| s.to_string());+    let sid = match sid {+        Some(s) => s,+        None => return serde_json::json!({"ok": false,+            "why": format!("ai-threads/new gave no sid ({})",+                made.map(|m| m.to_string().chars().take(120).collect::<String>())+                    .unwrap_or_else(|| "unreachable".into()))}),+    };+    match post("/ai-threads/run-prompt", &serde_json::json!({+        "sid": sid, "prompt": phrase, "submit": true})) {+        Some(r) if r["ok"].as_bool() != Some(false) =>+            serde_json::json!({"ok": true, "sid": sid}),+        _ => serde_json::json!({"ok": false, "sid": sid,+            "why": "thread created but run-prompt failed"}),+    }+}+ fn stage_say_phrase(phrase: &str) -> serde_json::Value {     const TAG: &str = "app:adom/fusion-bridge";     // 1. CENSUS: open threads. Shape: {"ok":true,"groups":[{"threads":[{"tid","sid"|null,"tags":[...]}]}]}
dashboard/ui/index.html+225−105
@@ -10,6 +10,11 @@   --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;+  /* dock-dashboard token stub aliases - the fleet recipes use these names */+  --surface2:#1c232c;--line:#262d36;--muted:#8b949e;--teal:#00b8b1;+  --led-green:#3fb950;--led-amber:#d29922;--led-red:#f85149;+  --sans:'Familjen Grotesk',system-ui,sans-serif;--body:'Satoshi',system-ui,sans-serif;+  --mono:'JetBrains Mono',ui-monospace,Consolas,monospace; } *{box-sizing:border-box;margin:0;padding:0} /* The house scrollbar (adom-ui-design > scrollbars): thin, transparent track, translucent@@ -32,32 +37,65 @@ header{height:44px;display:flex;align-items:center;gap:12px;padding:0 16px;backg .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}+/* dash-header: version lives in the brand hover popover, NEVER the bar */+.brand{position:relative}+.brand-pop{display:none;position:absolute;top:34px;left:0;z-index:9000;background:var(--overlay);border:1px solid var(--border);border-radius:10px;padding:10px 14px;min-width:230px;box-shadow:0 10px 30px rgba(0,0,0,.5)}+.brand:hover .brand-pop{display:block;animation:bpin .18s ease}+@keyframes bpin{from{opacity:0;transform:translateY(-3px)}to{opacity:1}}+.bp-row{display:flex;justify-content:space-between;gap:16px;font:12px var(--mono);color:var(--text-2);padding:2px 0}+.bp-row b{color:var(--text);font-weight:500}+.bp-row a{color:var(--accent);text-decoration:none}+.tchip{display:inline-flex;align-items:center;gap:7px;background:var(--elevated);border:1px solid var(--border);border-radius:999px;padding:3px 11px;font:12px var(--mono);color:var(--text-body)}+.tled{width:8px;height:8px;border-radius:50%;background:var(--text-2)}+.tled.on{background:var(--led-green)}+.tled.bad{background:var(--led-red)} .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{transition:background .15s ease,border-color .15s ease,transform .06s ease} .iconbtn:hover{background:var(--elevated);border-color:var(--border)} .iconbtn:active{transform:scale(.9);background:var(--overlay)} main{max-width:1080px;margin:0 auto;padding:20px 16px 60px}-/* Say-it strip: the FIRST thing on the page - teaches asking for the app by its noun */-.saystrip{background:var(--surface);border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:12px;padding:14px 16px;margin-bottom:22px}-.saytitle{font-family:'Familjen Grotesk',sans-serif;font-size:15px;font-weight:600;color:var(--accent)}-.saydesc{font-size:12.5px;color:var(--text-2);margin:4px 0 10px;line-height:1.5}-.saydesc b{color:var(--text)}-.saytry-hint{color:var(--accent)}-.saychips{display:flex;flex-wrap:wrap;gap:8px}-.saychip{display:inline-flex;align-items:center;gap:0;background:var(--elevated);border:1px solid var(--border);border-radius:999px;overflow:hidden;transition:border-color .15s ease}-.saychip:hover{border-color:var(--accent)}-.saychip .phrase{padding:6px 10px 6px 14px;font-size:13px;color:var(--text-body);cursor:pointer;background:none;border:none;font-family:'Satoshi',sans-serif;transition:color .15s ease}-.saychip .phrase:hover{color:var(--text)}-.saychip .phrase:active{transform:translateY(1px)}-.saychip .phrase::before{content:'\201C';color:var(--text-2)}-.saychip .phrase::after{content:'\201D';color:var(--text-2)}-.saychip .try{padding:6px 12px 6px 8px;font-size:11.5px;font-weight:600;color:var(--accent);cursor:pointer;background:none;border:none;border-left:1px solid var(--border);font-family:'Familjen Grotesk',sans-serif;transition:background .15s ease}-.saychip .try:hover{background:var(--overlay)}-.saychip .try:active{transform:translateY(1px)}-.saychip .phrase:focus-visible,.saychip .try:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}+/* say-strip contract (adom-ui-design 1.9): ONE strip fleet-wide */+.say{background:var(--surface);border:1px solid var(--line);border-left:3px solid var(--teal);border-radius:10px;padding:14px 16px;margin-bottom:18px;position:relative}+.say h2{font:600 15px var(--sans);margin:0;color:var(--text)}+.say-head p{color:var(--muted);font-size:13.5px;margin:4px 0 0}+.say-head p .noun{color:var(--teal)}+.say-x{position:absolute;top:8px;right:10px;background:none;border:none;color:var(--muted);font-size:18px;cursor:pointer;padding:2px 6px;border-radius:6px}+.say-x:hover{color:var(--text);background:var(--elevated)}+.say-chips{margin-top:6px}+.say-chip{display:inline-flex;align-items:center;gap:8px;background:var(--surface2);border:1px solid var(--line);border-radius:999px;padding:5px 6px 5px 12px;font-size:13.5px;margin:4px 6px 0 0;cursor:pointer;color:var(--text-body)}+.say-chip em{color:var(--teal);font-style:normal}+.say-chip .copy,.say-chip .send{font:500 11px var(--body);background:none;border:1px solid var(--line);border-radius:999px;padding:2px 9px;cursor:pointer}+.say-chip .copy{color:var(--muted)}+.say-chip .send{color:var(--teal);border-color:rgba(0,184,177,.45)}+.say-chip:hover .copy{color:var(--teal);border-color:var(--teal)}+.say-chip .send:hover{background:rgba(0,184,177,.12)}+/* dash-demo player toolbar: a remote control, not a feature */+#tourTransport{position:fixed;left:50%;bottom:16px;transform:translateX(-50%);z-index:9500;display:flex;align-items:center;gap:6px;background:var(--overlay);border:1px solid var(--line);border-radius:999px;padding:6px 12px;box-shadow:0 8px 24px rgba(0,0,0,.45)}+.tt{background:none;border:none;color:var(--text);font-size:15px;cursor:pointer;padding:4px 8px;border-radius:8px;transition:background .15s ease,transform .06s ease}+.tt:hover{background:var(--elevated)}+.tt:active{transform:scale(.92)}+.tt.muted{color:var(--muted)}+.tt-exit{color:var(--red);font-size:12.5px}+.tt-sep{width:1px;height:16px;background:var(--line)}+.tt-step{font:12px var(--mono);color:var(--muted);padding:0 2px}+.say-folded{font-size:13px;color:var(--muted);cursor:pointer}+.say-folded:hover{color:var(--teal)} 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}+/* status-leds contract: ONE compact pill row, prose on tap, never by default */+.leds{display:flex;flex-wrap:wrap;gap:8px}+.led-pill{display:inline-flex;align-items:center;gap:7px;background:var(--surface);border:1px solid var(--line);border-radius:999px;padding:4px 12px;font-size:12.5px;cursor:pointer;color:var(--text-body);transition:border-color .15s ease}+.led-pill:hover{border-color:var(--accent)}+.led-pill.sel{border-color:var(--accent)}+.led-pill .dot{width:8px;height:8px;border-radius:50%;background:var(--text-2);flex:none}+.led-pill[data-state=ok] .dot{background:var(--led-green)}+.led-pill[data-state=busy] .dot{background:var(--led-amber)}+.led-pill[data-state=bad] .dot{background:var(--led-red)}+.led-pill[data-state=starting] .dot{background:var(--led-amber);animation:ledpulse 1.1s ease-in-out infinite}+.led-pill .st{color:var(--muted)}+.led-detail{margin-top:10px;background:var(--surface);border:1px solid var(--line);border-left:3px solid var(--accent);border-radius:10px;padding:12px 14px;font-size:13px;line-height:1.55;color:var(--text-body)}+.led-detail .who{font:12px var(--mono);color:var(--muted);margin-top:6px} .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)}@@ -216,14 +254,21 @@ dialog.settings{max-width:560px;overflow:hidden;padding:0} </head> <body> <header>-  <div class="brand">+  <div class="brand" id="brandZone">     <img src="favicon.png?v=fusion-real" alt="" width="16" height="16">-    Fusion Dashboard <span style="color:var(--text-2)">&middot; bridge launch page</span>+    Fusion Dashboard <span style="color:var(--text-2)">&middot; the Fusion 360 bridge</span>+    <div class="brand-pop" id="brandPop">+      <div class="bp-row"><span>dashboard</span><b id="bpDash">-</b></div>+      <div class="bp-row"><span>bridge</span><b id="bpBridge">-</b></div>+      <div class="bp-row"><span>add-in</span><b id="bpAddin">-</b></div>+      <div class="bp-row"><a href="https://wiki.adom.inc/adom/fusion-bridge" target="_blank" rel="noopener">wiki.adom.inc/adom/fusion-bridge</a></div>+    </div>   </div>   <div class="vsep"></div>-  <div class="subject" id="subject">connecting...</div>+  <div class="subject" id="subject"><span class="tchip" id="tchip"><span class="tled" id="tled"></span><span id="tname">connecting...</span></span></div>   <div class="meta" id="meta"></div>   <div class="actions">+    <button class="iconbtn" id="btnRecall" data-tip="Bring back the getting-started helpers for this session" aria-label="Show help"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="9"/><path d="M9.5 9a2.5 2.5 0 1 1 3.7 2.2c-.8.5-1.2 1-1.2 1.8v.5"/><circle cx="12" cy="17" r=".5" fill="currentColor"/></svg></button>     <button class="iconbtn" id="btnSettings" data-tip="Settings: toggle bridge defaults like silent Fusion auto-update (sticky per user)" 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" data-tip="Re-read every facet's status from the bridge right now (also auto-refreshes every 15s)" 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>@@ -232,40 +277,49 @@ dialog.settings{max-width:560px;overflow:hidden;padding:0}   <!-- SAY IT (dock-bar `say` contract, John 2026-08-23): teach the ASK before the status.        "for fusion I might say 'open the step in fusion'... those should all be taught first        and foremost in those dashboards, possibly even at the top of it." -->-  <div class="saystrip" id="saystrip">-    <div class="saytitle">Say it</div>-    <div class="saydesc">You never need this page's buttons: just tell your AI what you want, using the word <b>fusion</b>. Click a phrase or its <span class="saytry-hint">Try it</span> to copy the prompt, then paste it into any AI thread.</div>-    <div class="saychips" id="sayChips"></div>-  </div>+  <section class="say" id="saystrip">+    <div class="say-head"><h2>Say it</h2>+      <p>You never need this page's buttons: just tell your AI what you want, using the word <b class="noun">fusion</b>. Copy a phrase, or send it straight to a new AI thread.</p>+      <button class="say-x" id="sayDismiss" aria-label="Collapse" data-tip="Fold this - the ? in the header brings it back">&times;</button></div>+    <div class="say-chips" id="sayChips"></div>+    <div class="say-folded" id="sayFolded" style="display:none">Say it - <span id="sayCount">6</span> phrases</div>+  </section>    <h2>Status</h2>   <div id="targets"></div>   <div class="seatnote" id="seatnote" style="display:none">One Autodesk seat, many boxes: start Fusion wherever suits the moment - the seat follows automatically and the other box's Fusion suspends resumably.</div>-  <div class="grid" id="ledGrid"></div>+  <div class="leds" id="ledGrid"></div>+  <div class="led-detail" id="ledDetail" style="display:none"></div>   <div class="btnrow">     <button class="btn primary" id="btnSetup" data-tip="Check every facet and get whatever is not ready READY for you - nothing here is a manual chore">Setup</button>     <button class="btn" id="btnStart" data-tip="Launch Fusion on the selected box, in the background - your focus is never taken">Start Fusion</button>     <button class="btn" id="btnStop" data-tip="Close Fusion gracefully on the selected box (documents are closed cleanly first)">Close Fusion</button>   </div> +  <div id="demosFolded" style="display:none;font-size:13px;color:var(--muted);cursor:pointer;margin:14px 0" data-tip="Tap to show the demos once (the header ? restores everything)">Demos - guided tour and part import</div>+  <div id="demosRegion">   <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" style="align-items:center">-      <button class="btn primary" id="btnTour" data-tip="Walk every layer of the bridge - library, symbol, footprint, the 3D chip, Data Hub, APS search, MCP, a generated board in 2D and 3D, and real exports - with a live progress bar and on-screen captions">Run the tour</button>-      <label style="display:inline-flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:var(--text-body)" data-tip="Speak each step's caption aloud (audio is generated by adom-tts and plays right here in this page)">-        <div class="toggle" id="tglTourAudio" role="switch" aria-checked="false"></div>-        Narrate with audio</label>+      <button class="btn primary" id="btnTour" data-tip="Walk every layer of the bridge - library, symbol, footprint, the 3D chip, Data Hub, APS search, MCP, a generated board in 2D and 3D, and real exports - narrated, captioned, with a live progress bar">Play the demo</button>     </div>     <div class="barlabel" id="tourCaption" style="display:none;margin-top:8px;color:var(--text);font-size:13px;line-height:1.5"></div>     <div class="bar" id="tourBar"><div></div></div>     <div class="barlabel" id="tourLabel"></div>-    <div class="btnrow" id="tourTransport" style="display:none;margin-top:8px">-      <button class="btn" id="ttPrev" data-tip="Replay the previous view">&#9198;</button>-      <button class="btn" id="ttPause" data-tip="Pause at the next step boundary">&#9208;</button>-      <button class="btn" id="ttNext" data-tip="Skip the upcoming step">&#9197;</button>-      <button class="btn" id="ttExit" data-tip="End the tour and clean up its documents">&#9209; Exit</button>+    <!-- dash-demo contract: the player toolbar - fixed bottom-center, visible only while+         the demo runs. prev / play-pause / next | mute | step x/y | exit. -->+    <div id="tourTransport" style="display:none">+      <button class="tt" id="ttPrev" data-tip="Replay the previous view">&#9198;</button>+      <button class="tt" id="ttPause" data-tip="Pause at the next step boundary">&#9208;</button>+      <button class="tt" id="ttNext" data-tip="Skip the upcoming step">&#9197;</button>+      <span class="tt-sep"></span>+      <button class="tt" id="ttMute" data-tip="Mute or unmute the narration">&#128266;</button>+      <span class="tt-sep"></span>+      <span class="tt-step" id="ttStep">step -/-</span>+      <span class="tt-sep"></span>+      <button class="tt tt-exit" id="ttExit" data-tip="End the demo and clean up its documents">&#10005; exit</button>     </div>     <div class="sgrid" id="tourGallery" style="margin-top:12px"></div>   </div>@@ -282,6 +336,8 @@ dialog.settings{max-width:560px;overflow:hidden;padding:0}     <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> +  </div><!-- /demosRegion -->+   <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>@@ -442,12 +498,10 @@ if(REMOTE_ID){  // SAY IT strip (dock-bar `say` contract): keep this list in lockstep with dockbar.json's // `say` array - the dock interstitial and this strip must teach the same phrases.-// SHIP-NIGHT POSTURE, amended by John's ruling (2026-08-23, via the dock-bar thread):-// NO live AI-thread staging for now - /api/say-try stays PARKED server-side and the-// census staging code below stays dormant (SAY_TRY_ENABLED=false until the fleet-// all-clear). But the Try button is BACK with new behavior: it copies the phrase to the-// clipboard and toasts exactly what happened. Zero /ai-threads calls from page or server.-const SAY_TRY_ENABLED=false;+// AI-thread posture (2026-08-24 ruling, say-strip contract): NEW threads are trusted -+// Send to AI goes through POST /api/say-send (ai-threads/new + run-prompt submit:true).+// RESUMING/typing into EXISTING threads stays parked: /api/say-try still answers+// {parked:true} and the census staging path stays retired until the platform all-clear. const SAY_PHRASES=[   'open the step in fusion',   'export this board to fusion',@@ -474,29 +528,71 @@ function copyText(t){ } function copyTextLegacy(t){const ta=document.createElement('textarea');ta.value=t;ta.style.position='fixed';ta.style.opacity='0';document.body.appendChild(ta);ta.select();try{document.execCommand('copy');}catch(e){}ta.remove();} (function renderSayChips(){+  // say-strip contract: TWO buttons per chip - Copy (ghost, fleet toast) and Send to AI+  // (teal outline, NEW thread via POST /api/say-send; on any failure copy + error toast+  // so the user is never stranded). Clicking the phrase itself also copies.   const host=$('#sayChips');+  $('#sayCount').textContent=SAY_PHRASES.length;+  const noun=/(fusion)/i;   SAY_PHRASES.forEach(p=>{-    const chip=document.createElement('div');chip.className='saychip';-    const btn=document.createElement('button');btn.className='phrase';btn.textContent=p;-    btn.setAttribute('data-tip','Copy this phrase - paste it to any AI thread');-    btn.onclick=async()=>{await copyText(p);toast('Copied - paste it to your AI','ok');};-    const tr=document.createElement('button');tr.className='try';tr.textContent='Try it';-    if(!SAY_TRY_ENABLED){-      tr.setAttribute('data-tip','Copy this prompt to your clipboard');-      tr.onclick=async()=>{await copyText(p);toast('Copied to clipboard. Paste it into any AI thread.','ok');};-      chip.appendChild(btn);chip.appendChild(tr);host.appendChild(chip);return;-    }-    tr.setAttribute('data-tip',"Type this phrase into the app's AI thread for you - you press Enter to run it");-    tr.onclick=async()=>{-      try{-        const r=await api('api/say-try',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({phrase:p})});-        if(r.ok){toast('Typed into your AI thread - press Enter there to run it','ok');return;}-        await copyText(p);toast('Could not reach your AI thread ('+(r.why||'unreachable')+') - copied instead','err');-      }catch(e){await copyText(p);toast('Copied - paste it to your AI (thread not reachable)','err');}-    };-    chip.appendChild(btn);chip.appendChild(tr);host.appendChild(chip);+    const chip=document.createElement('span');chip.className='say-chip';chip.dataset.say=p;+    const quoted='\u201C'+p.replace(noun,'<em>$1</em>')+'\u201D';+    chip.innerHTML=quoted+' <button class="copy">Copy</button><button class="send">Send to AI</button>';+    const doCopy=async()=>{await copyText(p);toast('Copied to clipboard. Paste it into any AI thread.','ok');};+    chip.addEventListener('click',doCopy);+    chip.querySelector('.copy').addEventListener('click',e=>{e.stopPropagation();doCopy();});+    chip.querySelector('.send').addEventListener('click',async e=>{+      e.stopPropagation();+      const r=await fetch('api/say-send',{method:'POST',headers:{'Content-Type':'application/json'},+        body:JSON.stringify({phrase:p})}).then(x=>x.json()).catch(()=>null);+      if(r&&r.ok){toast('Sent to a new AI thread.','ok');gradSave({aiDriven:true});applyGraduation();}+      else{await copyText(p);toast((r&&r.why)||'Could not reach the AI panel; copied instead.','err');}+    });+    host.appendChild(chip);   });+  $('#sayDismiss').onclick=()=>{gradSave({dismissed:{...(gradState().dismissed||{}),say:true}});applyGraduation();};+  $('#sayFolded').onclick=()=>{$('#sayChips').style.display='';$('#sayFolded').style.display='none';+    document.querySelector('.say-head p').style.display='';}; })();++// ── newbie-graduation: Fresh / Graduated / Recallable, localStorage per-app origin ──+const GRAD_KEY='adom-dash-graduation';+function gradState(){try{return JSON.parse(localStorage.getItem(GRAD_KEY))??{}}catch(e){return{}}}+function gradSave(patch){try{localStorage.setItem(GRAD_KEY,JSON.stringify({...gradState(),...patch}))}catch(e){}}+let gradRecalled=false;   // the header ? restores Fresh for this session only+function applyGraduation(){+  const g=gradState();+  const launches=g.launches||0;+  const demosFold=!gradRecalled&&((g.dismissed&&g.dismissed.start)||launches>=3||g.aiDriven);+  const sayFold=!gradRecalled&&((g.dismissed&&g.dismissed.say)||launches>=5||g.aiDriven);+  // the say strip folds LAST: it is the graduation path itself+  const strip=$('#saystrip');+  if(sayFold){$('#sayChips').style.display='none';$('#sayFolded').style.display='';+    document.querySelector('.say-head p').style.display='none';}+  else{$('#sayChips').style.display='';$('#sayFolded').style.display='none';+    document.querySelector('.say-head p').style.display='';}+  const demos=$('#demosRegion');+  if(demos)demos.style.display=demosFold?'none':'';+  const demosFolded=$('#demosFolded');+  if(demosFolded)demosFolded.style.display=demosFold?'':'none';+}+let _gradCounted=false;+function gradCountLaunch(st){+  // a launch counts as successful when the status rollup is green within the first minute+  if(_gradCounted)return;+  const leds=(st&&st.leds)||{};+  const core=['fusionRunning','addinReady'];+  if(core.every(k=>leds[k]&&leds[k].on)&&(performance.now()<60000)){+    _gradCounted=true;gradSave({launches:(gradState().launches||0)+1});+  }+}+document.addEventListener('DOMContentLoaded',()=>{applyGraduation();+  const df=$('#demosFolded');if(df)df.onclick=()=>{$('#demosRegion').style.display='';df.style.display='none';};});+// the header ? recalls all scaffolding for this session (newbie-graduation: Recallable)+document.addEventListener('DOMContentLoaded',()=>{+  $('#btnRecall').onclick=()=>{gradRecalled=true;applyGraduation();toast('Helpers are back for this session - they fold again on reload','ok');};+});+ let targetsCache=[]; function statusUrl(){return 'api/status'+(curTarget?('?target='+encodeURIComponent(curTarget)):'');} async function loadTargets(){@@ -520,25 +616,44 @@ const LED_LABELS={fusionRunning:'Fusion running',addinReady:'Bridge add-in',sign                   aps:'APS cloud search',mcp:'Fusion MCP server',datahub:'Data Hub API',update:'Fusion up to date'}; function renderSkeleton(){   const g=$('#ledGrid');if(g.children.length)return;      // never wipe real data-  g.innerHTML=LED_ORDER.map(k=>`<div class="card"><div class="led-row"><span class="led"></span>`+-    `<span class="led-label">${LED_LABELS[k]}</span></div><div class="led-sub">checking...</div></div>`).join('');+  g.innerHTML=LED_ORDER.map(k=>`<button class="led-pill"><span class="dot"></span>${LED_LABELS[k]} <span class="st">checking</span></button>`).join(''); }+let ledSel=null; function renderLeds(st){+  // status-leds contract: pills only at rest, prose on tap, red carries its next step,+  // stale busy escalates to "stuck?" (server sets state:'stale' past 15 min).   const g=$('#ledGrid');g.innerHTML='';   const leds=st.leds||{};   const keys=LED_ORDER.filter(k=>leds[k]).concat(Object.keys(leds).filter(k=>!LED_ORDER.includes(k)));+  const stateOf=l=>l.state==='starting'?'starting':(l.state==='stale'?'busy':(l.pending||l.state==='dormant'||l.state==='busy')?'busy':(l.on?'ok':'bad'));+  const wordOf=l=>l.state==='stale'?'stuck?':(l.state==='starting'?'starting':(l.state==='dormant'?'dormant':(l.state==='busy'||l.pending?'busy':(l.on?'':'needs setup'))));   for(const key of keys){     const l=leds[key];-    const cls=l.state==='starting'?'starting':(l.pending?'warn':((l.state==='dormant'||l.state==='busy')?'warn':(l.on?'on':'off')));-    const el=document.createElement('div');el.className='card';-    const who=l.who?`<span class="led-who" title="Signed in as ${l.who}">${l.who}</span>`:'';-    el.innerHTML=`<div class="led-row"><span class="led ${cls}"></span><span class="led-label">${l.label}</span>${who}</div><div class="led-sub">${l._hint||''}</div>`;-    g.appendChild(el);+    const p=document.createElement('button');p.className='led-pill'+(ledSel===key?' sel':'');+    p.dataset.state=stateOf(l);+    const w=wordOf(l);+    p.innerHTML=`<span class="dot"></span>${l.label}${w?` <span class="st">${w}</span>`:''}`;+    p.onclick=()=>{+      const d=$('#ledDetail');+      if(ledSel===key){ledSel=null;d.style.display='none';p.classList.remove('sel');return;}+      ledSel=key;+      document.querySelectorAll('.led-pill').forEach(x=>x.classList.remove('sel'));p.classList.add('sel');+      d.style.display='';+      d.innerHTML=`<b>${l.label}</b>${l.version?` <span class="who">${l.version}</span>`:''}<div style="margin-top:6px">${l._hint||''}</div>${l.who?`<div class="who">signed in as ${l.who}</div>`:''}`;+    };+    g.appendChild(p);+    if(ledSel===key){const d=$('#ledDetail');d.style.display='';+      d.innerHTML=`<b>${l.label}</b>${l.version?` <span class="who">${l.version}</span>`:''}<div style="margin-top:6px">${l._hint||''}</div>${l.who?`<div class="who">signed in as ${l.who}</div>`:''}`;}   }-  $('#subject').textContent=st.target?('target: '+st.target):'no bridge target';+  // dash-header: target chip with connection LED; versions feed the popover, never the bar+  $('#tname').textContent=st.target?('target: '+st.target):'no bridge target';+  const tl=$('#tled');tl.className='tled '+(st.answered===false?'bad':(leds.fusionRunning?'on':''));   if(st.bridgeVersion)window.__lastBridgeVer=st.bridgeVersion;-  $('#meta').textContent='bridge '+(st.bridgeVersion||window.__lastBridgeVer||'?')+' | dashboard '+(st.dashboardVersion||'')-    +(st.stale?'  (busy - last verified state)':'');+  $('#bpDash').textContent=st.dashboardVersion||'-';+  $('#bpBridge').textContent=st.bridgeVersion||window.__lastBridgeVer||'-';+  $('#bpAddin').textContent=(leds.addinReady&&leds.addinReady.version)||'-';+  $('#meta').textContent=st.stale?'(busy - last verified state)':'';+  gradCountLaunch(st); } async function refresh(){try{lastStatus=await api(statusUrl());renderLeds(lastStatus);}catch(e){toast('status failed: '+e,'err');}} $('#btnRefresh').onclick=()=>{toast('refreshing status');refresh();};@@ -552,24 +667,20 @@ $('#btnTour').onclick=async()=>{   if(!id){toast('tour failed to start: '+(d.error||'unknown'),'err');return;}   $('#tourBar').style.display='block';   const gal=$('#tourGallery');gal.innerHTML='';-  // PRE-GENERATE the narration clips (John, 2026-08-24: "pre-create these mp3 audio files-  // so you don't have to generate them on the fly"): the tour-start payload carries every-  // step's caption, so the clips synthesize into the server cache while the first steps-  // run. Sequential to keep the tts service polite; fire-and-forget.-  const caps=((d.steps||[]).map(st=>st.caption).filter(Boolean));-  if(tourAudioOn()&&caps.length){(async()=>{for(const c of caps){try{await fetch('api/tts?text='+encodeURIComponent(c));}catch(e){}}})();}   const tt=$('#tourTransport');tt.style.display='flex';+  tourSetMuted(tourMuted());   // paint the mute button from the persisted state+  $('#ttMute').onclick=()=>tourSetMuted(!tourMuted());   let ttPaused=false;   const ctl=async(action)=>{try{     let r=await act('tour_control',{demoId:id,action}).catch(()=>null);     if(!r)r=await act('tour_control',{demoId:id,action});   // one silent retry - a slow relay hop must not eat a pause click     const x=r.data||r;     if(x.success===false){toast(x.error||'control refused','err');return;}-    if(action==='pause'){ttPaused=true;$('#ttPause').innerHTML='&#9654;';toast('pausing at the next step boundary');}-    if(action==='resume'){ttPaused=false;$('#ttPause').innerHTML='&#9208;';toast('resuming');}+    if(action==='pause'){ttPaused=true;$('#ttPause').innerHTML='&#9654;';if(tourAudioEl)tourAudioEl.pause();toast('pausing at the next step boundary');}+    if(action==='resume'){ttPaused=false;$('#ttPause').innerHTML='&#9208;';if(tourAudioEl&&!tourMuted())tourAudioEl.play().catch(()=>{});toast('resuming');}     if(action==='next')toast('skipping the upcoming step');     if(action==='prev')toast('replaying the previous view');-    if(action==='exit')toast('exiting the tour (documents will be cleaned up)');+    if(action==='exit'){if(tourAudioEl)tourAudioEl.pause();tourAudioQueue=[];toast('exiting the demo (documents will be cleaned up)');}   }catch(e){toast('control failed: '+e,'err');}};   $('#ttPause').onclick=()=>ctl(ttPaused?'resume':'pause');   $('#ttNext').onclick=()=>ctl('next');@@ -601,12 +712,17 @@ $('#btnTour').onclick=async()=>{     if(q.stepCaption&&q.stepCaption!==lastCaption){       lastCaption=q.stepCaption;       const cap=$('#tourCaption');cap.style.display='block';cap.textContent=q.stepCaption;-      tourSpeak(q.stepCaption);+      tourSpeak(q.stepCaption,q.step);     }+    const stepsAll=(d.steps||[]).map(x=>x.step);+    const si=stepsAll.indexOf(q.step);+    if(si>=0)$('#ttStep').textContent='step '+(si+1)+'/'+stepsAll.length;     (q.shots||[]).forEach(s=>{if(s.image)addShot(s.label||s.title,s.image);});     if(typeof q.shotCount==='number')since=q.shotCount; else since+= (q.shots||[]).length;     if(q.done){       $('#tourTransport').style.display='none';+      if(tourAudioEl)tourAudioEl.pause();tourAudioQueue=[];+      if(!q.failed){gradSave({aiDriven:true});}       $('#tourLabel').textContent=(q.failed?'tour stopped: '+(q.error||''):'tour complete')+'  -  '+shown+' views captured';       toast(q.failed?('tour failed: '+(q.error||'')):'tour complete: '+shown+' views captured',q.failed?'err':'ok');       return;@@ -615,33 +731,38 @@ $('#btnTour').onclick=async()=>{   };   tick(); };-// ── Tour audio narration: adom-tts clips of each step caption, played sequentially in-// this page (perfectly synced to the caption shown; nothing plays unless the toggle is on).-let tourAudioQueue=[], tourAudioPlaying=false;-function tourAudioOn(){return $('#tglTourAudio').classList.contains('is-on');}-function tourSpeak(text){-  if(!tourAudioOn())return;-  tourAudioQueue.push(text);+// ── dash-demo audio: pre-created per-step mp3s (embedded in this binary at release+// time), played by DEFAULT with a persisted mute (localStorage). Caption-text tts via+// /api/tts remains only as the fallback for a step with no shipped clip.+let tourAudioQueue=[], tourAudioPlaying=false, tourAudioEl=null;+function tourMuted(){try{return localStorage.getItem('adom-tour-muted')==='1'}catch(e){return false}}+function tourSetMuted(m){try{localStorage.setItem('adom-tour-muted',m?'1':'0')}catch(e){}+  const b=$('#ttMute');if(b){b.innerHTML=m?'&#128263;':'&#128266;';b.classList.toggle('muted',m);}+  if(m&&tourAudioEl){tourAudioEl.pause();tourAudioQueue=[];tourAudioPlaying=false;}}+function tourSpeak(caption,stepKey){+  if(tourMuted())return;+  tourAudioQueue.push({caption,stepKey});   if(!tourAudioPlaying)tourAudioNext(); } async function tourAudioNext(){-  const text=tourAudioQueue.shift();-  if(!text){tourAudioPlaying=false;return;}+  const item=tourAudioQueue.shift();+  if(!item){tourAudioPlaying=false;return;}   tourAudioPlaying=true;-  try{-    const a=new Audio('api/tts?text='+encodeURIComponent(text));-    a.onended=()=>tourAudioNext();-    a.onerror=()=>tourAudioNext();-    await a.play();-  }catch(e){tourAudioNext();}+  const srcs=[];+  if(item.stepKey)srcs.push('demo/audio/'+encodeURIComponent(item.stepKey)+'.mp3');+  if(item.caption)srcs.push('api/tts?text='+encodeURIComponent(item.caption));+  const tryPlay=async(i)=>{+    if(i>=srcs.length){tourAudioNext();return;}+    try{+      const a=new Audio(srcs[i]);tourAudioEl=a;+      a.onended=()=>tourAudioNext();+      a.onerror=()=>tryPlay(i+1);+      await a.play();+    }catch(e){tryPlay(i+1);}+  };+  tryPlay(0); }-$('#tglTourAudio').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({tourAudio:now});-  if(!now){tourAudioQueue=[];}-  toast(now?'Audio narration on - each tour step will be spoken':'Audio narration off','ok');-};+ $('#btnImportPart').onclick=async()=>{   const page=$('#partPage').value.trim();   toast('importing '+page+' from the wiki into Fusion');@@ -655,8 +776,7 @@ $('#btnSettings').onclick=async()=>{   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;-  const ta=!!dashSettings.tourAudio;-  const tg=$('#tglTourAudio');if(tg){tg.classList.toggle('is-on',ta);tg.setAttribute('aria-checked',String(ta));}+   set('#tglAutoGreen',ag);   $('#stAutoGreen').textContent=ag?'active - drives every facet to green on open':'status only';   $('#stAutoGreen').className='card-status'+(ag?' ok':'');
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.193"+DASH_VERSION="1.9.194" 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.193",+  "version": "1.9.194",   "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,6 +1,6 @@ {   "slug": "fusion-bridge",-  "version": "1.9.193",+  "version": "1.9.194",   "type": "app",   "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.",   "dependencies": {@@ -11,6 +11,10 @@     "uninstall": "./uninstall.sh"   },   "tags": [+    "ab",+    "bridge",+    "fusion",+    "desktop-app",     "fusion360",     "autodesk",     "cad",@@ -75,7 +79,10 @@     "build a basic parts library",     "add a 3d model to a component",     "make a fusion 360 library with 3d",-    "set up the fusion bridge"+    "set up the fusion bridge",+    "fusion dashboard",+    "fusion status page",+    "open the fusion launch page"   ],   "discovery_pitch": "Use the Adom Bridge Fusion 360 bridge when the user wants to DRIVE Autodesk Fusion 360 from the cloud: open/search their team's Fusion cloud designs, lay out a PCB or do electronics, export STEP/IGES/STL/Gerbers/BOM, or build a component library with real rendering 3D. It runs Fusion on the user's own machine via Adom Bridge. NOT for web/browser/login/form-filling (that's the native-browser extension) and NOT for KiCad (that's the kicad bridge).",   "brief": "Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.",@@ -104,9 +111,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+3−2
@@ -8111,8 +8111,9 @@ def _tour_caption(text: str, seconds: int = 45):     """One desktop caption per tour step (id-replace: each step's caption replaces the     last, so the screen always narrates the CURRENT step). Best-effort, never raises."""     try:-        _ad_call("desktop_caption", {"text": text, "id": "fusion-tour", "position": "top",-                                     "size": "small",+        # dash-demo fleet ruling: ALWAYS the bridge's DEFAULT caption size and position -+        # seven apps, one caption look. If the default is wrong, fix it in ab for everyone.+        _ad_call("desktop_caption", {"text": text, "id": "fusion-tour",                                      "duration": max(15000, min(int(seconds) * 2000, 240000))},                  timeout=10)     except Exception: