← Commit history

dashboard 1.9.44: house-contract completion (favicon, SSE live channel, ui verbs); dev-skill lessons

John Lauer ·9518eb6977 ·1mo ago ·parent ac90965
10 files changed +463−103
CHANGELOG.md+14
@@ -3,6 +3,20 @@ 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.44 - 2026-08-16 (dashboard only; bridge zip stays 1.9.42)++- **Dashboard house-contract completion (John's callout: no favicon = not done).** Served+  favicon + tab identity, SSE live channel at /events (state/toast/click events), POST+  /ui/toast + /ui/click, CLI driving verbs (`ui toast|click|state|console`), brand-hover+  app-info popover, viewport-clamped tooltips, centered dialogs, 24h idle reaper,+  --ai-thread identity forwarded to every bridge call. All ralph-tested live in pup:+  CLI-driven Settings open + toast render verified by screenshot, favicon verified in the+  actual browser tab via OS window capture.+- **Two streaming lessons now in the dev skill:** tiny_http buffers a streamed body until+  EOF (server is now hand-rolled HTTP/1.1 with per-event flush, binary smaller), and the+  container proxy buffers SSE until you send no-transform + X-Accel-Buffering: no + a 2KB+  comment padding at connect.+ ## 1.9.42 - 2026-08-16  - **THE DASHBOARD (John's Launch Page spec).** New `dashboard/` Rust CLI (`fusion-dashboard`) served
bin/fusion-dashboard
⋯ 1 unchanged line ⋯
dashboard/Cargo.toml+1−2
@@ -1,10 +1,9 @@ [package] name = "fusion-dashboard"-version = "1.9.42"+version = "1.9.44" edition = "2021"  [dependencies]-tiny_http = "0.12" serde_json = "1"  [profile.release]
dashboard/src/main.rs+330−86
@@ -1,18 +1,37 @@ // fusion-dashboard - the fusion bridge's Launch Page (John's Dashboard spec, 2026-08-16).-// The FIRST container-side bridge binary: installed by the wiki pkg tarball, serves the-// Dashboard web app, and aggregates status by shelling the adom-bridge CLI (the bridge-// stays the single source of truth; this is a thin, honest aggregator).+// The FIRST container-side bridge binary: installed by the wiki pkg, serves the Dashboard+// web app, and aggregates status by shelling the adom-bridge CLI (the bridge stays the+// single source of truth; this is a thin, honest aggregator).+//+// House contract (adom-ui-design + app-creator), all of it:+//   - served favicon + <link rel=icon> (the tab must carry the app's identity)+//   - SSE live channel at /events (state pushes, toasts, click directives)+//   - POST /ui/toast from day one - the AI narrates ON TOP of the running app+//   - CLI driving verbs: `ui toast|click|state|console` so the AI drives the real UI+//   - /state + /console observability, OK:/ERROR:/Hint: output, --ai-thread identity+//   - 24h idle reaper so containers never accumulate zombie servers+//+// The HTTP server is hand-rolled on TcpListener: tiny_http buffers a streamed body until+// EOF (verified live - zero bytes reached an SSE client), and SSE NEEDS per-event flushes.+// We control every route and run behind the container proxy, so ~120 lines of HTTP/1.1+// with explicit flush() beats a framework that can't stream. // // AI HINTS ARE THE PRODUCT as much as the UI: every JSON response carries _hint fields-// teaching a calling AI what it can automate next. The dashboard is a surface for-// humans AND a discovery surface for AIs.+// teaching a calling AI what it can automate next. +use std::io::{BufRead, BufReader, Read, Write};+use std::net::{TcpListener, TcpStream}; use std::process::Command;+use std::sync::mpsc::{channel, RecvTimeoutError, Sender}; use std::sync::{Arc, Mutex};-use tiny_http::{Header, Method, Response, Server};+use std::time::{Duration, Instant};  const UI_HTML: &str = include_str!("../ui/index.html"); const VERSION: &str = env!("CARGO_PKG_VERSION");+const IDLE_REAP_HOURS: u64 = 24;++// Tab identity: teal rounded square, two green LEDs - matches the dock icon family.+const FAVICON_SVG: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><rect x="1" y="1" width="22" height="22" rx="5" fill="#0d1117" stroke="#00b8b1" stroke-width="2"/><circle cx="8" cy="9" r="2.2" fill="#3fb950"/><circle cx="8" cy="15" r="2.2" fill="#3fb950"/><path d="M12.5 9h5M12.5 15h5" stroke="#00b8b1" stroke-width="2" stroke-linecap="round"/></svg>"##;  fn ok(msg: &str) {     println!("OK: {}", msg);@@ -22,11 +41,11 @@ fn err_hint(msg: &str, hint: &str) {     eprintln!("Hint: {}", hint); } -/// Shell an adom-bridge verb and parse its JSON. The ai-thread names this surface so-/// every call is attributed in the user's Activity Log.-fn bridge(target: &str, verb: &str, args: &str) -> serde_json::Value {+/// Shell an adom-bridge verb and parse its JSON. --ai-thread attributes every call in the+/// user's Activity Log; forwarded from our own --ai-thread flag (app-creator identity rule).+fn bridge(ai_thread: &str, target: &str, verb: &str, args: &str) -> serde_json::Value {     let out = Command::new("adom-bridge")-        .args(["--target", target, "--ai-thread", "fusion-dashboard", verb, args])+        .args(["--target", target, "--ai-thread", ai_thread, verb, args])         .output();     match out {         Ok(o) => serde_json::from_slice(&o.stdout).unwrap_or_else(|_| {@@ -59,11 +78,12 @@ fn default_target() -> String { }  /// The LED payload: one call per facet, each with state + an AI hint on how to act.-fn gather_status(target: &str) -> serde_json::Value {-    let rd = unwrap_data(&bridge(target, "fusion_readiness", "{}"));-    let aps = unwrap_data(&bridge(target, "fusion_aps_status", "{}"));-    let mcp = unwrap_data(&bridge(target, "fusion_mcp_status", "{}"));-    let auto_upd = unwrap_data(&bridge(target, "fusion_set_auto_update", "{}"));+fn gather_status(ai_thread: &str, target: &str) -> serde_json::Value {+    let rd = unwrap_data(&bridge(ai_thread, target, "fusion_readiness", "{}"));+    let aps = unwrap_data(&bridge(ai_thread, target, "fusion_aps_status", "{}"));+    let mcp = unwrap_data(&bridge(ai_thread, target, "fusion_mcp_status", "{}"));+    // 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 running = rd["fusionRunning"].as_bool().or(rd["running"].as_bool()).unwrap_or(false);     let ready = rd["ready"].as_bool().unwrap_or(false);@@ -99,22 +119,22 @@ fn gather_status(target: &str) -> serde_json::Value {         "settings": {             "autoUpdateFusion": auto_upd["autoUpdateFusion"].as_bool().unwrap_or(true)         },-        "_hint": "This is the Dashboard status payload. Every LED carries its own _hint naming the verb that fixes it. POST /api/action {action, args} to act; GET /api/status to re-read."+        "_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."     }) } -fn action(target: &str, name: &str, args: &serde_json::Value) -> serde_json::Value {+fn action(ai_thread: &str, target: &str, name: &str, args: &serde_json::Value) -> serde_json::Value {     let a = args.to_string();     match name {-        "start" => bridge(target, "fusion_start", "{}"),-        "stop" => bridge(target, "fusion_stop", "{\"reason\":\"dashboard: user clicked Stop\"}"),-        "signin" => bridge(target, "fusion_signin", &a),-        "tour" => bridge(target, "fusion_demo", "{\"mode\":\"tour\"}"),-        "tour_progress" => bridge(target, "fusion_demo_progress", &a),-        "set_auto_update" => bridge(target, "fusion_set_auto_update", &a),-        "mcp_enable" => bridge(target, "fusion_mcp_enable", "{}"),-        "aps_signin" => bridge(target, "fusion_aps_signin", "{}"),-        "import_basic_part" => import_basic_part(target, args),+        "start" => bridge(ai_thread, target, "fusion_start", "{}"),+        "stop" => bridge(ai_thread, target, "fusion_stop", "{\"reason\":\"dashboard: user clicked Close Fusion\"}"),+        "signin" => bridge(ai_thread, target, "fusion_signin", &a),+        "tour" => bridge(ai_thread, target, "fusion_demo", "{\"mode\":\"tour\"}"),+        "tour_progress" => bridge(ai_thread, target, "fusion_demo_progress", &a),+        "set_auto_update" => bridge(ai_thread, target, "fusion_set_auto_update", &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),         _ => serde_json::json!({"error": format!("unknown action {}", name),             "_hint": "Actions: start, stop, signin, tour, tour_progress, set_auto_update, mcp_enable, aps_signin, import_basic_part."}),     }@@ -122,7 +142,7 @@ fn action(target: &str, name: &str, args: &serde_json::Value) -> serde_json::Val  /// The wiki-to-Fusion demo, end to end from the container: clone the part page, /// send its .lbr to the box, open it in Fusion's Library Manager. All background.-fn import_basic_part(target: &str, args: &serde_json::Value) -> serde_json::Value {+fn import_basic_part(ai_thread: &str, target: &str, args: &serde_json::Value) -> serde_json::Value {     let page = args["page"].as_str().unwrap_or("aravk/in-s42ata");     let tmp = format!("/tmp/dash-part-{}", std::process::id());     let _ = std::fs::remove_dir_all(&tmp);@@ -141,76 +161,260 @@ fn import_basic_part(target: &str, args: &serde_json::Value) -> serde_json::Valu             "_hint": "Pick an adom basic part page (it publishes <PART>.lbr + <PART>.step)."});     };     let dest = "C:/Users/john/AppData/Local/Temp/fusion-dashboard";-    let send = bridge(target, "send_files",+    let send = bridge(ai_thread, target, "send_files",         &serde_json::json!({"filePaths": [lbr.to_string_lossy()], "dest": dest}).to_string());     let name = lbr.file_name().unwrap().to_string_lossy().to_string();-    let open = bridge(target, "fusion_open_lbr",+    let open = bridge(ai_thread, target, "fusion_open_lbr",         &serde_json::json!({"filePath": format!("{}/{}", dest, name)}).to_string());     serde_json::json!({"page": page, "sent": unwrap_data(&send), "opened": unwrap_data(&open),-        "_hint": "Wiki part -> Fusion Library Manager, all background. Next steps an AI can run: fusion_show_symbol / fusion_show_footprint {name}, fusion_bind_library_3d for the 3D, fusion_make_test_project for a solo demo board."})+        "_hint": "Wiki part -> Fusion Library Manager, all background. Next steps an AI can run: fusion_show_symbol / fusion_show_footprint {name}, fusion_bind_library_3d for the 3D, fusion_make_test_project for a solo demo board. Render any of them with fusion_state."}) } -fn serve(port: u16, target: String, print_url: bool) {-    let server = match Server::http(("0.0.0.0", port)) {-        Ok(s) => s,-        Err(e) => {-            err_hint(&format!("cannot bind port {}: {}", port, e),-                     &format!("Try `fusion-dashboard serve --port {}`", port + 1));-            std::process::exit(1);-        }-    };-    let console: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));-    let log = |c: &Arc<Mutex<Vec<String>>>, m: String| {-        let mut g = c.lock().unwrap();+// ── SSE hub: the live channel every Adom app carries ─────────────────────────────────────+struct Hub {+    clients: Mutex<Vec<Sender<String>>>,+}+impl Hub {+    fn broadcast(&self, event: &str, data: &serde_json::Value) {+        let msg = format!("event: {}\ndata: {}\n\n", event, data);+        self.clients.lock().unwrap().retain(|c| c.send(msg.clone()).is_ok());+    }+    fn client_count(&self) -> usize {+        // Prune dead senders lazily so the count reflects live viewers.+        let mut g = self.clients.lock().unwrap();+        g.retain(|c| c.send(": ping\n\n".into()).is_ok());+        g.len()+    }+}++struct App {+    ai_thread: String,+    target: String,+    hub: Hub,+    console: Mutex<Vec<String>>,+    last_activity: Mutex<Instant>,+}+impl App {+    fn log(&self, m: String) {+        let mut g = self.console.lock().unwrap();         g.push(m);         let overflow = g.len().saturating_sub(500);-        if overflow > 0 { g.drain(0..overflow); }+        if overflow > 0 {+            g.drain(0..overflow);+        }+    }+    fn tgt(&self) -> String {+        if self.target.is_empty() { default_target() } else { self.target.clone() }+    }+}++// ── Minimal HTTP/1.1 with explicit flushes ───────────────────────────────────────────────+fn respond(stream: &mut TcpStream, status: &str, ctype: &str, body: &[u8]) {+    let head = format!(+        "HTTP/1.1 {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nAccess-Control-Allow-Origin: *\r\nConnection: close\r\n\r\n",+        status, ctype, body.len());+    let _ = stream.write_all(head.as_bytes());+    let _ = stream.write_all(body);+    let _ = stream.flush();+}+fn respond_json(stream: &mut TcpStream, v: &serde_json::Value) {+    respond(stream, "200 OK", "application/json", v.to_string().as_bytes());+}++fn handle_conn(app: &Arc<App>, mut stream: TcpStream) {+    let mut reader = BufReader::new(match stream.try_clone() { Ok(s) => s, Err(_) => return });+    let mut req_line = String::new();+    if reader.read_line(&mut req_line).is_err() || req_line.trim().is_empty() {+        return;+    }+    let mut it = req_line.split_whitespace();+    let method = it.next().unwrap_or("").to_uppercase();+    let path_full = it.next().unwrap_or("/").to_string();+    let path = path_full.split('?').next().unwrap_or("/").to_string();+    let mut content_len = 0usize;+    loop {+        let mut h = String::new();+        if reader.read_line(&mut h).is_err() {+            return;+        }+        let h = h.trim();+        if h.is_empty() {+            break;+        }+        if let Some(v) = h.to_ascii_lowercase().strip_prefix("content-length:") {+            content_len = v.trim().parse().unwrap_or(0);+        }+    }+    let mut body_raw = vec![0u8; content_len.min(1 << 20)];+    if content_len > 0 {+        let _ = reader.read_exact(&mut body_raw);+    }+    let body: serde_json::Value = serde_json::from_slice(&body_raw).unwrap_or(serde_json::json!({}));++    if path != "/events" {+        *app.last_activity.lock().unwrap() = Instant::now();+    }+    match (method.as_str(), path.as_str()) {+        ("GET", "/") | ("GET", "/index.html") => {+            respond(&mut stream, "200 OK", "text/html; charset=utf-8", UI_HTML.as_bytes());+        }+        ("GET", "/favicon.svg") | ("GET", "/favicon.ico") => {+            respond(&mut stream, "200 OK", "image/svg+xml", FAVICON_SVG.as_bytes());+        }+        ("GET", "/events") => {+            // The live channel: headers immediately, then one flush PER EVENT - the whole+            // reason this server is hand-rolled. X-Accel-Buffering + no-transform + a 2KB+            // comment padding defeat proxy/compression buffering (the container proxy held+            // ALL bytes until we did this - verified live: direct streamed, proxied stalled).+            let head = "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nCache-Control: no-cache, no-transform\r\nX-Accel-Buffering: no\r\nAccess-Control-Allow-Origin: *\r\nConnection: keep-alive\r\n\r\n";+            if stream.write_all(head.as_bytes()).is_err() {+                return;+            }+            let padding = format!(": {}\n\n", "p".repeat(2048));+            if stream.write_all(padding.as_bytes()).is_err() {+                return;+            }+            let hello = format!("event: hello\ndata: {}\n\n", serde_json::json!({"version": VERSION}));+            if stream.write_all(hello.as_bytes()).is_err() || stream.flush().is_err() {+                return;+            }+            let (tx, rx) = channel::<String>();+            app.hub.clients.lock().unwrap().push(tx);+            app.log("sse client connected".into());+            loop {+                let msg = match rx.recv_timeout(Duration::from_secs(15)) {+                    Ok(m) => m,+                    Err(RecvTimeoutError::Timeout) => ": ping\n\n".to_string(),+                    Err(_) => return,+                };+                if stream.write_all(msg.as_bytes()).is_err() || stream.flush().is_err() {+                    app.log("sse client disconnected".into());+                    return; // rx drops -> sender errors -> hub prunes on next broadcast+                }+            }+        }+        ("GET", "/api/status") | ("GET", "/state") => {+            let t = app.tgt();+            app.log(format!("status target={}", t));+            let st = gather_status(&app.ai_thread, &t);+            app.hub.broadcast("state", &st);+            respond_json(&mut stream, &st);+        }+        ("GET", "/console") => {+            let v = serde_json::json!({"lines": *app.console.lock().unwrap()});+            respond_json(&mut stream, &v);+        }+        ("GET", "/health") => {+            respond_json(&mut stream, &serde_json::json!({"ok": true, "version": VERSION,+                "sseClients": app.hub.client_count()}));+        }+        ("POST", "/api/action") => {+            let name = body["action"].as_str().unwrap_or("").to_string();+            let t = app.tgt();+            app.log(format!("action {} target={}", name, t));+            let out = action(&app.ai_thread, &t, &name, &body["args"]);+            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();+            let kind = body["kind"].as_str().unwrap_or("info").to_string();+            app.log(format!("toast [{}] {}", kind, msg));+            app.hub.broadcast("toast", &serde_json::json!({"message": msg, "kind": kind}));+            respond_json(&mut stream, &serde_json::json!({"ok": true,+                "delivered": app.hub.client_count(),+                "_hint": "delivered = live SSE viewers that showed it. 0 means nobody has the page open - open it first."}));+        }+        ("POST", "/ui/click") => {+            // CLI-driven front-end click: the page clicks its own control (same path a user takes).+            let target = body["target"].as_str().unwrap_or("").to_string();+            app.log(format!("ui click {}", target));+            app.hub.broadcast("click", &serde_json::json!({"target": target}));+            respond_json(&mut stream, &serde_json::json!({"ok": true,+                "delivered": app.hub.client_count(),+                "_hint": "Targets: setup, start, stop, tour, import-part, settings, refresh. The PAGE performs the click, so what the AI drives is exactly what the user gets."}));+        }+        _ => {+            respond(&mut stream, "404 Not Found", "application/json",+                serde_json::json!({"error": "not found",+                    "_hint": "GET / /favicon.svg /api/status /state /console /health /events(SSE); POST /api/action {action,args}, /ui/toast {message,kind}, /ui/click {target}"}).to_string().as_bytes());+        }+    }+}++fn serve(port: u16, target: String, ai_thread: String, print_url: bool) {+    let mut port = port;+    let listener = loop {+        match TcpListener::bind(("0.0.0.0", port)) {+            Ok(l) => break l,+            Err(e) => {+                // --port auto behavior: walk forward a little before giving up.+                if port < 8940 {+                    port += 1;+                    continue;+                }+                err_hint(&format!("cannot bind any port 8930-8940: {}", e),+                         "Another instance may be serving - `fusion-dashboard ui state` to check it.");+                std::process::exit(1);+            }+        }     };+    let app = Arc::new(App {+        ai_thread,+        target,+        hub: Hub { clients: Mutex::new(Vec::new()) },+        console: Mutex::new(Vec::new()),+        last_activity: Mutex::new(Instant::now()),+    });     if print_url {         // Hydrogen dock await:url contract - the proxied URL a webview can reach.         println!("http://127.0.0.1:{}/", port);     }     ok(&format!("fusion-dashboard v{} serving on port {} (target {})", VERSION, port,-        if target.is_empty() { "auto" } else { &target }));-    for mut req in server.incoming_requests() {-        let url = req.url().to_string();-        let method = req.method().clone();-        let tgt = if target.is_empty() { default_target() } else { target.clone() };-        let json_hdr = Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap();-        let resp = match (method, url.split('?').next().unwrap_or("")) {-            (Method::Get, "/") | (Method::Get, "/index.html") => {-                let h = Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..]).unwrap();-                req.respond(Response::from_string(UI_HTML).with_header(h)).ok();-                continue;-            }-            (Method::Get, "/api/status") | (Method::Get, "/state") => {-                log(&console, format!("status target={}", tgt));-                Response::from_string(gather_status(&tgt).to_string()).with_header(json_hdr)-            }-            (Method::Get, "/console") => {-                let body = serde_json::json!({"lines": *console.lock().unwrap()}).to_string();-                Response::from_string(body).with_header(json_hdr)-            }-            (Method::Get, "/health") => {-                Response::from_string(serde_json::json!({"ok": true, "version": VERSION}).to_string())-                    .with_header(json_hdr)+        if app.target.is_empty() { "auto" } else { &app.target }));++    // 24h idle reaper (app-creator lifecycle): a dock click respawns us, so exiting when+    // nobody has used the app for a day keeps containers free of zombie servers.+    {+        let app = app.clone();+        std::thread::spawn(move || loop {+            std::thread::sleep(Duration::from_secs(3600));+            let idle = app.last_activity.lock().unwrap().elapsed();+            if idle > Duration::from_secs(IDLE_REAP_HOURS * 3600) && app.hub.client_count() == 0 {+                println!("OK: idle {}h with no viewers - reaping (dock click restarts me)", IDLE_REAP_HOURS);+                std::process::exit(0);             }-            (Method::Post, "/api/action") => {-                let mut body = String::new();-                req.as_reader().read_to_string(&mut body).ok();-                let v: serde_json::Value = serde_json::from_str(&body).unwrap_or(serde_json::json!({}));-                let name = v["action"].as_str().unwrap_or("").to_string();-                log(&console, format!("action {} target={}", name, tgt));-                let out = action(&tgt, &name, &v["args"]);-                Response::from_string(out.to_string()).with_header(json_hdr)+        });+    }++    for stream in listener.incoming() {+        let Ok(stream) = stream else { continue };+        let app = app.clone();+        // Thread-per-connection so an open SSE stream never blocks the accept loop.+        std::thread::spawn(move || handle_conn(&app, stream));+    }+}++/// CLI `ui` verbs drive the RUNNING server over HTTP (find it on 8930-8940).+fn ui_call(port_flag: Option<u16>, path: &str, body: Option<serde_json::Value>) -> Result<serde_json::Value, String> {+    let ports: Vec<u16> = port_flag.map(|p| vec![p]).unwrap_or((8930..=8940).collect());+    for p in ports {+        let url = format!("http://127.0.0.1:{}{}", p, path);+        let mut cmd = Command::new("curl");+        cmd.args(["-s", "-m", "60"]);+        if let Some(b) = &body {+            cmd.args(["-X", "POST", "-H", "Content-Type: application/json", "-d", &b.to_string()]);+        }+        cmd.arg(&url);+        if let Ok(o) = cmd.output() {+            if o.status.success() && !o.stdout.is_empty() {+                if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&o.stdout) {+                    return Ok(v);+                }             }-            _ => Response::from_string(-                serde_json::json!({"error": "not found",-                    "_hint": "GET /api/status, GET /state, GET /console, GET /health, POST /api/action {action,args}"}).to_string())-                .with_header(json_hdr),-        };-        req.respond(resp).ok();+        }     }+    Err("no fusion-dashboard server answering on 8930-8940".into()) }  fn main() {@@ -219,23 +423,63 @@ fn main() {     let get_flag = |name: &str| -> Option<String> {         args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).cloned()     };+    let ai_thread = get_flag("--ai-thread").unwrap_or_else(|| "fusion-dashboard".into());+    let port_flag: Option<u16> = get_flag("--port").and_then(|p| p.parse().ok());     match cmd {         "serve" => {-            let port: u16 = get_flag("--port").and_then(|p| p.parse().ok()).unwrap_or(8930);             let target = get_flag("--target").unwrap_or_default();             let print_url = args.iter().any(|a| a == "--print-url");-            serve(port, target, print_url);+            serve(port_flag.unwrap_or(8930), target, ai_thread, print_url);         }         "status" => {             let target = get_flag("--target").unwrap_or_else(default_target);-            println!("{}", serde_json::to_string_pretty(&gather_status(&target)).unwrap());+            println!("{}", serde_json::to_string_pretty(&gather_status(&ai_thread, &target)).unwrap());+        }+        "ui" => {+            let sub = args.get(2).map(|s| s.as_str()).unwrap_or("");+            let res = match sub {+                "toast" => {+                    let msg = args.get(3).cloned().unwrap_or_default();+                    if msg.is_empty() {+                        err_hint("ui toast needs a message", "fusion-dashboard ui toast \"running test 3 of 8\"");+                        std::process::exit(1);+                    }+                    let kind = get_flag("--kind").unwrap_or_else(|| "info".into());+                    ui_call(port_flag, "/ui/toast", Some(serde_json::json!({"message": msg, "kind": kind})))+                }+                "click" => {+                    let target = args.get(3).cloned().unwrap_or_default();+                    if target.is_empty() {+                        err_hint("ui click needs a target", "fusion-dashboard ui click tour   (targets: setup, start, stop, tour, import-part, settings, refresh)");+                        std::process::exit(1);+                    }+                    ui_call(port_flag, "/ui/click", Some(serde_json::json!({"target": target})))+                }+                "state" => ui_call(port_flag, "/state", None),+                "console" => ui_call(port_flag, "/console", None),+                _ => {+                    err_hint("unknown ui verb", "ui toast \"msg\" [--kind ok|err|info] | ui click <target> | ui state | ui console");+                    std::process::exit(1);+                }+            };+            match res {+                Ok(v) => println!("OK: {}", v),+                Err(e) => {+                    err_hint(&e, "Start it first: `fusion-dashboard serve &`");+                    std::process::exit(1);+                }+            }         }         "health" => ok(&format!("fusion-dashboard v{}", VERSION)),         _ => {             println!("fusion-dashboard v{} - the fusion bridge's Launch Page", VERSION);-            println!("  serve [--port 8930] [--target <box>] [--print-url]   serve the Dashboard");-            println!("  status [--target <box>]                              one-shot LED JSON");-            println!("  health                                               liveness");+            println!("  serve [--port 8930] [--target <box>] [--print-url]      serve the Dashboard (SSE live channel included)");+            println!("  status [--target <box>]                                 one-shot LED JSON");+            println!("  ui toast \"msg\" [--kind ok|err|info] [--port]            narrate a toast over the running app");+            println!("  ui click <setup|start|stop|tour|import-part|settings|refresh>   drive the real UI");+            println!("  ui state | ui console                                   observe the running app");+            println!("  health                                                  liveness");+            println!("  (global: --ai-thread <name> attributes bridge calls in the Activity Log)");             println!("Hint: `fusion-dashboard serve --print-url` is the Hydrogen dock launch step.");         }     }
dashboard/ui/index.html+66−3
@@ -4,6 +4,7 @@ <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Fusion Dashboard</title>+<link rel="icon" type="image/svg+xml" href="favicon.svg"> <style> :root{   --bg:#0d1117;--surface:#161b22;--elevated:#1c2128;--overlay:#21262d;--border:#30363d;@@ -49,7 +50,7 @@ h2{font-family:'Familjen Grotesk',sans-serif;font-size:17px;color:var(--text);ma #toasts{position:fixed;left:50%;bottom:18px;transform:translateX(-50%);display:flex;flex-direction:column-reverse;gap:8px;z-index:99999} .toast{background:var(--overlay);border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:8px;padding:10px 14px;color:var(--text);font-size:14px;box-shadow:0 8px 24px rgba(0,0,0,.4)} .toast.ok{border-left-color:var(--green)}.toast.err{border-left-color:var(--red)}-dialog{background:var(--overlay);color:var(--text-body);border:1px solid var(--border);border-radius:12px;padding:20px;max-width:520px;width:92vw}+dialog{margin:auto;background:var(--overlay);color:var(--text-body);border:1px solid var(--border);border-radius:12px;padding:20px;max-width:520px;width:92vw} dialog::backdrop{background:rgba(0,0,0,.55)} .setrow{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:12px 0;border-bottom:1px solid var(--border)} .toggle{width:42px;height:24px;border-radius:999px;background:var(--elevated);border:1px solid var(--border);position:relative;cursor:pointer;flex:none}@@ -58,6 +59,12 @@ dialog::backdrop{background:rgba(0,0,0,.55)} .toggle.is-on::after{left:21px;background:#05221f} @media (max-width:720px){.meta{display:none}.grid{grid-template-columns:1fr 1fr}} @media (max-width:480px){.grid{grid-template-columns:1fr}}+#tt{position:fixed;z-index:99999;background:var(--overlay);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:12.5px;color:var(--text-body);max-width:260px;pointer-events:none;opacity:0;transform:translateY(3px);transition:opacity .18s ease,transform .18s ease;text-transform:none}+#tt.show{opacity:1;transform:none}+#appinfo{position:fixed;z-index:99998;background:var(--overlay);border:1px solid var(--border);border-radius:10px;padding:12px 14px;font-size:13px;color:var(--text-body);box-shadow:0 8px 24px rgba(0,0,0,.4);display:none;pointer-events:auto;min-width:220px}+#appinfo.show{display:block}+#appinfo a{color:var(--accent)}+.brand{cursor:default} </style> </head> <body>@@ -70,8 +77,8 @@ dialog::backdrop{background:rgba(0,0,0,.55)}   <div class="subject" id="subject">connecting...</div>   <div class="meta" id="meta"></div>   <div class="actions">-    <button class="iconbtn" id="btnSettings" title="Settings" aria-label="Settings"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 1.55V21a2 2 0 1 1-4 0v-.09A1.7 1.7 0 0 0 9 19.4a1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-1.55-1H3a2 2 0 1 1 0-4h.09A1.7 1.7 0 0 0 4.6 9a1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1-1.55V3a2 2 0 1 1 4 0v.09a1.7 1.7 0 0 0 1 1.51 1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.7 1.7 0 0 0 19.4 9c.26.6.86 1 1.51 1H21a2 2 0 1 1 0 4h-.09c-.65 0-1.25.4-1.51 1z"/></svg></button>-    <button class="iconbtn" id="btnRefresh" title="Refresh status" aria-label="Refresh"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M21 12a9 9 0 1 1-2.64-6.36M21 3v6h-6"/></svg></button>+    <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> </header> <main>@@ -128,6 +135,8 @@ dialog::backdrop{background:rgba(0,0,0,.55)} </dialog>  <div id="toasts"></div>+<div id="tt" role="tooltip"></div>+<div id="appinfo"></div> <script> const $=s=>document.querySelector(s); const consoleBuf=[];@@ -223,6 +232,60 @@ const PROMPTS=[ ]; $('#prompts').innerHTML=PROMPTS.map(p=>`<div class="prompt"><code>${p}</code><button class="btn copy" onclick="navigator.clipboard.writeText('${p.replace(/'/g,"\\'")}');toast('copied','ok')">Copy</button></div>`).join(''); +// ── Live channel (SSE): state pushes, AI toasts, CLI click directives ──+const CLICK_TARGETS={setup:'#btnSetup',start:'#btnStart',stop:'#btnStop',tour:'#btnTour','import-part':'#btnImportPart',settings:'#btnSettings',refresh:'#btnRefresh'};+let sseUp=false;+function connectSSE(){+  const es=new EventSource('events');+  es.addEventListener('hello',()=>{sseUp=true;console.log('sse connected');});+  es.addEventListener('state',e=>{try{lastStatus=JSON.parse(e.data);renderLeds(lastStatus);}catch(_){}});+  es.addEventListener('toast',e=>{try{const t=JSON.parse(e.data);toast(t.message,t.kind==='info'?'':t.kind);}catch(_){}});+  es.addEventListener('click',e=>{try{+    const {target}=JSON.parse(e.data);const sel=CLICK_TARGETS[target];+    if(sel&&$(sel)){$(sel).click();}else{toast('unknown click target: '+target,'err');}+  }catch(_){}});+  es.onerror=()=>{sseUp=false;es.close();setTimeout(connectSSE,3000);};+}+connectSSE();++// ── Tooltips: body-appended, fixed, clamped to the viewport on BOTH axes ──+const tt=$('#tt');let ttTimer=null;+document.addEventListener('mouseover',e=>{+  const el=e.target.closest('[data-tip]');+  if(!el){clearTimeout(ttTimer);tt.classList.remove('show');return;}+  clearTimeout(ttTimer);+  ttTimer=setTimeout(()=>{+    tt.textContent=el.getAttribute('data-tip');+    tt.style.left='0px';tt.style.top='0px';tt.classList.add('show');+    const r=el.getBoundingClientRect(),M=8,w=tt.offsetWidth,h=tt.offsetHeight;+    let x=r.left+r.width/2-w/2, y=r.bottom+10;+    if(y+h>innerHeight-M) y=r.top-h-10;                       // flip above+    x=Math.max(M,Math.min(x,innerWidth-w-M));                  // clamp X+    y=Math.max(M,Math.min(y,innerHeight-h-M));                 // clamp Y+    tt.style.left=x+'px';tt.style.top=y+'px';+  },600);+});+document.addEventListener('mouseout',e=>{if(e.target.closest('[data-tip]')){clearTimeout(ttTimer);tt.classList.remove('show');}});++// ── Brand hover popover: app identity lives here, not in the header row ──+const ai=$('#appinfo');const brand=document.querySelector('.brand');+let aiShow=null,aiHide=null;+function fillAppInfo(){+  const v=lastStatus?lastStatus.dashboardVersion:'';const bv=lastStatus?(lastStatus.bridgeVersion||'?'):'?';+  ai.innerHTML='<b style="color:var(--text)">Fusion Dashboard</b><br>dashboard v'+(v||'?')+' · bridge v'+bv++    '<br>engine: fusion-dashboard (Rust) + adom-bridge<br>author: adom<br>'++    '<a href="https://wiki.adom.inc/adom/fusion-bridge" target="_blank" rel="noopener">wiki.adom.inc/adom/fusion-bridge</a>';+}+brand.addEventListener('mouseenter',()=>{clearTimeout(aiHide);aiShow=setTimeout(()=>{+  fillAppInfo();ai.classList.add('show');+  const r=brand.getBoundingClientRect(),M=8;+  let x=Math.max(M,Math.min(r.left,innerWidth-ai.offsetWidth-M));+  ai.style.left=x+'px';ai.style.top=Math.min(r.bottom+8,innerHeight-ai.offsetHeight-M)+'px';+},600);});+brand.addEventListener('mouseleave',()=>{clearTimeout(aiShow);aiHide=setTimeout(()=>ai.classList.remove('show'),220);});+ai.addEventListener('mouseenter',()=>clearTimeout(aiHide));+ai.addEventListener('mouseleave',()=>{aiHide=setTimeout(()=>ai.classList.remove('show'),220);});+ refresh();setInterval(refresh,15000); </script> </body>
dev-skills/fusion-dashboard/SKILL.md+24
@@ -98,6 +98,30 @@ fusion-dashboard status                                 # one-shot JSON (the LED 7. Verify the circle end-to-end: `adom-wiki pkg install adom/fusion-bridge` in a container    -> `fusion-dashboard health` -> `serve` -> `curl /api/status` returns live LEDs. +## Hard-won build lessons (v1.9.44, 2026-08-16 - John called out the misses)++1. **The house contract is ALL mandatory, not a menu.** v1.9.42 shipped without a favicon,+   without POST /ui/toast, without SSE, without CLI ui verbs - John: "they sure as fuck+   tell you to make a favicon." Before calling a dashboard done, check off: served+   favicon + <link rel=icon>, SSE /events, /ui/toast, /ui/click, CLI ui verbs,+   /state + /console, brand-hover app-info popover, clamped tooltips, 24h idle reaper,+   --ai-thread identity. THEN ralph-test each one live.+2. **tiny_http cannot stream SSE** - it buffers the entire response until the reader EOFs+   (verified: zero bytes reached a connected client, not even headers). The dashboard+   hand-rolls HTTP/1.1 on TcpListener with an explicit flush() per event. ~120 lines,+   smaller binary, full control.+3. **The container proxy buffers event-streams too.** Direct 127.0.0.1 streamed, the+   proxied URL delivered NOTHING. Fix (all three): `Cache-Control: no-cache,+   no-transform`, `X-Accel-Buffering: no`, and a ~2KB SSE comment padding written at+   connect. Without the padding the compression buffer sits on your first events.+4. **`*{margin:0}` resets kill `<dialog>` centering** - native centering is margin:auto;+   re-add `dialog{margin:auto}` or every showModal() pins top-left.+5. **`curl -I` sends HEAD** - a GET-only route 404s under it and you chase a phantom bug.+   Test GETs with `-o /dev/null -w "%{http_code}"`.+6. **Favicon-in-tab proof needs an OS window capture** (desktop_screenshot_window on the+   pup hwnd - pup window titles end "- Google Chrome for Testing"); a page screenshot+   never shows the tab bar.+ ## SDK evangelism  ab's Bridge SDK gets this pattern as "the container binary facet" - filed on the ab/SDK
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.42"+DASH_VERSION="1.9.44" 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.43",+  "version": "1.9.44",   "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.43",+  "version": "1.9.44",   "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
skills/fusion-dashboard/SKILL.md+15−2
@@ -16,14 +16,27 @@ A Rust CLI in THIS container serving the Dashboard web app. Status comes from sh ``` fusion-dashboard serve [--port 8930] [--target <box>] [--print-url]   # serve the app fusion-dashboard status [--target <box>]                              # one-shot LED JSON+fusion-dashboard ui toast "msg" [--kind ok|err|info]                  # narrate OVER the running app+fusion-dashboard ui click <setup|start|stop|tour|import-part|settings|refresh>+fusion-dashboard ui state | ui console                                # observe the running app fusion-dashboard health                                               # liveness ```  - `serve --print-url` prints the local URL first - the Hydrogen dock's `await:"url"` step.-- No `--target` = first box from `adom-bridge targets`.+- No `--target` = first box from `adom-bridge targets`. Global `--ai-thread <name>`+  attributes every bridge call in the user's Activity Log. - HTTP: `GET /api/status` (the LED payload), `POST /api/action {action, args}` (start,   stop, signin, tour, tour_progress, set_auto_update, mcp_enable, aps_signin,-  import_basic_part), `GET /state`, `GET /console`, `GET /health`.+  import_basic_part), `GET /events` (SSE live channel: state/toast/click events),+  `POST /ui/toast {message,kind}`, `POST /ui/click {target}`, `GET /state`,+  `GET /console`, `GET /health` (includes sseClients - live viewer count).++## Driving it as an AI (the ralph loop)++The page holds an SSE connection; `ui click` makes THE PAGE click its own control (same+path a user takes) and `ui toast` narrates on top of the app while you work. Toast/click+responses return `delivered` = live viewers; 0 means nobody has the page open. Screenshot+via pup after each drive step and fix what you see.  ## Displaying this app (REQUIRED - read before showing anything)