← Commit history

2.14.0: full 1.22.1 visual contract; one-call Send to AI; install-or-update; port-ownership retire

John Lauer ·c7531bdc97 ·1mo ago ·parent 7b2e6a7
8 files changed +271−201
Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-lbr"-version = "2.13.0"+version = "2.14.0" dependencies = [  "altium-codec",  "anyhow",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-lbr"-version = "2.13.0"+version = "2.14.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." 
VERSION+1−1
@@ -1 +1 @@-2.13.0+2.14.0
dockbar.json+2−3
@@ -22,9 +22,8 @@     "console": "show",     "steps": [       {-        "label": "Installing 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'",+        "label": "Installing or updating adom-lbr",+        "run": "sh -c 'PATH=\"$HOME/.local/bin:$PATH\"; adom-wiki pkg update adom/adom-lbr || adom-wiki pkg install adom/adom-lbr'",         "timeout_seconds": 180       },       {
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-lbr",-  "version": "2.13.0",+  "version": "2.14.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": {},
page.json+9−12
@@ -1,8 +1,8 @@ {   "slug": "adom-lbr",-  "version": "2.13.0",+  "version": "2.14.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,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+}
src/dock.rs+99−31
@@ -218,27 +218,46 @@ pub fn say_send(phrase: &str) -> Value {             "why": "Send to AI is parked fleet-wide while a crash in the AI panel's send path is fixed. Copied instead; paste it into any AI thread."});     }     let base = control_api_base();-    if let Some(v) = shell_version(&base) {-        if v < MIN_SHELL_VERSION {-            return json!({"ok": false, "parked": true,-                "why": format!("This Hydrogen ({}.{}.{}) predates the fix for a crash in the AI panel's send path; update to 1.0.142 or newer. Copied instead; paste it into any AI thread.", v.0, v.1, v.2)});++    // Gate NUMERICALLY on the updater's current version (a lexical compare+    // passes 1.0.99 as newer than 1.0.142). The crash fix ships in 1.0.142;+    // an older shell gets an honest refusal and the page's copy fallback.+    if let Some(v) = curl_json(&format!("{base}/updater/status"), None, 4)+        .and_then(|j| j.pointer("/updater/current_version").or_else(|| j.pointer("/current_version"))+            .and_then(|x| x.as_str()).map(String::from))+    {+        let nums: Vec<u32> = v.trim_start_matches('v').split('.').filter_map(|p| p.parse().ok()).collect();+        if nums.len() >= 3 && (nums[0], nums[1], nums[2]) < (1u32, 0u32, 142u32) {+            return json!({"ok": false, "shellTooOld": true, "shellVersion": v,+                "why": format!("This Hydrogen ({v}) predates the fix for a crash in the AI panel's send path; update to 1.0.142 or newer. Copied instead; paste it into any AI thread.")});         }     }-    let new_body = json!({"name": "Adom LBR — say it", "tags": ["dock:adom-lbr"]});-    let Some(created) = curl_json(&format!("{base}/ai-threads/new"), Some(&new_body), 4) else {-        return json!({"ok": false, "why": "the AI panel's control API is not reachable from this machine"});-    };-    let sid = created.get("sid").and_then(|s| s.as_str())-        .or_else(|| created.get("id").and_then(|s| s.as_str()))-        .map(String::from);-    let Some(sid) = sid else {-        return json!({"ok": false, "why": format!("the panel did not return a thread id ({})", created.get("error").and_then(|e| e.as_str()).unwrap_or("no sid in reply"))});++    // ONE call: /ai-threads/new never returns an id, so the create and the+    // prompt ride together. 120 s, because the tab create alone can take 60.+    let body = json!({+        "new": true,+        "prompt": phrase,+        "submit": true,+        "name": "Adom LBR - say it",+        "tags": ["dock:adom-lbr"]+    });+    let Some(r) = curl_json(&format!("{base}/ai-threads/run-prompt"), Some(&body), 120) else {+        // NEVER retry blind on a create timeout: a second call can land a+        // second thread. Surface the failure and let the page copy instead.+        return json!({"ok": false, "why": "the AI panel did not answer within 120 s; nothing was sent. Copied instead."});     };-    let run = json!({"sid": sid, "prompt": phrase, "submit": true});-    match curl_json(&format!("{base}/ai-threads/run-prompt"), Some(&run), 6) {-        Some(r) if r.get("ok").and_then(|o| o.as_bool()) != Some(false) => json!({"ok": true, "sid": sid}),-        Some(r) => json!({"ok": false, "why": r.get("error").and_then(|e| e.as_str()).unwrap_or("run-prompt refused"), "sid": sid}),-        None => json!({"ok": false, "why": "run-prompt did not answer", "sid": sid}),+    // Verify by delivered_to, never by the thread's name: a correctly-named tab+    // that delivered nothing, and an unnamed tab that delivered, both happened.+    let delivered = r.get("delivered_to").and_then(|d| d.as_str()).filter(|d| !d.is_empty());+    match delivered {+        Some(d) => json!({"ok": true, "delivered_to": d}),+        None => {+            let why = r.get("_hints").and_then(|h| h.as_str())+                .or_else(|| r.get("error").and_then(|e| e.as_str()))+                .unwrap_or("the AI panel accepted the call but confirmed no delivery");+            json!({"ok": false, "why": format!("{why}. Copied instead.")})+        }     } } @@ -312,6 +331,15 @@ pub fn mark_started() {     *STARTED.lock().unwrap() = Some(Instant::now()); } +/// The SERVER emits the short pill state (status-leds 1.22.1): a page that+/// truncates prose is how "installed and pinned" became "in talled and pinned"+/// on a sibling app. `short` is 1-4 words and is exactly what the pill renders.+fn led_short(label: &str, state: &str, detail: String, short: &str, hint: Option<&str>) -> Value {+    let mut v = led(label, state, detail, hint);+    v["short"] = json!(short);+    v+}+ 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 {@@ -340,8 +368,9 @@ pub fn assets_dir() -> Option<PathBuf> { /// 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));+    leds.push(led_short("Binary", "ok",+        format!("adom-lbr {VERSION} at {}", std::env::current_exe().map(|p| p.display().to_string()).unwrap_or_default()),+        &format!("v{VERSION}"), None));      let wiki_ok = Command::new("curl")         .args(["-s", "-o", "/dev/null", "-w", "%{http_code}", "--max-time", "3", "https://wiki.adom.inc/health"])@@ -350,35 +379,35 @@ fn build_status(library_count: Option<usize>, owner: &str) -> Value {         .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)+        led_short("Wiki", "ok", "wiki.adom.inc answers; components and boards load from it".into(), "answering", None)     } else {-        led("Wiki", "fail", "wiki.adom.inc is not answering, so no components can load".into(),+        led_short("Wiki", "fail", "wiki.adom.inc is not answering, so no components can load".into(), "not answering",             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(),+        led_short("Identity", "warn", "not signed in to the wiki, so My projects cannot be told apart from public ones".into(), "not signed in",             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)+        led_short("Identity", "ok", format!("My projects = boards by {owner}"), "signed in", 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),+        Some(n) => led_short("Library", "ok", format!("{n} wiki components cached"), &format!("{n} parts"), None),+        None => led_short("Library", "ok", "component list warming up from the wiki; the first open fetches it".into(), "warming up", 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(d) => led_short("3D viewer", "ok", format!("viewer assets at {}", d.display()), "assets ok", None),+        None => led_short("3D viewer", "warn", "viewer assets missing: the 3D pane of exported viewers stays empty".into(), "assets missing",             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)+        led_short("Dock", "ok", "started by hand (no dock webview to own)".into(), "standalone", None)     } else {-        led("Dock", "ok", format!("owns the dock webview tab '{dock}'"), None)+        led_short("Dock", "ok", format!("owns the dock webview tab '{dock}'"), "tab owned", None)     });      let worst = |s: &str| leds.iter().any(|l| l.get("state").and_then(|x| x.as_str()) == Some(s));@@ -551,8 +580,13 @@ fn kill(pid: i32, sig: &str) { /// 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.+/// Retire by PORT OWNERSHIP, not by argv: argv is a claim, not a fact (a+/// process can be re-exec'd, renamed, or started by a wrapper). We ask the+/// port who it is first; the argv scan is only the fallback for a pre-2.11+/// instance that cannot identify itself over HTTP. fn retire_stale(port: u16, theirs: &str) -> bool {-    let victims = serve_pids(port);+    let victims = pids_owning_port(port);+    let victims = if victims.is_empty() { serve_pids(port) } else { victims };     if victims.is_empty() {         // Something answers on the port but no server process of ours exists:         // it is not ours to kill, and waiting would not change that.@@ -571,6 +605,40 @@ fn retire_stale(port: u16, theirs: &str) -> bool {     !port_in_use(port) } +/// PIDs actually holding the listening socket, read from /proc: the fact,+/// where argv is only a claim. Empty when we cannot read it (other users'+/// sockets, a restricted /proc), and the caller falls back to argv.+fn pids_owning_port(port: u16) -> Vec<i32> {+    let mut inodes: Vec<String> = vec![];+    for f in ["/proc/net/tcp", "/proc/net/tcp6"] {+        let Ok(txt) = std::fs::read_to_string(f) else { continue };+        for line in txt.lines().skip(1) {+            let cols: Vec<&str> = line.split_whitespace().collect();+            if cols.len() < 10 { continue; }+            let Some(hex_port) = cols[1].split(':').nth(1) else { continue };+            if u16::from_str_radix(hex_port, 16).ok() != Some(port) { continue; }+            if cols[3] != "0A" { continue; }              // 0A = LISTEN+            inodes.push(cols[9].to_string());+        }+    }+    if inodes.is_empty() { return vec![]; }+    let mut pids = vec![];+    let Ok(rd) = std::fs::read_dir("/proc") else { return pids };+    for ent in rd.flatten() {+        let Ok(pid) = ent.file_name().to_string_lossy().parse::<i32>() else { continue };+        let Ok(fds) = std::fs::read_dir(ent.path().join("fd")) else { continue };+        for fd in fds.flatten() {+            if let Ok(target) = std::fs::read_link(fd.path()) {+                let t = target.to_string_lossy();+                if let Some(ino) = t.strip_prefix("socket:[").and_then(|x| x.strip_suffix(']')) {+                    if inodes.iter().any(|i| i == ino) { pids.push(pid); break; }+                }+            }+        }+    }+    pids+}+ /// Does anything ACCEPT a TCP connection on the port? Only a completed /// connect counts. Refused means free, and so does a timeout: under WSL2 /// mirrored networking a connect to a free loopback port can hang instead of
src/manage.html+157−151
@@ -9,7 +9,8 @@ <style>   :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}+        --brand-heading:"Familjen Grotesk",-apple-system,Segoe UI,Roboto,sans-serif;--brand-body:Satoshi,-apple-system,Segoe UI,Roboto,sans-serif;+        --line:#262d36;--muted:#8b949e;--teal:#00b8b1}   *{box-sizing:border-box}   /* House scrollbars everywhere (the wiki's slim dark pill; scrollbars sub-skill, John 2026-08-18) */   *{scrollbar-width:thin;scrollbar-color:rgba(139,148,158,.38) transparent}@@ -21,10 +22,10 @@   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{position:relative;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 .bs{color:var(--mut);font-weight:400;font-size:12px;font-family:var(--brand-body)}+  #hdr{height:44px;min-height:44px;display:flex;align-items:center;gap:12px;padding:0 16px;background:var(--panel);border-bottom:1px solid var(--edge);margin:0;border-radius:0}+  #hdr .brand{position:relative;display:flex;align-items:center;gap:8px;font-family:var(--brand-heading);font-weight:600;font-size:15px;color:var(--text);letter-spacing:0}+  #hdr .brand img{width:18px;height:18px;display:block}+  #hdr .brand .bs{color:var(--mut);font-weight:400;font-size:13px;font-family:var(--brand-body);margin-left:0}   #brandpop{position:absolute;top:calc(100% + 8px);left:0;z-index:1500;background:var(--elevated);border:1px solid var(--border);border-radius:9px;padding:10px 13px;font:400 12px/1.6 var(--brand-body);color:var(--text-body);box-shadow:0 12px 32px #000a;white-space:nowrap}   #brandpop a{color:var(--acc)}   #hdr .chip{display:inline-flex;align-items:center;gap:6px;margin-left:14px;font:11.5px ui-monospace,monospace;color:var(--mut);background:#0d0f14;border:1px solid var(--edge);border-radius:999px;padding:3px 10px}@@ -46,96 +47,46 @@   /* graduation: collapsed scaffolding one-liners */   .foldline{display:none;margin:10px 18px 2px;padding:7px 14px;background:var(--panel);border:1px solid var(--edge);border-radius:999px;font-size:12px;color:var(--text-2);cursor:pointer;width:fit-content}   .foldline:hover{border-color:var(--acc);color:var(--ink)}-  .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{background:none;border:1px solid transparent;color:var(--text-2);width:28px;height:28px;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 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:16px 18px 14px;padding:16px 48px 16px 20px;animation:fadein .25s ease}+  .iconbtn.on{color:var(--teal);background:rgba(0,184,177,.10);border-color:rgba(0,184,177,.35)}+  /* ---- The newbie panel (newbie-panel 1.22.1): one full-width band, two columns ---- */+  #content{padding:20px 20px 24px}          /* the CONTENT owns the padding, not the panel */+  #say{position:relative;background:var(--surface);border:1px solid var(--line);border-left:3px solid var(--teal);border-radius:12px;padding:18px 20px 20px;margin:0 0 20px}   #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:11px}-  .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)}-  .say-list{display:flex;flex-wrap:wrap;gap:9px}-  .say-chip{display:inline-flex;align-items:center;gap:8px;background:#0d0f14;border:1px solid var(--border);border-radius:999px;padding:5px 6px 5px 12px;font:500 13.5px var(--brand-body);color:var(--ink);margin:4px 6px 0 0;cursor:pointer;transition:border-color .15s}-  .say-chip:hover{border-color:var(--accent)}-  .say-chip em{color:var(--acc);font-style:normal}-  .say-chip .copy,.say-chip .send{font:500 11px var(--brand-body);background:none;border:1px solid var(--border);border-radius:999px;padding:2px 9px;cursor:pointer;transition:color .12s,border-color .12s,background .12s}-  .say-chip .copy{color:var(--text-2)}-  .say-chip .send{color:var(--acc);border-color:rgba(0,184,177,.45)}-  .say-chip:hover .copy{color:var(--acc);border-color:var(--acc)}-  .say-chip .send:hover{background:rgba(0,184,177,.12)}-  .say-chip .copy:active,.say-chip .send:active{transform:translateY(1px)}-  #demo-row{margin:10px 18px 2px}-  #demo-row button{display:inline-flex;align-items:center;gap:7px;background:#12302f;border:1px solid var(--acc);color:var(--acc);border-radius:9px;padding:7px 16px;font:700 13px var(--brand-heading);cursor:pointer;transition:filter .12s,transform .06s}-  #demo-row button:hover{filter:brightness(1.2)}-  #demo-row button:active{transform:translateY(1px)}-  /* ---- Send progress panel: bar + live console + final-frame evidence ---- */-  #sendpanel{position:fixed;right:14px;bottom:14px;z-index:1500;width:min(480px,92vw);background:var(--elevated);border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:12px;box-shadow:0 16px 48px #000c;display:none}-  #sendpanel.show{display:block}-  #sendpanel .sp-head{display:flex;align-items:center;gap:10px;padding:10px 12px 8px 14px}-  #sendpanel .sp-head h3{margin:0;font:600 13.5px var(--brand-heading);color:var(--text);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}-  #sendpanel .sp-body{padding:0 14px 12px}-  .sp-bar{height:6px;border-radius:999px;background:var(--overlay);overflow:hidden;margin:2px 0 4px}-  .sp-bar i{display:block;height:100%;background:var(--accent);border-radius:999px;width:0%;transition:width .4s ease}-  .sp-bar.ind i{width:30%;animation:spslide 1.2s ease-in-out infinite alternate}-  @keyframes spslide{from{margin-left:0}to{margin-left:70%}}-  .sp-step{font:11.5px var(--brand-body);color:var(--text-2);margin-bottom:6px;min-height:15px}-  #sp-console{background:#0b0e13;border:1px solid var(--border);border-radius:8px;padding:8px 10px;font:11px/1.5 ui-monospace,monospace;color:#c9d1d9;max-height:150px;overflow:auto;white-space:pre-wrap;margin-bottom:8px}-  #sp-shots{display:flex;gap:8px;flex-wrap:wrap}-  #sp-shots figure{margin:0;width:104px}-  #sp-shots img{width:104px;height:70px;object-fit:cover;border:1px solid var(--border);border-radius:6px;background:#0d0f14;cursor:zoom-in;display:block}-  #sp-shots img:hover{border-color:var(--accent)}-  #sp-shots figcaption{font:10px var(--brand-body);color:var(--text-2);margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}-  #sp-zoom{position:fixed;inset:0;z-index:4000;background:rgba(0,0,0,.75);display:none;align-items:center;justify-content:center;cursor:zoom-out}-  #sp-zoom img{max-width:92vw;max-height:92vh;border:1px solid var(--border);border-radius:8px}-  /* ---- Demo tour: spotlight ring + narration card ---- */-  #tour-ring{position:fixed;z-index:3000;border:2px solid var(--accent);border-radius:10px;box-shadow:0 0 0 100vmax rgba(0,0,0,.55),0 0 18px var(--accent);pointer-events:none;transition:all .35s ease;display:none}-  #tour-card{position:fixed;z-index:3001;width:min(400px,90vw);background:var(--elevated);border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:12px;padding:14px 16px;box-shadow:0 16px 48px #000c;display:none}-  #tour-card h3{margin:0 0 6px;font:700 15px var(--brand-heading);color:var(--text);display:flex;align-items:center;gap:8px}-  #tour-card .step-n{font:600 10px var(--brand-body);color:var(--on-accent);background:var(--accent);border-radius:999px;padding:2px 8px}-  #tour-card p{margin:0 0 12px;font:13px/1.55 var(--brand-body);color:var(--text-body)}-  #tour-card p b{color:var(--text)}-  #tour-card .row{display:flex;gap:8px;align-items:center}-  #tour-card .row .spacer{flex:1;font:11px var(--brand-body);color:var(--text-2)}-  #tour-card button{background:#1a2130;border:1px solid var(--border);color:var(--ink);border-radius:7px;padding:6px 14px;font:600 12.5px var(--brand-heading);cursor:pointer}-  #tour-card button:hover{border-color:var(--accent)}-  #tour-card button.primary{background:var(--accent);color:var(--on-accent);border-color:var(--accent)}-  #tour-card button:active{transform:translateY(1px)}-  #tour-bar{position:fixed;left:50%;bottom:18px;transform:translateX(-50%);z-index:3002;display:none;align-items:center;gap:6px;background:var(--elevated);border:1px solid var(--border);border-radius:999px;padding:6px 10px;box-shadow:0 12px 36px #000c}-  #tour-bar.show{display:flex}-  #tour-bar button{width:34px;height:34px;border-radius:50%;border:1px solid var(--border);background:#1a2130;color:var(--ink);cursor:pointer;display:inline-flex;align-items:center;justify-content:center;transition:border-color .12s,background .12s,transform .06s}-  #tour-bar button:hover{border-color:var(--accent);color:var(--accent)}-  #tour-bar button:active{transform:translateY(1px)}-  #tour-bar button.play{background:var(--accent);color:var(--on-accent);border-color:var(--accent)}-  #tour-bar .tn{font:600 11.5px var(--brand-body);color:var(--text-2);padding:0 6px;min-width:44px;text-align:center}-  #btnDemo{display:inline-flex;align-items:center;gap:6px;background:#12302f;border:1px solid var(--acc);color:var(--acc);border-radius:8px;padding:4px 12px;font:700 12px var(--brand-heading);cursor:pointer;transition:filter .12s,transform .06s}-  #btnDemo:hover{filter:brightness(1.2)}-  #btnDemo: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:8px 18px;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)}-  .splitgrp{position:relative;gap:0}-  .splitgrp>button:first-child{border-radius:7px 0 0 7px;border-right:none}-  .splitgrp .caret{border-radius:0 7px 7px 0;padding:5px 6px;display:inline-flex;align-items:center}-  .dropmenu{position:absolute;top:calc(100% + 6px);left:0;z-index:1200;min-width:180px;background:var(--elevated);border:1px solid var(--border);border-radius:9px;box-shadow:0 12px 32px #000a;padding:5px;display:flex;flex-direction:column;gap:2px}-  .dropmenu[hidden]{display:none}-  .dropmenu button{text-align:left;background:none;border:none;border-radius:6px;padding:7px 10px;color:var(--ink);font-size:12.5px;cursor:pointer}-  .dropmenu button:hover:not(:disabled){background:#12302f;color:var(--acc)}-  .dropmenu button:disabled{opacity:.45;cursor:not-allowed}+  #say h2{margin:0;font:600 15px var(--brand-heading);color:var(--text)}+  #say .say-sub{margin:4px 0 14px;font:13.5px var(--brand-body);color:var(--muted);max-width:70ch}+  #say .say-sub b{color:var(--teal);font-weight:700}+  .say-close{position:absolute;top:12px;right:12px;width:28px;height:28px}+  .say-cols{display:grid;grid-template-columns:220px 1fr;gap:24px;align-items:start}+  .say-right{border-left:1px solid var(--line);padding-left:24px;min-width:0}+  #btnDemo{display:inline-flex;align-items:center;gap:8px;background:var(--teal);color:var(--on-accent);border:none;border-radius:8px;padding:10px 18px;font:600 14px var(--brand-heading);cursor:pointer;transition:filter .12s,transform .06s}+  #btnDemo:hover:not(:disabled){filter:brightness(1.08)}+  #btnDemo:active:not(:disabled){transform:translateY(1px)}+  #btnDemo:disabled{opacity:.65;cursor:default}+  #btnDemo:focus-visible{outline:2px solid var(--teal);outline-offset:2px}+  .say-dur{margin-top:8px;font:12px var(--brand-body);color:var(--muted)}+  /* prompts TABLE: one left edge, one row height, actions tracking the phrase */+  .say-row{display:grid;grid-template-columns:minmax(0,max-content) 72px;align-items:center;height:40px;border-bottom:1px solid var(--line);cursor:pointer}+  .say-row:last-child{border-bottom:none}+  .say-row:hover{background:rgba(255,255,255,.03)}+  .say-row .ph{font:14px var(--brand-body);color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;padding-right:16px;min-width:0}+  .say-row .ph em{color:var(--teal);font-style:normal}+  .say-row .acts{display:flex;gap:6px}+  .actbtn{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:6px;border:1px solid transparent;background:none;color:var(--muted);cursor:pointer;transition:color .12s,border-color .12s,background .12s}+  .actbtn:hover{color:var(--teal);border-color:var(--line);background:rgba(0,184,177,.08)}+  .actbtn:active{transform:translateY(1px)}+  .actbtn:focus-visible{outline:2px solid var(--teal);outline-offset:2px}+  @media (max-width:760px){+    .say-cols{grid-template-columns:1fr}+    .say-right{border-left:none;border-top:1px solid var(--line);padding-left:0;padding-top:16px}+    #btnDemo{width:100%;justify-content:center}+    .actbtn{padding:8px;width:44px;height:44px}+  }+  /* tooltips (tooltips skill): 600ms, body-appended, clamped */+  #tip2{position:fixed;z-index:5000;background:var(--overlay);border:1px solid var(--line);color:var(--text);font:12px var(--brand-body);padding:5px 9px;border-radius:6px;pointer-events:none;display:none;max-width:280px}   /* 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}@@ -143,10 +94,15 @@   #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)}-  #toast.success{border-color:var(--green);color:var(--green)}+  #toasts{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:400;display:flex;flex-direction:column-reverse;gap:8px;pointer-events:none}+  .toast{display:flex;align-items:flex-start;gap:9px;background:var(--surface);border:1px solid var(--line);border-left:3px solid var(--muted);border-radius:9px;padding:10px 14px;font:13px var(--brand-body);color:var(--text);max-width:min(560px,86vw);box-shadow:0 12px 32px #0009;opacity:0;transform:translateY(8px);transition:opacity .18s ease,transform .18s ease;pointer-events:auto}+  .toast.in{opacity:1;transform:translateY(0)}+  .toast.out{opacity:0;transition:opacity .14s ease}+  .toast svg{flex:none;margin-top:1px}+  .toast.success{border-left-color:var(--green)} .toast.success svg{color:var(--green)}+  .toast.error{border-left-color:var(--red)}     .toast.error svg{color:var(--red)}+  .toast.info{border-left-color:var(--teal)}     .toast.info svg{color:var(--teal)}+  @media (prefers-reduced-motion:reduce){.toast{transition:none;transform:none}}   /* ---- 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)}@@ -299,8 +255,8 @@     </div>     <span class="chip" id="targetchip" title="Which machine Adom Bridge answers on for the Send-to buttons"><i id="tdot"></i><span class="tc">target:</span> <span id="tname">looking…</span></span>     <span style="margin-left:auto"></span>-    <button class="iconbtn" id="btnHelp" title="Bring back the getting-started helpers for this session" aria-label="Help">?</button>-    <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,8A4,4 0 0,1 16,12A4,4 0 0,1 12,16A4,4 0 0,1 8,12A4,4 0 0,1 12,8M12,10A2,2 0 0,0 10,12A2,2 0 0,0 12,14A2,2 0 0,0 14,12A2,2 0 0,0 12,10M10,22C9.75,22 9.54,21.82 9.5,21.58L9.13,18.93C8.5,18.68 7.96,18.34 7.44,17.94L4.95,18.95C4.73,19.03 4.46,18.95 4.34,18.73L2.34,15.27C2.21,15.05 2.27,14.78 2.46,14.63L4.57,12.97L4.5,12L4.57,11L2.46,9.37C2.27,9.22 2.21,8.95 2.34,8.73L4.34,5.27C4.46,5.05 4.73,4.96 4.95,5.05L7.44,6.05C7.96,5.66 8.5,5.32 9.13,5.07L9.5,2.42C9.54,2.18 9.75,2 10,2H14C14.25,2 14.46,2.18 14.5,2.42L14.87,5.07C15.5,5.32 16.04,5.66 16.56,6.05L19.05,5.05C19.27,4.96 19.54,5.05 19.66,5.27L21.66,8.73C21.79,8.95 21.73,9.22 21.54,9.37L19.43,11L19.5,12L19.43,12.97L21.54,14.63C21.73,14.78 21.79,15.05 21.66,15.27L19.66,18.73C19.54,18.95 19.27,19.04 19.05,18.95L16.56,17.95C16.04,18.34 15.5,18.68 14.87,18.93L14.5,21.58C14.46,21.82 14.25,22 14,22H10M11.25,4L10.88,6.61C9.68,6.86 8.62,7.5 7.85,8.39L5.44,7.35L4.69,8.65L6.8,10.2C6.4,11.37 6.4,12.64 6.8,13.8L4.68,15.36L5.43,16.66L7.86,15.62C8.63,16.5 9.68,17.14 10.87,17.38L11.24,20H12.76L13.13,17.39C14.32,17.14 15.37,16.5 16.14,15.62L18.57,16.66L19.32,15.36L17.2,13.81C17.6,12.64 17.6,11.37 17.2,10.2L19.31,8.65L18.56,7.35L16.15,8.39C15.38,7.5 14.32,6.86 13.12,6.62L12.75,4H11.25Z"/></svg></button>+    <button class="iconbtn" id="btnHelp" aria-pressed="true" aria-label="Hide the prompts panel" data-tooltip="Hide the prompts panel"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M9.6 9.2a2.5 2.5 0 1 1 3.3 2.4c-.6.2-.9.7-.9 1.3v.6"/><path d="M12 17h.01"/></svg></button>+    <button class="iconbtn" id="btnSettings" aria-label="Settings" data-tooltip="Settings"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/> <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>   </header>   <nav id="sendbar" aria-label="Send the open part to your EDA tools">     <span class="lab">Send to KiCad</span>@@ -319,15 +275,20 @@       <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>   </nav>+  <div id="content">   <div id="scafwrap">-  <section id="say" aria-labelledby="sayTitle" hidden>-    <button class="iconbtn say-close" id="sayClose" aria-label="Collapse the Say it strip" title="Collapse. The ? in the header brings it back."><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</h2>-    <span class="say-sub">You never need this page's buttons: just tell your AI what you want, using the word <b>lbr</b>. Copy a phrase, or send it straight to a new AI thread.</span></div>-    <div class="say-list" id="phrases"></div>+  <section id="say" aria-labelledby="sayTitle">+    <button class="iconbtn say-close" id="sayClose" aria-label="Close the prompts panel" data-tooltip="Close the prompts panel"><svg viewBox="0 0 24 24" width="16" height="16" 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>+    <h2 id="sayTitle">Say it</h2>+    <p class="say-sub">You never need this page's buttons: just tell your AI what you want, using the word <b>lbr</b>. Copy a phrase, or send it straight to a new AI thread.</p>+    <div class="say-cols">+      <div class="say-left">+        <button id="btnDemo" data-tooltip="A narrated, self-driving tour of this dashboard"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 4.8v14.4a.6.6 0 0 0 .92.5l11.3-7.2a.6.6 0 0 0 0-1l-11.3-7.2a.6.6 0 0 0-.92.5z"/></svg><span id="demoLabel">Play the demo</span></button>+        <div class="say-dur">13 steps, about 2 minutes</div>+      </div>+      <div class="say-right"><div id="phrases" role="table" aria-label="Sample AI prompts"></div></div>+    </div>   </section>-  <div id="demo-row"><button id="btnDemo" title="A narrated, self-driving tour of everything this dashboard can do"><svg viewBox="0 0 24 24" width="13" height="13" aria-hidden="true"><path fill="currentColor" d="M8 5v14l11-7z"/></svg>Play the demo</button></div>-  <button class="foldline" id="say-fold">Say it — <span id="fold-n">6</span> phrases · tap to expand</button>   </div>   <div class="ledrow" id="ledrow" title="live status; tap a pill for the detail"></div>   <div id="led-pop" role="dialog"></div>@@ -348,7 +309,9 @@   </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>+  </div>+  <div id="toasts" role="status" aria-live="polite"></div>+  <div id="tip2"></div>   <div id="sendpanel" role="status" aria-live="polite">     <div class="sp-head"><h3 id="sp-title"></h3><button class="iconbtn" id="sp-close" aria-label="Close" title="Close"><svg viewBox="0 0 24 24" width="16" height="16" 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="sp-body">@@ -752,20 +715,27 @@ async function loadSample(){ loadList(); loadSample();  // ===================== dock contract: LEDs, say, settings =====================-let toastT=null;-function toast(msg,err,kind){const t=document.getElementById('toast');-  const k=kind||(err?'err':'');-  t.innerHTML=(k==='success'?'<svg viewBox="0 0 24 24" width="15" height="15" style="vertical-align:-2px;margin-right:7px" aria-hidden="true"><path fill="currentColor" d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>':'')+msg;-  t.className='show'+(k?' '+k:'');clearTimeout(toastT);toastT=setTimeout(()=>t.className='',k==='success'?4000:2600);}-async function api(path,opts){const r=await fetch(path,Object.assign({},opts,{signal:AbortSignal.timeout(5000)}));return r.json();}--// ---- status pill row (status-leds contract): compact pills, prose on tap -----let lastStatus=null;-const busySince={};   // stuck? escalation: busy longer than 15 min-function shortState(l){-  if(l.state==='ok')return l.label==='Binary'?'v'+(lastStatus?lastStatus.version:''):'ok';-  if(l.state==='warn')return 'attention';-  return 'broken';+// toasts skill: bottom centre 24px, newest nearest the bottom, max 3 visible,+// colour ONLY in the 16px line icon and the 3px left border.+const TOAST_ICONS={+  success:'<path d="M20 6 9 17l-5-5"/>',+  error:'<circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16h.01"/>',+  info:'<circle cx="12" cy="12" r="9"/><path d="M12 11v5M12 8h.01"/>'+};+function toast(msg,err,kind){+  const k=kind||(err?'error':'info');+  const wrap=document.getElementById('toasts');+  while(wrap.children.length>=3)wrap.firstChild.remove();+  const t=document.createElement('div');t.className='toast '+k;+  t.innerHTML='<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">'+TOAST_ICONS[k]+'</svg><span></span>';+  t.querySelector('span').textContent=msg;+  t.onclick=()=>fade(t);+  wrap.appendChild(t);+  requestAnimationFrame(()=>t.classList.add('in'));+  const life=k==='error'?7000:4000;+  const timer=setTimeout(()=>fade(t),life);+  t.addEventListener('mouseenter',()=>clearTimeout(timer));+  function fade(el){el.classList.add('out');setTimeout(()=>el.remove(),160);} } function paintLeds(st){   const row=document.getElementById('ledrow');row.innerHTML='';@@ -836,19 +806,32 @@ function gradLaunchTick(st){ } // AI-driven graduation: the first Send-to run or Send-to-AI success counts. function gradAiDriven(){ if(!gradState().aiDriven){gradSave({aiDriven:true});applyGraduation();} }+// ONE state, two controls (the panel X and the header toggle). Closing is a+// FULL close: no stub, no residual one-liner; the app UI moves up and the+// content padding is unchanged because it lives on #content, not the panel.+function panelOpen(){+  const g=gradState(),n=g.launches||0,dism=(g.dismissed||{}),ai=!!g.aiDriven;+  const allowed=settings?settings.show_say_strip!==false:true;+  return gradRecall||(allowed&&!dism.say&&!ai&&n<5);+} function applyGraduation(){-  const g=gradState(),n=g.launches||0;-  const dism=(g.dismissed||{});-  const showSettings=settings?settings.show_say_strip!==false:true;-  const ai=!!g.aiDriven;-  const sayFresh=gradRecall||(showSettings&&!dism.say&&!ai&&n<5);-  const demoFresh=gradRecall||(!dism.say&&!ai&&n<3);-  document.getElementById('say').hidden=!sayFresh;-  document.getElementById('demo-row').style.display=demoFresh?'':'none';-  document.getElementById('say-fold').style.display=sayFresh?'none':'inline-block';+  const open=panelOpen();+  document.getElementById('say').hidden=!open;+  const t=document.getElementById('btnHelp');+  t.setAttribute('aria-pressed',open?'true':'false');+  const label=open?'Hide the prompts panel':'Show the prompts panel';+  t.setAttribute('aria-label',label);t.dataset.tooltip=label;+  t.classList.toggle('on',open);+}+// The header control is a real TOGGLE: pressing it while open closes it.+document.getElementById('btnHelp').onclick=()=>{+  if(panelOpen()){closePanel();}+  else{gradRecall=true;applyGraduation();}+};+function closePanel(){+  const g=gradState();gradSave({dismissed:Object.assign(g.dismissed||{},{say:true})});+  gradRecall=false;applyGraduation(); }-document.getElementById('btnHelp').onclick=()=>{gradRecall=true;applyGraduation();toast('Helpers are back for this session.',false,'success');};-document.getElementById('say-fold').onclick=()=>{gradRecall=true;applyGraduation();}; 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 ----@@ -869,41 +852,43 @@ async function copyText(t){ } function applySayVisibility(){ applyGraduation(); } document.getElementById('sayClose').onclick=()=>{-  const g=gradState();gradSave({dismissed:Object.assign(g.dismissed||{}, {say:true})});-  gradRecall=false;applyGraduation();-  toast('Collapsed. The ? in the header brings it back.',false,'success');+  closePanel();+  toast('Panel closed. The button left of the cog brings it back.',false,'success'); }; async function loadSay(){   try{     const r=await api('api/say');const el=document.getElementById('phrases');el.innerHTML='';     const noun=r.noun||'lbr';-    document.getElementById('fold-n').textContent=(r.say||[]).length;-    (r.say||[]).forEach(p=>{-      const w=document.createElement('span');w.className='say-chip';w.dataset.say=p;-      const quoted=('\u201C'+p+'\u201D').replace(new RegExp('\\b'+noun+'\\b','g'),'<em>'+noun+'</em>');-      const t=document.createElement('span');t.innerHTML=quoted;+    (r.say||[]).slice(0,6).forEach(p=>{+      const row=document.createElement('div');row.className='say-row';row.setAttribute('role','row');row.dataset.say=p;+      const ph=document.createElement('div');ph.className='ph';ph.setAttribute('role','cell');+      ph.innerHTML=('\u201C'+p+'\u201D').replace(new RegExp('\\b'+noun+'\\b','g'),'<em>'+noun+'</em>');+      ph.title=p;                                  // full text, no custom tooltip here+      const acts=document.createElement('div');acts.className='acts';acts.setAttribute('role','cell');+      const mk=(svg,label,fn)=>{+        const b=document.createElement('button');b.className='actbtn';+        b.setAttribute('aria-label',label);b.dataset.tooltip=label;   // never title + data-tooltip+        b.innerHTML='<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">'+svg+'</svg>';+        b.onclick=e=>{e.stopPropagation();fn(b);};return b;+      };       const doCopy=async()=>{const ok=await copyText(p);if(ok)toast('Copied to clipboard. Paste it into any AI thread.',false,'success');else toast('Could not copy; select it by hand',true);};-      w.onclick=doCopy;   // largest hit target does the safest thing-      const c=document.createElement('button');c.className='copy';c.textContent='Copy';-      c.onclick=e=>{e.stopPropagation();doCopy();};-      // Send to AI (John's ruling 2026-08-24): a NEW thread via the panel's-      // AI-threads API, through our backend. New threads only; resume stays parked.-      const b=document.createElement('button');b.className='send';b.textContent='Send to AI';-      b.title='opens a new AI thread and sends this phrase';-      b.onclick=async e=>{-        e.stopPropagation();b.disabled=true;+      row.onclick=doCopy;                          // the whole row copies+      acts.appendChild(mk(COPY_SVG,'Copy this prompt to your clipboard',doCopy));+      acts.appendChild(mk(SEND_SVG,'Send this prompt to a new AI thread',async b=>{+        b.disabled=true;         try{-          const res=await api('api/say-send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({phrase:p})});+          const res=await api('api/say-send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({phrase:p}),timeoutMs:120000});           if(res&&res.ok){toast('Sent to a new AI thread.',false,'success');gradAiDriven();}           else{await copyText(p);toast((res&&res.why)||'Could not reach the AI panel; copied instead.',true);}         }catch(err){await copyText(p);toast('Could not reach the AI panel; copied instead.',true);}         b.disabled=false;-      };-      w.appendChild(t);w.appendChild(c);w.appendChild(b);-      el.appendChild(w);+      }));+      row.appendChild(ph);row.appendChild(acts);el.appendChild(row);     });   }catch(e){} }+const COPY_SVG="<rect x=\"9\" y=\"9\" width=\"12\" height=\"12\" rx=\"2.5\"/> <path d=\"M6 15H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v1\"/>";+const SEND_SVG="<path d=\"M21.5 2.5 10.5 13.5\"/> <path d=\"M21.5 2.5 14.8 21.5l-4.3-8-8-4.3z\"/>"; loadSay();  // ---- Settings modal: native <dialog>, dim never blur, four dismissals ----@@ -1293,6 +1278,7 @@ function tourStop(){   document.getElementById('tour-ring').style.display='none';   document.getElementById('tour-card').style.display='none';   document.getElementById('tour-bar').classList.remove('show');+  demoBtnRunning(false); } async function tourShow(i){   const prev=TOUR[tourI];@@ -1317,6 +1303,7 @@ async function tourShow(i){   document.getElementById('tour-progress').textContent=(i+1)+' / '+TOUR.length;   card.style.display='block';   document.getElementById('tour-bar').classList.add('show');+  demoBtnRunning(true);   tourPaintPlay();   // pre-rendered narration (adom-tts, shipped with the app - nothing generated live)   clearTimeout(tourTimer);@@ -1337,7 +1324,26 @@ async function tourShow(i){   if(y+ch>innerHeight-12)y=Math.max(12,rr.top-ch-14);   card.style.left=x+'px';card.style.top=y+'px'; }+// tooltips (tooltips skill): 600ms delay, fixed div, clamped to the viewport+(function(){+  const tip=document.getElementById('tip2');let t=null;+  document.addEventListener('mouseover',e=>{+    const el=e.target.closest('[data-tooltip]');if(!el)return;+    clearTimeout(t);t=setTimeout(()=>{+      tip.textContent=el.dataset.tooltip;tip.style.display='block';+      const r=el.getBoundingClientRect(),w=tip.offsetWidth,h=tip.offsetHeight;+      tip.style.left=Math.max(8,Math.min(innerWidth-w-8,r.left+r.width/2-w/2))+'px';+      tip.style.top=(r.bottom+8+h>innerHeight?r.top-h-8:r.bottom+8)+'px';+    },600);+  });+  document.addEventListener('mouseout',e=>{if(e.target.closest('[data-tooltip]')){clearTimeout(t);tip.style.display='none';}});+  document.addEventListener('click',()=>{clearTimeout(t);tip.style.display='none';});+})(); document.getElementById('btnDemo').onclick=()=>{tourPlaying=true;tourShow(0);};+function demoBtnRunning(on){+  const b=document.getElementById('btnDemo'),l=document.getElementById('demoLabel');+  if(!b||!l)return; b.disabled=on; l.textContent=on?'Demo running':'Play the demo';+} document.getElementById('tour-next').onclick=()=>{ if(tourI<TOUR.length-1)tourShow(tourI+1); else tourStop(); }; document.getElementById('tour-prev').onclick=()=>{ if(tourI>0)tourShow(tourI-1); }; document.getElementById('tour-play').onclick=()=>{