← Commit history

v1.4.0: The full 1.22.1 visual contract, in-page demo captions, and two launch-path fixes

John Lauer ·e48a29dc64 ·1mo ago ·parent 8966037
9 files changed +151−186
Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-parts-search"-version = "1.3.5"+version = "1.4.0" dependencies = [  "base64",  "clap",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-parts-search"-version = "1.3.5"+version = "1.4.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.3.5+1.4.0
package.json+1−40
@@ -38,44 +38,5 @@     "SKILL.md",     "BUILD-SKILL.md",     "CHANGELOG.md"-  ],-  "changelog": "The full 1.22.1 visual contract, in-page demo captions, and two launch-path fixes",-  "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"-    }   ]-}\ No newline at end of file+}
page.json+7−48
@@ -36,22 +36,10 @@     "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": "The full 1.22.1 visual contract, in-page demo captions, and two launch-path fixes"+  "org": "adom" }\ No newline at end of file
publish.sh+10−7
@@ -151,19 +151,22 @@ fetch_tarball() { curl -fsSL -H "Authorization: Bearer $TOK" "$REG/api/packages/ # listing for well over a minute after a SUCCESSFUL publish. Poll up to 3 # minutes, and if it still looks wrong, say exactly what we saw rather than # asserting a failure the publish did not actually have.+# ADVISORY, not fatal. This check has now produced five false failures on five+# SUCCESSFUL publishes (1.1.3, 1.2.0, 1.3.2, 1.3.3, 1.3.5): the registry can+# serve a stale listing for minutes after the publish it just accepted, and a+# hard failure here aborts the page-repo push below, which then has to be run+# by hand. Publish success is already asserted by step 4; this is a smoke test. VERIFY_OK=""-for _try in $(seq 1 18); do+for _try in 1 2 3 4 5 6; do   if fetch_tarball && tar tzf "$CHECK_TGZ" 2>/dev/null | grep -q "dockbar\.json"; then VERIFY_OK=1; break; fi   sleep 10 done if [ -z "$VERIFY_OK" ]; then-  echo "--- registry still not serving a complete $VERSION tarball after 3 minutes."-  echo "--- last fetch: $(curl -s -o /dev/null -w 'http %{http_code}, %{size_download} bytes' -H "Authorization: Bearer $TOK" "$REG/api/packages/adom/adom-parts-search/$VERSION/tarball")"-  echo "--- members seen: $(tar tzf "$CHECK_TGZ" 2>/dev/null | tr '\n' ' ' | cut -c1-200)"-  fail "tarball verify never passed; the publish itself may still be fine, re-check with: curl -H \"Authorization: Bearer \$TOK\" $REG/api/packages/adom/adom-parts-search/$VERSION/tarball | tar tz"+  printf '%sWARN:%s registry has not served a complete %s tarball yet (it usually catches up within a few minutes).\n' "$C_ERR" "$C_RESET" "$VERSION"+  echo "      re-check: curl -H \"Authorization: Bearer \$TOK\" $REG/api/packages/adom/adom-parts-search/$VERSION/tarball | tar tz | grep dockbar"+  echo "      continuing: the publish itself succeeded above." 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+if [ -n "$VERIFY_OK" ] && tar tzf "$CHECK_TGZ" | grep -q "page\.json"; then rm -f "$CHECK_TGZ"; fail "published tarball still contains page.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"
src/app/mod.rs+11−6
@@ -135,7 +135,7 @@ fn backends(state: &AppState) -> (String, String, String) { /// 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",+        return json!({"label": label, "state": "warn", "short": "off", "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();@@ -147,13 +147,15 @@ fn probe_backend(label: &str, url: &str, enabled: bool) -> Value {             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" };+            let mut quota_hit = false;             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);+                quota_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"); }+                if quota_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"})+            let short = if st == "ok" { format!("{} ms", ms) } else if quota_hit { "quota hit".to_string() } else { "degraded".to_string() };+            json!({"label": label, "state": st, "short": short, "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())}),@@ -174,7 +176,8 @@ fn build_status(state: &AppState) -> Value {     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" },+    leds.push(json!({"label": "Search", "state": "ok",+        "short": match &searching { Some(_) => "searching".to_string(), None => "idle".to_string() },         "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();@@ -467,7 +470,9 @@ fn handle(mut req: Request, state: Arc<AppState>, no_tab: bool, tab_name: &str)     }      let result: (u16, Value) = if method == Method::Get && (path == "/" || ends("/index.html")) {-        let _ = req.respond(text_resp(UI_HTML, "text/html; charset=utf-8"));+        let _ = req.respond(with_cors(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-store, must-revalidate").unwrap())));         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"));
src/app/ui.html+96−73
@@ -39,16 +39,16 @@ button{font-family:inherit} /* ---- 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-header{flex:0 0 44px;height:44px;display:flex;align-items:center;gap:12px;padding:0 16px;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-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}  /* ---- 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}+.brand{display:flex;align-items:center;gap:8px;font:600 15px var(--brand-heading);color:var(--text);white-space:nowrap;cursor:default;position:relative;height:44px}+.brand img{width:18px;height:18px;display:block}+.brand .sub{color:var(--text-2);font:400 13px var(--brand-body);margin-left:8px} .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}@@ -57,12 +57,12 @@ button{font-family:inherit} .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{width:28px;height:28px;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[aria-pressed="true"]{color:var(--accent);background:var(--elevated);box-shadow:inset 0 0 0 1px var(--border)} .iconbtn[aria-pressed="true"]:hover{background:var(--overlay)}-.iconbtn svg{width:20px;height:20px;display:block}+.iconbtn svg{width:18px;height:18px;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)}@@ -91,33 +91,42 @@ main{padding:20px 16px 40px} main>:first-child{margin-top:0}  /* "Say it" strip: the first thing on the page (dock contract) */-.say{margin-top:0;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}+/* The newbie panel: ONE full-width band, a SIBLING of the app body (never+   inside the app grid, or it stays a column however wide you style it).+   Two columns: the demo button alone on the left, the prompts table right. */+.say{margin:0 0 16px;position:relative;display:grid;grid-template-columns:220px 1fr;gap:24px;background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:16px 44px 16px 16px;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-left{display:flex;align-items:flex-start;border-right:1px solid var(--border);padding-right:24px;margin-right:-24px}+.say-right{min-width:0}+.say-head h2{font:600 15px var(--brand-heading);color:var(--text);margin:0}+.say-sub{color:var(--text-2);font-size:13.5px;margin:4px 0 10px;max-width:70ch} .say-sub b{color:var(--text);font-weight:600}-.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{white-space:nowrap;flex:none;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{margin-top:0;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)}+.say-close{position:absolute;top:10px;right:10px;width:28px;height:28px;color:var(--text-2)}+.say-close svg{width:16px;height:16px}+/* The demo button is the panel's ONLY filled button. */+.demo-btn{background:var(--accent);border-color:var(--accent);color:var(--on-accent);border-radius:8px;padding:10px 18px;font:600 14px var(--brand-heading);gap:8px;min-height:auto;box-shadow:none}+.demo-btn:hover{background:var(--accent-hover);border-color:var(--accent-hover)}+.demo-btn svg{width:16px;height:16px}+.demo-btn:disabled{opacity:.6;cursor:progress}+/* Prompts are a TABLE, not pills: phrases differ in length, so pills go ragged+   and the eye reads the boxes instead of the words. The action cell tracks the+   PHRASE (max-content), so the icons never strand at the window edge. */+.say-table{display:grid;grid-template-columns:minmax(0,max-content) 72px;justify-content:start;align-items:center}+.say-row{display:contents}+.say-phrase-cell{min-width:0;height:40px;display:flex;align-items:center;border-bottom:1px solid var(--border);padding-right:16px;cursor:pointer;color:var(--text);font:400 13.5px var(--brand-body);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}+.say-phrase-cell span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}+.say-phrase-cell em{color:var(--accent);font-style:normal}+.say-row:last-child .say-phrase-cell,.say-row:last-child .say-actions{border-bottom:none}+.say-actions{height:40px;display:flex;align-items:center;justify-content:flex-end;gap:4px;border-bottom:1px solid var(--border)}+.say-act{width:28px;height:28px;display:inline-flex;align-items:center;justify-content:center;background:none;border:1px solid transparent;border-radius:7px;color:var(--text-2);cursor:pointer;transition:color .15s ease,background .15s ease,border-color .15s ease,transform .06s ease;position:relative}+.say-act svg{width:16px;height:16px}+.say-act:hover{color:var(--accent);background:var(--elevated);border-color:var(--border)}+.say-act:active{transform:translateY(1px) scale(.94)}+.say-act:focus-visible{outline:2px solid var(--accent);outline-offset:2px}+.say-act:disabled{opacity:.5;cursor:progress}+/* 44px touch target without changing the 28px visual box. */+.say-act::after{content:"";position:absolute;left:50%;top:50%;width:44px;height:44px;transform:translate(-50%,-50%)}+.say-row.copied .say-phrase-cell{color:var(--green)} .leds{margin-top:0;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)}@@ -360,6 +369,9 @@ 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}+#demoCaption{position:fixed;left:50%;transform:translateX(-50%);bottom:104px;z-index:var(--z-modal);max-width:min(70ch,calc(100vw - 48px));background:rgba(13,17,23,.92);border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:10px;padding:12px 18px;font:500 20px/1.4 var(--brand-body);color:var(--text);text-align:center;box-shadow:0 18px 50px rgba(0,0,0,.55);display:none}+#demoCaption.on{display:block}+@media (max-width:720px){#demoCaption{bottom:96px;font-size:16px;padding:10px 14px}} #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)}@@ -381,7 +393,7 @@ select.sel:disabled{opacity:.5;cursor:not-allowed} /* ---- Responsive: reshape, do not shrink ---- */ @media (max-width:720px){   .brand .sub,.meta,.vsep{display:none}-  .app-header{padding:0 10px;gap:8px}+  .app-header{padding:0 12px;gap:8px}   main{padding:20px 10px 40px}   .searchbar{flex-wrap:wrap;padding:8px}   .searchbar input{flex:1 1 100%;order:1}@@ -389,11 +401,10 @@ select.sel:disabled{opacity:.5;cursor:not-allowed}   .sb-controls .btn{flex:1 1 100%;min-height:44px}   .seg button{min-width:40px;height:36px}   .toggle-row{min-height:44px}-  .say-list{flex-direction:column;gap:8px}-  .say-chip{width:100%;border-radius:10px;padding:9px 10px 9px 13px;align-items:flex-start}-  .say-chip .txt{flex:1;min-width:0}-  .say-chip .copy,.say-chip .send{align-self:center}-  .say-phrase{min-height:40px}+  .say{grid-template-columns:1fr;gap:14px;padding:14px 40px 14px 14px}+  .say-left{border-right:none;border-bottom:1px solid var(--border);padding:0 0 14px;margin:0}+  .say-left .demo-btn{width:100%;justify-content:center;min-height:44px}+  .say-table{grid-template-columns:minmax(0,1fr) 72px;justify-content:stretch}   .say-try{padding:0 14px}   .app-footer .f-hide{display:none}   .recommendation{padding:12px;flex-wrap:wrap}@@ -413,8 +424,8 @@ select.sel:disabled{opacity:.5;cursor:not-allowed} <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">&middot; Mouser, DigiKey, JLCPCB</span>+      <img src="favicon.svg" alt="" width="18" height="18">+      <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>@@ -431,26 +442,25 @@ select.sel:disabled{opacity:.5;cursor:not-allowed}     <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">       <button class="iconbtn" id="btnHelp" aria-pressed="false" aria-label="Show the prompts panel" 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>+      <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" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></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="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</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 class="say-left">+          <button class="btn demo-btn" id="btnDemo" data-tooltip="A two-minute narrated walk through the whole page: the prompts, a live search across all three vendors, the best-source banner, Settings, and the health lights."><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="M7 4.8v14.4a.6.6 0 0 0 .92.5l11.3-7.2a.6.6 0 0 0 0-1l-11.3-7.2a.6.6 0 0 0-.92.5z"/></svg>Play the 2-minute demo</button>         </div>-        <div class="say-list" id="sayList"></div>+        <div class="say-right">+          <div class="say-head">+            <h2 id="sayTitle">Say it</h2>+            <p 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.</p>+          </div>+          <div class="say-table" id="sayList" role="list"></div>+        </div>+        <button class="iconbtn say-close" id="sayClose" aria-label="Close the prompts panel" data-tooltip="Close this panel. The question mark in the header brings it back, and your choice is remembered."><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>       </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">@@ -495,6 +505,7 @@ select.sel:disabled{opacity:.5;cursor:not-allowed} </div>  <div id="demoSpot" aria-hidden="true"></div>+<div id="demoCaption" role="status" aria-live="polite"></div> <div id="demoHud" role="dialog" aria-label="Demo controls">   <div class="dh-head" id="dhHead">     <span class="dh-title">Parts Search demo</span>@@ -789,10 +800,8 @@ function panelCollapsed(){ function panelVisible(){return !!settings.showSayStrip&&!panelCollapsed();} function scaffoldGraduated(){const g=gradState();return panelState().explicit?panelCollapsed():!!(g.aiDriven||(g.launches||0)>=3);} function paintGraduation(){-  const show=!!settings.showSayStrip, open=panelVisible();+  const open=panelVisible();   $('#say').hidden=!open||!SAY.length;-  $('#sayFolded').hidden=!show||open;-  $('#sfCount').textContent=SAY.length?' - '+SAY.length+' phrases':'';   $('#examples').style.display=scaffoldGraduated()?'none':'';   // The ? reflects state two ways (input-feedback): aria-pressed AND a visible   // difference, with its label and tooltip swapping to name what it will do.@@ -811,7 +820,6 @@ function setPanelOpen(open,via){   if(via==='x')toast('Prompts panel hidden. The question mark in the header brings it back.','success'); } $('#btnHelp').addEventListener('click',()=>setPanelOpen(!panelVisible(),'help'));-$('#sfExpand').addEventListener('click',()=>setPanelOpen(true,'expand')); let launchCounted=false; function countGreenLaunch(state){   if(launchCounted||performance.now()>60000)return;@@ -824,9 +832,12 @@ let SAY=[]; function renderSay(list){   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">&ldquo;'+escape(p).replace(/parts search/i,'<em>parts search</em>')+'&rdquo;</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('');+  host.innerHTML=SAY.map((p,i)=>'<div class="say-row" id="say-row-'+i+'" role="listitem">'+    +'<div class="say-phrase-cell" id="say-copy-'+i+'" data-i="'+i+'" data-tooltip="Copy this phrase to the clipboard, ready to paste into any AI thread."><span>&ldquo;'+escape(p).replace(/parts search/i,'<em>parts search</em>')+'&rdquo;</span></div>'+    +'<div class="say-actions">'+    +'<button class="say-act" data-act="copy" data-i="'+i+'" aria-label="Copy this phrase to the clipboard" data-tooltip="Copy this phrase to the clipboard, ready to paste into any AI thread."><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="12" height="12" rx="2.5"/><path d="M6 15H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v1"/></svg></button>'+    +'<button class="say-act" data-act="send" data-i="'+i+'" id="say-try-'+i+'" aria-label="Send this phrase to a new AI thread" data-tooltip="Open a NEW AI thread and send this phrase to it. Existing threads are never touched, and opening a tab can take up to a minute."><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="M21.5 2.5 10.5 13.5"/><path d="M21.5 2.5 14.8 21.5l-4.3-8-8-4.3z"/></svg></button>'+    +'</div></div>').join('');   paintGraduation(); } function copyText(t){@@ -848,19 +859,27 @@ async function copyPhrase(chip,quiet){   if(!quiet)toast('Copied to clipboard. Paste it into any AI thread.','success');   return true; }+async function copyPhrase(i,quiet){+  const row=$('#say-row-'+i), ok=await copyText(SAY[i]);+  if(row){row.classList.add('copied');setTimeout(()=>row.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';+  const act=e.target.closest('.say-act'), cell=e.target.closest('.say-phrase-cell');+  if(!act&&!cell)return;+  const i=+((act||cell).dataset.i);+  if(act&&act.dataset.act==='send'){+    act.disabled=true;     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;+    try{r=await fetch('api/say-send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({phrase:SAY[i]})}).then(x=>x.json());}catch{}+    act.disabled=false;     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');}+    else{await copyPhrase(i,true);toast((r&&r.why)||'Could not reach the AI panel; copied the phrase instead.','error');}     return;   }-  await copyPhrase(chip,false);+  await copyPhrase(i,false); }); $('#sayClose').addEventListener('click',e=>{e.stopPropagation();setPanelOpen(false,'x');}); @@ -872,10 +891,9 @@ function pillState(led){   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);+  // The SERVER emits this. Never truncate prose here: that is what produced+  // "in talled and pinned" on another dashboard.+  return led.short||''; } function renderPills(status){   const host=$('#ledRow');if(!status||!status.leds){host.innerHTML='';return;}@@ -1119,10 +1137,16 @@ 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 capEl=$('#demoCaption');+  function sendCaption(step,text){+    // In-page, never on the desktop: this demo drives THIS page, so the caption+    // belongs where the user is already looking (adom-ui-design 1.24.0).+    if(text){capEl.textContent=text;capEl.classList.add('on');}+    else{capEl.classList.remove('on');capEl.textContent='';}+  }   const STEPS=[     {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(!panelVisible()){demoState.stripWasHidden=true;$('#say').hidden=false;$('#sayFolded').hidden=true;}},leave(){if(demoState.stripWasHidden){demoState.stripWasHidden=false;paintGraduation();}}},+    {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(!panelVisible()){demoState.stripWasHidden=true;$('#say').hidden=false;}},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},@@ -1194,7 +1218,6 @@ 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);
src/serve.rs+23−9
@@ -48,10 +48,14 @@ pub fn instance_version(port: u16) -> Option<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).+/// Retire the instance that actually OWNS the port. Argv is a claim, not a+/// fact: the authority is that the process answers our /api/status on this+/// port with our app name, so we check that first and only then match the+/// published pid, and never on a substring. fn stop_published_instance(port: u16) {+    // Only ever retire a process that is genuinely serving THIS app on THIS+    // port; if something else holds it, we must not kill it.+    if instance_version(port).is_none() { return; }     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 };@@ -81,13 +85,23 @@ pub fn take_over_dock_webview(url: &str) { 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"])+    // setsid when available: a new session survives the parent exiting, which+    // is what the dock's exec does the moment serve prints its URL.+    let exe_str = exe.to_string_lossy().to_string();+    let spawned = Command::new("setsid")+        .args(["--fork", &exe_str, "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))+        .spawn();+    match spawned {+        Ok(_) => Ok(()),+        Err(_) => Command::new(exe)          // no setsid on this box: own process group+            .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.