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
v1.1.0: Dock Bar ready page with a settings modal, Say it strip, bundled brand fonts, taskbar progress, and AI drive verbs
26 files changed
+2038−795
Cargo.lock+1−1Cargo.toml+1−1VERSION+1−1docs/hero.pngpackage.json+1−40page.json+15−54publish.sh+80−54screenshots/results-grid.pngscreenshots/settings-taskbar.pngscreenshots/settings.pngscreenshots/tooltip.pngsrc/app/fonts/familjen-grotesk-latin-500-normal.woff2src/app/fonts/familjen-grotesk-latin-600-normal.woff2src/app/fonts/familjen-grotesk-latin-700-normal.woff2src/app/fonts/satoshi-400-normal.woff2src/app/fonts/satoshi-500-normal.woff2src/app/fonts/satoshi-700-normal.woff2src/app/mod.rs+427−140src/app/ui.html+850−408src/cli.rs+115−59src/install.rs+19−11src/main.rs+72−17src/serve.rs+122src/settings.rs+131src/taskbar.rs+116src/vendors.rs+87−9Cargo.lock+1−1@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-parts-search"-version = "0.1.7"+version = "1.1.0" dependencies = [ "base64", "clap",
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-parts-search"-version = "0.1.7"+version = "1.1.0" edition = "2021" description = "Parts Search Electronics component search — CLI, backend HTTP service, and Hydrogen webview app" license = "MIT"
VERSION+1−1@@ -1 +1 @@-0.1.7+1.1.0
docs/hero.png⋯ 1 unchanged line ⋯
package.json+1−40@@ -38,44 +38,5 @@ "SKILL.md", "BUILD-SKILL.md", "CHANGELOG.md"- ],- "changelog": "Dock Bar ready page with a settings modal, Say it strip, bundled brand fonts, taskbar progress, and AI drive verbs",- "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 routing for 40-minute drone delivery from Mansfield to the Fort Worth facility. 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+15−54@@ -2,13 +2,13 @@ "schema_version": 1, "type": "app", "slug": "adom-parts-search",- "title": "Adom Parts Search",- "brief": "One search box across Mouser + DigiKey + JLCPCB. Parallel queries, side-by-side product photos, and a Mouser-preferred recommendation (40-min drone delivery to Fort Worth) that reasons around stock and lead time.",+ "title": "Parts Search",+ "brief": "One search box across Mouser, DigiKey and JLCPCB: parallel queries, side-by-side product photos, and a Mouser-preferred recommendation (40-minute drone delivery to Fort Worth) that reasons around stock and lead time.", "version": "1.1.0", "install": { "binary_name": "adom-parts-search", "install_dir": "",- "install_hint": "adompkg install adom-parts-search",+ "install_hint": "adom-wiki pkg install adom-parts-search", "version_cmd": "adom-parts-search --version" }, "author": {@@ -28,28 +28,18 @@ "mit" ], "license": "MIT",- "visibility": "public",+ "visibility": {+ "public": true+ }, "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",@@ -64,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 routing for 40-minute drone delivery from Mansfield to the Fort Worth facility. 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 recommendation for 40-min drone delivery from Mansfield to our Fort Worth facility.", "metadata": { "platform": "linux" },- "description": "One search box across Mouser, DigiKey and JLCPCB. Parallel queries, side-by-side product photos, and a Mouser-preferred recommendation (40-minute drone delivery to Fort Worth) 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"- ],- "org": "adom",- "updated_at": "2026-06-25T01:59:26.452Z",- "changelog": "Dock Bar ready page with a settings modal, Say it strip, bundled brand fonts, taskbar progress, and AI drive verbs"+ "category": "tools",+ "org": "adom" }
publish.sh+80−54@@ -64,9 +64,12 @@ ok "binary prints adom-parts-search $VERSION" # ── 2. Git commit + tag + push ───────────────────────────────────── step "2. git commit + tag v$VERSION"+# Changelog: first line of CHANGELOG.md's top entry (never a placeholder).+CHANGELOG_LINE=$(awk '/^## /{if(seen)exit; seen=1; next} seen && NF {print; exit}' CHANGELOG.md 2>/dev/null || true)+if [ -z "$CHANGELOG_LINE" ]; then fail "CHANGELOG.md has no entry under its top '## ' heading; write one before publishing"; fi git add -A if ! git diff --cached --quiet; then- git commit -m "release v$VERSION"+ git commit -m "v$VERSION: $CHANGELOG_LINE" ok "committed" else ok "nothing to commit"@@ -80,68 +83,91 @@ ok "pushed main + v$VERSION tag" step "3. gh release create v$VERSION" if gh release view "v$VERSION" >/dev/null 2>&1; then gh release upload "v$VERSION" "$BIN" --clobber- ok "updated release v$VERSION binary"+ ok "GitHub release asset for v$VERSION replaced" else- gh release create "v$VERSION" "$BIN" --title "v$VERSION" --generate-notes- ok "created release v$VERSION"+ gh release create "v$VERSION" "$BIN" --title "v$VERSION" --notes "$CHANGELOG_LINE"+ ok "GitHub release for v$VERSION created" fi -# ── 4. Wiki page publish/update ────────────────────────────────────-step "4. adom-wiki page publish apps/adom-parts-search"-TITLE=$(jq -r '.title' wiki/page.json)-BRIEF=$(jq -r '.brief' wiki/page.json)-adom-wiki page publish apps/adom-parts-search \- --title "$TITLE" \- --brief "$BRIEF" \- --body-md wiki/body.md \- --version "$VERSION" \- --sample-prompt "Parts search|find me a USB-C connector on Mouser, DigiKey, or JLCPCB with stock > 1000" \- --sample-prompt "Compare distributors|compare prices and stock for STM32F103RBT6 across Mouser, DigiKey, and JLCPCB" \- --sample-prompt "Open parts search|open the Adom Parts Search app in Hydrogen so I can see product photos" \- --sample-prompt "Best deal|find the best price for iCE40HX4K-TQ144 accounting for Mouser's 40-minute drone delivery to Fort Worth" \- --sample-prompt "Check stock|where is the ESP32-S3-WROOM-1U-N4R2 in stock fastest?"-ok "wiki page published at apps/adom-parts-search v$VERSION"+# ── 4. Assemble + publish the registry package (adom/wiki#78) ──────+# Registry tarball = install payload only (bin + skills + installer ++# docs the CLI reads + dockbar.json + favicon). Page assets (hero,+# screenshots, source) live in the PAGE REPO and are pushed in step 7;+# the two stores are NOT synced by the server.+step "4. assemble + publish registry package"+TOK=$(cat "$HOME/.config/adom-wiki/token")+REG="https://wiki.adom.inc"+STAGE=$(mktemp -d)+mkdir -p "$STAGE/pkg/bin" "$STAGE/pkg/skills/adom-parts-search" "$STAGE/pkg/skills/adom-parts-search-build" "$STAGE/pkg/skills/adom-parts-search-dock" "$STAGE/pkg/docs"+cp "$BIN" "$STAGE/pkg/bin/adom-parts-search"+cp README.md SKILL.md BUILD-SKILL.md CHANGELOG.md install.sh uninstall.sh dockbar.json "$STAGE/pkg/"+cp docs/icon.svg "$STAGE/pkg/docs/icon.svg"+cp SKILL.md "$STAGE/pkg/skills/adom-parts-search/SKILL.md"+cp BUILD-SKILL.md "$STAGE/pkg/skills/adom-parts-search-build/SKILL.md"+cp skills/adom-parts-search-dock/SKILL.md "$STAGE/pkg/skills/adom-parts-search-dock/SKILL.md"+# Manifest: this repo's package.json + this version + discovery from wiki/discovery.json+# + the page's canonical sample_prompts. python3 (not jq: not present on every container).+PAGE_JSON_API=$(curl -fsSL -H "Authorization: Bearer $TOK" "$REG/api/pages/adom/adom-parts-search")+STAGE="$STAGE" VERSION="$VERSION" PAGE_JSON_API="$PAGE_JSON_API" CHANGELOG_LINE="$CHANGELOG_LINE" python3 - << 'PYEOF'+import json, os+stage = os.environ["STAGE"]+m = json.load(open("package.json"))+m["version"] = os.environ["VERSION"]+m["files"] = ["bin/**", "skills/**", "docs/icon.svg", "dockbar.json", "install.sh", "uninstall.sh",+ "package.json", "README.md", "SKILL.md", "BUILD-SKILL.md", "CHANGELOG.md"]+m["changelog"] = os.environ["CHANGELOG_LINE"]+disc = json.load(open("wiki/discovery.json"))+if disc.get("triggers"): m["discovery_triggers"] = disc["triggers"]+if disc.get("pitch"): m["discovery_pitch"] = disc["pitch"]+page = json.loads(os.environ["PAGE_JSON_API"]).get("page") or {}+sp = page.get("sample_prompts_json")+if sp:+ prompts = json.loads(sp)+ m["sample_prompts"] = [p if isinstance(p, dict) else {"label": p[:40], "prompt": p} for p in prompts]+json.dump(m, open(f"{stage}/pkg/package.json", "w"), indent=2)+print("manifest:", m["version"], "| triggers:", len(m.get("discovery_triggers", [])), "| prompts:", len(m.get("sample_prompts", [])))+PYEOF+( cd "$STAGE/pkg" && adom-wiki pkg publish --org adom --yes )+ok "registry package published: adom/adom-parts-search@$VERSION" -# ── 5. Wiki asset upload (canonical install surface) ───────────────-step "5. adom-wiki asset upload (docker-binary + skill + build-skill)"-adom-wiki asset upload apps/adom-parts-search --asset-type docker-binary --file "$BIN"-ok "uploaded $BIN to wiki at apps/adom-parts-search"-# Skills live on the wiki so prose-only edits ship without a binary rebuild-# (see adom-cli-design "DO NOT embed SKILL.md in the binary" rule).-adom-wiki asset upload apps/adom-parts-search --asset-type file --file SKILL.md-adom-wiki asset upload apps/adom-parts-search --asset-type file --file BUILD-SKILL.md-ok "uploaded SKILL.md + BUILD-SKILL.md to wiki"+# ── 5. Release binary upload (raw-download surface) ────────────────+step "5. adom-wiki release upload"+adom-wiki release upload adom/adom-parts-search "$VERSION" "$BIN" --platform linux+ok "binary uploaded as a release asset" -# ── 6. Push metadata (discovery_triggers, install_hint, releases) ──-step "6. adom-wiki page edit --field metadata"-TMP_META=$(mktemp)-jq --arg v "$VERSION" '.metadata | .version = $v' wiki/page.json > "$TMP_META"-adom-wiki page edit apps/adom-parts-search --field metadata --body-md "$TMP_META"-rm -f "$TMP_META"-ok "metadata updated"+# ── 6. Verify the registry serves this version, clean ──────────────+step "6. verify registry artifact"+CHECK_TGZ=$(mktemp)+curl -fsSL -H "Authorization: Bearer $TOK" "$REG/api/packages/adom/adom-parts-search/$VERSION/tarball" -o "$CHECK_TGZ"+if tar tzf "$CHECK_TGZ" | grep -q "page\.json"; then rm -f "$CHECK_TGZ"; fail "published tarball still contains page.json"; fi+if ! tar tzf "$CHECK_TGZ" | grep -q "dockbar\.json"; then rm -f "$CHECK_TGZ"; fail "published tarball is missing dockbar.json"; fi+rm -f "$CHECK_TGZ"+CHECK_BIN=$(mktemp)+curl -fsSL "$REG/download/adom/adom-parts-search/$VERSION/adom-parts-search" -o "$CHECK_BIN" -H "Authorization: Bearer $TOK"+chmod +x "$CHECK_BIN"+WIKI_VER=$("$CHECK_BIN" --version | awk '{print $NF}')+rm -f "$CHECK_BIN"+if [ "$WIKI_VER" != "$VERSION" ]; then fail "registry serves $WIKI_VER but we just published $VERSION"; fi+ok "registry tarball clean + release binary returns --version $VERSION" -# ── 7. Verify the wiki actually serves v$VERSION ───────────────────-step "7. verify wiki binary --version"-BINARY_URL=$(jq -r '.metadata.releases.adom_docker.binary_url' wiki/page.json)-if [ -z "$BINARY_URL" ] || [ "$BINARY_URL" = "null" ]; then- fail "wiki page.json has no binary_url"+# ── 7. Page repo: source, docs, dockbar.json, favicon, hero, screenshots ──+# The Dock Bar reads dockbar.json + webview.favicon from the PAGE REPO root.+step "7. push page repo (dockbar.json, docs, screenshots, source)"+PAGE_FILES=(dockbar.json docs/icon.svg README.md SKILL.md BUILD-SKILL.md CHANGELOG.md VERSION Cargo.toml Cargo.lock+ package.json page.json build.sh publish.sh install.sh uninstall.sh LICENSE+ src/main.rs src/cli.rs src/install.rs src/serve.rs src/settings.rs src/taskbar.rs src/vendors.rs+ src/app/mod.rs src/app/tab.rs src/app/ui.html skills/adom-parts-search-dock/SKILL.md)+for f in src/app/fonts/*.woff2 screenshots/*.png docs/hero.png; do [ -f "$f" ] && PAGE_FILES+=("$f"); done+adom-wiki repo push adom/adom-parts-search --files "${PAGE_FILES[@]}" -m "v$VERSION: $CHANGELOG_LINE"+ok "page repo updated"+if [ -f docs/hero.png ]; then+ adom-wiki page hero adom/adom-parts-search --image docs/hero.png && ok "page hero set from docs/hero.png" || warn_line="hero not applied" fi-CHECK=$(mktemp)-if ! curl -fsSL "$BINARY_URL" -o "$CHECK"; then- rm -f "$CHECK"- fail "wiki binary download failed at $BINARY_URL (may need a minute to propagate; re-run step 7)"-fi-chmod +x "$CHECK"-WIKI_VER=$("$CHECK" --version | awk '{print $NF}')-rm -f "$CHECK"-if [ "$WIKI_VER" != "$VERSION" ]; then- fail "wiki serves $WIKI_VER but we just published $VERSION"-fi-ok "wiki binary returns --version adom-parts-search $VERSION" echo echo "───────────────────────────────────────────────" echo " adom-parts-search v$VERSION published."-echo " Wiki: $BINARY_URL"-echo " The service-parts-search container will auto-update within 2 minutes."+echo " Install: adom-wiki pkg install adom/adom-parts-search"+echo " Binary: https://wiki.adom.inc/download/adom/adom-parts-search/$VERSION/adom-parts-search"+echo " Dock Bar: dockbar.json is at the page root; cold-test with `adom-wiki pkg install adom/adom-parts-search` on a box without the CLI." echo "───────────────────────────────────────────────"
screenshots/results-grid.pngadded⋯ 1 unchanged line ⋯
screenshots/settings-taskbar.pngadded⋯ 1 unchanged line ⋯
screenshots/settings.pngadded⋯ 1 unchanged line ⋯
screenshots/tooltip.pngadded⋯ 1 unchanged line ⋯
src/app/fonts/familjen-grotesk-latin-500-normal.woff2added⋯ 1 unchanged line ⋯
src/app/fonts/familjen-grotesk-latin-600-normal.woff2added⋯ 1 unchanged line ⋯
src/app/fonts/familjen-grotesk-latin-700-normal.woff2added⋯ 1 unchanged line ⋯
src/app/fonts/satoshi-400-normal.woff2added⋯ 1 unchanged line ⋯
src/app/fonts/satoshi-500-normal.woff2added⋯ 1 unchanged line ⋯
src/app/fonts/satoshi-700-normal.woff2added⋯ 1 unchanged line ⋯
src/app/mod.rs+427−140@@ -1,207 +1,494 @@-//! `adom-parts-search app` — Hydrogen webview showing unified parts search-//! across all three vendors in a 3-column grid with product photos.+//! `adom-parts-search app`: the Parts Search page and its API. //!-//! The app owns its own HTTP server (tiny_http). When the UI POSTs to-//! /search, the server delegates to vendors::search_all which fires-//! three parallel HTTP calls to the existing vendor backends.+//! One tiny_http server, one thread per request (a 10s vendor search must+//! never block the dock's health probe or the UI's state poll). State lives+//! on the server (GET /state); the UI, the CLI, and the dock all read the+//! same truth. Every /api/* route answers OPTIONS and carries+//! Access-Control-Allow-Origin: * because the Dock Bar probes /api/status+//! cross-origin from the Hydrogen shell page. pub mod tab; -use crate::vendors;-use crate::{note, ok, warn, AppArgs};+use crate::settings::{self, Settings};+use crate::{note, ok, taskbar, vendors, warn, AppArgs}; use serde_json::{json, Value}; use std::collections::VecDeque; use std::sync::{Arc, Mutex};-use tiny_http::{Header, Method, Response, Server};+use std::time::{Duration, Instant};+use tiny_http::{Header, Method, Request, Response, Server}; const UI_HTML: &str = include_str!("ui.html"); const ICON_SVG: &str = include_str!("../../docs/icon.svg");+const DOCKBAR_JSON: &str = include_str!("../../dockbar.json");+/// Brand fonts, served same-origin: adom.inc sends no CORS header on+/// its font files, so a cross-origin @font-face fails in every browser.+const FONTS: &[(&str, &[u8])] = &[+ ("familjen-grotesk-latin-500-normal.woff2", include_bytes!("fonts/familjen-grotesk-latin-500-normal.woff2")),+ ("familjen-grotesk-latin-600-normal.woff2", include_bytes!("fonts/familjen-grotesk-latin-600-normal.woff2")),+ ("familjen-grotesk-latin-700-normal.woff2", include_bytes!("fonts/familjen-grotesk-latin-700-normal.woff2")),+ ("satoshi-400-normal.woff2", include_bytes!("fonts/satoshi-400-normal.woff2")),+ ("satoshi-500-normal.woff2", include_bytes!("fonts/satoshi-500-normal.woff2")),+ ("satoshi-700-normal.woff2", include_bytes!("fonts/satoshi-700-normal.woff2")),+]; const CONSOLE_MAX: usize = 500;+const STATUS_CACHE_MS: u128 = 5000;+pub const VERSION: &str = env!("CARGO_PKG_VERSION");+const APP_TAG: &str = "app:adom/adom-parts-search";+const HYDROGEN_CTL: &str = "http://127.0.0.1:47084"; struct AppState {+ port: u16, mouser_api: Option<String>, digikey_api: Option<String>, jlcpcb_api: Option<String>, dev: bool,+ started: Instant,+ settings: Mutex<Settings>, console: Mutex<VecDeque<Value>>, last_query: Mutex<Option<String>>, last_results: Mutex<Option<Value>>, last_query_ts: Mutex<u128>,+ searching: Mutex<Option<String>>,+ search_count: Mutex<u64>,+ toasts: Mutex<VecDeque<Value>>,+ clicks: Mutex<VecDeque<Value>>,+ status_cache: Mutex<Option<(Instant, Value)>>,+ status_refreshing: std::sync::atomic::AtomicBool, pending_eval: Mutex<Option<(String, String)>>, eval_results: Mutex<std::collections::HashMap<String, Value>>, } +fn now_ms() -> u128 {+ std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or(0)+}++fn say_phrases() -> Vec<String> {+ serde_json::from_str::<Value>(DOCKBAR_JSON).ok()+ .and_then(|v| v.get("say").and_then(|s| s.as_array()).map(|a| a.iter().filter_map(|p| p.as_str().map(|s| s.to_string())).collect()))+ .unwrap_or_default()+}++fn with_cors(r: Response<std::io::Cursor<Vec<u8>>>) -> Response<std::io::Cursor<Vec<u8>>> {+ r.with_header(Header::from_bytes("Access-Control-Allow-Origin", "*").unwrap())+ .with_header(Header::from_bytes("Access-Control-Allow-Methods", "GET, POST, OPTIONS").unwrap())+ .with_header(Header::from_bytes("Access-Control-Allow-Headers", "Content-Type").unwrap())+ .with_header(Header::from_bytes("Content-Security-Policy", "frame-ancestors *").unwrap())+}+ fn json_resp(code: u16, body: Value) -> Response<std::io::Cursor<Vec<u8>>> {- // Every response — HTML, JSON, SVG — carries the embed-friendly CSP- // so Hydrogen's "Some sites block embedding" warning doesn't trip on- // any request it sniffs (it probes more than just /).- Response::from_string(body.to_string())+ with_cors(Response::from_string(body.to_string()) .with_status_code(code) .with_header(Header::from_bytes("Content-Type", "application/json").unwrap())- .with_header(Header::from_bytes("Cache-Control", "no-cache").unwrap())- .with_header(Header::from_bytes("Content-Security-Policy", "frame-ancestors *").unwrap())+ .with_header(Header::from_bytes("Cache-Control", "no-store").unwrap()))+}++fn text_resp(body: &str, ctype: &str) -> Response<std::io::Cursor<Vec<u8>>> {+ with_cors(Response::from_string(body).with_status_code(200)+ .with_header(Header::from_bytes("Content-Type", ctype).unwrap())+ .with_header(Header::from_bytes("Cache-Control", "no-cache").unwrap())) } -fn read_body(req: &mut tiny_http::Request) -> Result<Value, String> {- use std::io::Read;+fn read_body(req: &mut Request) -> Result<Value, String> { let mut body = String::new(); req.as_reader().read_to_string(&mut body).map_err(|e| format!("read body: {}", e))?; if body.is_empty() { return Ok(Value::Null); } serde_json::from_str(&body).map_err(|e| format!("parse JSON: {}", e)) } +fn push_toast(state: &AppState, kind: &str, message: &str, action: Option<Value>) -> Value {+ let t = json!({"id": format!("t{}", now_ms()), "ts": now_ms() as u64, "kind": kind, "message": message, "action": action});+ let mut q = state.toasts.lock().unwrap();+ if q.len() >= 20 { q.pop_front(); }+ q.push_back(t.clone());+ t+}++fn backends(state: &AppState) -> (String, String, String) {+ (+ state.mouser_api.clone().unwrap_or(vendors::DEFAULT_MOUSER.into()),+ state.digikey_api.clone().unwrap_or(vendors::DEFAULT_DIGIKEY.into()),+ state.jlcpcb_api.clone().unwrap_or(vendors::DEFAULT_JLCPCB.into()),+ )+}++/// One vendor backend facet: probe /health with a 2.5s budget.+fn probe_backend(label: &str, url: &str, enabled: bool) -> Value {+ if !enabled {+ return json!({"label": label, "state": "warn", "detail": "switched off in Settings",+ "_hint": "Turn it on in Settings (the cog) or `adom-parts-search settings set vendor{label} on`"});+ }+ let agent = ureq::AgentBuilder::new().timeout(Duration::from_millis(2500)).build();+ let t0 = Instant::now();+ match agent.get(&format!("{}/health", url.trim_end_matches('/'))).call() {+ Ok(r) => {+ let ms = t0.elapsed().as_millis() as u64;+ let v: Value = r.into_json().unwrap_or(json!({}));+ let ok = v.get("ok").and_then(|b| b.as_bool()).unwrap_or(true);+ let mut detail = format!("reachable, {} ms", ms);+ let mut st = if ok { "ok" } else { "warn" };+ if let Some(q) = v.get("quota") {+ let searches = q.get("searches").and_then(|n| n.as_u64()).unwrap_or(0);+ let hit = q.get("first_quota_hit_ts").map(|t| !t.is_null()).unwrap_or(false);+ detail = format!("{}; {} searches today", detail, searches);+ if hit { st = "warn"; detail.push_str("; daily quota hit, resets midnight CT"); }+ }+ json!({"label": label, "state": st, "detail": detail, "_hint": "Vendor backend answered its /health; no action needed"})+ }+ Err(e) => json!({"label": label, "state": "fail", "detail": format!("no answer: {}", crate::strip_ansi(&e.to_string()).chars().take(80).collect::<String>()),+ "_hint": format!("The {} backend at {} is down or the slug rotated. Override with --{}-api or the {}_API env var, or ping an Adom admin.", label, url, label.to_lowercase(), label.to_uppercase())}),+ }+}++fn build_status(state: &AppState) -> Value {+ let s = state.settings.lock().unwrap().clone();+ let (m, d, j) = backends(state);+ let (tx, rx) = std::sync::mpsc::channel();+ for (label, url, on) in [("Mouser", m.clone(), s.vendor_mouser), ("DigiKey", d.clone(), s.vendor_digikey), ("JLCPCB", j.clone(), s.vendor_jlcpcb)] {+ let tx = tx.clone();+ std::thread::spawn(move || { let _ = tx.send((label, probe_backend(label, &url, on))); });+ }+ drop(tx);+ let mut leds: Vec<Value> = vec![];+ let mut got: Vec<(&str, Value)> = rx.iter().collect();+ got.sort_by_key(|(l, _)| match *l { "Mouser" => 0, "DigiKey" => 1, _ => 2 });+ for (_, v) in got { leds.push(v); }+ let searching = state.searching.lock().unwrap().clone();+ leds.push(json!({"label": "Search", "state": if searching.is_some() { "ok" } else { "ok" },+ "detail": match &searching { Some(q) => format!("searching \"{}\"", q), None => format!("idle, {} searches this session", *state.search_count.lock().unwrap()) },+ "_hint": "adom-parts-search show \"LM555\" runs a search and renders it here"}));+ let fails = leds.iter().filter(|l| l.get("state").and_then(|s| s.as_str()) == Some("fail")).count();+ let warns = leds.iter().filter(|l| l.get("state").and_then(|s| s.as_str()) == Some("warn")).count();+ // Rollup we own: every backend down = fail; any backend down or a quota hit = warn.+ let rollup = if fails >= 3 { "fail" } else if fails > 0 || warns > 0 { "warn" } else { "ok" };+ json!({+ "app": "adom-parts-search", "version": VERSION, "state": rollup, "label": "Parts Search",+ "detail": match rollup { "ok" => "all three vendor backends reachable".to_string(), "fail" => "no vendor backend is reachable".to_string(), _ => format!("{} vendor backend(s) degraded", fails + warns) },+ "leds": leds,+ "uptime_s": state.started.elapsed().as_secs(),+ "port": state.port,+ "url": crate::serve::direct_url(state.port),+ "searching": searching,+ "settings": settings::as_json(&s),+ "timing": {"search_avg_ms": taskbar::average_ms()},+ "dock": {"webview": std::env::var("ADOM_DOCK_WEBVIEW").ok(), "app": std::env::var("ADOM_DOCK_APP").ok()},+ })+}++/// Stale-while-revalidate: a cached reading is answered in microseconds;+/// when it is older than STATUS_CACHE_MS one background refresh runs.+/// Only the very first probe (no cache yet) computes synchronously.+fn cached_status(state: &Arc<AppState>) -> Value {+ let cached = state.status_cache.lock().unwrap().clone();+ match cached {+ Some((at, v)) => {+ if at.elapsed().as_millis() >= STATUS_CACHE_MS && !state.status_refreshing.swap(true, std::sync::atomic::Ordering::SeqCst) {+ let st = Arc::clone(state);+ std::thread::spawn(move || {+ let fresh = build_status(&st);+ *st.status_cache.lock().unwrap() = Some((Instant::now(), fresh));+ st.status_refreshing.store(false, std::sync::atomic::Ordering::SeqCst);+ });+ }+ v+ }+ None => {+ let v = build_status(state);+ *state.status_cache.lock().unwrap() = Some((Instant::now(), v.clone()));+ v+ }+ }+}++// ---- "Try it": stage a phrase into this app's own AI thread (dock guide recipe) ----+fn hget(path: &str) -> Result<Value, String> {+ let agent = ureq::AgentBuilder::new().timeout(Duration::from_millis(2500)).build();+ agent.get(&format!("{}{}", HYDROGEN_CTL, path)).call().map_err(|e| e.to_string())?.into_json().map_err(|e| e.to_string())+}+fn hpost(path: &str, body: Value) -> Result<(u16, Value), String> {+ let agent = ureq::AgentBuilder::new().timeout(Duration::from_millis(4000)).build();+ match agent.post(&format!("{}{}", HYDROGEN_CTL, path)).send_json(body) {+ Ok(r) => { let code = r.status(); Ok((code, r.into_json().unwrap_or(Value::Null))) }+ Err(ureq::Error::Status(code, r)) => Ok((code, r.into_json().unwrap_or(Value::Null))),+ Err(e) => Err(e.to_string()),+ }+}+fn stage_phrase(phrase: &str) -> Value {+ if !say_phrases().iter().any(|p| p == phrase) { return json!({"ok": false, "error": "unknown phrase"}); }+ let census = match hget("/ai-threads") {+ Ok(v) => v,+ Err(e) => return json!({"ok": false, "error": format!("Hydrogen is not reachable on 127.0.0.1:47084 ({}). Paste the phrase into any Adom AI thread instead.", e)}),+ };+ let mut open: Option<Value> = None;+ for g in census.get("groups").and_then(|g| g.as_array()).cloned().unwrap_or_default() {+ for t in g.get("threads").and_then(|t| t.as_array()).cloned().unwrap_or_default() {+ if t.get("tags").and_then(|a| a.as_array()).map(|a| a.iter().any(|x| x.as_str() == Some(APP_TAG))).unwrap_or(false) { open = Some(t); break; }+ }+ if open.is_some() { break; }+ }+ if let Some(t) = open {+ let mut body = json!({"prompt": phrase, "submit": false});+ if let Some(sid) = t.get("sid").and_then(|s| s.as_str()) { body["sid"] = json!(sid); } else { body["tid"] = t.get("tid").cloned().unwrap_or(Value::Null); }+ return match hpost("/ai-threads/run-prompt", body) {+ Ok((code, v)) if (200..300).contains(&code) && v.get("ok").and_then(|b| b.as_bool()) != Some(false) =>+ json!({"ok": true, "staged": phrase, "where": format!("open thread{}", t.get("label").and_then(|l| l.as_str()).map(|l| format!(" \"{}\"", l)).unwrap_or_default())}),+ Ok((code, v)) => json!({"ok": false, "error": format!("Hydrogen refused the stage ({}): {}", code, v)}),+ Err(e) => json!({"ok": false, "error": format!("Hydrogen refused the stage: {}", e)}),+ };+ }+ let hist = hget(&format!("/ai-threads/history?tag={}", APP_TAG)).unwrap_or(json!({}));+ let item = hist.get("items").and_then(|a| a.as_array()).and_then(|a| a.iter().find(|i| i.get("id").is_some()).cloned());+ if let Some(item) = item {+ let id = item.get("id").cloned().unwrap_or(Value::Null);+ let ai = item.get("ai").and_then(|a| a.as_str()).unwrap_or("claude");+ match hpost("/ai-threads/resume", json!({"ai": ai, "id": id})) {+ Ok((code, _)) if (200..300).contains(&code) => {}+ other => return json!({"ok": false, "error": format!("Found your Parts Search thread but Hydrogen could not reopen it ({:?}). Paste the phrase into any Adom AI thread instead.", other.map(|(c, _)| c))}),+ }+ return match hpost("/ai-threads/run-prompt", json!({"sid": id, "prompt": phrase, "submit": false})) {+ Ok((code, v)) if (200..300).contains(&code) && v.get("ok").and_then(|b| b.as_bool()) != Some(false) =>+ json!({"ok": true, "staged": phrase, "where": format!("reopened thread{}", item.get("title").and_then(|l| l.as_str()).map(|l| format!(" \"{}\"", l)).unwrap_or_default())}),+ other => json!({"ok": false, "error": format!("Reopened your Parts Search thread but the stage was refused ({:?})", other)}),+ };+ }+ json!({"ok": false, "error": "No Parts Search AI thread exists yet. Copied the phrase instead: paste it into any Adom AI thread."})+}++fn run_search(state: &AppState, keyword: &str, limit: u32, in_stock: bool, source: &str) -> Value {+ let s = state.settings.lock().unwrap().clone();+ let opts = vendors::SearchOpts::from_settings(&s);+ *state.searching.lock().unwrap() = Some(keyword.to_string());+ let bar = taskbar::start(s.taskbar_progress, &s.taskbar_box, source == "ai");+ let t0 = Instant::now();+ let agg = vendors::search_all(keyword, limit, in_stock, state.mouser_api.as_deref(), state.digikey_api.as_deref(), state.jlcpcb_api.as_deref(), &opts);+ let ms = t0.elapsed().as_millis() as u64;+ taskbar::record_ms(ms);+ if let Some(b) = bar { b.finish(true); }+ let mut v = serde_json::to_value(&agg).unwrap_or(json!({}));+ v["elapsed_ms"] = json!(ms);+ v["source"] = json!(source);+ v["policy"] = json!(s.policy);+ *state.last_query.lock().unwrap() = Some(keyword.to_string());+ *state.last_results.lock().unwrap() = Some(v.clone());+ *state.last_query_ts.lock().unwrap() = now_ms();+ *state.searching.lock().unwrap() = None;+ *state.search_count.lock().unwrap() += 1;+ if source == "ai" {+ let n = agg.mouser.hits.len() + agg.digikey.hits.len() + agg.jlcpcb.hits.len();+ push_toast(state, "info", &format!("Your AI searched \"{}\": {} hits in {} ms", keyword, n, ms), None);+ }+ v+}+ pub fn run(args: AppArgs) -> Result<(), String> {- ok(format!("adom-parts-search app starting on port {}", args.port));- note!(" backends:");- note!(" mouser: {}", args.mouser_api.clone().unwrap_or(vendors::DEFAULT_MOUSER.into()));- note!(" digikey: {}", args.digikey_api.clone().unwrap_or(vendors::DEFAULT_DIGIKEY.into()));- note!(" jlcpcb: {}", args.jlcpcb_api.clone().unwrap_or(vendors::DEFAULT_JLCPCB.into()));+ ok(format!("adom-parts-search v{} starting on port {}", VERSION, args.port));+ let (m, d, j) = (+ args.mouser_api.clone().unwrap_or(vendors::DEFAULT_MOUSER.into()),+ args.digikey_api.clone().unwrap_or(vendors::DEFAULT_DIGIKEY.into()),+ args.jlcpcb_api.clone().unwrap_or(vendors::DEFAULT_JLCPCB.into()),+ );+ note!(" backends: mouser {} digikey {} jlcpcb {}", m, d, j); let state = Arc::new(AppState {+ port: args.port, mouser_api: args.mouser_api.clone(), digikey_api: args.digikey_api.clone(), jlcpcb_api: args.jlcpcb_api.clone(), dev: args.dev,+ started: Instant::now(),+ settings: Mutex::new(settings::load()), console: Mutex::new(VecDeque::with_capacity(CONSOLE_MAX)), last_query: Mutex::new(None), last_results: Mutex::new(None), last_query_ts: Mutex::new(0),+ searching: Mutex::new(None),+ search_count: Mutex::new(0),+ toasts: Mutex::new(VecDeque::new()),+ clicks: Mutex::new(VecDeque::new()),+ status_cache: Mutex::new(None),+ status_refreshing: std::sync::atomic::AtomicBool::new(false), pending_eval: Mutex::new(None), eval_results: Mutex::new(std::collections::HashMap::new()), }); let addr = format!("0.0.0.0:{}", args.port);- let server = Server::http(&addr).map_err(|e| format!("bind {}: {}", addr, e))?;+ let server = Server::http(&addr).map_err(|e| format!("bind {}: {}. Hint: `adom-parts-search serve` reuses a running instance instead of binding twice.", addr, e))?;+ crate::serve::publish_state(args.port); if !args.no_tab { let proxy_url = tab::proxy_url(args.port); match tab::add_webview_tab(&args.tab_name, &proxy_url, ICON_SVG) {- Ok(_) => ok(format!("opened Hydrogen tab '{}' → {}", args.tab_name, proxy_url)),+ Ok(_) => ok(format!("opened Hydrogen tab '{}' at {}", args.tab_name, proxy_url)), Err(e) => warn(format!("couldn't open tab: {}. Manually: {}", e, proxy_url)), } }- note!(" {}Press ctrl-c or POST /shutdown to stop.{}", crate::DIM, crate::RESET);-- for mut req in server.incoming_requests() {- let url = req.url().to_string();- let path = url.split_once('?').map(|(p, _)| p).unwrap_or(&url).to_string();- let method = req.method().clone();- let state_ref = Arc::clone(&state);-- let result: (u16, Value) = match (method.clone(), path.as_str()) {- (Method::Get, "/") | (Method::Get, "/index.html") => {- // Explicitly declare we are embed-safe so Hydrogen's webview- // doesn't show its "Some sites block embedding" banner. We- // allow framing from any origin (the container's Coder proxy- // hostname varies per user) but require same-origin for- // everything else. No `X-Frame-Options` — modern browsers- // prefer `frame-ancestors` and emitting both can contradict.- let _ = req.respond(- Response::from_string(UI_HTML).with_status_code(200)- .with_header(Header::from_bytes("Content-Type", "text/html; charset=utf-8").unwrap())- .with_header(Header::from_bytes("Cache-Control", "no-cache").unwrap())- .with_header(Header::from_bytes("Content-Security-Policy", "frame-ancestors *").unwrap()),- );- continue;- }- (Method::Get, "/favicon.svg") => {- let _ = req.respond(- Response::from_string(ICON_SVG).with_status_code(200)- .with_header(Header::from_bytes("Content-Type", "image/svg+xml").unwrap())- .with_header(Header::from_bytes("Content-Security-Policy", "frame-ancestors *").unwrap()),- );- continue;- }- (Method::Get, "/state") => {- let q = state_ref.last_query.lock().unwrap().clone();- let r = state_ref.last_results.lock().unwrap().clone();- let ts = *state_ref.last_query_ts.lock().unwrap();- (200, json!({- "dev": state_ref.dev,- "last_query": q,- "last_query_ts": ts as u64,- "last_results": r,- "backends": {- "mouser": state_ref.mouser_api.clone().unwrap_or(vendors::DEFAULT_MOUSER.into()),- "digikey": state_ref.digikey_api.clone().unwrap_or(vendors::DEFAULT_DIGIKEY.into()),- "jlcpcb": state_ref.jlcpcb_api.clone().unwrap_or(vendors::DEFAULT_JLCPCB.into()),- }- }))+ note!(" {}direct url http://127.0.0.1:{}/ ctrl-c or POST /shutdown to stop{}", crate::DIM, args.port, crate::RESET);++ // Warm the status cache so the dock's first probe answers in ms.+ { let st = Arc::clone(&state); std::thread::spawn(move || { let _ = cached_status(&st); }); }++ let no_tab = args.no_tab;+ let tab_name = args.tab_name.clone();+ for req in server.incoming_requests() {+ let st = Arc::clone(&state);+ let tab_name = tab_name.clone();+ std::thread::spawn(move || handle(req, st, no_tab, &tab_name));+ }+ Ok(())+}++fn handle(mut req: Request, state: Arc<AppState>, no_tab: bool, tab_name: &str) {+ let url = req.url().to_string();+ let path = url.split_once('?').map(|(p, _)| p).unwrap_or(&url).trim_end_matches('/').to_string();+ let path = if path.is_empty() { "/".to_string() } else { path };+ let method = req.method().clone();+ // Behind a proxy sub-path the route is a SUFFIX of the url; match by suffix for api routes.+ 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)));+ return;+ }++ let result: (u16, Value) = if method == Method::Get && (path == "/" || ends("/index.html")) {+ let _ = req.respond(text_resp(UI_HTML, "text/html; charset=utf-8"));+ return;+ } else if method == Method::Get && (ends("/favicon.svg") || ends("/icon.svg") || ends("/favicon.ico")) {+ let _ = req.respond(text_resp(ICON_SVG, "image/svg+xml"));+ return;+ } else if method == Method::Get && path.contains("/fonts/") {+ let name = path.rsplit('/').next().unwrap_or("");+ if let Some((_, bytes)) = FONTS.iter().find(|(n, _)| *n == name) {+ let _ = req.respond(with_cors(Response::from_data(bytes.to_vec()).with_status_code(200)+ .with_header(Header::from_bytes("Content-Type", "font/woff2").unwrap())+ .with_header(Header::from_bytes("Cache-Control", "public, max-age=604800").unwrap())));+ } else {+ let _ = req.respond(json_resp(404, json!({"error": "no such font"})));+ }+ return;+ } else if method == Method::Get && (ends("/api/status") || ends("/health")) {+ (200, cached_status(&state))+ } else if method == Method::Get && ends("/api/say") {+ (200, json!({"say": say_phrases(), "noun": "parts search"}))+ } else if method == Method::Post && ends("/api/try") {+ match read_body(&mut req) {+ Ok(v) => {+ let phrase = v.get("phrase").and_then(|p| p.as_str()).unwrap_or("").to_string();+ (200, stage_phrase(&phrase)) }- (Method::Post, "/search") => match read_body(&mut req) {- Ok(body) => {- let keyword = body.get("keyword").and_then(|v| v.as_str()).unwrap_or("").to_string();- let limit = body.get("limit").and_then(|v| v.as_u64()).unwrap_or(5) as u32;- let in_stock = body.get("inStockOnly").and_then(|v| v.as_bool()).unwrap_or(false);- if keyword.trim().is_empty() {- (400, json!({"error": "keyword required"}))- } else {- let agg = vendors::search_all(- &keyword, limit, in_stock,- state_ref.mouser_api.as_deref(),- state_ref.digikey_api.as_deref(),- state_ref.jlcpcb_api.as_deref(),- );- let v = serde_json::to_value(&agg).unwrap_or(json!({}));- *state_ref.last_query.lock().unwrap() = Some(keyword);- *state_ref.last_results.lock().unwrap() = Some(v.clone());- *state_ref.last_query_ts.lock().unwrap() = std::time::SystemTime::now()- .duration_since(std::time::UNIX_EPOCH)- .map(|d| d.as_millis()).unwrap_or(0);- (200, v)- }- }- Err(e) => (400, json!({"error": e})),- },- (Method::Post, "/console") => match read_body(&mut req) {- Ok(v) => {- let mut c = state_ref.console.lock().unwrap();- if c.len() >= CONSOLE_MAX { c.pop_front(); }- c.push_back(v);- (200, json!({"ok": true}))+ Err(e) => (400, json!({"ok": false, "error": e})),+ }+ } else if method == Method::Get && ends("/api/settings") {+ let s = state.settings.lock().unwrap().clone();+ (200, json!({"ok": true, "settings": settings::as_json(&s), "defaults": settings::defaults_json(), "path": settings::path().to_string_lossy(), "boxes": taskbar::list_targets()}))+ } else if method == Method::Post && ends("/api/settings") {+ match read_body(&mut req) {+ Ok(v) => {+ let patch = if v.get("restore").and_then(|b| b.as_bool()) == Some(true) { settings::defaults_json() } else { v.get("settings").cloned().unwrap_or(v.clone()) };+ let mut s = state.settings.lock().unwrap().clone();+ match settings::apply(&mut s, &patch) {+ Ok(changed) => match settings::save(&s) {+ Ok(()) => {+ *state.settings.lock().unwrap() = s.clone();+ *state.status_cache.lock().unwrap() = None;+ (200, json!({"ok": true, "changed": changed, "settings": settings::as_json(&s)}))+ }+ Err(e) => (500, json!({"ok": false, "error": e})),+ },+ Err(e) => (400, json!({"ok": false, "error": e, "settings": settings::as_json(&state.settings.lock().unwrap())})), }- Err(e) => (400, json!({"error": e})),- },- (Method::Get, "/console") => {- let c: Vec<Value> = state_ref.console.lock().unwrap().iter().cloned().collect();- (200, json!({"messages": c})) }- (Method::Post, "/eval") if state_ref.dev => match read_body(&mut req) {- Ok(v) => {- let code = v.get("code").and_then(|v| v.as_str()).unwrap_or("").to_string();- let id = format!("ev{:x}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos());- *state_ref.pending_eval.lock().unwrap() = Some((id.clone(), code));- (200, json!({"id": id}))- }- Err(e) => (400, json!({"error": e})),- },- (Method::Get, "/eval/pending") if state_ref.dev => {- let p = state_ref.pending_eval.lock().unwrap().take();- match p {- Some((id, code)) => (200, json!({"id": id, "code": code})),- None => (200, json!({})),- }+ Err(e) => (400, json!({"ok": false, "error": e})),+ }+ } else if method == Method::Post && ends("/api/toast") {+ match read_body(&mut req) {+ Ok(v) => {+ let msg = v.get("message").and_then(|m| m.as_str()).unwrap_or("").to_string();+ let kind = v.get("kind").and_then(|k| k.as_str()).unwrap_or("info").to_string();+ if msg.is_empty() { (400, json!({"ok": false, "error": "message required"})) }+ else { (200, json!({"ok": true, "toast": push_toast(&state, &kind, &msg, v.get("action").cloned())})) } }- (Method::Post, path) if state_ref.dev && path.starts_with("/eval/") && path.ends_with("/result") => {- let id = path.trim_start_matches("/eval/").trim_end_matches("/result").to_string();- match read_body(&mut req) {- Ok(v) => {- let r = v.get("result").cloned().unwrap_or(Value::Null);- state_ref.eval_results.lock().unwrap().insert(id, r);- (200, json!({"ok": true}))- }- Err(e) => (400, json!({"error": e})),+ Err(e) => (400, json!({"ok": false, "error": e})),+ }+ } else if method == Method::Post && ends("/api/click") {+ match read_body(&mut req) {+ Ok(v) => {+ let id = v.get("id").and_then(|m| m.as_str()).unwrap_or("").to_string();+ if id.is_empty() { (400, json!({"ok": false, "error": "id required"})) }+ else {+ let c = json!({"id": id, "ts": now_ms() as u64, "value": v.get("value").cloned()});+ let mut q = state.clicks.lock().unwrap();+ if q.len() >= 20 { q.pop_front(); }+ q.push_back(c.clone());+ (200, json!({"ok": true, "click": c})) } }- (Method::Post, "/shutdown") => {- if !args.no_tab { let _ = tab::remove_webview_tab(&args.tab_name); }- let _ = req.respond(json_resp(200, json!({"ok": true, "shutdown": true})));- ok("shutdown requested, exiting");- std::process::exit(0);+ Err(e) => (400, json!({"ok": false, "error": e})),+ }+ } else if method == Method::Get && ends("/state") {+ let s = state.settings.lock().unwrap().clone();+ (200, json!({+ "app": "adom-parts-search", "version": VERSION, "dev": state.dev,+ "last_query": state.last_query.lock().unwrap().clone(),+ "last_query_ts": *state.last_query_ts.lock().unwrap() as u64,+ "last_results": state.last_results.lock().unwrap().clone(),+ "searching": state.searching.lock().unwrap().clone(),+ "settings": settings::as_json(&s),+ "toasts": state.toasts.lock().unwrap().iter().cloned().collect::<Vec<_>>(),+ "clicks": state.clicks.lock().unwrap().iter().cloned().collect::<Vec<_>>(),+ "say": say_phrases(),+ "timing": {"search_avg_ms": taskbar::average_ms()},+ "backends": { "mouser": backends(&state).0, "digikey": backends(&state).1, "jlcpcb": backends(&state).2 }+ }))+ } else if method == Method::Post && (ends("/search") || ends("/api/search")) {+ match read_body(&mut req) {+ Ok(body) => {+ let s = state.settings.lock().unwrap().clone();+ let keyword = body.get("keyword").and_then(|v| v.as_str()).unwrap_or("").trim().to_string();+ let limit = body.get("limit").and_then(|v| v.as_u64()).map(|n| n as u32).unwrap_or(s.default_limit);+ let in_stock = body.get("inStockOnly").and_then(|v| v.as_bool()).unwrap_or(s.in_stock_only);+ let source = body.get("source").and_then(|v| v.as_str()).unwrap_or("ui").to_string();+ if keyword.is_empty() { (400, json!({"error": "keyword required"})) }+ else if state.searching.lock().unwrap().is_some() && source == "ui" { (409, json!({"error": "a search is already running; wait for it to finish"})) }+ else { (200, run_search(&state, &keyword, limit, in_stock, &source)) } }- _ => (404, json!({"error": format!("unknown endpoint: {} {}", method, path)})),- };- let _ = req.respond(json_resp(result.0, result.1));- }- Ok(())+ Err(e) => (400, json!({"error": e})),+ }+ } else if method == Method::Post && ends("/console") {+ match read_body(&mut req) {+ Ok(v) => { let mut c = state.console.lock().unwrap(); if c.len() >= CONSOLE_MAX { c.pop_front(); } c.push_back(v); (200, json!({"ok": true})) }+ Err(e) => (400, json!({"error": e})),+ }+ } else if method == Method::Get && ends("/console") {+ (200, json!({"messages": state.console.lock().unwrap().iter().cloned().collect::<Vec<_>>()}))+ } else if state.dev && method == Method::Post && ends("/eval") {+ match read_body(&mut req) {+ Ok(v) => {+ let code = v.get("code").and_then(|v| v.as_str()).unwrap_or("").to_string();+ let id = format!("ev{:x}", now_ms());+ *state.pending_eval.lock().unwrap() = Some((id.clone(), code));+ (200, json!({"id": id}))+ }+ Err(e) => (400, json!({"error": e})),+ }+ } else if state.dev && method == Method::Get && ends("/eval/pending") {+ match state.pending_eval.lock().unwrap().take() { Some((id, code)) => (200, json!({"id": id, "code": code})), None => (200, json!({})) }+ } else if state.dev && method == Method::Post && path.contains("/eval/") && path.ends_with("/result") {+ let id = path.rsplit("/eval/").next().unwrap_or("").trim_end_matches("/result").to_string();+ match read_body(&mut req) {+ Ok(v) => { state.eval_results.lock().unwrap().insert(id, v.get("result").cloned().unwrap_or(Value::Null)); (200, json!({"ok": true})) }+ Err(e) => (400, json!({"error": e})),+ }+ } else if state.dev && method == Method::Get && path.contains("/eval/") {+ let id = path.rsplit("/eval/").next().unwrap_or("").to_string();+ match state.eval_results.lock().unwrap().remove(&id) { Some(r) => (200, json!({"id": id, "result": r})), None => (404, json!({"error": "no result yet"})) }+ } else if method == Method::Post && ends("/shutdown") {+ if !no_tab { let _ = tab::remove_webview_tab(tab_name); }+ let _ = std::fs::remove_file(crate::serve::state_path());+ let _ = req.respond(json_resp(200, json!({"ok": true, "shutdown": true})));+ ok("shutdown requested, exiting");+ std::process::exit(0);+ } else {+ (404, json!({"error": format!("unknown endpoint: {} {}", method, path), "_hint": "GET /state, /api/status, /api/settings, /console; POST /search, /api/toast, /api/click, /api/try, /api/settings"}))+ };+ let _ = req.respond(json_resp(result.0, result.1)); }
src/app/ui.html+850−408@@ -2,454 +2,896 @@ <html lang="en"> <head> <meta charset="utf-8">-<meta name="viewport" content="width=device-width,initial-scale=1">-<title>Adom Parts Search</title>+<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">+<title>Parts Search</title> <link rel="icon" type="image/svg+xml" href="favicon.svg"> <style>-@font-face {- font-family: 'Familjen Grotesk';- src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-600-normal.woff2') format('woff2');- font-weight: 600; font-display: swap;-}-@font-face {- font-family: 'Satoshi';- src: url('https://adom.inc/fonts/satoshi/satoshi-400-normal.woff2') format('woff2');- font-weight: 400; font-display: swap;-}-@font-face {- font-family: 'Satoshi';- src: url('https://adom.inc/fonts/satoshi/satoshi-500-normal.woff2') format('woff2');- font-weight: 500; font-display: swap;-}+@font-face{font-family:'Familjen Grotesk';src:url('fonts/familjen-grotesk-latin-500-normal.woff2') format('woff2');font-weight:500;font-display:swap}+@font-face{font-family:'Familjen Grotesk';src:url('fonts/familjen-grotesk-latin-600-normal.woff2') format('woff2');font-weight:600;font-display:swap}+@font-face{font-family:'Familjen Grotesk';src:url('fonts/familjen-grotesk-latin-700-normal.woff2') format('woff2');font-weight:700;font-display:swap}+@font-face{font-family:'Satoshi';src:url('fonts/satoshi-400-normal.woff2') format('woff2');font-weight:400;font-display:swap}+@font-face{font-family:'Satoshi';src:url('fonts/satoshi-500-normal.woff2') format('woff2');font-weight:500;font-display:swap}+@font-face{font-family:'Satoshi';src:url('fonts/satoshi-700-normal.woff2') format('woff2');font-weight:700;font-display:swap} -:root {- --bg: #0d1117; --surface: #161b22; --surface2: #1c2129;- --border: #30363d; --text: #e6edf3; --text-dim: #8b949e;- --accent: #00b8b0;- --green: #3fb950; --red: #f85149; --yellow: #d29922; --blue: #58a6ff;- --mouser: #0073CF; --digikey: #CC0000; --jlcpcb: #57A84A;+:root{+ --bg:#0d1117;--surface:#161b22;--elevated:#1c2128;--overlay:#21262d;--border:#30363d;+ --text:#e6edf3;--text-body:#c9d1d9;--text-2:#8b949e;--text-3:#484f58;+ --accent:#00b8b1;--accent-hover:#00d4cb;--accent-bright:#00e6dc;--on-accent:#05221f;+ --green:#3fb950;--red:#f85149;--yellow:#d29922;--blue:#64abff;--purple:#8c6bf7;+ --mouser:#3f8fe0;--digikey:#e0503f;--jlcpcb:#5fb35a;+ --brand-heading:'Familjen Grotesk',sans-serif;--brand-body:'Satoshi',sans-serif;+ --mono:'JetBrains Mono',ui-monospace,'Cascadia Mono',Consolas,monospace;+ --z-hud:50;--z-toolbar:80;--z-floating-menu:200;--z-toast:400;--z-modal:600;--z-tooltip:99999; }-* { box-sizing: border-box; margin: 0; padding: 0; }-html, body {- background: var(--bg); color: var(--text);- font-family: 'Satoshi', sans-serif; font-size: 14px; line-height: 1.55;- min-height: 100vh; overflow-x: hidden;-}-body { padding: 20px 24px 80px; }-h1 { font-family: 'Familjen Grotesk', sans-serif; font-weight: 600; font-size: 22px; letter-spacing: -0.01em; }+*{box-sizing:border-box;margin:0;padding:0}+html,body{background:var(--bg);color:var(--text-body);font:14px/1.5 var(--brand-body);height:100%;overflow:hidden}+html{scrollbar-width:thin;scrollbar-color:rgba(139,148,158,.35) transparent}+::-webkit-scrollbar{width:8px;height:8px}+::-webkit-scrollbar-track{background:transparent}+::-webkit-scrollbar-thumb{background:rgba(139,148,158,.35);border-radius:999px;border:2px solid transparent;background-clip:padding-box}+::-webkit-scrollbar-thumb:hover{background:var(--accent);background-clip:padding-box}+a{color:var(--blue)}+button{font-family:inherit}+:focus-visible{outline:2px solid var(--accent);outline-offset:2px} -.hdr { display: flex; align-items: center; gap: 14px; margin-bottom: 16px; }-.hdr .logo { width: 34px; height: 34px; color: var(--text); display: inline-flex; }-.hdr .subtitle { color: var(--text-dim); font-size: 13px; margin-top: 2px; }+/* ---- App shell: fixed full-height column, the body is the only scroller ---- */+.app{position:fixed;inset:0;display:flex;flex-direction:column;background:var(--bg)}+.app-body{background:radial-gradient(900px 420px at 50% -120px,rgba(0,184,177,.16),transparent 70%),radial-gradient(700px 300px at 85% 10%,rgba(140,107,247,.07),transparent 70%),var(--bg)}+.app-header{flex:0 0 44px;height:44px;display:flex;align-items:center;gap:12px;padding:0 14px;background:var(--surface);border-bottom:1px solid var(--border);z-index:var(--z-toolbar);position:relative}+.app-body{flex:1 1 auto;min-height:0;overflow-y:auto;overflow-x:hidden;scrollbar-gutter:stable;-webkit-overflow-scrolling:touch}+.app-footer{flex:0 0 auto;height:26px;display:flex;align-items:center;gap:14px;padding:0 14px;background:var(--surface);border-top:1px solid var(--border);font:11.5px/1 var(--mono);color:var(--text-2);white-space:nowrap;overflow:hidden}+.app-footer .f-led{display:inline-flex;align-items:center;gap:5px}+.app-footer .f-sp{margin-left:auto} -.searchbar {- display: flex; gap: 10px; align-items: stretch; margin-bottom: 10px;- background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 6px;-}-.searchbar:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(0,184,176,0.15); }-.searchbar input {- flex: 1; background: transparent; border: none; outline: none; color: var(--text);- font-family: 'Satoshi', sans-serif; font-size: 15px; padding: 10px 14px;-}-.searchbar input::placeholder { color: var(--text-dim); }-.limit-select {- background: var(--surface2); border: 1px solid var(--border); border-radius: 8px;- color: var(--text); padding: 0 10px; font-size: 13px; cursor: pointer;- font-family: 'JetBrains Mono', monospace;-}-.btn { padding: 10px 18px; border: none; border-radius: 8px; cursor: pointer; font-weight: 600; font-size: 14px; }-.btn.primary { background: var(--accent); color: #0d1117; }-.btn.primary:hover { background: #00d4cb; }-.btn.primary:disabled { background: var(--border); color: var(--text-dim); cursor: not-allowed; }-.filters { display: flex; gap: 14px; align-items: center; color: var(--text-dim); font-size: 13px; margin-bottom: 20px; }-.filters label { display: flex; gap: 6px; align-items: center; cursor: pointer; }-.filters input[type=checkbox] { accent-color: var(--accent); width: 14px; height: 14px; }--.recommendation {- display: flex; align-items: center; gap: 14px;- background: linear-gradient(90deg, rgba(0,184,176,0.10), rgba(0,184,176,0.02));- border: 1px solid rgba(0,184,176,0.35);- border-radius: 12px; padding: 14px 18px; margin-bottom: 20px;-}-.recommendation .label { font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: var(--accent); font-weight: 600; }-.recommendation .main { font-family: 'Familjen Grotesk', sans-serif; font-size: 17px; font-weight: 600; }-.recommendation .reason { color: var(--text-dim); font-size: 13px; margin-top: 2px; }-.recommendation.empty { background: linear-gradient(90deg, rgba(210,153,34,0.10), rgba(210,153,34,0.02)); border-color: rgba(210,153,34,0.35); }-.recommendation.empty .label { color: var(--yellow); }-.recommendation.empty .es-hint { color: var(--text); font-size: 12.5px; margin-top: 6px; padding-top: 6px; border-top: 1px dashed var(--border); }-.recommendation.empty .es-hint::before { content: "→ "; color: var(--accent); font-weight: 600; }--.statusline { color: var(--text-dim); font-size: 13px; margin: 4px 0 14px; min-height: 20px; }-.statusline.error { color: var(--red); }-.statusline.ok { color: var(--green); }-.loading { display: inline-block; width: 14px; height: 14px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; vertical-align: middle; margin-right: 6px; }-@keyframes spin { to { transform: rotate(360deg); } }--/* Auto-fit: always fit as many columns as will fit at ≥320 px each.- Narrow panes get 1-col, medium 2-col, wide 3-col — no hard breakpoint- that drops us from 3 straight to 1 (the previous 1100 px rule did- that, hiding the other two columns below a horizontal scroll in a- split Hydrogen pane). */-.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; }--.column { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; min-height: 240px; display: flex; flex-direction: column; }-.column.mouser { border-top: 3px solid var(--mouser); }-.column.digikey { border-top: 3px solid var(--digikey); }-.column.jlcpcb { border-top: 3px solid var(--jlcpcb); }-.column.preferred { box-shadow: 0 0 0 2px rgba(0,184,176,0.35), 0 12px 40px rgba(0,184,176,0.10); }--.col-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 10px; padding-bottom: 10px; border-bottom: 1px solid var(--border); flex-wrap: wrap; }-.col-title { font-family: 'Familjen Grotesk', sans-serif; font-size: 19px; font-weight: 600; }-.col-title.mouser { color: var(--mouser); }-.col-title.digikey { color: var(--digikey); }-.col-title.jlcpcb { color: var(--jlcpcb); }-.col-meta { color: var(--text-dim); font-size: 11px; font-family: 'JetBrains Mono', monospace; }-.col-badge { font-size: 10px; text-transform: uppercase; letter-spacing: 0.8px; font-weight: 600; padding: 3px 8px; border-radius: 6px; background: rgba(0,184,176,0.15); color: var(--accent); border: 1px solid rgba(0,184,176,0.3); }--.card { display: grid; grid-template-columns: 72px 1fr; gap: 12px; padding: 10px 0; border-bottom: 1px dashed var(--border); }-.card:last-child { border-bottom: none; }-.card-img { width: 72px; height: 72px; border-radius: 8px; background: #fff; display: flex; align-items: center; justify-content: center; overflow: hidden; cursor: zoom-in; flex-shrink: 0; }-.card-img img { width: 100%; height: 100%; object-fit: contain; }-.card-img .placeholder { color: #6b7380; font-size: 10px; }-.card-body { min-width: 0; display: flex; flex-direction: column; gap: 3px; }-.mpn { font-family: 'JetBrains Mono', monospace; color: var(--text); font-size: 13px; font-weight: 600; word-break: break-word; }-.vendor-pn { font-family: 'JetBrains Mono', monospace; color: var(--text-dim); font-size: 11px; }-.manuf { color: var(--text-dim); font-size: 11px; }-.desc { color: var(--text); font-size: 12px; line-height: 1.4; margin-top: 2px; }-.row-price { display: flex; gap: 10px; font-size: 12px; margin-top: 4px; align-items: baseline; flex-wrap: wrap; }-.price { font-family: 'JetBrains Mono', monospace; color: var(--accent); font-weight: 600; }-.price-100 { font-family: 'JetBrains Mono', monospace; color: var(--text-dim); font-size: 11px; }-.stock.high { color: var(--green); }-.stock.medium { color: var(--yellow); }-.stock.low { color: var(--red); }-.stock.zero { color: var(--text-dim); }-.flags { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 4px; }-.flag { font-size: 10px; font-family: 'JetBrains Mono', monospace; padding: 1px 6px; border-radius: 4px; background: var(--surface2); color: var(--text-dim); border: 1px solid var(--border); }-.flag.basic { color: var(--green); border-color: rgba(63,185,80,0.3); }-.flag.preferred { color: var(--blue); border-color: rgba(88,166,255,0.3); }-.card-actions { margin-top: 6px; display: flex; gap: 8px; }-.card-actions a { font-size: 11px; color: var(--accent); text-decoration: none; padding: 3px 8px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface2); }-.card-actions a:hover { border-color: var(--accent); }-.xref-badge { font-size: 9px; padding: 1px 4px; border-radius: 3px; background: var(--surface); color: var(--text-dim); margin-left: 4px; border: 1px solid var(--border); vertical-align: baseline; }--.col-empty { flex: 1; display: flex; align-items: center; justify-content: center; color: var(--text-dim); font-size: 13px; font-style: italic; padding: 30px 10px; text-align: center; }-.col-error { padding: 16px 14px; display: flex; flex-direction: column; gap: 10px; }-.col-error .ce-title { display: flex; align-items: center; gap: 8px; color: var(--yellow); font-family: 'Familjen Grotesk', sans-serif; font-weight: 600; font-size: 14px; line-height: 1.3; }-.col-error .ce-icon { width: 18px; height: 18px; flex-shrink: 0; color: var(--yellow); }-.col-error .ce-body { color: var(--text); font-size: 12.5px; line-height: 1.5; }-.col-error .ce-hint { color: var(--text-dim); font-size: 12px; line-height: 1.5; padding-top: 4px; border-top: 1px dashed var(--border); }-.col-error .ce-hint::before { content: "→ "; color: var(--accent); font-weight: 600; }--.lightbox { position: fixed; inset: 0; z-index: 100000; background: rgba(13,17,23,0.92); backdrop-filter: blur(10px); display: flex; align-items: center; justify-content: center; opacity: 0; pointer-events: none; transition: opacity 0.18s ease; cursor: zoom-out; }-.lightbox.open { opacity: 1; pointer-events: auto; }-.lightbox img { max-width: min(90vw, 1200px); max-height: 86vh; border-radius: 12px; box-shadow: 0 24px 80px rgba(0,0,0,0.7); background: #fff; }-.lb-caption { position: absolute; bottom: 24px; left: 50%; transform: translateX(-50%); color: var(--text); background: rgba(22,27,34,0.88); padding: 10px 18px; border-radius: 10px; font-size: 13px; border: 1px solid var(--border); max-width: min(90vw, 900px); font-family: 'JetBrains Mono', monospace; }-.lb-close { position: absolute; top: 20px; right: 24px; width: 40px; height: 40px; border-radius: 50%; background: rgba(22,27,34,0.88); border: 1px solid var(--border); color: var(--text); display: flex; align-items: center; justify-content: center; cursor: pointer; font-size: 18px; }--[data-tooltip] { position: relative; }-[data-tooltip]::after {- content: attr(data-tooltip);- position: absolute;- bottom: calc(100% + 8px);- left: 0;- right: auto;- width: max-content;- max-width: min(360px, calc(100vw - 48px));- white-space: pre-wrap;- background: rgba(22,27,34,0.96);- backdrop-filter: blur(16px);- border: 1px solid rgba(0,184,176,0.2);- border-radius: 10px;- padding: 12px 16px;- font-family: 'Satoshi', sans-serif;- font-size: 12px;- line-height: 1.55;- color: #e6edf3;- text-align: left;- pointer-events: none;- opacity: 0;- transform: translateY(4px);- box-shadow: 0 12px 40px rgba(0,0,0,0.55);- transition: opacity 0.2s ease 0s, transform 0.2s ease 0s;- z-index: 99999;+/* ---- Header zones ---- */+.brand{display:flex;align-items:center;gap:8px;font:600 14px var(--brand-heading);color:var(--accent);white-space:nowrap;cursor:default;position:relative;height:44px}+.brand svg{width:18px;height:18px;color:var(--text);display:block}+.brand .sub{color:var(--text-2);font-weight:500}+.vsep{width:1px;height:20px;background:var(--border);flex:none}+.subject{font-weight:600;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}+.subject .ph{color:var(--text-2);font-weight:400}+.meta{font:12px var(--mono);color:var(--text-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}+.actions{margin-left:auto;display:flex;gap:6px;align-items:center;flex:none}+.iconbtn{width:32px;height:32px;display:inline-flex;align-items:center;justify-content:center;background:none;border:1px solid transparent;border-radius:8px;color:var(--text-body);cursor:pointer;transition:background .15s ease,border-color .15s ease,color .15s ease,transform .06s ease}+.iconbtn:hover{background:var(--elevated);border-color:var(--border);color:var(--text)}+.iconbtn:active{transform:scale(.9);background:var(--overlay)}+.iconbtn svg{width:20px;height:20px;display:block}+.hled{width:10px;height:10px;border-radius:999px;background:var(--text-3);margin:0 6px;flex:none;transition:background .15s ease}+.hled.ok{background:var(--green);box-shadow:0 0 8px rgba(63,185,80,.5)}+.hled.warn{background:var(--yellow)}+.hled.fail{background:var(--red)}+.hled.busy{background:var(--accent);animation:ledpulse 1.1s ease-in-out infinite}+@keyframes ledpulse{0%,100%{opacity:1;box-shadow:0 0 0 0 rgba(0,184,177,.55)}50%{opacity:.55;box-shadow:0 0 10px 3px rgba(0,184,177,.25)}}+/* App-info popover under the brand (600ms show, 220ms hide grace) */+.brand-pop{position:absolute;top:40px;left:0;min-width:260px;background:var(--overlay);border:1px solid var(--border);border-radius:10px;padding:10px 12px;box-shadow:0 12px 40px rgba(0,0,0,.55);z-index:var(--z-floating-menu);display:none;pointer-events:auto;color:var(--text-body);font:13px/1.5 var(--brand-body);font-weight:400;white-space:normal;cursor:auto}+.brand-pop.open{display:block}+.brand-pop dl{display:grid;grid-template-columns:auto 1fr;gap:2px 12px}+.brand-pop dt{color:var(--text-2)}+.brand-pop dd{font-family:var(--mono);font-size:12px;color:var(--text)}+.brand-pop dd a{font-family:var(--brand-body);font-size:13px}++/* ---- Main column ---- */+main{max-width:1320px;margin:0 auto;padding:16px 16px 40px}++/* "Say it" strip: the first thing on the page (dock contract) */+.say{position:relative;background:var(--surface);border:1px solid var(--border);border-left:3px solid color-mix(in srgb,var(--accent) 45%,transparent);border-radius:12px;padding:12px 44px 12px 14px;margin-bottom:14px;animation:fadein .25s ease}+.say[hidden]{display:none}+.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:8px}+.say-head h2{font:600 15px var(--brand-heading);color:var(--text)}+.say-sub{font-size:12.5px;color:var(--text-2);max-width:70ch}+.say-sub b{color:var(--text);font-weight:600}+.say-list{display:flex;flex-wrap:wrap;gap:8px}+.say-item{display:inline-flex;align-items:stretch;border:1px solid var(--border);border-radius:999px;background:var(--elevated);overflow:hidden;transition:border-color .15s ease}+.say-item:hover{border-color:var(--accent)}+.say-item.copied{border-color:var(--green)}+.say-phrase{display:inline-block;min-height:34px;line-height:22px;padding:6px 12px 6px 14px;background:none;border:none;color:var(--text);font:500 13.5px/22px var(--brand-body);cursor:pointer;transition:background .15s ease,transform .06s ease;text-align:left;white-space:normal}+.say-phrase .txt{white-space:pre-wrap}+.say-phrase em{font-style:normal;color:var(--accent)}+.say-phrase:hover{background:var(--overlay)}+.say-phrase:active{transform:translateY(1px) scale(.98)}+.say-try{display:inline-flex;align-items:center;padding:0 12px;border:none;border-left:1px solid var(--border);background:none;color:var(--text-2);font:600 12px var(--brand-heading);cursor:pointer;transition:background .15s ease,color .15s ease,transform .06s ease}+.say-try:hover{background:var(--overlay);color:var(--accent)}+.say-try:active{transform:translateY(1px)}+.say-try:disabled{opacity:.5;cursor:progress}+.say-phrase:focus-visible,.say-try:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}++/* Search bar */+.searchwrap{position:relative;border-radius:14px;padding:1px;background:var(--border);transition:background .2s ease;box-shadow:0 12px 40px rgba(0,0,0,.35)}+.searchwrap:hover{background:#4b5563}+.searchwrap:focus-within{background:linear-gradient(100deg,#00e6dc,#39b8ff 60%,#8c6bf7);box-shadow:0 0 0 4px rgba(0,184,177,.12),0 12px 40px rgba(0,0,0,.35)}+.searchbar{display:flex;gap:8px;align-items:center;background:var(--surface);border-radius:13px;padding:7px}+.searchbar .sicon{width:20px;height:20px;color:var(--text-2);margin-left:8px;flex:none}+.searchbar input{flex:1 1 200px;min-width:0;background:transparent;border:none;outline:none;color:var(--text);font:500 16px var(--brand-body);padding:9px 6px;height:42px}+.examples{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin:10px 4px 0}+.ex-label{font:600 11.5px var(--brand-heading);color:var(--text-2);margin-right:2px}+.ex{min-height:28px;padding:3px 11px;border-radius:999px;border:1px solid var(--border);background:var(--surface);color:var(--text-body);font:500 12.5px var(--mono);cursor:pointer;transition:border-color .15s ease,color .15s ease,background .15s ease,transform .06s ease}+.ex:hover{border-color:var(--accent);color:var(--accent);background:var(--elevated)}+.ex:active{transform:translateY(1px) scale(.97)}+.searchbar input::placeholder{color:var(--text-2)}+.sb-controls{display:flex;gap:8px;align-items:center;flex:none}+.seg{display:inline-flex;background:var(--elevated);border:1px solid var(--border);border-radius:8px;padding:2px;gap:2px}+.seg button{min-width:34px;height:32px;padding:0 8px;border:none;border-radius:6px;background:transparent;color:var(--text-2);font:500 13px var(--mono);cursor:pointer;transition:background .15s ease,color .15s ease,transform .06s ease}+.seg button:hover{background:var(--overlay);color:var(--text)}+.seg button:active{transform:scale(.95)}+.seg button[aria-pressed="true"]{background:var(--accent);color:var(--on-accent);box-shadow:inset 0 0 0 1px rgba(0,0,0,.15)}+.seg-label{font-size:12px;color:var(--text-2);margin-right:2px}+.toggle{background:none;border:none;padding:2px;cursor:pointer;flex-shrink:0;transition:transform .06s ease;display:inline-flex;align-items:center}+.toggle-track{display:block;width:32px;height:18px;background:var(--overlay);border:1px solid var(--border);border-radius:9px;position:relative;transition:background .15s ease,border-color .15s ease}+.toggle:hover .toggle-track{border-color:var(--accent)}+.toggle:active{transform:translateY(1px) scale(.97)}+.toggle.active .toggle-track{background:var(--accent);border-color:var(--accent)}+.toggle-thumb{position:absolute;top:2px;left:2px;width:12px;height:12px;background:#fff;border-radius:50%;transition:transform .18s ease,width .12s ease}+.toggle.active .toggle-thumb{transform:translateX(14px)}+.toggle:active .toggle-thumb{width:16px}+.toggle.active:active .toggle-thumb{transform:translateX(10px)}+.toggle:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:10px}+.toggle:disabled{opacity:.5;cursor:not-allowed}+.toggle-row{display:inline-flex;align-items:center;gap:8px;min-height:36px;padding:0 6px;border-radius:8px;cursor:pointer;transition:background .15s ease}+.toggle-row:hover{background:var(--elevated)}+.toggle-row .tl{font-size:13px;color:var(--text-body);white-space:nowrap}+.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:38px;padding:0 18px;border-radius:8px;border:1px solid var(--border);background:var(--elevated);color:var(--text);font:600 14px var(--brand-heading);cursor:pointer;transition:background .15s ease,border-color .15s ease,color .15s ease,transform .06s ease;white-space:nowrap}+.btn:hover{background:var(--overlay);border-color:#4b5563}+.btn:active{transform:translateY(1px) scale(.98);background:var(--surface)}+.btn.primary{background:var(--accent);border-color:var(--accent);color:var(--on-accent);box-shadow:0 6px 18px rgba(0,184,177,.25)}+.btn.primary:hover{background:var(--accent-hover);border-color:var(--accent-hover)}+.btn.primary:active{background:#009d97;border-color:#009d97}+.btn:disabled{opacity:.55;cursor:progress;transform:none}+.btn svg{width:16px;height:16px}++.statusline{display:flex;align-items:center;gap:8px;color:var(--text-2);font-size:13px;margin:10px 2px 12px;min-height:20px}+.statusline.error{color:var(--red)}+.statusline.ok{color:var(--text-2)}+.spin{display:inline-block;width:14px;height:14px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite;flex:none}+@keyframes spin{to{transform:rotate(360deg)}}+.pbar{height:4px;background:var(--elevated);border-radius:999px;overflow:hidden;margin:0 2px 12px;display:none}+.pbar>div{height:100%;width:0;background:var(--accent);transition:width .3s linear}+.pbar.on{display:block}+.pbar.indet>div{width:30%!important;animation:marquee 1.2s linear infinite}+@keyframes marquee{0%{margin-left:-30%}100%{margin-left:100%}}++/* Recommendation banner */+.recommendation{display:flex;align-items:center;gap:14px;background:linear-gradient(90deg,rgba(0,184,177,.14),rgba(0,184,177,.03) 60%,rgba(140,107,247,.06));border:1px solid rgba(0,184,177,.4);border-radius:14px;padding:14px 18px;margin-bottom:16px;box-shadow:0 10px 40px rgba(0,184,177,.08);animation:fadein .3s ease}+.recommendation .rbody{flex:1;min-width:0}+.recommendation .ricon{width:40px;height:40px;flex:none;border-radius:12px;background:rgba(0,184,177,.15);color:var(--accent);display:flex;align-items:center;justify-content:center}+.recommendation .ricon svg{width:22px;height:22px}+.recommendation.empty .ricon{background:rgba(210,153,34,.15)}+.recommendation .label{font:600 12px var(--brand-heading);letter-spacing:.02em;color:var(--accent)}+.recommendation .main{font:600 18px/1.3 var(--brand-heading);color:var(--text);margin-top:2px}+.recommendation .main code{font:600 14px var(--mono);color:var(--text);background:rgba(110,118,129,.2);padding:1px 6px;border-radius:4px}+.recommendation .reason{color:var(--text-body);font-size:13px;margin-top:3px}+.recommendation .prov{color:var(--text-2);font-size:12px;margin-top:4px}+.recommendation.empty{background:linear-gradient(90deg,rgba(210,153,34,.10),rgba(210,153,34,.02));border-color:rgba(210,153,34,.35)}+.recommendation.empty .label,.recommendation.empty .ricon{color:var(--yellow)}+.recommendation .es-hint{color:var(--text-body);font-size:12.5px;margin-top:6px;padding-top:6px;border-top:1px dashed var(--border)}++/* The three columns */+.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:14px}+.column{background:linear-gradient(180deg,var(--elevated),var(--surface) 80px);border:1px solid var(--border);border-radius:14px;padding:12px 14px 6px;min-height:220px;display:flex;flex-direction:column;min-width:0;position:relative;overflow:hidden;transition:box-shadow .2s ease,border-color .2s ease}+.column:hover{border-color:#3d444d}+.column::before{content:"";position:absolute;left:0;right:0;top:0;height:3px;background:var(--vcol,var(--border))}+.column.mouser{--vcol:var(--mouser)}+.column.digikey{--vcol:var(--digikey)}+.column.jlcpcb{--vcol:var(--jlcpcb)}+.column.preferred{border-color:rgba(0,184,177,.5);box-shadow:0 0 0 1px rgba(0,184,177,.35),0 16px 50px rgba(0,184,177,.12)}+.column.skipped{opacity:.6}+.col-head{display:flex;align-items:center;gap:8px;margin:4px 0 10px;padding-bottom:10px;border-bottom:1px solid var(--border);flex-wrap:wrap;min-height:30px}+.col-dot{width:10px;height:10px;border-radius:999px;background:var(--vcol);flex:none;box-shadow:0 0 10px var(--vcol)}+.col-title{font:600 19px var(--brand-heading);color:var(--text);letter-spacing:-.01em}+.col-meta{color:var(--text-2);font:11px var(--mono);margin-left:auto;white-space:nowrap}+.col-badge{font:600 11px var(--brand-heading);letter-spacing:.01em;padding:3px 8px;border-radius:999px;background:rgba(0,184,177,.14);color:var(--accent);border:1px solid rgba(0,184,177,.3)}+.col-badge.win{background:var(--accent);color:var(--on-accent);border-color:var(--accent)}+.col-body{display:flex;flex-direction:column;min-width:0}+.card{display:grid;grid-template-columns:72px minmax(0,1fr);gap:12px;padding:12px 0;border-bottom:1px solid var(--border);animation:fadein .35s ease both;border-radius:8px;transition:background .15s ease}+.card:nth-child(2){animation-delay:.05s}.card:nth-child(3){animation-delay:.1s}.card:nth-child(4){animation-delay:.15s}.card:nth-child(5){animation-delay:.2s}+.card:hover{background:rgba(255,255,255,.02)}+.card:last-child{border-bottom:none}+@keyframes fadein{from{opacity:0;transform:translateY(3px)}to{opacity:1;transform:none}}+.card-img{width:72px;height:72px;border-radius:10px;background:#f6f7f9;box-shadow:0 4px 14px rgba(0,0,0,.35);display:flex;align-items:center;justify-content:center;overflow:hidden;cursor:zoom-in;flex-shrink:0;border:1px solid var(--border);transition:transform .15s ease,border-color .15s ease}+.card-img:hover{border-color:var(--accent);transform:scale(1.03)}+.card-img:active{transform:scale(.97)}+.card-img.noimg{background:var(--elevated);cursor:default}+.card-img.noimg:hover{transform:none;border-color:var(--border)}+.card-img img{width:100%;height:100%;object-fit:contain;display:block;padding:4px}+.card-img .placeholder{color:var(--text-2);font-size:10px;text-align:center;line-height:1.2}+.card-img .placeholder svg{width:22px;height:22px;display:block;margin:0 auto 2px;color:var(--text-2)}+.card-body{min-width:0;display:flex;flex-direction:column;gap:2px}+.mpn{font:600 13px var(--mono);color:var(--text);word-break:break-word}+.vendor-pn{font:11px var(--mono);color:var(--text-2)}+.manuf{color:var(--text-2);font-size:11.5px}+.desc{color:var(--text-body);font-size:12px;line-height:1.4;margin-top:2px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}+.row-price{display:flex;gap:10px;font-size:12px;margin-top:4px;align-items:baseline;flex-wrap:wrap}+.price{font:700 15px var(--mono);color:var(--accent)}+.price-100{font:11px var(--mono);color:var(--text-2)}+.stock{font-size:12px;display:inline-flex;align-items:center;gap:6px}+.stock i{display:inline-block;width:34px;height:4px;border-radius:999px;background:var(--overlay);overflow:hidden;position:relative}+.stock i::after{content:"";position:absolute;inset:0;width:var(--fill,0%);background:currentColor;border-radius:999px}+.stock.high{color:var(--green)}.stock.medium{color:var(--yellow)}.stock.low{color:var(--red)}.stock.zero{color:var(--text-2)}+.flags{display:flex;gap:4px;flex-wrap:wrap;margin-top:4px}+.flag{font:10.5px var(--mono);padding:1px 6px;border-radius:4px;background:var(--elevated);color:var(--text-2);border:1px solid var(--border)}+.flag.basic{color:var(--green);border-color:rgba(63,185,80,.3)}+.flag.preferred{color:var(--blue);border-color:rgba(100,171,255,.3)}+.card-actions{margin-top:6px;display:flex;gap:6px;flex-wrap:wrap}+.card-actions a{display:inline-flex;align-items:center;gap:4px;min-height:26px;font:600 11.5px var(--brand-heading);color:var(--text-body);text-decoration:none;padding:2px 9px;border:1px solid var(--border);border-radius:999px;background:var(--elevated);transition:background .15s ease,border-color .15s ease,color .15s ease,transform .06s ease}+.card-actions a:hover{border-color:var(--accent);color:var(--accent);background:var(--overlay)}+.card-actions a:active{transform:translateY(1px) scale(.97)}+.card-actions a svg{width:12px;height:12px}+.xref{font:10px var(--brand-body);color:var(--text-2);margin-left:2px}+.col-empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;color:var(--text-2);font-size:13px;padding:32px 14px;text-align:center;max-width:30ch;margin:0 auto;line-height:1.5}+.col-empty svg{width:34px;height:34px;color:var(--text-3);opacity:.9}+.skel{display:grid;grid-template-columns:72px minmax(0,1fr);gap:12px;padding:12px 0;border-bottom:1px solid var(--border)}+.skel:last-child{border-bottom:none}+.skel .b{border-radius:8px;background:linear-gradient(90deg,var(--elevated) 25%,var(--overlay) 50%,var(--elevated) 75%);background-size:200% 100%;animation:shimmer 1.2s linear infinite}+.skel .img{width:72px;height:72px;border-radius:10px}+.skel .l{height:12px;margin-bottom:8px}+@keyframes shimmer{to{background-position:-200% 0}}+.col-error{padding:12px 4px;display:flex;flex-direction:column;gap:8px}+.col-error .ce-title{display:flex;align-items:center;gap:8px;color:var(--yellow);font:600 13.5px/1.3 var(--brand-heading)}+.col-error .ce-title svg{width:18px;height:18px;flex-shrink:0}+.col-error.skip .ce-title{color:var(--text-2)}+.col-error .ce-body{color:var(--text-body);font-size:12.5px;line-height:1.5}+.col-error .ce-hint{color:var(--text-2);font-size:12px;line-height:1.5;padding-top:6px;border-top:1px dashed var(--border)}+.col-error .ce-hint b{color:var(--accent);font-weight:600}++/* Lightbox */+.lightbox{position:fixed;inset:0;z-index:var(--z-modal);background:rgba(13,17,23,.9);display:flex;align-items:center;justify-content:center;opacity:0;pointer-events:none;transition:opacity .18s ease;cursor:zoom-out}+.lightbox.open{opacity:1;pointer-events:auto}+.lightbox img{max-width:min(90vw,1200px);max-height:86vh;border-radius:12px;box-shadow:0 24px 80px rgba(0,0,0,.7);background:#fff}+.lb-caption{position:absolute;bottom:24px;left:50%;transform:translateX(-50%);color:var(--text);background:rgba(22,27,34,.92);padding:10px 18px;border-radius:10px;font:13px var(--mono);border:1px solid var(--border);max-width:min(90vw,900px)}+.lb-close{position:absolute;top:16px;right:16px}++/* Toasts: bottom center, newest on top */+#toasts{position:fixed;left:50%;bottom:calc(34px + env(safe-area-inset-bottom));transform:translateX(-50%);display:flex;flex-direction:column-reverse;gap:8px;z-index:var(--z-toast);width:min(560px,calc(100vw - 32px));pointer-events:none}+.toast{display:flex;align-items:center;gap:12px;background:var(--overlay);border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:12px;padding:11px 14px;color:var(--text);font-size:13.5px;box-shadow:0 18px 50px rgba(0,0,0,.5);pointer-events:auto;animation:toastin .2s ease}+@keyframes toastin{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}+.toast.success{border-left-color:var(--green)}.toast.error{border-left-color:var(--red)}+.toast svg.ti{width:18px;height:18px;flex:none;color:var(--accent)}+.toast.success svg.ti{color:var(--green)}.toast.error svg.ti{color:var(--red)}+.toast .tmsg{flex:1;min-width:0;overflow-wrap:anywhere}+.toast .taction{background:none;border:none;color:var(--blue);font:600 13px var(--brand-heading);cursor:pointer;padding:4px 6px;border-radius:6px;transition:background .15s ease}+.toast .taction:hover{background:rgba(100,171,255,.12)}+.toast .tclose{width:26px;height:26px}++/* Global tooltip: ONE body-appended fixed div, always topmost */+#global-tooltip{position:fixed;z-index:99999;top:0;left:0;transform:translate(var(--tip-x,0),var(--tip-y,0));max-width:min(340px,calc(100vw - 40px));pointer-events:none;opacity:0;display:none;background:rgba(22,27,34,.97);color:var(--text);border:1px solid rgba(0,184,177,.28);border-radius:10px;padding:9px 13px;font:400 12.5px/1.45 var(--brand-body);box-shadow:0 12px 40px rgba(0,0,0,.55);text-transform:none;letter-spacing:0;white-space:pre-wrap}+#global-tooltip.visible{display:block}+#global-tooltip.shown{opacity:1;transition:opacity .18s ease .6s}+#global-tooltip.now{transition:opacity .18s ease 0s}++/* ---- Settings modal (settings-layout skill) ---- */+dialog{margin:auto;background:var(--surface);color:var(--text-body);border:1px solid var(--border);border-radius:12px;padding:0;width:min(680px,92vw);max-width:none;overflow:hidden;box-shadow:0 24px 80px rgba(0,0,0,.6)}+dialog:focus{outline:none}+dialog::backdrop{background:rgba(0,0,0,.25)}+.settings-head{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 12px 10px 18px;border-bottom:1px solid var(--border);cursor:move;user-select:none}+.settings-head h2{font:600 15px/1.3 var(--brand-heading);color:var(--text);display:flex;align-items:center;gap:10px}+.settings-body{max-height:70vh;overflow:auto;padding:18px;scrollbar-gutter:stable}+.settings-card{background:var(--elevated);border:1px solid var(--border);border-left:3px solid color-mix(in srgb,var(--accent) 45%,transparent);border-radius:10px;padding:.9rem 1.1rem 1rem;margin-bottom:1rem}+.card-title{font:600 13px/1.3 var(--brand-heading);color:var(--text);display:flex;align-items:center;gap:10px;margin:0 0 .45rem;flex-wrap:wrap}+.card-status{font:400 11px var(--brand-body);color:var(--text-2)}+.card-status.ok{color:var(--green)}.card-status.warn{color:var(--yellow)}.card-status.fail{color:var(--red)}+.card-desc{font:12px/1.55 var(--brand-body);color:var(--text-2);max-width:60ch;margin:0 0 .55rem}+.card-desc b{color:var(--text-body);font-weight:600}+.card-desc code{font:11px var(--mono);background:rgba(110,118,129,.2);padding:1px 4px;border-radius:4px;color:var(--text-body)}+.divider{height:1px;background:var(--border);margin:.55rem 0}+.wrapper{padding:.5rem 0;display:flex;flex-direction:column;gap:.3rem}+.name{margin:.125rem 0;font:600 .9375rem var(--brand-heading)}+.prefix{color:var(--text)}+.title{color:#8fd3d4}+.default-hint{font:.75rem var(--brand-body);color:var(--text-2)}+.setting-row{display:flex;align-items:center;gap:10px;min-height:32px}+.setting-row .rstat{font:11.5px var(--mono);color:var(--text-2);margin-left:auto;white-space:nowrap}+.setting-row .rstat.ok{color:var(--green)}.setting-row .rstat.warn{color:var(--yellow)}.setting-row .rstat.fail{color:var(--red)}+.set-check-label{font:.875rem var(--brand-body);color:var(--text-body)}+.setting-children{margin-left:.75rem;padding-left:1rem;border-left:2px solid var(--border)}+.setting-children[data-enabled='false']{opacity:.55}+.setting-children[data-enabled='false'] .set-check-label{color:var(--text-2)}+.choice-set{border:none;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:.75rem}+.choice{flex:1 1 220px;display:flex;flex-direction:column;gap:.35rem;padding:.7rem .75rem;cursor:pointer;position:relative;background:var(--surface);border:1px solid var(--border);border-radius:8px;transition:border-color .15s ease,background .15s ease,transform .06s ease}+.choice input{position:absolute;opacity:0;pointer-events:none}+.choice-name{font:600 .875rem var(--brand-heading);color:var(--text);display:flex;align-items:center;gap:8px}+.choice-name svg{width:16px;height:16px;color:var(--text-2)}+.choice-desc{font:.75rem/1.5 var(--brand-body);color:var(--text-2)}+.choice:hover{border-color:var(--accent);background:var(--elevated)}+.choice:active{transform:translateY(1px) scale(.995)}+.choice[data-selected='true']{border-color:var(--accent);box-shadow:inset 0 0 0 1px var(--accent)}+.choice:focus-within{outline:2px solid var(--accent);outline-offset:2px}+select.sel{background:var(--overlay);border:1px solid var(--border);border-radius:8px;color:var(--text);padding:6px 10px;font:13px var(--brand-body);min-height:32px;cursor:pointer;transition:border-color .15s ease}+select.sel:hover{border-color:var(--accent)}+select.sel:disabled{opacity:.5;cursor:not-allowed}+.settings-footer{margin-top:1.25rem;padding-top:1rem;border-top:1px solid var(--border);display:flex;flex-direction:column;gap:.5rem}+.footer-note{font:.75rem/1.5 var(--brand-body);color:var(--text-2)}+.restore-btn{width:fit-content;display:inline-flex;align-items:center;gap:.4rem;padding:.4rem .85rem;background:transparent;border:1px solid var(--border);border-radius:.375rem;color:var(--text-body);font:600 .8125rem var(--brand-heading);cursor:pointer;transition:background .15s ease,border-color .15s ease,color .15s ease,transform .06s ease}+.restore-btn:hover{background:var(--elevated)}+.restore-btn:active{transform:translateY(1px) scale(.98)}+.restore-btn.armed{color:var(--red);border-color:rgba(248,81,73,.5);background:rgba(248,81,73,.08)}+.restore-btn svg{width:16px;height:16px}++/* ---- Responsive: reshape, do not shrink ---- */+@media (max-width:720px){+ .brand .sub,.meta,.vsep{display:none}+ .app-header{padding:0 10px;gap:8px}+ main{padding:12px 10px 40px}+ .searchbar{flex-wrap:wrap;padding:8px}+ .searchbar input{flex:1 1 100%;order:1}+ .sb-controls{order:2;flex:1 1 100%;justify-content:space-between;flex-wrap:wrap;gap:6px}+ .sb-controls .btn{flex:1 1 100%;min-height:44px}+ .seg button{min-width:40px;height:36px}+ .toggle-row{min-height:44px}+ .say-phrase{min-height:40px}+ .say-try{padding:0 14px}+ .app-footer .f-hide{display:none}+ .recommendation{padding:12px;flex-wrap:wrap}+ .examples{display:none}+ .card{grid-template-columns:56px minmax(0,1fr)}+ .card-img{width:56px;height:56px}+ .card-actions a{min-height:32px}+ dialog{width:100vw;max-height:100vh;border-radius:0}+ .settings-body{max-height:calc(100vh - 50px)} }-[data-tooltip][data-tooltip-align="right"]::after { left: auto; right: 0; }-[data-tooltip]:hover::after {- opacity: 1;- transform: translateY(0);- transition: opacity 0.2s ease 0.5s, transform 0.2s ease 0.5s;+@media (hover:none),(pointer:coarse){+ .iconbtn,.btn,.say-try{min-width:44px;min-height:44px} } </style> </head> <body>+<div class="app">+ <header class="app-header" role="banner">+ <div class="brand" id="brand" aria-label="Parts Search">+ <svg viewBox="0 0 64 64" aria-hidden="true"><g fill="none" stroke="currentColor" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"><rect x="10" y="14" width="28" height="28" rx="3"/><path d="M6 20 L10 20 M6 28 L10 28 M6 36 L10 36 M38 20 L42 20 M38 28 L42 28 M38 36 L42 36 M16 10 L16 14 M24 10 L24 14 M32 10 L32 14 M16 42 L16 46 M24 42 L24 46 M32 42 L32 46"/></g><circle cx="42" cy="40" r="10" fill="#0d1117" stroke="currentColor" stroke-width="4"/><line x1="50" y1="48" x2="58" y2="56" stroke="currentColor" stroke-width="5" stroke-linecap="round"/></svg>+ <span class="app-name">Parts Search</span><span class="sub">· Mouser, DigiKey, JLCPCB</span>+ <div class="brand-pop" id="brandPop" role="dialog" aria-label="About Parts Search">+ <dl>+ <dt>Version</dt><dd id="popVersion">loading</dd>+ <dt>Engine</dt><dd>Rust, tiny_http</dd>+ <dt>Author</dt><dd>John Lauer, Adom</dd>+ <dt>Wiki</dt><dd><a href="https://wiki.adom.inc/adom/adom-parts-search" target="_blank" rel="noopener">wiki.adom.inc/adom/adom-parts-search</a></dd>+ <dt>Policy</dt><dd id="popPolicy">Mouser preferred</dd>+ </dl>+ </div>+ </div>+ <div class="vsep"></div>+ <div class="subject subject-name" id="subject"><span class="ph">No search yet</span></div>+ <div class="meta" id="meta"></div>+ <div class="actions">+ <span class="hled" id="hled" data-tooltip="Vendor backend health. Green: Mouser, DigiKey, and JLCPCB all answered. Yellow: one is degraded or switched off. Red: none reachable. Pulsing teal: a search is running."></span>+ <button class="iconbtn" id="btnSettings" aria-label="Settings" data-tooltip="Settings: results per vendor, which vendors to query, the recommendation policy, product photos, and the taskbar progress bar."><svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M12 15.5A3.5 3.5 0 0 1 8.5 12A3.5 3.5 0 0 1 12 8.5a3.5 3.5 0 0 1 3.5 3.5a3.5 3.5 0 0 1-3.5 3.5m7.43-2.53c.04-.32.07-.64.07-.97s-.03-.66-.07-1l2.11-1.63c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.31-.61-.22l-2.49 1c-.52-.39-1.06-.73-1.69-.98l-.37-2.65A.506.506 0 0 0 14 2h-4c-.25 0-.46.18-.5.42l-.37 2.65c-.63.25-1.17.59-1.69.98l-2.49-1c-.22-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64L4.57 11c-.04.34-.07.67-.07 1s.03.65.07.97l-2.11 1.66c-.19.15-.25.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1.01c.52.4 1.06.74 1.69.99l.37 2.65c.04.24.25.42.5.42h4c.25 0 .46-.18.5-.42l.37-2.65c.63-.26 1.17-.59 1.69-.99l2.49 1.01c.22.08.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64z"/></svg></button>+ </div>+ </header> -<div class="hdr">- <span class="logo" aria-hidden="true">- <svg width="30" height="30" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">- <g fill="none" stroke="currentColor" stroke-width="4" stroke-linecap="round" stroke-linejoin="round">- <rect x="10" y="14" width="28" height="28" rx="3"/>- <path d="M6 20 L10 20 M6 28 L10 28 M6 36 L10 36 M38 20 L42 20 M38 28 L42 28 M38 36 L42 36 M16 10 L16 14 M24 10 L24 14 M32 10 L32 14 M16 42 L16 46 M24 42 L24 46 M32 42 L32 46"/>- </g>- <circle cx="42" cy="40" r="10" fill="#0d1117" stroke="currentColor" stroke-width="4"/>- <line x1="50" y1="48" x2="58" y2="56" stroke="currentColor" stroke-width="5" stroke-linecap="round"/>- </svg>- </span>- <div>- <h1>Adom Parts Search</h1>- <div class="subtitle">Mouser · DigiKey · JLCPCB — parallel search, Mouser-preferred (40-min drone, Fort Worth ↔ Mansfield, near-zero shipping).</div>- </div>-</div>--<form id="search-form" class="searchbar" autocomplete="off">- <input id="q" name="keyword" type="text" placeholder="Search all three vendors: STM32F103RBT6, iCE40HX4K-TQ144, LDO 3.3V 500mA …" autofocus>- <select id="limit" class="limit-select"- data-tooltip="How many top hits to fetch per vendor. Each vendor's column shows up to this many product cards; the recommendation banner above only ever ranks the top pick. Set higher (10) when you want to compare variants, lower (3) when you just want a quick snapshot. Higher limits cost more upstream API calls — Mouser's free-tier quota is ~1000/day, so 10 means ~100 searches/day max on Mouser.">- <option value="3">3</option>- <option value="5" selected>5</option>- <option value="10">10</option>- </select>- <button type="submit" class="btn primary" id="go"- data-tooltip="Fires one keyword search in parallel against all three vendor backends (Mouser, DigiKey, JLCPCB) and renders the results as three columns below. Also computes a single recommendation — Mouser wins by default because of the 40-minute drone delivery from Mansfield to our Fort Worth facility and near-zero shipping; DigiKey or JLCPCB only win when Mouser is out of stock, has a long lead time, or is meaningfully more expensive on a volume order."- data-tooltip-align="right">Search all 3</button>-</form>-<div class="filters">- <label data-tooltip="When checked, filters out any vendor hits with stock = 0 before the results are shown. Useful when you want to buy today and don't care about backorders or long-lead-time parts. Leave unchecked when you're researching a BOM and want to see all options even if some are out of stock (you might accept a 60-day lead if the part is a perfect fit).">- <input type="checkbox" id="instock"> In stock only- </label>-</div>+ <div class="app-body" id="appBody">+ <main>+ <section class="say" id="say" aria-labelledby="sayTitle" hidden>+ <button class="iconbtn say-close" id="sayClose" aria-label="Hide the Say it strip" data-tooltip="Hide this strip. It stays hidden on every visit until you turn it back on in Settings (Say it strip)."><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 to your AI</h2>+ <span class="say-sub">Ask your AI for parts by name, using the words <b>parts search</b>. Click a phrase to copy it; Try it stages it into this app's AI thread, unsent.</span>+ </div>+ <div class="say-list" id="sayList"></div>+ </section> -<div id="status" class="statusline"></div>-<div id="recommendation"></div>+ <div class="searchwrap"><form id="search-form" class="searchbar" autocomplete="off" role="search">+ <svg class="sicon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5"/></svg>+ <input id="q" name="keyword" type="text" placeholder="Part number or keyword: STM32F103RBT6, LM358, 10k 0402, LDO 3.3V" aria-label="Part number or keyword" autofocus>+ <div class="sb-controls">+ <div class="seg" role="group" aria-label="Results per vendor" data-tooltip="How many hits to fetch from each vendor. 3 for a quick snapshot, 10 to compare variants. Higher costs more upstream API calls: Mouser's free tier is about 1000 per day.">+ <span class="seg-label">per vendor</span>+ <button type="button" data-limit="3" aria-pressed="false">3</button>+ <button type="button" data-limit="5" aria-pressed="true">5</button>+ <button type="button" data-limit="10" aria-pressed="false">10</button>+ </div>+ <label class="toggle-row" data-tooltip="On: hits with zero stock are dropped before rendering, for when you need the part today. Off: every match shows, including backorders and long-lead parts, for BOM research.">+ <button type="button" class="toggle" id="instock" role="switch" aria-checked="false" aria-label="In stock only"><span class="toggle-track"><span class="toggle-thumb"></span></span></button>+ <span class="tl">In stock only</span>+ </label>+ <button type="submit" class="btn primary" id="go" data-tooltip="Fires one search at Mouser, DigiKey, and JLCPCB in parallel and renders three columns below, then names the best source. Mouser wins by default (40-minute drone delivery from Mansfield to Fort Worth, near-zero shipping) unless it is out of stock or long-lead; change the policy in Settings.">Search all 3</button>+ </div>+ </form></div>+ <div class="examples" id="examples" aria-label="Example searches"><span class="ex-label">Try</span></div>+ <div id="status" class="statusline"></div>+ <div id="pbar" class="pbar"><div></div></div>+ <div id="recommendation"></div> -<div id="grid" class="grid">- <div id="col-mouser" class="column mouser">- <div class="col-head">- <span class="col-title mouser">Mouser</span>- <span class="col-badge">Preferred · 40-min drone · $0 ship</span>- <span class="col-meta" id="meta-mouser"></span>- </div>- <div class="col-body" id="body-mouser"><div class="col-empty">Run a search to see Mouser results.</div></div>- </div>- <div id="col-digikey" class="column digikey">- <div class="col-head">- <span class="col-title digikey">DigiKey</span>- <span class="col-meta" id="meta-digikey"></span>- </div>- <div class="col-body" id="body-digikey"><div class="col-empty">1–3 day ground ship · alternative when Mouser is out.</div></div>- </div>- <div id="col-jlcpcb" class="column jlcpcb">- <div class="col-head">- <span class="col-title jlcpcb">JLCPCB</span>- <span class="col-meta" id="meta-jlcpcb"></span>- </div>- <div class="col-body" id="body-jlcpcb"><div class="col-empty">Parts library — for JLCPCB-assembled boards.</div></div>+ <div id="grid" class="grid">+ <section id="col-mouser" class="column mouser" aria-labelledby="t-mouser">+ <div class="col-head"><span class="col-dot"></span><span class="col-title" id="t-mouser">Mouser</span><span class="col-badge" id="badge-mouser" data-tooltip="Mouser is the preferred vendor under the default policy: 40-minute drone delivery from Mansfield, TX to the Fort Worth facility, with near-zero shipping cost.">Preferred</span><span class="col-meta" id="meta-mouser"></span></div>+ <div class="col-body" id="body-mouser"></div>+ </section>+ <section id="col-digikey" class="column digikey" aria-labelledby="t-digikey">+ <div class="col-head"><span class="col-dot"></span><span class="col-title" id="t-digikey">DigiKey</span><span class="col-badge" id="badge-digikey" hidden></span><span class="col-meta" id="meta-digikey"></span></div>+ <div class="col-body" id="body-digikey"></div>+ </section>+ <section id="col-jlcpcb" class="column jlcpcb" aria-labelledby="t-jlcpcb">+ <div class="col-head"><span class="col-dot"></span><span class="col-title" id="t-jlcpcb">JLCPCB</span><span class="col-badge" id="badge-jlcpcb" hidden></span><span class="col-meta" id="meta-jlcpcb"></span></div>+ <div class="col-body" id="body-jlcpcb"></div>+ </section>+ </div>+ </main> </div>++ <footer class="app-footer" id="appFooter">+ <span class="f-led" id="f-mouser"><span class="hled" style="margin:0"></span>Mouser</span>+ <span class="f-led" id="f-digikey"><span class="hled" style="margin:0"></span>DigiKey</span>+ <span class="f-led" id="f-jlcpcb"><span class="hled" style="margin:0"></span>JLCPCB</span>+ <span class="f-hide" id="f-timing"></span>+ <span class="f-sp" id="f-version"></span>+ </footer> </div> -<div id="lightbox" class="lightbox" role="dialog" aria-hidden="true">- <div class="lb-close" id="lb-close">✕</div>+<div id="toasts" aria-live="polite"></div>++<div id="lightbox" class="lightbox" role="dialog" aria-hidden="true" aria-label="Product photo">+ <button class="iconbtn lb-close" id="lb-close" aria-label="Close photo"><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> <img id="lb-img" alt=""> <div class="lb-caption" id="lb-caption"></div> </div> -<script>-(function() {- const orig = { log: console.log, warn: console.warn, error: console.error };- function fwd(level, args) {- orig[level].apply(console, args);- try {- fetch('console', { method: 'POST', headers: {'Content-Type':'application/json'},- body: JSON.stringify({ level, ts: Date.now(),- message: args.map(a => { try { return typeof a === 'string' ? a : JSON.stringify(a); } catch { return String(a); } }).join(' ')- }) }).catch(() => {});- } catch {}- }- console.log = (...a) => fwd('log', a);- console.warn = (...a) => fwd('warn', a);- console.error = (...a) => fwd('error', a);- window.addEventListener('error', e => fwd('error', [e.message, (e.filename||'')+':'+(e.lineno||'')]));- window.addEventListener('unhandledrejection', e => fwd('error', ['unhandledrejection', String(e.reason)]));-})();+<dialog id="dlgSettings" class="settings" tabindex="-1" aria-labelledby="settingsTitle">+ <div class="settings-head" id="settingsHead">+ <h2 id="settingsTitle">Parts Search Settings <span class="card-status" id="settingsStatus"></span></h2>+ <button class="iconbtn" id="btnCloseSettings" aria-label="Close settings" data-tooltip="Close settings. Escape and clicking outside also close it; every change was already saved."><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>+ <div class="settings-body" id="settingsBody"> -function escape(s) { return String(s||'').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }-function fmtStock(n) { if (n >= 1000000) return (n/1000000).toFixed(1)+'M'; if (n >= 1000) return (n/1000).toFixed(1)+'k'; return String(n||0); }-function stockClass(n) { if (!n) return 'zero'; if (n < 1000) return 'low'; if (n < 10000) return 'medium'; return 'high'; }--function renderCard(hit) {- const flags = (hit.flags||[]).map(f => {- const cls = ['basic','preferred','extended'].includes(String(f).toLowerCase()) ? String(f).toLowerCase() : '';- return `<span class="flag ${cls}">${escape(f)}</span>`;- }).join('');- const actions = [];- if (hit.product_url) actions.push(`<a href="${escape(hit.product_url)}" target="_blank" rel="noopener">Buy ↗</a>`);- if (hit.datasheet_url) actions.push(`<a href="${escape(hit.datasheet_url)}" target="_blank" rel="noopener">Datasheet ↗</a>`);- if (hit.cad_3d_url) {- const fmt = (hit.cad_3d_format || '3D').toUpperCase();- const src = hit.cad_3d_source || 'vendor';- const xrefBadge = src.includes('xref') ? ' <span class="xref-badge" title="URL came from a DigiKey hit with the same MPN; this vendor doesn\'t publish its own CAD">via DigiKey</span>' : '';- actions.push(`<a href="${escape(hit.cad_3d_url)}" target="_blank" rel="noopener"- data-tooltip="Opens the ${escape(fmt)} CAD model for ${escape(hit.mpn || 'this part')}. Most links redirect through SamacSys / Ultra Librarian, which may ask you to sign in before the download starts. Once you've got the STEP file locally, pipe it into \`adom-molecule create --step …\` to bring it into the Adom molecule system."- >3D ${fmt} ↗${xrefBadge}</a>`);- }- return `- <div class="card">- <div class="card-img" ${hit.image_url ? `data-zoom="${escape(hit.image_url)}" data-zoom-caption="${escape((hit.mpn||'')+' — '+(hit.manufacturer||''))}"` : ''}>- ${hit.image_url ? `<img src="${escape(hit.image_url)}" alt="" onerror="this.style.display='none'; this.nextElementSibling.style.display='block'">` : ''}- <div class="placeholder" ${hit.image_url ? 'style="display:none"' : ''}>no image</div>+ <section class="settings-card" id="card-search">+ <h3 class="card-title">Search defaults <span class="card-status" id="stSearch"></span></h3>+ <p class="card-desc">Every search goes to the vendors you have enabled below as one parallel request, and each vendor answers with up to this many hits. The limit is per vendor, so 5 means up to 15 cards. The search bar shows the same two controls; what you pick there applies to that one search, while what you pick here is where the bar starts next time, and what the CLI (<code>adom-parts-search search</code>) uses when no flag is given.</p>+ <div class="wrapper">+ <h4 class="name" title="defaultLimit"><span class="prefix">Search: </span><span class="title">Results per vendor</span></h4>+ <div class="setting-row">+ <div class="seg" role="group" aria-label="Default results per vendor" id="segLimit">+ <button type="button" data-limit="3" aria-pressed="false">3</button>+ <button type="button" data-limit="5" aria-pressed="false">5</button>+ <button type="button" data-limit="10" aria-pressed="false">10</button>+ </div>+ <span class="set-check-label">hits from each vendor</span>+ </div>+ <span class="default-hint">Default: 5</span> </div>- <div class="card-body">- <div class="mpn">${escape(hit.mpn || hit.vendor_pn || '—')}</div>- ${hit.vendor_pn && hit.vendor_pn !== hit.mpn ? `<div class="vendor-pn">${escape(hit.vendor_pn)}</div>` : ''}- ${hit.manufacturer ? `<div class="manuf">${escape(hit.manufacturer)}</div>` : ''}- ${hit.description ? `<div class="desc">${escape(hit.description).slice(0,160)}</div>` : ''}- <div class="row-price">- ${hit.unit_price ? `<span class="price">${escape(hit.unit_price)}</span>` : ''}- ${hit.price_100 ? `<span class="price-100">@100: ${escape(hit.price_100)}</span>` : ''}- <span class="stock ${stockClass(hit.stock)}">${fmtStock(hit.stock)} in stock</span>- ${hit.lead_time ? `<span class="vendor-pn">lead: ${escape(hit.lead_time)}</span>` : ''}+ <div class="divider"></div>+ <div class="wrapper">+ <h4 class="name" title="inStockOnly"><span class="prefix">Search: </span><span class="title">In stock only</span></h4>+ <div class="setting-row">+ <button class="toggle" id="tglInStock" role="switch" aria-checked="false" aria-label="In stock only by default"><span class="toggle-track"><span class="toggle-thumb"></span></span></button>+ <span class="set-check-label">Drop hits with zero stock before they are shown</span> </div>- ${flags ? `<div class="flags">${flags}</div>` : ''}- ${actions.length ? `<div class="card-actions">${actions.join('')}</div>` : ''}+ <span class="default-hint">Default: Off</span> </div>- </div>`;-}+ </section> -function renderColumn(vendorKey, block) {- const body = document.getElementById('body-'+vendorKey);- const meta = document.getElementById('meta-'+vendorKey);- if (!block || !block.ok) {- const title = (block && block.error) || 'No response from vendor.';- const expl = (block && block.error_explanation) || '';- const hint = (block && block.error_hint) || '';- body.innerHTML = `- <div class="col-error">- <div class="ce-title">- <svg class="ce-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>- <span>${escape(title)}</span>+ <section class="settings-card" id="card-say">+ <h3 class="card-title">Say it strip <span class="card-status" id="stSay"></span></h3>+ <p class="card-desc">The strip at the top of the page teaches the one thing worth knowing: you can ask your AI for parts by saying <b>parts search</b>. Each phrase copies on click, and Try it stages it into this app's own AI thread without sending. Once you know the noun you do not need the lesson, so the X on the strip hides it for good; this switch brings it back.</p>+ <div class="wrapper">+ <h4 class="name" title="showSayStrip"><span class="prefix">Page: </span><span class="title">Show the Say it strip</span></h4>+ <div class="setting-row">+ <button class="toggle" id="tglSay" role="switch" aria-checked="true" aria-label="Show the Say it strip"><span class="toggle-track"><span class="toggle-thumb"></span></span></button>+ <span class="set-check-label">Show the phrases above the search bar</span> </div>- ${expl ? `<div class="ce-body">${escape(expl)}</div>` : ''}- ${hint ? `<div class="ce-hint">${escape(hint)}</div>` : ''}- </div>`;- meta.textContent = block && block.elapsed_ms ? block.elapsed_ms+' ms' : '';- return;- }- meta.textContent = `${block.total||0} total · ${block.elapsed_ms} ms`;- const hits = block.hits || [];- body.innerHTML = hits.length ? hits.map(renderCard).join('') : '<div class="col-empty">No matches.</div>';-}+ <span class="default-hint">Default: On</span>+ </div>+ </section> -function renderRecommendation(rec, emptyState) {- const el = document.getElementById('recommendation');- for (const v of ['mouser','digikey','jlcpcb']) document.getElementById('col-'+v).classList.remove('preferred');- if (rec) {- const vc = String(rec.winner).toLowerCase();- el.innerHTML = `- <div class="recommendation">- <div style="flex:1">- <div class="label">Recommendation</div>- <div class="main">Order <span style="color:var(--${vc})">${escape(rec.winner)}</span> · <code>${escape(rec.mpn)}</code> ${rec.unit_price ? '· '+escape(rec.unit_price)+'/unit' : ''} · ${fmtStock(rec.stock)} stock</div>- <div class="reason">${escape(rec.reason)}</div>+ <section class="settings-card" id="card-vendors">+ <h3 class="card-title">Vendors <span class="card-status" id="stVendors"></span></h3>+ <p class="card-desc">Each vendor is reached through its own Adom backend service, which holds that vendor's API key so nothing secret lives on this machine: Mouser and DigiKey through their official search APIs, JLCPCB through a local mirror of the JLCPCB parts library. A vendor that is switched off is simply not queried; its column stays on the page and says so, so a missing column never looks like a vendor outage. The status beside each name is read live from the backend's own health endpoint, never cached from an earlier claim.</p>+ <div class="wrapper">+ <h4 class="name" title="vendorMouser vendorDigikey vendorJlcpcb"><span class="prefix">Vendors: </span><span class="title">Query these vendors</span></h4>+ <div class="setting-row">+ <button class="toggle" id="tglMouser" role="switch" aria-checked="true" aria-label="Query Mouser"><span class="toggle-track"><span class="toggle-thumb"></span></span></button>+ <span class="set-check-label">Mouser</span><span class="rstat" id="rsMouser"></span> </div>- </div>`;- document.getElementById('col-'+vc).classList.add('preferred');- return;- }- if (emptyState) {- el.innerHTML = `- <div class="recommendation empty">- <div style="flex:1">- <div class="label">No recommendation</div>- <div class="main">${escape(emptyState.title)}</div>- <div class="reason">${escape(emptyState.explanation)}</div>- <div class="es-hint">${escape(emptyState.hint)}</div>+ <div class="setting-row">+ <button class="toggle" id="tglDigikey" role="switch" aria-checked="true" aria-label="Query DigiKey"><span class="toggle-track"><span class="toggle-thumb"></span></span></button>+ <span class="set-check-label">DigiKey</span><span class="rstat" id="rsDigikey"></span>+ </div>+ <div class="setting-row">+ <button class="toggle" id="tglJlcpcb" role="switch" aria-checked="true" aria-label="Query JLCPCB"><span class="toggle-track"><span class="toggle-thumb"></span></span></button>+ <span class="set-check-label">JLCPCB</span><span class="rstat" id="rsJlcpcb"></span> </div>- </div>`;- return;+ <span class="default-hint">Default: all three on</span>+ </div>+ </section>++ <section class="settings-card" id="card-policy">+ <h3 class="card-title">Recommendation <span class="card-status" id="stPolicy"></span></h3>+ <p class="card-desc">After the columns come back, one hit is named in the banner above the grid as the best source. Only hits with stock and a normal lead time are eligible (a 280-day lead never wins). Under <b>Mouser preferred</b>, the first stocked Mouser hit wins outright, DigiKey only when Mouser has nothing; JLCPCB is never recommended because its parts are for JLCPCB-assembled boards, not for delivery. Under <b>Cheapest in stock</b>, every stocked hit from every vendor that answered is compared on unit price and the lowest wins, ties going to Mouser. The banner always states which policy produced it.</p>+ <div class="wrapper">+ <h4 class="name" title="policy"><span class="prefix">Recommendation: </span><span class="title">Policy</span></h4>+ <fieldset class="choice-set" role="radiogroup" aria-labelledby="policyLabel" id="policySet">+ <legend id="policyLabel" class="set-check-label" style="padding:0;margin-bottom:.35rem">How the winner is chosen.</legend>+ <label class="choice" data-policy="mouser" data-selected="false">+ <input type="radio" name="policy" value="mouser">+ <span class="choice-name"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12h4l2-5 4 10 2-5h6"/></svg>Mouser preferred</span>+ <span class="choice-desc">40-minute drone delivery from Mansfield to Fort Worth and near-zero shipping beat a few cents of unit price. Mouser wins whenever it has stock.</span>+ </label>+ <label class="choice" data-policy="cheapest" data-selected="false">+ <input type="radio" name="policy" value="cheapest">+ <span class="choice-name"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v18M7 8h7a3 3 0 0 1 0 6H8a3 3 0 0 0 0 6h9"/></svg>Cheapest in stock</span>+ <span class="choice-desc">Lowest in-stock unit price across every vendor that answered. Shipping time and cost are ignored, so this suits volume orders that can wait.</span>+ </label>+ </fieldset>+ <span class="default-hint">Default: Mouser preferred</span>+ </div>+ </section>++ <section class="settings-card" id="card-images">+ <h3 class="card-title">Product photos <span class="card-status" id="stImages"></span></h3>+ <p class="card-desc">Each card carries the vendor's own product photo, loaded straight from mouser.com, digikey.com, or the LCSC image CDN, and clicking one opens it full size. That means a search contacts those three sites from this page. Turn photos off to keep the page on this app's server alone; cards then show a chip placeholder in the same spot, so the layout does not shift.</p>+ <div class="wrapper">+ <h4 class="name" title="showImages"><span class="prefix">Photos: </span><span class="title">Show product photos</span></h4>+ <div class="setting-row">+ <button class="toggle" id="tglImages" role="switch" aria-checked="true" aria-label="Show product photos"><span class="toggle-track"><span class="toggle-thumb"></span></span></button>+ <span class="set-check-label">Load photos from the vendors' sites</span>+ </div>+ <span class="default-hint">Default: On</span>+ </div>+ </section>++ <section class="settings-card" id="card-taskbar">+ <h3 class="card-title">Taskbar progress <span class="card-status" id="stTaskbar"></span></h3>+ <p class="card-desc">While a search runs, this app paints a progress bar on the <b>Hydrogen</b> button in the Windows taskbar, through the Adom Desktop bridge's <code>desktop_taskbar</code> verb (the <code>ITaskbarList3</code> API), so the wait is legible even when this tab is not in front. The bar is sized from this app's own measured search durations (a recent-weighted average kept in <code>~/.adom/parts-search-timing.json</code>), caps at 92% until the search really finishes, then jumps to 100 and clears itself. A search your AI started (<code>adom-parts-search show</code>) is open-ended, so it paints an indeterminate marquee instead of a made-up estimate. Each bridge call is bounded and off the search path: with no desktop connected, nothing is painted and nothing slows down. Windows only; there is no taskbar to paint on macOS or Linux.</p>+ <div class="wrapper">+ <h4 class="name" title="taskbarProgress"><span class="prefix">Taskbar: </span><span class="title">Progress bar while searching</span></h4>+ <div class="setting-row">+ <button class="toggle" id="tglTaskbar" role="switch" aria-checked="true" aria-label="Paint a taskbar progress bar while searching"><span class="toggle-track"><span class="toggle-thumb"></span></span></button>+ <span class="set-check-label">Paint the bar on the Hydrogen taskbar button</span>+ </div>+ <span class="default-hint">Default: On</span>+ </div>+ <div class="setting-children" id="taskbarChildren" data-enabled="true" title="Available when the progress bar is on">+ <div class="wrapper">+ <h4 class="name" title="taskbarBox"><span class="prefix">Taskbar: </span><span class="title">Desktop box</span></h4>+ <div class="setting-row">+ <select class="sel" id="selBox" aria-label="Desktop box carrying the taskbar button"><option value="">First connected box</option></select>+ <span class="set-check-label">which Adom Desktop to paint on</span>+ </div>+ <span class="default-hint">Default: First connected box</span>+ </div>+ </div>+ </section>++ <footer class="settings-footer">+ <p class="footer-note">Settings are stored on this machine in <code>~/.adom/parts-search-settings.json</code> and apply immediately; the CLI reads the same file (<code>adom-parts-search settings get</code>). Restoring returns every option above to its default.</p>+ <button class="restore-btn" id="btnRestore" type="button"><svg viewBox="0 0 24 24" aria-hidden="true"><path id="restoreIcon" fill="currentColor" d="M13 3a9 9 0 0 0-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42A8.954 8.954 0 0 0 13 21a9 9 0 0 0 0-18m-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8z"/></svg><span id="restoreLabel">Restore defaults</span></button>+ </footer>+ </div>+</dialog>++<script>+// ---- Console forwarder: the AI reads GET /console instead of DevTools ----+(function(){+ const orig={log:console.log,warn:console.warn,error:console.error};+ function fwd(level,args){+ orig[level].apply(console,args);+ try{fetch('console',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({level,ts:Date.now(),message:args.map(a=>{try{return typeof a==='string'?a:JSON.stringify(a);}catch{return String(a);}}).join(' ')})}).catch(()=>{});}catch{} }- el.innerHTML = '';+ console.log=(...a)=>fwd('log',a);console.warn=(...a)=>fwd('warn',a);console.error=(...a)=>fwd('error',a);+ window.addEventListener('error',e=>fwd('error',[e.message,(e.filename||'')+':'+(e.lineno||'')]));+ window.addEventListener('unhandledrejection',e=>fwd('error',['unhandledrejection',String(e.reason)]));+})();++const $=s=>document.querySelector(s);+const VENDORS=['mouser','digikey','jlcpcb'];+const VNAME={mouser:'Mouser',digikey:'DigiKey',jlcpcb:'JLCPCB'};+const ICON_EXT='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 4h6v6M20 4l-9 9M18 13v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h6"/></svg>';+const ICON_CHIP='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2"/><path d="M9 2v4M15 2v4M9 18v4M15 18v4M2 9h4M2 15h4M18 9h4M18 15h4"/></svg>';+const ICON_WARN='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>';+const ICON_OFF='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M8 12h8"/></svg>';+const ICON_PIN='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0z"/><circle cx="12" cy="10" r="3"/></svg>';++function escape(s){return String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}+function fmtStock(n){n=n||0;if(n>=1e6)return (n/1e6).toFixed(1)+'M';if(n>=1000)return (n/1000).toFixed(1)+'k';return String(n);}+function stockClass(n){if(!n)return 'zero';if(n<1000)return 'low';if(n<10000)return 'medium';return 'high';}+function fmtMs(ms){return ms>=1000?(ms/1000).toFixed(1)+' s':ms+' ms';}++// ---- Settings mirror (local-first, optimistic) ----+const DEFAULTS={defaultLimit:5,inStockOnly:false,vendorMouser:true,vendorDigikey:true,vendorJlcpcb:true,policy:'mouser',showImages:true,taskbarProgress:true,taskbarBox:'',showSayStrip:true};+let settings=Object.assign({},DEFAULTS);+try{Object.assign(settings,JSON.parse(localStorage.getItem('psSettings')||'{}'));}catch{}+let limit=settings.defaultLimit, inStock=settings.inStockOnly;+function remember(){try{localStorage.setItem('psSettings',JSON.stringify(settings));}catch{}}++// ---- Toasts ----+const seenToasts=new Set();+function toast(message,kind='info',action){+ const host=$('#toasts');+ const el=document.createElement('div');+ el.className='toast '+kind; el.setAttribute('role',kind==='error'?'alert':'status');+ const icon=kind==='success'?'<svg class="ti" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>'+ :kind==='error'?'<svg class="ti" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16h.01"/></svg>'+ :'<svg class="ti" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 11v5M12 8h.01"/></svg>';+ el.innerHTML=icon+'<span class="tmsg">'+escape(message)+'</span>'+(action&&action.label?'<button class="taction">'+escape(action.label)+'</button>':'')+'<button class="iconbtn tclose" aria-label="Dismiss"><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>';+ let timer=null; const sticky=kind==='error';+ const remove=()=>{el.remove();};+ const arm=()=>{if(!sticky)timer=setTimeout(remove,4000);};+ el.addEventListener('mouseenter',()=>{if(timer){clearTimeout(timer);timer=null;}});+ el.addEventListener('mouseleave',arm);+ el.querySelector('.tclose').addEventListener('click',remove);+ const ab=el.querySelector('.taction'); if(ab)ab.addEventListener('click',()=>{try{action.run&&action.run();}catch(e){console.error('toast action failed',e.message);}remove();});+ host.appendChild(el);+ while(host.children.length>3)host.firstElementChild.remove();+ arm(); } -async function doSearch(keyword) {- if (!keyword.trim()) return;- const limit = parseInt(document.getElementById('limit').value, 10);- const inStock = document.getElementById('instock').checked;- const go = document.getElementById('go');- go.disabled = true; go.textContent = 'Searching…';- document.getElementById('status').innerHTML = '<span class="loading"></span> querying Mouser + DigiKey + JLCPCB in parallel…';- for (const v of ['mouser','digikey','jlcpcb']) {- document.getElementById('body-'+v).innerHTML = '<div class="col-empty"><span class="loading"></span> searching…</div>';- document.getElementById('meta-'+v).textContent = '';- }- try {- const r = await fetch('search', { method: 'POST', headers: {'Content-Type':'application/json'},- body: JSON.stringify({ keyword, limit, inStockOnly: inStock }) });- const v = await r.json();- if (!r.ok) {- const st = document.getElementById('status');- st.textContent = 'error: '+(v.error||r.statusText); st.className = 'statusline error';- return;- }- renderColumn('mouser', v.mouser);- renderColumn('digikey', v.digikey);- renderColumn('jlcpcb', v.jlcpcb);- renderRecommendation(v.recommendation, v.empty_state);- const st = document.getElementById('status');- st.textContent = `Done — Mouser ${v.mouser?.hits?.length||0} · DigiKey ${v.digikey?.hits?.length||0} · JLCPCB ${v.jlcpcb?.hits?.length||0}`;- st.className = 'statusline ok';- } catch (e) {- console.error('search failed (see column error cards for details)');- const st = document.getElementById('status');- st.textContent = 'error: '+(e.message||e); st.className = 'statusline error';- } finally {- go.disabled = false; go.textContent = 'Search all 3';- }+// ---- Global tooltip (one fixed div, 600ms reveal, clamped on both axes) ----+const tip=document.createElement('div');tip.id='global-tooltip';document.body.appendChild(tip);+let _cx=0,_cy=0,_tipEl=null;+document.addEventListener('mousemove',e=>{_cx=e.clientX;_cy=e.clientY;},true);+function pickAnchor(trigger,w,h,cx,cy){+ const O=14,vw=innerWidth,vh=innerHeight,tr=trigger.getBoundingClientRect();+ const below=Math.max(cy+O,tr.bottom+4),above=Math.min(cy-h-O,tr.top-h-4);+ const cands=[{x:cx+O,y:below},{x:cx-w-O,y:below},{x:cx+O,y:above},{x:cx-w-O,y:above}];+ const score=c=>{let p=0;if(c.x<8||c.x+w>vw-8)p+=1000;if(c.y<8||c.y+h>vh-8)p+=1000;+ const ox=Math.max(0,Math.min(c.x+w,tr.right)-Math.max(c.x,tr.left)),oy=Math.max(0,Math.min(c.y+h,tr.bottom)-Math.max(c.y,tr.top));return p+ox*oy;};+ const best=cands.map(c=>({c,s:score(c)})).sort((a,b)=>a.s-b.s)[0].c;+ best.x=Math.max(8,Math.min(best.x,vw-w-8));best.y=Math.max(8,Math.min(best.y,vh-h-8));return best;+}+function showTip(trigger,now){+ if(_tipEl===trigger&&tip.classList.contains('visible'))return;+ _tipEl=trigger;tip.textContent=trigger.getAttribute('data-tooltip')||'';+ tip.classList.remove('shown','now');tip.classList.add('visible');+ const r=trigger.getBoundingClientRect();+ const cx=now?r.left+r.width/2:_cx,cy=now?r.bottom:_cy;+ const {x,y}=pickAnchor(trigger,tip.offsetWidth,tip.offsetHeight,cx,cy);+ tip.style.setProperty('--tip-x',x+'px');tip.style.setProperty('--tip-y',y+'px');+ requestAnimationFrame(()=>{tip.classList.add('shown');if(now)tip.classList.add('now');});+}+function hideTip(){_tipEl=null;tip.classList.remove('visible','shown','now');}+const coarse=matchMedia('(hover: none), (pointer: coarse)').matches;+if(!coarse){+ document.addEventListener('mouseover',e=>{const t=e.target.closest('[data-tooltip]');t?showTip(t):hideTip();});+ document.addEventListener('mousedown',hideTip,true);+ window.addEventListener('scroll',hideTip,true);+}else{+ // Touch: long-press reveals the explanation, tap anywhere hides it.+ let lp=null;+ document.addEventListener('touchstart',e=>{const t=e.target.closest('[data-tooltip]');if(!t)return;lp=setTimeout(()=>showTip(t,true),500);},{passive:true});+ document.addEventListener('touchend',()=>{if(lp){clearTimeout(lp);lp=null;}},{passive:true});+ document.addEventListener('touchmove',()=>{if(lp){clearTimeout(lp);lp=null;}},{passive:true});+ document.addEventListener('click',e=>{if(!e.target.closest('#global-tooltip'))hideTip();},true); }+window.__tooltipAssert=function(){const r=tip.getBoundingClientRect();return r.left>=0&&r.right<=innerWidth&&r.top>=0&&r.bottom<=innerHeight;}; -document.getElementById('search-form').addEventListener('submit', e => {- e.preventDefault();- doSearch(document.getElementById('q').value);+// ---- Brand popover (600ms show, 220ms hide grace) ----+(function(){const b=$('#brand'),p=$('#brandPop');let st=null,ht=null;+ b.addEventListener('mouseenter',()=>{clearTimeout(ht);st=setTimeout(()=>p.classList.add('open'),600);});+ b.addEventListener('mouseleave',()=>{clearTimeout(st);ht=setTimeout(()=>p.classList.remove('open'),220);});+ b.addEventListener('click',e=>{if(e.target.closest('a'))return;p.classList.toggle('open');});+})();++// ---- Say it strip ----+let SAY=[];+function renderSay(list){+ if(JSON.stringify(list)===JSON.stringify(SAY)&&$('#sayList').children.length)return;+ SAY=list;const host=$('#sayList');$('#say').hidden=!settings.showSayStrip;+ host.innerHTML=SAY.map((p,i)=>'<span class="say-item"><button type="button" class="say-phrase" id="say-copy-'+i+'" data-i="'+i+'" data-tooltip="Click to copy this phrase, then paste it into any Adom AI thread."><span class="txt">'+escape(p).replace(/parts search/i,'<em>parts search</em>')+'</span></button><button type="button" class="say-try" id="say-try-'+i+'" data-i="'+i+'" data-tooltip="Stage this phrase into your Parts Search AI thread. It is not sent until you press Enter there.">Try it</button></span>').join('');+}+$('#sayList').addEventListener('click',async e=>{+ const ph=e.target.closest('.say-phrase'),tr=e.target.closest('.say-try');+ if(ph){const p=SAY[+ph.dataset.i],item=ph.closest('.say-item');try{await navigator.clipboard.writeText(p);}catch{}+ item.classList.add('copied');const old=ph.innerHTML;ph.innerHTML='<span class="txt">copied</span>';setTimeout(()=>{item.classList.remove('copied');ph.innerHTML=old;},1200);+ toast('Copied: '+p,'success');return;}+ if(tr){const p=SAY[+tr.dataset.i];tr.disabled=true;tr.textContent='Staging';+ try{const r=await fetch('api/try',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({phrase:p})});const d=await r.json();+ if(d.ok)toast('Staged in your '+(d.where||'AI thread')+'. Press Enter there to send it.','success');+ else{try{await navigator.clipboard.writeText(p);}catch{} toast(d.error||'Could not stage the phrase; copied it instead.','error');}+ }catch(err){try{await navigator.clipboard.writeText(p);}catch{} toast('Hydrogen did not answer; copied the phrase instead.','error');}+ tr.disabled=false;tr.textContent='Try it';} }); -(function() {- const lb = document.getElementById('lightbox');- const img = document.getElementById('lb-img');- const cap = document.getElementById('lb-caption');- function open(src, caption) { img.src = src; cap.textContent = caption||''; lb.classList.add('open'); }- function close() { lb.classList.remove('open'); setTimeout(() => { if (!lb.classList.contains('open')) img.src = ''; }, 200); }- document.addEventListener('click', e => {- const z = e.target.closest('[data-zoom]');- if (z) { e.preventDefault(); open(z.dataset.zoom, z.dataset.zoomCaption); return; }- if (e.target === lb || e.target.id === 'lb-close') close();- });- document.addEventListener('keydown', e => { if (e.key === 'Escape') close(); });+// ---- Rendering ----+function renderCard(hit){+ const flags=(hit.flags||[]).map(f=>{const k=String(f).toLowerCase();const cls=['basic','preferred','extended'].includes(k)?k:'';return '<span class="flag '+cls+'">'+escape(f)+'</span>';}).join('');+ const actions=[];+ if(hit.product_url)actions.push('<a href="'+escape(hit.product_url)+'" target="_blank" rel="noopener" data-tooltip="Open this part\'s page on '+VNAME[hit.vendor?String(hit.vendor).toLowerCase():'mouser']+' in a new tab: full specs, pricing tiers, and stock.">View '+ICON_EXT+'</a>');+ if(hit.datasheet_url)actions.push('<a href="'+escape(hit.datasheet_url)+'" target="_blank" rel="noopener" data-tooltip="Open the manufacturer datasheet (PDF) in a new tab.">Datasheet '+ICON_EXT+'</a>');+ if(hit.cad_3d_url){const fmt=(hit.cad_3d_format||'3D').toUpperCase();const xref=(hit.cad_3d_source||'').includes('xref');+ actions.push('<a href="'+escape(hit.cad_3d_url)+'" target="_blank" rel="noopener" data-tooltip="Opens the '+escape(fmt)+' CAD model for '+escape(hit.mpn||'this part')+'. Most links go through SamacSys or Ultra Librarian, which may ask you to sign in first. With the STEP file local, adom-molecule create --step brings it into Adom.'+(xref?' This link came from a DigiKey hit with the same MPN; '+VNAME[String(hit.vendor).toLowerCase()]+' does not publish its own CAD.':'')+'">3D '+escape(fmt)+(xref?'<span class="xref">via DigiKey</span>':'')+' '+ICON_EXT+'</a>');}+ const img=settings.showImages&&hit.image_url;+ const cap=escape((hit.mpn||'')+(hit.manufacturer?' '+hit.manufacturer:''));+ return '<div class="card">'+ +'<div class="card-img'+(img?'':' noimg')+'" '+(img?'data-zoom="'+escape(hit.image_url)+'" data-zoom-caption="'+cap+'" data-tooltip="Click to view the product photo full size."':'')+'>'+ +(img?'<img src="'+escape(hit.image_url)+'" alt="" loading="lazy" onerror="this.parentElement.classList.add(\'noimg\');this.remove()">':'')+ +'<div class="placeholder">'+ICON_CHIP+(img?'':(settings.showImages?'no photo':'photos off'))+'</div></div>'+ +'<div class="card-body"><div class="mpn">'+escape(hit.mpn||hit.vendor_pn||'-')+'</div>'+ +(hit.vendor_pn&&hit.vendor_pn!==hit.mpn?'<div class="vendor-pn">'+escape(hit.vendor_pn)+'</div>':'')+ +(hit.manufacturer?'<div class="manuf">'+escape(hit.manufacturer)+'</div>':'')+ +(hit.description?'<div class="desc" title="'+escape(hit.description)+'">'+escape(hit.description)+'</div>':'')+ +'<div class="row-price">'+(hit.unit_price?'<span class="price">'+escape(hit.unit_price)+'</span>':'')+(hit.price_100?'<span class="price-100">@100: '+escape(hit.price_100)+'</span>':'')+ +'<span class="stock '+stockClass(hit.stock)+'" title="'+(hit.stock||0)+' units"><i style="--fill:'+Math.min(100,Math.round(Math.log10((hit.stock||0)+1)/5*100))+'%"></i>'+fmtStock(hit.stock)+' in stock</span>'+(hit.lead_time?'<span class="vendor-pn">lead '+escape(hit.lead_time)+'</span>':'')+'</div>'+ +(flags?'<div class="flags">'+flags+'</div>':'')+(actions.length?'<div class="card-actions">'+actions.join('')+'</div>':'')+'</div></div>';+}+function renderColumn(v,block){+ const body=$('#body-'+v),meta=$('#meta-'+v),col=$('#col-'+v);+ col.classList.toggle('skipped',!!(block&&block.skipped));+ if(!block||!block.ok){+ const skip=block&&block.skipped;+ const title=(block&&block.error)||'No response from this vendor.',expl=(block&&block.error_explanation)||'',hint=(block&&block.error_hint)||'';+ body.innerHTML='<div class="col-error'+(skip?' skip':'')+'"><div class="ce-title">'+(skip?ICON_OFF:ICON_WARN)+'<span>'+escape(title)+'</span></div>'+(expl?'<div class="ce-body">'+escape(expl)+'</div>':'')+(hint?'<div class="ce-hint"><b>Next:</b> '+escape(hint)+'</div>':'')+'</div>';+ meta.textContent=block&&block.elapsed_ms?fmtMs(block.elapsed_ms):'';return;+ }+ meta.textContent=(block.total||0)+' total, '+fmtMs(block.elapsed_ms||0);+ const hits=block.hits||[];+ body.innerHTML=hits.length?hits.map(renderCard).join(''):'<div class="col-empty">'+ICON_CHIP+'<span>No matches at '+VNAME[v]+'.</span></div>';+}+function skeleton(n){let h='';for(let i=0;i<Math.min(n||3,5);i++)h+='<div class="skel"><div class="b img"></div><div><div class="b l" style="width:60%"></div><div class="b l" style="width:40%"></div><div class="b l" style="width:85%"></div><div class="b l" style="width:50%;margin:0"></div></div></div>';return h;}+function renderIdle(){+ const idle={mouser:'Run a search to see Mouser results.',digikey:'1 to 3 day ground shipping; the alternative when Mouser is out.',jlcpcb:'Parts library for JLCPCB-assembled boards.'};+ for(const v of VENDORS){$('#body-'+v).innerHTML='<div class="col-empty">'+ICON_CHIP+'<span>'+idle[v]+'</span></div>';$('#meta-'+v).textContent='';}+}+function renderRecommendation(rec,emptyState,policy){+ const el=$('#recommendation');+ for(const v of VENDORS){$('#col-'+v).classList.remove('preferred');const b=$('#badge-'+v);b.hidden=!(v==='mouser'&&(policy||settings.policy)==='mouser');b.classList.remove('win');b.textContent='Preferred';}+ if(rec){const vc=String(rec.winner).toLowerCase();+ el.innerHTML='<div class="recommendation"><div class="ricon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg></div><div class="rbody"><div class="label">Best source</div>'+ +'<div class="main">Best source: <span style="color:var(--'+vc+')">'+escape(rec.winner)+'</span>, <code>'+escape(rec.mpn)+'</code>'+(rec.unit_price?' at '+escape(rec.unit_price)+' per unit':'')+', '+fmtStock(rec.stock)+' in stock</div>'+ +'<div class="reason">'+escape(rec.reason)+'</div><div class="prov">Policy: '+((policy||settings.policy)==='cheapest'?'cheapest in stock':'Mouser preferred')+' (change it in Settings). Eligible hits need stock and a normal lead time.</div></div>'+ +'</div>';+ $('#col-'+vc).classList.add('preferred');const b=$('#badge-'+vc);b.hidden=false;b.classList.add('win');b.textContent='Recommended';return;}+ if(emptyState){el.innerHTML='<div class="recommendation empty"><svg class="ricon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16h.01"/></svg><div style="flex:1;min-width:0"><div class="label">No source found</div><div class="main">'+escape(emptyState.title)+'</div><div class="reason">'+escape(emptyState.explanation)+'</div><div class="es-hint">'+escape(emptyState.hint)+'</div></div></div>';return;}+ el.innerHTML='';+}+function renderResults(r){+ lastResults=r;+ for(const v of VENDORS)renderColumn(v,r[v]);+ renderRecommendation(r.recommendation,r.empty_state,r.policy);+ const n=VENDORS.reduce((a,v)=>a+((r[v]&&r[v].hits)||[]).length,0);+ $('#subject').textContent=r.query||'';+ $('#meta').textContent=n+' hits, '+fmtMs(r.elapsed_ms||0)+(r.source==='ai'?', from your AI':'');+ const st=$('#status');st.textContent='Done: Mouser '+((r.mouser&&r.mouser.hits)||[]).length+', DigiKey '+((r.digikey&&r.digikey.hits)||[]).length+', JLCPCB '+((r.jlcpcb&&r.jlcpcb.hits)||[]).length+' in '+fmtMs(r.elapsed_ms||0);st.className='statusline ok';+}++// ---- Search (state lives on the server; the poll renders) ----+let searching=false,pTimer=null,busyFromPoll=false;+function setBusy(q,indet){+ searching=!!q;const go=$('#go');go.disabled=searching;go.textContent=searching?'Searching':'Search all 3';+ $('#hled').classList.toggle('busy',searching);+ const pb=$('#pbar');+ if(searching){$('#status').innerHTML='<span class="spin"></span> Querying '+VENDORS.filter(v=>settings['vendor'+v[0].toUpperCase()+v.slice(1)]).map(v=>VNAME[v]).join(', ')+' in parallel for "'+escape(q)+'"';$('#status').className='statusline';+ pb.classList.add('on');pb.classList.toggle('indet',!!indet);+ if(!indet){const est=Math.max(800,avgMs||4000),t0=Date.now();pb.firstElementChild.style.width='2%';clearInterval(pTimer);pTimer=setInterval(()=>{pb.firstElementChild.style.width=Math.min(92,(Date.now()-t0)/est*100)+'%';},200);}+ }else{clearInterval(pTimer);pb.firstElementChild.style.width='100%';setTimeout(()=>{pb.classList.remove('on','indet');pb.firstElementChild.style.width='0';},350);}+}+async function doSearch(keyword){+ keyword=(keyword||'').trim();if(!keyword){toast('Type a part number or keyword first.','error');$('#q').focus();return;}+ if(searching)return;+ setBusy(keyword,false);+ for(const v of VENDORS){$('#body-'+v).innerHTML=skeleton(limit);$('#meta-'+v).textContent='';}+ try{const r=await fetch('search',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({keyword,limit,inStockOnly:inStock,source:'ui'})});+ const v=await r.json();+ if(!r.ok){const st=$('#status');st.textContent='Search failed: '+(v.error||r.statusText);st.className='statusline error';toast('Search failed: '+(v.error||r.statusText),'error');renderIdle();return;}+ renderResults(v);lastSig=v.query+':'+(v.elapsed_ms||0);+ const n=VENDORS.reduce((a,v2)=>a+((v[v2]&&v[v2].hits)||[]).length,0);+ if(v.recommendation)toast('Recommended: '+v.recommendation.winner+' for '+v.recommendation.mpn,'success');else if(!n)toast('No matches for "'+keyword+'"','info');+ }catch(e){console.error('search failed: '+(e.message||e));const st=$('#status');st.textContent='Search failed: '+(e.message||e);st.className='statusline error';toast('Search failed: '+(e.message||e),'error');renderIdle();}+ finally{setBusy(null);}+}+$('#search-form').addEventListener('submit',e=>{e.preventDefault();doSearch($('#q').value);});++// Search-bar controls (apply to this search; Settings sets where they start)+function paintLimit(){document.querySelectorAll('#search-form .seg button').forEach(b=>b.setAttribute('aria-pressed',String(+b.dataset.limit===limit)));}+function paintInStock(){const t=$('#instock');t.classList.toggle('active',inStock);t.setAttribute('aria-checked',String(inStock));}+$('#search-form .seg').addEventListener('click',e=>{const b=e.target.closest('button[data-limit]');if(!b)return;limit=+b.dataset.limit;paintLimit();toast('Fetching '+limit+' hits per vendor for this search','info');});+$('#instock').addEventListener('click',()=>{inStock=!inStock;paintInStock();toast(inStock?'Showing in-stock hits only':'Showing every match, including zero stock','info');});+paintLimit();paintInStock();++// ---- Lightbox ----+(function(){const lb=$('#lightbox'),img=$('#lb-img'),cap=$('#lb-caption');+ function open(src,c){img.src=src;cap.textContent=c||'';lb.classList.add('open');lb.setAttribute('aria-hidden','false');}+ function close(){lb.classList.remove('open');lb.setAttribute('aria-hidden','true');setTimeout(()=>{if(!lb.classList.contains('open'))img.src='';},200);}+ document.addEventListener('click',e=>{const z=e.target.closest('[data-zoom]');if(z){e.preventDefault();open(z.dataset.zoom,z.dataset.zoomCaption);return;}+ if(e.target===lb||e.target.closest('#lb-close'))close();});+ document.addEventListener('keydown',e=>{if(e.key==='Escape'&&lb.classList.contains('open'))close();});+})();++// ---- Settings modal ----+const dlg=$('#dlgSettings');+async function saveSettings(patch,revertTo){+ Object.assign(settings,patch);remember();paintSettings();+ const ctl=new AbortController();const t=setTimeout(()=>ctl.abort(),5000);+ try{const r=await fetch('api/settings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({settings:patch}),signal:ctl.signal});+ const d=await r.json();+ if(!d.ok){toast('Setting refused: '+(d.error||'unknown'),'error');if(d.settings)Object.assign(settings,d.settings);else if(revertTo)Object.assign(settings,revertTo);remember();paintSettings();return false;}+ Object.assign(settings,d.settings||{});remember();paintSettings();return true;+ }catch(e){toast('Saved locally; the server did not confirm within 5 s.','error');return false;}+ finally{clearTimeout(t);}+}+function setToggle(id,on){const t=$(id);t.classList.toggle('active',!!on);t.setAttribute('aria-checked',String(!!on));}+function paintSettings(){+ document.querySelectorAll('#segLimit button').forEach(b=>b.setAttribute('aria-pressed',String(+b.dataset.limit===settings.defaultLimit)));+ setToggle('#tglInStock',settings.inStockOnly);setToggle('#tglMouser',settings.vendorMouser);setToggle('#tglDigikey',settings.vendorDigikey);setToggle('#tglJlcpcb',settings.vendorJlcpcb);+ setToggle('#tglImages',settings.showImages);setToggle('#tglTaskbar',settings.taskbarProgress);setToggle('#tglSay',settings.showSayStrip);+ $('#say').hidden=!settings.showSayStrip;$('#stSay').textContent=settings.showSayStrip?'shown':'hidden';$('#stSay').className='card-status '+(settings.showSayStrip?'ok':'');+ document.querySelectorAll('#policySet .choice').forEach(c=>{const sel=c.dataset.policy===settings.policy;c.dataset.selected=String(sel);c.querySelector('input').checked=sel;});+ const ch=$('#taskbarChildren');ch.dataset.enabled=String(!!settings.taskbarProgress);$('#selBox').disabled=!settings.taskbarProgress;+ const sb=$('#selBox');if(sb.value!==settings.taskbarBox){const opt=[...sb.options].find(o=>o.value===settings.taskbarBox);if(!opt&&settings.taskbarBox){const o=document.createElement('option');o.value=settings.taskbarBox;o.textContent=settings.taskbarBox;sb.appendChild(o);}sb.value=settings.taskbarBox;}+ const on=VENDORS.filter(v=>settings['vendor'+v[0].toUpperCase()+v.slice(1)]);+ $('#stVendors').textContent=on.length===3?'all three on':on.length?on.map(v=>VNAME[v]).join(', ')+' on':'none on';$('#stVendors').className='card-status '+(on.length===3?'ok':on.length?'warn':'fail');+ $('#stPolicy').textContent=settings.policy==='cheapest'?'cheapest in stock':'Mouser preferred';+ $('#stImages').textContent=settings.showImages?'on':'off';$('#stImages').className='card-status '+(settings.showImages?'ok':'');+ $('#stTaskbar').textContent=(settings.taskbarProgress?'on':'off')+(avgMs?', searches average '+fmtMs(avgMs):', no search measured yet');$('#stTaskbar').className='card-status '+(settings.taskbarProgress?'ok':'');+ $('#stSearch').textContent=settings.defaultLimit+' per vendor'+(settings.inStockOnly?', in stock only':'');+ $('#popPolicy').textContent=settings.policy==='cheapest'?'Cheapest in stock':'Mouser preferred';+ $('#badge-mouser').hidden=settings.policy!=='mouser'&&!$('#col-mouser').classList.contains('preferred');+}+async function openSettings(){+ try{const r=await fetch('api/settings');const d=await r.json();if(d.settings)Object.assign(settings,d.settings);remember();+ const sb=$('#selBox');const cur=settings.taskbarBox;sb.innerHTML='<option value="">First connected box</option>'+(d.boxes||[]).map(b=>'<option value="'+escape(b)+'">'+escape(b)+'</option>').join('');sb.value=cur;+ $('#settingsStatus').textContent=(d.boxes||[]).length?((d.boxes||[]).length+' desktop box'+((d.boxes||[]).length>1?'es':'')+' connected'):'no desktop box connected';+ }catch{}+ paintSettings();paintVendorStatus();+ dlg.showModal();dlg.focus();const body=$('#settingsBody');body.scrollTop=0;requestAnimationFrame(()=>{body.scrollTop=0;});+}+$('#btnSettings').addEventListener('click',openSettings);+$('#btnCloseSettings').addEventListener('click',()=>dlg.close());+dlg.addEventListener('click',e=>{if(e.target!==dlg)return;const r=dlg.getBoundingClientRect();const inside=e.clientX>=r.left&&e.clientX<=r.right&&e.clientY>=r.top&&e.clientY<=r.bottom;if(!inside)dlg.close();});+dlg.addEventListener('close',()=>{hideTip();});+// drag by the header, clamped to the viewport+(function(){const head=$('#settingsHead');let drag=null;+ head.addEventListener('mousedown',e=>{if(e.target.closest('button'))return;const r=dlg.getBoundingClientRect();drag={dx:e.clientX-r.left,dy:e.clientY-r.top};dlg.style.margin='0';dlg.style.position='fixed';dlg.style.left=r.left+'px';dlg.style.top=r.top+'px';e.preventDefault();});+ document.addEventListener('mousemove',e=>{if(!drag)return;const w=dlg.offsetWidth,h=dlg.offsetHeight;dlg.style.left=Math.max(0,Math.min(e.clientX-drag.dx,innerWidth-w))+'px';dlg.style.top=Math.max(0,Math.min(e.clientY-drag.dy,innerHeight-h))+'px';});+ document.addEventListener('mouseup',()=>{drag=null;});+})();+$('#segLimit').addEventListener('click',e=>{const b=e.target.closest('button[data-limit]');if(!b)return;const prev={defaultLimit:settings.defaultLimit};saveSettings({defaultLimit:+b.dataset.limit},prev).then(ok=>{if(ok){limit=settings.defaultLimit;paintLimit();toast('Default set to '+settings.defaultLimit+' hits per vendor','success');}});});+function bindToggle(id,key,label){$(id).addEventListener('click',()=>{const prev={[key]:settings[key]};saveSettings({[key]:!settings[key]},prev).then(ok=>{if(ok){toast(label+(settings[key]?' on':' off'),'success');if(key==='inStockOnly'){inStock=settings.inStockOnly;paintInStock();}if(key==='showImages'&&lastResults)renderResults(lastResults);if(key.startsWith('vendor'))paintVendorStatus(true);}});});}+bindToggle('#tglInStock','inStockOnly','In stock only by default');bindToggle('#tglMouser','vendorMouser','Mouser');bindToggle('#tglDigikey','vendorDigikey','DigiKey');bindToggle('#tglJlcpcb','vendorJlcpcb','JLCPCB');+bindToggle('#tglImages','showImages','Product photos');bindToggle('#tglTaskbar','taskbarProgress','Taskbar progress bar');bindToggle('#tglSay','showSayStrip','Say it strip');+$('#sayClose').addEventListener('click',()=>{const prev={showSayStrip:true};saveSettings({showSayStrip:false},prev).then(ok=>{if(ok)toast('Say it strip hidden. Bring it back any time in Settings.','success',{label:'Undo',run:()=>saveSettings({showSayStrip:true},{showSayStrip:false})});});});+$('#policySet').addEventListener('change',e=>{const v=e.target.value;if(!v||v===settings.policy)return;const prev={policy:settings.policy};saveSettings({policy:v},prev).then(ok=>{if(ok){toast('Policy: '+(v==='cheapest'?'cheapest in stock':'Mouser preferred')+'. Run the search again to re-rank.','success');if(lastResults)renderRecommendation(lastResults.recommendation,lastResults.empty_state,lastResults.policy);}});});+$('#selBox').addEventListener('change',e=>{const prev={taskbarBox:settings.taskbarBox};saveSettings({taskbarBox:e.target.value},prev).then(ok=>{if(ok)toast('Taskbar bar paints on '+(settings.taskbarBox||'the first connected box'),'success');});});+(function(){const btn=$('#btnRestore'),lbl=$('#restoreLabel'),ic=$('#restoreIcon');let armed=false,t=null;+ const RESTORE='M13 3a9 9 0 0 0-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42A8.954 8.954 0 0 0 13 21a9 9 0 0 0 0-18m-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8z';+ const ALERT='M11 15h2v2h-2v-2m0-8h2v6h-2V7m1-5C6.47 2 2 6.5 2 12a10 10 0 0 0 10 10 10 10 0 0 0 10-10A10 10 0 0 0 12 2m0 18a8 8 0 0 1-8-8 8 8 0 0 1 8-8 8 8 0 0 1 8 8 8 8 0 0 1-8 8z';+ function disarm(){armed=false;btn.classList.remove('armed');lbl.textContent='Restore defaults';ic.setAttribute('d',RESTORE);clearTimeout(t);}+ btn.addEventListener('click',async()=>{if(!armed){armed=true;btn.classList.add('armed');lbl.textContent='Click again to confirm';ic.setAttribute('d',ALERT);t=setTimeout(disarm,4000);return;}+ disarm();try{const r=await fetch('api/settings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({restore:true})});const d=await r.json();if(d.ok){settings=Object.assign({},DEFAULTS,d.settings||{});remember();limit=settings.defaultLimit;inStock=settings.inStockOnly;paintLimit();paintInStock();paintSettings();toast('Every setting restored to its default','success');}else toast('Restore refused: '+(d.error||''),'error');}catch(e){toast('Restore failed: '+e.message,'error');}}); })(); -// Server state is the single source of truth (app-creator §7a).-// The UI polls /state every 500ms; whenever a new query lands on the-// server (from the form submit, from the CLI's `show` subcommand, or-// from any other client posting to /search), the UI re-renders. This-// is what makes the app CLI-drivable and AI-drivable without any-// URL-param or eval hackery.-let lastSig = null;-async function pollState() {- try {- const r = await fetch('state');- if (!r.ok) return;- const v = await r.json();- const sig = v.last_query_ts || (v.last_query ? v.last_query + ':' + JSON.stringify(v.last_results || {}).length : null);- if (sig === lastSig) return;- lastSig = sig;- if (v.last_query) document.getElementById('q').value = v.last_query;- if (v.last_results) {- renderColumn('mouser', v.last_results.mouser);- renderColumn('digikey', v.last_results.digikey);- renderColumn('jlcpcb', v.last_results.jlcpcb);- renderRecommendation(v.last_results.recommendation, v.last_results.empty_state);- }- } catch {}+// ---- Health: the header LED, the footer LEDs, and the vendor rows in Settings ----+let lastStatus=null,avgMs=null;+function paintVendorStatus(force){+ if(!lastStatus)return;+ for(const v of VENDORS){const led=lastStatus.leds.find(l=>l.label.toLowerCase()===v);const el=$('#rs'+VNAME[v].replace('DigiKey','Digikey').replace('JLCPCB','Jlcpcb'));if(!led||!el)continue;+ const on=settings['vendor'+v[0].toUpperCase()+v.slice(1)];el.textContent=on?led.detail:'off, not queried';el.className='rstat '+(on?led.state:'');}+}+async function pollStatus(){+ try{const r=await fetch('api/status');if(!r.ok)return;const s=await r.json();lastStatus=s;avgMs=s.timing&&s.timing.search_avg_ms||avgMs;+ const h=$('#hled');h.classList.remove('ok','warn','fail');if(!searching)h.classList.add(s.state);h.setAttribute('data-tooltip','Vendor backend health: '+s.detail+'. '+s.leds.map(l=>l.label+' '+l.state+' ('+l.detail+')').join('; ')+'.');+ for(const v of VENDORS){const led=s.leds.find(l=>l.label.toLowerCase()===v);const f=$('#f-'+v+' .hled');f.className='hled '+(led?led.state:'');$('#f-'+v).setAttribute('data-tooltip',led?led.label+': '+led.detail:'');}+ $('#f-version').textContent='v'+s.version;$('#popVersion').textContent='v'+s.version+(s.uptime_s!=null?', up '+Math.round(s.uptime_s/60)+' min':'');+ $('#f-timing').textContent=avgMs?'searches average '+fmtMs(avgMs):'';+ paintVendorStatus();if(dlg.open)paintSettings();+ }catch{}+}+pollStatus();setInterval(pollStatus,15000);++// ---- State poll: the server is the single source of truth ----+let lastSig=null,lastResults=null,lastClickTs=Date.now(),first=true;+function clickById(id,value){+ const map={search:()=>$('#go').click(),instock:()=>$('#instock').click(),settings:()=>{if(!dlg.open)$('#btnSettings').click();},'close-settings':()=>{if(dlg.open)dlg.close();},restore:()=>$('#btnRestore').click(),+ limit:()=>{const b=document.querySelector('#search-form .seg button[data-limit="'+value+'"]');if(b)b.click();},policy:()=>{const i=document.querySelector('#policySet input[value="'+value+'"]');if(i){i.checked=true;i.dispatchEvent(new Event('change',{bubbles:true}));}},+ 'say-close':()=>$('#sayClose').click(),'say':()=>$('#tglSay').click(),'vendor-mouser':()=>$('#tglMouser').click(),'vendor-digikey':()=>$('#tglDigikey').click(),'vendor-jlcpcb':()=>$('#tglJlcpcb').click(),images:()=>$('#tglImages').click(),taskbar:()=>$('#tglTaskbar').click(),query:()=>{$('#q').value=value||'';$('#q').focus();}};+ if(map[id]){map[id]();console.log('ui click: '+id+(value?' '+value:''));return true;}+ const el=document.getElementById(id);if(el){el.click();console.log('ui click: #'+id);return true;}+ console.warn('ui click: no control with id "'+id+'"');return false;+}+async function pollState(){+ try{const r=await fetch('state');if(!r.ok)return;const v=await r.json();+ if(v.say)renderSay(v.say);+ if(v.timing&&v.timing.search_avg_ms)avgMs=v.timing.search_avg_ms;+ if(v.settings&&JSON.stringify(v.settings)!==JSON.stringify(settings)){Object.assign(settings,v.settings);remember();if(!dlg.open){limit=settings.defaultLimit;inStock=settings.inStockOnly;paintLimit();paintInStock();}paintSettings();}+ for(const t of v.toasts||[]){if(seenToasts.has(t.id))continue;seenToasts.add(t.id);if(first)continue;toast(t.message,t.kind||'info');}+ for(const c of v.clicks||[]){if(c.ts<=lastClickTs)continue;lastClickTs=c.ts;clickById(c.id,c.value);}+ if(v.searching&&!searching){busyFromPoll=true;setBusy(v.searching,true);$('#q').value=v.searching;for(const vv of VENDORS){$('#body-'+vv).innerHTML=skeleton(settings.defaultLimit);}}+ else if(!v.searching&&searching&&busyFromPoll){busyFromPoll=false;setBusy(null);}+ const sig=v.last_query_ts||null;+ if(sig&&sig!==lastSig){lastSig=sig;if(v.last_query)$('#q').value=v.last_query;if(v.last_results){lastResults=v.last_results;renderResults(v.last_results);}}+ first=false;+ }catch{} }-pollState();-setInterval(pollState, 500);+(function(){const ex=['STM32F103RBT6','LM358','ESP32-S3-WROOM-1','10k 0402','LDO 3.3V 500mA','USB-C receptacle'];const host=$('#examples');host.insertAdjacentHTML('beforeend',ex.map(e=>'<button type="button" class="ex" data-tooltip="Run this example search.">'+escape(e)+'</button>').join(''));host.addEventListener('click',e=>{const b=e.target.closest('.ex');if(!b)return;$('#q').value=b.textContent;doSearch(b.textContent);});})();+renderIdle();paintSettings();pollState();setInterval(pollState,500); </script> </body> </html>
src/cli.rs+115−59@@ -3,16 +3,19 @@ //! the running Hydrogen webview app so the user sees the 3-column grid. use crate::vendors;-use crate::{ShowArgs, SearchArgs};+use crate::{settings, ShowArgs, SearchArgs, SettingsCmd, UiCmd}; pub fn run_search(args: SearchArgs) -> Result<(), String> {+ let s = settings::load();+ let opts = vendors::SearchOpts::from_settings(&s); let agg = vendors::search_all( &args.keyword,- args.limit,- args.in_stock_only,+ args.limit.unwrap_or(s.default_limit),+ args.in_stock_only || s.in_stock_only, args.mouser_api.as_deref(), args.digikey_api.as_deref(), args.jlcpcb_api.as_deref(),+ &opts, ); // OK: line to stderr so stdout stays pure JSON for jq. let ok_line = format!(@@ -39,27 +42,24 @@ pub fn run_search(args: SearchArgs) -> Result<(), String> { pub fn run_show(args: ShowArgs) -> Result<(), String> { let base = format!("http://127.0.0.1:{}", args.port); - // 1. Ensure the app server is up. If not, spawn it detached and wait.- if !ping(&base) {- crate::note!("app server not running on :{} — starting it...", args.port);- spawn_app_detached(args.port, &args.tab_name)?;- wait_until_up(&base, std::time::Duration::from_secs(10))?;- crate::ok(format!("app server up on :{}", args.port));- } else {- crate::note!("app server already up on :{}", args.port);- }+ // 1. Ensure a server of OUR version is up (version-aware, idempotent).+ let (_, started) = crate::serve::ensure(args.port)?;+ if started { crate::ok(format!("page server started on :{}", args.port)); }+ else { crate::note!("page server already up on :{}", args.port); } // 2. Ensure the Hydrogen tab exists (if the app was already up without // a tab we still want the user to see results). ensure_tab(&args.tab_name, args.port); - // 3. POST the search through the app — server updates its state, UI- // poller renders the 3 columns, and we get the full JSON back to- // print to stdout.+ // 3. POST the search through the app: the server updates its state, the+ // page re-renders, and we get the full JSON back to print. source=ai+ // paints an INDETERMINATE taskbar bar (open-ended, AI-driven).+ let s = settings::load(); let body = serde_json::json!({ "keyword": args.keyword,- "limit": args.limit,- "inStockOnly": args.in_stock_only,+ "limit": args.limit.unwrap_or(s.default_limit),+ "inStockOnly": args.in_stock_only || s.in_stock_only,+ "source": "ai", }); let resp = post_json(&format!("{}/search", base), body) .map_err(|e| format!("POST /search failed: {}", e))?;@@ -73,40 +73,6 @@ pub fn run_show(args: ShowArgs) -> Result<(), String> { Ok(()) } -fn ping(base: &str) -> bool {- let agent = ureq::AgentBuilder::new()- .timeout(std::time::Duration::from_millis(400))- .build();- agent.get(&format!("{}/state", base)).call().is_ok()-}--fn wait_until_up(base: &str, total: std::time::Duration) -> Result<(), String> {- let start = std::time::Instant::now();- while start.elapsed() < total {- if ping(base) { return Ok(()); }- std::thread::sleep(std::time::Duration::from_millis(150));- }- Err(format!("app server on {} did not come up within {:?}", base, total))-}--fn spawn_app_detached(port: u16, tab_name: &str) -> Result<(), String> {- use std::process::{Command, Stdio};- let exe = std::env::current_exe().map_err(|e| format!("current_exe: {}", e))?;- Command::new(exe)- .args([- "app",- "--port", &port.to_string(),- "--tab-name", tab_name,- "--no-tab",- ])- .stdin(Stdio::null())- .stdout(Stdio::null())- .stderr(Stdio::null())- .spawn()- .map(|_| ())- .map_err(|e| format!("spawn: {}", e))-}- fn ensure_tab(tab_name: &str, port: u16) { use std::process::Command; // `find-tab` takes the name as a positional arg. Exit-code isn't@@ -132,6 +98,96 @@ fn ensure_tab(tab_name: &str, port: u16) { } } +fn get_json(url: &str, timeout_ms: u64) -> Result<serde_json::Value, String> {+ let agent = ureq::AgentBuilder::new().timeout(std::time::Duration::from_millis(timeout_ms)).build();+ match agent.get(url).call() {+ Ok(r) => r.into_json().map_err(|e| format!("decode: {}", e)),+ Err(ureq::Error::Status(code, r)) => Err(format!("HTTP {}: {}", code, r.into_string().unwrap_or_default())),+ Err(e) => Err(e.to_string()),+ }+}++fn not_running_hint(port: u16) -> String {+ format!("no page server answered on port {}. Hint: start one with `adom-parts-search serve` (or `adom-parts-search show \"LM555\"`)", port)+}++pub fn run_status(port: u16) -> Result<(), String> {+ let v = get_json(&format!("http://127.0.0.1:{}/api/status", port), 6000).map_err(|_| not_running_hint(port))?;+ println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());+ Ok(())+}++pub fn run_health(port: u16) -> Result<(), String> {+ let v = get_json(&format!("http://127.0.0.1:{}/api/status", port), 6000).map_err(|_| not_running_hint(port))?;+ let state = v.get("state").and_then(|s| s.as_str()).unwrap_or("unknown");+ let detail = v.get("detail").and_then(|s| s.as_str()).unwrap_or("");+ let leds: Vec<String> = v.get("leds").and_then(|l| l.as_array()).map(|a| a.iter().map(|l| format!("{}={}", l.get("label").and_then(|x| x.as_str()).unwrap_or("?"), l.get("state").and_then(|x| x.as_str()).unwrap_or("?"))).collect()).unwrap_or_default();+ let line = format!("v{} {} ({}) [{}] {}", v.get("version").and_then(|s| s.as_str()).unwrap_or("?"), state, detail, leds.join(" "), v.get("url").and_then(|s| s.as_str()).unwrap_or(""));+ if state == "fail" {+ for l in v.get("leds").and_then(|l| l.as_array()).cloned().unwrap_or_default() {+ if l.get("state").and_then(|x| x.as_str()) == Some("fail") { crate::hint(l.get("_hint").and_then(|x| x.as_str()).unwrap_or("")); }+ }+ return Err(line);+ }+ crate::ok(line);+ Ok(())+}++pub fn run_ui(cmd: UiCmd) -> Result<(), String> {+ match cmd {+ UiCmd::Toast { message, kind, port } => {+ let v = post_json(&format!("http://127.0.0.1:{}/api/toast", port), serde_json::json!({"message": message, "kind": kind})).map_err(|_| not_running_hint(port))?;+ if v.get("ok").and_then(|b| b.as_bool()) == Some(true) { crate::ok(format!("toast raised: {}", message)); Ok(()) }+ else { Err(format!("toast refused: {}", v)) }+ }+ UiCmd::Click { id, value, port } => {+ let v = post_json(&format!("http://127.0.0.1:{}/api/click", port), serde_json::json!({"id": id, "value": value})).map_err(|_| not_running_hint(port))?;+ if v.get("ok").and_then(|b| b.as_bool()) == Some(true) { crate::ok(format!("click queued for the page: {}", id)); crate::hint("ids: search, instock, settings, close-settings, restore, limit (with --value 3|5|10), policy (--value mouser|cheapest), say-close, say, say-copy-<n>, say-try-<n>"); Ok(()) }+ else { Err(format!("click refused: {}", v)) }+ }+ UiCmd::Search { keyword, port } => {+ let (_, started) = crate::serve::ensure(port)?;+ if started { crate::note!("page server started on :{}", port); }+ let v = post_json(&format!("http://127.0.0.1:{}/search", port), serde_json::json!({"keyword": keyword, "source": "ai"}))?;+ let n = ["mouser", "digikey", "jlcpcb"].iter().map(|k| v[k].get("hits").and_then(|h| h.as_array()).map(|a| a.len()).unwrap_or(0)).sum::<usize>();+ crate::ok(format!("searched \"{}\": {} hits, rendered on the page", keyword, n));+ Ok(())+ }+ }+}++pub fn run_settings(cmd: SettingsCmd) -> Result<(), String> {+ match cmd {+ SettingsCmd::Get => {+ let s = settings::load();+ let cur = settings::as_json(&s); let def = settings::defaults_json();+ for k in settings::KEYS {+ let c = cur.get(*k).cloned().unwrap_or_default(); let d = def.get(*k).cloned().unwrap_or_default();+ crate::note!(" {:<16} {:<12} (default {})", k, c, d);+ }+ crate::note!(" file: {}", settings::path().display());+ Ok(())+ }+ SettingsCmd::Set { key, value } => {+ let v = settings::parse_cli_value(&key, &value)?;+ let mut s = settings::load();+ settings::apply(&mut s, &serde_json::json!({ key.clone(): v.clone() }))?;+ settings::save(&s)?;+ // Tell a running server so the page reflects it without a restart.+ let _ = post_json("http://127.0.0.1:8893/api/settings", serde_json::json!({ key.clone(): v.clone() }));+ crate::ok(format!("{} = {}", key, v));+ Ok(())+ }+ SettingsCmd::Restore => {+ let s = settings::Settings::default();+ settings::save(&s)?;+ let _ = post_json("http://127.0.0.1:8893/api/settings", serde_json::json!({"restore": true}));+ crate::ok("every setting restored to its default");+ Ok(())+ }+ }+}+ fn post_json(url: &str, body: serde_json::Value) -> Result<serde_json::Value, String> { let agent = ureq::AgentBuilder::new() .timeout(std::time::Duration::from_secs(60))@@ -175,9 +231,9 @@ fn render_summary_to_stdout(query: &str, resp: &serde_json::Value) { println!("{} total={:>3} hits={:>2} {}ms [{}]", header, total, hits.len(), elapsed, status); for h in hits.iter().take(5) {- let mpn = h.get("mpn").and_then(|v| v.as_str()).unwrap_or("—");+ let mpn = h.get("mpn").and_then(|v| v.as_str()).unwrap_or("-"); let manuf = h.get("manufacturer").and_then(|v| v.as_str()).unwrap_or("");- let price1 = h.get("price_1").and_then(|v| v.as_str()).unwrap_or("—");+ let price1 = h.get("unit_price").and_then(|v| v.as_str()).unwrap_or("-"); let stock = h.get("stock").and_then(|v| v.as_u64()).unwrap_or(0); let lead = h.get("lead_time").and_then(|v| v.as_str()).unwrap_or(""); println!(" {} {} {} stock {} {}",@@ -191,14 +247,14 @@ fn render_summary_to_stdout(query: &str, resp: &serde_json::Value) { // Recommendation OR empty-state explainer if let Some(rec) = resp.get("recommendation").filter(|v| !v.is_null()) {- let winner = rec.get("winner").and_then(|v| v.as_str()).unwrap_or("—");- let mpn = rec.get("mpn").and_then(|v| v.as_str()).unwrap_or("—");- let price = rec.get("unit_price").and_then(|v| v.as_str()).unwrap_or("—");+ let winner = rec.get("winner").and_then(|v| v.as_str()).unwrap_or("-");+ let mpn = rec.get("mpn").and_then(|v| v.as_str()).unwrap_or("-");+ let price = rec.get("unit_price").and_then(|v| v.as_str()).unwrap_or("-"); let stock = rec.get("stock").and_then(|v| v.as_u64()).unwrap_or(0); let reason = rec.get("reason").and_then(|v| v.as_str()).unwrap_or(""); println!(); println!("{} {} {} {} stock {}",- color(BOLD, "RECOMMENDATION →"),+ color(BOLD, "RECOMMENDATION:"), color(CYAN, winner), mpn, color(YELLOW, price),@@ -209,9 +265,9 @@ fn render_summary_to_stdout(query: &str, resp: &serde_json::Value) { let expl = es.get("explanation").and_then(|v| v.as_str()).unwrap_or(""); let hint = es.get("hint").and_then(|v| v.as_str()).unwrap_or(""); println!();- println!("{} {}", color(BOLD, "NO MATCH —"), color(YELLOW, title));+ println!("{} {}", color(BOLD, "NO MATCH:"), color(YELLOW, title)); if !expl.is_empty() { println!(" {}", color(DIM, expl)); }- if !hint.is_empty() { println!(" {} {}", color(CYAN, "→"), hint); }+ if !hint.is_empty() { println!(" {} {}", color(CYAN, ">"), hint); } } println!(); }
src/install.rs+19−11@@ -15,15 +15,15 @@ use std::io::Write; use std::path::PathBuf; use std::time::Duration; -// SAFETY NET only — do not rely on these for day-to-day release-// propagation. The wiki-hosted copy at-// https://wiki-ufypy5dpx93o.adom.cloud/static/apps/adom-parts-search/SKILL.md-// is authoritative.+// SAFETY NET only: do not rely on these for day-to-day release+// propagation. The wiki page repo copy (raw file API) is authoritative. const FALLBACK_SKILL_MD: &str = include_str!("../SKILL.md"); const FALLBACK_BUILD_SKILL_MD: &str = include_str!("../BUILD-SKILL.md");+const FALLBACK_DOCK_SKILL_MD: &str = include_str!("../skills/adom-parts-search-dock/SKILL.md"); -const WIKI_SKILL_URL: &str = "https://wiki-ufypy5dpx93o.adom.cloud/static/apps/adom-parts-search/SKILL.md";-const WIKI_BUILD_SKILL_URL: &str = "https://wiki-ufypy5dpx93o.adom.cloud/static/apps/adom-parts-search/BUILD-SKILL.md";+const WIKI_RAW: &str = "https://wiki.adom.inc/api/v1/pages/adom/adom-parts-search/files";+const WIKI_SKILL_URL: &str = "https://wiki.adom.inc/api/v1/pages/adom/adom-parts-search/files/SKILL.md?raw=1";+const WIKI_BUILD_SKILL_URL: &str = "https://wiki.adom.inc/api/v1/pages/adom/adom-parts-search/files/BUILD-SKILL.md?raw=1"; fn fetch_or_fallback(url: &str, fallback: &'static str, label: &str) -> String { let agent = ureq::AgentBuilder::new().timeout(Duration::from_secs(5)).build();@@ -31,12 +31,12 @@ fn fetch_or_fallback(url: &str, fallback: &'static str, label: &str) -> String { Ok(r) => match r.into_string() { Ok(body) if body.len() >= 200 => body, _ => {- warn(format!("wiki returned short/unreadable {label} — using bundled fallback"));+ warn(format!("wiki returned short/unreadable {label}, using bundled fallback")); fallback.to_string() } }, Err(_) => {- warn(format!("wiki unreachable, installing bundled {label} (may be stale — re-run `adom-parts-search install` once network is up)"));+ warn(format!("wiki unreachable, installing bundled {label} (may be stale, re-run `adom-parts-search install` once network is up)")); fallback.to_string() } }@@ -52,7 +52,7 @@ pub fn install() -> Result<(), String> { fs::create_dir_all(&skill_dir).map_err(|e| format!("create {}: {}", skill_dir.display(), e))?; let skill_path = skill_dir.join("SKILL.md"); fs::write(&skill_path, &skill_md).map_err(|e| format!("write {}: {}", skill_path.display(), e))?;- ok(format!("installed skill → {} ({} bytes)", skill_path.display(), skill_md.len()));+ ok(format!("installed skill: {} ({} bytes)", skill_path.display(), skill_md.len())); // 1b. Build skill (per-app release workflow) let build_skill_md = fetch_or_fallback(WIKI_BUILD_SKILL_URL, FALLBACK_BUILD_SKILL_MD, "BUILD-SKILL.md");@@ -62,7 +62,15 @@ pub fn install() -> Result<(), String> { let build_skill_path = build_skill_dir.join("SKILL.md"); fs::write(&build_skill_path, &build_skill_md) .map_err(|e| format!("write {}: {}", build_skill_path.display(), e))?;- ok(format!("installed build skill → {} ({} bytes)", build_skill_path.display(), build_skill_md.len()));+ ok(format!("installed build skill: {} ({} bytes)", build_skill_path.display(), build_skill_md.len()));++ // 1c. Dock Bar sub-skill (how the app lives in the Hydrogen Dock Bar)+ let dock_skill_md = fetch_or_fallback(&format!("{}/skills/adom-parts-search-dock/SKILL.md?raw=1", WIKI_RAW), FALLBACK_DOCK_SKILL_MD, "adom-parts-search-dock/SKILL.md");+ let dock_skill_dir = skills_root.join("adom-parts-search-dock");+ fs::create_dir_all(&dock_skill_dir).map_err(|e| format!("create {}: {}", dock_skill_dir.display(), e))?;+ let dock_skill_path = dock_skill_dir.join("SKILL.md");+ fs::write(&dock_skill_path, &dock_skill_md).map_err(|e| format!("write {}: {}", dock_skill_path.display(), e))?;+ ok(format!("installed dock skill: {} ({} bytes)", dock_skill_path.display(), dock_skill_md.len())); // 2. Bash completions let comp_dir = PathBuf::from(&home)@@ -76,7 +84,7 @@ pub fn install() -> Result<(), String> { let mut buf: Vec<u8> = Vec::new(); clap_complete::generate(Shell::Bash, &mut cmd, "adom-parts-search", &mut buf); fs::write(&comp_path, &buf).map_err(|e| format!("write {}: {}", comp_path.display(), e))?;- ok(format!("installed completions → {}", comp_path.display()));+ ok(format!("installed completions: {}", comp_path.display())); // 3. Append eval to .bashrc once let bashrc = PathBuf::from(&home).join(".bashrc");
src/main.rs+72−17@@ -6,12 +6,15 @@ //! with product photos (which you can't see in a terminal), and apply the //! Mouser-preferred routing policy from the `adom-parts-search` skill. //!-//! Client-only — no service container. Talks to the three existing+//! Client-only, no service container. Talks to the three existing //! vendor backends over HTTPS. mod app; mod cli; mod install;+mod serve;+mod settings;+mod taskbar; mod vendors; use clap::{CommandFactory, Parser, Subcommand};@@ -65,7 +68,7 @@ macro_rules! note { } #[derive(Parser)]-#[command(name = "adom-parts-search", version, about = "Unified parts search across Mouser/DigiKey/JLCPCB with Mouser-preferred routing (Fort Worth 40-min drone delivery)")]+#[command(name = "adom-parts-search", version, about = "Parts Search: one query across Mouser, DigiKey, and JLCPCB, with a Mouser-preferred recommendation (40-minute drone delivery to Fort Worth)")] pub struct Cli { #[command(subcommand)] cmd: Cmd,@@ -76,10 +79,28 @@ enum Cmd { /// Search all three vendors in parallel; print unified JSON to stdout. Search(SearchArgs), - /// Open the Hydrogen webview — 3-column grid with product photos.+ /// Run the Parts Search page server in the foreground (3-column grid with product photos). App(AppArgs), - /// DEPRECATED — this feature moved to the `adom-chipfit` app.+ /// Dock Bar contract: start the page server in the background (idempotent,+ /// version-aware), take over the dock webview, and print its direct URL.+ Serve(ServeArgs),++ /// Print the /api/status payload (per-facet LEDs + rollup) and exit.+ Status(PortArg),++ /// One-line health check of the running page server (OK: or ERROR: + Hint).+ Health(PortArg),++ /// Drive the page: raise a toast, click a control, or run a search in it.+ #[command(subcommand)]+ Ui(UiCmd),++ /// Read or change settings (~/.adom/parts-search-settings.json).+ #[command(subcommand)]+ Settings(SettingsCmd),++ /// DEPRECATED: this feature moved to the `adom-chipfit` app. /// See `adom-parts-search align --help` for the one-liner. #[command(hide = false)] Align(AlignArgs),@@ -97,10 +118,45 @@ enum Cmd { Completions { shell: clap_complete::Shell }, } +#[derive(clap::Args)]+pub struct ServeArgs {+ #[arg(long, default_value_t = 8893)] pub port: u16,+ /// Print only the URL on stdout (what the dock's serve step consumes).+ #[arg(long)] pub print_url: bool,+}++#[derive(clap::Args)]+pub struct PortArg {+ #[arg(long, default_value_t = 8893)] pub port: u16,+ /// Print the full /api/status JSON instead of the one-line summary.+ #[arg(long)] pub json: bool,+}++#[derive(Subcommand)]+pub enum UiCmd {+ /// Show a toast on the page (bottom center). Kinds: info, success, error.+ Toast { message: String, #[arg(long, default_value = "info")] kind: String, #[arg(long, default_value_t = 8893)] port: u16 },+ /// Click a control by id, exactly as a user would (e.g. settings, search, instock, close-settings).+ Click { id: String, #[arg(long)] value: Option<String>, #[arg(long, default_value_t = 8893)] port: u16 },+ /// Run a search through the page (same as `show`, without the stdout summary).+ Search { keyword: String, #[arg(long, default_value_t = 8893)] port: u16 },+}++#[derive(Subcommand)]+pub enum SettingsCmd {+ /// Print every setting (and its default).+ Get,+ /// Change one setting, e.g. `settings set policy cheapest`, `settings set taskbarProgress off`.+ Set { key: String, value: String },+ /// Restore every setting to its default.+ Restore,+}+ #[derive(clap::Args)] pub struct SearchArgs { pub keyword: String,- #[arg(long, default_value_t = 5)] pub limit: u32,+ /// Hits per vendor (defaults to the Settings value, 5 out of the box).+ #[arg(long)] pub limit: Option<u32>, #[arg(long)] pub in_stock_only: bool, #[arg(long, env = "MOUSER_API")] pub mouser_api: Option<String>, #[arg(long, env = "DIGIKEY_API")] pub digikey_api: Option<String>,@@ -110,7 +166,8 @@ pub struct SearchArgs { #[derive(clap::Args)] pub struct ShowArgs { pub keyword: String,- #[arg(long, default_value_t = 5)] pub limit: u32,+ /// Hits per vendor (defaults to the Settings value, 5 out of the box).+ #[arg(long)] pub limit: Option<u32>, #[arg(long)] pub in_stock_only: bool, #[arg(long, default_value_t = 8893)] pub port: u16, #[arg(long, default_value = "Parts Search")] pub tab_name: String,@@ -132,7 +189,7 @@ pub struct AlignArgs { /// Tab name in the Hydrogen workspace. #[arg(long, default_value = "Part Align")] pub tab_name: String,- /// Don't open the Hydrogen tab (just run the server — useful for+ /// Don't open the Hydrogen tab (just run the server, useful for /// capturing via pup or curling endpoints directly). #[arg(long)] pub no_tab: bool,@@ -155,18 +212,16 @@ fn main() -> ExitCode { Cmd::Search(a) => cli::run_search(a), Cmd::Show(a) => cli::run_show(a), Cmd::App(a) => app::run(a),+ Cmd::Serve(a) => serve::run(a.port, a.print_url),+ Cmd::Status(a) => cli::run_status(a.port),+ Cmd::Health(a) => if a.json { cli::run_status(a.port) } else { cli::run_health(a.port) },+ Cmd::Ui(u) => cli::run_ui(u),+ Cmd::Settings(c) => cli::run_settings(c), Cmd::Align(_) => { warn("`adom-parts-search align` has moved to its own app: `adom-chipfit`.");- note!("");- note!(" Install it (one-liner, run once per container):");- note!(" {CYAN}curl -fsSL https://wiki-ufypy5dpx93o.adom.cloud/static/apps/adom-chipfit/adom-chipfit \\");- note!(" -o /tmp/adom-chipfit && chmod +x /tmp/adom-chipfit \\");- note!(" && sudo install -m 0755 /tmp/adom-chipfit /usr/local/bin/adom-chipfit \\");- note!(" && adom-chipfit install{RESET}");- note!("");- note!(" Then run the same validation via:");- note!(" {CYAN}adom-chipfit check --footprint <fp.kicad_mod> --glb <chip.glb>{RESET}");- Err("align has been removed from adom-parts-search — see message above".into())+ hint(format!("Install it once per container: {CYAN}adom-wiki pkg install adom/adom-chipfit{RESET}"));+ hint(format!("Then: {CYAN}adom-chipfit check --footprint <fp.kicad_mod> --glb <chip.glb>{RESET}"));+ Err("align has been removed from adom-parts-search; see the hints above".into()) } Cmd::Install => install::install(), Cmd::Completions { shell } => {
src/serve.rsadded+122@@ -0,0 +1,122 @@+//! `adom-parts-search serve [--print-url]`: the Dock Bar contract.+//!+//! 1. SELF-BACKGROUNDS: spawns the server detached, waits for it to+//! answer, prints the DIRECT url (http://127.0.0.1:<port>/), exits 0.+//! 2. IDEMPOTENT + VERSION-AWARE: an instance already up with OUR version+//! is reused (print its url, exit 0). An instance of another version is+//! stopped (matched by the pid it published AND its exact argv) and+//! replaced, so a reinstall never leaves users pinned to a stale server.+//! 3. WEBVIEW OWNERSHIP: when the dock set ADOM_DOCK_WEBVIEW, the tab it+//! pre-opened is navigated to our url via `adom-cli hydrogen webview+//! open-or-refresh`; skipped when unset (a hand-run serve).++use serde_json::{json, Value};+use std::process::{Command, Stdio};+use std::time::{Duration, Instant};++pub const VERSION: &str = env!("CARGO_PKG_VERSION");++pub fn state_path() -> std::path::PathBuf { crate::settings::adom_dir().join("parts-search-server.json") }++pub fn direct_url(port: u16) -> String { format!("http://127.0.0.1:{}/", port) }++/// Written by the running server so serve / show can find and version it.+pub fn publish_state(port: u16) {+ let _ = std::fs::create_dir_all(crate::settings::adom_dir());+ let v = json!({+ "port": port, "pid": std::process::id(), "version": VERSION, "url": direct_url(port),+ "argv": expected_argv(port),+ "dockWebview": std::env::var("ADOM_DOCK_WEBVIEW").ok(),+ "dockApp": std::env::var("ADOM_DOCK_APP").ok(),+ });+ let _ = std::fs::write(state_path(), serde_json::to_string_pretty(&v).unwrap_or_default());+}++fn expected_argv(port: u16) -> Vec<String> {+ let exe = std::env::current_exe().map(|p| p.to_string_lossy().to_string()).unwrap_or_else(|_| "adom-parts-search".into());+ vec![exe, "app".into(), "--port".into(), port.to_string(), "--no-tab".into()]+}++/// GET /api/status on the port; Some(version) when something of ours answers.+pub fn instance_version(port: u16) -> Option<String> {+ let agent = ureq::AgentBuilder::new().timeout(Duration::from_millis(1200)).build();+ let r = agent.get(&format!("http://127.0.0.1:{}/api/status", port)).call().ok()?;+ let v: Value = r.into_json().ok()?;+ if v.get("app").and_then(|a| a.as_str()) != Some("adom-parts-search") { return None; }+ Some(v.get("version").and_then(|s| s.as_str()).unwrap_or("").to_string())+}++pub fn alive(port: u16) -> bool { instance_version(port).is_some() }++/// Kill a published instance only when its argv is EXACTLY what we spawn+/// (never a substring match: "adom-parts-search" also matches an editor+/// with the repo open).+fn stop_published_instance(port: u16) {+ let Ok(raw) = std::fs::read_to_string(state_path()) else { return };+ let Ok(st) = serde_json::from_str::<Value>(&raw) else { return };+ let Some(pid) = st.get("pid").and_then(|p| p.as_u64()) else { return };+ let Ok(cmd) = std::fs::read(format!("/proc/{}/cmdline", pid)) else { return };+ let argv: Vec<String> = cmd.split(|b| *b == 0).filter(|s| !s.is_empty()).map(|s| String::from_utf8_lossy(s).to_string()).collect();+ let want = expected_argv(port);+ // argv[0] may be the bare name or a path; compare the tail exactly.+ let same = argv.len() == want.len()+ && argv[1..] == want[1..]+ && (argv[0] == want[0] || argv[0].rsplit('/').next() == want[0].rsplit('/').next());+ if !same { return; }+ let _ = Command::new("kill").arg(pid.to_string()).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).status();+ let _ = std::fs::remove_file(state_path());+ let t0 = Instant::now();+ while alive(port) && t0.elapsed() < Duration::from_secs(3) { std::thread::sleep(Duration::from_millis(100)); }+}++pub fn take_over_dock_webview(url: &str) {+ let Ok(name) = std::env::var("ADOM_DOCK_WEBVIEW") else { return };+ if name.trim().is_empty() { return; }+ let _ = Command::new("adom-cli")+ .args(["hydrogen", "webview", "open-or-refresh", "--name", &name, "--url", url])+ .stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null())+ .spawn();+}++pub fn spawn_detached(port: u16) -> Result<(), String> {+ use std::os::unix::process::CommandExt;+ let exe = std::env::current_exe().map_err(|e| format!("current_exe: {}", e))?;+ Command::new(exe)+ .args(["app", "--port", &port.to_string(), "--no-tab"])+ .stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null())+ .process_group(0)+ .spawn()+ .map(|_| ())+ .map_err(|e| format!("spawn: {}", e))+}++/// Ensure a server of OUR version is up on `port`. Returns its direct url.+pub fn ensure(port: u16) -> Result<(String, bool), String> {+ if let Some(v) = instance_version(port) {+ if v == VERSION { return Ok((direct_url(port), false)); }+ crate::warn(format!("running instance is {}, this is {}; restarting", if v.is_empty() { "unversioned" } else { &v }, VERSION));+ stop_published_instance(port);+ if alive(port) {+ return Err(format!("port {} is held by a parts-search server I could not stop (pid not published by this version). Hint: POST /shutdown to http://127.0.0.1:{}/ or free the port, then retry.", port, port));+ }+ }+ spawn_detached(port)?;+ let t0 = Instant::now();+ while t0.elapsed() < Duration::from_secs(15) {+ if alive(port) { return Ok((direct_url(port), true)); }+ std::thread::sleep(Duration::from_millis(200));+ }+ Err(format!("the server did not answer on port {} within 15s. Hint: run `adom-parts-search app --no-tab --port {}` in the foreground to see why.", port, port))+}++pub fn run(port: u16, print_url: bool) -> Result<(), String> {+ let (url, started) = ensure(port)?;+ take_over_dock_webview(&url);+ if print_url {+ println!("{}", url);+ } else {+ crate::ok(format!("{} on {} (v{})", if started { "started" } else { "already running" }, url, VERSION));+ if std::env::var("ADOM_DOCK_WEBVIEW").is_ok() { crate::note!(" took over dock webview \"{}\"", std::env::var("ADOM_DOCK_WEBVIEW").unwrap_or_default()); }+ }+ Ok(())+}
src/settings.rsadded+131@@ -0,0 +1,131 @@+//! Persistent app settings: ~/.adom/parts-search-settings.json.+//!+//! Local-first truth (settings-layout skill): the file on this machine is+//! the store, the UI mirrors it optimistically, and every key is typed by+//! its default so an enum can never persist as a bool.++use serde::{Deserialize, Serialize};+use serde_json::{json, Value};+use std::path::PathBuf;++#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]+#[serde(rename_all = "camelCase")]+pub struct Settings {+ /// Hits fetched per vendor per search (3, 5, or 10).+ pub default_limit: u32,+ /// Drop stock=0 hits before rendering.+ pub in_stock_only: bool,+ pub vendor_mouser: bool,+ pub vendor_digikey: bool,+ pub vendor_jlcpcb: bool,+ /// "mouser" (Mouser-preferred, the Fort Worth drone policy) or+ /// "cheapest" (lowest in-stock unit price wins across all vendors).+ pub policy: String,+ /// Render vendor product photos (fetched from the vendors' CDNs).+ pub show_images: bool,+ /// Paint a progress bar on the Hydrogen taskbar button while a search runs.+ pub taskbar_progress: bool,+ /// Which Adom Desktop box carries the taskbar button ("" = first connected).+ pub taskbar_box: String,+ /// Show the "Say it to your AI" strip at the top of the page. The X on+ /// the strip turns this off; Settings turns it back on.+ pub show_say_strip: bool,+}++impl Default for Settings {+ fn default() -> Self {+ Settings {+ default_limit: 5,+ in_stock_only: false,+ vendor_mouser: true,+ vendor_digikey: true,+ vendor_jlcpcb: true,+ policy: "mouser".into(),+ show_images: true,+ taskbar_progress: true,+ taskbar_box: String::new(),+ show_say_strip: true,+ }+ }+}++pub const KEYS: &[&str] = &[+ "defaultLimit", "inStockOnly", "vendorMouser", "vendorDigikey", "vendorJlcpcb",+ "policy", "showImages", "taskbarProgress", "taskbarBox", "showSayStrip",+];++pub fn adom_dir() -> PathBuf {+ let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());+ PathBuf::from(home).join(".adom")+}++pub fn path() -> PathBuf { adom_dir().join("parts-search-settings.json") }++pub fn load() -> Settings {+ let defaults = Settings::default();+ let Ok(raw) = std::fs::read_to_string(path()) else { return defaults };+ let Ok(v) = serde_json::from_str::<Value>(&raw) else { return defaults };+ let mut s = defaults;+ let _ = apply(&mut s, &v);+ s+}++pub fn save(s: &Settings) -> Result<(), String> {+ let dir = adom_dir();+ std::fs::create_dir_all(&dir).map_err(|e| format!("mkdir {}: {}", dir.display(), e))?;+ let tmp = path().with_extension("json.tmp");+ std::fs::write(&tmp, serde_json::to_string_pretty(s).unwrap_or_default())+ .map_err(|e| format!("write {}: {}", tmp.display(), e))?;+ std::fs::rename(&tmp, path()).map_err(|e| format!("rename: {}", e))+}++/// Apply a partial JSON patch, typing every key by its default. Unknown+/// keys and wrongly-typed values are rejected by name so the caller can+/// report them instead of silently coercing (the kicad "flash persisted+/// as True" bug).+pub fn apply(s: &mut Settings, patch: &Value) -> Result<Vec<String>, String> {+ let Some(obj) = patch.as_object() else { return Err("settings patch must be a JSON object".into()) };+ let mut changed = vec![];+ for (k, v) in obj {+ match k.as_str() {+ "defaultLimit" => {+ let n = v.as_u64().ok_or("defaultLimit must be a number")?;+ if ![3, 5, 10].contains(&n) { return Err("defaultLimit must be 3, 5, or 10".into()); }+ s.default_limit = n as u32;+ }+ "inStockOnly" => s.in_stock_only = v.as_bool().ok_or("inStockOnly must be true or false")?,+ "vendorMouser" => s.vendor_mouser = v.as_bool().ok_or("vendorMouser must be true or false")?,+ "vendorDigikey" => s.vendor_digikey = v.as_bool().ok_or("vendorDigikey must be true or false")?,+ "vendorJlcpcb" => s.vendor_jlcpcb = v.as_bool().ok_or("vendorJlcpcb must be true or false")?,+ "policy" => {+ let p = v.as_str().ok_or("policy must be a string")?;+ if p != "mouser" && p != "cheapest" { return Err("policy must be \"mouser\" or \"cheapest\"".into()); }+ s.policy = p.to_string();+ }+ "showImages" => s.show_images = v.as_bool().ok_or("showImages must be true or false")?,+ "taskbarProgress" => s.taskbar_progress = v.as_bool().ok_or("taskbarProgress must be true or false")?,+ "taskbarBox" => s.taskbar_box = v.as_str().ok_or("taskbarBox must be a string")?.to_string(),+ "showSayStrip" => s.show_say_strip = v.as_bool().ok_or("showSayStrip must be true or false")?,+ other => return Err(format!("unknown setting \"{}\" (known: {})", other, KEYS.join(", "))),+ }+ changed.push(k.clone());+ }+ Ok(changed)+}++/// Parse a CLI string value into the JSON type the key expects.+pub fn parse_cli_value(key: &str, raw: &str) -> Result<Value, String> {+ match key {+ "defaultLimit" => raw.parse::<u64>().map(Value::from).map_err(|_| "expected a number (3, 5, or 10)".to_string()),+ "policy" | "taskbarBox" => Ok(Value::String(raw.to_string())),+ k if KEYS.contains(&k) => match raw.to_ascii_lowercase().as_str() {+ "on" | "true" | "1" | "yes" => Ok(json!(true)),+ "off" | "false" | "0" | "no" => Ok(json!(false)),+ _ => Err("expected on or off".into()),+ },+ other => Err(format!("unknown setting \"{}\" (known: {})", other, KEYS.join(", "))),+ }+}++pub fn as_json(s: &Settings) -> Value { serde_json::to_value(s).unwrap_or(json!({})) }+pub fn defaults_json() -> Value { as_json(&Settings::default()) }
src/taskbar.rsadded+116@@ -0,0 +1,116 @@+//! Windows taskbar progress on the Hydrogen button while a search runs,+//! painted through Adom Desktop's `desktop_taskbar` verb (ITaskbarList3).+//!+//! Determinate bars are sized from this app's own measured search+//! durations (~/.adom/parts-search-timing.json, recent runs weighted),+//! cap at 92% until the search really finishes, then jump to 100 and+//! clear. An AI-driven search (the `show` verb, open-ended) paints an+//! INDETERMINATE marquee instead, so the user sees activity without a+//! made-up estimate. Every bridge call is fire-and-forget on a thread+//! and bounded, so a missing desktop can never slow a search down.++use serde_json::{json, Value};+use std::process::{Command, Stdio};+use std::sync::atomic::{AtomicBool, Ordering};+use std::sync::Arc;+use std::time::{Duration, Instant};++const AI_THREAD: &str = "Parts Search";+const WINDOW_TITLE: &str = "Hydrogen";+const CAP_PERCENT: u64 = 92;++fn timing_path() -> std::path::PathBuf { crate::settings::adom_dir().join("parts-search-timing.json") }++/// Average search duration in ms, recent-weighted. None until measured.+pub fn average_ms() -> Option<u64> {+ let raw = std::fs::read_to_string(timing_path()).ok()?;+ let v: Value = serde_json::from_str(&raw).ok()?;+ v.get("search").and_then(|s| s.get("avg_ms")).and_then(|n| n.as_u64()).filter(|n| *n > 0)+}++pub fn record_ms(ms: u64) {+ let mut v: Value = std::fs::read_to_string(timing_path()).ok()+ .and_then(|r| serde_json::from_str(&r).ok()).unwrap_or(json!({}));+ let prev = v.get("search").and_then(|s| s.get("avg_ms")).and_then(|n| n.as_u64()).unwrap_or(0);+ let runs = v.get("search").and_then(|s| s.get("runs")).and_then(|n| n.as_u64()).unwrap_or(0);+ // Exponential moving average, alpha 0.3: recent runs dominate.+ let avg = if prev == 0 { ms } else { (prev as f64 * 0.7 + ms as f64 * 0.3) as u64 };+ v["search"] = json!({"avg_ms": avg, "runs": runs + 1, "last_ms": ms});+ let _ = std::fs::create_dir_all(crate::settings::adom_dir());+ let _ = std::fs::write(timing_path(), serde_json::to_string_pretty(&v).unwrap_or_default());+}++/// Which box? Settings can pin one; otherwise the first connected target.+/// Resolved per run (cheap, ~10ms) so a box that reconnects is found.+fn resolve_target(pinned: &str) -> Option<String> {+ if !pinned.trim().is_empty() { return Some(pinned.trim().to_string()); }+ let out = Command::new("adom-bridge").arg("targets")+ .stdin(Stdio::null()).output().ok()?;+ let v: Value = serde_json::from_slice(&out.stdout).ok()?;+ v.get("targets")?.as_array()?.first()?.get("name")?.as_str().map(|s| s.to_string())+}++pub fn list_targets() -> Vec<String> {+ let Ok(out) = Command::new("adom-bridge").arg("targets").stdin(Stdio::null()).output() else { return vec![] };+ let Ok(v) = serde_json::from_slice::<Value>(&out.stdout) else { return vec![] };+ v.get("targets").and_then(|t| t.as_array()).map(|a| {+ a.iter().filter_map(|t| t.get("name").and_then(|n| n.as_str()).map(|s| s.to_string())).collect()+ }).unwrap_or_default()+}++fn paint(target: &str, progress: Value) -> bool {+ let args = json!({"titleContains": WINDOW_TITLE, "progress": progress}).to_string();+ let out = Command::new("adom-bridge")+ .args(["--ai-thread", AI_THREAD, "--target", target, "desktop_taskbar", &args])+ .stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null())+ .output();+ match out {+ Ok(o) => serde_json::from_slice::<Value>(&o.stdout).ok()+ .map(|v| v.get("success").and_then(|b| b.as_bool()).unwrap_or(false)).unwrap_or(false),+ Err(_) => false,+ }+}++/// Handle returned to the search; call `finish()` when the search ends.+pub struct Bar { done: Arc<AtomicBool> }++impl Bar {+ pub fn finish(&self, ok: bool) {+ // finish() is idempotent; the worker thread reads the flag.+ let _ = ok;+ self.done.store(true, Ordering::SeqCst);+ }+}++/// Start a bar. `indeterminate` = AI-driven open-ended search.+pub fn start(enabled: bool, pinned_box: &str, indeterminate: bool) -> Option<Bar> {+ if !enabled { return None; }+ let done = Arc::new(AtomicBool::new(false));+ let flag = Arc::clone(&done);+ let pinned = pinned_box.to_string();+ std::thread::spawn(move || {+ let Some(target) = resolve_target(&pinned) else { return };+ if indeterminate {+ if !paint(&target, json!({"state": "indeterminate"})) { return; }+ while !flag.load(Ordering::SeqCst) { std::thread::sleep(Duration::from_millis(150)); }+ paint(&target, json!({"state": "normal", "value": 100}));+ std::thread::sleep(Duration::from_millis(350));+ paint(&target, json!({"state": "none"}));+ return;+ }+ let est = average_ms().unwrap_or(4000).max(800) as f64;+ let t0 = Instant::now();+ if !paint(&target, json!({"state": "normal", "value": 2})) { return; }+ let mut last = 2u64;+ while !flag.load(Ordering::SeqCst) {+ std::thread::sleep(Duration::from_millis(300));+ let pct = ((t0.elapsed().as_millis() as f64 / est) * 100.0) as u64;+ let pct = pct.min(CAP_PERCENT).max(2);+ if pct != last { paint(&target, json!({"state": "normal", "value": pct})); last = pct; }+ }+ paint(&target, json!({"state": "normal", "value": 100}));+ std::thread::sleep(Duration::from_millis(350));+ paint(&target, json!({"state": "none"}));+ });+ Some(Bar { done })+}
src/vendors.rs+87−9@@ -21,6 +21,7 @@ pub const DEFAULT_JLCPCB: &str = "https://jlcpcb-wela51osctvp.adom.cloud"; pub enum Vendor { Mouser, DigiKey, JLCPCB } impl Vendor {+ #[allow(dead_code)] pub fn as_str(&self) -> &'static str { match self { Vendor::Mouser => "mouser", Vendor::DigiKey => "digikey", Vendor::JLCPCB => "jlcpcb" } }@@ -89,6 +90,9 @@ pub struct EmptyState { #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct VendorBlock { pub ok: bool,+ /// True when this vendor was switched off in Settings (no query sent).+ #[serde(default, skip_serializing_if = "std::ops::Not::not")]+ pub skipped: bool, /// Short, human-friendly one-liner. Never contains API keys or URLs. pub error: Option<String>, /// Longer explanation of what happened + what the user can expect.@@ -154,7 +158,7 @@ fn classify_error(vendor: Vendor, raw: &str) -> (String, String, String) { midnight Central Time." ), "DigiKey and JLCPCB results below are still live. If you need Mouser pricing \- before midnight CT, rotate the key or bump the plan — ask an Adom admin.".into(),+ before midnight CT, rotate the key or bump the plan: ask an Adom admin.".into(), ); } @@ -359,6 +363,36 @@ fn normalize_jlcpcb(c: &Value) -> VendorHit { /// Fire three parallel HTTP POSTs to the vendor backends. Returns a /// single unified AggregatedResults.+/// Per-search options that come from Settings (or CLI flags).+#[derive(Debug, Clone)]+pub struct SearchOpts {+ pub mouser: bool,+ pub digikey: bool,+ pub jlcpcb: bool,+ /// "mouser" or "cheapest".+ pub policy: String,+}++impl Default for SearchOpts {+ fn default() -> Self { SearchOpts { mouser: true, digikey: true, jlcpcb: true, policy: "mouser".into() } }+}++impl SearchOpts {+ pub fn from_settings(s: &crate::settings::Settings) -> Self {+ SearchOpts { mouser: s.vendor_mouser, digikey: s.vendor_digikey, jlcpcb: s.vendor_jlcpcb, policy: s.policy.clone() }+ }+}++fn skipped_block(url: String) -> VendorBlock {+ VendorBlock {+ ok: false, skipped: true,+ error: Some("Switched off in Settings".into()),+ error_explanation: Some("This vendor was not queried because its toggle is off in Settings. Nothing is wrong with the backend.".into()),+ error_hint: Some("Open Settings (the cog, upper right) and turn the vendor back on.".into()),+ total: 0, hits: vec![], backend_url: url, elapsed_ms: 0,+ }+}+ pub fn search_all( query: &str, limit: u32,@@ -366,6 +400,7 @@ pub fn search_all( mouser_api: Option<&str>, digikey_api: Option<&str>, jlcpcb_api: Option<&str>,+ opts: &SearchOpts, ) -> AggregatedResults { let m_url = mouser_api.unwrap_or(DEFAULT_MOUSER).to_string(); let d_url = digikey_api.unwrap_or(DEFAULT_DIGIKEY).to_string();@@ -376,7 +411,10 @@ pub fn search_all( let body_common = json!({"keyword": q, "limit": limit, "inStockOnly": in_stock_only}); // Spawn three threads — one per vendor.- for (vendor, url) in [(Vendor::Mouser, m_url.clone()), (Vendor::DigiKey, d_url.clone()), (Vendor::JLCPCB, j_url.clone())] {+ let enabled: Vec<(Vendor, String)> = [(Vendor::Mouser, m_url.clone(), opts.mouser), (Vendor::DigiKey, d_url.clone(), opts.digikey), (Vendor::JLCPCB, j_url.clone(), opts.jlcpcb)]+ .into_iter().filter(|(_, _, on)| *on).map(|(v, u, _)| (v, u)).collect();+ let n_enabled = enabled.len();+ for (vendor, url) in enabled { let tx = tx.clone(); let body = { let mut b = body_common.clone();@@ -390,7 +428,7 @@ pub fn search_all( let result = post_action(&url, body); let elapsed = t0.elapsed().as_millis() as u64; let mut block = VendorBlock {- ok: false, error: None, error_explanation: None, error_hint: None,+ ok: false, skipped: false, error: None, error_explanation: None, error_hint: None, total: 0, hits: vec![], backend_url: url_for_block, elapsed_ms: elapsed, };@@ -442,7 +480,10 @@ pub fn search_all( drop(tx); let mut agg = AggregatedResults { query: query.into(), ..Default::default() };- for _ in 0..3 {+ if !opts.mouser { agg.mouser = skipped_block(m_url.clone()); }+ if !opts.digikey { agg.digikey = skipped_block(d_url.clone()); }+ if !opts.jlcpcb { agg.jlcpcb = skipped_block(j_url.clone()); }+ for _ in 0..n_enabled { match rx.recv() { Ok((Vendor::Mouser, b)) => agg.mouser = b, Ok((Vendor::DigiKey, b)) => agg.digikey = b,@@ -471,7 +512,7 @@ pub fn search_all( } } - agg.recommendation = pick_winner(&agg);+ agg.recommendation = if opts.policy == "cheapest" { pick_cheapest(&agg) } else { pick_winner(&agg) }; if agg.recommendation.is_none() { agg.empty_state = Some(build_empty_state(&agg)); }@@ -483,8 +524,17 @@ fn build_empty_state(agg: &AggregatedResults) -> EmptyState { let m_ok = agg.mouser.ok; let d_ok = agg.digikey.ok; let j_ok = agg.jlcpcb.ok; let m_hits = agg.mouser.hits.len(); let d_hits = agg.digikey.hits.len(); let j_hits = agg.jlcpcb.hits.len(); - // All three errored — service degradation, not a search-miss.- if !m_ok && !d_ok && !j_ok {+ let all_skipped = agg.mouser.skipped && agg.digikey.skipped && agg.jlcpcb.skipped;+ if all_skipped {+ return EmptyState {+ title: "Every vendor is switched off in Settings.".into(),+ explanation: "No query was sent because Mouser, DigiKey, and JLCPCB are all toggled off.".into(),+ hint: "Open Settings (the cog, upper right) and turn at least one vendor on.".into(),+ };+ }+ let m_down = !m_ok && !agg.mouser.skipped; let d_down = !d_ok && !agg.digikey.skipped; let j_down = !j_ok && !agg.jlcpcb.skipped;+ // Every queried vendor errored: service degradation, not a search miss.+ if (m_down || agg.mouser.skipped) && (d_down || agg.digikey.skipped) && (j_down || agg.jlcpcb.skipped) { return EmptyState { title: "All three vendor backends are currently unreachable.".into(), explanation: format!(@@ -501,7 +551,7 @@ fn build_empty_state(agg: &AggregatedResults) -> EmptyState { return EmptyState { title: format!("No matches for \"{q}\" on any vendor."), explanation: "All three distributors returned zero hits. Vendor APIs match on MPN \- and description substrings, not on natural-language intent — so a \+ and description substrings, not on natural-language intent, so a \ typo, a too-broad phrase, or an out-of-family name will produce this \ empty result.".into(), hint: "Check the MPN spelling, try a cleaner keyword (e.g. LM555 instead of \@@ -539,6 +589,34 @@ fn build_empty_state(agg: &AggregatedResults) -> EmptyState { /// for Mouser to return a handful of stock=0 entries before the real /// in-stock variant. Pick the FIRST in-stock hit per vendor, then /// apply the vendor-preference policy on top.+/// Parse "$0.1234" / "0.12 USD" style prices into a float for comparison.+fn price_value(p: &Option<String>) -> Option<f64> {+ let s = p.as_ref()?;+ let cleaned: String = s.chars().filter(|c| c.is_ascii_digit() || *c == '.').collect();+ cleaned.parse::<f64>().ok().filter(|v| *v > 0.0)+}++/// The "cheapest" policy: lowest in-stock unit price across every vendor+/// that answered, ties broken toward Mouser (shipping is still near zero).+fn pick_cheapest(agg: &AggregatedResults) -> Option<Recommendation> {+ let long_lead = |lt: &str| lt.contains("280");+ let mut best: Option<(f64, &VendorHit)> = None;+ for h in agg.mouser.hits.iter().chain(agg.digikey.hits.iter()).chain(agg.jlcpcb.hits.iter()) {+ if h.stock == 0 || long_lead(&h.lead_time) { continue; }+ let Some(v) = price_value(&h.unit_price) else { continue };+ let better = match best { None => true, Some((bv, bh)) => v < bv || (v == bv && h.vendor == Vendor::Mouser && bh.vendor != Vendor::Mouser) };+ if better { best = Some((v, h)); }+ }+ let (_, h) = best?;+ Some(Recommendation {+ winner: h.vendor,+ reason: format!("Lowest in-stock unit price across the vendors that answered ({} at {}). Policy: cheapest, set in Settings.", h.vendor.display(), h.unit_price.clone().unwrap_or_default()),+ mpn: h.mpn.clone(),+ unit_price: h.unit_price.clone(),+ stock: h.stock,+ })+}+ fn pick_winner(agg: &AggregatedResults) -> Option<Recommendation> { let long_lead = |lt: &str| lt.contains("280"); let first_in_stock = |hits: &[VendorHit]| {@@ -549,7 +627,7 @@ fn pick_winner(agg: &AggregatedResults) -> Option<Recommendation> { if let Some(m) = &m { return Some(Recommendation { winner: Vendor::Mouser,- reason: "Mouser stocked — 40-min drone delivery from Mansfield, near-zero shipping.".into(),+ reason: "Mouser has it in stock: 40-minute drone delivery from Mansfield, near-zero shipping.".into(), mpn: m.mpn.clone(), unit_price: m.unit_price.clone(), stock: m.stock,