app
Fusion - the Fusion 360 Bridge
Public Made by Adomby adom
Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.
← Commit history
Update 15 file(s)
11 files changed
+328−127
BRIDGE_VERSION+1−1@@ -1 +1 @@-1.9.193+1.9.194
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)">· bridge launch page</span>+ Fusion Dashboard <span style="color:var(--text-2)">· 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">×</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">⏮</button>- <button class="btn" id="ttPause" data-tip="Pause at the next step boundary">⏸</button>- <button class="btn" id="ttNext" data-tip="Skip the upcoming step">⏭</button>- <button class="btn" id="ttExit" data-tip="End the tour and clean up its documents">⏹ 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">⏮</button>+ <button class="tt" id="ttPause" data-tip="Pause at the next step boundary">⏸</button>+ <button class="tt" id="ttNext" data-tip="Skip the upcoming step">⏭</button>+ <span class="tt-sep"></span>+ <button class="tt" id="ttMute" data-tip="Mute or unmute the narration">🔊</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">✕ 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='▶';toast('pausing at the next step boundary');}- if(action==='resume'){ttPaused=false;$('#ttPause').innerHTML='⏸';toast('resuming');}+ if(action==='pause'){ttPaused=true;$('#ttPause').innerHTML='▶';if(tourAudioEl)tourAudioEl.pause();toast('pausing at the next step boundary');}+ if(action==='resume'){ttPaused=false;$('#ttPause').innerHTML='⏸';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?'🔇':'🔊';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]"- }-}+ ]+}
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: