app
Adom Parts Search
Public Made by Adomby adom
One search box across Mouser + DigiKey + JLCPCB. Parallel queries, side-by-side product photos, and a Mouser-preferred recommendation (40-min drone delivery to Fort Worth) that reasons around stock and lead time.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Adom Parts Search</title>
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<style>
@font-face {
font-family: 'Familjen Grotesk';
src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-600-normal.woff2') format('woff2');
font-weight: 600; font-display: swap;
}
@font-face {
font-family: 'Satoshi';
src: url('https://adom.inc/fonts/satoshi/satoshi-400-normal.woff2') format('woff2');
font-weight: 400; font-display: swap;
}
@font-face {
font-family: 'Satoshi';
src: url('https://adom.inc/fonts/satoshi/satoshi-500-normal.woff2') format('woff2');
font-weight: 500; font-display: swap;
}
:root {
--bg: #0d1117; --surface: #161b22; --surface2: #1c2129;
--border: #30363d; --text: #e6edf3; --text-dim: #8b949e;
--accent: #00b8b0;
--green: #3fb950; --red: #f85149; --yellow: #d29922; --blue: #58a6ff;
--mouser: #0073CF; --digikey: #CC0000; --jlcpcb: #57A84A;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
background: var(--bg); color: var(--text);
font-family: 'Satoshi', sans-serif; font-size: 14px; line-height: 1.55;
min-height: 100vh; overflow-x: hidden;
}
body { padding: 20px 24px 80px; }
h1 { font-family: 'Familjen Grotesk', sans-serif; font-weight: 600; font-size: 22px; letter-spacing: -0.01em; }
.hdr { display: flex; align-items: center; gap: 14px; margin-bottom: 16px; }
.hdr .logo { width: 34px; height: 34px; color: var(--text); display: inline-flex; }
.hdr .subtitle { color: var(--text-dim); font-size: 13px; margin-top: 2px; }
.searchbar {
display: flex; gap: 10px; align-items: stretch; margin-bottom: 10px;
background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 6px;
}
.searchbar:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(0,184,176,0.15); }
.searchbar input {
flex: 1; background: transparent; border: none; outline: none; color: var(--text);
font-family: 'Satoshi', sans-serif; font-size: 15px; padding: 10px 14px;
}
.searchbar input::placeholder { color: var(--text-dim); }
.limit-select {
background: var(--surface2); border: 1px solid var(--border); border-radius: 8px;
color: var(--text); padding: 0 10px; font-size: 13px; cursor: pointer;
font-family: 'JetBrains Mono', monospace;
}
.btn { padding: 10px 18px; border: none; border-radius: 8px; cursor: pointer; font-weight: 600; font-size: 14px; }
.btn.primary { background: var(--accent); color: #0d1117; }
.btn.primary:hover { background: #00d4cb; }
.btn.primary:disabled { background: var(--border); color: var(--text-dim); cursor: not-allowed; }
.filters { display: flex; gap: 14px; align-items: center; color: var(--text-dim); font-size: 13px; margin-bottom: 20px; }
.filters label { display: flex; gap: 6px; align-items: center; cursor: pointer; }
.filters input[type=checkbox] { accent-color: var(--accent); width: 14px; height: 14px; }
.recommendation {
display: flex; align-items: center; gap: 14px;
background: linear-gradient(90deg, rgba(0,184,176,0.10), rgba(0,184,176,0.02));
border: 1px solid rgba(0,184,176,0.35);
border-radius: 12px; padding: 14px 18px; margin-bottom: 20px;
}
.recommendation .label { font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: var(--accent); font-weight: 600; }
.recommendation .main { font-family: 'Familjen Grotesk', sans-serif; font-size: 17px; font-weight: 600; }
.recommendation .reason { color: var(--text-dim); font-size: 13px; margin-top: 2px; }
.recommendation.empty { background: linear-gradient(90deg, rgba(210,153,34,0.10), rgba(210,153,34,0.02)); border-color: rgba(210,153,34,0.35); }
.recommendation.empty .label { color: var(--yellow); }
.recommendation.empty .es-hint { color: var(--text); font-size: 12.5px; margin-top: 6px; padding-top: 6px; border-top: 1px dashed var(--border); }
.recommendation.empty .es-hint::before { content: "→ "; color: var(--accent); font-weight: 600; }
.statusline { color: var(--text-dim); font-size: 13px; margin: 4px 0 14px; min-height: 20px; }
.statusline.error { color: var(--red); }
.statusline.ok { color: var(--green); }
.loading { display: inline-block; width: 14px; height: 14px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; vertical-align: middle; margin-right: 6px; }
@keyframes spin { to { transform: rotate(360deg); } }
/* Auto-fit: always fit as many columns as will fit at ≥320 px each.
Narrow panes get 1-col, medium 2-col, wide 3-col — no hard breakpoint
that drops us from 3 straight to 1 (the previous 1100 px rule did
that, hiding the other two columns below a horizontal scroll in a
split Hydrogen pane). */
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; }
.column { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; min-height: 240px; display: flex; flex-direction: column; }
.column.mouser { border-top: 3px solid var(--mouser); }
.column.digikey { border-top: 3px solid var(--digikey); }
.column.jlcpcb { border-top: 3px solid var(--jlcpcb); }
.column.preferred { box-shadow: 0 0 0 2px rgba(0,184,176,0.35), 0 12px 40px rgba(0,184,176,0.10); }
.col-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 10px; padding-bottom: 10px; border-bottom: 1px solid var(--border); flex-wrap: wrap; }
.col-title { font-family: 'Familjen Grotesk', sans-serif; font-size: 19px; font-weight: 600; }
.col-title.mouser { color: var(--mouser); }
.col-title.digikey { color: var(--digikey); }
.col-title.jlcpcb { color: var(--jlcpcb); }
.col-meta { color: var(--text-dim); font-size: 11px; font-family: 'JetBrains Mono', monospace; }
.col-badge { font-size: 10px; text-transform: uppercase; letter-spacing: 0.8px; font-weight: 600; padding: 3px 8px; border-radius: 6px; background: rgba(0,184,176,0.15); color: var(--accent); border: 1px solid rgba(0,184,176,0.3); }
.card { display: grid; grid-template-columns: 72px 1fr; gap: 12px; padding: 10px 0; border-bottom: 1px dashed var(--border); }
.card:last-child { border-bottom: none; }
.card-img { width: 72px; height: 72px; border-radius: 8px; background: #fff; display: flex; align-items: center; justify-content: center; overflow: hidden; cursor: zoom-in; flex-shrink: 0; }
.card-img img { width: 100%; height: 100%; object-fit: contain; }
.card-img .placeholder { color: #6b7380; font-size: 10px; }
.card-body { min-width: 0; display: flex; flex-direction: column; gap: 3px; }
.mpn { font-family: 'JetBrains Mono', monospace; color: var(--text); font-size: 13px; font-weight: 600; word-break: break-word; }
.vendor-pn { font-family: 'JetBrains Mono', monospace; color: var(--text-dim); font-size: 11px; }
.manuf { color: var(--text-dim); font-size: 11px; }
.desc { color: var(--text); font-size: 12px; line-height: 1.4; margin-top: 2px; }
.row-price { display: flex; gap: 10px; font-size: 12px; margin-top: 4px; align-items: baseline; flex-wrap: wrap; }
.price { font-family: 'JetBrains Mono', monospace; color: var(--accent); font-weight: 600; }
.price-100 { font-family: 'JetBrains Mono', monospace; color: var(--text-dim); font-size: 11px; }
.stock.high { color: var(--green); }
.stock.medium { color: var(--yellow); }
.stock.low { color: var(--red); }
.stock.zero { color: var(--text-dim); }
.flags { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 4px; }
.flag { font-size: 10px; font-family: 'JetBrains Mono', monospace; padding: 1px 6px; border-radius: 4px; background: var(--surface2); color: var(--text-dim); border: 1px solid var(--border); }
.flag.basic { color: var(--green); border-color: rgba(63,185,80,0.3); }
.flag.preferred { color: var(--blue); border-color: rgba(88,166,255,0.3); }
.card-actions { margin-top: 6px; display: flex; gap: 8px; }
.card-actions a { font-size: 11px; color: var(--accent); text-decoration: none; padding: 3px 8px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface2); }
.card-actions a:hover { border-color: var(--accent); }
.xref-badge { font-size: 9px; padding: 1px 4px; border-radius: 3px; background: var(--surface); color: var(--text-dim); margin-left: 4px; border: 1px solid var(--border); vertical-align: baseline; }
.col-empty { flex: 1; display: flex; align-items: center; justify-content: center; color: var(--text-dim); font-size: 13px; font-style: italic; padding: 30px 10px; text-align: center; }
.col-error { padding: 16px 14px; display: flex; flex-direction: column; gap: 10px; }
.col-error .ce-title { display: flex; align-items: center; gap: 8px; color: var(--yellow); font-family: 'Familjen Grotesk', sans-serif; font-weight: 600; font-size: 14px; line-height: 1.3; }
.col-error .ce-icon { width: 18px; height: 18px; flex-shrink: 0; color: var(--yellow); }
.col-error .ce-body { color: var(--text); font-size: 12.5px; line-height: 1.5; }
.col-error .ce-hint { color: var(--text-dim); font-size: 12px; line-height: 1.5; padding-top: 4px; border-top: 1px dashed var(--border); }
.col-error .ce-hint::before { content: "→ "; color: var(--accent); font-weight: 600; }
.lightbox { position: fixed; inset: 0; z-index: 100000; background: rgba(13,17,23,0.92); backdrop-filter: blur(10px); display: flex; align-items: center; justify-content: center; opacity: 0; pointer-events: none; transition: opacity 0.18s ease; cursor: zoom-out; }
.lightbox.open { opacity: 1; pointer-events: auto; }
.lightbox img { max-width: min(90vw, 1200px); max-height: 86vh; border-radius: 12px; box-shadow: 0 24px 80px rgba(0,0,0,0.7); background: #fff; }
.lb-caption { position: absolute; bottom: 24px; left: 50%; transform: translateX(-50%); color: var(--text); background: rgba(22,27,34,0.88); padding: 10px 18px; border-radius: 10px; font-size: 13px; border: 1px solid var(--border); max-width: min(90vw, 900px); font-family: 'JetBrains Mono', monospace; }
.lb-close { position: absolute; top: 20px; right: 24px; width: 40px; height: 40px; border-radius: 50%; background: rgba(22,27,34,0.88); border: 1px solid var(--border); color: var(--text); display: flex; align-items: center; justify-content: center; cursor: pointer; font-size: 18px; }
[data-tooltip] { position: relative; }
[data-tooltip]::after {
content: attr(data-tooltip);
position: absolute;
bottom: calc(100% + 8px);
left: 0;
right: auto;
width: max-content;
max-width: min(360px, calc(100vw - 48px));
white-space: pre-wrap;
background: rgba(22,27,34,0.96);
backdrop-filter: blur(16px);
border: 1px solid rgba(0,184,176,0.2);
border-radius: 10px;
padding: 12px 16px;
font-family: 'Satoshi', sans-serif;
font-size: 12px;
line-height: 1.55;
color: #e6edf3;
text-align: left;
pointer-events: none;
opacity: 0;
transform: translateY(4px);
box-shadow: 0 12px 40px rgba(0,0,0,0.55);
transition: opacity 0.2s ease 0s, transform 0.2s ease 0s;
z-index: 99999;
}
[data-tooltip][data-tooltip-align="right"]::after { left: auto; right: 0; }
[data-tooltip]:hover::after {
opacity: 1;
transform: translateY(0);
transition: opacity 0.2s ease 0.5s, transform 0.2s ease 0.5s;
}
</style>
</head>
<body>
<div class="hdr">
<span class="logo" aria-hidden="true">
<svg width="30" height="30" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<g fill="none" stroke="currentColor" stroke-width="4" stroke-linecap="round" stroke-linejoin="round">
<rect x="10" y="14" width="28" height="28" rx="3"/>
<path d="M6 20 L10 20 M6 28 L10 28 M6 36 L10 36 M38 20 L42 20 M38 28 L42 28 M38 36 L42 36 M16 10 L16 14 M24 10 L24 14 M32 10 L32 14 M16 42 L16 46 M24 42 L24 46 M32 42 L32 46"/>
</g>
<circle cx="42" cy="40" r="10" fill="#0d1117" stroke="currentColor" stroke-width="4"/>
<line x1="50" y1="48" x2="58" y2="56" stroke="currentColor" stroke-width="5" stroke-linecap="round"/>
</svg>
</span>
<div>
<h1>Adom Parts Search</h1>
<div class="subtitle">Mouser · DigiKey · JLCPCB — parallel search, Mouser-preferred (40-min drone, Fort Worth ↔ Mansfield, near-zero shipping).</div>
</div>
</div>
<form id="search-form" class="searchbar" autocomplete="off">
<input id="q" name="keyword" type="text" placeholder="Search all three vendors: STM32F103RBT6, iCE40HX4K-TQ144, LDO 3.3V 500mA …" autofocus>
<select id="limit" class="limit-select"
data-tooltip="How many top hits to fetch per vendor. Each vendor's column shows up to this many product cards; the recommendation banner above only ever ranks the top pick. Set higher (10) when you want to compare variants, lower (3) when you just want a quick snapshot. Higher limits cost more upstream API calls — Mouser's free-tier quota is ~1000/day, so 10 means ~100 searches/day max on Mouser.">
<option value="3">3</option>
<option value="5" selected>5</option>
<option value="10">10</option>
</select>
<button type="submit" class="btn primary" id="go"
data-tooltip="Fires one keyword search in parallel against all three vendor backends (Mouser, DigiKey, JLCPCB) and renders the results as three columns below. Also computes a single recommendation — Mouser wins by default because of the 40-minute drone delivery from Mansfield to our Fort Worth facility and near-zero shipping; DigiKey or JLCPCB only win when Mouser is out of stock, has a long lead time, or is meaningfully more expensive on a volume order."
data-tooltip-align="right">Search all 3</button>
</form>
<div class="filters">
<label data-tooltip="When checked, filters out any vendor hits with stock = 0 before the results are shown. Useful when you want to buy today and don't care about backorders or long-lead-time parts. Leave unchecked when you're researching a BOM and want to see all options even if some are out of stock (you might accept a 60-day lead if the part is a perfect fit).">
<input type="checkbox" id="instock"> In stock only
</label>
</div>
<div id="status" class="statusline"></div>
<div id="recommendation"></div>
<div id="grid" class="grid">
<div id="col-mouser" class="column mouser">
<div class="col-head">
<span class="col-title mouser">Mouser</span>
<span class="col-badge">Preferred · 40-min drone · $0 ship</span>
<span class="col-meta" id="meta-mouser"></span>
</div>
<div class="col-body" id="body-mouser"><div class="col-empty">Run a search to see Mouser results.</div></div>
</div>
<div id="col-digikey" class="column digikey">
<div class="col-head">
<span class="col-title digikey">DigiKey</span>
<span class="col-meta" id="meta-digikey"></span>
</div>
<div class="col-body" id="body-digikey"><div class="col-empty">1–3 day ground ship · alternative when Mouser is out.</div></div>
</div>
<div id="col-jlcpcb" class="column jlcpcb">
<div class="col-head">
<span class="col-title jlcpcb">JLCPCB</span>
<span class="col-meta" id="meta-jlcpcb"></span>
</div>
<div class="col-body" id="body-jlcpcb"><div class="col-empty">Parts library — for JLCPCB-assembled boards.</div></div>
</div>
</div>
<div id="lightbox" class="lightbox" role="dialog" aria-hidden="true">
<div class="lb-close" id="lb-close">✕</div>
<img id="lb-img" alt="">
<div class="lb-caption" id="lb-caption"></div>
</div>
<script>
(function() {
const orig = { log: console.log, warn: console.warn, error: console.error };
function fwd(level, args) {
orig[level].apply(console, args);
try {
fetch('console', { method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ level, ts: Date.now(),
message: args.map(a => { try { return typeof a === 'string' ? a : JSON.stringify(a); } catch { return String(a); } }).join(' ')
}) }).catch(() => {});
} catch {}
}
console.log = (...a) => fwd('log', a);
console.warn = (...a) => fwd('warn', a);
console.error = (...a) => fwd('error', a);
window.addEventListener('error', e => fwd('error', [e.message, (e.filename||'')+':'+(e.lineno||'')]));
window.addEventListener('unhandledrejection', e => fwd('error', ['unhandledrejection', String(e.reason)]));
})();
function escape(s) { return String(s||'').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
function fmtStock(n) { if (n >= 1000000) return (n/1000000).toFixed(1)+'M'; if (n >= 1000) return (n/1000).toFixed(1)+'k'; return String(n||0); }
function stockClass(n) { if (!n) return 'zero'; if (n < 1000) return 'low'; if (n < 10000) return 'medium'; return 'high'; }
function renderCard(hit) {
const flags = (hit.flags||[]).map(f => {
const cls = ['basic','preferred','extended'].includes(String(f).toLowerCase()) ? String(f).toLowerCase() : '';
return `<span class="flag ${cls}">${escape(f)}</span>`;
}).join('');
const actions = [];
if (hit.product_url) actions.push(`<a href="${escape(hit.product_url)}" target="_blank" rel="noopener">Buy ↗</a>`);
if (hit.datasheet_url) actions.push(`<a href="${escape(hit.datasheet_url)}" target="_blank" rel="noopener">Datasheet ↗</a>`);
if (hit.cad_3d_url) {
const fmt = (hit.cad_3d_format || '3D').toUpperCase();
const src = hit.cad_3d_source || 'vendor';
const xrefBadge = src.includes('xref') ? ' <span class="xref-badge" title="URL came from a DigiKey hit with the same MPN; this vendor doesn\'t publish its own CAD">via DigiKey</span>' : '';
actions.push(`<a href="${escape(hit.cad_3d_url)}" target="_blank" rel="noopener"
data-tooltip="Opens the ${escape(fmt)} CAD model for ${escape(hit.mpn || 'this part')}. Most links redirect through SamacSys / Ultra Librarian, which may ask you to sign in before the download starts. Once you've got the STEP file locally, pipe it into \`adom-molecule create --step …\` to bring it into the Adom molecule system."
>3D ${fmt} ↗${xrefBadge}</a>`);
}
return `
<div class="card">
<div class="card-img" ${hit.image_url ? `data-zoom="${escape(hit.image_url)}" data-zoom-caption="${escape((hit.mpn||'')+' — '+(hit.manufacturer||''))}"` : ''}>
${hit.image_url ? `<img src="${escape(hit.image_url)}" alt="" onerror="this.style.display='none'; this.nextElementSibling.style.display='block'">` : ''}
<div class="placeholder" ${hit.image_url ? 'style="display:none"' : ''}>no image</div>
</div>
<div class="card-body">
<div class="mpn">${escape(hit.mpn || hit.vendor_pn || '—')}</div>
${hit.vendor_pn && hit.vendor_pn !== hit.mpn ? `<div class="vendor-pn">${escape(hit.vendor_pn)}</div>` : ''}
${hit.manufacturer ? `<div class="manuf">${escape(hit.manufacturer)}</div>` : ''}
${hit.description ? `<div class="desc">${escape(hit.description).slice(0,160)}</div>` : ''}
<div class="row-price">
${hit.unit_price ? `<span class="price">${escape(hit.unit_price)}</span>` : ''}
${hit.price_100 ? `<span class="price-100">@100: ${escape(hit.price_100)}</span>` : ''}
<span class="stock ${stockClass(hit.stock)}">${fmtStock(hit.stock)} in stock</span>
${hit.lead_time ? `<span class="vendor-pn">lead: ${escape(hit.lead_time)}</span>` : ''}
</div>
${flags ? `<div class="flags">${flags}</div>` : ''}
${actions.length ? `<div class="card-actions">${actions.join('')}</div>` : ''}
</div>
</div>`;
}
function renderColumn(vendorKey, block) {
const body = document.getElementById('body-'+vendorKey);
const meta = document.getElementById('meta-'+vendorKey);
if (!block || !block.ok) {
const title = (block && block.error) || 'No response from vendor.';
const expl = (block && block.error_explanation) || '';
const hint = (block && block.error_hint) || '';
body.innerHTML = `
<div class="col-error">
<div class="ce-title">
<svg class="ce-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
<span>${escape(title)}</span>
</div>
${expl ? `<div class="ce-body">${escape(expl)}</div>` : ''}
${hint ? `<div class="ce-hint">${escape(hint)}</div>` : ''}
</div>`;
meta.textContent = block && block.elapsed_ms ? block.elapsed_ms+' ms' : '';
return;
}
meta.textContent = `${block.total||0} total · ${block.elapsed_ms} ms`;
const hits = block.hits || [];
body.innerHTML = hits.length ? hits.map(renderCard).join('') : '<div class="col-empty">No matches.</div>';
}
function renderRecommendation(rec, emptyState) {
const el = document.getElementById('recommendation');
for (const v of ['mouser','digikey','jlcpcb']) document.getElementById('col-'+v).classList.remove('preferred');
if (rec) {
const vc = String(rec.winner).toLowerCase();
el.innerHTML = `
<div class="recommendation">
<div style="flex:1">
<div class="label">Recommendation</div>
<div class="main">Order <span style="color:var(--${vc})">${escape(rec.winner)}</span> · <code>${escape(rec.mpn)}</code> ${rec.unit_price ? '· '+escape(rec.unit_price)+'/unit' : ''} · ${fmtStock(rec.stock)} stock</div>
<div class="reason">${escape(rec.reason)}</div>
</div>
</div>`;
document.getElementById('col-'+vc).classList.add('preferred');
return;
}
if (emptyState) {
el.innerHTML = `
<div class="recommendation empty">
<div style="flex:1">
<div class="label">No recommendation</div>
<div class="main">${escape(emptyState.title)}</div>
<div class="reason">${escape(emptyState.explanation)}</div>
<div class="es-hint">${escape(emptyState.hint)}</div>
</div>
</div>`;
return;
}
el.innerHTML = '';
}
async function doSearch(keyword) {
if (!keyword.trim()) return;
const limit = parseInt(document.getElementById('limit').value, 10);
const inStock = document.getElementById('instock').checked;
const go = document.getElementById('go');
go.disabled = true; go.textContent = 'Searching…';
document.getElementById('status').innerHTML = '<span class="loading"></span> querying Mouser + DigiKey + JLCPCB in parallel…';
for (const v of ['mouser','digikey','jlcpcb']) {
document.getElementById('body-'+v).innerHTML = '<div class="col-empty"><span class="loading"></span> searching…</div>';
document.getElementById('meta-'+v).textContent = '';
}
try {
const r = await fetch('search', { method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ keyword, limit, inStockOnly: inStock }) });
const v = await r.json();
if (!r.ok) {
const st = document.getElementById('status');
st.textContent = 'error: '+(v.error||r.statusText); st.className = 'statusline error';
return;
}
renderColumn('mouser', v.mouser);
renderColumn('digikey', v.digikey);
renderColumn('jlcpcb', v.jlcpcb);
renderRecommendation(v.recommendation, v.empty_state);
const st = document.getElementById('status');
st.textContent = `Done — Mouser ${v.mouser?.hits?.length||0} · DigiKey ${v.digikey?.hits?.length||0} · JLCPCB ${v.jlcpcb?.hits?.length||0}`;
st.className = 'statusline ok';
} catch (e) {
console.error('search failed (see column error cards for details)');
const st = document.getElementById('status');
st.textContent = 'error: '+(e.message||e); st.className = 'statusline error';
} finally {
go.disabled = false; go.textContent = 'Search all 3';
}
}
document.getElementById('search-form').addEventListener('submit', e => {
e.preventDefault();
doSearch(document.getElementById('q').value);
});
(function() {
const lb = document.getElementById('lightbox');
const img = document.getElementById('lb-img');
const cap = document.getElementById('lb-caption');
function open(src, caption) { img.src = src; cap.textContent = caption||''; lb.classList.add('open'); }
function close() { lb.classList.remove('open'); setTimeout(() => { if (!lb.classList.contains('open')) img.src = ''; }, 200); }
document.addEventListener('click', e => {
const z = e.target.closest('[data-zoom]');
if (z) { e.preventDefault(); open(z.dataset.zoom, z.dataset.zoomCaption); return; }
if (e.target === lb || e.target.id === 'lb-close') close();
});
document.addEventListener('keydown', e => { if (e.key === 'Escape') close(); });
})();
// Server state is the single source of truth (app-creator §7a).
// The UI polls /state every 500ms; whenever a new query lands on the
// server (from the form submit, from the CLI's `show` subcommand, or
// from any other client posting to /search), the UI re-renders. This
// is what makes the app CLI-drivable and AI-drivable without any
// URL-param or eval hackery.
let lastSig = null;
async function pollState() {
try {
const r = await fetch('state');
if (!r.ok) return;
const v = await r.json();
const sig = v.last_query_ts || (v.last_query ? v.last_query + ':' + JSON.stringify(v.last_results || {}).length : null);
if (sig === lastSig) return;
lastSig = sig;
if (v.last_query) document.getElementById('q').value = v.last_query;
if (v.last_results) {
renderColumn('mouser', v.last_results.mouser);
renderColumn('digikey', v.last_results.digikey);
renderColumn('jlcpcb', v.last_results.jlcpcb);
renderRecommendation(v.last_results.recommendation, v.last_results.empty_state);
}
} catch {}
}
pollState();
setInterval(pollState, 500);
</script>
</body>
</html>