← Commit history

2.11.4: Send-to toolbar (web control), Parts browse, sample part, copy-only Say strip

John Lauer ·49bbb9857c ·1mo ago ·parent f0edaaf
12 files changed +383−210
Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-lbr"-version = "2.11.3"+version = "2.11.4" dependencies = [  "altium-codec",  "anyhow",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-lbr"-version = "2.11.3"+version = "2.11.4" 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." 
README.md+3−4
@@ -56,10 +56,9 @@ hint that names the fix when it is not green. The first thing on the page is the **Say it** strip, because you do not drive this dashboard by hand: you ask your AI, using the word **lbr** ("validate my lbr for fusion"). Click a phrase to copy it; **Try it** stages it, unsent, into-this app's own AI thread. The cog opens Settings: taskbar progress during long-work (a real progress bar on the Hydrogen window's Windows taskbar button),-whose boards count as My projects, the wiki list cache, and package-Update/Uninstall with a live console. The launch contract lives in+this app's own AI thread. Click a phrase in the Say it strip to copy it. The cog opens Settings: whose boards count as+My projects, the wiki list cache, and package Update/Uninstall with a live+console. The launch contract lives in `dockbar.json` at the root of this page repo; the bundled `adom-lbr-dockbar` skill documents every piece of it for your AI. 
SKILL.md+2−3
@@ -82,9 +82,8 @@ 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+console. Settings live in `~/.config/adom-lbr/settings.json`: the Say it+strip, 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`. 
VERSION+1−1
@@ -1 +1 @@-2.11.3+2.11.4
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-lbr",-  "version": "2.11.3",+  "version": "2.11.4",   "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": {},
page.json+9−12
@@ -1,8 +1,8 @@ {   "slug": "adom-lbr",-  "version": "2.11.3",+  "version": "2.11.4",   "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,24 +52,21 @@   ],   "files": [     "bin/**",-    "assets/**",     "SKILL.md",-    "skills/**",     "README.md",     "install.sh",     "uninstall.sh",-    "screenshots/**",-    "dockbar.json"+    "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",@@ -77,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+}
skills/adom-lbr-dockbar/SKILL.md+36−14
@@ -3,13 +3,13 @@ name: adom-lbr-dockbar description: >   adom-lbr on the Hydrogen Dock Bar: what the dock card does, how the one-click   launch works (install, serve, webview takeover), the /api/status LEDs the card-  reads, the "Say it" strip, the settings modal, taskbar progress, and how to-  debug a card that reads red or a launch that fails. Use when the user clicks+  reads, the "Say it" strip, the settings modal, and how to debug a card+  that reads red or a launch that fails. Use when the user clicks   the Adom LBR dock card, asks why its LED is not green, wants the dashboard   opened or restarted, or asks how adom-lbr integrates with the dock. Trigger   words: adom-lbr dock, lbr dock card, adom lbr dashboard, lbr dashboard serve,   dockbar.json lbr, lbr card red, lbr launch failed, adom-lbr status leds,-  lbr say phrases, lbr settings, lbr taskbar progress.+  lbr say phrases, lbr settings. ---  # adom-lbr on the Hydrogen Dock Bar@@ -98,23 +98,45 @@ say` prints them). Click copies; **Try it** stages the phrase, unsent, into this app's own AI thread through the Hydrogen control API at 127.0.0.1:47084: open threads first (census, tag `app:adom/adom-lbr`), then history (resume, then stage), else it copies and says why. From a cloud container the control-API is unreachable and copying is the correct fallback.--## Settings and taskbar progress+API is unreachable and copying is the correct fallback. Try it is currently+PARKED ship-wide (the strip is copy-only) until ah's typing-path fix is+certified; the code stays behind a flag and the server route stays live.++## The Send-to toolbar (web control)++The toolbar under the header sends the OPEN part to the user's desktop EDA+tools through Adom Bridge web control: the page itself POSTs verbs to+`http://127.0.0.1:24680/command`, and the Bridge pops its own Allow once /+Allow 1 hr / Deny consent naming the page. Nothing is a new verb; it is the+one shared verb surface. The chains:++- Staging: `GET /api/export/<slug>` on this server returns the part's+  `.kicad_sym`, `.kicad_mod`, a generated `.lbr`, and the page's `.step` as+  base64; the page writes them to `~/Documents/adom-lbr/<MPN>/` with+  `write_file` (or `write_file_chunk` in ~1 MB pieces past the body cap).+- KiCad: `kicad_install_symbol {filePath, quietInstall}` (reports the real+  library and symbol names), `kicad_install_footprint {filePath, model}` (the+  bridge wires the 3D reference itself), then the button's show verb:+  `kicad_show_symbol` / `kicad_show_footprint` / `kicad_show_3d_chip` /+  `kicad_show_library` (the all-three proof).+- Fusion: `fusion_create_library {libraryPath, stepPath, name}` (shape A, one+  call = one consent), then poll `fusion_prove_result` for the verdict.++Detection (`GET /health`) lights the toolbar's dot. It works when the page is+served at its 127.0.0.1 address, which is exactly how the dock opens it; a+browser may block loopback probes from a cloud proxy origin (Chrome's Local+Network Access permission), and the buttons stay disabled with the reason in+the tooltip.++## Settings  The cog (upper right of the 44 px header) opens the settings modal (settings-layout style: dim never blur; Escape, the X, and an outside click close it). Stored at `~/.config/adom-lbr/settings.json`, typed by their defaults; wrong-typed or unknown keys are rejected by name, never written. -- **Taskbar progress** (default on): while the dashboard does long work, it-  paints the Hydrogen window's Windows taskbar button through Adom Bridge's-  `desktop_taskbar` verb. Add to Library is determinate (every symbol and-  footprint is one step); package install/update fills over the average this-  app has measured for that operation (`~/.config/adom-lbr/durations.json`)-  and is indeterminate the very first time. The child setting names the window-  title to paint (default "Hydrogen"). No bridge, no window, or the toggle off-  all mean nothing is painted.+- **Say it strip** (default on): the X on the strip hides it once the user+  knows the noun; this switch brings it back. - **My projects author** overrides the `adom-wiki whoami` identity for the   left pane's grouping; **List cache** sets how long the wiki page list is   reused (default 30 minutes; the refresh button always fetches fresh).
src/dock.rs+3−114
@@ -2,8 +2,8 @@ //! 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.+//! staging through the Hydrogen control API; settings; and lifecycle jobs+//! with a live console. //! //! Guide: https://wiki.adom.inc/adom/hydrogen/files/docs/dockbar.md @@ -47,8 +47,7 @@ pub fn home() -> PathBuf { /// 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",+        "show_say_strip": true,         "owner_override": "",         "list_cache_minutes": 30     })@@ -320,113 +319,6 @@ pub fn cached_status(library_count: Option<usize>, owner: &str, force: bool) ->     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 // ---------------------------------------------------------------------------@@ -457,7 +349,6 @@ pub fn start_job(name: &str, shell: &str) -> Value {     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())@@ -468,7 +359,6 @@ pub fn start_job(name: &str, shell: &str) -> Value {             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;             }         };@@ -488,7 +378,6 @@ pub fn start_job(name: &str, shell: &str) -> Value {             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")})
src/main.rs+4−4
@@ -324,7 +324,7 @@ struct ParsedSymbol {     graphics: Vec<SymGraphic>, } -fn parse_kicad_sym(content: &str, name_override: Option<&str>) -> Result<ParsedSymbol, String> {+pub(crate) fn parse_kicad_sym(content: &str, name_override: Option<&str>) -> Result<ParsedSymbol, String> {     let name = if let Some(n) = name_override {         n.to_string()     } else {@@ -705,7 +705,7 @@ fn dedup_pad_names(names: &[String]) -> (Vec<String>, std::collections::HashMap< /// (GPIO0..47, DB0..15) contiguous in numeric order, a blank row between /// groups, and a body wide enough that opposing names never meet. Every pin /// name renders horizontal. Returns whether the re-layout was applied.-fn relayout_two_columns(sym: &mut ParsedSymbol, force: bool) -> bool {+pub(crate) fn relayout_two_columns(sym: &mut ParsedSymbol, force: bool) -> bool {     // KiCad pin angles: 0 = points right (pin on the LEFT edge), 180 = left     // (RIGHT edge), 90 = up (BOTTOM edge), 270 = down (TOP edge). Top/bottom     // pins are the package-mirror tell.@@ -1218,7 +1218,7 @@ fn symbol_art_block(art: &SymbolArt, body: Option<&KicadBody>, scale_frac: f64)     } } -fn generate_lbr(sym: &ParsedSymbol, fp_content: Option<&str>, symbol_art: Option<&SymbolArt>, symbol_art_scale: f64, pin_map: &std::collections::HashMap<String, String>) -> String {+pub(crate) fn generate_lbr(sym: &ParsedSymbol, fp_content: Option<&str>, symbol_art: Option<&SymbolArt>, symbol_art_scale: f64, pin_map: &std::collections::HashMap<String, String>) -> String {     let mut xml = String::with_capacity(8192);      xml.push_str("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n");@@ -2246,7 +2246,7 @@ fn main() {  // ── `embed`: Fusion-style 3-column interactive HTML package ── -fn b64(data: &[u8]) -> String {+pub(crate) fn b64(data: &[u8]) -> String {     const T: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";     let mut out = String::with_capacity(data.len().div_ceil(3) * 4);     for ch in data.chunks(3) {
src/manage.html+262−46
@@ -29,17 +29,43 @@   .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 it": the family card (adom-parts-search look), dismissible via the X / Settings ---- */+  #say{position:relative;background:var(--elevated);border:1px solid var(--border);border-left:3px solid color-mix(in srgb,var(--accent) 45%,transparent);border-radius:12px;margin:10px 12px 2px;padding:12px 44px 12px 14px;animation:fadein .25s ease}+  #say[hidden]{display:none}+  @keyframes fadein{from{opacity:0}to{opacity:1}}+  .say-close{position:absolute;top:8px;right:8px;width:28px;height:28px;color:var(--text-2)}+  .say-close svg{width:16px;height:16px}+  .say-head{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 12px;margin-bottom:8px}+  .say-head h2{margin:0;font:600 15px var(--brand-heading);color:var(--text)}+  #say .say-sub{font-size:12.5px;color:var(--text-2);max-width:80ch}   #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}+  .say-list{display:flex;flex-wrap:wrap;gap:8px}+  .phrase{display:inline-flex;align-items:stretch;border:1px solid var(--border);border-radius:999px;background:#0d0f14;overflow:hidden;font-size:13px;transition:border-color .15s ease}+  .phrase:hover{border-color:var(--accent)}+  .phrase .txt{padding:6px 10px 6px 14px;cursor:copy;color:var(--ink);background:none;border:none;font:500 13px var(--brand-body);transition:background .15s}+  .phrase .txt:hover{background:var(--overlay)}+  .phrase .try{padding:6px 12px 6px 9px;border-left:1px solid var(--border);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)}+  /* ---- Send-to toolbar: first-class web-control buttons (KiCad / Fusion) ---- */+  #sendbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:6px 14px;background:var(--panel);border-bottom:1px solid var(--edge);font-size:12px}+  #sendbar .grp{display:flex;align-items:center;gap:6px}+  #sendbar .lab{color:var(--mut);font:600 11px var(--brand-heading);text-transform:uppercase;letter-spacing:.05em}+  #sendbar button{background:#1a2130;border:1px solid var(--edge);color:var(--ink);border-radius:7px;padding:5px 10px;font-size:12px;cursor:pointer;transition:border-color .12s,background .12s,transform .06s}+  #sendbar button:hover:not(:disabled){border-color:var(--acc);background:#12302f}+  #sendbar button:active:not(:disabled){transform:translateY(1px)}+  #sendbar button:disabled{opacity:.45;cursor:not-allowed}+  #sendbar .sep{width:1px;height:18px;background:var(--edge)}+  #sendbar .abdot{display:inline-flex;align-items:center;gap:6px;margin-left:auto;color:var(--mut)}+  #sendbar .abdot i{width:8px;height:8px;border-radius:50%;background:#555;display:inline-block}+  #sendbar .abdot.on i{background:var(--green);box-shadow:0 0 6px var(--green)}+  /* sidebar mode tabs: Projects (boards) | Parts (the whole wiki library) */+  #modes{display:flex;gap:6px;padding:10px 12px 0}+  #modes button{flex:1;background:#0d0f14;border:1px solid var(--edge);color:var(--mut);border-radius:8px;padding:6px 8px;font:600 12px var(--brand-heading);cursor:pointer}+  #modes button:hover{border-color:var(--acc);color:var(--ink)}+  #modes button.on{background:#12302f;border-color:var(--acc);color:var(--acc)}+  .sample-note{background:var(--elevated);border:1px solid var(--border);border-left:3px solid color-mix(in srgb,var(--accent) 45%,transparent);border-radius:10px;padding:8px 12px;margin:0 0 12px;font-size:12.5px;color:var(--text-2)}+  .sample-note b{color:var(--text)}   #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)}@@ -195,14 +221,31 @@     <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>+  <nav id="sendbar" aria-label="Send the open part to your EDA tools">+    <span class="lab">Send to KiCad</span>+    <div class="grp">+      <button data-send="kicad-symbol" title="Install this part's symbol on your PC and open it in KiCad's Symbol Editor">Symbol</button>+      <button data-send="kicad-footprint" title="Install the footprint (with its 3D model wired) and open the Footprint Editor">Footprint</button>+      <button data-send="kicad-3d" title="Open the part's STEP model on its footprint in KiCad's 3D viewer">3D chip</button>+      <button data-send="kicad-library" title="Install symbol + footprint + 3D and open all three: the working-library proof">Library</button>+    </div>+    <span class="sep"></span>+    <span class="lab">Send to Fusion</span>+    <div class="grp">+      <button data-send="fusion-library" title="Stage the .lbr + .step on your PC and have the Fusion bridge build and prove the library">Library</button>+    </div>+    <span class="abdot" id="abdot" title="Adom Bridge on this machine. The buttons stage files and drive KiCad / Fusion through it; the Bridge asks for your permission in its own window. Detection needs the dashboard opened at its 127.0.0.1 address (the dock does this); a browser may block loopback probes from a cloud proxy URL."><i></i><span id="abtext">looking for Adom Bridge…</span></span>+  </nav>+  <section id="say" aria-labelledby="sayTitle" hidden>+    <button class="iconbtn say-close" id="sayClose" aria-label="Hide the Say it strip" title="Hide this strip. It stays hidden until you turn it back on in Settings (Say it strip)."><svg viewBox="0 0 24 24" 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 class="say-head"><h2 id="sayTitle">Say it to your AI</h2>+    <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, then paste it into any Adom AI thread.</span></div>+    <div class="say-list" id="phrases"></div>   </section>   <div id="panes">   <aside id="list">     <h1>Library <small>boards and parts</small><button id="refresh" title="refresh list (fetch newly-pushed boards)">↻</button></h1>+    <div id="modes"><button id="mode-projects" class="on">Projects</button><button id="mode-parts">Parts</button></div>     <div id="crumb"></div>     <input id="q" placeholder="filter boards…">     <div id="toolbar" style="display:none">@@ -225,20 +268,12 @@     </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>+      <section class="settings-card" id="card-say">+        <h3 class="card-title">Say it strip <span class="card-status" id="st-say"></span></h3>+        <p class="card-desc">The strip at the top of the page teaches the one thing worth knowing: you ask your AI for this app by saying <b>lbr</b>. Each phrase copies on click, and Try it stages it into this app's own AI thread without sending. Once you know the noun you do not need the lesson, so the X on the strip hides it for good; this switch brings it back.</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 class="row-main"><span class="row-name" title="show_say_strip">Say it: <span class="title">Show the strip</span></span><span class="default-hint">Default: On</span></div>+          <button class="toggle" id="tg-say" role="switch" aria-checked="true" aria-label="Show the Say it strip"><span class="toggle-track"><span class="toggle-thumb"></span></span></button>         </div>       </section> @@ -321,7 +356,28 @@ async function loadList(fresh){     rowsEl().innerHTML='<div class="row" style="color:#f87171;cursor:default">failed to load — retry</div>';   }finally{ if(btn)btn.classList.remove('spin'); } }-function render(){ boardSlug?renderDeps():renderBoards(); }+let sideMode='projects';+function render(){ if(sideMode==='parts'){renderParts();return;} boardSlug?renderDeps():renderBoards(); }+function setMode(m){+  sideMode=m; boardSlug=null; selected.clear();+  document.getElementById('mode-projects').classList.toggle('on',m==='projects');+  document.getElementById('mode-parts').classList.toggle('on',m==='parts');+  document.getElementById('q').placeholder=m==='parts'?'search all wiki parts…':'filter projects…';+  render();+}+document.getElementById('mode-projects').onclick=()=>setMode('projects');+document.getElementById('mode-parts').onclick=()=>setMode('parts');+// The wiki IS the library: every component page is browsable directly, no board+// (and nothing local) required. Boards stay one click away on the Projects tab.+function renderParts(){+  document.getElementById('toolbar').style.display='none';+  document.getElementById('crumb').innerHTML='<span class="cactive">All wiki parts</span>';+  const q=qval(),el=rowsEl();el.innerHTML='';+  const parts=all.filter(c=>!isBoard(c)).filter(c=>!q||c.slug.includes(q)||(c.title||'').toLowerCase().includes(q)||(c.brief||'').toLowerCase().includes(q));+  const h=document.createElement('div');h.className='cathead';h.textContent=`Parts · ${parts.length}${q?' matching':''}`;el.appendChild(h);+  parts.slice(0,400).forEach(c=>el.appendChild(makeRow(c.slug,c.title||c.slug,c.slug,(sl,d)=>openPart(sl,d))));+  if(parts.length>400){const d=document.createElement('div');d.className='row';d.style.cssText='color:#8b93a3;cursor:default';d.textContent=`… ${parts.length-400} more — type to narrow`;el.appendChild(d);}+} function renderBoards(){   document.getElementById('toolbar').style.display='none';selected.clear();   document.getElementById('crumb').innerHTML='<span class="cactive">Projects</span>';@@ -402,7 +458,9 @@ function renderDeps(){   updateToolbar(); } function backToBoards(){boardSlug=null;curDeps=[];document.getElementById('q').value='';document.querySelector('#view #hint')||(document.getElementById('view').innerHTML='<div id="hint">← pick a board, then a component, to open its viewer</div>');render();}+let currentPart=null; async function openPart(slug,el){+  currentPart=slug; updateSendbar();   document.querySelectorAll('.row').forEach(r=>r.classList.remove('sel'));   if(el)el.classList.add('sel');   const v=document.getElementById('view');@@ -557,7 +615,29 @@ document.getElementById('btn-addproj').onclick=openAddProj;  document.getElementById('q').oninput=render; document.getElementById('refresh').onclick=()=>loadList(true);-loadList();+// First load: show a real part in full glory instead of an empty pane. An+// industry-grade sample (STM32 H-line MCU; TMC stepper driver as fallback), and+// only while the user has not opened anything themselves.+let userNavigated=false;+document.getElementById('rows').addEventListener('click',()=>{userNavigated=true;},true);+async function loadSample(){+  for (const slug of ['stm32h563rgtx','tmc2209','ads131m04']){+    if(userNavigated) return;+    try{+      const probe=await fetch('api/part/'+slug).then(r=>r.json());+      if(probe.status!=='ok') continue;+      if(userNavigated) return;+      await openPart(slug);+      const v=document.getElementById('view');+      const note=document.createElement('div');+      note.className='sample-note';+      note.innerHTML='<b>Sample part.</b> This is '+slug.toUpperCase()+' straight from the wiki — symbol, footprint, 3D and the per-EDA layer stackup, rendered live. Browse everything under <b>Parts</b> on the left, or your own boards under <b>Projects</b>.';+      v.prepend(note);+      return;+    }catch(e){}+  }+}+loadList(); loadSample();  // ===================== dock contract: LEDs, say, settings ===================== let toastT=null;@@ -576,12 +656,22 @@ async function refreshStatus(force){   }catch(e){ document.getElementById('leds').innerHTML='<span class="led fail" title="dashboard not answering"><i></i>offline</span>'; } } refreshStatus(); setInterval(refreshStatus,15000);+loadSettings();   // the Say-it strip's visibility rides settings, so load them at startup  // ---- "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; } }+function applySayVisibility(){+  const el=document.getElementById('say');+  if(!settings) return;+  el.hidden = settings.show_say_strip===false;+}+document.getElementById('sayClose').onclick=()=>{+  save({show_say_strip:false});+  toast('Say it strip hidden. Bring it back any time in Settings.');+}; async function loadSay(){   try{     const r=await api('api/say');const el=document.getElementById('phrases');el.innerHTML='';@@ -589,16 +679,23 @@ async function loadSay(){       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);+      // Try it is PARKED (ship-night ruling, 2026-08-23): the run-prompt path+      // can crash ah until the typing fix certifies, and /ai-threads calls wake+      // ah's parked backend. Copy-only until the all-clear; flip to re-enable.+      const TRY_ENABLED=false;+      if(TRY_ENABLED){+        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);+      } else { w.appendChild(t); }+      el.appendChild(w);     });   }catch(e){} }@@ -624,17 +721,15 @@ dlg.addEventListener('click',e=>{ if(e.target!==dlg)return; const r=dlg.getBound 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||'';+  applySayVisibility();+  setToggle('tg-say',settings.show_say_strip!==false);   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 ss=document.getElementById('st-say');ss.textContent=settings.show_say_strip===false?'hidden':'shown';ss.className='card-status '+(settings.show_say_strip===false?'':'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 ','')+')':'');@@ -643,8 +738,6 @@ function paintSettingsStatus(){   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); }@@ -660,8 +753,7 @@ function save(patch){     }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('tg-say').onclick=()=>save({show_say_strip:settings.show_say_strip===false}); 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))}); @@ -684,6 +776,130 @@ function runJob(action){   }).catch(e=>{con.textContent='could not start: '+e;done();});   function done(){document.getElementById('btn-update').disabled=false;document.getElementById('btn-uninstall').disabled=false;} }+// ===================== Send-to toolbar: Adom Bridge web control =====================+// The page talks to the Adom Bridge on THIS machine over loopback+// (http://127.0.0.1:24680). Detection is open; running a verb pops the+// Bridge's own Allow once / Allow 1 hr / Deny consent naming this page.+const AB='http://127.0.0.1:24680';+let abUp=false;+async function abPing(){+  try{const r=await fetch(AB+'/health',{signal:AbortSignal.timeout(1500)});const j=await r.json();abUp=(j.service==='adom-bridge');}+  catch(e){abUp=false;}+  const d=document.getElementById('abdot');d.classList.toggle('on',abUp);+  document.getElementById('abtext').textContent=abUp?'Adom Bridge ready':'Adom Bridge not detected on this machine';+  updateSendbar();+}+function updateSendbar(){+  const ok=abUp&&!!currentPart;+  document.querySelectorAll('#sendbar button').forEach(b=>{+    b.disabled=!ok||b.dataset.busy==='1';+    if(!abUp)b.title2=b.title;  // keep original titles; tooltip text below explains+  });+}+abPing(); setInterval(abPing,20000);+async function abVerb(command,args){+  const body={command,args:Object.assign({caller:{aiThread:'adom-lbr dashboard'}},args||{})};+  const r=await fetch(AB+'/command',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});+  let j={}; try{j=await r.json();}catch(e){}+  return {s:r.status,j};+}+// ---- staging: fetch the part's files from this server, write them to the PC ----+const exportCache={}, stagedCache={};+async function partExport(slug){+  if(exportCache[slug])return exportCache[slug];+  toast('Preparing '+slug.toUpperCase()+' (symbol, footprint, 3D, .lbr)…');+  const r=await fetch('api/export/'+slug).then(x=>x.json());+  if(r.status!=='ok')throw new Error(r.error||'export failed');+  exportCache[slug]=r; return r;+}+const CHUNK_B64=Math.floor(1024*1024/3)*4;   // ~1 MB raw per request, sliced on base64 boundaries+async function stageFile(dir,name,b64){+  const path=dir+name, raw=Math.floor(b64.length*3/4);+  if(raw<=1024*1024){+    const r=await abVerb('write_file',{path,content:b64,encoding:'base64'});+    if(!(r.s===200&&r.j&&r.j.success))throw new Error('write_file '+name+': '+((r.j&&(r.j.error||r.j.errorCode))||r.s));+    return r.j.path||path;+  }+  let offset=0,i=0,total=Math.ceil(b64.length/CHUNK_B64),finalPath=path;+  while(i<total){+    const slice=b64.substr(i*CHUNK_B64,CHUNK_B64),last=(i===total-1);+    const r=await abVerb('write_file_chunk',{path,dataB64:slice,offset,final:last});+    if(!(r.s===200&&r.j&&r.j.success))throw new Error('write_file_chunk '+name+' @'+offset+': '+((r.j&&(r.j.error||r.j.errorCode))||r.s));+    finalPath=r.j.path||finalPath; offset=r.j.nextOffset; i++;+  }+  return finalPath;+}+async function stagePart(slug){+  if(stagedCache[slug])return stagedCache[slug];+  const ex=await partExport(slug);+  const dir='~/Documents/adom-lbr/'+ex.mpn+'/';+  toast('Staging files onto your PC (the Bridge will ask your permission)…');+  const out={mpn:ex.mpn,paths:{}};+  for(const [name,f] of Object.entries(ex.files)){+    out.paths[name.split('.').pop()]=await stageFile(dir,name,f.b64);+  }+  stagedCache[slug]=out; return out;+}+// ---- KiCad: install once per part, then the show verbs ----+const kicadCache={};+async function kicadInstall(slug){+  if(kicadCache[slug])return kicadCache[slug];+  const st=await stagePart(slug);+  toast('Installing into KiCad’s Adom library…');+  const rs=await abVerb('kicad_install_symbol',{filePath:st.paths.kicad_sym,quietInstall:true});+  if(!(rs.s===200&&rs.j&&rs.j.success))throw new Error('kicad_install_symbol: '+((rs.j&&rs.j.error)||rs.s));+  const info={lib:rs.j.libraryName||'Adom',symbol:(rs.j.symbolNames&&rs.j.symbolNames[0])||st.mpn};+  const fpArgs={filePath:st.paths.kicad_mod,quietInstall:true};+  if(st.paths.step)fpArgs.model={filePath:st.paths.step};+  const rf=await abVerb('kicad_install_footprint',fpArgs);+  if(!(rf.s===200&&rf.j&&rf.j.success))throw new Error('kicad_install_footprint: '+((rf.j&&rf.j.error)||rf.s));+  info.footprint=rf.j.footprintName||st.mpn;+  kicadCache[slug]=info; return info;+}+async function sendKicad(kind){+  const k=await kicadInstall(currentPart);+  const shows={+    'kicad-symbol':   ['kicad_show_symbol',   {symbolName:k.symbol,libraryName:k.lib}],+    'kicad-footprint':['kicad_show_footprint',{footprintName:k.footprint,libraryName:k.lib,library:k.lib}],+    'kicad-3d':       ['kicad_show_3d_chip',  {footprintName:k.footprint,libraryName:k.lib,library:k.lib}],+    'kicad-library':  ['kicad_show_library',  {libraryName:k.lib,partName:k.symbol}],+  };+  const [cmd,args]=shows[kind];+  toast('Opening in KiCad…');+  const r=await abVerb(cmd,args);+  if(!(r.s===200&&r.j&&r.j.success!==false&&!r.j.errorCode))throw new Error(cmd+': '+((r.j&&(r.j.error||r.j.errorCode))||r.s));+  toast('Open in KiCad: '+(kind.replace('kicad-','')));+}+// ---- Fusion: one call builds + proves the library, then poll the verdict ----+async function sendFusion(){+  const st=await stagePart(currentPart);+  if(!st.paths.lbr)throw new Error('no .lbr could be generated for this part');+  const args={libraryPath:st.paths.lbr,name:st.mpn};+  if(st.paths.step)args.stepPath=st.paths.step;+  toast('Asking the Fusion bridge to build the library…');+  const r=await abVerb('fusion_create_library',args);+  if(!(r.s===200&&r.j&&(r.j.started||r.j.success)))throw new Error('fusion_create_library: '+((r.j&&(r.j.error||r.j.errorCode))||r.s));+  let lastStage='';+  for(let i=0;i<60;i++){+    await new Promise(res=>setTimeout(res,3000));+    const pr=await abVerb('fusion_prove_result',{});+    const j=pr.j||{};+    if(j.stage&&j.stage!==lastStage){lastStage=j.stage;toast('Fusion: '+j.stage);}+    if(j.verdict){toast('Fusion library '+(j.verdict==='proven'?'built and PROVEN':'finished: '+j.verdict),j.verdict!=='proven');return;}+    if(j.stage==='done')return void toast('Fusion chain finished');+  }+  toast('Fusion is still working; check the Fusion window',true);+}+document.querySelectorAll('#sendbar button').forEach(b=>{+  b.onclick=async()=>{+    if(!currentPart){toast('Open a part first (Parts tab, or a board’s component)',true);return;}+    b.dataset.busy='1';updateSendbar();+    try{ b.dataset.send==='fusion-library'?await sendFusion():await sendKicad(b.dataset.send); }+    catch(e){toast(String(e.message||e),true);}+    b.dataset.busy='';updateSendbar();+  };+});+ 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;}
src/manage.rs+60−9
@@ -173,7 +173,6 @@ fn route_inner(method: &Method, url: &str, path: &str, body: &str) -> Response<C             dock::claim_named_webview(&name, port_of_server());             json_resp(&json!({"status":"ok","claimed":name}).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) {@@ -182,6 +181,10 @@ fn route_inner(method: &Method, url: &str, path: &str, body: &str) -> Response<C             }         }         (Method::Get, p) if p.starts_with("/api/job/") => json_resp(&dock::job_state(&p["/api/job/".len()..]).to_string()),+        (Method::Get, p) if p.starts_with("/api/export/") => {+            let slug = &p["/api/export/".len()..];+            json_resp(&export_part(slug).to_string())+        }         (Method::Get, "/api/components") => {             // ?refresh=1 busts the server-side cache so newly-pushed components appear             if url.contains("refresh=1") {@@ -1052,12 +1055,7 @@ 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![];-    // 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}));-    };+    let prog = |stage: String, cur: usize| set_progress(json!({"stage":stage,"current":cur,"total":total,"done":false}));      for (symlib, slugs) in &by_symlib {         let mut blocks: Vec<(String, String)> = vec![];@@ -1149,10 +1147,63 @@ 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);+}++/// Everything the toolbar's web-control chains need to stage this part on the+/// user's PC, in one payload: KiCad symbol + footprint text, a generated EAGLE+/// .lbr (for Fusion's fusion_create_library shape A), the page's .step when it+/// has one, and the pin->pad map. The page writes these to the machine through+/// Adom Bridge (write_file / write_file_chunk) and installs by filePath.+fn export_part(slug: &str) -> Value {+    if !valid_slug(slug) {+        return json!({"status":"error","error":format!("invalid slug: {slug:?}")});+    }+    let mpn = up(slug);+    let (part, src) = match load_part(slug, &mpn) {+        Ok(v) => v,+        Err(e) => return json!({"status":"error","error":e}),+    };+    let sym_txt = export_kicad_symbol(&part.symbol);+    let fp_txt = export_kicad_footprint(&part.footprint);+    // pin->pad map from the hub's connect[] (only the disagreements matter)+    let mut pin_map: std::collections::HashMap<String, String> = Default::default();+    for c in &part.connect {+        if c.pin != c.pad {+            pin_map.insert(c.pin.clone(), c.pad.clone());+        }+    }+    // The .lbr for Fusion: same path the CLI takes (parse -> re-layout -> generate)+    let lbr = match crate::parse_kicad_sym(&sym_txt, Some(&mpn)) {+        Ok(mut parsed) => {+            crate::relayout_two_columns(&mut parsed, false);+            Some(crate::generate_lbr(&parsed, Some(&fp_txt), None, 0.38, &pin_map))+        }+        Err(_) => None,+    };+    // The page's STEP model, when it publishes one+    let step_name = list_page_files(slug).into_iter().find(|n| n.to_lowercase().ends_with(".step"));+    let step_b64 = step_name.as_ref().and_then(|n| wiki_bytes(slug, n)).map(|b| crate::b64(&b));+    let mut files = serde_json::Map::new();+    files.insert(format!("{mpn}.kicad_sym"), json!({"b64": crate::b64(sym_txt.as_bytes())}));+    files.insert(format!("{mpn}.kicad_mod"), json!({"b64": crate::b64(fp_txt.as_bytes())}));+    if let Some(l) = &lbr {+        files.insert(format!("{mpn}.lbr"), json!({"b64": crate::b64(l.as_bytes())}));+    }+    if let Some(sb) = &step_b64 {+        files.insert(format!("{mpn}.step"), json!({"b64": sb, "source": step_name}));+    }+    json!({+        "status": "ok",+        "mpn": mpn,+        "slug": slug,+        "source": src,+        "pin_map": pin_map,+        "has_step": step_b64.is_some(),+        "files": files,+        "hints": ["stage each file with write_file (or write_file_chunk over ~1 MB), then kicad_install_symbol / kicad_install_footprint {model} / fusion_create_library"]+    }) }  // ---------------------------------------------------------------------------