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.
master
John Lauer
v2.0.14: Module 2.4.0: the panel gutter check now measures against the containing block
51a16ff
1mo ago
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<title>Parts Search</title>
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<style>
@font-face{font-family:'Familjen Grotesk';src:url('fonts/familjen-grotesk-latin-500-normal.woff2') format('woff2');font-weight:500;font-display:swap}
@font-face{font-family:'Familjen Grotesk';src:url('fonts/familjen-grotesk-latin-600-normal.woff2') format('woff2');font-weight:600;font-display:swap}
@font-face{font-family:'Familjen Grotesk';src:url('fonts/familjen-grotesk-latin-700-normal.woff2') format('woff2');font-weight:700;font-display:swap}
@font-face{font-family:'Satoshi';src:url('fonts/satoshi-400-normal.woff2') format('woff2');font-weight:400;font-display:swap}
@font-face{font-family:'Satoshi';src:url('fonts/satoshi-500-normal.woff2') format('woff2');font-weight:500;font-display:swap}
@font-face{font-family:'Satoshi';src:url('fonts/satoshi-700-normal.woff2') format('woff2');font-weight:700;font-display:swap}
:root{
--bg:#0d1117;--surface:#161b22;--elevated:#1c2128;--overlay:#21262d;--border:#30363d;
--text:#e6edf3;--text-body:#c9d1d9;--text-2:#8b949e;--text-3:#484f58;
--accent:#00b8b1;--accent-hover:#00d4cb;--accent-bright:#00e6dc;--on-accent:#05221f;
--green:#3fb950;--red:#f85149;--yellow:#d29922;--blue:#64abff;--purple:#8c6bf7;
--mouser:#3f8fe0;--digikey:#e0503f;--jlcpcb:#5fb35a;
--brand-heading:'Familjen Grotesk',sans-serif;--brand-body:'Satoshi',sans-serif;
--mono:'JetBrains Mono',ui-monospace,'Cascadia Mono',Consolas,monospace;
--z-hud:50;--z-toolbar:80;--z-floating-menu:200;--z-toast:400;--z-modal:600;--z-tooltip:99999;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{background:var(--bg);color:var(--text-body);font:14px/1.5 var(--brand-body);height:100%;overflow:hidden}
/* House scrollbar, fleet-standard block (scrollbars sub-skill; matches the wiki) */
*{scrollbar-width:thin;scrollbar-color:rgba(139,148,158,.38) transparent}
::-webkit-scrollbar{width:10px;height:10px}
::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:rgba(139,148,158,.32);border-radius:999px;border:2px solid transparent;background-clip:padding-box}
::-webkit-scrollbar-thumb:hover{background:rgba(0,184,177,.55);background-clip:padding-box}
::-webkit-scrollbar-corner{background:transparent}
a{color:var(--blue)}
button{font-family:inherit}
:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
/* ---- App shell: fixed full-height column, the body is the only scroller ---- */
.app{position:fixed;inset:0;display:flex;flex-direction:column;background:var(--bg)}
.app-body{background:radial-gradient(900px 420px at 50% -120px,rgba(0,184,177,.16),transparent 70%),radial-gradient(700px 300px at 85% 10%,rgba(140,107,247,.07),transparent 70%),var(--bg)}
.app-header{flex:0 0 44px;height:44px;display:flex;align-items:center;gap:12px;padding:0 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 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}
.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: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: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)}
.hled.fail{background:var(--red)}
.hled.busy{background:var(--accent);animation:ledpulse 1.1s ease-in-out infinite}
@keyframes ledpulse{0%,100%{opacity:1;box-shadow:0 0 0 0 rgba(0,184,177,.55)}50%{opacity:.55;box-shadow:0 0 10px 3px rgba(0,184,177,.25)}}
/* App-info popover under the brand (600ms show, 220ms hide grace) */
.brand-pop{position:absolute;top:40px;left:0;min-width:260px;background:var(--overlay);border:1px solid var(--border);border-radius:10px;padding:10px 12px;box-shadow:0 12px 40px rgba(0,0,0,.55);z-index:var(--z-floating-menu);display:none;pointer-events:auto;color:var(--text-body);font:13px/1.5 var(--brand-body);font-weight:400;white-space:normal;cursor:auto}
.brand-pop.open{display:block}
.brand-pop dl{display:grid;grid-template-columns:auto 1fr;gap:2px 12px}
.brand-pop dt{color:var(--text-2)}
.brand-pop dd{font-family:var(--mono);font-size:12px;color:var(--text)}
.brand-pop dd a{font-family:var(--brand-body);font-size:13px}
/* ---- Main column ---- */
/* Spacing ruling (John, 2026-08-24): the CONTENT REGION owns the page
padding, not the panel, so the gap under the header is IDENTICAL whether the
prompts panel is open or closed. Minimum 20px. Nothing above the first child
may add a top margin, or closing the panel would change the gap. */
/* A dashboard is an APP SURFACE, not an article (adom-ui-design 1.17.0): no
layout cap, so extra width becomes more room for the vendor columns at any
size from a 700px webview pane to a maximised 2500px browser. Reading
measures are capped on the PROSE blocks themselves (.say-sub, .card-desc),
never on the layout around them. */
main{padding:20px 16px 40px}
main>:first-child{margin-top:0}
/* "Say it" strip: the first thing on the page (dock contract) */
.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)}
.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)}
/* Search bar */
.searchwrap{position:relative;border-radius:14px;padding:1px;background:var(--border);transition:background .2s ease;box-shadow:0 12px 40px rgba(0,0,0,.35)}
.searchwrap:hover{background:#4b5563}
.searchwrap:focus-within{background:linear-gradient(100deg,#00e6dc,#39b8ff 60%,#8c6bf7);box-shadow:0 0 0 4px rgba(0,184,177,.12),0 12px 40px rgba(0,0,0,.35)}
.searchbar{display:flex;gap:8px;align-items:center;background:var(--surface);border-radius:13px;padding:7px}
/* The icon and the input are ONE unit. Before this they were siblings, and at
the narrow breakpoint the input was forced to a full-width second line while
the icon (with no order set) stayed on the first: a magnifier orphaned on a
row of its own, which is what adom/adom-parts-search#9 shows. Grouping them
means they wrap together or not at all. */
.sb-field{display:flex;align-items:center;flex:1 1 200px;min-width:0}
.searchbar .sicon{width:20px;height:20px;color:var(--text-2);margin-left:8px;flex:none}
.searchbar input{flex:1 1 auto;min-width:0;background:transparent;border:none;outline:none;color:var(--text);font:500 16px var(--brand-body);padding:9px 6px;height:42px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
.examples{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin:10px 4px 0}
.ex-label{font:600 11.5px var(--brand-heading);color:var(--text-2);margin-right:2px}
.ex{min-height:28px;padding:3px 11px;border-radius:999px;border:1px solid var(--border);background:var(--surface);color:var(--text-body);font:500 12.5px var(--mono);cursor:pointer;transition:border-color .15s ease,color .15s ease,background .15s ease,transform .06s ease}
.ex:hover{border-color:var(--accent);color:var(--accent);background:var(--elevated)}
.ex:active{transform:translateY(1px) scale(.97)}
.searchbar input::placeholder{color:var(--text-2);text-overflow:ellipsis}
.sb-controls{display:flex;gap:8px;align-items:center;flex:none}
.seg{display:inline-flex;background:var(--elevated);border:1px solid var(--border);border-radius:8px;padding:2px;gap:2px}
.seg button{min-width:34px;height:32px;padding:0 8px;border:none;border-radius:6px;background:transparent;color:var(--text-2);font:500 13px var(--mono);cursor:pointer;transition:background .15s ease,color .15s ease,transform .06s ease}
.seg button:hover{background:var(--overlay);color:var(--text)}
.seg button:active{transform:scale(.95)}
.seg button[aria-pressed="true"]{background:var(--accent);color:var(--on-accent);box-shadow:inset 0 0 0 1px rgba(0,0,0,.15)}
.seg-label{font-size:12px;color:var(--text-2);margin-right:2px}
.toggle{background:none;border:none;padding:2px;cursor:pointer;flex-shrink:0;transition:transform .06s ease;display:inline-flex;align-items:center}
.toggle-track{display:block;width:32px;height:18px;background:var(--overlay);border:1px solid var(--border);border-radius:9px;position:relative;transition:background .15s ease,border-color .15s ease}
.toggle:hover .toggle-track{border-color:var(--accent)}
.toggle:active{transform:translateY(1px) scale(.97)}
.toggle.active .toggle-track{background:var(--accent);border-color:var(--accent)}
.toggle-thumb{position:absolute;top:2px;left:2px;width:12px;height:12px;background:#fff;border-radius:50%;transition:transform .18s ease,width .12s ease}
.toggle.active .toggle-thumb{transform:translateX(14px)}
.toggle:active .toggle-thumb{width:16px}
.toggle.active:active .toggle-thumb{transform:translateX(10px)}
.toggle:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:10px}
.toggle:disabled{opacity:.5;cursor:not-allowed}
.toggle-row{display:inline-flex;align-items:center;gap:8px;min-height:36px;padding:0 6px;border-radius:8px;cursor:pointer;transition:background .15s ease}
.toggle-row:hover{background:var(--elevated)}
.toggle-row .tl{font-size:13px;color:var(--text-body);white-space:nowrap}
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:38px;padding:0 18px;border-radius:8px;border:1px solid var(--border);background:var(--elevated);color:var(--text);font:600 14px var(--brand-heading);cursor:pointer;transition:background .15s ease,border-color .15s ease,color .15s ease,transform .06s ease;white-space:nowrap}
.btn:hover{background:var(--overlay);border-color:#4b5563}
.btn:active{transform:translateY(1px) scale(.98);background:var(--surface)}
.btn.primary{background:var(--accent);border-color:var(--accent);color:var(--on-accent);box-shadow:0 6px 18px rgba(0,184,177,.25)}
.btn.primary:hover{background:var(--accent-hover);border-color:var(--accent-hover)}
.btn.primary:active{background:#009d97;border-color:#009d97}
.btn:disabled{opacity:.55;cursor:progress;transform:none}
.btn svg{width:16px;height:16px}
.statusline{display:flex;align-items:center;gap:8px;color:var(--text-2);font-size:13px;margin:10px 2px 12px;min-height:20px}
.statusline.error{color:var(--red)}
.statusline.ok{color:var(--text-2)}
.spin{display:inline-block;width:14px;height:14px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite;flex:none}
@keyframes spin{to{transform:rotate(360deg)}}
.pbar{height:4px;background:var(--elevated);border-radius:999px;overflow:hidden;margin:0 2px 12px;display:none}
.pbar>div{height:100%;width:0;background:var(--accent);transition:width .3s linear}
.pbar.on{display:block}
.pbar.indet>div{width:30%!important;animation:marquee 1.2s linear infinite}
@keyframes marquee{0%{margin-left:-30%}100%{margin-left:100%}}
/* Recommendation banner */
.recommendation{display:flex;align-items:center;gap:14px;background:linear-gradient(90deg,rgba(0,184,177,.14),rgba(0,184,177,.03) 60%,rgba(140,107,247,.06));border:1px solid rgba(0,184,177,.4);border-radius:14px;padding:14px 18px;margin-bottom:16px;box-shadow:0 10px 40px rgba(0,184,177,.08);animation:fadein .3s ease}
.recommendation .rbody{flex:1;min-width:0}
.recommendation .ricon{width:40px;height:40px;flex:none;border-radius:12px;background:rgba(0,184,177,.15);color:var(--accent);display:flex;align-items:center;justify-content:center}
.recommendation .ricon svg{width:22px;height:22px}
.recommendation.empty .ricon{background:rgba(210,153,34,.15)}
.recommendation .subst{margin-top:6px;padding:8px 10px;border-radius:8px;background:rgba(210,153,34,.14);border:1px solid rgba(210,153,34,.5);color:#f0c674;font:500 13px/1.45 var(--brand-body)}
.recommendation .exact{margin-top:4px;color:var(--accent);font:600 12px var(--brand-heading)}
.recommendation .label{font:600 12px var(--brand-heading);letter-spacing:.02em;color:var(--accent)}
.recommendation .main{font:600 18px/1.3 var(--brand-heading);color:var(--text);margin-top:2px}
.recommendation .main code{font:600 14px var(--mono);color:var(--text);background:rgba(110,118,129,.2);padding:1px 6px;border-radius:4px}
.recommendation .reason{color:var(--text-body);font-size:13px;margin-top:3px}
.recommendation .prov{color:var(--text-2);font-size:12px;margin-top:4px}
.recommendation.empty{background:linear-gradient(90deg,rgba(210,153,34,.10),rgba(210,153,34,.02));border-color:rgba(210,153,34,.35)}
.recommendation.empty .label,.recommendation.empty .ricon{color:var(--yellow)}
.recommendation .es-hint{color:var(--text-body);font-size:12.5px;margin-top:6px;padding-top:6px;border-top:1px dashed var(--border)}
/* The three columns */
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:14px}
.column{background:linear-gradient(180deg,var(--elevated),var(--surface) 80px);border:1px solid var(--border);border-radius:14px;padding:12px 14px 6px;min-height:220px;display:flex;flex-direction:column;min-width:0;position:relative;overflow:hidden;transition:box-shadow .2s ease,border-color .2s ease}
.column:hover{border-color:#3d444d}
.column::before{content:"";position:absolute;left:0;right:0;top:0;height:3px;background:var(--vcol,var(--border))}
.column.mouser{--vcol:var(--mouser)}
.column.digikey{--vcol:var(--digikey)}
.column.jlcpcb{--vcol:var(--jlcpcb)}
.column.preferred{border-color:rgba(0,184,177,.5);box-shadow:0 0 0 1px rgba(0,184,177,.35),0 16px 50px rgba(0,184,177,.12)}
.column.skipped{opacity:.6}
.col-head{display:flex;align-items:center;gap:8px;margin:4px 0 10px;padding-bottom:10px;border-bottom:1px solid var(--border);flex-wrap:wrap;min-height:30px}
.col-dot{width:10px;height:10px;border-radius:999px;background:var(--vcol);flex:none;box-shadow:0 0 10px var(--vcol)}
.col-title{font:600 19px var(--brand-heading);color:var(--text);letter-spacing:-.01em}
.col-meta{color:var(--text-2);font:11px var(--mono);margin-left:auto;white-space:nowrap}
.col-badge{font:600 11px var(--brand-heading);letter-spacing:.01em;padding:3px 8px;border-radius:999px;background:rgba(0,184,177,.14);color:var(--accent);border:1px solid rgba(0,184,177,.3)}
.col-badge.win{background:var(--accent);color:var(--on-accent);border-color:var(--accent)}
.col-body{display:flex;flex-direction:column;min-width:0}
.card{display:grid;grid-template-columns:72px minmax(0,1fr);gap:12px;padding:12px 0;border-bottom:1px solid var(--border);animation:fadein .35s ease both;border-radius:8px;transition:background .15s ease}
.card:nth-child(2){animation-delay:.05s}.card:nth-child(3){animation-delay:.1s}.card:nth-child(4){animation-delay:.15s}.card:nth-child(5){animation-delay:.2s}
.card:hover{background:rgba(255,255,255,.02)}
.card:last-child{border-bottom:none}
@keyframes fadein{from{opacity:0;transform:translateY(3px)}to{opacity:1;transform:none}}
.card-img{width:72px;height:72px;border-radius:10px;background:#f6f7f9;box-shadow:0 4px 14px rgba(0,0,0,.35);display:flex;align-items:center;justify-content:center;overflow:hidden;cursor:zoom-in;flex-shrink:0;border:1px solid var(--border);transition:transform .15s ease,border-color .15s ease}
.card-img:hover{border-color:var(--accent);transform:scale(1.03)}
.card-img:active{transform:scale(.97)}
.card-img.noimg{background:var(--elevated);cursor:default}
.card-img.noimg:hover{transform:none;border-color:var(--border)}
.card-img img{width:100%;height:100%;object-fit:contain;display:block;padding:4px}
.card-img .placeholder{color:var(--text-2);font-size:10px;text-align:center;line-height:1.2}
.card-img .placeholder svg{width:22px;height:22px;display:block;margin:0 auto 2px;color:var(--text-2)}
.card-body{min-width:0;display:flex;flex-direction:column;gap:2px}
.mpn{font:600 13px var(--mono);color:var(--text);word-break:break-word}
.vendor-pn{font:11px var(--mono);color:var(--text-2)}
.manuf{color:var(--text-2);font-size:11.5px}
.desc{color:var(--text-body);font-size:12px;line-height:1.4;margin-top:2px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.row-price{display:flex;gap:10px;font-size:12px;margin-top:4px;align-items:baseline;flex-wrap:wrap}
.price{font:700 15px var(--mono);color:var(--accent)}
.price-100{font:11px var(--mono);color:var(--text-2)}
.stock{font-size:12px;display:inline-flex;align-items:center;gap:6px}
.stock i{display:inline-block;width:34px;height:4px;border-radius:999px;background:var(--overlay);overflow:hidden;position:relative}
.stock i::after{content:"";position:absolute;inset:0;width:var(--fill,0%);background:currentColor;border-radius:999px}
.stock.high{color:var(--green)}.stock.medium{color:var(--yellow)}.stock.low{color:var(--red)}.stock.zero{color:var(--text-2)}
.flags{display:flex;gap:4px;flex-wrap:wrap;margin-top:4px}
.flag{font:10.5px var(--mono);padding:1px 6px;border-radius:4px;background:var(--elevated);color:var(--text-2);border:1px solid var(--border)}
.flag.basic{color:var(--green);border-color:rgba(63,185,80,.3)}
.flag.preferred{color:var(--blue);border-color:rgba(100,171,255,.3)}
.card-actions{margin-top:6px;display:flex;gap:6px;flex-wrap:wrap}
.card-actions a{display:inline-flex;align-items:center;gap:4px;min-height:26px;font:600 11.5px var(--brand-heading);color:var(--text-body);text-decoration:none;padding:2px 9px;border:1px solid var(--border);border-radius:999px;background:var(--elevated);transition:background .15s ease,border-color .15s ease,color .15s ease,transform .06s ease}
.card-actions a:hover{border-color:var(--accent);color:var(--accent);background:var(--overlay)}
.card-actions a:active{transform:translateY(1px) scale(.97)}
.card-actions a svg{width:12px;height:12px}
.xref{font:10px var(--brand-body);color:var(--text-2);margin-left:2px}
.col-empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;color:var(--text-2);font-size:13px;padding:32px 14px;text-align:center;max-width:30ch;margin:0 auto;line-height:1.5}
.col-empty svg{width:34px;height:34px;color:var(--text-3);opacity:.9}
.skel{display:grid;grid-template-columns:72px minmax(0,1fr);gap:12px;padding:12px 0;border-bottom:1px solid var(--border)}
.skel:last-child{border-bottom:none}
.skel .b{border-radius:8px;background:linear-gradient(90deg,var(--elevated) 25%,var(--overlay) 50%,var(--elevated) 75%);background-size:200% 100%;animation:shimmer 1.2s linear infinite}
.skel .img{width:72px;height:72px;border-radius:10px}
.skel .l{height:12px;margin-bottom:8px}
@keyframes shimmer{to{background-position:-200% 0}}
.col-error{padding:12px 4px;display:flex;flex-direction:column;gap:8px}
.col-error .ce-title{display:flex;align-items:center;gap:8px;color:var(--yellow);font:600 13.5px/1.3 var(--brand-heading)}
.col-error .ce-title svg{width:18px;height:18px;flex-shrink:0}
.col-error.skip .ce-title{color:var(--text-2)}
.col-error .ce-body{color:var(--text-body);font-size:12.5px;line-height:1.5}
.col-error .ce-hint{color:var(--text-2);font-size:12px;line-height:1.5;padding-top:6px;border-top:1px dashed var(--border)}
.col-error .ce-hint b{color:var(--accent);font-weight:600}
/* Lightbox */
.lightbox{position:fixed;inset:0;z-index:var(--z-modal);background:rgba(13,17,23,.9);display:flex;align-items:center;justify-content:center;opacity:0;pointer-events:none;transition:opacity .18s ease;cursor:zoom-out}
.lightbox.open{opacity:1;pointer-events:auto}
.lightbox img{max-width:min(90vw,1200px);max-height:86vh;border-radius:12px;box-shadow:0 24px 80px rgba(0,0,0,.7);background:#fff}
.lb-caption{position:absolute;bottom:24px;left:50%;transform:translateX(-50%);color:var(--text);background:rgba(22,27,34,.92);padding:10px 18px;border-radius:10px;font:13px var(--mono);border:1px solid var(--border);max-width:min(90vw,900px)}
.lb-close{position:absolute;top:16px;right:16px}
/* Toasts: bottom center, newest on top */
#toasts{position:fixed;left:50%;bottom:calc(34px + env(safe-area-inset-bottom));transform:translateX(-50%);display:flex;flex-direction:column-reverse;gap:8px;z-index:var(--z-toast);width:min(560px,calc(100vw - 32px));pointer-events:none}
.toast{display:flex;align-items:center;gap:12px;background:var(--overlay);border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:12px;padding:11px 14px;color:var(--text);font-size:13.5px;box-shadow:0 18px 50px rgba(0,0,0,.5);pointer-events:auto;animation:toastin .2s ease}
@keyframes toastin{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
.toast.success{border-left-color:var(--green)}.toast.error{border-left-color:var(--red)}
.toast svg.ti{width:18px;height:18px;flex:none;color:var(--accent)}
.toast.success svg.ti{color:var(--green)}.toast.error svg.ti{color:var(--red)}
.toast .tmsg{flex:1;min-width:0;overflow-wrap:anywhere}
.toast .taction{background:none;border:none;color:var(--blue);font:600 13px var(--brand-heading);cursor:pointer;padding:4px 6px;border-radius:6px;transition:background .15s ease}
.toast .taction:hover{background:rgba(100,171,255,.12)}
.toast .tclose{width:26px;height:26px}
/* Global tooltip: ONE body-appended fixed div, always topmost */
#global-tooltip{position:fixed;z-index:99999;top:0;left:0;transform:translate(var(--tip-x,0),var(--tip-y,0));max-width:min(340px,calc(100vw - 40px));pointer-events:none;opacity:0;display:none;background:rgba(22,27,34,.97);color:var(--text);border:1px solid rgba(0,184,177,.28);border-radius:10px;padding:9px 13px;font:400 12.5px/1.45 var(--brand-body);box-shadow:0 12px 40px rgba(0,0,0,.55);text-transform:none;letter-spacing:0;white-space:pre-wrap}
#global-tooltip.visible{display:block}
#global-tooltip.shown{opacity:1;transition:opacity .18s ease .6s}
#global-tooltip.now{transition:opacity .18s ease 0s}
/* ---- Settings modal (settings-layout skill) ---- */
dialog{margin:auto;background:var(--surface);color:var(--text-body);border:1px solid var(--border);border-radius:12px;padding:0;width:min(680px,92vw);max-width:none;overflow:hidden;box-shadow:0 24px 80px rgba(0,0,0,.6)}
dialog:focus{outline:none}
dialog::backdrop{background:rgba(0,0,0,.25)}
.settings-head{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 12px 10px 18px;border-bottom:1px solid var(--border);cursor:move;user-select:none}
.settings-head h2{font:600 15px/1.3 var(--brand-heading);color:var(--text);display:flex;align-items:center;gap:10px}
.settings-body{max-height:70vh;overflow:auto;padding:18px;scrollbar-gutter:stable}
.settings-body::-webkit-scrollbar-track{margin:12px 0}
.settings-card{background:var(--elevated);border:1px solid var(--border);border-left:3px solid color-mix(in srgb,var(--accent) 45%,transparent);border-radius:10px;padding:.9rem 1.1rem 1rem;margin-bottom:1rem}
.card-title{font:600 13px/1.3 var(--brand-heading);color:var(--text);display:flex;align-items:center;gap:10px;margin:0 0 .45rem;flex-wrap:wrap}
.card-status{font:400 11px var(--brand-body);color:var(--text-2)}
.card-status.ok{color:var(--green)}.card-status.warn{color:var(--yellow)}.card-status.fail{color:var(--red)}
.card-desc{font:12px/1.55 var(--brand-body);color:var(--text-2);max-width:60ch;margin:0 0 .55rem}
.card-desc b{color:var(--text-body);font-weight:600}
.card-desc code{font:11px var(--mono);background:rgba(110,118,129,.2);padding:1px 4px;border-radius:4px;color:var(--text-body)}
.divider{height:1px;background:var(--border);margin:.55rem 0}
.wrapper{padding:.5rem 0;display:flex;flex-direction:column;gap:.3rem}
.name{margin:.125rem 0;font:600 .9375rem var(--brand-heading)}
.prefix{color:var(--text)}
.title{color:#8fd3d4}
.default-hint{font:.75rem var(--brand-body);color:var(--text-2)}
.setting-row{display:flex;align-items:center;gap:10px;min-height:32px}
.setting-row .rstat{font:11.5px var(--mono);color:var(--text-2);margin-left:auto;white-space:nowrap}
.setting-row .rstat.ok{color:var(--green)}.setting-row .rstat.warn{color:var(--yellow)}.setting-row .rstat.fail{color:var(--red)}
.set-check-label{font:.875rem var(--brand-body);color:var(--text-body)}
.setting-children{margin-left:.75rem;padding-left:1rem;border-left:2px solid var(--border)}
.setting-children[data-enabled='false']{opacity:.55}
.setting-children[data-enabled='false'] .set-check-label{color:var(--text-2)}
.choice-set{border:none;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:.75rem}
.choice{flex:1 1 220px;display:flex;flex-direction:column;gap:.35rem;padding:.7rem .75rem;cursor:pointer;position:relative;background:var(--surface);border:1px solid var(--border);border-radius:8px;transition:border-color .15s ease,background .15s ease,transform .06s ease}
.choice input{position:absolute;opacity:0;pointer-events:none}
.choice-name{font:600 .875rem var(--brand-heading);color:var(--text);display:flex;align-items:center;gap:8px}
.choice-name svg{width:16px;height:16px;color:var(--text-2)}
.choice-desc{font:.75rem/1.5 var(--brand-body);color:var(--text-2)}
.choice:hover{border-color:var(--accent);background:var(--elevated)}
.choice:active{transform:translateY(1px) scale(.995)}
.choice[data-selected='true']{border-color:var(--accent);box-shadow:inset 0 0 0 1px var(--accent)}
.choice:focus-within{outline:2px solid var(--accent);outline-offset:2px}
select.sel{background:var(--overlay);border:1px solid var(--border);border-radius:8px;color:var(--text);padding:6px 10px;font:13px var(--brand-body);min-height:32px;cursor:pointer;transition:border-color .15s ease}
select.sel:hover{border-color:var(--accent)}
select.sel:disabled{opacity:.5;cursor:not-allowed}
.settings-footer{margin-top:1.25rem;padding-top:1rem;border-top:1px solid var(--border);display:flex;flex-direction:column;gap:.5rem}
.footer-note{font:.75rem/1.5 var(--brand-body);color:var(--text-2)}
.restore-btn{width:fit-content;display:inline-flex;align-items:center;gap:.4rem;padding:.4rem .85rem;background:transparent;border:1px solid var(--border);border-radius:.375rem;color:var(--text-body);font:600 .8125rem var(--brand-heading);cursor:pointer;transition:background .15s ease,border-color .15s ease,color .15s ease,transform .06s ease}
.restore-btn:hover{background:var(--elevated)}
.restore-btn:active{transform:translateY(1px) scale(.98)}
.restore-btn.armed{color:var(--red);border-color:rgba(248,81,73,.5);background:rgba(248,81,73,.08)}
.restore-btn svg{width:16px;height:16px}
/* ---- 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)}
.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}
.dh-step{font:11px var(--mono);color:var(--text-2);flex:none}
.dh-body{padding:8px 12px 10px}
.dh-name{font:600 13px var(--brand-heading);color:var(--accent);margin-bottom:6px}
.dh-prog{display:flex;gap:4px;margin-bottom:9px}
.dh-prog i{flex:1;height:4px;border-radius:999px;background:var(--border);transition:background .2s ease}
.dh-prog i.done{background:var(--accent)}
.dh-prog i.cur{background:var(--accent-hover)}
.dh-controls{display:flex;gap:6px;justify-content:center}
.dh-controls .iconbtn{width:36px;height:36px;background:var(--elevated);border-color:var(--border)}
.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{left:10px;right:10px;transform:none;bottom:30px;width:auto}}
/* ---- Responsive: reshape, do not shrink ---- */
@media (max-width:720px){
.brand .sub,.meta,.vsep{display:none}
.app-header{padding:0 12px;gap:8px}
main{padding:20px 10px 40px}
.searchbar{flex-wrap:wrap;padding:8px}
.sb-field{flex:1 1 100%;order:1}
.sb-controls{order:2;flex:1 1 100%;justify-content:space-between;flex-wrap:wrap;gap:6px}
.sb-controls .btn{flex:1 1 100%;min-height:44px}
.seg button{min-width:40px;height:36px}
.toggle-row{min-height:44px}
.app-footer .f-hide{display:none}
.recommendation{padding:12px;flex-wrap:wrap}
.examples{display:none}
.card{grid-template-columns:56px minmax(0,1fr)}
.card-img{width:56px;height:56px}
.card-actions a{min-height:32px}
dialog{width:100vw;max-height:100vh;border-radius:0}
.settings-body{max-height:calc(100vh - 50px)}
}
@media (hover:none),(pointer:coarse){
.iconbtn,.btn,.say-try{min-width:44px;min-height:44px}
}
</style>
</head>
<body>
<div class="app">
<header class="app-header" role="banner">
<div class="brand" id="brand" aria-label="Parts Search">
<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>
<dt>Engine</dt><dd>Rust, tiny_http</dd>
<dt>Author</dt><dd>John Lauer, Adom</dd>
<dt>Wiki</dt><dd><a href="https://wiki.adom.inc/adom/adom-parts-search" target="_blank" rel="noopener">wiki.adom.inc/adom/adom-parts-search</a></dd>
<dt>Policy</dt><dd id="popPolicy">Mouser preferred</dd>
</dl>
</div>
</div>
<div class="vsep"></div>
<div class="subject subject-name" id="subject"><span class="ph">No search yet</span></div>
<div class="meta" id="meta"></div>
<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" 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>
<div id="newbie"></div>
<div class="leds" id="ledRow" aria-label="Live health"></div>
<div class="searchwrap"><form id="search-form" class="searchbar" autocomplete="off" role="search">
<div class="sb-field">
<svg class="sicon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5"/></svg>
<input id="q" name="keyword" type="text" placeholder="Part number or keyword, e.g. STM32F103RBT6 or LM358" aria-label="Part number or keyword" autofocus>
</div>
<div class="sb-controls">
<div class="seg" role="group" aria-label="Results per vendor" data-tooltip="How many hits to fetch from each vendor. 3 for a quick snapshot, 10 to compare variants. Higher costs more upstream API calls: Mouser's free tier is about 1000 per day.">
<span class="seg-label">per vendor</span>
<button type="button" data-limit="3" aria-pressed="false">3</button>
<button type="button" data-limit="5" aria-pressed="true">5</button>
<button type="button" data-limit="10" aria-pressed="false">10</button>
</div>
<label class="toggle-row" data-tooltip="On: hits with zero stock are dropped before rendering, for when you need the part today. Off: every match shows, including backorders and long-lead parts, for BOM research.">
<button type="button" class="toggle" id="instock" role="switch" aria-checked="false" aria-label="In stock only"><span class="toggle-track"><span class="toggle-thumb"></span></span></button>
<span class="tl">In stock only</span>
</label>
<button type="submit" class="btn primary" id="go" data-tooltip="Fires one search at Mouser, DigiKey, and JLCPCB in parallel and renders three columns below, then names the best source. Mouser wins by default (the preferred vendor) unless it is out of stock or long-lead; change the policy in Settings.">Search all 3</button>
</div>
</form></div>
<div class="examples" id="examples" aria-label="Example searches"><span class="ex-label">Try</span></div>
<div id="status" class="statusline"></div>
<div id="pbar" class="pbar"><div></div></div>
<div id="recommendation"></div>
<div id="grid" class="grid">
<section id="col-mouser" class="column mouser" aria-labelledby="t-mouser">
<div class="col-head"><span class="col-dot"></span><span class="col-title" id="t-mouser">Mouser</span><span class="col-badge" id="badge-mouser" data-tooltip="Mouser is the preferred vendor under the default policy: fastest, lowest-cost fulfillment for Adom orders.">Preferred</span><span class="col-meta" id="meta-mouser"></span></div>
<div class="col-body" id="body-mouser"></div>
</section>
<section id="col-digikey" class="column digikey" aria-labelledby="t-digikey">
<div class="col-head"><span class="col-dot"></span><span class="col-title" id="t-digikey">DigiKey</span><span class="col-badge" id="badge-digikey" hidden></span><span class="col-meta" id="meta-digikey"></span></div>
<div class="col-body" id="body-digikey"></div>
</section>
<section id="col-jlcpcb" class="column jlcpcb" aria-labelledby="t-jlcpcb">
<div class="col-head"><span class="col-dot"></span><span class="col-title" id="t-jlcpcb">JLCPCB</span><span class="col-badge" id="badge-jlcpcb" hidden></span><span class="col-meta" id="meta-jlcpcb"></span></div>
<div class="col-body" id="body-jlcpcb"></div>
</section>
</div>
</main>
</div>
</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>
<span class="dh-step" id="dhStep"></span>
</div>
<div class="dh-body">
<div class="dh-name" id="dhName"></div>
<div class="dh-prog" id="dhProg"></div>
<div class="dh-controls">
<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>
</div>
<audio id="demoAudio" preload="none"></audio>
<div id="toasts" aria-live="polite"></div>
<div id="lightbox" class="lightbox" role="dialog" aria-hidden="true" aria-label="Product photo">
<button class="iconbtn lb-close" id="lb-close" aria-label="Close photo"><svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></button>
<img id="lb-img" alt="">
<div class="lb-caption" id="lb-caption"></div>
</div>
<dialog id="dlgSettings" class="settings" tabindex="-1" aria-labelledby="settingsTitle">
<div class="settings-head" id="settingsHead">
<h2 id="settingsTitle">Parts Search Settings <span class="card-status" id="settingsStatus"></span></h2>
<button class="iconbtn" id="btnCloseSettings" aria-label="Close settings" data-tooltip="Close settings. Escape and clicking outside also close it; every change was already saved."><svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></button>
</div>
<div class="settings-body" id="settingsBody">
<section class="settings-card" id="card-search">
<h3 class="card-title">Search defaults <span class="card-status" id="stSearch"></span></h3>
<p class="card-desc">Every search goes to the vendors you have enabled below as one parallel request, and each vendor answers with up to this many hits. The limit is per vendor, so 5 means up to 15 cards. The search bar shows the same two controls; what you pick there applies to that one search, while what you pick here is where the bar starts next time, and what the CLI (<code>adom-parts-search search</code>) uses when no flag is given.</p>
<div class="wrapper">
<h4 class="name" title="defaultLimit"><span class="prefix">Search: </span><span class="title">Results per vendor</span></h4>
<div class="setting-row">
<div class="seg" role="group" aria-label="Default results per vendor" id="segLimit">
<button type="button" data-limit="3" aria-pressed="false">3</button>
<button type="button" data-limit="5" aria-pressed="false">5</button>
<button type="button" data-limit="10" aria-pressed="false">10</button>
</div>
<span class="set-check-label">hits from each vendor</span>
</div>
<span class="default-hint">Default: 5</span>
</div>
<div class="divider"></div>
<div class="wrapper">
<h4 class="name" title="inStockOnly"><span class="prefix">Search: </span><span class="title">In stock only</span></h4>
<div class="setting-row">
<button class="toggle" id="tglInStock" role="switch" aria-checked="false" aria-label="In stock only by default"><span class="toggle-track"><span class="toggle-thumb"></span></span></button>
<span class="set-check-label">Drop hits with zero stock before they are shown</span>
</div>
<span class="default-hint">Default: Off</span>
</div>
</section>
<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: 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">
<button class="toggle" id="tglSay" role="switch" aria-checked="true" aria-label="Show the Say it strip"><span class="toggle-track"><span class="toggle-thumb"></span></span></button>
<span class="set-check-label">Show the phrases above the search bar</span>
</div>
<span class="default-hint">Default: On</span>
</div>
</section>
<section class="settings-card" id="card-vendors">
<h3 class="card-title">Vendors <span class="card-status" id="stVendors"></span></h3>
<p class="card-desc">Each vendor is reached through its own Adom backend service, which holds that vendor's API key so nothing secret lives on this machine: Mouser and DigiKey through their official search APIs, JLCPCB through a local mirror of the JLCPCB parts library. A vendor that is switched off is simply not queried; its column stays on the page and says so, so a missing column never looks like a vendor outage. The status beside each name is read live from the backend's own health endpoint, never cached from an earlier claim.</p>
<div class="wrapper">
<h4 class="name" title="vendorMouser vendorDigikey vendorJlcpcb"><span class="prefix">Vendors: </span><span class="title">Query these vendors</span></h4>
<div class="setting-row">
<button class="toggle" id="tglMouser" role="switch" aria-checked="true" aria-label="Query Mouser"><span class="toggle-track"><span class="toggle-thumb"></span></span></button>
<span class="set-check-label">Mouser</span><span class="rstat" id="rsMouser"></span>
</div>
<div class="setting-row">
<button class="toggle" id="tglDigikey" role="switch" aria-checked="true" aria-label="Query DigiKey"><span class="toggle-track"><span class="toggle-thumb"></span></span></button>
<span class="set-check-label">DigiKey</span><span class="rstat" id="rsDigikey"></span>
</div>
<div class="setting-row">
<button class="toggle" id="tglJlcpcb" role="switch" aria-checked="true" aria-label="Query JLCPCB"><span class="toggle-track"><span class="toggle-thumb"></span></span></button>
<span class="set-check-label">JLCPCB</span><span class="rstat" id="rsJlcpcb"></span>
</div>
<span class="default-hint">Default: all three on</span>
</div>
</section>
<section class="settings-card" id="card-policy">
<h3 class="card-title">Recommendation <span class="card-status" id="stPolicy"></span></h3>
<p class="card-desc">After the columns come back, one hit is named in the banner above the grid as the best source. Only hits with stock and a normal lead time are eligible (a 280-day lead never wins). Under <b>Mouser preferred</b>, the first stocked Mouser hit wins outright, DigiKey only when Mouser has nothing; JLCPCB is never recommended because its parts are for JLCPCB-assembled boards, not for delivery. Under <b>Cheapest in stock</b>, every stocked hit from every vendor that answered is compared on unit price and the lowest wins, ties going to Mouser. The banner always states which policy produced it.</p>
<div class="wrapper">
<h4 class="name" title="policy"><span class="prefix">Recommendation: </span><span class="title">Policy</span></h4>
<fieldset class="choice-set" role="radiogroup" aria-labelledby="policyLabel" id="policySet">
<legend id="policyLabel" class="set-check-label" style="padding:0;margin-bottom:.35rem">How the winner is chosen.</legend>
<label class="choice" data-policy="mouser" data-selected="false">
<input type="radio" name="policy" value="mouser">
<span class="choice-name"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12h4l2-5 4 10 2-5h6"/></svg>Mouser preferred</span>
<span class="choice-desc">Mouser is Adom's preferred vendor: fastest, lowest-cost fulfillment beats a few cents of unit price. Mouser wins whenever it has stock.</span>
</label>
<label class="choice" data-policy="cheapest" data-selected="false">
<input type="radio" name="policy" value="cheapest">
<span class="choice-name"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v18M7 8h7a3 3 0 0 1 0 6H8a3 3 0 0 0 0 6h9"/></svg>Cheapest in stock</span>
<span class="choice-desc">Lowest in-stock unit price across every vendor that answered. Shipping time and cost are ignored, so this suits volume orders that can wait.</span>
</label>
</fieldset>
<span class="default-hint">Default: Mouser preferred</span>
</div>
</section>
<section class="settings-card" id="card-images">
<h3 class="card-title">Product photos <span class="card-status" id="stImages"></span></h3>
<p class="card-desc">Each card carries the vendor's own product photo, loaded straight from mouser.com, digikey.com, or the LCSC image CDN, and clicking one opens it full size. That means a search contacts those three sites from this page. Turn photos off to keep the page on this app's server alone; cards then show a chip placeholder in the same spot, so the layout does not shift.</p>
<div class="wrapper">
<h4 class="name" title="showImages"><span class="prefix">Photos: </span><span class="title">Show product photos</span></h4>
<div class="setting-row">
<button class="toggle" id="tglImages" role="switch" aria-checked="true" aria-label="Show product photos"><span class="toggle-track"><span class="toggle-thumb"></span></span></button>
<span class="set-check-label">Load photos from the vendors' sites</span>
</div>
<span class="default-hint">Default: On</span>
</div>
</section>
<section class="settings-card" id="card-taskbar">
<h3 class="card-title">Taskbar progress <span class="card-status" id="stTaskbar"></span></h3>
<p class="card-desc">While a search runs, this app paints a progress bar on the <b>Hydrogen</b> button in the Windows taskbar, through the Adom Desktop bridge's <code>desktop_taskbar</code> verb (the <code>ITaskbarList3</code> API), so the wait is legible even when this tab is not in front. The bar is sized from this app's own measured search durations (a recent-weighted average kept in <code>~/.adom/parts-search-timing.json</code>), caps at 92% until the search really finishes, then jumps to 100 and clears itself. A search your AI started (<code>adom-parts-search show</code>) is open-ended, so it paints an indeterminate marquee instead of a made-up estimate. Each bridge call is bounded and off the search path: with no desktop connected, nothing is painted and nothing slows down. Windows only; there is no taskbar to paint on macOS or Linux.</p>
<div class="wrapper">
<h4 class="name" title="taskbarProgress"><span class="prefix">Taskbar: </span><span class="title">Progress bar while searching</span></h4>
<div class="setting-row">
<button class="toggle" id="tglTaskbar" role="switch" aria-checked="true" aria-label="Paint a taskbar progress bar while searching"><span class="toggle-track"><span class="toggle-thumb"></span></span></button>
<span class="set-check-label">Paint the bar on the Hydrogen taskbar button</span>
</div>
<span class="default-hint">Default: On</span>
</div>
<div class="setting-children" id="taskbarChildren" data-enabled="true" title="Available when the progress bar is on">
<div class="wrapper">
<h4 class="name" title="taskbarBox"><span class="prefix">Taskbar: </span><span class="title">Desktop box</span></h4>
<div class="setting-row">
<select class="sel" id="selBox" aria-label="Desktop box carrying the taskbar button"><option value="">First connected box</option></select>
<span class="set-check-label">which Adom Desktop to paint on</span>
</div>
<span class="default-hint">Default: First connected box</span>
</div>
</div>
</section>
<footer class="settings-footer">
<p class="footer-note">Settings are stored on this machine in <code>~/.adom/parts-search-settings.json</code> and apply immediately; the CLI reads the same file (<code>adom-parts-search settings get</code>). Restoring returns every option above to its default.</p>
<button class="restore-btn" id="btnRestore" type="button"><svg viewBox="0 0 24 24" aria-hidden="true"><path id="restoreIcon" fill="currentColor" d="M13 3a9 9 0 0 0-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42A8.954 8.954 0 0 0 13 21a9 9 0 0 0 0-18m-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8z"/></svg><span id="restoreLabel">Restore defaults</span></button>
</footer>
</div>
</dialog>
<script src="newbie-panel.js"></script>
<script>
// ---- Console forwarder: the AI reads GET /console instead of DevTools ----
(function(){
const orig={log:console.log,warn:console.warn,error:console.error};
function fwd(level,args){
orig[level].apply(console,args);
try{fetch('console',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({level,ts:Date.now(),message:args.map(a=>{try{return typeof a==='string'?a:JSON.stringify(a);}catch{return String(a);}}).join(' ')})}).catch(()=>{});}catch{}
}
console.log=(...a)=>fwd('log',a);console.warn=(...a)=>fwd('warn',a);console.error=(...a)=>fwd('error',a);
window.addEventListener('error',e=>fwd('error',[e.message,(e.filename||'')+':'+(e.lineno||'')]));
window.addEventListener('unhandledrejection',e=>fwd('error',['unhandledrejection',String(e.reason)]));
})();
const $=s=>document.querySelector(s);
const VENDORS=['mouser','digikey','jlcpcb'];
const VNAME={mouser:'Mouser',digikey:'DigiKey',jlcpcb:'JLCPCB'};
const ICON_EXT='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 4h6v6M20 4l-9 9M18 13v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h6"/></svg>';
const ICON_CHIP='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2"/><path d="M9 2v4M15 2v4M9 18v4M15 18v4M2 9h4M2 15h4M18 9h4M18 15h4"/></svg>';
const ICON_WARN='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>';
const ICON_OFF='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M8 12h8"/></svg>';
const ICON_PIN='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0z"/><circle cx="12" cy="10" r="3"/></svg>';
function escape(s){return String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
function fmtStock(n){n=n||0;if(n>=1e6)return (n/1e6).toFixed(1)+'M';if(n>=1000)return (n/1000).toFixed(1)+'k';return String(n);}
function stockClass(n){if(!n)return 'zero';if(n<1000)return 'low';if(n<10000)return 'medium';return 'high';}
function fmtMs(ms){return ms>=1000?(ms/1000).toFixed(1)+' s':ms+' ms';}
// ---- Settings mirror (local-first, optimistic) ----
const DEFAULTS={defaultLimit:5,inStockOnly:false,vendorMouser:true,vendorDigikey:true,vendorJlcpcb:true,policy:'mouser',showImages:true,taskbarProgress:true,taskbarBox:'',showSayStrip:true};
let settings=Object.assign({},DEFAULTS);
try{Object.assign(settings,JSON.parse(localStorage.getItem('psSettings')||'{}'));}catch{}
let limit=settings.defaultLimit, inStock=settings.inStockOnly;
function remember(){try{localStorage.setItem('psSettings',JSON.stringify(settings));}catch{}}
// ---- Toasts ----
const seenToasts=new Set();
function toast(message,kind='info',action){
const host=$('#toasts');
const el=document.createElement('div');
el.className='toast '+kind; el.setAttribute('role',kind==='error'?'alert':'status');
const icon=kind==='success'?'<svg class="ti" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>'
:kind==='error'?'<svg class="ti" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16h.01"/></svg>'
:'<svg class="ti" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 11v5M12 8h.01"/></svg>';
el.innerHTML=icon+'<span class="tmsg">'+escape(message)+'</span>'+(action&&action.label?'<button class="taction">'+escape(action.label)+'</button>':'')+'<button class="iconbtn tclose" aria-label="Dismiss"><svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></button>';
let timer=null; const sticky=kind==='error';
const remove=()=>{el.remove();};
const arm=()=>{if(!sticky)timer=setTimeout(remove,4000);};
el.addEventListener('mouseenter',()=>{if(timer){clearTimeout(timer);timer=null;}});
el.addEventListener('mouseleave',arm);
el.querySelector('.tclose').addEventListener('click',remove);
const ab=el.querySelector('.taction'); if(ab)ab.addEventListener('click',()=>{try{action.run&&action.run();}catch(e){console.error('toast action failed',e.message);}remove();});
host.appendChild(el);
while(host.children.length>3)host.firstElementChild.remove();
arm();
}
// ---- Global tooltip (one fixed div, 600ms reveal, clamped on both axes) ----
const tip=document.createElement('div');tip.id='global-tooltip';document.body.appendChild(tip);
let _cx=0,_cy=0,_tipEl=null;
document.addEventListener('mousemove',e=>{_cx=e.clientX;_cy=e.clientY;},true);
function pickAnchor(trigger,w,h,cx,cy){
const O=14,vw=innerWidth,vh=innerHeight,tr=trigger.getBoundingClientRect();
const below=Math.max(cy+O,tr.bottom+4),above=Math.min(cy-h-O,tr.top-h-4);
const cands=[{x:cx+O,y:below},{x:cx-w-O,y:below},{x:cx+O,y:above},{x:cx-w-O,y:above}];
const score=c=>{let p=0;if(c.x<8||c.x+w>vw-8)p+=1000;if(c.y<8||c.y+h>vh-8)p+=1000;
const ox=Math.max(0,Math.min(c.x+w,tr.right)-Math.max(c.x,tr.left)),oy=Math.max(0,Math.min(c.y+h,tr.bottom)-Math.max(c.y,tr.top));return p+ox*oy;};
const best=cands.map(c=>({c,s:score(c)})).sort((a,b)=>a.s-b.s)[0].c;
best.x=Math.max(8,Math.min(best.x,vw-w-8));best.y=Math.max(8,Math.min(best.y,vh-h-8));return best;
}
function showTip(trigger,now){
if(_tipEl===trigger&&tip.classList.contains('visible'))return;
_tipEl=trigger;tip.textContent=trigger.getAttribute('data-tooltip')||'';
tip.classList.remove('shown','now');tip.classList.add('visible');
const r=trigger.getBoundingClientRect();
const cx=now?r.left+r.width/2:_cx,cy=now?r.bottom:_cy;
const {x,y}=pickAnchor(trigger,tip.offsetWidth,tip.offsetHeight,cx,cy);
tip.style.setProperty('--tip-x',x+'px');tip.style.setProperty('--tip-y',y+'px');
requestAnimationFrame(()=>{tip.classList.add('shown');if(now)tip.classList.add('now');});
}
function hideTip(){_tipEl=null;tip.classList.remove('visible','shown','now');}
const coarse=matchMedia('(hover: none), (pointer: coarse)').matches;
if(!coarse){
document.addEventListener('mouseover',e=>{const t=e.target.closest('[data-tooltip]');t?showTip(t):hideTip();});
document.addEventListener('mousedown',hideTip,true);
window.addEventListener('scroll',hideTip,true);
}else{
// Touch: long-press reveals the explanation, tap anywhere hides it.
let lp=null;
document.addEventListener('touchstart',e=>{const t=e.target.closest('[data-tooltip]');if(!t)return;lp=setTimeout(()=>showTip(t,true),500);},{passive:true});
document.addEventListener('touchend',()=>{if(lp){clearTimeout(lp);lp=null;}},{passive:true});
document.addEventListener('touchmove',()=>{if(lp){clearTimeout(lp);lp=null;}},{passive:true});
document.addEventListener('click',e=>{if(!e.target.closest('#global-tooltip'))hideTip();},true);
}
window.__tooltipAssert=function(){const r=tip.getBoundingClientRect();return r.left>=0&&r.right<=innerWidth&&r.top>=0&&r.bottom<=innerHeight;};
// ---- Brand popover (600ms show, 220ms hide grace) ----
(function(){const b=$('#brand'),p=$('#brandPop');let st=null,ht=null;
b.addEventListener('mouseenter',()=>{clearTimeout(ht);st=setTimeout(()=>p.classList.add('open'),600);});
b.addEventListener('mouseleave',()=>{clearTimeout(st);ht=setTimeout(()=>p.classList.remove('open'),220);});
b.addEventListener('click',e=>{if(e.target.closest('a'))return;p.classList.toggle('open');});
})();
// ---- The prompts panel is the SHARED MODULE, not ours ----
// AdomNewbiePanel owns the card, the two-column grid and its collapse, the
// table, both icons, the aligned action column, clipboard, the send path with
// its version gate, the toasts, the X, the header toggle and the open/closed
// memory. We own only our phrases, our noun, and what the demo button does.
let SAY=[],panelApi=null;
function renderSay(list){
if(!list||!list.length)return;
if(panelApi&&JSON.stringify(list)===JSON.stringify(SAY))return;
if(!window.AdomNewbiePanel){console.warn('newbie panel: module not loaded yet, will retry on the next poll');return;}
try{
const api=AdomNewbiePanel.mount({
mount:'#newbie', app:'parts search', appName:'Parts Search', slug:'adom-parts-search',
toggleButton:'#btnHelp',
demo:{label:'Play the demo', meta:'8 steps, about 2 minutes', onPlay:function(){demo.start();}},
phrases:list
});
// Only commit SAY once the mount SUCCEEDS. Committing first disabled the
// retry: every later poll short-circuited on the equality check, and
// pollState's bare catch swallowed the reason.
panelApi=api; SAY=list;
if(api&&api.overBudget&&api.overBudget.length)
console.warn('newbie panel: phrases over budget: '+JSON.stringify(api.overBudget));
}catch(e){
console.error('newbie panel mount failed: '+(e&&e.message||e)+' (retrying next poll)');
}
}
function countGreenLaunch(){}
// ---- 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){
// 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;}
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){
const flags=(hit.flags||[]).map(f=>{const k=String(f).toLowerCase();const cls=['basic','preferred','extended'].includes(k)?k:'';return '<span class="flag '+cls+'">'+escape(f)+'</span>';}).join('');
const actions=[];
if(hit.product_url)actions.push('<a href="'+escape(hit.product_url)+'" target="_blank" rel="noopener" data-tooltip="Open this part\'s page on '+VNAME[hit.vendor?String(hit.vendor).toLowerCase():'mouser']+' in a new tab: full specs, pricing tiers, and stock.">View '+ICON_EXT+'</a>');
if(hit.datasheet_url)actions.push('<a href="'+escape(hit.datasheet_url)+'" target="_blank" rel="noopener" data-tooltip="Open the manufacturer datasheet (PDF) in a new tab.">Datasheet '+ICON_EXT+'</a>');
if(hit.cad_3d_url){const fmt=(hit.cad_3d_format||'3D').toUpperCase();const xref=(hit.cad_3d_source||'').includes('xref');
actions.push('<a href="'+escape(hit.cad_3d_url)+'" target="_blank" rel="noopener" data-tooltip="Opens the '+escape(fmt)+' CAD model for '+escape(hit.mpn||'this part')+'. Most links go through SamacSys or Ultra Librarian, which may ask you to sign in first. With the STEP file local, adom-molecule create --step brings it into Adom.'+(xref?' This link came from a DigiKey hit with the same MPN; '+VNAME[String(hit.vendor).toLowerCase()]+' does not publish its own CAD.':'')+'">3D '+escape(fmt)+(xref?'<span class="xref">via DigiKey</span>':'')+' '+ICON_EXT+'</a>');}
const img=settings.showImages&&hit.image_url;
const cap=escape((hit.mpn||'')+(hit.manufacturer?' '+hit.manufacturer:''));
return '<div class="card">'
+'<div class="card-img'+(img?'':' noimg')+'" '+(img?'data-zoom="'+escape(hit.image_url)+'" data-zoom-caption="'+cap+'" data-tooltip="Click to view the product photo full size."':'')+'>'
+(img?'<img src="'+escape(hit.image_url)+'" alt="" loading="lazy" onerror="this.parentElement.classList.add(\'noimg\');this.remove()">':'')
+'<div class="placeholder">'+ICON_CHIP+(img?'':(settings.showImages?'no photo':'photos off'))+'</div></div>'
+'<div class="card-body"><div class="mpn">'+escape(hit.mpn||hit.vendor_pn||'-')+'</div>'
+(hit.vendor_pn&&hit.vendor_pn!==hit.mpn?'<div class="vendor-pn">'+escape(hit.vendor_pn)+'</div>':'')
+(hit.manufacturer?'<div class="manuf">'+escape(hit.manufacturer)+'</div>':'')
+(hit.description?'<div class="desc" title="'+escape(hit.description)+'">'+escape(hit.description)+'</div>':'')
+'<div class="row-price">'+(hit.unit_price?'<span class="price">'+escape(hit.unit_price)+'</span>':'')+(hit.price_100?'<span class="price-100">@100: '+escape(hit.price_100)+'</span>':'')
+'<span class="stock '+stockClass(hit.stock)+'" title="'+(hit.stock||0)+' units"><i style="--fill:'+Math.min(100,Math.round(Math.log10((hit.stock||0)+1)/5*100))+'%"></i>'+fmtStock(hit.stock)+' in stock</span>'+(hit.lead_time?'<span class="vendor-pn">lead '+escape(hit.lead_time)+'</span>':'')+'</div>'
+(flags?'<div class="flags">'+flags+'</div>':'')+(actions.length?'<div class="card-actions">'+actions.join('')+'</div>':'')+'</div></div>';
}
function renderColumn(v,block){
const body=$('#body-'+v),meta=$('#meta-'+v),col=$('#col-'+v);
col.classList.toggle('skipped',!!(block&&block.skipped));
if(!block||!block.ok){
const skip=block&&block.skipped;
const title=(block&&block.error)||'No response from this vendor.',expl=(block&&block.error_explanation)||'',hint=(block&&block.error_hint)||'';
body.innerHTML='<div class="col-error'+(skip?' skip':'')+'"><div class="ce-title">'+(skip?ICON_OFF:ICON_WARN)+'<span>'+escape(title)+'</span></div>'+(expl?'<div class="ce-body">'+escape(expl)+'</div>':'')+(hint?'<div class="ce-hint"><b>Next:</b> '+escape(hint)+'</div>':'')+'</div>';
meta.textContent=block&&block.elapsed_ms?fmtMs(block.elapsed_ms):'';return;
}
meta.textContent=(block.total||0)+' total, '+fmtMs(block.elapsed_ms||0);
const hits=block.hits||[];
body.innerHTML=hits.length?hits.map(renderCard).join(''):'<div class="col-empty">'+ICON_CHIP+'<span>No matches at '+VNAME[v]+'.</span></div>';
}
function skeleton(n){let h='';for(let i=0;i<Math.min(n||3,5);i++)h+='<div class="skel"><div class="b img"></div><div><div class="b l" style="width:60%"></div><div class="b l" style="width:40%"></div><div class="b l" style="width:85%"></div><div class="b l" style="width:50%;margin:0"></div></div></div>';return h;}
function renderIdle(){
const idle={mouser:'Run a search to see Mouser results.',digikey:'1 to 3 day ground shipping; the alternative when Mouser is out.',jlcpcb:'Parts library for JLCPCB-assembled boards.'};
for(const v of VENDORS){$('#body-'+v).innerHTML='<div class="col-empty">'+ICON_CHIP+'<span>'+idle[v]+'</span></div>';$('#meta-'+v).textContent='';}
}
function renderRecommendation(rec,emptyState,policy){
const el=$('#recommendation');
for(const v of VENDORS){$('#col-'+v).classList.remove('preferred');const b=$('#badge-'+v);b.hidden=!(v==='mouser'&&(policy||settings.policy)==='mouser');b.classList.remove('win');b.textContent='Preferred';}
if(rec){const vc=String(rec.winner).toLowerCase();
el.innerHTML='<div class="recommendation"><div class="ricon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg></div><div class="rbody"><div class="label">Best source</div>'
+'<div class="main">Best source: <span style="color:var(--'+vc+')">'+escape(rec.winner)+'</span>, <code>'+escape(rec.mpn)+'</code>'+(rec.unit_price?' at '+escape(rec.unit_price)+' per unit':'')+', '+fmtStock(rec.stock)+' in stock</div>'
+(rec.substitution?'<div class="subst" role="alert"><b>Not the part you asked for.</b> '+escape(rec.substitution)+'</div>':(rec.exact_match?'<div class="exact">Exact match for <code>'+escape(rec.requested_mpn||rec.mpn)+'</code></div>':''))
+'<div class="reason">'+escape(rec.reason)+'</div><div class="prov">Policy: '+((policy||settings.policy)==='cheapest'?'cheapest in stock':'Mouser preferred')+' (change it in Settings). Eligible hits need stock and a normal lead time.</div></div>'
+'</div>';
$('#col-'+vc).classList.add('preferred');const b=$('#badge-'+vc);b.hidden=false;b.classList.add('win');b.textContent='Recommended';return;}
if(emptyState){el.innerHTML='<div class="recommendation empty"><svg class="ricon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16h.01"/></svg><div style="flex:1;min-width:0"><div class="label">No source found</div><div class="main">'+escape(emptyState.title)+'</div><div class="reason">'+escape(emptyState.explanation)+'</div><div class="es-hint">'+escape(emptyState.hint)+'</div></div></div>';return;}
el.innerHTML='';
}
function renderResults(r){
lastResults=r;
for(const v of VENDORS)renderColumn(v,r[v]);
renderRecommendation(r.recommendation,r.empty_state,r.policy);
const n=VENDORS.reduce((a,v)=>a+((r[v]&&r[v].hits)||[]).length,0);
$('#subject').textContent=r.query||'';
$('#meta').textContent=n+' hits, '+fmtMs(r.elapsed_ms||0)+(r.source==='ai'?', from your AI':'');
const st=$('#status');st.textContent='Done: Mouser '+((r.mouser&&r.mouser.hits)||[]).length+', DigiKey '+((r.digikey&&r.digikey.hits)||[]).length+', JLCPCB '+((r.jlcpcb&&r.jlcpcb.hits)||[]).length+' in '+fmtMs(r.elapsed_ms||0);st.className='statusline ok';
}
// ---- Search (state lives on the server; the poll renders) ----
let searching=false,pTimer=null,busyFromPoll=false;
function setBusy(q,indet){
searching=!!q;const go=$('#go');go.disabled=searching;go.textContent=searching?'Searching':'Search all 3';
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);
if(!indet){const est=Math.max(800,avgMs||4000),t0=Date.now();pb.firstElementChild.style.width='2%';clearInterval(pTimer);pTimer=setInterval(()=>{pb.firstElementChild.style.width=Math.min(92,(Date.now()-t0)/est*100)+'%';},200);}
}else{clearInterval(pTimer);pb.firstElementChild.style.width='100%';setTimeout(()=>{pb.classList.remove('on','indet');pb.firstElementChild.style.width='0';},350);}
}
async function doSearch(keyword){
keyword=(keyword||'').trim();if(!keyword){toast('Type a part number or keyword first.','error');$('#q').focus();return;}
if(searching)return;
setBusy(keyword,false);
for(const v of VENDORS){$('#body-'+v).innerHTML=skeleton(limit);$('#meta-'+v).textContent='';}
try{const r=await fetch('search',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({keyword,limit,inStockOnly:inStock,source:'ui'})});
const v=await r.json();
if(!r.ok){const st=$('#status');st.textContent='Search failed: '+(v.error||r.statusText);st.className='statusline error';toast('Search failed: '+(v.error||r.statusText),'error');renderIdle();return;}
renderResults(v);lastSig=v.query+':'+(v.elapsed_ms||0);
const n=VENDORS.reduce((a,v2)=>a+((v[v2]&&v[v2].hits)||[]).length,0);
if(v.recommendation)toast('Recommended: '+v.recommendation.winner+' for '+v.recommendation.mpn,'success');else if(!n)toast('No matches for "'+keyword+'"','info');
}catch(e){console.error('search failed: '+(e.message||e));const st=$('#status');st.textContent='Search failed: '+(e.message||e);st.className='statusline error';toast('Search failed: '+(e.message||e),'error');renderIdle();}
finally{setBusy(null);}
}
$('#search-form').addEventListener('submit',e=>{e.preventDefault();doSearch($('#q').value);});
// Search-bar controls (apply to this search; Settings sets where they start)
function paintLimit(){document.querySelectorAll('#search-form .seg button').forEach(b=>b.setAttribute('aria-pressed',String(+b.dataset.limit===limit)));}
function paintInStock(){const t=$('#instock');t.classList.toggle('active',inStock);t.setAttribute('aria-checked',String(inStock));}
$('#search-form .seg').addEventListener('click',e=>{const b=e.target.closest('button[data-limit]');if(!b)return;limit=+b.dataset.limit;paintLimit();toast('Fetching '+limit+' hits per vendor for this search','info');});
$('#instock').addEventListener('click',()=>{inStock=!inStock;paintInStock();toast(inStock?'Showing in-stock hits only':'Showing every match, including zero stock','info');});
paintLimit();paintInStock();
// ---- Lightbox ----
(function(){const lb=$('#lightbox'),img=$('#lb-img'),cap=$('#lb-caption');
function open(src,c){img.src=src;cap.textContent=c||'';lb.classList.add('open');lb.setAttribute('aria-hidden','false');}
function close(){lb.classList.remove('open');lb.setAttribute('aria-hidden','true');setTimeout(()=>{if(!lb.classList.contains('open'))img.src='';},200);}
document.addEventListener('click',e=>{const z=e.target.closest('[data-zoom]');if(z){e.preventDefault();open(z.dataset.zoom,z.dataset.zoomCaption);return;}
if(e.target===lb||e.target.closest('#lb-close'))close();});
document.addEventListener('keydown',e=>{if(e.key==='Escape'&&lb.classList.contains('open'))close();});
})();
// ---- Settings modal ----
const dlg=$('#dlgSettings');
async function saveSettings(patch,revertTo){
Object.assign(settings,patch);remember();paintSettings();
const ctl=new AbortController();const t=setTimeout(()=>ctl.abort(),5000);
try{const r=await fetch('api/settings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({settings:patch}),signal:ctl.signal});
const d=await r.json();
if(!d.ok){toast('Setting refused: '+(d.error||'unknown'),'error');if(d.settings)Object.assign(settings,d.settings);else if(revertTo)Object.assign(settings,revertTo);remember();paintSettings();return false;}
Object.assign(settings,d.settings||{});remember();paintSettings();return true;
}catch(e){toast('Saved locally; the server did not confirm within 5 s.','error');return false;}
finally{clearTimeout(t);}
}
function setToggle(id,on){const t=$(id);t.classList.toggle('active',!!on);t.setAttribute('aria-checked',String(!!on));}
function paintSettings(){
document.querySelectorAll('#segLimit button').forEach(b=>b.setAttribute('aria-pressed',String(+b.dataset.limit===settings.defaultLimit)));
setToggle('#tglInStock',settings.inStockOnly);setToggle('#tglMouser',settings.vendorMouser);setToggle('#tglDigikey',settings.vendorDigikey);setToggle('#tglJlcpcb',settings.vendorJlcpcb);
setToggle('#tglImages',settings.showImages);setToggle('#tglTaskbar',settings.taskbarProgress);setToggle('#tglSay',settings.showSayStrip);
document.querySelectorAll('#policySet .choice').forEach(c=>{const sel=c.dataset.policy===settings.policy;c.dataset.selected=String(sel);c.querySelector('input').checked=sel;});
const ch=$('#taskbarChildren');ch.dataset.enabled=String(!!settings.taskbarProgress);$('#selBox').disabled=!settings.taskbarProgress;
const sb=$('#selBox');if(sb.value!==settings.taskbarBox){const opt=[...sb.options].find(o=>o.value===settings.taskbarBox);if(!opt&&settings.taskbarBox){const o=document.createElement('option');o.value=settings.taskbarBox;o.textContent=settings.taskbarBox;sb.appendChild(o);}sb.value=settings.taskbarBox;}
const on=VENDORS.filter(v=>settings['vendor'+v[0].toUpperCase()+v.slice(1)]);
$('#stVendors').textContent=on.length===3?'all three on':on.length?on.map(v=>VNAME[v]).join(', ')+' on':'none on';$('#stVendors').className='card-status '+(on.length===3?'ok':on.length?'warn':'fail');
$('#stPolicy').textContent=settings.policy==='cheapest'?'cheapest in stock':'Mouser preferred';
$('#stImages').textContent=settings.showImages?'on':'off';$('#stImages').className='card-status '+(settings.showImages?'ok':'');
$('#stTaskbar').textContent=(settings.taskbarProgress?'on':'off')+(avgMs?', searches average '+fmtMs(avgMs):', no search measured yet');$('#stTaskbar').className='card-status '+(settings.taskbarProgress?'ok':'');
$('#stSearch').textContent=settings.defaultLimit+' per vendor'+(settings.inStockOnly?', in stock only':'');
$('#popPolicy').textContent=settings.policy==='cheapest'?'Cheapest in stock':'Mouser preferred';
$('#badge-mouser').hidden=settings.policy!=='mouser'&&!$('#col-mouser').classList.contains('preferred');
}
async function openSettings(){
try{const r=await fetch('api/settings');const d=await r.json();if(d.settings)Object.assign(settings,d.settings);remember();
const sb=$('#selBox');const cur=settings.taskbarBox;sb.innerHTML='<option value="">First connected box</option>'+(d.boxes||[]).map(b=>'<option value="'+escape(b)+'">'+escape(b)+'</option>').join('');sb.value=cur;
$('#settingsStatus').textContent=(d.boxes||[]).length?((d.boxes||[]).length+' desktop box'+((d.boxes||[]).length>1?'es':'')+' connected'):'no desktop box connected';
}catch{}
paintSettings();paintVendorStatus();
dlg.showModal();dlg.focus();const body=$('#settingsBody');body.scrollTop=0;requestAnimationFrame(()=>{body.scrollTop=0;});
}
$('#btnSettings').addEventListener('click',openSettings);
$('#btnCloseSettings').addEventListener('click',()=>dlg.close());
dlg.addEventListener('click',e=>{if(e.target!==dlg)return;const r=dlg.getBoundingClientRect();const inside=e.clientX>=r.left&&e.clientX<=r.right&&e.clientY>=r.top&&e.clientY<=r.bottom;if(!inside)dlg.close();});
dlg.addEventListener('close',()=>{hideTip();});
// drag by the header, clamped to the viewport
(function(){const head=$('#settingsHead');let drag=null;
head.addEventListener('mousedown',e=>{if(e.target.closest('button'))return;const r=dlg.getBoundingClientRect();drag={dx:e.clientX-r.left,dy:e.clientY-r.top};dlg.style.margin='0';dlg.style.position='fixed';dlg.style.left=r.left+'px';dlg.style.top=r.top+'px';e.preventDefault();});
document.addEventListener('mousemove',e=>{if(!drag)return;const w=dlg.offsetWidth,h=dlg.offsetHeight;dlg.style.left=Math.max(0,Math.min(e.clientX-drag.dx,innerWidth-w))+'px';dlg.style.top=Math.max(0,Math.min(e.clientY-drag.dy,innerHeight-h))+'px';});
document.addEventListener('mouseup',()=>{drag=null;});
})();
$('#segLimit').addEventListener('click',e=>{const b=e.target.closest('button[data-limit]');if(!b)return;const prev={defaultLimit:settings.defaultLimit};saveSettings({defaultLimit:+b.dataset.limit},prev).then(ok=>{if(ok){limit=settings.defaultLimit;paintLimit();toast('Default set to '+settings.defaultLimit+' hits per vendor','success');}});});
function bindToggle(id,key,label){$(id).addEventListener('click',()=>{const prev={[key]:settings[key]};saveSettings({[key]:!settings[key]},prev).then(ok=>{if(ok){toast(label+(settings[key]?' on':' off'),'success');if(key==='inStockOnly'){inStock=settings.inStockOnly;paintInStock();}if(key==='showImages'&&lastResults)renderResults(lastResults);if(key.startsWith('vendor'))paintVendorStatus(true);}});});}
bindToggle('#tglInStock','inStockOnly','In stock only by default');bindToggle('#tglMouser','vendorMouser','Mouser');bindToggle('#tglDigikey','vendorDigikey','DigiKey');bindToggle('#tglJlcpcb','vendorJlcpcb','JLCPCB');
bindToggle('#tglImages','showImages','Product photos');bindToggle('#tglTaskbar','taskbarProgress','Taskbar progress bar');bindToggle('#tglSay','showSayStrip','Say it strip');
$('#policySet').addEventListener('change',e=>{const v=e.target.value;if(!v||v===settings.policy)return;const prev={policy:settings.policy};saveSettings({policy:v},prev).then(ok=>{if(ok){toast('Policy: '+(v==='cheapest'?'cheapest in stock':'Mouser preferred')+'. Run the search again to re-rank.','success');if(lastResults)renderRecommendation(lastResults.recommendation,lastResults.empty_state,lastResults.policy);}});});
$('#selBox').addEventListener('change',e=>{const prev={taskbarBox:settings.taskbarBox};saveSettings({taskbarBox:e.target.value},prev).then(ok=>{if(ok)toast('Taskbar bar paints on '+(settings.taskbarBox||'the first connected box'),'success');});});
(function(){const btn=$('#btnRestore'),lbl=$('#restoreLabel'),ic=$('#restoreIcon');let armed=false,t=null;
const RESTORE='M13 3a9 9 0 0 0-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42A8.954 8.954 0 0 0 13 21a9 9 0 0 0 0-18m-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8z';
const ALERT='M11 15h2v2h-2v-2m0-8h2v6h-2V7m1-5C6.47 2 2 6.5 2 12a10 10 0 0 0 10 10 10 10 0 0 0 10-10A10 10 0 0 0 12 2m0 18a8 8 0 0 1-8-8 8 8 0 0 1 8-8 8 8 0 0 1 8 8 8 8 0 0 1-8 8z';
function disarm(){armed=false;btn.classList.remove('armed');lbl.textContent='Restore defaults';ic.setAttribute('d',RESTORE);clearTimeout(t);}
btn.addEventListener('click',async()=>{if(!armed){armed=true;btn.classList.add('armed');lbl.textContent='Click again to confirm';ic.setAttribute('d',ALERT);t=setTimeout(disarm,4000);return;}
disarm();try{const r=await fetch('api/settings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({restore:true})});const d=await r.json();if(d.ok){settings=Object.assign({},DEFAULTS,d.settings||{});remember();limit=settings.defaultLimit;inStock=settings.inStockOnly;paintLimit();paintInStock();paintSettings();toast('Every setting restored to its default','success');}else toast('Restore refused: '+(d.error||''),'error');}catch(e){toast('Restore failed: '+e.message,'error');}});
})();
// ---- Health: the header LED, the footer LEDs, and the vendor rows in Settings ----
let lastStatus=null,avgMs=null;
function paintVendorStatus(force){
if(!lastStatus)return;
for(const v of VENDORS){const led=lastStatus.leds.find(l=>l.label.toLowerCase()===v);const el=$('#rs'+VNAME[v].replace('DigiKey','Digikey').replace('JLCPCB','Jlcpcb'));if(!led||!el)continue;
const on=settings['vendor'+v[0].toUpperCase()+v.slice(1)];el.textContent=on?led.detail:'off, not queried';el.className='rstat '+(on?led.state:'');}
}
async function pollStatus(){
try{const r=await fetch('api/status');if(!r.ok)return;const s=await r.json();lastStatus=s;avgMs=s.timing&&s.timing.search_avg_ms||avgMs;
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{}
}
pollStatus();setInterval(pollStatus,15000);
// ---- State poll: the server is the single source of truth ----
let lastSig=null,lastResults=null,lastClickTs=Date.now(),first=true;
function clickById(id,value){
const map={search:()=>$('#go').click(),instock:()=>$('#instock').click(),settings:()=>{if(!dlg.open)$('#btnSettings').click();},'close-settings':()=>{if(dlg.open)dlg.close();},restore:()=>$('#btnRestore').click(),
limit:()=>{const b=document.querySelector('#search-form .seg button[data-limit="'+value+'"]');if(b)b.click();},policy:()=>{const i=document.querySelector('#policySet input[value="'+value+'"]');if(i){i.checked=true;i.dispatchEvent(new Event('change',{bubbles:true}));}},
demo:()=>demo.start(),'demo-next':()=>demo.next(),'demo-prev':()=>demo.prev(),'demo-pause':()=>demo.toggle(),'demo-exit':()=>demo.exit(),'say-close':()=>$('#btnHelp').click(),'say':()=>$('#tglSay').click(),'vendor-mouser':()=>$('#tglMouser').click(),'vendor-digikey':()=>$('#tglDigikey').click(),'vendor-jlcpcb':()=>$('#tglJlcpcb').click(),images:()=>$('#tglImages').click(),taskbar:()=>$('#tglTaskbar').click(),query:()=>{$('#q').value=value||'';$('#q').focus();}};
if(map[id]){map[id]();console.log('ui click: '+id+(value?' '+value:''));return true;}
const el=document.getElementById(id);if(el){el.click();console.log('ui click: #'+id);return true;}
console.warn('ui click: no control with id "'+id+'"');return false;
}
async function pollState(){
try{const r=await fetch('state');if(!r.ok)return;const v=await r.json();
if(v.say)renderSay(v.say);
if(v.timing&&v.timing.search_avg_ms)avgMs=v.timing.search_avg_ms;
if(v.settings&&JSON.stringify(v.settings)!==JSON.stringify(settings)){Object.assign(settings,v.settings);remember();if(!dlg.open){limit=settings.defaultLimit;inStock=settings.inStockOnly;paintLimit();paintInStock();}paintSettings();}
for(const t of v.toasts||[]){if(seenToasts.has(t.id))continue;seenToasts.add(t.id);if(first)continue;toast(t.message,t.kind||'info');}
for(const c of v.clicks||[]){if(c.ts<=lastClickTs)continue;lastClickTs=c.ts;clickById(c.id,c.value);}
if(v.searching&&!searching){busyFromPoll=true;setBusy(v.searching,true);$('#q').value=v.searching;for(const vv of VENDORS){$('#body-'+vv).innerHTML=skeleton(settings.defaultLimit);}}
else if(!v.searching&&searching&&busyFromPoll){busyFromPoll=false;setBusy(null);}
const sig=v.last_query_ts||null;
if(sig&&sig!==lastSig){lastSig=sig;if(v.last_query)$('#q').value=v.last_query;if(v.last_results){lastResults=v.last_results;renderResults(v.last_results);}}
first=false;
}catch{}
}
// ---- 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"/>';}
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:'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,stepAt:0};
function paintSpot(){
if(!demoState.active)return;
const st=STEPS[demoState.step];
if(!st){demoState.raf=requestAnimationFrame(paintSpot);return;}
let el=document.querySelector(st.target);
if(el&&st.target==='#recommendation'&&!el.firstElementChild)el=document.querySelector(st.fallback);
if(!el||(el.getBoundingClientRect().width===0)){spot.classList.remove('on');}
else{const r=el.getBoundingClientRect();spot.classList.add('on');spot.style.left=(r.left-6)+'px';spot.style.top=(r.top-6)+'px';spot.style.width=(r.width+12)+'px';spot.style.height=(r.height+12)+'px';}
demoState.raf=requestAnimationFrame(paintSpot);
}
function paintHud(){
$('#dhStep').textContent=(demoState.step+1)+' / '+STEPS.length;
$('#dhName').textContent=STEPS[demoState.step].name;
$('#dhProg').innerHTML=STEPS.map((_,i)=>'<i class="'+(i<demoState.step?'done':i===demoState.step?'cur':'')+'"></i>').join('');
$('#dhPlayIcon').innerHTML=demoState.playing?'<path d="M8 6h3v12H8zM13 6h3v12h-3z"/>':'<path d="M9 6.5v11l8.5-5.5z"/>';
$('#dhPlay').setAttribute('aria-label',demoState.playing?'Pause':'Play');
}
function goto(n){
if(!demoState.active)return;
const prev=STEPS[demoState.step];if(prev&&prev.leave)try{prev.leave();}catch{}
if(n<0)n=0;
if(n>=STEPS.length){exit();toast('Demo finished. Ask your AI for parts, or just type into the box.','success');return;}
demoState.step=n;const st=STEPS[n];
if(st.enter)try{st.enter();}catch(e){console.warn('demo step enter failed: '+e.message);}
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 toolbar to hear the narration (browsers unlock audio on a click).','info');});
paintHud();
}
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');paintMute();
paintSpot();goto(0);
console.log('demo started');
}
function exit(){
if(!demoState.active)return;
const st=STEPS[demoState.step];if(st&&st.leave)try{st.leave();}catch{}
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;
head.addEventListener('mousedown',e=>{const r=hud.getBoundingClientRect();drag={dx:e.clientX-r.left,dy:e.clientY-r.top};hud.style.right='auto';hud.style.bottom='auto';e.preventDefault();});
document.addEventListener('mousemove',e=>{if(!drag)return;hud.style.left=Math.max(0,Math.min(e.clientX-drag.dx,innerWidth-hud.offsetWidth))+'px';hud.style.top=Math.max(0,Math.min(e.clientY-drag.dy,innerHeight-hud.offsetHeight))+'px';});
document.addEventListener('mouseup',()=>{drag=null;});
})();
return {start,exit,next:()=>goto(demoState.step+1),prev:()=>goto(demoState.step-1),toggle:()=>$('#dhPlay').click(),active:()=>demoState.active};
})();
(function(){const ex=['STM32F103RBT6','LM358','ESP32-S3-WROOM-1','10k 0402','LDO 3.3V 500mA','USB-C receptacle'];const host=$('#examples');host.insertAdjacentHTML('beforeend',ex.map(e=>'<button type="button" class="ex" data-tooltip="Run this example search.">'+escape(e)+'</button>').join(''));host.addEventListener('click',e=>{const b=e.target.closest('.ex');if(!b)return;$('#q').value=b.textContent;doSearch(b.textContent);});})();
renderIdle();paintSettings();pollState();setInterval(pollState,500);
</script>
</body>
</html>