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.3.0: Dock Bar dashboard contract (adom-ui-design 1.9.x): header grammar, Say it with Send to AI, status pills, demo captions, newbie graduation
11 files changed
+266−191
Cargo.lock+1−1@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-parts-search"-version = "1.2.0"+version = "1.3.0" dependencies = [ "base64", "clap",
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-parts-search"-version = "1.2.0"+version = "1.3.0" edition = "2021" description = "Parts Search Electronics component search — CLI, backend HTTP service, and Hydrogen webview app" license = "MIT"
VERSION+1−1@@ -1 +1 @@-1.2.0+1.3.0
package.json+1−40@@ -38,44 +38,5 @@ "SKILL.md", "BUILD-SKILL.md", "CHANGELOG.md"- ],- "changelog": "Dock Bar dashboard contract (adom-ui-design 1.9.x): header grammar, Say it with Send to AI, status pills, demo captions, newbie graduation",- "discovery_triggers": [- "parts search",- "find part",- "source component",- "source part",- "compare distributors",- "compare vendors",- "Mouser or DigiKey",- "BOM sourcing",- "price this part",- "where can I get",- "check stock",- "parts availability",- "fastest delivery",- "cheapest part",- "component search",- "part lookup",- "price breaks"- ],- "discovery_pitch": "Search Mouser, DigiKey and JLCPCB in parallel with product photos; Mouser-preferred best-source pick. Say 'with parts search' to your AI.",- "sample_prompts": [- {- "label": "source an STM32F103RBT6",- "prompt": "source an STM32F103RBT6"- },- {- "label": "where can I get an ESP32-S3-WROOM cheape",- "prompt": "where can I get an ESP32-S3-WROOM cheapest and fastest"- },- {- "label": "compare Mouser, DigiKey and JLCPCB for a",- "prompt": "compare Mouser, DigiKey and JLCPCB for an LM555"- },- {- "label": "price this BOM across distributors",- "prompt": "price this BOM across distributors"- } ]-}+}
page.json+8−49@@ -33,25 +33,13 @@ }, "hero": { "type": "image",- "path": "screenshots/hero.png"+ "path": "docs/hero.png" }, "sample_prompts": [- {- "label": "source an STM32F103RBT6",- "prompt": "source an STM32F103RBT6"- },- {- "label": "where can I get an ESP32-S3-WROOM cheape",- "prompt": "where can I get an ESP32-S3-WROOM cheapest and fastest"- },- {- "label": "compare Mouser, DigiKey and JLCPCB for a",- "prompt": "compare Mouser, DigiKey and JLCPCB for an LM555"- },- {- "label": "price this BOM across distributors",- "prompt": "price this BOM across distributors"- }+ "source an STM32F103RBT6",+ "where can I get an ESP32-S3-WROOM cheapest and fastest",+ "compare Mouser, DigiKey and JLCPCB for an LM555",+ "price this BOM across distributors" ], "discovery_triggers": [ "parts search",@@ -66,41 +54,12 @@ "where can I get", "check stock", "parts availability",- "fastest delivery",- "cheapest part",- "component search",- "part lookup",- "price breaks"+ "fastest delivery" ],- "discovery_pitch": "Search Mouser, DigiKey and JLCPCB in parallel with product photos; Mouser-preferred best-source pick. Say 'with parts search' to your AI.",+ "discovery_pitch": "Search Mouser + DigiKey + JLCPCB in parallel from one interface, with product photos and a Mouser-preferred best-source pick.", "metadata": { "platform": "linux" }, "category": "tools",- "org": "adom",- "description": "One search box across Mouser, DigiKey and JLCPCB. Parallel queries, side-by-side product photos, and a Mouser-preferred recommendation that reasons around stock and lead time. Dock Bar ready: serve, health, settings, Say it.",- "dependencies": {- "adom/adom-mouser": "*",- "adom/adom-digikey": "*",- "adom/adom-jlcpcb": "*"- },- "needs_sudo": false,- "scripts": {- "install": "./install.sh",- "uninstall": "./uninstall.sh"- },- "files": [- "bin/**",- "skills/**",- "docs/icon.svg",- "dockbar.json",- "install.sh",- "uninstall.sh",- "package.json",- "README.md",- "SKILL.md",- "BUILD-SKILL.md",- "CHANGELOG.md"- ],- "changelog": "Dock Bar dashboard contract (adom-ui-design 1.9.x): header grammar, Say it with Send to AI, status pills, demo captions, newbie graduation"+ "org": "adom" }
publish.sh+5−1@@ -138,7 +138,11 @@ ok "binary uploaded as a release asset" # ── 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"+# The registry can serve a just-superseded listing for a few seconds after a+# publish (seen on 1.1.3 and 1.2.0); fetch, and retry once before failing.+fetch_tarball() { curl -fsSL -H "Authorization: Bearer $TOK" "$REG/api/packages/adom/adom-parts-search/$VERSION/tarball" -o "$CHECK_TGZ"; }+fetch_tarball+if ! tar tzf "$CHECK_TGZ" | grep -q "dockbar\.json"; then sleep 8; fetch_tarball; fi 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"
src/app/audio/demo-2.mp3⋯ 1 unchanged line ⋯
src/app/audio/demo-7.mp3⋯ 1 unchanged line ⋯
src/app/audio/narration.json+2−2@@ -1,10 +1,10 @@ { "1": "Welcome to Parts Search. One search box across Mouser, DigiKey, and JLCPCB: type a part once, and all three distributors answer side by side, with the best source picked for you. This quick demo walks the whole page. Use the controls in the corner to pause, skip, or exit at any time.",- "2": "The fastest way to use Parts Search is to not touch it at all: ask your AI, using the words parts search. Each phrase here copies to your clipboard with one click, ready to paste into any Adom AI thread. Once you know the noun, close the strip with the X, and it stays closed.",+ "2": "The Say it strip. Every phrase has two buttons: Copy puts it on your clipboard, ready to paste into any Adom AI thread, and Send to AI opens a brand new AI thread and sends it for you. Once you know the words parts search, fold the strip with the X; the question mark in the header brings it back.", "3": "This is the search bar. Type a part number or a keyword, pick how many hits you want from each vendor, and flip in stock only when you need parts today. Watch: we will search for an LM555 right now, live, against all three vendors at once.", "4": "Each vendor gets a column: product photos, part numbers, prices at one and at one hundred, live stock with a meter, lead times, and links to the product page, the datasheet, and a 3D model when one exists. A vendor that is down, or switched off in Settings, says so honestly, right in its column.", "5": "Above the columns, one hit is named the best source. Under the default policy, Mouser wins whenever it has stock. Switch to cheapest in stock in Settings, and the lowest in stock unit price wins across every vendor instead. The banner always says which policy chose the winner.", "6": "The cog opens Settings. Every card explains itself in full sentences: search defaults, which vendors to query, the recommendation policy, product photos, and a taskbar progress bar that paints on the Hydrogen button while a search runs. Every row shows its default, and restore defaults takes two clicks, so a stray tap can never wipe your setup.",- "7": "Down here, live health. One LED per vendor backend, read from each service's own health endpoint, and the same lights the Dock Bar card shows. Green means a search will work, before you spend one finding out.",+ "7": "These pills are live health: one per vendor backend, read from each service's own health endpoint, and the same lights the Dock Bar card shows. Tap a pill for the full story; a red one always names its fix. Green means a search will work, before you spend one finding out.", "8": "That is Parts Search. Ask your AI to find the cheapest LM358 with parts search, or just type into the box. Enjoy." }
src/app/mod.rs+73−43@@ -47,7 +47,15 @@ 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";+fn hydrogen_ctl() -> String {+ // The workspace control API URL, per the say-strip skill: the file wins,+ // the mirrored-networking default otherwise.+ let home = std::env::var("HOME").unwrap_or_default();+ std::fs::read_to_string(format!("{}/.adom/hydrogen-control-url", home)).ok()+ .map(|s| s.trim().trim_end_matches('/').to_string())+ .filter(|s| !s.is_empty())+ .unwrap_or_else(|| "http://127.0.0.1:47084".into())+} struct AppState { port: u16,@@ -169,6 +177,10 @@ fn build_status(state: &AppState) -> Value { 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 boxes = taskbar::list_targets();+ let pinned = s.taskbar_box.trim().to_string();+ let target_box = if pinned.is_empty() { boxes.first().cloned() } else { Some(pinned) };+ let target_info = json!({"box": target_box, "connected": !boxes.is_empty()}); 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.@@ -184,6 +196,7 @@ fn build_status(state: &AppState) -> Value { "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()},+ "target": target_info, }) } @@ -213,57 +226,64 @@ fn cached_status(state: &Arc<AppState>) -> Value { } // ---- "Try it": stage a phrase into this app's own AI thread (dock guide recipe) ----+#[allow(dead_code)] 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())+ 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) {+ 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)}),+/// Send to AI (John's 2026-08-24 ruling): open a NEW AI thread via the+/// panel's AI-threads API and submit the phrase. New threads ONLY; resuming+/// or typing into existing threads stays parked pending platform+/// certification, so this never calls resume and never targets a census tid.+fn say_send(phrase: &str) -> Value {+ if !say_phrases().iter().any(|p| p == phrase) { return json!({"ok": false, "why": "unknown phrase"}); }+ let created = match hpost("/ai-threads/new", json!({"name": "Parts Search - say it", "tags": [APP_TAG, "dock:adom-parts-search"]})) {+ Ok((code, v)) if (200..300).contains(&code) => v,+ Ok((code, v)) => return json!({"ok": false, "why": format!("the AI panel refused a new thread ({}): {}", code, v)}),+ Err(_) => return json!({"ok": false, "why": "The AI panel is not reachable from this workspace yet. Copied the phrase instead."}), };- 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; }+ let sid = created.get("sid").or_else(|| created.get("id")).cloned().unwrap_or(Value::Null);+ if sid.is_null() { return json!({"ok": false, "why": "the AI panel returned no thread id"}); }+ match hpost("/ai-threads/run-prompt", json!({"sid": sid, "prompt": phrase, "submit": true})) {+ Ok((code, v)) if (200..300).contains(&code) && v.get("ok").and_then(|b| b.as_bool()) != Some(false) => json!({"ok": true, "sid": sid}),+ other => json!({"ok": false, "why": format!("thread created but the prompt was refused: {:?}", other.map(|(c, _)| c))}), }- 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))}),+}++/// One ab caption per demo step, at the bridge DEFAULT size and position+/// (the fleet consistency ruling: never pass size/position). Same id so+/// steps replace each other; exit hides it. Fire-and-forget on a thread.+fn demo_caption(state: &AppState, step: Option<u32>, text: &str) {+ let pinned = state.settings.lock().unwrap().taskbar_box.clone();+ let text = text.to_string();+ let _ = step;+ std::thread::spawn(move || {+ use std::process::{Command, Stdio};+ let target = if pinned.trim().is_empty() { crate::taskbar::list_targets().into_iter().next() } else { Some(pinned.trim().to_string()) };+ let Some(target) = target else { return };+ let args = if text.is_empty() { json!({"action": "hide", "id": "parts-search-demo"}) }+ else { json!({"id": "parts-search-demo", "text": text, "duration": 60000}) };+ let out = Command::new("adom-bridge")+ .args(["--ai-thread", "Parts Search", "--target", &target, "desktop_caption", &args.to_string()])+ .stdin(Stdio::null()).output();+ // Captions are best-effort; log only a real failure so a missing+ // desktop never spams anything.+ if let Ok(o) = &out {+ if !o.status.success() {+ let _ = std::fs::write("/tmp/ps-caption-debug.log",+ format!("caption[{}] exit={} err={}", target, o.status,+ String::from_utf8_lossy(&o.stderr).chars().take(200).collect::<String>()));+ } }- 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."})+ }); } /// Vendor API calls are quota-limited (Mouser's free tier is ~1000/day,@@ -420,13 +440,23 @@ fn handle(mut req: Request, state: Arc<AppState>, no_tab: bool, tab_name: &str) (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") {+ } else if method == Method::Post && ends("/api/say-send") { 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))+ (200, say_send(&phrase)) }- Err(e) => (400, json!({"ok": false, "error": e})),+ Err(e) => (400, json!({"ok": false, "why": e})),+ }+ } else if method == Method::Post && ends("/api/demo-step") {+ match read_body(&mut req) {+ Ok(v) => {+ let caption = v.get("caption").and_then(|c| c.as_str()).unwrap_or("").to_string();+ let step = v.get("step").and_then(|n| n.as_u64()).map(|n| n as u32);+ demo_caption(&state, step, &caption);+ (200, json!({"ok": true}))+ }+ Err(e) => (400, json!({"ok": false, "why": e})), } } else if method == Method::Get && ends("/api/settings") { let s = state.settings.lock().unwrap().clone();@@ -539,7 +569,7 @@ fn handle(mut req: Request, state: Arc<AppState>, no_tab: bool, tab_name: &str) 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"}))+ (404, json!({"error": format!("unknown endpoint: {} {}", method, path), "_hint": "GET /state, /api/status, /api/settings, /console; POST /search, /api/toast, /api/click, /api/say-send, /api/demo-step, /api/settings"})) }; let _ = req.respond(json_resp(result.0, result.1)); }
src/app/ui.html+174−53@@ -41,7 +41,7 @@ button{font-family:inherit} .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-retired{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} @@ -53,6 +53,9 @@ button{font-family:inherit} .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}+.target-chip{display:inline-flex;align-items:center;gap:6px;font:11.5px var(--mono);color:var(--text-2);background:var(--elevated);border:1px solid var(--border);border-radius:999px;padding:3px 10px;white-space:nowrap;flex:none}+.target-chip .tdot{width:7px;height:7px;border-radius:50%;background:var(--text-3)}+.target-chip.connected .tdot{background:var(--green)} .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)}@@ -84,7 +87,40 @@ main{max-width:1320px;margin:0 auto;padding:16px 16px 40px} .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-list{display:flex;flex-wrap:wrap;gap:6px}+.say-head{position:relative}+.demo-btn{margin-top:9px;font-size:13px;min-height:34px;gap:7px}+.say-chip{display:inline-flex;align-items:center;gap:8px;background:var(--elevated);border:1px solid var(--border);border-radius:999px;padding:5px 6px 5px 12px;font:400 13.5px var(--brand-body);color:var(--text);margin:2px 0;cursor:pointer;transition:border-color .15s ease,background .15s ease}+.say-chip:hover{border-color:var(--accent)}+.say-chip.copied{border-color:var(--green)}+.say-chip em{color:var(--accent);font-style:normal}+.say-chip .copy,.say-chip .send{font:500 11px var(--brand-heading);background:none;border:1px solid var(--border);border-radius:999px;padding:3px 9px;min-height:24px;cursor:pointer;transition:color .15s ease,border-color .15s ease,background .15s ease,transform .06s ease}+.say-chip .copy{color:var(--text-2)}+.say-chip .send{color:var(--accent);border-color:rgba(0,184,177,.45)}+.say-chip:hover .copy{color:var(--accent);border-color:var(--accent)}+.say-chip .send:hover{background:rgba(0,184,177,.12)}+.say-chip .copy:active,.say-chip .send:active{transform:translateY(1px) scale(.96)}+.say-chip .copy:focus-visible,.say-chip .send:focus-visible{outline:2px solid var(--accent);outline-offset:1px}+.say-folded{display:flex;align-items:center;gap:12px;background:var(--surface);border:1px solid var(--border);border-left:3px solid color-mix(in srgb,var(--accent) 45%,transparent);border-radius:10px;padding:7px 14px;margin-bottom:12px;font:13px var(--brand-body);color:var(--text-2)}+.say-folded[hidden]{display:none}+.sf-label b{color:var(--text);font:600 13px var(--brand-heading)}+.sf-link{background:none;border:none;color:var(--accent);font:500 12.5px var(--brand-body);cursor:pointer;padding:4px 6px;border-radius:6px;transition:background .15s ease}+.sf-link:hover{background:var(--elevated)}+.leds{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:14px}+.led-pill{display:inline-flex;align-items:center;gap:7px;background:var(--surface);border:1px solid var(--border);border-radius:999px;padding:4px 12px;font:12.5px var(--brand-body);color:var(--text-body);cursor:pointer;transition:border-color .15s ease,background .15s ease,transform .06s ease}+.led-pill:hover{border-color:#4b5563;background:var(--elevated)}+.led-pill:active{transform:translateY(1px) scale(.98)}+.led-pill:focus-visible{outline:2px solid var(--accent);outline-offset:2px}+.led-pill .dot{width:8px;height:8px;border-radius:50%;background:var(--text-3);flex:none}+.led-pill[data-state=ok] .dot{background:var(--green);box-shadow:0 0 6px rgba(63,185,80,.5)}+.led-pill[data-state=busy] .dot{background:var(--yellow)}+.led-pill[data-state=bad] .dot{background:var(--red)}+.led-pill .st{color:var(--text-2)}+.led-pop{position:fixed;z-index:var(--z-floating-menu);max-width:min(360px,calc(100vw - 24px));background:var(--overlay);border:1px solid var(--border);border-radius:10px;padding:11px 14px;box-shadow:0 12px 40px rgba(0,0,0,.55);font:12.5px/1.55 var(--brand-body);color:var(--text-body);display:none}+.led-pop.on{display:block}+.led-pop b{color:var(--text)}+.led-pop .next{margin-top:6px;padding-top:6px;border-top:1px dashed var(--border);color:var(--text-2)}+.led-pop .next b{color:var(--accent)} .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)}@@ -312,7 +348,7 @@ select.sel:disabled{opacity:.5;cursor:not-allowed} /* ---- Guided demo: spotlight + transport floaty ---- */ #demoSpot{position:fixed;z-index:calc(var(--z-toolbar) + 5);pointer-events:none;border:2px solid var(--accent);border-radius:12px;box-shadow:0 0 0 4px rgba(0,184,177,.25),0 0 0 9999px rgba(6,9,14,.5);transition:all .35s ease;display:none} #demoSpot.on{display:block}-#demoHud{position:fixed;right:22px;bottom:44px;z-index:var(--z-floating-menu);width:264px;background:var(--overlay);border:1px solid var(--border);border-radius:12px;box-shadow:0 18px 50px rgba(0,0,0,.55);display:none;user-select:none}+#demoHud{position:fixed;left:50%;transform:translateX(-50%);bottom:26px;z-index:var(--z-floating-menu);width:264px;background:var(--overlay);border:1px solid var(--border);border-radius:12px;box-shadow:0 18px 50px rgba(0,0,0,.55);display:none;user-select:none} #demoHud.on{display:block} .dh-head{display:flex;align-items:center;gap:8px;padding:8px 10px 6px 12px;cursor:move;border-bottom:1px solid var(--border)} .dh-title{font:600 12.5px var(--brand-heading);color:var(--text);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}@@ -328,7 +364,7 @@ select.sel:disabled{opacity:.5;cursor:not-allowed} .dh-controls .iconbtn.play{background:var(--accent);color:var(--on-accent)} .dh-controls .iconbtn.play:hover{background:var(--accent-hover)} .dh-controls .iconbtn.exit:hover{color:var(--red);border-color:rgba(248,81,73,.5)}-@media (max-width:720px){#demoHud{right:10px;left:10px;bottom:36px;width:auto}}+@media (max-width:720px){#demoHud{left:10px;right:10px;transform:none;bottom:30px;width:auto}} /* ---- Responsive: reshape, do not shrink ---- */ @media (max-width:720px){@@ -376,23 +412,30 @@ select.sel:disabled{opacity:.5;cursor:not-allowed} <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>+ <span class="target-chip" id="targetChip" hidden data-tooltip="Which Adom Desktop box this app paints taskbar progress and demo captions on. Change it under Settings, Taskbar."><span class="tdot" id="targetDot"></span>target: <span id="targetName"></span></span> <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="btnDemo" aria-label="Run the demo" data-tooltip="A two-minute narrated walk through the whole page: the Say it strip, a live search, the columns, the best-source banner, Settings, and the health lights. A floaty gives you pause, next, previous, and exit."><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M10 8.5v7l5.5-3.5z"/></svg></button>- <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>+ <button class="iconbtn" id="btnHelp" aria-label="Show the getting-started helpers" data-tooltip="Bring back the newbie helpers (the Say it strip, examples, and the demo button) for this session, even after they have folded away."><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M9.2 9a2.9 2.9 0 0 1 5.6 1c0 1.8-2.6 2.2-2.6 3.6"/><path d="M12 17h.01"/></svg></button>+ <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,8A4,4 0 0,1 16,12A4,4 0 0,1 12,16A4,4 0 0,1 8,12A4,4 0 0,1 12,8M12,10A2,2 0 0,0 10,12A2,2 0 0,0 12,14A2,2 0 0,0 14,12A2,2 0 0,0 12,10M10,22C9.75,22 9.54,21.82 9.5,21.58L9.13,18.93C8.5,18.68 7.96,18.34 7.44,17.94L4.95,18.95C4.73,19.03 4.46,18.95 4.34,18.73L2.34,15.27C2.21,15.05 2.27,14.78 2.46,14.63L4.57,12.97L4.5,12L4.57,11L2.46,9.37C2.27,9.22 2.21,8.95 2.34,8.73L4.34,5.27C4.46,5.05 4.73,4.96 4.95,5.05L7.44,6.05C7.96,5.66 8.5,5.32 9.13,5.07L9.5,2.42C9.54,2.18 9.75,2 10,2H14C14.25,2 14.46,2.18 14.5,2.42L14.87,5.07C15.5,5.32 16.04,5.66 16.56,6.05L19.05,5.05C19.27,4.96 19.54,5.05 19.66,5.27L21.66,8.73C21.79,8.95 21.73,9.22 21.54,9.37L19.43,11L19.5,12L19.43,12.97L21.54,14.63C21.73,14.78 21.79,15.05 21.66,15.27L19.66,18.73C19.54,18.95 19.27,19.04 19.05,18.95L16.56,17.95C16.04,18.34 15.5,18.68 14.87,18.93L14.5,21.58C14.46,21.82 14.25,22 14,22H10M11.25,4L10.88,6.61C9.68,6.86 8.62,7.5 7.85,8.39L5.44,7.35L4.69,8.65L6.8,10.2C6.4,11.37 6.4,12.64 6.8,13.8L4.68,15.36L5.43,16.66L7.86,15.62C8.63,16.5 9.68,17.14 10.87,17.38L11.24,20H12.76L13.13,17.39C14.32,17.14 15.37,16.5 16.14,15.62L18.57,16.66L19.32,15.36L17.2,13.81C17.6,12.64 17.6,11.37 17.2,10.2L19.31,8.65L18.56,7.35L16.15,8.39C15.38,7.5 14.32,6.86 13.12,6.62L12.75,4H11.25Z"/></svg></button> </div> </header> <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>+ <button class="iconbtn say-close" id="sayClose" aria-label="Collapse the Say it strip" data-tooltip="Fold this strip to a single line. The question mark in the header brings it back any time."><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, then paste it into any Adom AI thread.</span>+ <h2 id="sayTitle">Say it</h2>+ <span class="say-sub">You never need this page's buttons: just tell your AI what you want, using the words <b>parts search</b>. Copy a phrase, or send it straight to a new AI thread.</span>+ <button class="btn demo-btn" id="btnDemo" data-tooltip="A two-minute narrated walk through the whole page, with captions on your desktop and a play / pause / next / previous / exit toolbar."><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="width:15px;height:15px"><circle cx="12" cy="12" r="9"/><path d="M10 8.5v7l5.5-3.5z"/></svg>Play the 2-minute demo</button> </div> <div class="say-list" id="sayList"></div> </section>+ <div class="say-folded" id="sayFolded" hidden>+ <span class="sf-label"><b>Say it</b><span id="sfCount"></span></span>+ <button class="sf-link" id="sfExpand" data-tooltip="Show the full phrase list once. The question mark in the header brings everything back for a whole session.">show phrases</button>+ <button class="sf-link" id="sfDemo" data-tooltip="Run the narrated two-minute demo.">play the demo</button>+ </div>+ <div class="leds" id="ledRow" aria-label="Live health"></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>@@ -433,13 +476,6 @@ select.sel:disabled{opacity:.5;cursor:not-allowed} </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="demoSpot" aria-hidden="true"></div>@@ -455,6 +491,7 @@ select.sel:disabled{opacity:.5;cursor:not-allowed} <button class="iconbtn" id="dhPrev" aria-label="Previous step" data-tooltip="Back one step (Left arrow)."><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 6l-6 6 6 6"/></svg></button> <button class="iconbtn play" id="dhPlay" aria-label="Pause" data-tooltip="Pause or resume the narration and the auto-advance (Space)."><svg id="dhPlayIcon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 6h3v12H8zM13 6h3v12h-3z"/></svg></button> <button class="iconbtn" id="dhNext" aria-label="Next step" data-tooltip="Skip ahead one step (Right arrow)."><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg></button>+ <button class="iconbtn" id="dhMute" aria-label="Mute the narration" data-tooltip="Mute or unmute the narration. The demo keeps advancing either way, and your choice is remembered."><svg id="dhMuteIcon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 5 6.5 9H3v6h3.5L11 19zM15.5 8.5a5 5 0 0 1 0 7M18.4 6a9 9 0 0 1 0 12"/></svg></button> <button class="iconbtn exit" id="dhExit" aria-label="Exit the demo" data-tooltip="Stop the demo and put everything back (Escape)."><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>@@ -504,7 +541,7 @@ select.sel:disabled{opacity:.5;cursor:not-allowed} <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>+ <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: the X folds the strip to a one-line reminder, three green visits fold the examples, and the question mark in the header brings everything back for a session. This switch removes the strip entirely, folded line included.</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">@@ -703,18 +740,41 @@ window.__tooltipAssert=function(){const r=tip.getBoundingClientRect();return r.l b.addEventListener('click',e=>{if(e.target.closest('a'))return;p.classList.toggle('open');}); })(); -// ---- Say it strip ----+// ---- Newbie graduation (dock contract): Fresh / Graduated / Recallable ----+const GRAD_KEY='adom-dash-graduation';+function gradState(){try{return JSON.parse(localStorage.getItem(GRAD_KEY))??{}}catch{return {}}}+function gradSave(patch){try{localStorage.setItem(GRAD_KEY,JSON.stringify({...gradState(),...patch}))}catch{}}+let recallSession=false;try{recallSession=sessionStorage.getItem('adom-dash-recall')==='1';}catch{}+function sayGraduated(){const g=gradState();return !recallSession&&(g.dismissed?.say||g.aiDriven||(g.launches||0)>=5);}+function scaffoldGraduated(){const g=gradState();return !recallSession&&(g.dismissed?.say||g.aiDriven||(g.launches||0)>=3);}+function paintGraduation(){+ const show=settings.showSayStrip;+ const folded=sayGraduated();+ $('#say').hidden=!show||folded||!SAY.length;+ $('#sayFolded').hidden=!show||!folded;+ $('#sfCount').textContent=SAY.length?' - '+SAY.length+' phrases':'';+ $('#examples').style.display=scaffoldGraduated()?'none':'';+}+$('#btnHelp').addEventListener('click',()=>{recallSession=true;try{sessionStorage.setItem('adom-dash-recall','1');}catch{}paintGraduation();toast('Helpers are back for this session. They fold again on the next visit.','info');});+$('#sfExpand').addEventListener('click',()=>{$('#sayFolded').hidden=true;$('#say').hidden=false;});+let launchCounted=false;+function countGreenLaunch(state){+ if(launchCounted||performance.now()>60000)return;+ if(state!=='ok')return;+ launchCounted=true;const g=gradState();gradSave({launches:(g.launches||0)+1});+}++// ---- Say it strip: Copy + Send to AI on every phrase (say-strip skill) ---- 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="Copy this phrase to the clipboard, 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="Copy this phrase to the clipboard, ready to paste into any AI thread. Nothing is sent anywhere.">Try it</button></span>').join('');+ if(JSON.stringify(list)===JSON.stringify(SAY)&&$('#sayList').children.length){paintGraduation();return;}+ SAY=list;const host=$('#sayList');+ host.innerHTML=SAY.map((p,i)=>'<span class="say-chip" id="say-chip-'+i+'" data-i="'+i+'" data-tooltip="Click anywhere on the phrase to copy it.">“<span class="txt">'+escape(p).replace(/parts search/i,'<em>parts search</em>')+'</span>”'+ +'<button class="copy" id="say-copy-'+i+'" data-i="'+i+'" data-tooltip="Copy this phrase to the clipboard, ready to paste into any AI thread.">Copy</button>'+ +'<button class="send" id="say-try-'+i+'" data-i="'+i+'" data-tooltip="Open a NEW AI thread and send this phrase to it. Existing threads are never touched.">Send to AI</button></span>').join('');+ paintGraduation(); } function copyText(t){- // The async clipboard API can hang forever in an unfocused window- // ("document is not focused"), so: no focus = go straight to the- // execCommand fallback while the click gesture is still live, and a- // focused writeText still races an 800ms timer as a belt and braces. const fallback=()=>{try{const ta=document.createElement('textarea');ta.value=t;ta.style.cssText='position:fixed;opacity:0';document.body.appendChild(ta);ta.select();const ok=document.execCommand('copy');ta.remove();return ok;}catch{return false;}}; return new Promise(res=>{ if(!navigator.clipboard||!document.hasFocus()){res(fallback());return;}@@ -725,17 +785,67 @@ function copyText(t){ ()=>{if(!done){done=true;clearTimeout(timer);res(fallback());}}); }); }-$('#sayList').addEventListener('click',async e=>{- const ph=e.target.closest('.say-phrase'),tr=e.target.closest('.say-try');- if(!ph&&!tr)return;- const item=(ph||tr).closest('.say-item');- const p=SAY[+(ph||tr).dataset.i];+async function copyPhrase(chip,quiet){+ const p=SAY[+chip.dataset.i]; const ok=await copyText(p);- const phEl=item.querySelector('.say-phrase');- item.classList.add('copied');const old=phEl.innerHTML;phEl.innerHTML='<span class="txt">copied</span>';setTimeout(()=>{item.classList.remove('copied');phEl.innerHTML=old;},1200);- if(!ok){toast('Could not reach the clipboard. Select the phrase and copy it by hand.','error');return;}- toast(tr?'Copied to clipboard. Paste it into any AI thread.':'Copied: '+p,'success');+ chip.classList.add('copied');setTimeout(()=>chip.classList.remove('copied'),1200);+ if(!ok){toast('Could not reach the clipboard. Select the phrase and copy it by hand.','error');return false;}+ if(!quiet)toast('Copied to clipboard. Paste it into any AI thread.','success');+ return true;+}+$('#sayList').addEventListener('click',async e=>{+ const chip=e.target.closest('.say-chip');if(!chip)return;+ const send=e.target.closest('.send');+ if(send){+ send.disabled=true;const old=send.textContent;send.textContent='Sending';+ let r=null;+ try{r=await fetch('api/say-send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({phrase:SAY[+chip.dataset.i]})}).then(x=>x.json());}catch{}+ send.disabled=false;send.textContent=old;+ if(r&&r.ok){toast('Sent to a new AI thread.','success');}+ else{await copyPhrase(chip,true);toast((r&&r.why)||'Could not reach the AI panel; copied the phrase instead.','error');}+ return;+ }+ await copyPhrase(chip,false);+});+$('#sayClose').addEventListener('click',e=>{e.stopPropagation();gradSave({dismissed:{...(gradState().dismissed||{}),say:true}});recallSession=false;try{sessionStorage.removeItem('adom-dash-recall');}catch{}paintGraduation();toast('Folded. The question mark in the header brings the helpers back.','success');});++// ---- Status pill row (status-leds skill): pills at rest, prose on tap ----+const ledPop=document.createElement('div');ledPop.className='led-pop';document.body.appendChild(ledPop);+let busySince={};+function pillState(led){+ const st=led.state==='ok'?'ok':led.state==='fail'?'bad':'busy';+ return st;+}+function shortState(led){+ const d=led.detail||'';+ if(led.state==='ok')return (d.split(';')[0]||'ok').replace('reachable, ','').replace('reachable','ok');+ if(led.state==='fail')return 'down';+ return (d.split(';')[0]||'degraded').slice(0,28);+}+function renderPills(status){+ const host=$('#ledRow');if(!status||!status.leds){host.innerHTML='';return;}+ const now=Date.now();+ host.innerHTML=status.leds.map((l,i)=>{+ let st=pillState(l),label=l.label,short=shortState(l);+ if(label==='Search'){+ if(status.searching){st='busy';short='searching';busySince.Search=busySince.Search||now;+ if(now-busySince.Search>15*60000)short='stuck?';}+ else{busySince.Search=0;short='idle';}+ }+ return '<button class="led-pill" data-state="'+st+'" data-i="'+i+'" aria-label="'+escape(label)+' status"><span class="dot"></span>'+escape(label)+' <span class="st">'+escape(short)+'</span></button>';+ }).join('');+}+$('#ledRow').addEventListener('click',e=>{+ const pill=e.target.closest('.led-pill');if(!pill||!lastStatus)return;+ const led=lastStatus.leds[+pill.dataset.i];if(!led)return;+ const bad=led.state!=='ok';+ ledPop.innerHTML='<b>'+escape(led.label)+'</b>: '+escape(led.detail||'')+(led.label==='Search'&&avgMs?' Searches average '+fmtMs(avgMs)+'.':'')+(bad&&led._hint?'<div class="next"><b>Next:</b> '+escape(led._hint)+'</div>':'');+ const r=pill.getBoundingClientRect();ledPop.classList.add('on');+ const w=ledPop.offsetWidth,h=ledPop.offsetHeight;+ ledPop.style.left=Math.max(8,Math.min(r.left,innerWidth-w-8))+'px';+ ledPop.style.top=Math.max(8,Math.min(r.bottom+8,innerHeight-h-8))+'px'; });+document.addEventListener('click',e=>{if(!e.target.closest('.led-pill')&&!e.target.closest('.led-pop'))ledPop.classList.remove('on');},true); // ---- Rendering ---- function renderCard(hit){@@ -803,7 +913,7 @@ function renderResults(r){ 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);+ if(lastStatus)renderPills(lastStatus); 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);@@ -859,7 +969,7 @@ 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':'');+ paintGraduation();$('#stSay').textContent=settings.showSayStrip?(sayGraduated()?'folded':'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;}@@ -914,10 +1024,12 @@ function paintVendorStatus(force){ } 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):'';+ renderPills(s);+ countGreenLaunch(s.state);+ const t=s.target||{};const chip=$('#targetChip');+ if(t.box){chip.hidden=false;$('#targetName').textContent=t.box;chip.classList.toggle('connected',!!t.connected);}+ else chip.hidden=true;+ $('#popVersion').textContent='v'+s.version+(s.uptime_s!=null?', up '+Math.round(s.uptime_s/60)+' min':''); paintVendorStatus();if(dlg.open)paintSettings(); }catch{} }@@ -940,6 +1052,7 @@ async function pollState(){ 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.last_results&&v.last_results.source==='ai'&&!gradState().aiDriven)gradSave({aiDriven:true}); 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;@@ -950,17 +1063,20 @@ async function pollState(){ // ---- Guided demo: pre-rendered narration, spotlight, transport floaty ---- const demo=(()=>{ const audio=$('#demoAudio'),hud=$('#demoHud'),spot=$('#demoSpot');+ let muted=false;try{muted=localStorage.getItem('psDemoMuted')==='1';}catch{}+ function paintMute(){audio.muted=muted;$('#dhMuteIcon').innerHTML=muted?'<path d="M11 5 6.5 9H3v6h3.5L11 19zM16 9.5l5 5M21 9.5l-5 5"/>':'<path d="M11 5 6.5 9H3v6h3.5L11 19zM15.5 8.5a5 5 0 0 1 0 7M18.4 6a9 9 0 0 1 0 12"/>';}+ function sendCaption(step,text){fetch('api/demo-step',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({step,caption:text||''})}).catch(()=>{});} const STEPS=[- {name:'Welcome',target:'.app-header'},- {name:'Say it to your AI',target:'#say',enter(){if($('#say').hidden){demoState.stripWasHidden=true;$('#say').hidden=false;}},leave(){if(demoState.stripWasHidden){$('#say').hidden=true;demoState.stripWasHidden=false;}}},- {name:'The search bar',target:'.searchwrap',enter(){if(!searching&&$('#subject').textContent!=='LM555'){$('#q').value='LM555';doSearch('LM555');}}},- {name:'Three vendor columns',target:'#grid'},- {name:'The best source',target:'#recommendation',fallback:'#grid'},- {name:'Settings',target:'#dlgSettings',enter(){const d=$('#dlgSettings');if(!d.open){demoState.openedSettings=true;d.style.position='fixed';d.style.margin='0';d.style.left='50%';d.style.top='50%';d.style.transform='translate(-50%,-50%)';d.show();$('#settingsBody').scrollTop=0;}},leave(){const d=$('#dlgSettings');if(demoState.openedSettings&&d.open){d.close();d.style.cssText='';demoState.openedSettings=false;}}},- {name:'Live health',target:'#appFooter'},- {name:'That is Parts Search',target:'.app-header'},+ {name:'Welcome',target:'.app-header',caption:'Parts Search: one query across Mouser, DigiKey, and JLCPCB.',min:4000},+ {name:'Say it',target:'#say',caption:'The Say it strip: copy a phrase, or send it straight to a new AI thread.',min:4000,enter(){if($('#say').hidden){demoState.stripWasHidden=true;$('#say').hidden=false;$('#sayFolded').hidden=true;}},leave(){if(demoState.stripWasHidden){demoState.stripWasHidden=false;paintGraduation();}}},+ {name:'The search bar',target:'.searchwrap',caption:'Searching for an LM555, live, across all three vendors at once.',min:4000,enter(){if(!searching&&$('#subject').textContent!=='LM555'){$('#q').value='LM555';doSearch('LM555');}}},+ {name:'Three vendor columns',target:'#grid',caption:'One column per vendor: photos, prices, stock, datasheets, and 3D models.',min:4000},+ {name:'The best source',target:'#recommendation',fallback:'#grid',caption:'One hit is named the best source, and the banner says which policy chose it.',min:4000},+ {name:'Settings',target:'#dlgSettings',caption:'Settings: every card explains itself, and restore defaults takes two clicks.',min:4000,enter(){const d=$('#dlgSettings');if(!d.open){demoState.openedSettings=true;d.style.position='fixed';d.style.margin='0';d.style.left='50%';d.style.top='50%';d.style.transform='translate(-50%,-50%)';d.show();$('#settingsBody').scrollTop=0;}},leave(){const d=$('#dlgSettings');if(demoState.openedSettings&&d.open){d.close();d.style.cssText='';demoState.openedSettings=false;}}},+ {name:'Live health',target:'#ledRow',caption:'Live health pills: one per vendor backend, the same lights the Dock Bar reads.',min:4000},+ {name:'That is Parts Search',target:'.app-header',caption:'That is Parts Search. Ask your AI, or just type into the box.',min:3000}, ];- const demoState={active:false,step:-1,playing:true,stripWasHidden:false,openedSettings:false,raf:0};+ const demoState={active:false,step:-1,playing:true,stripWasHidden:false,openedSettings:false,raf:0,stepAt:0}; function paintSpot(){ if(!demoState.active)return; const st=STEPS[demoState.step];@@ -988,14 +1104,16 @@ const demo=(()=>{ const el=document.querySelector(st.target); if(el&&st.target!=='#dlgSettings'&&st.target!=='.app-header'&&st.target!=='#appFooter')el.scrollIntoView({block:'center',behavior:'smooth'}); if(st.target==='.app-header')$('#appBody').scrollTo({top:0,behavior:'smooth'});+ demoState.stepAt=Date.now();+ sendCaption(n+1,st.caption); audio.src='audio/demo-'+(n+1)+'.mp3';- if(demoState.playing)audio.play().catch(()=>{demoState.playing=false;paintHud();toast('Press Play on the demo floaty to hear the narration (browsers unlock audio on a click).','info');});+ if(demoState.playing)audio.play().catch(()=>{demoState.playing=false;paintHud();toast('Press Play on the demo toolbar to hear the narration (browsers unlock audio on a click).','info');}); paintHud(); }- audio.addEventListener('ended',()=>{if(demoState.active&&demoState.playing)setTimeout(()=>{if(demoState.active&&demoState.playing)goto(demoState.step+1);},500);});+ audio.addEventListener('ended',()=>{if(!demoState.active||!demoState.playing)return;const st=STEPS[demoState.step]||{};const wait=Math.max(500,(st.min||0)-(Date.now()-demoState.stepAt));setTimeout(()=>{if(demoState.active&&demoState.playing)goto(demoState.step+1);},wait);}); function start(){ if(demoState.active)return;- demoState.active=true;demoState.playing=true;hud.classList.add('on');+ demoState.active=true;demoState.playing=true;hud.classList.add('on');paintMute(); paintSpot();goto(0); console.log('demo started'); }@@ -1005,11 +1123,13 @@ const demo=(()=>{ demoState.active=false;demoState.step=-1;cancelAnimationFrame(demoState.raf); hud.classList.remove('on');spot.classList.remove('on'); audio.pause();audio.removeAttribute('src');+ sendCaption(null,''); console.log('demo exited'); } $('#dhNext').addEventListener('click',()=>goto(demoState.step+1)); $('#dhPrev').addEventListener('click',()=>goto(demoState.step-1)); $('#dhExit').addEventListener('click',exit);+ $('#dhMute').addEventListener('click',()=>{muted=!muted;try{localStorage.setItem('psDemoMuted',muted?'1':'0');}catch{}paintMute();}); $('#dhPlay').addEventListener('click',()=>{demoState.playing=!demoState.playing;if(demoState.playing){(audio.src?audio.play():Promise.reject()).catch(()=>goto(demoState.step));}else audio.pause();paintHud();}); document.addEventListener('keydown',e=>{if(!demoState.active)return;if(e.key==='Escape'&&!$('#dlgSettings').open){exit();}else if(e.key==='ArrowRight'){goto(demoState.step+1);}else if(e.key==='ArrowLeft'){goto(demoState.step-1);}else if(e.key===' '&&!e.target.closest('input,select')){e.preventDefault();$('#dhPlay').click();}}); (function(){const head=$('#dhHead');let drag=null;@@ -1020,6 +1140,7 @@ const demo=(()=>{ return {start,exit,next:()=>goto(demoState.step+1),prev:()=>goto(demoState.step-1),toggle:()=>$('#dhPlay').click(),active:()=>demoState.active}; })(); $('#btnDemo').addEventListener('click',()=>demo.start());+$('#sfDemo').addEventListener('click',()=>demo.start()); (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);