← Commit history

2.19.0: newbie-panel module 2.21.0, byte-faithful control proxies, per-path OPTIONS

Colby Knox ·8a260929be ·1mo ago ·parent f01a63a
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);