app
Parts Search
Public Made by Adomby adom
One search box across Mouser, DigiKey and JLCPCB: parallel queries, side-by-side product photos, and a Mouser-preferred recommendation that reasons around stock and lead time.
← Commit history
v2.0.12: Module 2.4.0: the panel gutter check now measures against the containing block
11 files changed
+239−102
Cargo.lock+1−1@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-parts-search"-version = "2.0.11"+version = "2.0.12" dependencies = [ "base64", "clap",
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-parts-search"-version = "2.0.11"+version = "2.0.12" edition = "2021" description = "Parts Search Electronics component search — CLI, backend HTTP service, and Hydrogen webview app" license = "MIT"
VERSION+1−1@@ -1 +1 @@-2.0.11+2.0.12
build.shinstall.shpackage.json+2−41@@ -1,6 +1,6 @@ { "slug": "adom-parts-search",- "version": "2.0.12",+ "version": "2.0.4", "type": "app", "description": "One search box across Mouser, DigiKey and JLCPCB. Parallel queries, side-by-side product photos, and a Mouser-preferred recommendation that reasons around stock and lead time. Dock Bar ready: serve, health, settings, Say it.", "dependencies": {@@ -38,44 +38,5 @@ "SKILL.md", "BUILD-SKILL.md", "CHANGELOG.md"- ],- "changelog": "Module 2.4.0: the panel gutter check now measures against the containing block",- "discovery_triggers": [- "parts search",- "find part",- "source component",- "source part",- "compare distributors",- "compare vendors",- "Mouser or DigiKey",- "BOM sourcing",- "price this part",- "where can I get",- "check stock",- "parts availability",- "fastest delivery",- "cheapest part",- "component search",- "part lookup",- "price breaks"- ],- "discovery_pitch": "Search Mouser, DigiKey and JLCPCB in parallel with product photos; Mouser-preferred best-source pick. Say 'with parts search' to your AI.",- "sample_prompts": [- {- "label": "source an STM32F103RBT6",- "prompt": "source an STM32F103RBT6"- },- {- "label": "where can I get an ESP32-S3-WROOM cheape",- "prompt": "where can I get an ESP32-S3-WROOM cheapest and fastest"- },- {- "label": "compare Mouser, DigiKey and JLCPCB for a",- "prompt": "compare Mouser, DigiKey and JLCPCB for an LM555"- },- {- "label": "price this BOM across distributors",- "prompt": "price this BOM across distributors"- } ]-}+}
page.json+9−50@@ -4,7 +4,7 @@ "slug": "adom-parts-search", "title": "Parts Search", "brief": "One search box across Mouser, DigiKey and JLCPCB: parallel queries, side-by-side product photos, and a Mouser-preferred recommendation that reasons around stock and lead time.",- "version": "2.0.12",+ "version": "2.0.4", "install": { "binary_name": "adom-parts-search", "install_dir": "",@@ -33,25 +33,13 @@ }, "hero": { "type": "image",- "path": "screenshots/hero.png"+ "path": "docs/hero.png" }, "sample_prompts": [- {- "label": "source an STM32F103RBT6",- "prompt": "source an STM32F103RBT6"- },- {- "label": "where can I get an ESP32-S3-WROOM cheape",- "prompt": "where can I get an ESP32-S3-WROOM cheapest and fastest"- },- {- "label": "compare Mouser, DigiKey and JLCPCB for a",- "prompt": "compare Mouser, DigiKey and JLCPCB for an LM555"- },- {- "label": "price this BOM across distributors",- "prompt": "price this BOM across distributors"- }+ "source an STM32F103RBT6",+ "where can I get an ESP32-S3-WROOM cheapest and fastest",+ "compare Mouser, DigiKey and JLCPCB for an LM555",+ "price this BOM across distributors" ], "discovery_triggers": [ "parts search",@@ -66,41 +54,12 @@ "where can I get", "check stock", "parts availability",- "fastest delivery",- "cheapest part",- "component search",- "part lookup",- "price breaks"+ "fastest delivery" ],- "discovery_pitch": "Search Mouser, DigiKey and JLCPCB in parallel with product photos; Mouser-preferred best-source pick. Say 'with parts search' to your AI.",+ "discovery_pitch": "Search Mouser + DigiKey + JLCPCB in parallel from one interface, with product photos and a Mouser-preferred best-source pick.", "metadata": { "platform": "linux" }, "category": "tools",- "org": "adom",- "description": "One search box across Mouser, DigiKey and JLCPCB. Parallel queries, side-by-side product photos, and a Mouser-preferred recommendation that reasons around stock and lead time. Dock Bar ready: serve, health, settings, Say it.",- "dependencies": {- "adom/adom-mouser": "*",- "adom/adom-digikey": "*",- "adom/adom-jlcpcb": "*"- },- "needs_sudo": false,- "scripts": {- "install": "./install.sh",- "uninstall": "./uninstall.sh"- },- "files": [- "bin/**",- "skills/**",- "docs/icon.svg",- "dockbar.json",- "install.sh",- "uninstall.sh",- "package.json",- "README.md",- "SKILL.md",- "BUILD-SKILL.md",- "CHANGELOG.md"- ],- "changelog": "Module 2.4.0: the panel gutter check now measures against the containing block"+ "org": "adom" }
publish.shsrc/app/mod.rs+46−1@@ -510,7 +510,27 @@ fn handle(mut req: Request, state: Arc<AppState>, no_tab: bool, tab_name: &str) let ends = |s: &str| path == s || path.ends_with(&format!("/{}", s.trim_start_matches('/'))); if method == Method::Options {- let _ = req.respond(with_cors(Response::from_string("").with_status_code(204)));+ // ANSWER PREFLIGHT ONLY FOR ROUTES THAT EXIST.+ //+ // This used to 204 unconditionally, which is a lie with a green tick on+ // it: a caller probing a route that exists in no method still got 204,+ // so any "is this route wired?" check passed for every string. The+ // panel's checkSend deliberately compares a real route against a+ // nonsense path and requires them to DIFFER — with a blanket handler+ // both answered 204 and the row was green while testing nothing.+ //+ // A preflight is a question about a specific route. Answer it that way.+ let known = path == "/"+ || path.starts_with("/api/")+ || ends("/index.html")+ || ends("/newbie-panel.js")+ || ends("/updater/status")+ || ends("/ai-threads/run-prompt")+ || ends("/ai-threads/run-prompt/status")+ || path.contains("/audio/")+ || ends("/favicon.svg") || ends("/favicon.ico") || ends("/icon.svg");+ let code = if known { 204 } else { 404 };+ let _ = req.respond(with_cors(Response::from_string("").with_status_code(code))); return; } @@ -527,6 +547,31 @@ fn handle(mut req: Request, state: Arc<AppState>, no_tab: bool, tab_name: &str) .with_header(Header::from_bytes("Content-Type", "application/javascript; charset=utf-8").unwrap()) .with_header(Header::from_bytes("Cache-Control", "no-store, must-revalidate").unwrap()))); return;+ } else if method == Method::Get && ends("/ai-threads/run-prompt/status") {+ // THE QUERY STRING IS THE WHOLE POINT OF THIS ROUTE.+ // It is called as ?job=<uuid>; a proxy that forwards only the path asks+ // the control API about no job at all and answers about nothing. Rebuild+ // the upstream URL from the ORIGINAL request URL so the query survives+ // verbatim, and pass the response back as bytes like the other two.+ //+ // Hydrogen 1.0.187+ returns from run-prompt in under a second with+ // {ok:true, job, state:"opening"} — ok means the job STARTED, not that+ // the prompt landed — and progress only exists through this poll. Without+ // this route every poll 404s and the user gets no progress at all.+ let upstream = match url.split_once('?') {+ Some((_, q)) => format!("/ai-threads/run-prompt/status?{}", q),+ None => "/ai-threads/run-prompt/status".to_string(),+ };+ let _ = match hraw(&upstream, None, 8000) {+ Ok((code, bytes, ct)) => req.respond(raw_resp(code, bytes, &ct)),+ Err(e) => req.respond(json_resp(502, json!({+ "error": e,+ "_hint": "Could not reach the Hydrogen control API to poll send progress. \+ The prompt may still have been delivered; check your AI threads \+ before sending again."+ }))),+ };+ return; } else if method == Method::Get && ends("/updater/status") { // Proxied so the panel module can gate on the shell version from our // own origin (no cross-origin fetch, no CORS dependency). Bytes and
src/app/vendor/newbie-panel.js+179−7@@ -112,6 +112,23 @@ var TWO_COL_MIN = 700, PHRASE_BUDGET = 336; var MIN_API = [1, 0, 142]; + /* Hydrogen 1.0.187+ answers a send with a JOB and reports progress on+ * GET /ai-threads/run-prompt/status?job=<uuid>. Measured on the VM: tab visible ~0.1s,+ * session id ~2.5s, sent by 3-5s, a trivial answer done ~8s. One poll a second is the+ * right cadence for that shape, and it is an in-memory read on the other end. */+ var PHASE = {+ opening: 'Opening a new AI chat…',+ prefilled: 'Prompt ready…',+ submitted: 'Sent, waiting for the AI…',+ working: 'The AI is working…',+ needs_you: 'The AI needs you. Open the tab.',+ done: 'Done.',+ submit_failed: 'The prompt is in the tab. Press Enter there to send it.',+ submitted_unidentified:'Sent, but the AI never checked in.',+ submit_unconfirmed: 'Sent, but delivery was not confirmed.',+ still_working: 'The AI is still working. Open the tab.'+ };+ function css() { if (document.getElementById('anp-css')) return; var s = document.createElement('style');@@ -174,6 +191,9 @@ if (!toastHost) { toastHost = el('div', 'anp-toasts'); document.body.appendChild(toastHost); } var t = el('div', 'anp-toast ' + (kind || 'info'), esc(msg)); toastHost.appendChild(t);+ /* retextable, so a long send can narrate its phases in ONE toast instead of+ * stacking six of them on top of each other. */+ t.setText = function (next) { t.innerHTML = esc(next); return t; }; setTimeout(function () { t.remove(); }, ms || (kind === 'err' ? 7000 : 4000)); return t; }@@ -224,7 +244,7 @@ }) .catch(function () { return { ok: true, version: null, open: true }; }); }- function send(base, phrase, appName, slug) {+ function send(base, phrase, appName, slug, onProgress) { return gate(base).then(function (g) { if (!g.ok) throw new Error('Adom needs updating to send prompts (found ' + g.version + ', needs 1.0.142). Run: adom update'); var ctl = new AbortController(), timer = setTimeout(function () { ctl.abort(); }, 120000);@@ -241,12 +261,55 @@ .then(function (res) { clearTimeout(timer); var j = res.j || {};- /* THREE states, never two. delivered_to is an ARRAY; a thread that opened but did- * not confirm delivery is NOT a failure, and retrying it double-sends. */+ if (!(res.status >= 200 && res.status < 300))+ throw new Error((j._hints && j._hints.note) || j.error || ('AI threads returned ' + res.status));+ /* PRE-1.0.187 SHAPE, kept because older Hydrogen builds still answer this way.+ * delivered_to is an ARRAY; a thread that opened but did not confirm delivery is+ * NOT a failure, and retrying it double-sends. */ var dt = j.delivered_to, arr = Array.isArray(dt) ? dt : (dt ? [dt] : []); if (arr.length || (j.inject && j.inject.delivered_in_conversation)) return { state: 'delivered' };- if (res.status >= 200 && res.status < 300) return { state: 'opened', hint: j._hints && j._hints.note };- throw new Error((j._hints && j._hints.note) || j.error || ('AI threads returned ' + res.status));+ /* 1.0.187+ returns in well under a second with a JOB, not a delivery. ok:true means+ * the job STARTED. delivered_to is never filled on this path any more, so without+ * the poll below every send would report "delivery not confirmed" forever. */+ if (!j.job) return { state: 'opened', hint: j._hints && j._hints.note };+ var started = Date.now(), missing = false;+ return new Promise(function (resolve) {+ (function tick() {+ /* the deadline is checked in BOTH paths. Checking it only after a successful+ * poll leaves a dashboard whose server stops answering polling forever. */+ function again() {+ if (Date.now() - started > 120000)+ return resolve({ state: 'opened', hint: 'no confirmation within 120s' });+ setTimeout(tick, 1000);+ }+ fetch(base + '/ai-threads/run-prompt/status?job=' + encodeURIComponent(j.job))+ .then(function (r) {+ /* THE STATUS ROUTE IS A THIRD PROXY ROUTE. Dashboards proxy /updater/status+ * and POST /ai-threads/run-prompt only. If this one was never added, every+ * poll 404s and the user watches "Opening…" for the full 120s before a+ * hedge, which is worse than the behaviour this replaced. Fail FAST and say+ * exactly which route is missing instead of hanging. */+ if (r.status === 404) { missing = true; return null; }+ return r.json();+ })+ .then(function (st) {+ if (missing) return resolve({ state: 'opened', phase: 'no_status_route',+ hint: 'Your AI chat opened. This app does not proxy GET ' ++ '/ai-threads/run-prompt/status yet, so progress cannot be shown.' });+ var state = (st.job && st.job.state) || '';+ if (typeof onProgress === 'function') { try { onProgress(state, st.job); } catch (e) {} }+ /* working or later IS sent, for the user-facing toast. */+ if (state === 'working' || state === 'done' || state === 'needs_you' ||+ state === 'still_working') return resolve({ state: 'delivered', phase: state });+ if (state === 'submit_failed' || state === 'submitted_unidentified' ||+ state === 'submit_unconfirmed')+ return resolve({ state: 'opened', phase: state,+ hint: (st.job && (st.job.error || st.job.note)) || state });+ again();+ })+ .catch(again);+ })();+ }); }) .catch(function (e) { clearTimeout(timer);@@ -316,7 +379,10 @@ if (sd.disabled) return; sd.disabled = true; var pending = toast('Opening a new AI thread…', 'info', 120000);- send(base, phrase, appName, slug).then(function (r) {+ send(base, phrase, appName, slug, function (state, job) {+ if (PHASE[state]) pending.setText(PHASE[state]);+ if (typeof cfg.onProgress === 'function') { try { cfg.onProgress(state, job); } catch (e) {} }+ }).then(function (r) { pending.remove(); if (r.state === 'delivered') toast('Sent. Your AI thread has the prompt.', 'ok'); else toast('Thread opened, delivery not confirmed. Check your AI threads before sending again.', 'info', 8000);@@ -412,6 +478,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@@ -507,6 +591,37 @@ * 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;@@ -514,6 +629,63 @@ return { ok: ok, rows: rows }; } - root.AdomNewbiePanel = { mount: mount, toast: toast, selfCheck: selfCheck, copyText: copyText, version: '2.8.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 () {+ /* THE STATUS ROUTE, added for Hydrogen 1.0.187+. It is a THIRD proxy route and no+ * dashboard had it before 2.22.0, so it is the row most likely to be red. A GET with+ * a job id that cannot exist must still be ANSWERED (any non-404), because a 404 here+ * means the route was never proxied and progress can never be reported. */+ return fetch(base + '/ai-threads/run-prompt/status?job=__anp_probe__')+ .then(function (r) { return r.status; }, function () { return 'ERR'; })+ .then(function (st) {+ add('progress route wired', 'answers (not 404)', String(st), st !== 'ERR' && st !== 404);+ });+ })+ .then(function () { return {ok: ok, rows: rows}; });+ }++ root.AdomNewbiePanel = { mount: mount, toast: toast, selfCheck: selfCheck, copyText: copyText,+ checkSend: checkSend, version: '2.22.0', TWO_COL_MIN: TWO_COL_MIN, PHRASE_BUDGET: PHRASE_BUDGET }; })(typeof window !== 'undefined' ? window : this);
uninstall.sh