← Commit history

2.11.0: the Dashboard joins the Hydrogen Dock Bar (dockbar.json 1.2.0, dashboard serve --print-url, /api/status with CORS, Say it strip, settings, taskbar progress)

John Lauer ·7bb30ee877 ·1mo ago ·parent ba90c6f
14 files changed +1212−42
Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-lbr"-version = "2.10.2"+version = "2.11.0" dependencies = [  "altium-codec",  "anyhow",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-lbr"-version = "2.10.2"+version = "2.11.0" edition = "2021" description = "adom-lbr generator / EDA translator: the canonical adom-lbr JSON is the hub, with native converters to Altium (.SchLib/.PcbLib/.IntLib), EAGLE/Fusion (.lbr), and KiCad. Altium output is written natively — no Altium needed at conversion time." 
SKILL.md+15−4
@@ -70,10 +70,20 @@ visibility** (`(pin_numbers (hide yes))`), so a KiCad→JSON→KiCad round trip solid vs hollow art and shown vs hidden pin labels. Footprint silk/courtyard/fab graphics keep their layers → `F.SilkS` / `F.CrtYd` / `F.Fab`. -## adom-lbr Manager (`adom-lbr manage`)+## adom-lbr Dashboard (`adom-lbr dashboard serve`) -A web app (tiny_http → webview) that browses wiki components and inspects any-part. `adom-lbr manage --port 8785` → open `http://localhost:8785`.+The Manager plus live status, settings and the Hydrogen Dock Bar contract.+`adom-lbr dashboard serve --print-url` starts it (or reuses a running one of+the same version, retiring a stale one), prints `http://127.0.0.1:8785/`, and+returns; `adom-lbr manage` is the foreground alias. Routes: `/api/status` (six+LEDs plus a `state` rollup, cached, CORS on every `/api/*`), `/api/say`,+`POST /api/try` (stage a phrase into the app's AI thread), `/api/settings`,+`POST /api/lifecycle/update|uninstall` with `/api/job/<name>` for the live+console. Settings live in `~/.config/adom-lbr/settings.json`: taskbar progress+on the Hydrogen window during long work, the owner for My projects, and the+list cache. The page opens with a "Say it" strip: users ask their AI using the+noun **lbr** ("validate my lbr for fusion"); `adom-lbr dashboard say` prints+the phrases, which come from the page's `dockbar.json`.  - **List ▸ click ▸ viewer.** Lists every component on the wiki (wiki API); click a   row to open its viewer.@@ -100,7 +110,8 @@ adom-lbr import <path.lbr> [-o output.kicad_sym] adom-lbr lint <path.lbr> adom-lbr validate <path.lbr> adom-lbr check <path.kicad_sym>-adom-lbr manage [--port 8785]                 # the adom-lbr Manager web app+adom-lbr dashboard serve [--port 8785] [--print-url]   # the Dashboard (Manager + status + settings)+adom-lbr manage [--port 8785]                 # foreground alias of the above adom-lbr health adom-lbr install adom-lbr completions <bash|zsh|fish>
VERSION+1−1
@@ -1 +1 @@-2.10.2+2.11.0
dockbar.json+28−7
@@ -1,24 +1,45 @@ {-  "dock_manifest": "1.1.0",+  "dock_manifest": "1.2.0",+  "say": [+    "open the lbr dashboard",+    "convert this kicad symbol to an eagle lbr",+    "validate my lbr for fusion",+    "make an altium library from this lbr",+    "show me this part in the lbr manager",+    "add these parts to my kicad library with lbr"+  ],   "webview": {-    "title": "Adom LBR"+    "title": "Adom LBR",+    "favicon": "docs/dock-lbr.svg"+  },+  "health": {+    "url": "/api/status",+    "poll_seconds": 15,+    "timeout_ms": 2000   },   "launch": {     "mode": "container_command",+    "console": "show",     "steps": [       {         "label": "Installing adom-lbr",-        "run": "adom-wiki pkg install adom/adom-lbr",-        "skip_if": "command -v adom-lbr",+        "run": "sh -c 'PATH=\"$HOME/.local/bin:$PATH\"; adom-wiki pkg install adom/adom-lbr; command -v adom-lbr >/dev/null 2>&1 || { adom-wiki pkg uninstall adom/adom-lbr >/dev/null 2>&1; adom-wiki pkg install adom/adom-lbr; }'",+        "skip_if": "sh -c 'PATH=\"$HOME/.local/bin:$PATH\"; command -v adom-lbr >/dev/null 2>&1 && adom-lbr dashboard say >/dev/null 2>&1'",         "timeout_seconds": 180       },       {-        "label": "Starting Adom LBR",-        "run": "adom-lbr serve --print-url",+        "label": "Starting the adom-lbr Dashboard",+        "run": "sh -c 'PATH=\"$HOME/.local/bin:$PATH\"; exec adom-lbr dashboard serve --print-url'",         "await": "url",         "timeout_seconds": 60       }     ],-    "on_ready": "navigate_placeholder"+    "on_ready": "navigate_placeholder",+    "ai": {+      "prompt": "The user just opened the adom-lbr Dashboard from the dock. It lists their wiki boards and components and opens a symbol, footprint, 3D and layer-stackup viewer for any part; its header LEDs come from /api/status, and each LED carries a _hint naming the fix when it is not green. adom-lbr is the EDA library translator: KiCad to EAGLE/Fusion .lbr, KiCad to Altium, and back, through one canonical JSON. Offer to convert, validate, or add parts to their KiCad library; never install anything into their EDA tool without asking.",+      "thread": "existing",+      "thread_fallback": "new",+      "submit": "staged"+    }   } }
docs/dock-lbr.svgadded+17
@@ -0,0 +1,17 @@+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">+  <rect x="2" y="2" width="60" height="60" rx="14" fill="#0f1b1e"/>+  <rect x="2" y="2" width="60" height="60" rx="14" fill="none" stroke="#00b8b1" stroke-width="2.5"/>+  <!-- an open library book: the left page is a KiCad symbol pin row, the right an EAGLE pad grid -->+  <path d="M12 17.5A2.5 2.5 0 0 1 14.5 15H27a3 3 0 0 1 3 3v29a3 3 0 0 0-3-3H14.5a2.5 2.5 0 0 1-2.5-2.5z" fill="#00b8b1"/>+  <path d="M52 17.5A2.5 2.5 0 0 0 49.5 15H37a3 3 0 0 0-3 3v29a3 3 0 0 1 3-3h12.5a2.5 2.5 0 0 0 2.5-2.5z" fill="#8fd3d4"/>+  <g stroke="#0f1b1e" stroke-width="2" stroke-linecap="round">+    <path d="M17 23h8M17 29h8M17 35h8"/>+  </g>+  <g fill="#0f1b1e">+    <rect x="39" y="22" width="4" height="4" rx="1"/><rect x="45" y="22" width="4" height="4" rx="1"/>+    <rect x="39" y="29" width="4" height="4" rx="1"/><rect x="45" y="29" width="4" height="4" rx="1"/>+    <rect x="39" y="36" width="4" height="4" rx="1"/><rect x="45" y="36" width="4" height="4" rx="1"/>+  </g>+  <path d="M30 18v29M34 18v29" stroke="#0f1b1e" stroke-width="1.5"/>+  <path d="M12 47.5h40" stroke="#00b8b1" stroke-width="2.5" stroke-linecap="round"/>+</svg>
install.sh+6
@@ -15,6 +15,12 @@ if [ -d "$DIR/assets" ]; then   cp -R "$DIR"/assets/. "$ASSETS/"   echo "Installed viewer assets -> $ASSETS" fi+# The dock manifest rides along so the dashboard's "Say it" strip reads the+# same phrases the dock shows on the card and the interstitial.+if [ -f "$DIR/dockbar.json" ]; then+  mkdir -p "$ASSETS"+  cp "$DIR/dockbar.json" "$ASSETS/dockbar.json"+fi  # Bundle sub-skills live in skills/<name>/ and are delivered to the user's skills dir. SKILLS="${HOME}/.claude/skills"
package.json+3−2
@@ -1,6 +1,6 @@ {   "slug": "adom-lbr",-  "version": "2.10.2",+  "version": "2.11.0",   "type": "app",   "description": "adom-lbr \u2014 the EDA library translator. Bring a component in from any supported EDA tool and convert it to any other (KiCad \u21c4 Altium \u21c4 EAGLE/Fusion) through one canonical adom-lbr JSON \u2014 symbol + footprint + 3D + pin\u2194pad map. Altium is written natively (no Altium needed). Includes the adom-lbr Manager: browse wiki components and view symbol/footprint/3D + the per-EDA layer stackup.",   "dependencies": {},@@ -36,7 +36,8 @@     "README.md",     "install.sh",     "uninstall.sh",-    "screenshots/**"+    "screenshots/**",+    "dockbar.json"   ],   "discovery_triggers": [     "convert kicad to altium",
page.json+8−10
@@ -1,8 +1,8 @@ {   "slug": "adom-lbr",-  "version": "2.10.2",+  "version": "2.11.0",   "type": "app",-  "description": "adom-lbr — the EDA library translator. Bring a component in from any supported EDA tool and convert it to any other (KiCad ⇄ Altium ⇄ EAGLE/Fusion) through one canonical adom-lbr JSON — symbol + footprint + 3D + pin↔pad map. Altium is written natively (no Altium needed). Includes the adom-lbr Manager: browse wiki components and view symbol/footprint/3D + the per-EDA layer stackup.",+  "description": "adom-lbr \u2014 the EDA library translator. Bring a component in from any supported EDA tool and convert it to any other (KiCad \u21c4 Altium \u21c4 EAGLE/Fusion) through one canonical adom-lbr JSON \u2014 symbol + footprint + 3D + pin\u2194pad map. Altium is written natively (no Altium needed). Includes the adom-lbr Manager: browse wiki components and view symbol/footprint/3D + the per-EDA layer stackup.",   "dependencies": {},   "tags": [     "eda",@@ -52,23 +52,21 @@   ],   "files": [     "bin/**",-    "assets/**",     "SKILL.md",-    "skills/**",     "README.md",     "install.sh",     "uninstall.sh",     "screenshots/**"   ],-  "discovery_pitch": "Convert electronic-component symbols + footprints between any two EDA tools — KiCad, Altium (written natively, no Altium needed), and EAGLE/Fusion — through one canonical adom-lbr JSON, silk/courtyard/fab layers preserved. Plus a Manager to browse and inspect any wiki part.",+  "discovery_pitch": "Convert electronic-component symbols + footprints between any two EDA tools \u2014 KiCad, Altium (written natively, no Altium needed), and EAGLE/Fusion \u2014 through one canonical adom-lbr JSON, silk/courtyard/fab layers preserved. Plus a Manager to browse and inspect any wiki part.",   "sample_prompts": [     {       "prompt": "Convert this KiCad symbol and footprint to an Altium SchLib and PcbLib",-      "label": "KiCad → Altium"+      "label": "KiCad \u2192 Altium"     },     {       "prompt": "Import this Altium footprint into the adom-lbr JSON",-      "label": "Altium → JSON"+      "label": "Altium \u2192 JSON"     },     {       "prompt": "Bundle this part into a single Altium IntLib",@@ -76,15 +74,15 @@     },     {       "prompt": "Convert this Altium SchLib and PcbLib to a KiCad symbol and footprint",-      "label": "Altium → KiCad"+      "label": "Altium \u2192 KiCad"     },     {       "prompt": "Export this adom-lbr JSON to a KiCad .kicad_sym and .kicad_mod",-      "label": "JSON → KiCad"+      "label": "JSON \u2192 KiCad"     },     {       "prompt": "Open the adom-lbr Manager to browse wiki components and view the layer stackup",       "label": "Browse parts"     }   ]-}\ No newline at end of file+}
src/dock.rsadded+646
@@ -0,0 +1,646 @@+//! The Hydrogen Dock Bar contract for the adom-lbr dashboard (the Manager):+//! idempotent, version-aware, self-backgrounding `dashboard serve --print-url`;+//! a cached `/api/status` with per-facet LEDs and a top-level rollup; webview+//! ownership through ADOM_DOCK_WEBVIEW; the `say` phrases and the "Try it"+//! staging through the Hydrogen control API; settings; lifecycle jobs with a+//! live console; and taskbar progress for long work.+//!+//! Guide: https://wiki.adom.inc/adom/hydrogen/files/docs/dockbar.md++use serde_json::{json, Value};+use std::collections::HashMap;+use std::path::PathBuf;+use std::process::{Command, Stdio};+use std::sync::Mutex;+use std::time::{Duration, Instant};++pub const VERSION: &str = env!("CARGO_PKG_VERSION");+pub const APP: &str = "adom-lbr";+pub const PAGE: &str = "adom/adom-lbr";+const CONTROL_API: &str = "http://127.0.0.1:47084";++/// What `serve --print-url` prints: the DIRECT port, always. The dock's+/// workspace shares the port namespace (WSL2 mirrored networking), so the+/// `:7380/proxy/<port>/` hop is pure indirection whose prefix broke the dock's+/// health-URL join; and `127.0.0.1` rather than the name `localhost`, which+/// Windows resolves IPv6-first while WSL binds IPv4 (~820 ms vs 13 ms).+pub fn direct_url(port: u16) -> String {+    format!("http://127.0.0.1:{port}/")+}++// ---------------------------------------------------------------------------+// Settings: ~/.config/adom-lbr/settings.json, local truth, typed by default+// ---------------------------------------------------------------------------++fn config_dir() -> PathBuf {+    let base = std::env::var("XDG_CONFIG_HOME")+        .map(PathBuf::from)+        .unwrap_or_else(|_| home().join(".config"));+    base.join("adom-lbr")+}++pub fn home() -> PathBuf {+    std::env::var("HOME").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from("/tmp"))+}++/// Every setting, its default, and the label the UI shows for it. The defaults+/// ARE the type: a stored value of the wrong JSON type is dropped on read.+pub fn settings_defaults() -> Value {+    json!({+        "taskbar_progress": true,+        "taskbar_window_title": "Hydrogen",+        "owner_override": "",+        "list_cache_minutes": 30+    })+}++pub fn settings_path() -> PathBuf {+    config_dir().join("settings.json")+}++pub fn load_settings() -> Value {+    let mut out = settings_defaults();+    if let Ok(raw) = std::fs::read_to_string(settings_path()) {+        if let Ok(Value::Object(stored)) = serde_json::from_str::<Value>(&raw) {+            let defaults = settings_defaults();+            for (k, v) in stored {+                let same_type = match (defaults.get(&k), &v) {+                    (Some(Value::Bool(_)), Value::Bool(_)) => true,+                    (Some(Value::String(_)), Value::String(_)) => true,+                    (Some(Value::Number(_)), Value::Number(_)) => true,+                    _ => false,+                };+                if same_type {+                    out[k] = v;+                }+            }+        }+    }+    out+}++/// Merge a partial update over the stored settings. Unknown keys and values of+/// the wrong type are rejected, named in the reply, and never written.+pub fn save_settings(patch: &Value) -> Value {+    let defaults = settings_defaults();+    let mut current = load_settings();+    let mut rejected = vec![];+    if let Value::Object(m) = patch {+        for (k, v) in m {+            let ok = match (defaults.get(k), v) {+                (Some(Value::Bool(_)), Value::Bool(_)) => true,+                (Some(Value::String(_)), Value::String(_)) => true,+                (Some(Value::Number(_)), Value::Number(_)) => true,+                _ => false,+            };+            if ok {+                current[k] = v.clone();+            } else {+                rejected.push(k.clone());+            }+        }+    }+    let _ = std::fs::create_dir_all(config_dir());+    match std::fs::write(settings_path(), serde_json::to_string_pretty(&current).unwrap()) {+        Ok(()) => json!({"status":"ok","settings":current,"rejected":rejected,"path":settings_path()}),+        Err(e) => json!({"status":"error","error":format!("write {}: {e}", settings_path().display()),"settings":current}),+    }+}++pub fn reset_settings() -> Value {+    let _ = std::fs::remove_file(settings_path());+    json!({"status":"ok","settings":settings_defaults(),"path":settings_path()})+}++// ---------------------------------------------------------------------------+// say: the phrases users say to their AI, from dockbar.json+// ---------------------------------------------------------------------------++const SAY_FALLBACK: &[&str] = &[+    "open the lbr dashboard",+    "convert this kicad symbol to an eagle lbr",+    "validate my lbr for fusion",+    "make an altium library from this lbr",+    "show me this part in the lbr manager",+];++/// Where the installed dockbar.json lives: beside the binary's assets (install.sh+/// copies it there), the source tree when running from a checkout, else the+/// built-in list so the strip is never empty.+pub fn say_phrases() -> Vec<String> {+    let mut cands = vec![home().join(".local/share/adom-lbr/dockbar.json")];+    if let Ok(exe) = std::env::current_exe() {+        if let Some(dir) = exe.parent() {+            cands.push(dir.join("dockbar.json"));+            cands.push(dir.join("../dockbar.json"));+            cands.push(dir.join("../../dockbar.json"));+        }+    }+    for c in cands {+        if let Ok(raw) = std::fs::read_to_string(&c) {+            if let Ok(v) = serde_json::from_str::<Value>(&raw) {+                if let Some(arr) = v.get("say").and_then(|s| s.as_array()) {+                    let list: Vec<String> = arr.iter().filter_map(|s| s.as_str().map(String::from)).take(6).collect();+                    if !list.is_empty() {+                        return list;+                    }+                }+            }+        }+    }+    SAY_FALLBACK.iter().map(|s| s.to_string()).collect()+}++fn curl_json(url: &str, body: Option<&Value>, timeout_s: u32) -> Option<Value> {+    let mut cmd = Command::new("curl");+    cmd.args(["-s", "--max-time", &timeout_s.to_string()]);+    if let Some(b) = body {+        cmd.args(["-X", "POST", "-H", "Content-Type: application/json", "--data-binary", &b.to_string()]);+    }+    cmd.arg(url);+    let out = cmd.output().ok()?;+    if !out.status.success() || out.stdout.is_empty() {+        return None;+    }+    serde_json::from_slice(&out.stdout).ok()+}++/// "Try it": stage the phrase, unsubmitted, into this app's own AI thread via+/// the Hydrogen control API. Census first (open threads), history second+/// (closed ones, resumed first), else tell the page to copy. Reachable from a+/// WSL2 workspace under mirrored networking; from a cloud container it is not,+/// and copying is the right fallback there.+pub fn try_phrase(phrase: &str) -> Value {+    let tag = format!("app:{PAGE}");+    let Some(census) = curl_json(&format!("{CONTROL_API}/ai-threads"), None, 2) else {+        return json!({"staged":false,"reason":"hydrogen control api unreachable (copied instead)"});+    };+    for g in census.get("groups").and_then(|g| g.as_array()).cloned().unwrap_or_default() {+        for t in g.get("threads").and_then(|t| t.as_array()).cloned().unwrap_or_default() {+            let tags = t.get("tags").and_then(|x| x.as_array()).cloned().unwrap_or_default();+            if !tags.iter().any(|x| x.as_str() == Some(tag.as_str())) {+                continue;+            }+            let mut body = json!({"prompt": phrase, "submit": false});+            match t.get("sid").and_then(|s| s.as_str()) {+                Some(sid) => body["sid"] = json!(sid),+                None => body["tid"] = t.get("tid").cloned().unwrap_or(Value::Null),+            }+            return match curl_json(&format!("{CONTROL_API}/ai-threads/run-prompt"), Some(&body), 4) {+                Some(r) if r.get("ok").and_then(|o| o.as_bool()) != Some(false) => json!({"staged":true,"where":"open thread"}),+                _ => json!({"staged":false,"reason":"run-prompt failed (copied instead)"}),+            };+        }+    }+    let hist = curl_json(&format!("{CONTROL_API}/ai-threads/history?tag={tag}"), None, 2);+    if let Some(item) = hist.and_then(|h| h.get("items").and_then(|i| i.as_array()).and_then(|a| a.first().cloned())) {+        let ai = item.get("ai").and_then(|a| a.as_str()).unwrap_or("claude").to_string();+        let id = item.get("id").cloned().unwrap_or(Value::Null);+        let resumed = curl_json(&format!("{CONTROL_API}/ai-threads/resume"), Some(&json!({"ai":ai,"id":id})), 4);+        if resumed.is_some() {+            let body = json!({"sid": id, "prompt": phrase, "submit": false});+            if curl_json(&format!("{CONTROL_API}/ai-threads/run-prompt"), Some(&body), 4).is_some() {+                return json!({"staged":true,"where":"resumed thread"});+            }+        }+        return json!({"staged":false,"reason":"could not resume this app's thread (copied instead)"});+    }+    json!({"staged":false,"reason":"no AI thread for this app yet: copy the phrase into any thread"})+}++// ---------------------------------------------------------------------------+// Status: per-facet LEDs + a rollup the app owns. Cached, answers in ms.+// ---------------------------------------------------------------------------++static STATUS_CACHE: Mutex<Option<(Instant, Value)>> = Mutex::new(None);+static STARTED: Mutex<Option<Instant>> = Mutex::new(None);++pub fn mark_started() {+    *STARTED.lock().unwrap() = Some(Instant::now());+}++fn led(label: &str, state: &str, detail: String, hint: Option<&str>) -> Value {+    let mut v = json!({"label": label, "state": state, "detail": detail});+    if let Some(h) = hint {+        v["_hint"] = json!(h);+    }+    v+}++pub fn assets_dir() -> Option<PathBuf> {+    let mut cands = vec![];+    if let Ok(p) = std::env::var("ADOM_LBR_ASSETS") {+        cands.push(PathBuf::from(p));+    }+    if let Ok(exe) = std::env::current_exe() {+        if let Some(d) = exe.parent() {+            cands.push(d.join("assets"));+            cands.push(d.join("../share/adom-lbr"));+            cands.push(d.join("../assets"));+        }+    }+    cands.push(home().join(".local/share/adom-lbr"));+    cands.into_iter().find(|d| d.join("adom-3d-viewer.min.js").is_file())+}++/// The facets, measured fresh. `library_count` is the Manager's cached wiki+/// list size (None = not loaded yet); `owner` its resolved identity.+fn build_status(library_count: Option<usize>, owner: &str) -> Value {+    let mut leds: Vec<Value> = vec![];+    leds.push(led("Binary", "ok",+        format!("adom-lbr {VERSION} at {}", std::env::current_exe().map(|p| p.display().to_string()).unwrap_or_default()), None));++    let wiki_ok = Command::new("curl")+        .args(["-s", "-o", "/dev/null", "-w", "%{http_code}", "--max-time", "3", "https://wiki.adom.inc/health"])+        .output()+        .ok()+        .map(|o| String::from_utf8_lossy(&o.stdout).starts_with('2'))+        .unwrap_or(false);+    leds.push(if wiki_ok {+        led("Wiki", "ok", "wiki.adom.inc answers; components and boards load from it".into(), None)+    } else {+        led("Wiki", "fail", "wiki.adom.inc is not answering, so no components can load".into(),+            Some("check the network, then `adom-wiki doctor`"))+    });++    leds.push(if owner.is_empty() {+        led("Identity", "warn", "not signed in to the wiki, so My projects cannot be told apart from public ones".into(),+            Some("run `adom-wiki auth` in the container, or set owner_override in Settings"))+    } else {+        led("Identity", "ok", format!("My projects = boards by {owner}"), None)+    });++    leds.push(match library_count {+        Some(n) => led("Library", "ok", format!("{n} wiki components cached"), None),+        None => led("Library", "ok", "component list warming up from the wiki; the first open fetches it".into(), None),+    });++    leds.push(match assets_dir() {+        Some(d) => led("3D viewer", "ok", format!("viewer assets at {}", d.display()), None),+        None => led("3D viewer", "warn", "viewer assets missing: the 3D pane of exported viewers stays empty".into(),+            Some("reinstall: `adom-wiki pkg install adom/adom-lbr`")),+    });++    let dock = std::env::var("ADOM_DOCK_WEBVIEW").unwrap_or_default();+    leds.push(if dock.is_empty() {+        led("Dock", "ok", "started by hand (no dock webview to own)".into(), None)+    } else {+        led("Dock", "ok", format!("owns the dock webview tab '{dock}'"), None)+    });++    let worst = |s: &str| leds.iter().any(|l| l.get("state").and_then(|x| x.as_str()) == Some(s));+    let state = if worst("fail") { "fail" } else if worst("warn") { "warn" } else { "ok" };+    let uptime = STARTED.lock().unwrap().map(|t| t.elapsed().as_secs()).unwrap_or(0);+    json!({+        "ok": true,+        "app": APP,+        "page": PAGE,+        "version": VERSION,+        "state": state,+        "leds": leds,+        "uptime_s": uptime,+        "settings": load_settings(),+        "_hint": "state is the app's own rollup; each LED carries a _hint naming the fix when it is not green"+    })+}++/// Cached for 10 s so the dock's 15 s poll and the page's own refresh both+/// answer in milliseconds; `force` re-measures.+pub fn cached_status(library_count: Option<usize>, owner: &str, force: bool) -> Value {+    let mut c = STATUS_CACHE.lock().unwrap();+    if !force {+        if let Some((t, v)) = c.as_ref() {+            if t.elapsed() < Duration::from_secs(10) {+                return v.clone();+            }+        }+    }+    let v = build_status(library_count, owner);+    *c = Some((Instant::now(), v.clone()));+    v+}++// ---------------------------------------------------------------------------+// Taskbar progress: paint the Hydrogen window's Windows taskbar button while+// the dashboard works, sized by the durations this app has measured.+// ---------------------------------------------------------------------------++fn durations_path() -> PathBuf {+    config_dir().join("durations.json")+}++fn load_durations() -> HashMap<String, (f64, u64)> {+    let mut out = HashMap::new();+    if let Ok(raw) = std::fs::read_to_string(durations_path()) {+        if let Ok(Value::Object(m)) = serde_json::from_str::<Value>(&raw) {+            for (k, v) in m {+                let avg = v.get("avg_ms").and_then(|a| a.as_f64()).unwrap_or(0.0);+                let n = v.get("n").and_then(|a| a.as_u64()).unwrap_or(0);+                out.insert(k, (avg, n));+            }+        }+    }+    out+}++fn record_duration(op: &str, ms: f64) {+    let mut d = load_durations();+    let e = d.entry(op.to_string()).or_insert((0.0, 0));+    e.0 = if e.1 == 0 { ms } else { (e.0 * e.1 as f64 + ms) / (e.1 as f64 + 1.0) };+    e.1 += 1;+    let _ = std::fs::create_dir_all(config_dir());+    let m: serde_json::Map<String, Value> = d.into_iter().map(|(k, (a, n))| (k, json!({"avg_ms": a, "n": n}))).collect();+    let _ = std::fs::write(durations_path(), serde_json::to_string_pretty(&Value::Object(m)).unwrap());+}++pub fn durations() -> Value {+    let m: serde_json::Map<String, Value> = load_durations().into_iter()+        .map(|(k, (a, n))| (k, json!({"avg_ms": a, "n": n}))).collect();+    Value::Object(m)+}++/// Paint the taskbar button of the Hydrogen window. Best-effort and fire-and-+/// forget: no bridge, no window, or the toggle off all mean nothing happens.+fn paint_taskbar(progress: Value) {+    let s = load_settings();+    if s.get("taskbar_progress").and_then(|b| b.as_bool()) != Some(true) {+        return;+    }+    let title = s.get("taskbar_window_title").and_then(|t| t.as_str()).unwrap_or("Hydrogen").to_string();+    std::thread::spawn(move || {+        let args = json!({"titleContains": title, "progress": progress}).to_string();+        let _ = Command::new("adom-bridge")+            .args(["--ai-thread", "adom-lbr dashboard", "desktop_taskbar", &args])+            .stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null())+            .spawn()+            .and_then(|mut c| c.wait());+    });+}++/// A long operation with a taskbar bar. Determinate when this operation has a+/// measured average (the bar fills over that estimate, capped at 95% until the+/// real end), indeterminate the first time or for open-ended work. `step()`+/// overrides the estimate with real progress when the caller knows it.+pub struct TaskbarJob {+    op: String,+    started: Instant,+    stop: std::sync::Arc<std::sync::atomic::AtomicBool>,+}++impl TaskbarJob {+    pub fn start(op: &str, open_ended: bool) -> TaskbarJob {+        let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));+        let avg = load_durations().get(op).map(|d| d.0).unwrap_or(0.0);+        if open_ended || avg <= 0.0 {+            paint_taskbar(json!({"state":"indeterminate"}));+        } else {+            let stop2 = stop.clone();+            let t0 = Instant::now();+            std::thread::spawn(move || {+                while !stop2.load(std::sync::atomic::Ordering::Relaxed) {+                    let pct = ((t0.elapsed().as_millis() as f64 / avg) * 100.0).min(95.0) as u64;+                    paint_taskbar(json!({"state":"normal","value":pct}));+                    std::thread::sleep(Duration::from_millis(1000));+                }+            });+        }+        TaskbarJob { op: op.to_string(), started: Instant::now(), stop }+    }++    pub fn step(&self, current: usize, total: usize) {+        if total > 0 {+            let pct = (current * 100 / total).min(99) as u64;+            paint_taskbar(json!({"state":"normal","value":pct}));+        }+    }++    pub fn finish(self, ok: bool) {+        self.stop.store(true, std::sync::atomic::Ordering::Relaxed);+        record_duration(&self.op, self.started.elapsed().as_millis() as f64);+        if ok {+            paint_taskbar(json!({"state":"normal","value":100}));+            std::thread::sleep(Duration::from_millis(400));+            paint_taskbar(json!({"state":"none"}));+        } else {+            paint_taskbar(json!({"state":"error","value":100}));+        }+    }+}++// ---------------------------------------------------------------------------+// Lifecycle jobs with a live console: install / update / uninstall+// ---------------------------------------------------------------------------++#[derive(Default)]+pub struct Job {+    pub lines: Vec<String>,+    pub done: bool,+    pub exit: Option<i32>,+}++static JOBS: Mutex<Option<HashMap<String, Job>>> = Mutex::new(None);++fn with_jobs<T>(f: impl FnOnce(&mut HashMap<String, Job>) -> T) -> T {+    let mut g = JOBS.lock().unwrap();+    let m = g.get_or_insert_with(HashMap::new);+    f(m)+}++/// Run a shell command as a named job, streaming its combined output into the+/// console that `/api/job/<name>` serves. One instance of a job at a time.+pub fn start_job(name: &str, shell: &str) -> Value {+    let running = with_jobs(|m| m.get(name).map(|j| !j.done).unwrap_or(false));+    if running {+        return json!({"status":"error","error":format!("{name} is already running"),"hint":"poll /api/job/{name}"});+    }+    with_jobs(|m| { m.insert(name.to_string(), Job { lines: vec![format!("$ {shell}")], done: false, exit: None }); });+    let name_s = name.to_string();+    let shell_s = shell.to_string();+    std::thread::spawn(move || {+        let bar = TaskbarJob::start(&format!("job:{name_s}"), false);+        let child = Command::new("sh")+            .args(["-c", &format!("PATH=\"$HOME/.local/bin:$PATH\"; {shell_s} 2>&1")])+            .stdin(Stdio::null())+            .stdout(Stdio::piped())+            .stderr(Stdio::null())+            .spawn();+        let mut child = match child {+            Ok(c) => c,+            Err(e) => {+                with_jobs(|m| if let Some(j) = m.get_mut(&name_s) { j.lines.push(format!("failed to start: {e}")); j.done = true; j.exit = Some(-1); });+                bar.finish(false);+                return;+            }+        };+        if let Some(out) = child.stdout.take() {+            use std::io::BufRead;+            for line in std::io::BufReader::new(out).lines().map_while(Result::ok) {+                with_jobs(|m| if let Some(j) = m.get_mut(&name_s) { j.lines.push(line); });+            }+        }+        let code = child.wait().ok().and_then(|s| s.code()).unwrap_or(-1);+        // The verify line: prove the result rather than trust the exit code.+        let verify = Command::new("sh").args(["-c", "PATH=\"$HOME/.local/bin:$PATH\"; command -v adom-lbr >/dev/null 2>&1 && adom-lbr --version || echo 'adom-lbr: not on PATH'"]).output()+            .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()).unwrap_or_default();+        with_jobs(|m| if let Some(j) = m.get_mut(&name_s) {+            j.lines.push(format!("exit {code}"));+            j.lines.push(format!("verify: {verify}"));+            j.done = true;+            j.exit = Some(code);+        });+        bar.finish(code == 0);+        *STATUS_CACHE.lock().unwrap() = None;+    });+    json!({"status":"ok","job":name,"hint":format!("poll /api/job/{name} for lines, done, exit")})+}++pub fn job_state(name: &str) -> Value {+    with_jobs(|m| match m.get(name) {+        Some(j) => json!({"status":"ok","job":name,"lines":j.lines,"done":j.done,"exit":j.exit}),+        None => json!({"status":"ok","job":name,"lines":[],"done":true,"exit":null,"hint":"never run this session"}),+    })+}++pub fn lifecycle_command(action: &str) -> Option<&'static str> {+    match action {+        "install" | "update" => Some("adom-wiki pkg install adom/adom-lbr"),+        "uninstall" => Some("adom-wiki pkg uninstall adom/adom-lbr"),+        _ => None,+    }+}++// ---------------------------------------------------------------------------+// serve: idempotent, version-aware, self-backgrounding+// ---------------------------------------------------------------------------++/// Version of OUR dashboard on the port, or None when nothing answers or+/// something else owns it (another service is not a reason to claim success).+pub fn running_version(port: u16) -> Option<String> {+    let v = curl_json(&format!("http://127.0.0.1:{port}/api/status"), None, 2)?;+    if v.get("ok").and_then(|o| o.as_bool()) == Some(true) && v.get("app").and_then(|a| a.as_str()) == Some(APP) {+        return Some(v.get("version").and_then(|s| s.as_str()).unwrap_or("?").to_string());+    }+    None+}++/// Our own serve processes, matched by EXACT argv shape (binary named adom-lbr,+/// then `dashboard serve` or `manage`), never by substring: a substring match+/// also hits the shell whose command text mentions this program.+fn serve_pids() -> Vec<i32> {+    let me = std::process::id() as i32;+    let mut out = vec![];+    let Ok(rd) = std::fs::read_dir("/proc") else { return out };+    for ent in rd.flatten() {+        let Ok(pid) = ent.file_name().to_string_lossy().parse::<i32>() else { continue };+        if pid == me { continue; }+        let Ok(raw) = std::fs::read(ent.path().join("cmdline")) else { continue };+        let argv: Vec<&[u8]> = raw.split(|b| *b == 0).filter(|s| !s.is_empty()).collect();+        if argv.len() < 2 { continue; }+        let base = argv[0].rsplit(|b| *b == b'/').next().unwrap_or(argv[0]);+        if base != b"adom-lbr" { continue; }+        let is_serve = argv[1] == b"manage" || (argv.len() >= 3 && argv[1] == b"dashboard" && argv[2] == b"serve");+        if is_serve { out.push(pid); }+    }+    out+}++fn kill(pid: i32, sig: &str) {+    let _ = Command::new("kill").args([sig, &pid.to_string()]).stdout(Stdio::null()).stderr(Stdio::null()).status();+}++/// A healthy instance of a DIFFERENT version is the thing to replace, not a+/// reason to skip serving: version-blind reuse pinned users to a stale server+/// no reinstall could fix. Returns true when the port is free afterwards.+fn retire_stale(port: u16, theirs: &str) -> bool {+    eprintln!("adom-lbr dashboard {theirs} is serving on {port} but this is {VERSION}; retiring the stale instance");+    let victims = serve_pids();+    for pid in &victims { kill(*pid, "-TERM"); }+    for _ in 0..40 {+        if !port_in_use(port) { return true; }+        std::thread::sleep(Duration::from_millis(100));+    }+    for pid in &victims { kill(*pid, "-KILL"); }+    std::thread::sleep(Duration::from_millis(500));+    !port_in_use(port)+}++/// Does anything accept a TCP connection on the port? curl exit 7 is+/// "could not connect"; any other outcome means someone is listening.+fn port_in_use(port: u16) -> bool {+    Command::new("curl")+        .args(["-s", "-o", "/dev/null", "--max-time", "1", &format!("http://127.0.0.1:{port}/")])+        .stdout(Stdio::null()).stderr(Stdio::null())+        .status()+        .map(|s| s.code() != Some(7))+        .unwrap_or(false)+}++/// Take ownership of the dock's pre-opened webview tab. The dock sets+/// ADOM_DOCK_WEBVIEW to the tab name; open-or-refresh with the same --name is+/// idempotent, which is the ownership handle. Unset env or a failed call are+/// silent no-ops (the dock's own interstitial navigation is the fallback).+pub fn claim_dock_webview(port: u16) {+    let name = std::env::var("ADOM_DOCK_WEBVIEW").unwrap_or_default();+    if name.trim().is_empty() {+        return;+    }+    let url = direct_url(port);+    let _ = Command::new("adom-cli")+        .args(["hydrogen", "webview", "open-or-refresh", "--name", &name, "--url", &url])+        .stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null())+        .status();+}++/// The dock entry point. Returns Ok(true) when a server is (now) serving and+/// the caller should exit 0; the foreground serve itself is the caller's job+/// when this returns Ok(false).+pub fn serve_entry(port: u16, print_url: bool, foreground: bool) -> anyhow::Result<bool> {+    let mut theirs = running_version(port);+    // A pre-2.11 Manager has no /api/status, so it cannot identify itself; but+    // it is still one of our serve processes by argv, and it must make way.+    if theirs.is_none() && port_in_use(port) && !serve_pids().is_empty() {+        if !retire_stale(port, "pre-2.11") {+            anyhow::bail!("an older adom-lbr Manager owns port {port} and would not exit; kill it and relaunch");+        }+    }+    if let Some(t) = theirs.clone() {+        if t != VERSION {+            if !retire_stale(port, &t) {+                anyhow::bail!("a stale adom-lbr {t} instance owns port {port} and would not exit; kill it and relaunch");+            }+            theirs = None;+        }+    }+    if theirs.is_some() {+        if print_url { println!("{}", direct_url(port)); }+        else { eprintln!("adom-lbr dashboard {VERSION} already serving on {port}; nothing to do"); }+        claim_dock_webview(port);+        return Ok(true);+    }+    if foreground {+        return Ok(false);+    }+    // Self-background: the dock runs steps synchronously and waits for exit, so+    // a foreground server is killed at the timeout. Re-exec ourselves detached+    // in a new session (the exec's tree kill cannot reach it), wait until the+    // child answers on the port, then print the URL and exit 0.+    let exe = std::env::current_exe()?;+    let mut cmd = Command::new(exe);+    cmd.args(["dashboard", "serve", "--port", &port.to_string(), "--foreground"])+        .stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null());+    #[cfg(unix)]+    {+        use std::os::unix::process::CommandExt;+        cmd.process_group(0);+    }+    cmd.spawn()?;+    for _ in 0..100 {+        if running_version(port).is_some() {+            if print_url { println!("{}", direct_url(port)); }+            else { eprintln!("adom-lbr dashboard {VERSION} serving on {}", direct_url(port)); }+            return Ok(true);+        }+        std::thread::sleep(Duration::from_millis(100));+    }+    anyhow::bail!("adom-lbr dashboard did not answer on port {port} within 10 s")+}
src/favicon.svgadded+17
@@ -0,0 +1,17 @@+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">+  <rect x="2" y="2" width="60" height="60" rx="14" fill="#0f1b1e"/>+  <rect x="2" y="2" width="60" height="60" rx="14" fill="none" stroke="#00b8b1" stroke-width="2.5"/>+  <!-- an open library book: the left page is a KiCad symbol pin row, the right an EAGLE pad grid -->+  <path d="M12 17.5A2.5 2.5 0 0 1 14.5 15H27a3 3 0 0 1 3 3v29a3 3 0 0 0-3-3H14.5a2.5 2.5 0 0 1-2.5-2.5z" fill="#00b8b1"/>+  <path d="M52 17.5A2.5 2.5 0 0 0 49.5 15H37a3 3 0 0 0-3 3v29a3 3 0 0 1 3-3h12.5a2.5 2.5 0 0 0 2.5-2.5z" fill="#8fd3d4"/>+  <g stroke="#0f1b1e" stroke-width="2" stroke-linecap="round">+    <path d="M17 23h8M17 29h8M17 35h8"/>+  </g>+  <g fill="#0f1b1e">+    <rect x="39" y="22" width="4" height="4" rx="1"/><rect x="45" y="22" width="4" height="4" rx="1"/>+    <rect x="39" y="29" width="4" height="4" rx="1"/><rect x="45" y="29" width="4" height="4" rx="1"/>+    <rect x="39" y="36" width="4" height="4" rx="1"/><rect x="45" y="36" width="4" height="4" rx="1"/>+  </g>+  <path d="M30 18v29M34 18v29" stroke="#0f1b1e" stroke-width="1.5"/>+  <path d="M12 47.5h40" stroke="#00b8b1" stroke-width="2.5" stroke-linecap="round"/>+</svg>
src/main.rs+65
@@ -1,6 +1,7 @@ mod deliver; mod server; mod manage;+mod dock;  use clap::{Parser, Subcommand}; use serde::{Deserialize, Serialize};@@ -19,6 +20,30 @@ struct Cli {     command: Commands, } +#[derive(Subcommand)]+enum DashboardCmd {+    /// Start the dashboard (or reuse a running one of this version) and print+    /// its DIRECT URL, http://127.0.0.1:<port>/.+    Serve {+        /// Port to listen on+        #[arg(long, default_value_t = 8785)]+        port: u16,+        /// Print the dashboard URL on stdout (the dock scrapes it), self-background, exit 0+        #[arg(long)]+        print_url: bool,+        /// Stay in the foreground (what the backgrounded child runs)+        #[arg(long)]+        foreground: bool,+    },+    /// Print the live /api/status of the running dashboard+    Status {+        #[arg(long, default_value_t = 8785)]+        port: u16,+    },+    /// The `say` phrases the dashboard teaches (from dockbar.json)+    Say,+}+ #[derive(Subcommand)] enum Commands {     /// Generate a .lbr from a KiCad .kicad_sym (and optional .kicad_mod).@@ -162,11 +187,19 @@ enum Commands {     },     /// adom-lbr Manager: browse wiki components, view symbol/footprint/3D + the     /// footprint layer stackup (KiCad + Altium). Renders SVG on the fly per click.+    /// Foreground alias of `dashboard serve --foreground`.     Manage {         /// Port to listen on         #[arg(long, default_value_t = 8785)]         port: u16,     },+    /// The adom-lbr Dashboard (the Manager plus live status, settings and the+    /// Hydrogen Dock Bar contract). `dashboard serve --print-url` is what the+    /// dock runs: idempotent, version-aware, self-backgrounding.+    Dashboard {+        #[command(subcommand)]+        cmd: DashboardCmd,+    },     /// Import a KiCad symbol + footprint into the canonical adom-lbr JSON (the hub).     ImportKicad {         /// Path to the .kicad_sym file@@ -2009,6 +2042,38 @@ fn main() {                 process::exit(1);             }         }+        Commands::Dashboard { cmd } => match cmd {+            DashboardCmd::Serve { port, print_url, foreground } => {+                match dock::serve_entry(port, print_url, foreground) {+                    Ok(true) => {}+                    Ok(false) => {+                        if let Err(e) = manage::run(port) {+                            // Lost a bind race? If it is us after all, this launch still succeeded.+                            if dock::running_version(port).is_some() {+                                if print_url { println!("{}", dock::direct_url(port)); }+                                return;+                            }+                            eprintln!("ERROR: adom-lbr dashboard cannot serve on port {port}: {e}. Something else owns it; pick another with --port.");+                            process::exit(2);+                        }+                    }+                    Err(e) => {+                        eprintln!("ERROR: {e}");+                        process::exit(2);+                    }+                }+            }+            DashboardCmd::Status { port } => {+                let out = process::Command::new("curl").args(["-s", "--max-time", "3", &format!("http://127.0.0.1:{port}/api/status")]).output();+                match out {+                    Ok(o) if o.status.success() && !o.stdout.is_empty() => println!("{}", String::from_utf8_lossy(&o.stdout)),+                    _ => { eprintln!("ERROR: no adom-lbr dashboard answering on port {port}"); eprintln!("Hint: adom-lbr dashboard serve --print-url"); process::exit(2); }+                }+            }+            DashboardCmd::Say => {+                for p in dock::say_phrases() { println!("{p}"); }+            }+        },         Commands::Serve { port, file } => {             if let Some(f) = file {                 match fs::read_to_string(&f) {
src/manage.html+300−5
@@ -3,12 +3,105 @@ <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1">-<title>adom-lbr Manager</title>+<title>adom-lbr Dashboard</title>+<link rel="icon" href="favicon.svg" type="image/svg+xml"> <script type="module" src="https://unpkg.com/@google/[email protected]/dist/model-viewer.min.js"></script> <style>-  :root{--bg:#0f1115;--panel:#171a21;--edge:#252a34;--ink:#e6e9ef;--mut:#8b93a3;--acc:#6ea8fe}+  :root{--bg:#0f1115;--panel:#171a21;--edge:#252a34;--ink:#e6e9ef;--mut:#8b93a3;--acc:#00b8b1;+        --surface:#161b22;--elevated:#1c2128;--overlay:#21262d;--border:#30363d;--text:#e6edf3;--text-body:#c9d1d9;--text-2:#8b949e;--accent:#00b8b1;--on-accent:#05221f;--red:#f85149;--yellow:#d29922;--green:#3fb950;+        --brand-heading:"Familjen Grotesk",-apple-system,Segoe UI,Roboto,sans-serif;--brand-body:Satoshi,-apple-system,Segoe UI,Roboto,sans-serif}   *{box-sizing:border-box}-  body{margin:0;font:14px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;background:var(--bg);color:var(--ink);height:100vh;display:flex}+  body{margin:0;font:14px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;background:var(--bg);color:var(--ink);height:100vh;display:flex;flex-direction:column}+  #panes{display:flex;flex:1;min-height:0}+  /* ---- the 44px app header: name left, live LEDs + version + the cog right ---- */+  #hdr{height:44px;min-height:44px;display:flex;align-items:center;gap:12px;padding:0 10px 0 14px;background:var(--panel);border-bottom:1px solid var(--edge)}+  #hdr .brand{display:flex;align-items:center;gap:9px;font-family:var(--brand-heading);font-weight:700;font-size:15px;letter-spacing:.01em}+  #hdr .brand img{width:22px;height:22px;display:block}+  #hdr .brand small{color:var(--mut);font-weight:400;font-size:12px}+  #hdr .leds{display:flex;align-items:center;gap:10px;margin-left:auto;font-size:11px;color:var(--mut)}+  .led{display:inline-flex;align-items:center;gap:5px;cursor:default}+  .led i{width:8px;height:8px;border-radius:50%;background:#555;display:inline-block;box-shadow:0 0 0 2px #0003}+  .led.ok i{background:var(--green);box-shadow:0 0 6px var(--green)}+  .led.warn i{background:var(--yellow);box-shadow:0 0 6px var(--yellow)}+  .led.fail i{background:var(--red);box-shadow:0 0 6px var(--red)}+  #hdr .ver{font:11px ui-monospace,monospace;color:var(--mut);background:#0d0f14;border:1px solid var(--edge);border-radius:6px;padding:1px 6px}+  .iconbtn{background:none;border:1px solid transparent;color:var(--ink);width:30px;height:30px;border-radius:8px;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;transition:background .12s,border-color .12s,transform .06s}+  .iconbtn:hover{background:#1e222b;border-color:var(--edge);color:var(--acc)}+  .iconbtn:active{transform:translateY(1px) scale(.96)}+  .iconbtn:focus-visible{outline:2px solid var(--acc);outline-offset:2px}+  /* ---- "Say it": the first thing on the page. Users ask their AI, using the noun lbr. ---- */+  #say{background:linear-gradient(90deg,#121a1e,var(--panel));border-bottom:1px solid var(--edge);padding:8px 14px;display:flex;align-items:center;gap:10px;flex-wrap:wrap}+  #say .say-title{font-family:var(--brand-heading);font-weight:700;font-size:13px;white-space:nowrap}+  #say .say-sub{font-size:11.5px;color:var(--mut);flex-basis:100%;margin-top:-4px}+  #say .say-sub b{color:var(--acc)}+  .phrase{display:inline-flex;align-items:stretch;border:1px solid var(--edge);border-radius:8px;overflow:hidden;background:#0d0f14;font-size:12.5px}+  .phrase .txt{padding:5px 10px;cursor:copy;color:var(--ink);transition:background .12s}+  .phrase .txt:hover{background:#1e222b;color:var(--acc)}+  .phrase .try{padding:5px 9px;border-left:1px solid var(--edge);color:var(--acc);cursor:pointer;font-weight:600;background:transparent;border-top:none;border-right:none;border-bottom:none;font-size:11px}+  .phrase .try:hover{background:#12302f}+  .phrase .txt:active,.phrase .try:active{transform:translateY(1px)}+  #toast{position:fixed;left:50%;bottom:22px;transform:translateX(-50%) translateY(20px);background:#0d1117;border:1px solid var(--acc);color:var(--ink);padding:8px 14px;border-radius:9px;font-size:13px;opacity:0;transition:opacity .18s,transform .18s;pointer-events:none;z-index:2000;max-width:80vw}+  #toast.show{opacity:1;transform:translateX(-50%) translateY(0)}+  #toast.err{border-color:var(--red)}+  /* ---- Settings modal: the Adom settings surface (settings-layout skill) ---- */+  dialog.settings{width:min(680px,92vw);max-width:none;padding:0;overflow:hidden;border-radius:12px;background:var(--surface);border:1px solid var(--border);color:var(--text-body);box-shadow:0 20px 60px #000a}+  dialog.settings::backdrop{background:rgba(0,0,0,.25)}+  dialog.settings:focus{outline:none}+  .settings-head{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 14px 12px 18px;border-bottom:1px solid var(--border);cursor:move;user-select:none}+  .settings-head h2{margin:0;font:600 15px/1.3 var(--brand-heading);color:var(--text);display:flex;align-items:center;gap:10px}+  .settings-body{max-height:70vh;overflow:auto;padding:18px;scrollbar-width:thin;scrollbar-color:rgba(139,148,158,.35) transparent}+  .settings-body::-webkit-scrollbar{width:8px}+  .settings-body::-webkit-scrollbar-track{background:transparent;margin:12px 0}+  .settings-body::-webkit-scrollbar-thumb{background:rgba(139,148,158,.35);border-radius:999px;border:2px solid transparent;background-clip:padding-box}+  .settings-body::-webkit-scrollbar-thumb:hover{background:var(--accent);background-clip:padding-box}+  .settings-card{background:var(--elevated);border:1px solid var(--border);border-left:3px solid color-mix(in srgb,var(--accent) 45%,transparent);border-radius:10px;padding:.9rem 1.1rem 1rem;margin-bottom:1rem}+  .card-title{font-family:var(--brand-heading);font-size:13px;font-weight:600;display:flex;align-items:center;gap:10px;color:var(--text);margin:0 0 .35rem}+  .card-status{font-family:var(--brand-body);font-size:11px;color:var(--text-2);font-weight:400}+  .card-status.ok{color:var(--green)}+  .card-status.warn{color:var(--yellow)}+  .card-desc{font-family:var(--brand-body);font-size:12px;line-height:1.55;color:var(--text-2);margin:0 0 .6rem;max-width:60ch}+  .card-desc code{font-size:11px;background:rgba(110,118,129,.2);padding:1px 4px;border-radius:4px}+  .divider{height:1px;background:var(--border);margin:.55rem 0}+  .setting-row{display:flex;align-items:center;gap:10px;padding:.35rem 0}+  .setting-row .row-main{display:flex;flex-direction:column;gap:2px;flex:1;min-width:0}+  .row-name{font-family:var(--brand-heading);font-size:.9375rem;font-weight:600;color:var(--text)}+  .row-name .title{color:#8fd3d4}+  .default-hint{font-family:var(--brand-body);font-size:.75rem;color:var(--text-2)}+  .setting-children{margin-left:.75rem;padding-left:1rem;border-left:2px solid var(--border)}+  .setting-children[data-enabled='false']{opacity:.55}+  .setting-children[data-enabled='false'] .row-name{color:var(--text-2)}+  .setting-children .why{font-family:var(--brand-body);font-size:.72rem;color:var(--text-2);margin:.1rem 0 .2rem}+  .toggle{background:none;border:none;padding:2px;cursor:pointer;flex-shrink:0;transition:transform .06s ease}+  .toggle-track{display:block;width:32px;height:18px;background:var(--overlay);border:1px solid var(--border);border-radius:9px;position:relative;transition:background .15s ease,border-color .15s ease}+  .toggle:hover .toggle-track{border-color:var(--accent)}+  .toggle:active{transform:translateY(1px) scale(.97)}+  .toggle.active .toggle-track{background:var(--accent);border-color:var(--accent)}+  .toggle-thumb{position:absolute;top:2px;left:2px;width:12px;height:12px;background:#fff;border-radius:50%;transition:transform .18s ease,width .12s ease}+  .toggle.active .toggle-thumb{transform:translateX(14px)}+  .toggle:active .toggle-thumb{width:16px}+  .toggle.active:active .toggle-thumb{transform:translateX(10px)}+  .toggle:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:10px}+  .toggle:disabled{cursor:not-allowed}+  .set-input{background:var(--surface);border:1px solid var(--border);color:var(--text);border-radius:7px;padding:6px 9px;font-size:13px;min-width:160px}+  .set-input:focus{outline:none;border-color:var(--accent)}+  .set-input:disabled{opacity:.6;cursor:not-allowed}+  .act-btn{font-family:var(--brand-heading);font-size:.8125rem;font-weight:600;background:var(--accent);color:var(--on-accent);border:1px solid var(--accent);border-radius:.375rem;padding:.35rem .8rem;cursor:pointer;transition:filter .12s,transform .06s}+  .act-btn:hover{filter:brightness(1.1)}+  .act-btn:active{transform:translateY(1px)}+  .act-btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}+  .act-btn.danger{background:transparent;color:var(--red);border-color:rgba(248,81,73,.5)}+  .act-btn.danger:hover{background:rgba(248,81,73,.08)}+  .act-btn:disabled{opacity:.5;cursor:not-allowed}+  .console{display:none;background:#0b0e13;border:1px solid var(--border);border-radius:8px;padding:8px 10px;font:11.5px/1.5 ui-monospace,monospace;color:#c9d1d9;max-height:200px;overflow:auto;white-space:pre-wrap;margin:.4rem 0 .2rem}+  .console.show{display:block}+  .details{font-family:var(--brand-body);font-size:11.5px;color:var(--text-2)}+  .details summary{cursor:pointer;color:var(--text-body)}+  .details code{font-size:11px}+  .settings-footer{margin-top:1.5rem;padding-top:1rem;border-top:1px solid var(--border);display:flex;flex-direction:column;gap:.5rem}+  .footer-note{font-family:var(--brand-body);font-size:.75rem;color:var(--text-2);margin:0}+  .restore-btn{width:fit-content;display:inline-flex;align-items:center;gap:.4rem;padding:.4rem .85rem;background:transparent;border:1px solid var(--border);border-radius:.375rem;color:var(--text-body);font-family:var(--brand-heading);font-size:.8125rem;font-weight:600;cursor:pointer}+  .restore-btn:hover{background:var(--elevated)}+  .restore-btn.armed{color:var(--red);border-color:rgba(255,107,107,.5);background:rgba(255,107,107,.08)}   #list{width:300px;min-width:300px;border-right:1px solid var(--edge);background:var(--panel);display:flex;flex-direction:column}   #list h1{font-size:15px;margin:0;padding:14px 16px;border-bottom:1px solid var(--edge);letter-spacing:.02em}   #list h1 small{color:var(--mut);font-weight:400}@@ -34,7 +127,7 @@   #toolbar button:hover{border-color:var(--acc);background:#22304a}   #toolbar button.primary{background:var(--acc);color:#08111f;border-color:var(--acc);font-weight:600}   #toolbar button:disabled{opacity:.45;cursor:not-allowed}-  .modal-overlay{position:fixed;inset:0;background:#000a;display:flex;align-items:center;justify-content:center;z-index:1000}+  .modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.25);display:flex;align-items:center;justify-content:center;z-index:1000}   .modal{background:var(--panel);border:1px solid var(--edge);border-radius:14px;width:min(760px,92vw);max-height:88vh;overflow:auto;padding:22px 24px;box-shadow:0 20px 60px #000a}   .modal h2{margin:0 0 4px;font-size:18px}   .modal .msub{color:var(--mut);font-size:13px;margin-bottom:16px}@@ -96,8 +189,20 @@ </style> </head> <body>+  <header id="hdr">+    <div class="brand"><img src="favicon.svg" alt="">adom-lbr <small>Dashboard</small></div>+    <div class="leds" id="leds" title="live status from /api/status"></div>+    <span class="ver" id="ver"></span>+    <button class="iconbtn" id="btnSettings" title="Settings" aria-label="Settings"><svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path fill="currentColor" d="M12 15.5A3.5 3.5 0 0 1 8.5 12A3.5 3.5 0 0 1 12 8.5a3.5 3.5 0 0 1 3.5 3.5a3.5 3.5 0 0 1-3.5 3.5m7.43-2.53c.04-.32.07-.64.07-.97s-.03-.66-.07-1l2.11-1.63c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.31-.61-.22l-2.49 1c-.52-.39-1.06-.73-1.69-.98l-.37-2.65A.506.506 0 0 0 14 2h-4c-.25 0-.46.18-.5.42l-.37 2.65c-.63.25-1.17.59-1.69.98l-2.49-1c-.22-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64L4.57 11c-.04.34-.07.67-.07 1s.03.65.07.97l-2.11 1.66c-.19.15-.25.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1.01c.52.4 1.06.74 1.69.99l.37 2.65c.04.24.25.42.5.42h4c.25 0 .46-.18.5-.42l.37-2.65c.63-.26 1.17-.59 1.69-.99l2.49 1.01c.22.08.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64z"/></svg></button>+  </header>+  <section id="say" aria-labelledby="sayTitle">+    <span class="say-title" id="sayTitle">Say it to your AI</span>+    <span id="phrases"></span>+    <span class="say-sub">You do not drive this dashboard by hand. You ask your AI, using the word <b>lbr</b>. Click a phrase to copy it; Try it stages it into this app's own AI thread.</span>+  </section>+  <div id="panes">   <aside id="list">-    <h1>adom-lbr <small>Manager</small><button id="refresh" title="refresh list (fetch newly-pushed boards)">↻</button></h1>+    <h1>Library <small>boards and parts</small><button id="refresh" title="refresh list (fetch newly-pushed boards)">↻</button></h1>     <div id="crumb"></div>     <input id="q" placeholder="filter boards…">     <div id="toolbar" style="display:none">@@ -110,6 +215,66 @@     <div id="rows"></div>   </aside>   <main id="view"><div id="hint">← pick a board, then a component, to open its viewer</div></main>+  </div>+  <div id="toast" role="status" aria-live="polite"></div>++  <dialog id="dlgSettings" class="settings" tabindex="-1" aria-labelledby="settingsTitle">+    <div class="settings-head" id="settingsHead">+      <h2 id="settingsTitle">adom-lbr Settings <span class="card-status" id="settingsVer"></span></h2>+      <button class="iconbtn" id="btnCloseSettings" aria-label="Close settings" title="Close"><svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></button>+    </div>+    <div class="settings-body" id="settingsBody">++      <section class="settings-card" id="card-taskbar">+        <h3 class="card-title">Taskbar progress <span class="card-status" id="st-taskbar"></span></h3>+        <p class="card-desc">While this dashboard does long work for you, it paints a progress bar on the <b>Hydrogen window's Windows taskbar button</b>, so the wait is legible even when this tab is not in front. It goes through Adom Bridge's <code>desktop_taskbar</code> verb. Work with a known length (Add to Library, where every symbol and footprint is one step) shows a determinate bar that fills step by step. Work whose length is only known from experience (installing or updating the package) fills over the average this app has measured for that operation, and stays indeterminate the very first time. Nothing is painted when Adom Bridge is not running, and a bar that is not cleared by the app is cleared when the job ends.</p>+        <p class="card-desc" id="durations-note"></p>+        <div class="setting-row">+          <div class="row-main"><span class="row-name" title="taskbar_progress">Taskbar: <span class="title">Progress bar</span></span><span class="default-hint">Default: On</span></div>+          <button class="toggle" id="tg-taskbar" role="switch" aria-checked="true" aria-label="Taskbar progress"><span class="toggle-track"><span class="toggle-thumb"></span></span></button>+        </div>+        <div class="setting-children" id="taskbar-children" data-enabled="true">+          <p class="why">Available when the progress bar is on.</p>+          <div class="setting-row child">+            <div class="row-main"><span class="row-name" title="taskbar_window_title">Taskbar: <span class="title">Window title to paint</span></span><span class="default-hint">Default: Hydrogen. The bar lands on the first visible window whose title contains this text.</span></div>+            <input class="set-input" id="in-window" placeholder="Hydrogen">+          </div>+        </div>+      </section>++      <section class="settings-card" id="card-wiki">+        <h3 class="card-title">Wiki identity and the library list <span class="card-status" id="st-wiki"></span></h3>+        <p class="card-desc">The library pane lists every board and component page on wiki.adom.inc. <b>My projects</b> are the boards whose wiki author is you, read from the sign-in this container holds (<code>adom-wiki whoami</code>). If that is not the name the wiki shows on your pages, set it here and the grouping follows. The list itself is fetched in 200-row pages and cached so later opens are instant; the cache expires after the minutes below, and the refresh button in the library pane always fetches fresh.</p>+        <div class="setting-row">+          <div class="row-main"><span class="row-name" title="owner_override">Wiki: <span class="title">My projects author</span></span><span class="default-hint" id="owner-default">Default: your wiki sign-in</span></div>+          <input class="set-input" id="in-owner" placeholder="leave empty for your sign-in">+        </div>+        <div class="divider"></div>+        <div class="setting-row">+          <div class="row-main"><span class="row-name" title="list_cache_minutes">Wiki: <span class="title">List cache</span></span><span class="default-hint">Default: 30 minutes</span></div>+          <input class="set-input" id="in-cache" type="number" min="1" max="1440" style="min-width:90px"> <span class="default-hint">minutes</span>+        </div>+      </section>++      <section class="settings-card" id="card-pkg">+        <h3 class="card-title">The adom-lbr package <span class="card-status" id="st-pkg"></span></h3>+        <p class="card-desc">This dashboard is the <code>adom-lbr</code> CLI, installed from the wiki page <code>adom/adom-lbr</code> into <code>~/.local/bin</code>, with the 3D viewer assets in <code>~/.local/share/adom-lbr</code>. Update fetches the latest published version with <code>adom-wiki pkg install</code>; the running dashboard keeps serving until the next launch from the dock, which restarts it on the new version. Uninstall removes the binary, the assets, and the bundled skills; it does not touch your libraries or your settings file. Each action streams its real output below the row and ends with a verify line.</p>+        <div class="setting-row">+          <div class="row-main"><span class="row-name">Package: <span class="title">adom-lbr</span></span><span class="default-hint" id="pkg-hint"></span></div>+          <button class="act-btn" id="btn-update">Update</button>+          <button class="act-btn danger" id="btn-uninstall">Uninstall</button>+        </div>+        <pre class="console" id="console-pkg"></pre>+        <details class="details"><summary>Details</summary><div id="pkg-details"></div></details>+      </section>++      <footer class="settings-footer">+        <p class="footer-note" id="footer-note">Settings are stored in this container and apply immediately. Restoring returns every option above to its default.</p>+        <button class="restore-btn" id="btn-restore">Restore defaults</button>+      </footer>+    </div>+  </dialog>+   <div id="modal" class="modal-overlay" style="display:none"><div class="modal">     <h2 id="modal-title"></h2><div class="msub" id="modal-sub"></div>     <div class="opts" id="modal-opts"></div>@@ -393,6 +558,136 @@ document.getElementById('btn-addproj').onclick=openAddProj; document.getElementById('q').oninput=render; document.getElementById('refresh').onclick=()=>loadList(true); loadList();++// ===================== dock contract: LEDs, say, settings =====================+let toastT=null;+function toast(msg,err){const t=document.getElementById('toast');t.textContent=msg;t.className='show'+(err?' err':'');clearTimeout(toastT);toastT=setTimeout(()=>t.className='',2600);}+async function api(path,opts){const r=await fetch(path,Object.assign({},opts,{signal:AbortSignal.timeout(5000)}));return r.json();}++// ---- live LEDs in the header, from the same /api/status the dock polls ----+let lastStatus=null;+async function refreshStatus(force){+  try{+    const s=await api('api/status'+(force?'?force=1':''));lastStatus=s;+    document.getElementById('ver').textContent='v'+s.version;+    const el=document.getElementById('leds');el.innerHTML='';+    (s.leds||[]).forEach(l=>{const d=document.createElement('span');d.className='led '+l.state;d.title=l.label+': '+l.detail+(l._hint?'\nfix: '+l._hint:'');d.innerHTML='<i></i>'+l.label;el.appendChild(d);});+    paintSettingsStatus();+  }catch(e){ document.getElementById('leds').innerHTML='<span class="led fail" title="dashboard not answering"><i></i>offline</span>'; }+}+refreshStatus(); setInterval(refreshStatus,15000);++// ---- "Say it": phrases come from dockbar.json `say` via the server ----+async function copyText(t){+  try{ await navigator.clipboard.writeText(t); return true; }+  catch(e){ const ta=document.createElement('textarea');ta.value=t;document.body.appendChild(ta);ta.select();let ok=false;try{ok=document.execCommand('copy');}catch(_){ }ta.remove();return ok; }+}+async function loadSay(){+  try{+    const r=await api('api/say');const el=document.getElementById('phrases');el.innerHTML='';+    (r.say||[]).forEach(p=>{+      const w=document.createElement('span');w.className='phrase';+      const t=document.createElement('span');t.className='txt';t.textContent=p;t.title='click to copy';+      t.onclick=async()=>{toast((await copyText(p))?'Copied: "'+p+'"':'Could not copy; select it by hand',false);};+      const b=document.createElement('button');b.className='try';b.textContent='Try it';b.title="stage this phrase, unsent, into this app's own AI thread";+      b.onclick=async()=>{+        b.disabled=true;+        try{ const res=await api('api/try',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({phrase:p})});+          if(res.staged){toast('Staged into '+(res.where||'your AI thread')+': "'+p+'" (press Enter there to send)');}+          else{await copyText(p);toast('Copied instead: '+(res.reason||'could not stage'),true);}+        }catch(e){await copyText(p);toast('Copied instead (dashboard could not reach the control API)',true);}+        b.disabled=false;+      };+      w.appendChild(t);w.appendChild(b);el.appendChild(w);+    });+  }catch(e){}+}+loadSay();++// ---- Settings modal: native <dialog>, dim never blur, four dismissals ----+const dlg=document.getElementById('dlgSettings');+let settings=null, saveTimers={};+function openSettings(){+  dlg.showModal(); dlg.focus();+  const body=document.getElementById('settingsBody'); body.scrollTop=0; requestAnimationFrame(()=>body.scrollTop=0);+  loadSettings(); refreshStatus(true);+}+document.getElementById('btnSettings').onclick=openSettings;+document.getElementById('btnCloseSettings').onclick=()=>dlg.close();+dlg.addEventListener('click',e=>{ if(e.target!==dlg)return; const r=dlg.getBoundingClientRect(); const inside=e.clientX>=r.left&&e.clientX<=r.right&&e.clientY>=r.top&&e.clientY<=r.bottom; if(!inside)dlg.close(); });+// draggable by its header+(function(){const h=document.getElementById('settingsHead');let sx,sy,ox,oy,drag=false;+  h.addEventListener('pointerdown',e=>{if(e.target.closest('button'))return;drag=true;const r=dlg.getBoundingClientRect();sx=e.clientX;sy=e.clientY;ox=r.left;oy=r.top;h.setPointerCapture(e.pointerId);});+  h.addEventListener('pointermove',e=>{if(!drag)return;const x=Math.max(0,Math.min(innerWidth-dlg.offsetWidth,ox+e.clientX-sx)),y=Math.max(0,Math.min(innerHeight-dlg.offsetHeight,oy+e.clientY-sy));dlg.style.position='fixed';dlg.style.margin='0';dlg.style.left=x+'px';dlg.style.top=y+'px';});+  h.addEventListener('pointerup',()=>drag=false);})();++function setToggle(id,on){const b=document.getElementById(id);b.classList.toggle('active',!!on);b.setAttribute('aria-checked',on?'true':'false');}+function renderSettings(){+  if(!settings)return;+  setToggle('tg-taskbar',settings.taskbar_progress);+  const ch=document.getElementById('taskbar-children');ch.dataset.enabled=settings.taskbar_progress?'true':'false';+  document.getElementById('in-window').disabled=!settings.taskbar_progress;+  if(document.activeElement!==document.getElementById('in-window'))document.getElementById('in-window').value=settings.taskbar_window_title||'';+  if(document.activeElement!==document.getElementById('in-owner'))document.getElementById('in-owner').value=settings.owner_override||'';+  if(document.activeElement!==document.getElementById('in-cache'))document.getElementById('in-cache').value=settings.list_cache_minutes;+  paintSettingsStatus();+}+function paintSettingsStatus(){+  if(!settings)return;+  const st=document.getElementById('st-taskbar');st.textContent=settings.taskbar_progress?'on':'off';st.className='card-status '+(settings.taskbar_progress?'ok':'');+  const s=lastStatus||{};const ident=(s.leds||[]).find(l=>l.label==='Identity')||{};+  const sw=document.getElementById('st-wiki');sw.textContent=ident.detail||'';sw.className='card-status '+(ident.state||'');+  document.getElementById('owner-default').textContent='Default: your wiki sign-in'+(ident.state==='ok'&&!settings.owner_override?' ('+ident.detail.replace('My projects = boards by ','')+')':'');+  const sp=document.getElementById('st-pkg');sp.textContent=s.version?'v'+s.version+' running':'';sp.className='card-status ok';+  document.getElementById('settingsVer').textContent=s.version?'v'+s.version:'';+  const bin=(s.leds||[]).find(l=>l.label==='Binary')||{}, v3=(s.leds||[]).find(l=>l.label==='3D viewer')||{};+  document.getElementById('pkg-details').innerHTML='<div>'+(bin.detail||'')+'</div><div>'+(v3.detail||'')+'</div><div>settings file: <code>'+(settings._path||'~/.config/adom-lbr/settings.json')+'</code></div><div>wiki page: <code>adom/adom-lbr</code></div>';+  document.getElementById('footer-note').textContent='Settings are stored in this container at '+(settings._path||'~/.config/adom-lbr/settings.json')+' and apply immediately. Restoring returns every option above to its default.';+  api('api/durations').then(d=>{const keys=Object.keys(d||{});const n=document.getElementById('durations-note');+    n.textContent=keys.length?('Measured so far: '+keys.map(k=>k.replace('job:','')+' about '+Math.round((d[k].avg_ms||0)/1000)+' s ('+d[k].n+' run'+(d[k].n===1?'':'s')+')').join('; ')+'.'):'No operation has been measured yet, so the first run of each shows an indeterminate bar.';}).catch(()=>{});+}+async function loadSettings(){+  try{ const r=await api('api/settings'); settings=r.settings; settings._path=r.path; renderSettings(); }catch(e){ toast('Could not load settings',true); }+}+function save(patch){+  Object.assign(settings,patch); renderSettings();   // optimistic, local first+  const key=Object.keys(patch).join(',');clearTimeout(saveTimers[key]);+  saveTimers[key]=setTimeout(async()=>{+    try{ const r=await api('api/settings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(patch)});+      if(r.status!=='ok'){toast('Save failed: '+(r.error||'?'),true);return;}+      if(r.rejected&&r.rejected.length){toast('Rejected: '+r.rejected.join(', '),true);}+      settings=Object.assign(r.settings,{_path:r.path}); renderSettings();+    }catch(e){ toast('Save timed out; the dashboard kept your click but the file may be stale',true); }+  },250);+}+document.getElementById('tg-taskbar').onclick=()=>save({taskbar_progress:!settings.taskbar_progress});+document.getElementById('in-window').onchange=e=>save({taskbar_window_title:e.target.value.trim()||'Hydrogen'});+document.getElementById('in-owner').onchange=e=>{save({owner_override:e.target.value.trim()});loadList(true);};+document.getElementById('in-cache').onchange=e=>save({list_cache_minutes:Math.max(1,Math.min(1440,parseInt(e.target.value,10)||30))});++// two-click Restore defaults, auto-disarming after 4 s+(function(){const b=document.getElementById('btn-restore');let armed=false,t=null;+  b.onclick=async()=>{ if(!armed){armed=true;b.classList.add('armed');b.textContent='Click again to confirm';t=setTimeout(()=>{armed=false;b.classList.remove('armed');b.textContent='Restore defaults';},4000);return;}+    clearTimeout(t);armed=false;b.classList.remove('armed');b.textContent='Restore defaults';+    try{const r=await api('api/settings/reset',{method:'POST'});settings=Object.assign(r.settings,{_path:r.path});renderSettings();toast('Settings restored to defaults');loadList(true);}catch(e){toast('Restore failed',true);} };})();++// lifecycle rows with a live console+function runJob(action){+  const con=document.getElementById('console-pkg');con.className='console show';con.textContent='starting '+action+'...';+  document.getElementById('btn-update').disabled=true;document.getElementById('btn-uninstall').disabled=true;+  api('api/lifecycle/'+action,{method:'POST'}).then(r=>{+    if(r.status!=='ok'){con.textContent=r.error||'failed';done();return;}+    const poll=setInterval(async()=>{+      try{const j=await api('api/job/'+action);con.textContent=(j.lines||[]).join('\n');con.scrollTop=con.scrollHeight;+        if(j.done){clearInterval(poll);done();refreshStatus(true);toast(action+(j.exit===0?' finished':' failed (exit '+j.exit+')'),j.exit!==0);}}catch(e){}+    },700);+  }).catch(e=>{con.textContent='could not start: '+e;done();});+  function done(){document.getElementById('btn-update').disabled=false;document.getElementById('btn-uninstall').disabled=false;}+}+document.getElementById('btn-update').onclick=()=>runJob('update');+(function(){const b=document.getElementById('btn-uninstall');let armed=false,t=null;+  b.onclick=()=>{ if(!armed){armed=true;b.textContent='Click again to uninstall';t=setTimeout(()=>{armed=false;b.textContent='Uninstall';},4000);return;}+    clearTimeout(t);armed=false;b.textContent='Uninstall';runJob('uninstall'); };})(); </script> </body> </html>
src/manage.rs+104−11
@@ -9,6 +9,7 @@ //!   GET  /api/part/<slug>       {part, symbol_svg, footprint_svg, layers, glb} //!   GET  /glb/<slug>            proxied 3D model (GLB) +use crate::dock; use altium_codec::{export_kicad_footprint, export_kicad_symbol, AdomLbrPart, Footprint, FpGraphic, FpLayer, Graphic, Symbol}; use anyhow::Result; use serde_json::{json, Value};@@ -28,8 +29,21 @@ fn my_owner() -> String {     if let Some(v) = std::env::var("ADOM_LBR_OWNER").ok().filter(|v| !v.trim().is_empty()) {         return v;     }+    if let Some(v) = dock::load_settings().get("owner_override").and_then(|s| s.as_str()).filter(|s| !s.trim().is_empty()) {+        return v.to_string();+    }+    // `adom-wiki whoami` is a ~200 ms subprocess; the identity does not change+    // under a running dashboard, so remember it for five minutes.+    {+        let c = OWNER_CACHE.lock().unwrap();+        if let Some((t, v)) = c.as_ref() {+            if t.elapsed() < std::time::Duration::from_secs(300) {+                return v.clone();+            }+        }+    }     let out = Command::new("adom-wiki").arg("whoami").output().ok();-    out.filter(|o| o.status.success())+    let v = out.filter(|o| o.status.success())         .and_then(|o| serde_json::from_slice::<Value>(&o.stdout).ok())         .and_then(|v| {             v.pointer("/user/displayName")@@ -37,23 +51,36 @@ fn my_owner() -> String {                 .and_then(|s| s.as_str())                 .map(|s| s.to_string())         })-        .unwrap_or_default()+        .unwrap_or_default();+    *OWNER_CACHE.lock().unwrap() = Some((std::time::Instant::now(), v.clone()));+    v } +static OWNER_CACHE: Mutex<Option<(std::time::Instant, String)>> = Mutex::new(None);+ /// The wiki component list is slow (~8s) but stable — fetch it once, serve /// instantly after. First click blocks; the rest are snappy.-static LIST_CACHE: Mutex<Option<String>> = Mutex::new(None);+static LIST_CACHE: Mutex<Option<(std::time::Instant, String, usize)>> = Mutex::new(None);  fn cached_list() -> String {+    let ttl = dock::load_settings().get("list_cache_minutes").and_then(|m| m.as_u64()).unwrap_or(30).max(1);     let mut c = LIST_CACHE.lock().unwrap();-    if let Some(s) = c.as_ref() {-        return s.clone();+    if let Some((t, s, _)) = c.as_ref() {+        if t.elapsed() < std::time::Duration::from_secs(ttl * 60) {+            return s.clone();+        }     }-    let s = list_components().to_string();-    *c = Some(s.clone());+    let v = list_components();+    let n = v.get("components").and_then(|a| a.as_array()).map(|a| a.len()).unwrap_or(0);+    let s = v.to_string();+    *c = Some((std::time::Instant::now(), s.clone(), n));     s } +fn library_count() -> Option<usize> {+    LIST_CACHE.lock().unwrap().as_ref().map(|(_, _, n)| *n)+}+ pub fn run(port: u16) -> Result<()> {     // Loopback by default: /api/apply writes files and runs commands on the     // user's machine via the bridge, so it must not be LAN-reachable.@@ -61,9 +88,13 @@ pub fn run(port: u16) -> Result<()> {     let bind = std::env::var("ADOM_LBR_BIND").unwrap_or_else(|_| "127.0.0.1".into());     let server = Server::http(format!("{bind}:{port}"))         .map_err(|e| anyhow::anyhow!("bind {bind}:{port}: {e}"))?;-    println!("OK: adom-lbr Manager on http://localhost:{port}");-    println!("Hint: open it in a browser, or `adom-desktop desktop_open_url {{\"url\":\"http://localhost:{port}\"}}`.");-    println!("Hint: the component list is fetched from {WIKI}; click a row to open the viewer.");+    dock::mark_started();+    println!("OK: adom-lbr dashboard {} on {}", dock::VERSION, dock::direct_url(port));+    println!("Hint: the dock reads /api/status; the component list comes from {WIKI}; click a row to open the viewer.");+    // Socket is bound, so the tab we claim lands on a page that answers.+    dock::claim_dock_webview(port);+    // Warm the wiki list now, so the first open of the page is instant.+    std::thread::spawn(|| { let _ = cached_list(); });     for mut req in server.incoming_requests() {         let method = req.method().clone();         let url = req.url().to_string();@@ -78,12 +109,66 @@ pub fn run(port: u16) -> Result<()> {     Ok(()) } +/// Every /api/* answer carries `Access-Control-Allow-Origin: *` and OPTIONS is+/// answered: the dock probes /api/status cross-origin from the Hydrogen shell+/// page, and without the header the browser blocks the response and the card+/// reads "not answering" while the server is perfectly healthy.+fn cors(resp: Response<Cursor<Vec<u8>>>) -> Response<Cursor<Vec<u8>>> {+    resp.with_header(Header::from_bytes(&b"Access-Control-Allow-Origin"[..], &b"*"[..]).unwrap())+        .with_header(Header::from_bytes(&b"Access-Control-Allow-Methods"[..], &b"GET, POST, OPTIONS"[..]).unwrap())+        .with_header(Header::from_bytes(&b"Access-Control-Allow-Headers"[..], &b"Content-Type"[..]).unwrap())+        .with_header(Header::from_bytes(&b"Cache-Control"[..], &b"no-store"[..]).unwrap())+}+ fn route(method: &Method, url: &str, body: &str) -> Response<Cursor<Vec<u8>>> {     let path = url.split('?').next().unwrap_or(url);+    if matches!(method, Method::Options) {+        return cors(Response::from_string("").with_status_code(204));+    }+    let resp = route_inner(method, url, path, body);+    if path.starts_with("/api/") || path == "/health" { cors(resp) } else { resp }+}++fn route_inner(method: &Method, url: &str, path: &str, body: &str) -> Response<Cursor<Vec<u8>>> {+    let force = url.contains("force=1");     match (method, path) {         (Method::Post, "/api/apply") => json_resp(&start_apply(body).to_string()),         (Method::Get, "/api/apply-progress") => json_resp(&get_progress().to_string()),         (Method::Get, "/") => html(INDEX_HTML),+        (Method::Get, p) if p.ends_with("/favicon.svg") || p.ends_with("/favicon.ico") => {+            Response::from_string(FAVICON_SVG).with_header(hdr("image/svg+xml"))+        }+        // The dock contract: status with per-facet LEDs + our own rollup.+        (Method::Get, "/api/status") | (Method::Get, "/health") => {+            json_resp(&dock::cached_status(library_count(), &my_owner(), force).to_string())+        }+        (Method::Get, "/api/say") => json_resp(&json!({"status":"ok","say":dock::say_phrases(),"noun":"lbr"}).to_string()),+        (Method::Post, "/api/try") => {+            let phrase = serde_json::from_str::<Value>(body).ok()+                .and_then(|v| v.get("phrase").and_then(|p| p.as_str()).map(String::from))+                .unwrap_or_default();+            if phrase.trim().is_empty() {+                return json_resp(&json!({"staged":false,"reason":"no phrase given"}).to_string());+            }+            json_resp(&dock::try_phrase(&phrase).to_string())+        }+        (Method::Get, "/api/settings") => {+            json_resp(&json!({"status":"ok","settings":dock::load_settings(),"defaults":dock::settings_defaults(),"path":dock::settings_path()}).to_string())+        }+        (Method::Post, "/api/settings") => match serde_json::from_str::<Value>(body) {+            Ok(v) => json_resp(&dock::save_settings(&v).to_string()),+            Err(e) => json_resp(&json!({"status":"error","error":format!("bad body: {e}")}).to_string()),+        },+        (Method::Post, "/api/settings/reset") => json_resp(&dock::reset_settings().to_string()),+        (Method::Get, "/api/durations") => json_resp(&dock::durations().to_string()),+        (Method::Post, p) if p.starts_with("/api/lifecycle/") => {+            let action = &p["/api/lifecycle/".len()..];+            match dock::lifecycle_command(action) {+                Some(cmd) => json_resp(&dock::start_job(action, cmd).to_string()),+                None => json_resp(&json!({"status":"error","error":format!("unknown action {action:?}"),"hint":"install | update | uninstall"}).to_string()),+            }+        }+        (Method::Get, p) if p.starts_with("/api/job/") => json_resp(&dock::job_state(&p["/api/job/".len()..]).to_string()),         (Method::Get, "/api/components") => {             // ?refresh=1 busts the server-side cache so newly-pushed components appear             if url.contains("refresh=1") {@@ -954,7 +1039,12 @@ fn run_apply(items: Vec<(String, String, String)>, board: Option<String>, dest:     let mut done_n = 0usize;     let mut added: Vec<Value> = vec![];     let mut errors: Vec<String> = vec![];-    let prog = |stage: String, cur: usize| set_progress(json!({"stage":stage,"current":cur,"total":total,"done":false}));+    // Every symbol and footprint is one step, so the taskbar bar is determinate.+    let bar = dock::TaskbarJob::start("add-to-library", false);+    let prog = |stage: String, cur: usize| {+        bar.step(cur, total + 1);+        set_progress(json!({"stage":stage,"current":cur,"total":total,"done":false}));+    };      for (symlib, slugs) in &by_symlib {         let mut blocks: Vec<(String, String)> = vec![];@@ -1046,8 +1136,10 @@ fn run_apply(items: Vec<(String, String, String)>, board: Option<String>, dest:         None => json!(null),     }; +    let ok = errors.is_empty();     let result = json!({"status":"ok","count":added.len(),"added":added,"errors":errors,"project":project});     set_progress(json!({"stage":"Done","current":total,"total":total,"done":true,"result":result}));+    bar.finish(ok); }  // ---------------------------------------------------------------------------@@ -1065,6 +1157,7 @@ fn json_resp(s: &str) -> Response<Cursor<Vec<u8>>> { }  const INDEX_HTML: &str = include_str!("manage.html");+const FAVICON_SVG: &str = include_str!("favicon.svg");  // --------------------------------------------------------------------------- // export-viewers: emit per-slot, self-contained interactive viewers