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.19.0: newbie-panel module 2.21.0, byte-faithful control proxies, per-path OPTIONS
12 files changed
+301−73
.gitignore+1@@ -2,3 +2,4 @@ **/*.rs.bk *.tmp FUSION_BRIDGE_FEEDBACK.md+tools/node_modules
Cargo.lock+1−1@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-lbr"-version = "2.18.0"+version = "2.19.0" dependencies = [ "altium-codec", "anyhow",
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-lbr"-version = "2.18.0"+version = "2.19.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.18.0+2.19.0
bin/adom-lbr⋯ 1 unchanged line ⋯
crates/altium-schlib/src/lib.rs+1−1@@ -338,7 +338,7 @@ pub fn to_kicad_sym(sym: &serde_json::Value, lib_name: &str) -> String { let pin_len = 5.08_f64; let mut body = String::new();- let mut emit = |bank: &Vec<&serde_json::Value>, is_left: bool, out: &mut String| {+ let emit = |bank: &Vec<&serde_json::Value>, is_left: bool, out: &mut String| { for (idx, p) in bank.iter().enumerate() { let num = p.get("number").and_then(|v| v.as_str()).unwrap_or(""); let name = p.get("name").and_then(|v| v.as_str()).unwrap_or("");
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "adom-lbr",- "version": "2.18.0",+ "version": "2.19.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+7−7@@ -1,8 +1,8 @@ { "slug": "adom-lbr",- "version": "2.18.0",+ "version": "2.19.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",@@ -61,15 +61,15 @@ "screenshots/**", "dockbar.json" ],- "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,11 +77,11 @@ }, { "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",
src/dock.rs+62−47@@ -178,65 +178,80 @@ fn control_api_base() -> String { /// thread named for the app and tagged dock:adom-lbr, then submit the phrase. /// NEW THREADS ONLY - resuming or typing into existing threads stays parked /// (try_phrase below remains dormant behind TRY_PARKED).-/// UN-PARKED 2026-08-24 on John's ruling, after the ah build thread certified-/// the crash family dead on the gate-fixed build (33ac2074 gated six raw-CDP-/// typing helpers that ran outside the concurrency gate) and the dock thread-/// verified the installed binary by code marker rather than the version stamp.-/// Kept as a const so a future park is one line again.-pub const SAY_SEND_PARKED: bool = false;+/// Un-parked 2026-08-24 on John's ruling; the parked early-return is DELETED+/// rather than flag-flipped (adom-ui-design 1.30.0: a dead branch cannot fail+/// under test, so a flag set false proves nothing). A future park re-adds the+/// branch as its own commit. -/// Hydrogen builds BEFORE the gate fix still carry the crash, and published-/// 1.0.141 predates it. A pre-fix shell gets an honest refusal here rather-/// than a dead editor; the page then copies the phrase as it always did.-/// Un-gated once every user is past 1.0.142.-const MIN_SHELL_VERSION: (u32, u32, u32) = (1, 0, 142);--fn parse_ver(v: &str) -> Option<(u32, u32, u32)> {- let core = v.trim().trim_start_matches('v');- let core = core.split(|c: char| c == '-' || c == '+' || c == ' ').next()?;- let mut it = core.split('.').map(|p| p.parse::<u32>().ok());- Some((it.next()??, it.next()??, it.next().flatten().unwrap_or(0)))-}--/// The shell's version, from the control API. None means "could not tell",-/// which is NOT treated as too old: an unknown shell still gets the attempt,-/// because the crash is the exception and refusing everyone on a probe miss-/// would break the feature for everybody.-fn shell_version(base: &str) -> Option<(u32, u32, u32)> {- let v = curl_json(&format!("{base}/version"), None, 2)?;- let s = v.get("version").or_else(|| v.get("hydrogenVersion")).or_else(|| v.get("app"))- .and_then(|x| x.as_str())?;- parse_ver(s)-}--/// Transparent proxies for the SHARED newbie-panel module (adom-ui-design 2.0.0).+/// Transparent proxies for the SHARED newbie-panel module (adom-ui-design 2.0.0+). /// The module speaks to the Hydrogen control API directly, which a page served from /// this server cannot do cross-origin, so these two routes put the control API on the-/// page's OWN origin and pass the payloads through UNCHANGED. Unchanged matters: the-/// module reads delivered_to itself and distinguishes delivered from opened-but--/// unconfirmed, and reshaping the JSON here would collapse three states into two and-/// make it retry a send that actually landed.-pub fn control_get(path: &str) -> Value {- let base = control_api_base();- curl_json(&format!("{base}{path}"), None, 5)- .unwrap_or_else(|| json!({"error": format!("the Hydrogen control API did not answer {path}")}))+/// page's OWN origin and pass BYTES through unchanged, both directions, status code+/// and Content-Type included (control-api-proxy contract). A parse-then-re-emit here+/// alphabetises keys, loses integer precision past 2^53, and turns a non-JSON upstream+/// body into an invented error object; the module reads delivered_to itself and+/// distinguishes delivered from opened-but-unconfirmed, so any reshaping makes it+/// retry a send that actually landed. Parse for what YOU decide (say_send below does),+/// never for what you hand back.+pub struct ProxyReply {+ pub status: u16,+ pub content_type: String,+ pub body: Vec<u8>, } -pub fn control_post(path: &str, body: &Value) -> Value {+/// Err means the control API is unreachable on this host; the route answers 503+/// with a hint (never 404, which is indistinguishable from "route never wired").+pub fn control_proxy_raw(path: &str, post_body: Option<&str>, timeout_s: u32) -> Result<ProxyReply, String> { let base = control_api_base();- // 120 s: creating the tab alone can take 60, and a blind retry lands a SECOND thread.- curl_json(&format!("{base}{path}"), Some(body), 120)- .unwrap_or_else(|| json!({"error": "the AI panel did not answer within 120 s; nothing was sent"}))+ let tmp = std::env::temp_dir().join(format!(+ "adom-lbr-proxy-{}-{:x}",+ std::process::id(),+ std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.subsec_nanos()).unwrap_or(0)+ ));+ let mut cmd = Command::new("curl");+ cmd.args(["-s", "-o"]).arg(&tmp)+ .args(["-w", "%{http_code}\t%{content_type}", "--max-time", &timeout_s.to_string()]);+ if post_body.is_some() {+ cmd.args(["-X", "POST", "-H", "Content-Type: application/json", "--data-binary", "@-"]);+ cmd.stdin(Stdio::piped());+ } else {+ cmd.stdin(Stdio::null());+ }+ cmd.arg(format!("{base}{path}"));+ cmd.stdout(Stdio::piped()).stderr(Stdio::null());+ let mut run = || -> Option<(String, Vec<u8>)> {+ let mut child = cmd.spawn().ok()?;+ if let Some(b) = post_body {+ use std::io::Write;+ child.stdin.take()?.write_all(b.as_bytes()).ok()?;+ }+ let out = child.wait_with_output().ok()?;+ if !out.status.success() {+ return None;+ }+ let meta = String::from_utf8_lossy(&out.stdout).to_string();+ let body = std::fs::read(&tmp).unwrap_or_default();+ Some((meta, body))+ };+ let result = run();+ let _ = std::fs::remove_file(&tmp);+ let Some((meta, body)) = result else {+ return Err(format!("no Hydrogen control API is reachable at {base} (from ~/.adom/hydrogen-control-url, read per request)"));+ };+ let mut parts = meta.splitn(2, '\t');+ let status: u16 = parts.next().and_then(|s| s.trim().parse().ok()).unwrap_or(0);+ if status == 0 {+ return Err(format!("no Hydrogen control API is reachable at {base} (from ~/.adom/hydrogen-control-url, read per request)"));+ }+ let content_type = parts.next().unwrap_or("").trim().to_string();+ let content_type = if content_type.is_empty() { "application/json".to_string() } else { content_type };+ Ok(ProxyReply { status, content_type, body }) } pub fn say_send(phrase: &str) -> Value { if phrase.trim().is_empty() { return json!({"ok": false, "why": "no phrase given"}); }- if SAY_SEND_PARKED {- return json!({"ok": false, "parked": true,- "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(); // Gate NUMERICALLY on the updater's current version (a lexical compare
src/manage.html+5@@ -33,6 +33,11 @@ #hdr .chip.on i{background:var(--green);box-shadow:0 0 5px var(--green)} #hdr .chip .tc{color:var(--text-2)} #hdr .chip #tname{color:var(--ink)}+ /* The bar is contract-fixed at 44px; in a narrow pane the subtitle, then the chip,+ drop out rather than wrap past it (four-frame rule: 390px is a judged state). */+ #hdr .brand .bn{white-space:nowrap}+ @media (max-width:640px){ #hdr .brand .bs{display:none} }+ @media (max-width:480px){ #hdr .chip{display:none} } /* status pill row (status-leds contract) */ .ledrow{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 12px} /* 0, not 18: it sits INSIDE #content's 16px gutter like every other band. Measured at 34px against the panel's 16px in his real pane, which reads as a step. */ .led-pill{display:inline-flex;align-items:center;gap:7px;background:var(--panel);border:1px solid var(--edge);border-radius:999px;padding:4px 12px;font-size:12.5px;cursor:pointer;color:var(--ink)}
src/manage.rs+45−7@@ -123,15 +123,52 @@ fn cors(resp: Response<Cursor<Vec<u8>>>) -> Response<Cursor<Vec<u8>>> { .with_header(Header::from_bytes(&b"Cache-Control"[..], &b"no-store"[..]).unwrap()) } +/// OPTIONS is answered per-path, 404 on anything unrouted. A blanket 204 answers+/// the SHAPE of a preflight rather than the question: the module's checkSend probes+/// its send route against a path that cannot exist and requires the two to DIFFER,+/// which is exactly how a sibling app's unconditional do_OPTIONS was caught passing+/// three separate checks for the wrong reason (control-api-proxy skill).+fn path_known(path: &str) -> bool {+ matches!(path,+ "/" | "/health" | "/api/apply" | "/api/apply-progress" | "/api/status"+ | "/newbie-panel.js" | "/updater/status" | "/ai-threads/run-prompt"+ | "/api/say" | "/api/say-send" | "/api/try" | "/api/settings"+ | "/api/settings/reset" | "/api/claim-webview" | "/api/components")+ || (path.starts_with("/tour/step-") && path.ends_with(".mp3"))+ || path.ends_with("/favicon.svg") || path.ends_with("/favicon.ico")+ || path.starts_with("/api/lifecycle/") || path.starts_with("/api/job/")+ || path.starts_with("/api/export/") || path.starts_with("/api/part/")+ || path.starts_with("/api/board/") || path.starts_with("/glb/")+}+ fn route(method: &Method, url: &str, body: &str) -> Response<Cursor<Vec<u8>>> { let path = url.split('?').next().unwrap_or(url); if matches!(method, Method::Options) {- return cors(Response::from_string("").with_status_code(204));+ return if path_known(path) {+ cors(Response::from_string("").with_status_code(204))+ } else {+ cors(Response::from_string("not found").with_status_code(404))+ }; } let resp = route_inner(method, url, path, body); if path.starts_with("/api/") || path == "/health" { cors(resp) } else { resp } } +/// Hand the upstream reply back byte-for-byte; only an unreachable control API+/// is answered locally, as 503 naming the pointer file.+fn proxy_resp(r: Result<dock::ProxyReply, String>) -> Response<Cursor<Vec<u8>>> {+ match r {+ Ok(p) => Response::from_data(p.body)+ .with_status_code(p.status)+ .with_header(Header::from_bytes(b"Content-Type", p.content_type.as_bytes())+ .unwrap_or_else(|_| hdr("application/json"))),+ Err(hint) => Response::from_string(json!({"error": hint, "_hint":+ "this route IS wired; the Hydrogen control API is just absent on this host (a container, or ah is not running)"}).to_string())+ .with_status_code(503)+ .with_header(hdr("application/json")),+ }+}+ fn route_inner(method: &Method, url: &str, path: &str, body: &str) -> Response<Cursor<Vec<u8>>> { let force = url.contains("force=1"); match (method, path) {@@ -167,12 +204,13 @@ fn route_inner(method: &Method, url: &str, path: &str, body: &str) -> Response<C .with_header(hdr("application/javascript; charset=utf-8")) .with_header(Header::from_bytes(&b"Cache-Control"[..], &b"no-store, must-revalidate"[..]).unwrap()) }- // Same-origin proxies the module's send path needs (see dock::control_get/post).- (Method::Get, "/updater/status") => json_resp(&dock::control_get("/updater/status").to_string()),- (Method::Post, "/ai-threads/run-prompt") => {- let payload = serde_json::from_str::<Value>(body).unwrap_or_else(|_| json!({}));- json_resp(&dock::control_post("/ai-threads/run-prompt", &payload).to_string())- }+ // Same-origin proxies the module's send path needs (control-api-proxy contract):+ // bytes through unchanged both directions, upstream status and Content-Type kept,+ // 503-with-a-hint when the control API is absent (a 404 here would be+ // indistinguishable from "route never wired"). 120 s on run-prompt: the tab+ // create alone can take 60, and a blind retry lands a SECOND thread.+ (Method::Get, "/updater/status") => proxy_resp(dock::control_proxy_raw("/updater/status", None, 5)),+ (Method::Post, "/ai-threads/run-prompt") => proxy_resp(dock::control_proxy_raw("/ai-threads/run-prompt", Some(body), 120)), (Method::Get, "/api/say") => json_resp(&json!({"status":"ok","say":dock::say_phrases(),"noun":"lbr"}).to_string()), // say-strip contract: Send to AI opens a NEW thread and submits (new // threads only; the resume path stays parked in /api/try).
src/newbie-panel.js+176−7@@ -139,6 +139,35 @@ return safe.replace(re, '<b>$1</b>'); } + /* ---- copy, with a fallback that does not need the async clipboard ------+ * navigator.clipboard.writeText needs a secure context AND, in several embedded webviews,+ * transient user activation plus a clipboard-write permission that is simply absent. In+ * ah's webview it rejects with NotAllowedError, so the ONLY path the panel had would fail+ * and the user got "Could not reach the clipboard" on a button that looks like it works.+ * The execCommand path is deprecated but synchronous, needs no permission, and works in+ * exactly the contexts the async API refuses. Try the modern one, fall back, and only then+ * report failure. */+ function copyText(text) {+ return new Promise(function (resolve, reject) {+ function legacy() {+ try {+ var ta = document.createElement('textarea');+ ta.value = text;+ ta.setAttribute('readonly', '');+ ta.style.cssText = 'position:fixed;top:0;left:-9999px;opacity:0';+ document.body.appendChild(ta);+ ta.select(); ta.setSelectionRange(0, ta.value.length);+ var ok = document.execCommand && document.execCommand('copy');+ ta.remove();+ ok ? resolve('legacy') : reject(new Error('execCommand refused'));+ } catch (e) { reject(e); }+ }+ if (navigator.clipboard && navigator.clipboard.writeText) {+ navigator.clipboard.writeText(text).then(function () { resolve('async'); }, legacy);+ } else legacy();+ });+ }+ /* ---- toasts ---------------------------------------------------------- */ var toastHost = null; function toast(msg, kind, ms) {@@ -275,7 +304,7 @@ tipFor(cp, 'Copy this phrase'); cp.addEventListener('click', function (e) { e.stopPropagation();- (navigator.clipboard ? navigator.clipboard.writeText(phrase) : Promise.reject())+ copyText(phrase) .then(function () { toast('Copied. Paste it to your AI.', 'ok'); }) .catch(function () { toast('Could not reach the clipboard. Select the phrase and copy it.', 'err'); }); });@@ -383,6 +412,24 @@ function selfCheck(panel) { panel = panel || document.querySelector('.anp'); if (!panel) return { ok: false, error: 'no .anp panel on the page' };+ /* REFUSE TO REPORT FROM A COLLAPSED LAYOUT. An unforegrounded tab returns zero-height+ * rects while computed styles still resolve normally, so selfCheck produced EIGHT GREEN+ * ROWS beside two red ones: "panel gutter: 0px left, 744px right" and "columns: want 1,+ * got 3". That mixed shape is far more dangerous than an all-zero one, because it reads+ * as a specific layout regression rather than an invalid run, and project-manager nearly+ * filed it as a bug. A refusal cannot be misread. (project-manager, 2026-08-25.) */+ var w0 = Math.round(panel.getBoundingClientRect().width);+ /* rows: [] is deliberate. The refusal previously omitted the key entirely, and EVERY+ * caller does r.rows.map(...) because every healthy result has rows, so a correct refusal+ * arrived at the caller as "Cannot read properties of undefined (reading 'map')" and read+ * like harness noise. kicad hit exactly that and logged it as its own crash. A guard whose+ * failure mode is a TypeError in someone else's stack is a guard that will be misread. */+ if (w0 === 0) return { ok: false, invalid: true, rows: [],+ error: 'panel measures 0px wide: this tab is not foregrounded (or the panel is hidden), ' ++ 'so every layout row would ' ++ 'be computed from a collapsed pane while the style rows pass normally. Activate ' ++ 'the tab by name (or open the panel) and re-run. No rows reported, because a ' ++ 'partial result from an unrendered pane looks like a real bug.' }; var cs = getComputedStyle(panel), rows = [], ok = true; /* Chrome reports the USED border-width, snapped to whole device pixels, so a declared * 1px reads as 0.666667px at dpr 1.5. Compare against the snapped value, not the@@ -427,12 +474,88 @@ * column while the other three rendered TWO, at an identical pane width. The panel was * conformant in all four; the PAGE around it was not. The fleet gutter is 16px a side, * so the panel's border box is the pane minus 32. */- var vw = document.documentElement.clientWidth;- var want32 = vw - 32, gotW = Math.round(panel.getBoundingClientRect().width);- var gutterOk = Math.abs(gotW - want32) <= 2;+ /* Measure against the panel's OWN CONTAINING BLOCK, never documentElement.+ * The first version compared to documentElement.clientWidth, which silently assumes the+ * PAGE scrolls. An app-shell dashboard (position:fixed shell, 44px header, scroller on an+ * inner .app-body) has no document scrollbar, so clientWidth is the full pane and the+ * inner scrollbar is charged to content instead: a correctly built app-shell page fails+ * by exactly one scrollbar width, and only pages that happen to scroll at document level+ * pass. Since app-shell is the prescribed structure, the check was failing the apps that+ * followed the skill. The containing block is scrollbar-agnostic and still catches the+ * real defect, which is a page adding its own padding around the mount point.+ * (parts-search, 2026-08-25, from reading the implementation rather than the prose.) */+ /* Measure BOTH INSETS AGAINST THE PANE, and never against a named element.+ * Two earlier versions asked the wrong element how wide the world is. documentElement+ * assumes the page scrolls, and fails every app-shell dashboard by one scrollbar width.+ * The mount div assumes the 16px lives on the mount div, and reds a visually perfect page+ * that puts the gutter on a shared outer container instead. Both encode a STRUCTURE the+ * fleet was never promised. What the contract actually says is where the panel's EDGES+ * land, so measure exactly that: find the nearest scrolling ancestor (or the document),+ * and check the panel sits 16px inside its client box on both sides. Structure-agnostic:+ * it does not care which element carries the padding, or how many wrappers sit between.+ * (pup found the mount-div version would red an outer-container page, before six apps+ * adopted the pattern I had recommended; parts-search found the documentElement one.) */+ var sc = panel.parentElement;+ while (sc && sc !== document.body) {+ /* A DECLARED scroller is the pane whether or not it is scrolling RIGHT NOW. Requiring+ * it to overflow was a bug with a nastier shape than the two before it: with+ * scrollbar-gutter:stable (which this pack mandates, so results arriving cause no+ * reflow) the track is reserved even when nothing overflows, so the loop walked PAST+ * the real pane, fell back to documentElement, and reported the right inset over by+ * one scrollbar width. The SAME PAGE passed when populated and failed when empty, and+ * empty is a dashboard's first-load state. Predicted from a code read by parts-search+ * and confirmed here: 16 left / 31 right, exact. */+ var so = getComputedStyle(sc).overflowY;+ if (so === 'auto' || so === 'scroll') break;+ sc = sc.parentElement;+ }+ var paneLeft, paneW;+ if (sc && sc !== document.body) { paneLeft = sc.getBoundingClientRect().left; paneW = sc.clientWidth; }+ else { paneLeft = 0; paneW = document.documentElement.clientWidth; }+ var pr = panel.getBoundingClientRect();+ var insetL = Math.round(pr.left - paneLeft);+ var insetR = Math.round(paneLeft + paneW - pr.right);+ var gutterOk = Math.abs(insetL - 16) <= 2 && Math.abs(insetR - 16) <= 2; if (!gutterOk) ok = false;- rows.push({ what: 'panel gutter', want: want32 + 'px wide (16px a side)',- got: gotW + 'px (' + Math.round((vw - gotW) / 2) + 'px a side)', pass: gutterOk });+ rows.push({ what: 'panel gutter', want: '16px inset both sides',+ got: insetL + 'px left, ' + insetR + 'px right', pass: gutterOk });+ /* THE LEFT-EDGE ROW closes the hole the gutter row cannot see. The gutter row measures+ * against whatever element the app mounts INTO, so an app that mounts into a wrapper+ * which itself carries padding passes while sitting inset from its neighbours, and the+ * fleet mismatch comes back in a form the check is blind to. This asserts the panel's+ * left edge against the PANE, which covers the whole ancestor chain in one number.+ * (parts-search, 2026-08-25, reasoning about what the new yardstick still could not see.) */+ /* PHRASE HEADROOM AS A ROW, not a console warning nobody reads: "does not truncate TODAY"+ * and "has room" are different claims.+ * MEASURE need AGAINST AN OFFSCREEN nowrap PROBE. Do NOT use clientWidth - scrollWidth:+ * for a block span with overflow:hidden, scrollWidth is max(clientWidth, content), so that+ * expression SATURATES AT 0 for anything that fits, whether it fits by 1px or by 180px. It+ * still goes negative correctly when text overflows, so it looks like a working metric.+ * Two apps independently reported "0px headroom, one character from truncating" off it;+ * remeasured with this probe, neither had less than 141px of slack. A metric that can only+ * return one value on success is not evidence of success. */+ var spans = [].slice.call(panel.querySelectorAll('.anp-phrase span'));+ if (spans.length) {+ var probe = document.createElement('span');+ probe.style.cssText = 'position:absolute;visibility:hidden;white-space:nowrap;font:' ++ getComputedStyle(spans[0]).font;+ document.body.appendChild(probe);+ var worst = 0, worstTxt = '', tight = 0;+ spans.forEach(function (sp) {+ probe.textContent = sp.textContent;+ var need = Math.ceil(probe.getBoundingClientRect().width);+ var have = Math.round(sp.getBoundingClientRect().width);+ var slack = have - need;+ if (worstTxt === '' || slack < worst) { worst = slack; worstTxt = sp.textContent.trim().slice(0, 40); }+ if (slack < 8) tight++;+ });+ probe.remove();+ var headOk = worst >= 8;+ if (!headOk) ok = false;+ rows.push({ what: 'phrase headroom', want: 'at least 8px of slack on every phrase',+ got: worst + 'px on ' + JSON.stringify(worstTxt) + (tight > 1 ? ' (' + tight + ' tight)' : ''),+ pass: headOk });+ } var fam = getComputedStyle(panel.querySelector('.anp-phrase span') || panel).fontFamily; var famOk = /Satoshi/.test(fam); if (!famOk) ok = false;@@ -440,6 +563,52 @@ return { ok: ok, rows: rows }; } - root.AdomNewbiePanel = { mount: mount, toast: toast, selfCheck: selfCheck, version: '2.3.0',+ /* checkSend(apiBase) - prove the send path EXISTS, without sending anything.+ * gate() fails OPEN on purpose, so a broken probe cannot block a user from sending. The+ * consequence, which kicad found in itself: A MISSING GATE READS AS AN ALLOWED GATE. Its+ * /updater/status route had been lost in a rebuild, the gate got HTML, caught, and returned+ * open, so the version gate had been silently no-opping. Nothing anywhere said so.+ * This makes that visible. It never posts a prompt: the run-prompt route is probed with+ * OPTIONS, so it can report "wired" without creating a thread in anyone's list. */+ function checkSend(base) {+ base = base == null ? '' : base;+ var rows = [], ok = true;+ function add(what, want, got, pass) { rows.push({what: what, want: want, got: got, pass: pass}); if (!pass) ok = false; }+ return fetch(base + '/updater/status', {cache: 'no-store'})+ .then(function (r) { return r.text().then(function (t) { return {s: r.status, t: t}; }); })+ .then(function (res) {+ var v = null, parsed = false;+ try { var j = JSON.parse(res.t); parsed = true; v = j && j.updater && j.updater.current_version; } catch (e) {}+ add('gate route wired', '200 with JSON', res.s + (parsed ? ' JSON' : ' NOT JSON'), res.s === 200 && parsed);+ add('gate reports a version', 'updater.current_version', String(v), !!v);+ if (v) add('version meets the minimum', MIN_API.join('.'), String(v), verGte(verNum(v), MIN_API));+ })+ .catch(function (e) { add('gate route wired', '200 with JSON', 'FETCH FAILED: ' + e.message, false); })+ .then(function () {+ /* PROBE A NONSENSE PATH TOO, AND REQUIRE THE TWO TO DIFFER.+ * "not 404" was too weak an assertion. kicad's do_OPTIONS returned 204 for EVERY path+ * unconditionally, so this row was green for any string at all, including routes that+ * exist in no method. An app could fix its GET catch-all, still have a blanket+ * preflight, and keep a green row that tests nothing. Comparing against a path that+ * cannot exist makes the row self-validating: if they answer identically, the server+ * is answering the SHAPE of the question rather than the question. */+ var nonsense = '/__anp_probe_' + String(Math.floor(1e9 * 0.6180339887)) + '__';+ return Promise.all([+ fetch(base + '/ai-threads/run-prompt', {method: 'OPTIONS'}).then(function (r) { return r.status; },+ function () { return 'ERR'; }),+ fetch(base + nonsense, {method: 'OPTIONS'}).then(function (r) { return r.status; },+ function () { return 'ERR'; })+ ]).then(function (both) {+ var real = both[0], fake = both[1];+ add('send route wired', 'answers, and differently from a path that cannot exist',+ 'route ' + real + ', nonsense ' + fake,+ real !== 'ERR' && real !== 404 && real !== fake);+ });+ })+ .then(function () { return {ok: ok, rows: rows}; });+ }++ root.AdomNewbiePanel = { mount: mount, toast: toast, selfCheck: selfCheck, copyText: copyText,+ checkSend: checkSend, version: '2.21.0', TWO_COL_MIN: TWO_COL_MIN, PHRASE_BUDGET: PHRASE_BUDGET }; })(typeof window !== 'undefined' ? window : this);