app
Adom Library
Public Made by Adomby adom
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.
← Commit history
2.14.0: full 1.22.1 visual contract; one-call Send to AI; install-or-update; port-ownership retire
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" } ]-}+}
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=()=>{