app
Adom Chip Fetcher
Public Made by Adomby adom
Your whole parts library — manufacturer-grade chip CAD (symbol, footprint, 3D) one tap from your EDA tool.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>chip-fetcher viewer</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%2300b8b0'><rect x='6' y='6' width='12' height='12' rx='1.2'/><rect x='9' y='2' width='2' height='2'/><rect x='13' y='2' width='2' height='2'/><rect x='9' y='20' width='2' height='2'/><rect x='13' y='20' width='2' height='2'/><rect x='2' y='9' width='2' height='2'/><rect x='2' y='13' width='2' height='2'/><rect x='20' y='9' width='2' height='2'/><rect x='20' y='13' width='2' height='2'/></svg>">
<style>
/* --- Adom canonical font loaders (per adom-app-header §Required @font-face loaders) --- */
@font-face {
font-family: 'Familjen Grotesk';
src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-700-normal.woff2') format('woff2');
font-weight: 700; font-display: swap;
}
@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: 'Familjen Grotesk';
src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-500-normal.woff2') format('woff2');
font-weight: 500; font-display: swap;
}
@font-face {
font-family: 'Satoshi';
src: url('https://adom.inc/fonts/satoshi/satoshi-400-normal.woff2') format('woff2');
font-weight: 400; font-display: swap;
}
@font-face {
font-family: 'Satoshi';
src: url('https://adom.inc/fonts/satoshi/satoshi-500-normal.woff2') format('woff2');
font-weight: 500; font-display: swap;
}
@font-face {
font-family: 'JetBrains Mono';
src: url('https://cdn.jsdelivr.net/npm/@fontsource/jetbrains-mono@5/files/jetbrains-mono-latin-400-normal.woff2') format('woff2');
font-weight: 400; font-display: swap;
}
@font-face {
font-family: 'JetBrains Mono';
src: url('https://cdn.jsdelivr.net/npm/@fontsource/jetbrains-mono@5/files/jetbrains-mono-latin-500-normal.woff2') format('woff2');
font-weight: 500; font-display: swap;
}
@font-face {
font-family: 'JetBrains Mono';
src: url('https://cdn.jsdelivr.net/npm/@fontsource/jetbrains-mono@5/files/jetbrains-mono-latin-600-normal.woff2') format('woff2');
font-weight: 600; font-display: swap;
}
:root {
/* Canonical Adom tokens */
--bg: #0d1117;
--surface: #161b22;
--elevated: #1c2128;
--border: #30363d;
--border-muted: #21262d;
--text: #e6edf3;
--text2: #7d8590;
--text3: #484f58;
--accent: #00b8b0; /* Adom teal — canonical brand primary accent */
/* chip-fetcher semantic accents */
--green: #3fb950;
--amber: #f59e0b;
--red: #f85149;
--grey: #4b5563;
/* legacy aliases (kept for existing rules below) */
--panel: var(--surface);
--muted: var(--text2);
}
* { box-sizing: border-box; }
html, body { font: 13px/1.4 'Satoshi', -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }
body { margin: 0; background: var(--bg); color: var(--text); display: flex; flex-direction: column; min-height: 100vh; }
/* --- Canonical Adom app-header (single row, 44 px) --- */
header {
display: flex;
align-items: center;
gap: 12px;
padding: 0 16px;
height: 44px;
border-bottom: 1px solid var(--border-muted);
background: var(--surface);
position: relative;
font-family: 'Familjen Grotesk', sans-serif;
flex-shrink: 0;
}
header .brand {
display: flex; align-items: center; gap: 8px;
color: var(--accent);
font-size: 13px; font-weight: 600;
letter-spacing: 0.01em;
cursor: default;
flex-shrink: 0;
outline: none;
}
header .brand:hover .app-name { text-decoration: underline; text-decoration-color: var(--accent); text-underline-offset: 3px; }
header .brand svg.logo { width: 16px; height: 16px; flex-shrink: 0; }
header .brand .app-name { color: var(--accent); }
header .brand .app-subtitle { color: var(--text2); font-weight: 500; }
header .brand .sep { color: var(--text3); font-weight: 400; }
header .vsep {
width: 1px; height: 18px;
background: var(--border);
flex-shrink: 0;
}
header .subject {
display: flex; align-items: center; gap: 8px;
min-width: 0;
flex: 1;
cursor: default;
}
header .subject-name {
color: var(--text);
font-family: 'Familjen Grotesk', sans-serif;
font-size: 14px; font-weight: 600;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
header .subject-name.idle { color: var(--text3); font-weight: 500; }
header .state-pill {
display: none;
font-size: 10px; font-weight: 700; letter-spacing: 0.5px;
padding: 2px 8px;
border-radius: 10px;
font-family: 'Familjen Grotesk', sans-serif;
flex-shrink: 0;
}
header .state-pill.show { display: inline-flex; }
header .state-pill.full { background: rgba(63,185,80,0.18); color: var(--green); }
header .state-pill.stub { background: rgba(245,158,11,0.20); color: var(--amber); }
header .state-pill.fetching { background: rgba(0,184,176,0.20); color: var(--accent); animation: pillpulse 1.2s ease-in-out infinite; }
@keyframes pillpulse { 0%,100% { opacity: 1; } 50% { opacity: 0.55; } }
header .subject-meta {
display: flex; align-items: center; gap: 6px;
color: var(--text2);
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
white-space: nowrap;
}
header .subject-meta .sep { color: var(--text3); }
@media (max-width: 720px) {
header .subject-meta { display: none; }
}
header .actions {
display: flex; align-items: center; gap: 6px;
margin-left: auto; flex-shrink: 0;
}
/* --- Interactive popover (hover-tooltip you can move into) --- */
header .hover-popover {
position: absolute;
top: calc(100% + 6px);
left: 16px;
background: var(--elevated);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
padding: 12px 14px;
min-width: 300px;
max-width: 420px;
font-family: 'Satoshi', sans-serif;
font-size: 12px;
color: var(--text);
z-index: 99999;
opacity: 0; transform: translateY(-2px);
pointer-events: none;
transition: opacity 120ms ease, transform 120ms ease;
}
header .hover-popover.show {
opacity: 1; transform: none;
pointer-events: auto;
}
header .hover-popover.compact { min-width: 220px; }
header .hover-popover .popover-title {
font-family: 'Familjen Grotesk', sans-serif;
font-size: 14px; font-weight: 700;
color: var(--text);
margin-bottom: 8px;
}
header .hover-popover .popover-row {
display: flex; gap: 12px; padding: 3px 0;
align-items: baseline;
}
header .hover-popover .popover-row .k {
color: var(--text3);
font-family: 'JetBrains Mono', monospace;
font-size: 10px; text-transform: lowercase;
width: 64px; flex-shrink: 0;
}
header .hover-popover .popover-row .v {
color: var(--text);
font-family: 'Satoshi', sans-serif;
font-size: 12px;
flex: 1;
}
header .hover-popover .popover-actions {
margin-top: 10px; padding-top: 10px;
border-top: 1px solid var(--border-muted);
display: flex; gap: 12px;
}
header .hover-popover .popover-link {
color: var(--accent);
text-decoration: none;
font-family: 'Familjen Grotesk', sans-serif;
font-size: 12px; font-weight: 600;
}
header .hover-popover .popover-link:hover { text-decoration: underline; }
/* --- Toolbar (filters / search / credentials) — separate row below header --- */
.toolbar {
display: flex; align-items: center; gap: 8px;
padding: 8px 16px;
border-bottom: 1px solid var(--border-muted);
background: var(--bg);
flex-wrap: wrap;
flex-shrink: 0;
}
.toolbar input[type=search],
.toolbar select,
.toolbar button {
background: var(--surface);
border: 1px solid var(--border);
color: var(--text);
padding: 6px 10px;
border-radius: 6px;
font: 12px/1.4 'Satoshi', sans-serif;
}
.toolbar input[type=search] { min-width: 220px; }
.toolbar input[type=search]:focus,
.toolbar select:focus,
.toolbar button:hover { border-color: var(--accent); outline: none; }
.toolbar button { cursor: pointer; }
.toolbar .spacer { margin-left: auto; }
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.6); display: none; align-items: center; justify-content: center; z-index: 100; }
.modal-overlay.open { display: flex; }
.modal { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 24px; min-width: 480px; max-width: 600px; }
.modal h3 { margin: 0 0 12px; font-size: 18px; }
.modal .helper { color: var(--muted); font-size: 12px; margin-bottom: 16px; line-height: 1.5; }
.modal label { display: block; font-size: 12px; color: var(--muted); margin: 12px 0 4px; }
.modal input { width: 100%; background: var(--bg); border: 1px solid var(--border); color: var(--text); padding: 8px 10px; border-radius: 4px; font: inherit; }
.modal .buttons { display: flex; gap: 8px; justify-content: flex-end; margin-top: 20px; }
.modal .btn-primary { background: var(--accent); border: 1px solid var(--accent); color: white; padding: 8px 16px; border-radius: 4px; cursor: pointer; font: inherit; }
.modal .btn-secondary { background: transparent; border: 1px solid var(--border); color: var(--text); padding: 8px 16px; border-radius: 4px; cursor: pointer; font: inherit; }
.creds-list { margin-bottom: 16px; }
.creds-list .row { display: flex; justify-content: space-between; align-items: center; padding: 8px; background: var(--bg); border: 1px solid var(--border); border-radius: 4px; margin-bottom: 6px; font-size: 13px; }
.creds-list .row .info { color: var(--muted); font-size: 11px; }
.creds-list .row code { font-family: "JetBrains Mono", monospace; font-size: 12px; color: var(--text); }
.creds-list .row button { background: transparent; border: 1px solid var(--border); color: var(--red); padding: 4px 10px; border-radius: 3px; cursor: pointer; font-size: 12px; }
.msg { font-size: 13px; padding: 8px; border-radius: 4px; margin-top: 12px; }
.msg.ok { background: rgba(34,197,94,.15); color: var(--green); }
.msg.err { background: rgba(239,68,68,.15); color: var(--red); }
main { display: grid; grid-template-columns: 1fr 380px; flex: 1; min-height: 0; }
.grid { padding: 20px; overflow-y: auto; }
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 14px; cursor: pointer; transition: border-color .15s; }
.card:hover { border-color: var(--accent); }
.card.selected { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(0,184,176,.25); }
.card .mpn-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 6px; margin-bottom: 4px; }
.card .mpn { font-weight: 600; font-size: 15px; word-break: break-all; flex: 1; min-width: 0; }
.card .reveal-btn { flex: 0 0 auto; background: transparent; border: 1px solid var(--border); color: var(--muted); border-radius: 4px; padding: 3px 5px; cursor: pointer; line-height: 0; display: inline-flex; align-items: center; justify-content: center; opacity: 0; transition: opacity .12s, color .12s, border-color .12s; }
.card:hover .reveal-btn, .card.selected .reveal-btn { opacity: 1; }
.card .reveal-btn:hover { color: var(--accent); border-color: var(--accent); }
.card .meta { color: var(--muted); font-size: 12px; margin-bottom: 8px; }
/* Thumbnail strip — three conceptual slots: SYMBOL | FOOTPRINT | 3D GROUP.
The 3D group bundles Z↑ + Y↑ + the ▾ orientation picker because they all represent
"the STEP file shown from different angles" — distinct from SYM (solved symbol) and
FP (solved footprint). */
/* Two-tier thumbnail hierarchy: SYM + FP are the big primary artifacts you
design with; the 3D up-axis QA + .lbr packaging action are a smaller
secondary row beneath. */
.card .thumbs { display: flex; flex-direction: column; gap: 6px; margin: 10px 0; }
.card .thumb-row { display: grid; gap: 6px; }
.card .thumb-row.primary { grid-template-columns: 1fr 1fr; }
/* 3D models are the row that tells near-identical parts (JST connectors) apart,
so give the 3D group the full card width (big Z↑/Y↑), and shrink the .lbr
build action to a thin bar beneath it. */
.card .thumb-row.secondary { grid-template-columns: 1fr; align-items: stretch; }
.card .thumb-row.secondary .thumb-lbr { aspect-ratio: auto; height: 38px; flex-direction: row; }
.card .thumb-3d-group { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; position: relative; border: 1px solid var(--border-muted); border-radius: 6px; padding: 2px; background: rgba(64,75,85,.15); }
.card .thumb-3d-group .orient-more-btn { position: absolute; bottom: 2px; left: 50%; transform: translateX(-50%); z-index: 5; background: var(--panel); border: 1px solid var(--border); color: var(--muted); border-radius: 3px; padding: 0 6px; font-size: 9px; line-height: 1.5; cursor: pointer; opacity: 0; transition: opacity .12s; }
.card:hover .thumb-3d-group .orient-more-btn, .card.selected .thumb-3d-group .orient-more-btn { opacity: 1; }
.card .thumb-3d-group .orient-more-btn:hover { color: var(--accent); border-color: var(--accent); }
/* Always-visible 3D source chip (KiCad lib / Mfr direct / SnapMagic …). Color = source, not the model's CAD color. Namespaced src3d- to avoid the sym/fp carat's .src-chip. */
.card .thumb-3d-group .src3d-chip { position: absolute; top: 3px; left: 3px; z-index: 6; display: inline-flex; align-items: center; gap: 4px;
font-size: 9px; line-height: 1; font-weight: 600; letter-spacing: .2px; padding: 3px 6px; border-radius: 999px;
background: rgba(13,17,23,.85); color: #cdd9e5; border: 1px solid var(--border); pointer-events: auto; }
.card .thumb-3d-group .src3d-chip .src3d-dot { width: 7px; height: 7px; border-radius: 50%; background: #8b949e; flex: none; }
.card .thumb-3d-group .src3d-chip.src3d-mfr { color: #7ee2c8; border-color: #1f6f5c; } .src3d-chip.src3d-mfr .src3d-dot { background: #19c37d; }
.card .thumb-3d-group .src3d-chip.src3d-kicad { color: #9fc6ff; border-color: #2c4a73; } .src3d-chip.src3d-kicad .src3d-dot { background: #4f8cff; }
.card .thumb-3d-group .src3d-chip.src3d-ul { color: #ffd479; border-color: #6b5320; } .src3d-chip.src3d-ul .src3d-dot { background: #e6b450; }
.card .thumb-3d-group .src3d-chip.src3d-snap { color: #d6a8ff; border-color: #4a2c6b; } .src3d-chip.src3d-snap .src3d-dot { background: #b46bff; }
.card .thumb-3d-group .src3d-chip.src3d-mouser, .card .thumb-3d-group .src3d-chip.src3d-dk { color: #9fc6ff; } .src3d-chip.src3d-mouser .src3d-dot, .src3d-chip.src3d-dk .src3d-dot { background: #5a8fd6; }
.card .thumb-3d-group .src3d-chip.src3d-unknown { color: #ff9b8a; border-color: #6b2f24; } .src3d-chip.src3d-unknown .src3d-dot { background: #ff5a52; }
/* Card zoom — animate from the card's grid position to centered,
so the user tracks which card is zooming. Uses CSS transition on
top/left/transform with a short ease-out. */
.card-zoom-backdrop { position: fixed; inset: 0; z-index: 200; background: rgba(0,0,0,0); cursor: pointer; transition: background .25s ease-out; }
.card-zoom-backdrop.visible { background: rgba(0,0,0,0.70); }
.card-zoom-backdrop .card-zoomed-wrapper { position: fixed; z-index: 201; pointer-events: auto; cursor: default; transition: top .3s ease-out, left .3s ease-out, transform .3s ease-out; }
.card-zoom-backdrop .card-zoomed-wrapper .card { transform-origin: top left; border: 2px solid var(--accent); box-shadow: 0 24px 80px rgba(0,0,0,0.7); }
.card-zoom-backdrop .card .card-actions { opacity: 1 !important; }
.card-zoom-backdrop .card .orient-more-btn { opacity: 1 !important; }
.card-zoom-backdrop .card .reveal-btn { opacity: 1 !important; }
/* Always-visible close button + full-screen scrollable mode (phones). */
.card-zoom-x { position: fixed; top: 12px; right: 12px; z-index: 210; width: 42px; height: 42px; border-radius: 50%; border: 1px solid var(--border); background: rgba(13,17,23,.92); color: #e6edf3; font-size: 19px; line-height: 1; cursor: pointer; display: grid; place-items: center; box-shadow: 0 6px 22px rgba(0,0,0,.6); }
.card-zoom-x:hover, .card-zoom-x:active { border-color: var(--accent); color: var(--accent); }
.card-zoom-backdrop.fullscreen { overflow-y: auto; -webkit-overflow-scrolling: touch; display: flex; align-items: flex-start; justify-content: center; padding: 64px 0 36px; }
.card-zoom-backdrop.fullscreen .card-zoomed-wrapper { position: relative !important; left: auto !important; top: auto !important; }
/* Fluid 2-column zoom (desktop): media (thumbnails) on the LEFT, title + meta +
badges + Open-in/Send-to on the RIGHT — so a zoomed card fills the horizontal
room and stays short enough to scale up large, instead of a tall narrow strip. */
.card-zoom-backdrop .card.zoom-fluid { display: grid; grid-template-columns: minmax(320px, 1.25fr) minmax(300px, 1fr); column-gap: 26px; align-items: start; padding: 22px; }
.card-zoom-backdrop .card.zoom-fluid .zoom-col-media,
.card-zoom-backdrop .card.zoom-fluid .zoom-col-info { min-width: 0; display: flex; flex-direction: column; gap: 10px; }
.card-zoom-backdrop .card.zoom-fluid .thumbs { margin: 0; }
.card-zoom-backdrop .card.zoom-fluid .zoom-actions { margin-top: 2px; }
/* In the ZOOM, show the FULL chip description (what the part actually is) —
not the 2-line clamp the grid card uses. John's repeated ask: the zoom must
explain the chip. Make it prominent and fully readable. */
.card-zoom-backdrop .card .card-desc {
-webkit-line-clamp: unset; display: block; overflow: visible;
font-size: 14px; line-height: 1.5; color: var(--text);
margin: 6px 0 12px; max-width: 60ch;
}
/* Tile background MATCHES the image's own baked-in canvas color, so any
whitespace introduced by aspect-ratio mismatch (KiCad SVG vs square
tile) blends seamlessly. Per-kind backgrounds via data-thumb-kind:
sym/fp → Adom dark canvas #0d1117 (chip-thumbnailer's SVG bg)
3d → slate #404B55 (pyrender's iso-pose backdrop) */
.card .thumb { background: #0d1117; border: 1px solid var(--border-muted); border-radius: 6px; aspect-ratio: 1 / 1; display: flex; align-items: center; justify-content: center; padding: 8%; overflow: hidden; position: relative; transition: transform .15s ease, border-color .15s ease, box-shadow .15s ease; }
.card .thumb[data-thumb-kind="chip3d"], .card .thumb[data-thumb-kind="chip3d-yup"] { background: #404B55; padding: 4%; }
.card .thumb.thumb-lbr { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:3px; color:#8aa0b8; background:linear-gradient(150deg,#1b2330,#222c3d); }
.card .thumb.thumb-lbr svg { width:34%; height:auto; }
.card .thumb.thumb-lbr:hover { color:#37c8a6; }
/* Active orientation gets an Adom-teal highlight ring + corner check.
The OTHER 3D thumb sits dimmer until hover, signalling "click to pick
this one if the active is wrong". */
.card .thumb[data-orient-active="true"] { border-color: var(--green); box-shadow: 0 0 0 1px var(--green) inset; }
.card .thumb[data-orient-inactive="true"] { opacity: 0.55; }
.card .thumb[data-orient-inactive="true"]:hover { opacity: 1; }
.card .thumb .orient-check { position: absolute; top: 4px; right: 4px; width: 16px; height: 16px; background: var(--green); border-radius: 50%; display: flex; align-items: center; justify-content: center; color: #0d1117; pointer-events: none; }
.card .thumb .orient-check svg { width: 11px; height: 11px; }
/* Lightweight "symbol re-rendering" overlay: a CSS-only spinner over the SYM
tile while the background outline+symbol regen runs after an up-axis switch.
pointer-events:none + a single dimmed tile = the rest of the dashboard stays
fully interactive while it spins. */
.card .thumb.refreshing img { opacity: .3; transition: opacity .2s; }
.card .thumb-spinner { position: absolute; top: 50%; left: 50%; width: 22px; height: 22px;
margin: -11px 0 0 -11px; border: 2px solid rgba(255,255,255,.22);
border-top-color: var(--accent); border-radius: 50%;
animation: thumbspin .7s linear infinite; pointer-events: none; z-index: 4; }
@keyframes thumbspin { to { transform: rotate(360deg); } }
.card .thumb:hover { border-color: var(--accent); transform: scale(1.06); z-index: 2; box-shadow: 0 6px 18px rgba(0,0,0,0.4), 0 0 0 1px var(--accent); }
/* Symbol/footprint tiles are non-interactive — no hover lift, no pointer. */
.card .thumb.thumb-static { cursor: default; }
.card .thumb.thumb-static:hover { transform: none; box-shadow: none; border-color: var(--border-muted); }
.card .thumb img { width: 100%; height: 100%; object-fit: contain; display: block; }
.card .thumb .thumb-label { position: absolute; bottom: 4px; right: 6px; font-size: 9px; color: rgba(230,237,243,0.55); font-family: 'JetBrains Mono', monospace; letter-spacing: .05em; text-shadow: 0 1px 2px rgba(0,0,0,0.6); pointer-events: none; }
.card .thumb.empty { background: var(--bg); border-style: dashed; padding: 8%; }
.card .thumb.empty .thumb-placeholder { color: var(--text3); font-size: 10px; font-family: 'JetBrains Mono', monospace; letter-spacing: .05em; }
/* Render FAILED (chip-thumbnailer errored) — distinct from "not generated"
so a failure isn't indistinguishable from an absent source file. */
.card .thumb.failed { background: rgba(220,80,60,.06); border-style: dashed; border-color: rgba(220,80,60,.45); padding: 6%; flex-direction: column; gap: 2px; }
.card .thumb.failed .thumb-failed { color: #ef7a6a; font-size: 10px; font-family: 'JetBrains Mono', monospace; font-weight: 600; letter-spacing: .05em; }
.card .thumb.failed .thumb-failed-sub { color: var(--text3); font-size: 8px; font-family: 'JetBrains Mono', monospace; }
/* Active-card emphasis — when this card is the one being heartbeated. */
.card.active { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(0,184,176,.35), 0 0 24px rgba(0,184,176,.15); animation: cardpulse 1.4s ease-in-out infinite; }
@keyframes cardpulse { 0%,100% { box-shadow: 0 0 0 2px rgba(0,184,176,.35), 0 0 24px rgba(0,184,176,.15); } 50% { box-shadow: 0 0 0 2px rgba(0,184,176,.55), 0 0 32px rgba(0,184,176,.30); } }
.badges { display: flex; gap: 4px; flex-wrap: wrap; align-items: center; }
.b { font-size: 10px; padding: 2px 6px; border-radius: 3px; font-weight: 600; letter-spacing: .3px; }
.b.step { background: rgba(34,197,94,.15); color: var(--green); }
.b.kicad { background: rgba(34,197,94,.15); color: var(--green); }
.b.altium { background: rgba(34,197,94,.15); color: var(--green); }
.b.fusion { background: rgba(34,197,94,.15); color: var(--green); }
.b.partial-eda { background: rgba(245,158,11,.18); color: var(--amber); }
.b.pin1ok { background: rgba(0,184,177,.18); color: var(--accent); } /* pin-1 signed off in chipsmith */
.b.pin1 { background: rgba(245,158,11,.16); color: var(--amber); } /* pin-1 not yet aligned */
.b.pin1bad{ background: rgba(248,81,73,.18); color: #ff7b72; } /* chip pin-1 corner != footprint pin-1 corner */
.b.pdf { background: rgba(0,184,176,.15); color: var(--accent); }
.b.clickable { cursor: pointer; }
.b.clickable:hover { filter: brightness(1.25); }
.b.miss { background: rgba(75,85,99,.25); color: var(--grey); }
.b.full { background: rgba(34,197,94,.2); color: var(--green); }
.b.stub { background: rgba(245,158,11,.2); color: var(--amber); }
.b.src.mfr { background: rgba(34,197,94,.2); color: var(--green); }
.b.src.fb { background: rgba(245,158,11,.2); color: var(--amber); }
.b.src.unk { background: rgba(75,85,99,.25); color: var(--grey); }
.b.lifecycle-unk { background: rgba(75,85,99,.20); color: var(--grey); font-style: italic; }
.b.stock-yes { background: rgba(34,197,94,.18); color: var(--green); }
.b.stock-low { background: rgba(245,158,11,.20); color: var(--amber); }
.b.stock-no { background: rgba(239,68,68,.18); color: var(--red); }
.b.stock-unk { background: rgba(75,85,99,.20); color: var(--grey); }
.b.price { background: rgba(0,184,176,.10); color: var(--accent); font-weight: 500; }
.b.nrnd { background: rgba(239,68,68,.20); color: var(--red); font-weight: 700; letter-spacing: .5px; }
.b.eol { background: rgba(220,38,38,.30); color: #fca5a5; font-weight: 700; letter-spacing: .5px; }
.b.new { background: rgba(34,197,94,.18); color: var(--green); font-weight: 600; letter-spacing: .3px; }
/* concur validation badges */
.b.val-golden { background: rgba(34,197,94,.25); color: var(--green); font-weight: 700; }
.b.val-golden-v { background: rgba(34,197,94,.15); color: var(--green); font-weight: 600; }
.b.val-divergent { background: rgba(245,158,11,.30); color: var(--amber); font-weight: 700; letter-spacing: .3px; }
.b.val-bad { background: rgba(239,68,68,.25); color: var(--red); font-weight: 700; }
.b.val-none { background: rgba(75,85,99,.20); color: var(--grey); font-style: italic; }
/* per-card action buttons (re-validate, re-thumbnails, refetch) */
.card .card-actions { display: flex; gap: 4px; align-items: center; flex: 0 0 auto; opacity: 0; transition: opacity .12s; }
.card:hover .card-actions, .card.selected .card-actions { opacity: 1; }
/* Touch devices have no hover — keep the card actions (incl. the zoom button)
visible so a tap can reach them. */
@media (hover: none) { .card .card-actions { opacity: 1; } }
.card .card-btn { background: transparent; border: 1px solid var(--border); color: var(--muted); border-radius: 4px; padding: 2px 6px; font-size: 12px; cursor: pointer; line-height: 1.4; transition: color .12s, border-color .12s; }
.card .card-btn:hover { color: var(--accent); border-color: var(--accent); }
.card .card-btn:disabled { cursor: wait; }
#cf-toast { position: fixed; left: 50%; bottom: 22px; transform: translateX(-50%) translateY(16px); z-index: 100050; max-width: 90vw; background: rgba(17,22,31,.98); border: 1px solid var(--border2, #2b3645); border-radius: 11px; padding: 11px 16px; font-size: 13px; color: var(--text, #e6edf3); box-shadow: 0 14px 44px rgba(0,0,0,.6); opacity: 0; pointer-events: none; transition: opacity .2s, transform .2s; }
#cf-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); pointer-events: auto; }
#cf-toast a { color: var(--accent, #00b8b1); font-weight: 700; margin-left: 6px; }
.openin-row { display: flex; gap: 6px; flex-wrap: wrap; }
/* Actions block injected into the zoom focus view. */
.zoom-actions { margin-top: 14px; border-top: 1px solid var(--border); padding-top: 12px; }
.zoom-actions .zoom-sec { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 1px; margin: 0 0 6px; }
.zoom-actions .openin-row, .zoom-actions .sendto-row { margin-bottom: 12px; }
.openin-row .openbtn { background: var(--panel2, #0f141c); border: 1px solid var(--border); color: var(--text, #e6edf3); border-radius: 8px; padding: 6px 11px; font-size: 12px; font-weight: 600; cursor: pointer; transition: background .12s, border-color .12s, color .12s; }
.openin-row .openbtn:hover { background: rgba(0,184,177,.16); border-color: var(--accent, #00b8b1); color: var(--accent, #00b8b1); }
.openin-row .openbtn:disabled { opacity: .55; cursor: wait; }
.sendto-row { display: flex; gap: 6px; flex-wrap: wrap; }
.sendto-row .sendbtn { background: var(--panel2, #0f141c); border: 1px solid var(--border); color: var(--text, #e6edf3); border-radius: 8px; padding: 6px 12px; font-size: 12px; font-weight: 600; cursor: pointer; transition: background .12s, border-color .12s, color .12s; }
.sendto-row .sendbtn:hover { background: var(--accent, #00b8b1); border-color: var(--accent, #00b8b1); color: #04201f; }
.sendto-row .sendbtn:disabled { opacity: .55; cursor: progress; }
#bridgeModal { position: fixed; inset: 0; z-index: 100001; display: none; align-items: center; justify-content: center; background: rgba(2,5,10,.62); backdrop-filter: blur(4px); padding: 20px; }
#bridgeModal.show { display: flex; }
#bridgeModal .bm-card { max-width: min(94vw,440px); background: rgba(17,22,31,.99); border: 1px solid var(--border); border-radius: 16px; padding: 22px 22px 18px; box-shadow: 0 30px 90px rgba(0,0,0,.7); text-align: center; }
#bridgeModal .bm-icon { font-size: 34px; margin-bottom: 6px; }
#bridgeModal .bm-title { font-size: 16px; font-weight: 700; margin-bottom: 8px; }
#bridgeModal .bm-body { font-size: 13px; line-height: 1.55; color: var(--muted); margin-bottom: 16px; }
#bridgeModal .bm-actions { display: flex; flex-direction: column; gap: 9px; }
#bridgeModal .bm-go { display: block; text-decoration: none; background: linear-gradient(180deg,#00b8b1,#009a94); color: #04201f; font-weight: 700; font-size: 13px; padding: 10px; border-radius: 10px; }
#bridgeModal .bm-hint { font-size: 11.5px; line-height: 1.5; color: var(--text, #cfd6e4); background: rgba(0,184,177,.08); border: 1px solid rgba(0,184,177,.22); border-radius: 9px; padding: 8px 10px; margin-bottom: 14px; text-align: left; }
#bridgeModal .bm-contribute { display: block; text-decoration: none; color: var(--muted); font-size: 12px; padding: 7px; border: 1px solid var(--border); border-radius: 10px; }
#bridgeModal .bm-contribute:hover { color: var(--accent); border-color: var(--accent); }
#bridgeModal .bm-dismiss { background: transparent; border: 1px solid var(--border); color: var(--muted); font-size: 12.5px; padding: 8px; border-radius: 10px; cursor: pointer; }
.card .refetch-select { font-size: 11px; padding: 1px 4px; width: 28px; background: var(--panel); color: var(--text); border: 1px solid var(--border); color-scheme: dark; overflow: hidden; text-overflow: ellipsis; }
.card .refetch-select option { background: var(--bg); color: var(--text); padding: 4px 8px; font-family: Satoshi, -apple-system, sans-serif; font-size: 12px; }
/* authoritative footprint SVG (ds2sf v0.5.1+) */
.card .auth-svg-thumb { position: relative; margin: 8px 0 4px; padding: 6px; background: #fff; border: 1px solid var(--border); border-radius: 6px; aspect-ratio: 2.4 / 1; display: flex; align-items: center; justify-content: center; overflow: hidden; }
.card .auth-svg-thumb img { max-width: 100%; max-height: 100%; object-fit: contain; }
.card .auth-svg-thumb.auth-canonical { border-color: var(--green); box-shadow: 0 0 0 1px rgba(34,197,94,.3) inset; }
.card .auth-svg-thumb.auth-synth { border-color: var(--amber); box-shadow: 0 0 0 1px rgba(245,158,11,.3) inset; }
.card .auth-svg-thumb .auth-svg-label { position: absolute; bottom: 2px; right: 4px; font-size: 9px; color: rgba(0,0,0,0.55); font-family: 'JetBrains Mono', monospace; letter-spacing: .05em; pointer-events: none; }
/* Auto-zoom toggle in toolbar */
.auto-zoom-toggle { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; color: var(--muted); cursor: pointer; margin-left: 8px; user-select: none; }
.auto-zoom-toggle input { accent-color: var(--accent); }
/* 6-orientation popover (button lives inside .thumb-3d-group) */
.card .orient-popover { position: absolute; top: 100%; left: 50%; transform: translateX(-50%); z-index: 50; background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 10px; display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.5); }
/* symbol/footprint provenance carat — always-visible tappable source chip */
.card .thumb-src-group { position: relative; display: flex; }
.card .thumb-src-group .thumb { flex: 1; }
.card .src-chip { position: absolute; top: 4px; left: 4px; z-index: 4; display: inline-flex; align-items: center; gap: 4px;
font-size: 8.5px; font-weight: 700; letter-spacing: .02em; color: #d7efed; background: rgba(13,17,23,.82); backdrop-filter: blur(4px);
border: 1px solid rgba(0,184,177,.45); border-radius: 5px; padding: 2px 5px; cursor: pointer; max-width: 88%;
transition: border-color .12s, background .12s; -webkit-tap-highlight-color: transparent; }
.card .src-chip:hover, .card .src-chip:active { border-color: var(--accent); background: rgba(0,184,177,.16); }
.card .src-chip-lab { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.card .src-chip-car { color: var(--muted); font-size: 8px; }
/* Variant picker is a side-by-side GALLERY with large thumbnails, so similar
variants (e.g. JST connector symbols/footprints) are actually tellable apart. */
.card .src-popover { position: absolute; top: 24px; left: 4px; z-index: 30;
background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 10px;
display: flex; flex-direction: row; flex-wrap: wrap; gap: 10px; max-width: 520px;
box-shadow: 0 14px 36px rgba(0,0,0,.6); }
.card .src-opt { cursor: pointer; display: flex; flex-direction: column; align-items: center; gap: 6px;
border: 1px solid var(--border-muted); border-radius: 8px; padding: 8px; width: 152px;
font-size: 11px; color: var(--text); transition: border-color .12s, background .12s; }
.card .src-opt:hover, .card .src-opt:active { border-color: var(--accent); background: rgba(0,184,177,.08); }
.card .src-opt.chosen { border-color: var(--green); box-shadow: 0 0 0 1px var(--green) inset; }
.card .src-opt-thumb { width: 136px; height: 136px; object-fit: contain; background: #0d1117; border-radius: 6px; flex: none; }
.card .src-opt-lab { flex: none; text-align: center; line-height: 1.3; white-space: normal; }
.card .src-cur { color: var(--green); font-size: 9px; }
.card .src-dot { width: 9px; height: 9px; border-radius: 50%; flex: 0 0 9px; }
.card .src-dot.vendor { background: #00b8b1; }
.card .src-dot.ds { background: #d29922; }
.card .orient-opt { cursor: pointer; border: 1px solid var(--border-muted); border-radius: 6px; padding: 4px; background: #404B55; text-align: center; transition: border-color .12s; }
.card .orient-opt:hover { border-color: var(--accent); }
.card .orient-opt.chosen { border-color: var(--green); box-shadow: 0 0 0 1px var(--green) inset; }
.card .orient-opt img { width: 130px; height: 130px; object-fit: contain; display: block; margin: 0 auto 2px; }
.card .orient-opt span { font-size: 11px; color: var(--muted); font-family: 'JetBrains Mono', monospace; }
aside { background: var(--panel); border-left: 1px solid var(--border); padding: 20px; overflow-y: auto; }
aside.empty { display: flex; align-items: center; justify-content: center; color: var(--muted); }
/* Tabbed detail panel — keeps the 66-file list from dominating. */
.dtabs { display: flex; gap: 4px; margin: 14px 0 12px; border-bottom: 1px solid var(--border); }
.dtab { background: none; border: none; border-bottom: 2px solid transparent; color: var(--muted); font-size: 13px; font-weight: 600; padding: 7px 10px; cursor: pointer; margin-bottom: -1px; display: inline-flex; align-items: center; gap: 5px; }
.dtab:hover { color: var(--text, #e6edf3); }
.dtab.on { color: var(--accent, #00b8b1); border-bottom-color: var(--accent, #00b8b1); }
.dtab .dtab-n { font-size: 10px; background: var(--panel2, #0f141c); border: 1px solid var(--border); border-radius: 8px; padding: 0 5px; color: var(--muted); }
.dpanel { display: none; }
.dpanel.on { display: block; }
.filelist { margin: 0; padding: 0; list-style: none; max-height: calc(100vh - 220px); overflow-y: auto; }
.filelist li { padding: 3px 0; border-bottom: 1px solid rgba(255,255,255,.04); }
.filelist a { color: var(--accent); text-decoration: none; font-size: 11.5px; word-break: break-all; }
aside h2 { margin: 0 0 8px; font-size: 18px; }
aside .vendor { color: var(--muted); margin-bottom: 16px; }
aside .section { margin-bottom: 18px; }
aside .section-title { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 6px; }
aside ul { margin: 0; padding-left: 0; list-style: none; }
aside ul li { padding: 4px 0; border-bottom: 1px solid var(--border); }
aside ul li:last-child { border-bottom: none; }
aside a { color: var(--accent); text-decoration: none; }
aside a:hover { text-decoration: underline; }
aside pre { background: var(--bg); border: 1px solid var(--border); border-radius: 4px; padding: 8px; font-size: 11px; color: var(--muted); overflow-x: auto; max-height: 300px; }
aside .fields { display: flex; flex-direction: column; gap: 0; }
aside .field { display: grid; grid-template-columns: 130px 1fr; gap: 10px; padding: 5px 0; border-bottom: 1px solid var(--border-muted); font-size: 12px; align-items: baseline; }
aside .field:last-child { border-bottom: none; }
aside .field-key { color: var(--text2); font-family: 'JetBrains Mono', monospace; font-size: 10px; text-transform: lowercase; letter-spacing: 0.02em; }
aside .field-val { color: var(--text); font-family: 'Satoshi', sans-serif; word-break: break-word; }
aside .field-val a { color: var(--accent); }
aside .field.unknown .field-val { color: var(--text3); font-style: italic; font-family: 'JetBrains Mono', monospace; font-size: 11px; }
.empty-state { color: var(--muted); padding: 40px; text-align: center; }
.card .card-desc { color: var(--text2); font-size: 11px; line-height: 1.35; margin: 4px 0 2px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.out-of-filter-note { grid-column: 1 / -1; background: rgba(230,180,80,.10); border: 1px solid rgba(230,180,80,.35); color: var(--text2); border-radius: 6px; padding: 8px 12px; font-size: 12px; display: flex; align-items: center; gap: 10px; }
.out-of-filter-note b { color: var(--accent); font-family: 'JetBrains Mono', monospace; }
.out-of-filter-note .ofn-clear { background: var(--accent); color: #fff; border: none; border-radius: 4px; padding: 3px 10px; font-size: 11px; cursor: pointer; margin-left: auto; }
.out-of-filter-note .ofn-clear:hover { filter: brightness(1.1); }
.activity { display: none; padding: 10px 24px; background: rgba(0,184,176,.10); border-bottom: 1px solid var(--accent); font-size: 13px; color: var(--text); align-items: center; gap: 12px; }
.activity.live { display: flex; }
/* Stale: the last heartbeat is old enough that no work is actually
happening. Show it muted with the age, instead of a permanent WORKING. */
.activity.stale { display: flex; background: rgba(120,130,140,.07); border-bottom-color: var(--border); }
.activity.stale .dot { background: var(--text3); animation: none; }
.activity.stale .mpn-tag { color: var(--text3); }
.activity .dot { width: 10px; height: 10px; border-radius: 50%; background: var(--accent); animation: pulse 1.2s ease-in-out infinite; }
.activity .label { color: var(--muted); }
.activity .mpn-tag { font-family: "JetBrains Mono", monospace; font-weight: 600; color: var(--accent); }
.activity .stage { color: var(--muted); }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .35; } }
.card.active { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(0,184,176,.45); animation: cardpulse 1.2s ease-in-out infinite; position: relative; }
.card.active::after { content: "WORKING"; position: absolute; top: -8px; right: 8px; background: var(--accent); color: white; font-size: 9px; padding: 2px 6px; border-radius: 3px; letter-spacing: .5px; font-weight: 700; }
@keyframes cardpulse { 0%,100% { box-shadow: 0 0 0 3px rgba(0,184,176,.45); } 50% { box-shadow: 0 0 0 6px rgba(0,184,176,.20); } }
/* --- Project tabs --- */
.project-tabs { display: flex; flex-wrap: wrap; }
.project-tab {
font: 500 11px/1 'JetBrains Mono', monospace;
padding: 4px 10px; border-radius: 4px; cursor: pointer;
background: var(--surface); border: 1px solid var(--border-muted);
color: var(--text2); transition: all 0.15s;
}
.project-tab:hover { background: var(--elevated); color: var(--text); }
.project-tab.active { background: var(--accent); color: #fff; border-color: var(--accent); }
.project-tab .count { opacity: 0.7; margin-left: 4px; }
</style>
</head>
<body>
<header>
<div class="brand" data-tooltip-target="app-info" tabindex="0">
<svg class="logo" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<rect x="6" y="6" width="12" height="12" rx="1.2"/>
<rect x="9" y="2" width="2" height="2"/><rect x="13" y="2" width="2" height="2"/>
<rect x="9" y="20" width="2" height="2"/><rect x="13" y="20" width="2" height="2"/>
<rect x="2" y="9" width="2" height="2"/><rect x="2" y="13" width="2" height="2"/>
<rect x="20" y="9" width="2" height="2"/><rect x="20" y="13" width="2" height="2"/>
</svg>
<span class="app-name">adom-chip-fetcher</span>
<span class="sep">·</span>
<span class="app-subtitle">manufacturer CAD bundles</span>
</div>
<div class="vsep" aria-hidden="true"></div>
<div class="subject">
<span class="subject-name idle" id="subjectName">—</span>
<span class="state-pill" id="subjectPill"></span>
<span class="subject-meta" id="subjectMeta">
<span class="sep">·</span><span id="metaCards" title="Each tile below is a “card” — one part's full CAD bundle. Tell the AI e.g. “open the B2B-PH-K-S card.”">0 cards</span>
<span class="sep">·</span><span id="metaFull">0 full</span>
<span class="sep">·</span><span id="metaStubs">0 stubs</span>
<span class="sep">·</span><span id="metaSize">0 MB</span>
</span>
</div>
<div class="actions"></div>
<!-- App-info popover (interactive tooltip — stays open on hover) -->
<div class="hover-popover" id="app-info" role="dialog" aria-label="App information">
<div class="popover-title">adom-chip-fetcher</div>
<div class="popover-row"><span class="k">about</span>
<span class="v">Download manufacturer-grade chip CAD bundles — STEP 3D + Altium IntLib + Fusion 360 .lbr + KiCad symbol/footprint + datasheet — into a structured library by driving Chrome via pup. Manufacturer-first sourcing ladder, then SnapEDA / Mouser / DigiKey / Ultra Librarian / Component Search Engine.</span></div>
<div class="popover-row"><span class="k">version</span><span class="v" id="popVersion">v0.1.2</span></div>
<div class="popover-row"><span class="k">built</span><span class="v" id="popBuilt">2026-05-03</span></div>
<div class="popover-row"><span class="k">author</span><span class="v">Adom Inc · adom.inc</span></div>
<div class="popover-actions">
<a class="popover-link" href="https://wiki-ufypy5dpx93o.adom.cloud/apps/chip-fetcher" target="_blank" rel="noopener">Open wiki ↗</a>
<a class="popover-link" href="https://github.com/adom-inc/chip-fetcher" target="_blank" rel="noopener">GitHub ↗</a>
</div>
</div>
</header>
<div class="toolbar" role="toolbar" aria-label="Library filters">
<input type="search" id="q" placeholder="search MPN, vendor, package…">
<select id="vendor"><option value="">all vendors</option></select>
<select id="status">
<option value="">all status</option>
<option value="full">fully-bundled</option>
<option value="stub">stubs only</option>
<option value="no-info">missing info.json</option>
<option value="no-pdf">missing datasheet</option>
</select>
<button id="btn-validate" class="spacer" data-tooltip="Full self-audit: a ✓/✗ matrix of EVERY required file for EVERY chip (datasheet, STEP, symbol, footprint, and the 3 card thumbnails). Goes green ONLY when truly complete — so 'done' can't be faked.">✓ Validation</button>
<button id="btn-sources" data-tooltip="The exact priority order chip-fetcher uses to search the entire internet for electronics data: manufacturer site first, then the Adom data tools (parts-search / Mouser / DigiKey / JLCPCB), then the manual CAD-crawl skills (SnapMagic / CSE / UltraLibrarian), with LCSC dead last.">⛓ Sourcing order</button>
<button id="btn-creds" data-tooltip="Manage saved credentials for vendor portals (Component Search Engine, etc.).\nUsed to authenticate downloads from SnapEDA / Ultra Librarian / DigiKey when a chip needs a login.">Credentials</button>
<button id="btn-reveal-lib" data-tooltip="Reveal the chip-fetcher library folder in the VS Code Explorer sidebar.\nOpens ~/project/chip-fetcher/library/ so you can see every chip directory."><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="vertical-align:-2px;margin-right:4px"><path d="M3 7.5a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7.5z"/></svg>Library folder</button>
<button id="btn-arch" data-tooltip="Architecture map — every tool chip-fetcher depends on: local CLIs (run in your container), native Rust libraries, remote service containers (called over HTTPS), and the sub-apps you may also need to modify. Open this before editing chip-fetcher to understand the whole structure.">🗺 Architecture</button>
<label class="auto-zoom-toggle" data-tooltip="When ON, the active chip's card auto-zooms to the foreground whenever the AI moves to a new chip.\nLets you watch live progress at readable size without clicking each card."><input type="checkbox" id="auto-zoom-cb"> auto-zoom</label>
</div>
<div class="project-tabs" id="project-tabs" style="display:none;padding:0 16px 6px;gap:6px;align-items:center">
<span style="font-size:11px;color:var(--text2);margin-right:4px">project:</span>
</div>
<div class="activity" id="activity-bar">
<span class="dot"></span>
<span class="label">working on</span>
<span class="mpn-tag" id="activity-mpn"></span>
<span class="stage" id="activity-stage"></span>
</div>
<div class="modal-overlay" id="creds-modal">
<div class="modal">
<h3>Saved credentials</h3>
<div class="helper">
Stored at <code>~/.config/chip-fetcher/credentials.json</code> (chmod 600 — only readable by your container user). chip-fetcher rewrites outbound URLs to <code>https://user:pass@host/…</code> so vendor portals like Component Search Engine never show a basic-auth popup.
<br><br>
Passwords are masked by default — click the dots to reveal or hide. The vault is plaintext on disk (vendor-portal creds aren't bank logins) but the UI keeps them off-screen so a casual screenshot doesn't leak them.
</div>
<div class="creds-list" id="creds-list"><div class="info">loading…</div></div>
<h3 style="margin-top:24px">Add / update</h3>
<label>Host pattern (glob)</label>
<input id="cred-host" placeholder="*.componentsearchengine.com" value="*.componentsearchengine.com">
<label>Username</label>
<input id="cred-user" placeholder="[email protected]">
<label>Password</label>
<input id="cred-pass" type="password" placeholder="password">
<div class="buttons">
<button class="btn-secondary" id="cred-close">Close</button>
<button class="btn-primary" id="cred-save">Save credentials</button>
</div>
<div id="cred-msg"></div>
</div>
</div>
<main>
<div class="grid" id="grid-view">
<div class="cards" id="cards"><div class="empty-state">loading library…</div></div>
</div>
<div id="validation-view" style="display:none;grid-column:1/-1;padding:18px 20px;overflow:auto"></div>
<div id="sources-view" style="display:none;grid-column:1/-1;padding:18px 24px;overflow:auto"></div>
<aside class="empty" id="detail">select a card to see details</aside>
</main>
<script>
let LIB = null;
let SELECTED = null;
let DETAIL_TAB = 'actions'; // which detail-panel tab is open (Actions | Details | Files)
// Per-chip+kind source the user picked in the provenance carat (e.g. "ultralibrarian").
// Keyed "mpn|kind". Persisted so the choice sticks across reloads.
let SRC_SEL = (() => { try { return JSON.parse(localStorage.getItem('cf_src_sel')) || {}; } catch { return {}; } })();
function saveSrcSel() { try { localStorage.setItem('cf_src_sel', JSON.stringify(SRC_SEL)); } catch {} }
// The source method to treat as active for a tile: the user's pick if it's still
// a valid source, else the backend canonical ("current").
function activeMethod(mpn, kind, sources) {
const key = mpn + '|' + kind;
const pick = SRC_SEL[key];
if (pick && sources.some(s => s.method === pick)) return pick;
const cur = sources.find(s => s.current);
return cur ? cur.method : (sources[0] && sources[0].method);
}
let ACTIVE_MPN = null;
let ACTIVE_STAGE = '';
let LAST_LIB_HASH = '';
let PROJECTS = null;
let ACTIVE_PROJECT_FILTER = null; // null = show all, string = filter to project
// --- Interactive hover-popover (600 ms show-delay, 220 ms hide-grace) ---
// per adom-app-header §Reference JS + human-ui-patterns §1b (600 ms).
// Hover-intent prevents fly-by triggers; the hide-grace lets the user
// move from the brand block into the popover to click the wiki link.
(function(){
const SHOW_DELAY = 600; // human-ui-patterns §1b
const HIDE_GRACE = 220;
document.querySelectorAll('header [data-tooltip-target]').forEach(trigger => {
const target = document.getElementById(trigger.dataset.tooltipTarget);
if (!target) return;
let showTimer = null, hideTimer = null;
const show = () => {
clearTimeout(hideTimer);
if (target.classList.contains('show')) return;
clearTimeout(showTimer);
showTimer = setTimeout(() => target.classList.add('show'), SHOW_DELAY);
};
const queueHide = () => {
clearTimeout(showTimer);
clearTimeout(hideTimer);
hideTimer = setTimeout(() => target.classList.remove('show'), HIDE_GRACE);
};
trigger.addEventListener('mouseenter', show);
trigger.addEventListener('mouseleave', queueHide);
trigger.addEventListener('focus', show);
trigger.addEventListener('blur', queueHide);
target.addEventListener('mouseenter', show);
target.addEventListener('mouseleave', queueHide);
trigger.addEventListener('click', e => {
clearTimeout(showTimer);
target.classList.toggle('show');
e.stopPropagation();
});
document.addEventListener('click', e => {
if (!target.contains(e.target) && !trigger.contains(e.target)) target.classList.remove('show');
});
});
})();
// Update the header subject zone — currently-fetched/selected MPN + state-pill
// + library-level meta counts. Called from pollActivity, load, and select so
// the header reflects "what we're doing right now" continuously.
function updateSubject() {
const nameEl = document.getElementById('subjectName');
const pillEl = document.getElementById('subjectPill');
const focusMpn = ACTIVE_MPN || SELECTED || null;
if (focusMpn) {
nameEl.textContent = focusMpn;
nameEl.classList.remove('idle');
pillEl.classList.add('show');
if (ACTIVE_MPN === focusMpn) {
pillEl.textContent = 'FETCHING';
pillEl.className = 'state-pill show fetching';
pillEl.setAttribute('data-tooltip', ACTIVE_STAGE || 'fetching…');
} else {
const chip = LIB && LIB.chips.find(c => c.mpn === focusMpn);
if (chip && chip.fully_bundled) {
pillEl.textContent = 'FULL';
pillEl.className = 'state-pill show full';
pillEl.setAttribute('data-tooltip', 'Fully bundled — STEP 3D + schematic symbol + PCB footprint + datasheet are all present.');
} else {
pillEl.textContent = 'STUB';
pillEl.className = 'state-pill show stub';
pillEl.setAttribute('data-tooltip', chip ? 'Missing one or more files (STEP / symbol / footprint / datasheet). Use the ⤓ menu to fetch the rest.' : 'unknown');
}
}
} else {
nameEl.textContent = '—';
nameEl.classList.add('idle');
pillEl.classList.remove('show');
pillEl.removeAttribute('title');
}
if (LIB) {
document.getElementById('metaCards').textContent = `${LIB.fully_count + LIB.stub_count} cards`;
document.getElementById('metaFull').textContent = `${LIB.fully_count} full`;
document.getElementById('metaStubs').textContent = `${LIB.stub_count} stubs`;
document.getElementById('metaSize').textContent = `${(LIB.total_size/1024/1024).toFixed(0)} MB`;
}
}
// Use relative paths so this works behind /proxy/<port>/ tunnels too.
const API_BASE = (() => {
const p = location.pathname;
// strip trailing index.html and trailing slash
return p.replace(/index\.html$/, '').replace(/\/$/, '');
})();
// A heartbeat older than this many seconds is treated as "not actively
// working" — heartbeats are write-only stage labels with no terminal state,
// so without an age check the last one ("fetching X") sticks forever.
const ACTIVITY_STALE_SECS = 25;
function agoLabel(s) {
if (s == null) return '';
if (s < 60) return s + 's ago';
if (s < 3600) return Math.floor(s / 60) + 'm ago';
return Math.floor(s / 3600) + 'h ago';
}
// Re-fetch the library and re-render iff the meaningful per-chip state
// changed. Returns true if it re-rendered. Called from pollActivity AND a
// steady timer, so card bodies (files, thumbnails, validation) stay fresh
// even when nothing is actively fetching — previously the library was only
// re-polled while a heartbeat was live, so idle cards went stale until reload.
async function refreshLibraryIfChanged() {
try {
const libR = await fetch(API_BASE + '/api/library');
const newLib = await libR.json();
const hash = JSON.stringify(newLib.chips.map(c => [c.mpn, c.size_bytes, c.has_step, c.has_sym, c.has_mod, c.pdf_count, c.has_thumb_symbol, c.sym_rev, c.has_thumb_footprint, c.has_thumb_chip3d, c.validation_status, c.pin1_signed, c.pin1_corner, c.fp_pin1_corner]));
if (hash !== LAST_LIB_HASH) {
LIB = newLib;
LAST_LIB_HASH = hash;
render();
updateSubject();
renderProjectTabs();
if (ACTIVE_MPN) { scrollActiveCardIntoView(); autoZoomIfEnabled(ACTIVE_MPN); }
return true;
}
} catch (e) { /* transient */ }
return false;
}
// Poll /api/activity every 1s. Drives the banner + the WORKING card highlight.
async function pollActivity() {
try {
const r = await fetch(API_BASE + '/api/activity');
const a = await r.json();
const mpn = a && a.mpn ? a.mpn : null;
const stage = a && a.stage ? a.stage : '';
const age = (a && typeof a._age_seconds === 'number') ? a._age_seconds : null;
const bar = document.getElementById('activity-bar');
const mpnEl = document.getElementById('activity-mpn');
const stEl = document.getElementById('activity-stage');
const terminalStage = !stage || stage === 'idle' || stage === 'done' || stage === 'thumbnails ready';
const stale = age != null && age > ACTIVITY_STALE_SECS;
const working = mpn && !terminalStage && !stale;
if (working) {
bar.classList.add('live');
bar.classList.remove('stale');
mpnEl.textContent = mpn;
stEl.textContent = stage ? '— ' + stage : '';
const stageChanged = stage !== ACTIVE_STAGE;
ACTIVE_STAGE = stage;
if (mpn !== ACTIVE_MPN) {
ACTIVE_MPN = mpn;
render();
updateSubject();
scrollActiveCardIntoView();
autoZoomIfEnabled(mpn);
} else if (stageChanged) {
updateSubject();
}
await refreshLibraryIfChanged();
} else {
// Clear the WORKING highlight the moment work stops/goes stale.
if (ACTIVE_MPN !== null) {
ACTIVE_MPN = null;
ACTIVE_STAGE = '';
render();
updateSubject();
}
// Don't lie with a permanent green WORKING bar. If there's a stale
// last-heartbeat, show it muted with its age; otherwise hide the bar.
if (mpn && stale && stage) {
bar.classList.remove('live');
bar.classList.add('stale');
mpnEl.textContent = mpn;
stEl.textContent = '— idle · last: ' + stage + (age != null ? ' · ' + agoLabel(age) : '');
} else {
bar.classList.remove('live', 'stale');
}
}
} catch (e) { /* ignore transient failures */ }
}
setInterval(pollActivity, 1000);
// Keep card bodies live even when idle (no active heartbeat).
setInterval(refreshLibraryIfChanged, 4000);
async function load() {
const r = await fetch(API_BASE + '/api/library');
LIB = await r.json();
updateSubject();
const vendors = [...new Set(LIB.chips.map(c => c.vendor))].sort();
const vsel = document.getElementById('vendor');
for (const v of vendors) {
const o = document.createElement('option'); o.value = v; o.textContent = v;
vsel.appendChild(o);
}
render();
loadProjects();
}
async function loadProjects() {
try {
const r = await fetch(API_BASE + '/api/projects');
if (!r.ok) return;
PROJECTS = await r.json();
// On first load, honor the server's active project so the dashboard opens
// already filtered (set via `chip-fetcher project activate <name>`), instead
// of dumping the full unfiltered library. The user can still tap "All".
if (!window._projInit) {
window._projInit = true;
if (PROJECTS && PROJECTS.active && PROJECTS.projects && PROJECTS.projects[PROJECTS.active]) {
ACTIVE_PROJECT_FILTER = PROJECTS.active;
}
}
renderProjectTabs();
applyDeepLink();
} catch {}
}
// Deep links: ?project=<name>&chip=<MPN>&source=<method>&zoom=1 open the
// dashboard already filtered to the project, the chip selected + scrolled into
// view (with an optional source preselected), and optionally zoomed — so a
// shared link lands exactly on what to look at.
function applyDeepLink() {
if (window._deepLinkDone || !LIB) return;
window._deepLinkDone = true;
const p = new URLSearchParams(location.search);
const proj = p.get('project'), chip = p.get('chip'), source = p.get('source');
const zoom = p.get('zoom'); const wantZoom = zoom && zoom !== '0' && zoom !== 'false';
if (!proj && !chip) return;
if (proj && PROJECTS && PROJECTS.projects && PROJECTS.projects[proj]) {
ACTIVE_PROJECT_FILTER = proj; renderProjectTabs();
}
if (source && chip) {
SRC_SEL[chip + '|symbol'] = source; SRC_SEL[chip + '|footprint'] = source; saveSrcSel();
}
render();
if (chip) {
setTimeout(() => {
select(chip);
const card = document.querySelector(`.card[data-mpn="${CSS.escape(chip)}"]`);
if (card) {
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
if (wantZoom) setTimeout(() => zoomCard(card), 380);
}
}, 160);
}
}
function renderProjectTabs() {
const bar = document.getElementById('project-tabs');
if (!PROJECTS || !PROJECTS.projects || Object.keys(PROJECTS.projects).length === 0) {
bar.style.display = 'none';
return;
}
bar.style.display = 'flex';
const label = bar.querySelector('span');
bar.innerHTML = '';
bar.appendChild(label);
// "All" tab
const allTab = document.createElement('div');
allTab.className = 'project-tab' + (ACTIVE_PROJECT_FILTER === null ? ' active' : '');
allTab.innerHTML = `Library<span class="count">${LIB ? LIB.chips.length : 0}</span>`;
allTab.onclick = () => { ACTIVE_PROJECT_FILTER = null; renderProjectTabs(); render(); };
bar.appendChild(allTab);
// Per-project tabs
for (const [name, proj] of Object.entries(PROJECTS.projects)) {
const tab = document.createElement('div');
tab.className = 'project-tab' + (ACTIVE_PROJECT_FILTER === name ? ' active' : '');
tab.innerHTML = `${name}<span class="count">${proj.mpns.length}</span>`;
tab.onclick = () => { ACTIVE_PROJECT_FILTER = name; renderProjectTabs(); render(); };
bar.appendChild(tab);
}
// NOTE: We deliberately do NOT auto-apply the server's persisted "active"
// project here. That field survives across sessions, so auto-narrowing the
// grid to it silently hid freshly-fetched chips behind a stale filter from
// weeks-old, unrelated work. The dashboard always opens on the full Library;
// the user clicks a project tab to filter. (PROJECTS.active is still shown
// highlighted if you want to know what the CLI considers active.)
}
function srcTier(src) {
if (src.startsWith('mfr-direct')) return 'mfr';
if (src.startsWith('mfr-')) return 'mfr';
if (src.startsWith('mouser') || src.startsWith('digikey') || src.startsWith('ul-')) return 'fb';
return 'unk';
}
function srcShort(src) {
if (src === '—' || !src) return '?';
return src.replace('-fallback','').replace('mouser-','mouser/').replace('mfr-','');
}
function render() {
const q = document.getElementById('q').value.toLowerCase();
const v = document.getElementById('vendor').value;
const s = document.getElementById('status').value;
const cards = document.getElementById('cards');
cards.innerHTML = '';
const filtered = LIB.chips.filter(c => {
if (ACTIVE_PROJECT_FILTER && PROJECTS) {
const proj = PROJECTS.projects[ACTIVE_PROJECT_FILTER];
if (proj && !proj.mpns.includes(c.mpn)) return false;
}
if (q && !(c.mpn + ' ' + c.vendor + ' ' + c.package).toLowerCase().includes(q)) return false;
if (v && c.vendor !== v) return false;
if (s === 'full' && !c.fully_bundled) return false;
if (s === 'stub' && c.fully_bundled) return false;
if (s === 'no-info' && c.has_info) return false;
if (s === 'no-pdf' && c.has_pdf) return false;
return true;
});
if (filtered.length === 0) {
cards.innerHTML = '<div class="empty-state">no chips match filters</div>';
return;
}
// If there's an active chip that isn't in the filtered set, still surface it
// (so the user always sees what's being worked on) — but make it OBVIOUS
// it's outside the current filter, with a one-click way to clear, instead of
// silently injecting a card that contradicts the grid behind it.
let outOfFilterMpn = null;
if (ACTIVE_MPN && !filtered.find(c => c.mpn === ACTIVE_MPN)) {
const activeChip = LIB.chips.find(c => c.mpn === ACTIVE_MPN);
if (activeChip) { filtered.unshift(activeChip); outOfFilterMpn = ACTIVE_MPN; }
}
if (outOfFilterMpn) {
const where = ACTIVE_PROJECT_FILTER ? ('project “' + ACTIVE_PROJECT_FILTER + '”') : 'the current filter';
const note = document.createElement('div');
note.className = 'out-of-filter-note';
note.innerHTML = `Showing <b>${escapeHtml(outOfFilterMpn)}</b> — not in ${escapeHtml(where)}. <button class="ofn-clear" type="button">Clear filter</button>`;
note.querySelector('.ofn-clear').onclick = () => {
ACTIVE_PROJECT_FILTER = null;
document.getElementById('q').value = '';
document.getElementById('vendor').value = '';
document.getElementById('status').value = '';
renderProjectTabs();
render();
};
cards.appendChild(note);
}
for (const c of filtered) {
const card = document.createElement('div');
const cls = ['card'];
if (SELECTED === c.mpn) cls.push('selected');
if (ACTIVE_MPN === c.mpn) cls.push('active');
card.className = cls.join(' ');
card.dataset.mpn = c.mpn;
const tier = srcTier(c.step_source);
const m = (c.stock && c.stock.mouser) || null;
// Always show stock + price badges. "?" when we don't have data.
let stockBadge = '<span class="b stock-unk" data-tooltip="Stock level unknown.\nMouser stock data hasn\'t been fetched yet for this chip. Run a fetch to populate stock.json with current inventory + pricing.">stock ?</span>';
let priceBadge = '<span class="b price" data-tooltip="Unit price unknown.\nMouser pricing hasn\'t been fetched yet. Run a fetch to get price breaks from Mouser\'s API.">$?</span>';
let lifecycleBadge = '<span class="b lifecycle-unk" data-tooltip="Lifecycle status unknown.\nMouser hasn\'t been queried for this chip\'s lifecycle (Active / NRND / EOL / New). Run a fetch to populate.">lifecycle ?</span>';
if (m) {
const s = m.stock || 0;
const cls = s >= 1000 ? 'stock-yes' : s > 0 ? 'stock-low' : 'stock-no';
const label = s > 0 ? (s >= 1000 ? `${(s/1000).toFixed(s>=10000?0:1)}k in stock` : `${s} in stock`) : 'no stock';
stockBadge = `<span class="b ${cls}" data-tooltip="Mouser stock: ${s} units.\n${s>=1000?'Well stocked — safe for production quantities.':s>0?'Low stock — check lead time before committing.':'Out of stock at Mouser. Check DigiKey or JLCPCB as alternatives.'}">${label}</span>`;
priceBadge = m.unit_price
? `<span class="b price" data-tooltip="Mouser unit price: ${escapeHtml(m.unit_price)}.\nPrice at qty 1. Click the Mouser link in the chip detail panel for price breaks at higher quantities.">${escapeHtml(m.unit_price)}</span>`
: '<span class="b price" data-tooltip="Price not available from Mouser for this chip.">$?</span>';
const lc = (m.lifecycle || '').toLowerCase();
if (/end of life|obsolete|discontinued|last.?time/.test(lc)) {
lifecycleBadge = `<span class="b eol" data-tooltip="End of Life (${escapeHtml(m.lifecycle)}).\nThis chip is discontinued. Do NOT use in new designs — it will become unavailable. chip-fetcher will suggest active replacements if available.">EOL — DON'T USE</span>`;
} else if (/not recommended|nrnd|phased.?out|mature/.test(lc)) {
lifecycleBadge = `<span class="b nrnd" data-tooltip="Not Recommended for New Designs (${escapeHtml(m.lifecycle)}).\nStill available but the manufacturer is phasing it out. Use an active replacement for new designs; OK for existing boards.">NRND — AVOID FOR NEW DESIGNS</span>`;
} else if (/new product|new release/.test(lc)) {
lifecycleBadge = `<span class="b new" data-tooltip="New product (${escapeHtml(m.lifecycle)}).\nRecently released by the manufacturer. Stock may be limited initially but supply is ramping.">NEW</span>`;
} else if (lc) {
lifecycleBadge = '';
} else {
lifecycleBadge = '<span class="b lifecycle-unk" data-tooltip="Lifecycle field empty in Mouser\'s response.\nThe chip is listed but Mouser didn\'t report Active/NRND/EOL status. Usually means Active.">lifecycle ?</span>';
}
}
const pkgDisplay = (c.package && c.package !== '?' && c.package !== '—') ? c.package : '?';
const sizeDisplay = c.size_bytes > 0 ? `${(c.size_bytes/1024/1024).toFixed(1)} MB` : '? MB';
// Per-EDA-tool badges. Each tool gets one badge:
// green = both symbol + footprint present
// amber = partial (e.g. only schematic, or only footprint)
// grey = missing entirely (shows "?")
const ked = badgeForEda('KICAD', c.has_sym, c.has_mod, c.info, c.file_provenance, c.mpn);
const alt = badgeForEda('ALTIUM', c.has_altium_sch, c.has_altium_pcb, c.info, c.file_provenance, c.mpn);
const fus = badgeForEda('FUSION', c.has_fusion_lbr, c.has_fusion_lbr, c.info, c.file_provenance, c.mpn); // Fusion ships sym+mod in one .lbr
// Pin-1 alignment status from chipsmith: did the user line up the 3D chip's
// pin-1 with the footprint's pin-1 and sign off? Only meaningful once both the
// 3D model and the footprint exist.
let pin1Badge = '';
if (c.has_step && c.has_mod) {
const fp = c.fp_pin1_corner || null; // footprint's pin-1 corner = the target
if (c.pin1_signed) {
const corner = c.pin1_corner || '?';
const when = c.pin1_signed_at ? ' (signed ' + String(c.pin1_signed_at).slice(0,10) + ')' : '';
const meas = c.pin1_measure_ok === false ? '\nNote: chipsmith flagged a measurement mismatch.' : '';
if (fp && corner !== '?' && corner !== fp) {
pin1Badge = `<span class="b pin1bad" data-tooltip="Pin-1 MISMATCH${when}.\nThe 3D chip's pin-1 was signed off in the ${escapeHtml(corner)} corner, but the footprint's pin-1 is in the ${escapeHtml(fp)} corner. Re-align in chipsmith so the chip and footprint agree.${meas}">Pin-1 ⚠ chip ${escapeHtml(corner)} / fp ${escapeHtml(fp)}</span>`;
} else {
const agree = fp ? ' (matches the footprint)' : '';
pin1Badge = `<span class="b pin1ok" data-tooltip="Pin-1 aligned + signed off in chipsmith${when}.\nPin-1 sits in the ${escapeHtml(corner)} corner${agree}; the 3D chip is lined up to the footprint. ${escapeHtml(c.mpn)}.chipsmith.step is the canonical oriented model downstream tools use.${meas}">Pin-1 ✓ ${escapeHtml(corner)}</span>`;
}
} else {
const target = fp ? ' · fp ' + escapeHtml(fp) : '';
const tip = fp
? `Pin-1 not yet aligned.\nThe footprint pin-1 is in the ${fp} corner (that's the target). Use the ‘Align pin-1’ button (opens chipsmith) to line up the 3D chip's pin-1 to it and sign off; chip-fetcher then shows the chip corner here + bakes ${escapeHtml(c.mpn)}.chipsmith.step.`
: `Pin-1 not yet aligned.\nThe 3D chip and footprint are both here. Use the ‘Align pin-1’ button (opens chipsmith) to line up pin-1 and sign off.`;
pin1Badge = `<span class="b pin1" data-tooltip="${tip}">Pin-1 ?${target}</span>`;
}
}
// Validation badge (concur status). Read straight from inventory.rs's
// validation_status field which we read from <MPN>-validation-status.json.
let valBadge = '';
if (c.validation_status === 'golden') {
valBadge = `<span class="b val-golden" data-tooltip="Golden dataset — every source (ds2sf from datasheet, KiCad UL bundle, EAGLE, Altium) agrees on pin count, pad geometry, pin names, and package family. Safe for downstream use.">✓ GOLDEN</span>`;
} else if (c.validation_status === 'golden_with_variants') {
const vs = (c.validation_variants || []).map(v => v.replace(/_/g, ' ')).join(', ');
valBadge = `<span class="b val-golden-v" data-tooltip="Golden with explainable variants.\nSources disagree on some axes, but every disagreement is benign:\n${escapeHtml(vs)}\nSafe for downstream — the variant list tells tools which source to prefer per axis.">✓ GOLDEN+${(c.validation_variants||[]).length}v</span>`;
} else if (c.validation_status === 'divergent') {
valBadge = `<span class="b val-divergent" data-tooltip="Divergent — at least one source disagrees and the disagreement is NOT explained by a known variant.\nClick ⟲ to re-validate, or open the concur result.json for the specific conflict axis + suggested refetch sources.">⚠ DIVERGENT</span>`;
} else if (c.validation_status === 'unrecoverable') {
valBadge = `<span class="b val-bad" data-tooltip="Unrecoverable — concur could not parse enough sources to run the comparison.\nCheck the chip directory for missing or corrupt files.">✗ FAIL</span>`;
} else {
valBadge = `<span class="b val-none" data-tooltip="Not yet validated.\nClick ⟲ to run ds2sf (extracts symbol + footprint from the datasheet PDF) then concur (cross-checks against KiCad/EAGLE/Altium). Takes 30-60s per chip.">— UNVAL</span>`;
}
// NOTE: the datasheet-derived ("authoritative") footprint is NOT a separate
// tile anymore — it lives as the `ds2sf · datasheet` source on the FP tile's
// provenance carat (one tap to compare against the vendor footprint). A big
// white SVG tile here was redundant with the FP tile and broke dark mode.
card.innerHTML = `
<div class="mpn-row">
<div class="mpn">${escapeHtml(c.mpn)}</div>
<div class="card-actions">
<button class="card-btn" data-tooltip="Re-validate this chip.\nRuns ds2sf (extracts symbol + footprint from datasheet PDF) then concur (cross-checks against KiCad/EAGLE/Altium libs). Updates the validation badge on this card when done." data-action="validate" data-mpn="${escapeHtml(c.mpn)}" aria-label="Re-validate">⟲</button>
<button class="card-btn" data-tooltip="Re-render thumbnails.\nRegenerates the symbol SVG, footprint SVG, and all 6 orientation 3D PNGs from the current library files. Useful after importing a new STEP or .kicad_sym." data-action="thumbnails" data-mpn="${escapeHtml(c.mpn)}" aria-label="Re-render thumbnails">🖼</button>
<select class="card-btn refetch-select" data-tooltip="Re-fetch a specific file from a different source.\nPick what you need and where to get it. Opens the source's product page in a browser window and watches for the download." data-mpn="${escapeHtml(c.mpn)}" aria-label="Refetch from source">
<option value="">⤓ re-fetch…</option>
<option value="manufacturer|kicad_sym">Manufacturer → Schematic symbol</option>
<option value="manufacturer|kicad_mod">Manufacturer → PCB footprint</option>
<option value="snapmagic|kicad_sym">SnapMagic → Schematic symbol</option>
<option value="snapmagic|kicad_mod">SnapMagic → PCB footprint</option>
<option value="mouser|kicad_sym">Mouser → Schematic symbol</option>
<option value="digikey|kicad_sym">DigiKey → Schematic symbol</option>
<option value="cse_ul|kicad_sym">Ultra Librarian → Schematic symbol</option>
<option value="manufacturer|step">Manufacturer → 3D STEP model</option>
<option value="manufacturer|pdf">Manufacturer → Datasheet PDF</option>
</select>
<button class="reveal-btn" data-tooltip="Open this chip's folder in VS Code Explorer.\nReveals library/${escapeHtml(c.mpn)}/ in the sidebar so you can see every file (STEP, KiCad sym/mod, datasheets, info.json, provenance logs)." data-reveal-mpn="${escapeHtml(c.mpn)}" aria-label="Reveal ${escapeHtml(c.mpn)} in VS Code Explorer"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7.5a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7.5z"/></svg></button>
<button class="card-btn zoom-btn" data-tooltip="Zoom this chip card into a centered overlay for close inspection (tap the backdrop to close). On a phone this is the way to enlarge a card — double-tap is reserved by the browser for page zoom." data-zoom-mpn="${escapeHtml(c.mpn)}" aria-label="Zoom ${escapeHtml(c.mpn)} card"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="M21 21l-3.5-3.5M11 8v6M8 11h6"/></svg></button>
</div>
</div>
<div class="meta">${escapeHtml(c.vendor || '?')} · ${escapeHtml(pkgDisplay)} · ${sizeDisplay}</div>
${(c.info && (c.info.summary || c.info.description)) ? `<div class="card-desc" data-tooltip="${escapeHtml(c.info.summary || c.info.description)}">${escapeHtml(c.info.summary || c.info.description)}</div>` : ''}
<div class="thumbs">
<!-- Primary row: the two artifacts you design with — biggest. -->
<div class="thumb-row primary">
${renderThumb(c.mpn, 'symbol', c.has_thumb_symbol, 'SYM', null, thumbErr(c, 'sym'), c.symbol_sources)}
${renderThumb(c.mpn, 'footprint', c.has_thumb_footprint, 'FP', null, thumbErr(c, 'fp'), c.footprint_sources)}
</div>
<!-- Secondary row: 3D up-axis QA + the .lbr packaging action — smaller. -->
<div class="thumb-row secondary">
<div class="thumb-3d-group">${render3dGroup(c)}</div>
${(c.has_sym || c.has_mod) ? `<div class="thumb thumb-lbr" data-tooltip="Build a true EAGLE .lbr (symbol + footprint) for ${escapeHtml(c.mpn)} and open it in the live adom-lbr app — the ONE EAGLE library that Fusion 360 / Altium / OrCAD all import directly. Lints it, then one-click Send to your desktop EDA tool. KiCad isn't in that list on purpose: KiCad can't read .lbr — it uses its own native .kicad_sym + .kicad_mod, which you already have (the KICAD capsule / Send → KiCad)." data-thumb-mpn="${escapeHtml(c.mpn)}" data-thumb-kind="lbr"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 5.5A1.5 1.5 0 0 1 5.5 4H9a1.5 1.5 0 0 1 1.5 1.5V20a1.5 1.5 0 0 0-1.5-1.5H5.5A1.5 1.5 0 0 1 4 17z"/><path d="M20 5.5A1.5 1.5 0 0 0 18.5 4H15a1.5 1.5 0 0 0-1.5 1.5V20a1.5 1.5 0 0 1 1.5-1.5h3.5a1.5 1.5 0 0 0 1.5-1.5z"/></svg><span class="thumb-label">.lbr</span></div>` : ''}
</div>
</div>
<div class="badges">
<span class="b ${c.fully_bundled?'full':'stub'}" data-tooltip="${c.fully_bundled?'Fully bundled — has STEP + KiCad symbol + KiCad footprint + Altium + Fusion .lbr.\nThis chip is ready for all downstream tools (chipsmith, InstaPCB, molecule creation).':'Stub — missing one or more core CAD files (STEP, KiCad sym/mod, Altium, Fusion).\nClick ⟲ to validate, or use the ⤓ dropdown to refetch missing files from a specific source.'}">${c.fully_bundled?'FULL':'STUB'}</span>
${valBadge}
${lifecycleBadge}
${stockBadge}
${priceBadge}
<span class="b ${c.has_step?'step':'miss'}" data-tooltip="${c.has_step?'Source: ' + resolveStepSource(c).label + '\n\nThe 3D STEP model — used by chipsmith for pin detection + footprint validation, and by chip-thumbnailer for the orientation renders above.':'3D STEP model missing.\nNeeded for chipsmith validation + 3D thumbnails.'}">STEP${c.has_step?'':' ?'}</span>
${ked}
${alt}
${fus}
<span class="b ${c.pdf_count>0?'pdf':'miss'}${c.has_pdf ? ' clickable' : ''}" data-tooltip="${c.pdf_count>0?'Source: ' + fileSource(c, /\.pdf$/i).label + '\n\n' + c.pdf_count+' datasheet PDF'+(c.pdf_count>1?'s':'')+' in the chip directory.\nClick to open in the PDF viewer.':'No datasheet PDF yet.\nUse ⤓ → Manufacturer → Datasheet PDF to fetch it.'}" ${c.has_pdf ? `data-thumb-mpn="${escapeHtml(c.mpn)}" data-thumb-kind="datasheet"` : ''}>${c.pdf_count>0 ? c.pdf_count : '?'} PDF${c.pdf_count===1?'':'s'}</span>
<span class="b src ${tier}" data-tooltip="${(function(){var r=resolveStepSource(c);return r.found?'STEP source: '+r.label+'.\nThis is where the 3D model came from. Ladder priority: Mfr → SnapMagic → Mouser → DigiKey → Arrow → CSE/UL → LCSC.':'STEP source '+r.label+'.\nchip-fetcher downloaded this STEP but did not record where from. Refetch to capture it.';})()}">${escapeHtml((function(){var r=resolveStepSource(c);return r.found?srcShort(r.label):'src ?';})())}</span>
${pin1Badge}
</div>
`;
card.onclick = (ev) => {
const rb = ev.target.closest('.reveal-btn');
if (rb) { ev.stopPropagation(); revealFolder(rb.dataset.revealMpn); return; }
// Per-card action buttons (validate / thumbnails) — POST to the
// matching /api/<action>/<MPN> endpoint, the server spawns
// chip-fetcher as a subprocess. Dashboard auto-refreshes from
// /api/library polling.
const ab = ev.target.closest('.card-btn[data-action]');
if (ab) {
ev.stopPropagation();
const action = ab.dataset.action;
const mpn = ab.dataset.mpn;
ab.disabled = true;
ab.style.opacity = '0.5';
fetch(API_BASE + '/api/' + action + '/' + encodeURIComponent(mpn), { method: 'POST' })
.then(r => r.json())
.then(j => {
console.log('[' + action + '] kicked:', j);
// The subprocess heartbeats; the dashboard's activity poller
// will pick that up + render the WORKING badge.
})
.catch(e => console.error('[' + action + '] failed:', e))
.finally(() => {
// Leave the button disabled briefly so the user gets visual
// confirmation; re-enable after 3s. The actual work continues
// in the background.
setTimeout(() => { ab.disabled = false; ab.style.opacity = ''; }, 3000);
});
return;
}
const tb = ev.target.closest('.thumb[data-thumb-kind]');
if (tb) {
ev.stopPropagation();
const flipTo = tb.dataset.orientFlip;
if (flipTo) { setOrientation(tb.dataset.thumbMpn, flipTo); return; }
// Symbol/footprint tiles are NOT clickable to open the app — that
// conflicted with the source-carat menu sitting on them. Use the carat to
// switch source and the "Open in" buttons to launch. 3D/datasheet still open.
if (tb.dataset.thumbKind === 'symbol' || tb.dataset.thumbKind === 'footprint') return;
openThumb(tb.dataset.thumbMpn, tb.dataset.thumbKind);
return;
}
// Single-click SELECTS the chip → fills the right-hand detail panel
// (the "select a chip to see details" pane). Double-click zooms the
// card into a centered overlay for close inspection.
select(c.mpn);
};
card.ondblclick = (ev) => {
if (ev.target.closest('.card-btn, .reveal-btn, .thumb, .refetch-select, .orient-more-btn, .orient-popover')) return;
zoomCard(card);
};
// Tap-to-zoom (touch-friendly — phones reserve double-tap for page zoom).
const zb = card.querySelector('.zoom-btn');
if (zb) zb.onclick = (ev) => { ev.stopPropagation(); zoomCard(card); };
// Orient-more popover toggle + pick
const orientBtn = card.querySelector('.orient-more-btn');
const orientPop = card.querySelector('.orient-popover');
if (orientBtn && orientPop) {
orientBtn.onclick = (ev) => {
ev.stopPropagation();
orientPop.style.display = orientPop.style.display === 'none' ? 'grid' : 'none';
};
orientPop.querySelectorAll('.orient-opt').forEach(opt => {
opt.onclick = (ev) => {
ev.stopPropagation();
const axis = opt.dataset.axis;
const mpn = opt.dataset.mpn;
orientPop.style.display = 'none';
setOrientation(mpn, axis);
};
});
}
// Refetch dropdown — separate handler because <select> needs change
// event, not click.
const refSel = card.querySelector('.refetch-select');
if (refSel) {
refSel.onchange = (ev) => {
ev.stopPropagation();
const v = ev.target.value;
if (!v) return;
const [source, format] = v.split('|');
const mpn = ev.target.dataset.mpn;
ev.target.value = ''; // reset selector
fetch(API_BASE + '/api/refetch/' + encodeURIComponent(mpn) +
'?format=' + encodeURIComponent(format) + '&source=' + encodeURIComponent(source),
{ method: 'POST' })
.then(r => r.json())
.then(j => console.log('[refetch] kicked:', j))
.catch(e => console.error('[refetch] failed:', e));
};
refSel.onclick = (ev) => ev.stopPropagation();
}
cards.appendChild(card);
}
}
function autoZoomIfEnabled(mpn) {
const cb = document.getElementById('auto-zoom-cb');
if (!cb || !cb.checked) return;
// Dismiss any existing zoom
const existing = document.querySelector('.card-zoom-backdrop');
if (existing) existing.remove();
// Find the card and zoom it
const card = document.querySelector(`[data-mpn="${CSS.escape(mpn)}"]`);
if (card) {
// Small delay so the render settles first
setTimeout(() => zoomCard(card), 100);
}
}
function zoomCard(cardEl) {
// PROGRAMMATIC ANTI-STACK GUARD. pollActivity (1s), the library refresh (4s),
// auto-zoom, and manual clicks can all call zoomCard within the same frame —
// autoZoomIfEnabled's remove-then-setTimeout(100) loses the race when two
// callers both pass the "none open" check before either timeout fires, so two
// backdrops get created and stack. The only robust fix is to dedup HERE, at
// the moment of creation: there is ever only ONE .card-zoom-backdrop. If one
// is already open for this same chip, do nothing; otherwise replace it.
const alreadyOpen = document.querySelector('.card-zoom-backdrop');
if (alreadyOpen) {
if (alreadyOpen.dataset.mpn === cardEl.dataset.mpn) return;
alreadyOpen.remove();
}
// Animate FROM the card's grid position TO centered on screen so
// the user can track which card is zooming. The clone starts at
// the card's exact rect (scale 1) and transitions to the final
// centered + scaled position.
const rect = cardEl.getBoundingClientRect();
// Phone: fill the browser width (no 1.9 cap) and allow vertical scroll.
const phone = matchMedia('(hover: none)').matches || window.innerWidth < 760;
// The FINAL scale is computed AFTER the clone is in the DOM, from its REAL
// height (which includes the injected Open-in/Send-to actions + status banner).
// Computing it here from the original card height clipped those off the bottom.
const backdrop = document.createElement('div');
backdrop.className = 'card-zoom-backdrop';
backdrop.dataset.mpn = cardEl.dataset.mpn || '';
const wrapper = document.createElement('div');
wrapper.className = 'card-zoomed-wrapper';
const clone = cardEl.cloneNode(true);
clone.style.width = rect.width + 'px';
clone.style.transformOrigin = 'top left';
// Inject Open-in + Send-to into the zoom focus view so the chip's actions are
// reachable here (especially on a phone, where the side panel is cramped).
// The .openbtn/.sendbtn handlers are delegated on document, so they just work.
const zchip = (LIB && LIB.chips || []).find(x => x.mpn === cardEl.dataset.mpn);
if (zchip) {
const open = [
zchip.has_sym && ['symbol', 'adom-symbol'],
zchip.has_mod && ['footprint', 'adom-footprint'],
(zchip.has_sym || zchip.has_mod) && ['lbr', 'adom-lbr'],
zchip.has_step && ['chip3d', 'adom-3D'],
zchip.has_step && ['chipsmith', 'chipsmith'],
(zchip.symbol_sources || []).some(s => s.method === 'ds2sf') && ['ds2sf', 'ds2sf extraction'],
].filter(Boolean);
const sec = document.createElement('div');
sec.className = 'zoom-actions';
sec.innerHTML =
`<div class="zoom-sec">Open in</div><div class="openin-row" data-mpn="${escapeHtml(zchip.mpn)}">`
+ open.map(([k, l]) => `<button class="openbtn" data-kind="${k}">${l}</button>`).join('')
+ `</div><div class="zoom-sec">Send to</div><div class="sendto-row" data-mpn="${escapeHtml(zchip.mpn)}">`
+ [['kicad', 'KiCad'], ['fusion360', 'Fusion'], ['altium', 'Altium'], ['orcad', 'OrCAD']]
.map(([t, l]) => `<button class="sendbtn" data-target="${t}">${l}</button>`).join('')
+ `</div>`;
clone.appendChild(sec);
}
// Fluid 2-column layout (desktop): move the media (.thumbs) into a left column
// and everything else into a right column, so the zoom uses the horizontal room
// and is short enough to scale up big. Children stay descendants of the clone,
// so the click re-wiring below still finds them.
if (!phone) {
const media = document.createElement('div'); media.className = 'zoom-col-media';
const info = document.createElement('div'); info.className = 'zoom-col-info';
Array.from(clone.children).forEach(ch => {
(ch.classList.contains('thumbs') ? media : info).appendChild(ch);
});
clone.appendChild(media);
clone.appendChild(info);
clone.classList.add('zoom-fluid');
clone.style.width = Math.min(900, Math.round(window.innerWidth * 0.82)) + 'px';
}
// Start at the card's grid position (scale 1)
wrapper.style.left = rect.left + 'px';
wrapper.style.top = rect.top + 'px';
clone.style.transform = 'scale(1)';
// Show a "working on" banner ONLY while THIS chip is actively being
// fetched. ACTIVE_MPN/ACTIVE_STAGE are maintained by pollActivity and are
// null when idle/stale — so an idle chip shows no banner at all (it used to
// inject an empty "working on" box, which read as broken).
const zoomMpn = cardEl.dataset.mpn;
function syncZoomStatus() {
const working = ACTIVE_MPN === zoomMpn && ACTIVE_STAGE;
// The status banner floats on the BACKDROP (fixed, top-center) — NOT inside
// the card. Injecting it into the card reflowed + rescaled the whole layout
// every time it appeared/disappeared, which read as "wacky". As an overlay
// pill it never touches the card's geometry; the liked layout stays put.
let bar = backdrop.querySelector('.zoom-status');
if (working) {
if (!bar) {
bar = document.createElement('div');
bar.className = 'zoom-status';
bar.style.cssText = 'position:fixed;top:18px;left:50%;transform:translateX(-50%);z-index:20;background:rgba(0,184,176,.18);border:1px solid var(--accent);border-radius:999px;padding:8px 18px;font-size:13px;font-weight:600;color:var(--accent);font-family:Satoshi,sans-serif;box-shadow:0 6px 22px rgba(0,0,0,.45);backdrop-filter:blur(6px);white-space:nowrap;';
backdrop.appendChild(bar);
}
bar.textContent = '● working on — ' + ACTIVE_STAGE;
} else if (bar) {
bar.remove();
}
}
syncZoomStatus();
const statusPoll = setInterval(syncZoomStatus, 1500);
wrapper.appendChild(clone);
backdrop.appendChild(wrapper);
// Dismiss with reverse animation (declared before use by the X button).
function dismiss() {
clearInterval(statusPoll);
backdrop.classList.remove('visible');
if (!phone) { wrapper.style.left = rect.left + 'px'; wrapper.style.top = rect.top + 'px'; clone.style.transform = 'scale(1)'; }
setTimeout(() => backdrop.remove(), 300);
document.removeEventListener('keydown', escHandler);
}
// Always-visible close (X) so it's obvious how to get back to the index.
const xbtn = document.createElement('button');
xbtn.className = 'card-zoom-x'; xbtn.setAttribute('aria-label', 'Close'); xbtn.textContent = '✕';
xbtn.onclick = (e) => { e.stopPropagation(); dismiss(); };
backdrop.appendChild(xbtn);
document.body.appendChild(backdrop);
// Fit the WHOLE card — including the injected Open-in/Send-to actions + any
// status banner — on screen with ONE zoom factor: the largest scale that fits
// BOTH the width and the card's REAL height. So it fills as much of the room as
// it can, is always fully visible, and NEVER scrolls or clips. (Measure the
// clone's natural height now that it's in the DOM at scale 1.)
// Fit the card to the viewport. CRITICAL: measure the clone's size AFTER the
// 2-column fluid layout + thumbnails have settled. Measuring synchronously
// here reads the TALL single-column height (before the grid reflows to wide-
// and-short), so the scale comes out too small and the card ends up a cramped
// little box floating in empty space — exactly the "weird at this zoom" bug.
function fitZoom() {
const naturalW = clone.offsetWidth || rect.width;
const naturalH = clone.offsetHeight || rect.height;
const scale = Math.min(
(window.innerWidth * (phone ? 0.96 : 0.94)) / naturalW,
(window.innerHeight * 0.94) / naturalH
);
const scaledW = naturalW * scale, scaledH = naturalH * scale;
wrapper.style.left = Math.max(8, (window.innerWidth - scaledW) / 2) + 'px';
wrapper.style.top = Math.max(8, (window.innerHeight - scaledH) / 2) + 'px';
clone.style.transition = 'transform .3s ease-out';
clone.style.transform = `scale(${scale})`;
}
backdrop.classList.add('visible');
// Double-rAF so the fluid 2-col layout has reflowed before we measure…
requestAnimationFrame(() => requestAnimationFrame(fitZoom));
// …and re-fit when a late-loading thumbnail changes the card's height.
clone.querySelectorAll('img').forEach(img => {
if (!img.complete) img.addEventListener('load', fitZoom, { once: true });
});
// Re-fit on browser zoom / window resize so it never goes weird at non-100%.
const onResize = () => fitZoom();
window.addEventListener('resize', onResize);
backdrop.addEventListener('remove', () => window.removeEventListener('resize', onResize));
backdrop.onclick = (ev) => { if (ev.target === backdrop) dismiss(); };
const escHandler = (ev) => { if (ev.key === 'Escape') dismiss(); };
document.addEventListener('keydown', escHandler);
// Re-wire ALL interactive elements inside the zoom clone.
// The clone is a dead DOM copy — no event listeners survived.
// Every clickable thing must be re-attached here.
// 1. Thumbnail clicks: open viewer OR flip orientation
clone.querySelectorAll('.thumb[data-thumb-kind]').forEach(tb => {
tb.onclick = (ev) => {
ev.stopPropagation();
const flipTo = tb.dataset.orientFlip;
if (flipTo) { setOrientation(tb.dataset.thumbMpn, flipTo); dismiss(); return; }
openThumb(tb.dataset.thumbMpn, tb.dataset.thumbKind);
};
tb.style.cursor = 'pointer';
});
// 2. Orient-more popover (▾ button + option tiles)
const orientBtn = clone.querySelector('.orient-more-btn');
const orientPop = clone.querySelector('.orient-popover');
if (orientBtn && orientPop) {
orientBtn.onclick = (ev) => {
ev.stopPropagation();
orientPop.style.display = orientPop.style.display === 'none' ? 'grid' : 'none';
};
clone.querySelectorAll('.orient-opt').forEach(opt => {
opt.onclick = (ev) => {
ev.stopPropagation();
setOrientation(opt.dataset.mpn, opt.dataset.axis);
dismiss();
};
});
}
// 3. Action buttons (⟲ validate, 🖼 thumbnails)
clone.querySelectorAll('.card-btn[data-action]').forEach(ab => {
ab.onclick = (ev) => {
ev.stopPropagation();
fetch(API_BASE + '/api/' + ab.dataset.action + '/' + encodeURIComponent(ab.dataset.mpn), { method: 'POST' });
};
});
// 4. Refetch dropdown
const refSel = clone.querySelector('.refetch-select');
if (refSel) {
refSel.onchange = (ev) => {
ev.stopPropagation();
const v = ev.target.value;
if (!v) return;
const [source, format] = v.split('|');
const mpn = ev.target.dataset.mpn;
ev.target.value = '';
fetch(API_BASE + '/api/refetch/' + encodeURIComponent(mpn) +
'?format=' + encodeURIComponent(format) + '&source=' + encodeURIComponent(source),
{ method: 'POST' });
};
refSel.onclick = (ev) => ev.stopPropagation();
}
// 5. Reveal folder button
clone.querySelectorAll('.reveal-btn[data-reveal-mpn]').forEach(rb => {
rb.onclick = (ev) => { ev.stopPropagation(); revealFolder(rb.dataset.revealMpn); };
});
// 6. PDF badge click (open datasheet)
clone.querySelectorAll('[data-thumb-mpn][data-thumb-kind="datasheet"]').forEach(pdf => {
pdf.onclick = (ev) => { ev.stopPropagation(); openThumb(pdf.dataset.thumbMpn, 'datasheet'); };
pdf.style.cursor = 'pointer';
});
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[m]);
}
// One thumbnail tile. Clickable when the source file is present —
// click dispatches to the right Adom app (3D → adom-step, PDF →
// adom-pdf-viewer, sym/fp → full-size SVG in a pup tab). Empty slot
// shows a dashed placeholder so the user can see what's coming.
// Returns the chip-thumbnailer failure reason for a thumbnail kind, or null.
// Keys in .thumb-errors.json are "sym" / "fp" / "3d".
function thumbErr(c, key) {
const e = c && c.thumb_errors;
return (e && typeof e === 'object' && e[key]) ? String(e[key]) : null;
}
// The six face-up orientations: thumbnail kind + label per axis token.
function axis3dInfo(axis) {
return ({
z: { kind: 'chip3d', label: 'Z↑' },
y: { kind: 'chip3d-yup', label: 'Y↑' },
xup: { kind: 'chip3d-xup', label: 'X↑' },
xdown: { kind: 'chip3d-xdown', label: 'X↓' },
ydown: { kind: 'chip3d-ydown', label: 'Y↓' },
zdown: { kind: 'chip3d-zdown', label: 'Z↓' },
})[axis] || { kind: 'chip3d-' + axis, label: axis };
}
// The 3D thumbnail group. Slot 1 is always Z↑; slot 2 shows the CHOSEN
// orientation whenever it isn't z/y — so picking X↑/X↓/Y↓/Z↓ from the dropdown
// SWAPS that render into the visible main view (marked ✓), instead of the choice
// hiding inside the closed dropdown. The dropdown lists the other four.
function render3dGroup(c) {
const chosen = c.chosen_up_axis;
const slot2 = (chosen && chosen !== 'z' && chosen !== 'y') ? chosen : 'y';
const mainAxes = ['z', slot2];
const dropAxes = ['y', 'xup', 'xdown', 'ydown', 'zdown'].filter(a => a !== slot2);
const present = ax => ax === 'z' ? c.has_thumb_chip3d : ax === 'y' ? c.has_thumb_chip3d_yup : true;
const main = mainAxes.map(ax => {
const ai = axis3dInfo(ax);
return renderThumb(c.mpn, ai.kind, present(ax), ai.label,
{ upAxis: ax, chosen: chosen, inferred: c.inferred_up_axis }, thumbErr(c, '3d'));
}).join('');
const drop = dropAxes.map(o => {
const ai = axis3dInfo(o);
const url = API_BASE + '/thumb/' + encodeURIComponent(c.mpn) + '/' + ai.kind + '?t=' + Date.now();
const isChosen = chosen === o;
return `<div class="orient-opt${isChosen ? ' chosen' : ''}" data-mpn="${escapeHtml(c.mpn)}" data-axis="${o}" data-tooltip="Mark the ${ai.label} 3D rendering as the correct up-axis — it swaps into the main view.">
<img src="${url}" onerror="this.parentElement.style.display='none'" /><span>${ai.label}</span></div>`;
}).join('');
return `${srcChipHtml(c)}
${main}
<button class="orient-more-btn" data-tooltip="Pick from all 6 face-up orientations. The one you pick swaps into the main view and is marked ✓. Saved to info.json for downstream tools." data-mpn="${escapeHtml(c.mpn)}">▾</button>
<div class="orient-popover" data-mpn="${escapeHtml(c.mpn)}" style="display:none">${drop}</div>`;
}
// Tracks which chips' SYM tiles are mid-regen (after an up-axis switch). Keyed
// by the symbol revision (mtime of <mpn>-symbol.svg) captured at click-time: the
// spinner clears itself the moment a newer sym_rev arrives on a poll — or after a
// 95s safety cap so a failed regen can't spin forever.
const SYM_REFRESHING = new Map(); // mpn -> { at, rev }
function chipByMpn(mpn) { return ((LIB && LIB.chips) || []).find(x => x.mpn === mpn); }
function markSymRefreshing(mpn) {
SYM_REFRESHING.set(mpn, { at: Date.now(), rev: (chipByMpn(mpn) || {}).sym_rev });
}
function isSymRefreshing(mpn) {
const e = SYM_REFRESHING.get(mpn);
if (!e) return false;
const c = chipByMpn(mpn);
if (Date.now() - e.at > 95000 || (c && c.sym_rev !== e.rev)) { SYM_REFRESHING.delete(mpn); return false; }
return true;
}
function renderThumb(mpn, kind, present, label, orient, errMsg, sources) {
if (!present) {
// Distinguish a FAILED render (chip-thumbnailer errored) from one that was
// never attempted — a blank tile next to a green KICAD/STEP chip otherwise
// reads as "no file", hiding a real generation failure.
if (errMsg) {
return `<div class="thumb failed" data-tooltip="${escapeHtml(label)} render failed: ${escapeHtml(errMsg)}\nClick the ⟲ / 🖼 card buttons to re-validate or re-render."><span class="thumb-failed">⚠ ${escapeHtml(label)}</span><span class="thumb-failed-sub">render failed</span></div>`;
}
return `<div class="thumb empty" data-tooltip="${escapeHtml(label)} thumbnail not yet generated.\nUse the 🖼 card button to render it from the current library files."><span class="thumb-placeholder">${escapeHtml(label)}</span></div>`;
}
// Show the ACTIVE source's thumbnail on the main tile: the canonical render
// for the current source, or the `.<method>` variant when the user picked one.
let methodSuffix = '';
if (sources && sources.length) {
const am = activeMethod(mpn, kind, sources);
const amS = sources.find(s => s.method === am);
if (amS && !amS.current && amS.thumb) methodSuffix = '.' + am;
}
const url = API_BASE + '/thumb/' + encodeURIComponent(mpn) + '/' + kind + methodSuffix;
const src = url + '?t=' + Date.now();
// Orientation-picker affordances on the two 3D tiles. When `orient` is
// passed, decorate active vs inactive: active gets a green border + corner
// check, inactive dims to 55% until hover with a "click to mark this
// orientation as correct" hint in the tooltip.
let orientAttrs = '';
let orientCheck = '';
let titleSuffix = '';
if (orient && kind.indexOf('chip3d') === 0) {
const isActive = orient.chosen === orient.upAxis;
if (isActive) {
orientAttrs = ' data-orient-active="true"';
orientCheck = `<span class="orient-check" aria-label="active orientation"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg></span>`;
const inferredMatches = orient.inferred === orient.upAxis;
titleSuffix = inferredMatches
? ` · active (auto-detected ${orient.inferred.toUpperCase()}-up)`
: ` · active (you picked, overriding inferred ${orient.inferred.toUpperCase()}-up)`;
} else {
orientAttrs = ` data-orient-inactive="true" data-orient-flip="${orient.upAxis}"`;
titleSuffix = ` · click to mark this ${orient.upAxis.toUpperCase()}-up rendering as correct`;
}
}
const openTip = {
symbol: `Open ${mpn} in the live adom-symbol app — interactive schematic symbol rendered via service-kicad (no local KiCad).`,
footprint: `Open ${mpn} in the live adom-footprint app — interactive PCB footprint rendered via service-kicad (no local KiCad).`,
chip3d: `Open ${mpn} in the adom-step 3D viewer — orbit the real STEP model.`,
'chip3d-yup': `Open the Y-up 3D render of ${mpn} full-size (Fusion 360 / SolidWorks convention).`,
datasheet: `Open the ${mpn} datasheet PDF in the viewer.`,
}[kind] || `Open ${mpn} ${label} in Adom.`;
// If a per-source variant thumbnail is missing/failed, fall back to the
// canonical render (no broken image). Only one fallback hop.
const canonUrl = API_BASE + '/thumb/' + encodeURIComponent(mpn) + '/' + kind;
const onerr = methodSuffix
? `onerror="if(!this.dataset.fb){this.dataset.fb=1;this.src='${canonUrl}?t='+Date.now()}else{this.style.visibility='hidden'}"`
: `onerror="this.style.visibility='hidden'"`;
// Symbol/footprint tiles are NON-interactive (no open-on-click, no hover
// tooltip) so they don't fight the source-carat menu sitting on them.
const noHover = (kind === 'symbol' || kind === 'footprint');
const tipAttr = noHover ? '' : ` data-tooltip="${escapeHtml(openTip + titleSuffix)}"`;
// SYM tile shows a spinner while its outline+symbol regenerate (up-axis switch).
const refreshing = (kind === 'symbol' && isSymRefreshing(mpn));
const spinner = refreshing ? '<span class="thumb-spinner" aria-label="re-rendering symbol"></span>' : '';
const thumbHtml = `<div class="thumb${noHover ? ' thumb-static' : ''}${refreshing ? ' refreshing' : ''}"${tipAttr} data-thumb-mpn="${escapeHtml(mpn)}" data-thumb-kind="${kind}"${orientAttrs}>
<img src="${src}" alt="${escapeHtml(mpn)} ${escapeHtml(label)}" loading="lazy" ${onerr}>
<span class="thumb-label">${escapeHtml(label)}</span>
${orientCheck}
${spinner}
</div>`;
// Symbol/footprint provenance carat: an always-visible, tappable source chip
// (works on touch) + a popover listing every method that produced a variant.
if ((kind === 'symbol' || kind === 'footprint') && sources && sources.length) {
return `<div class="thumb-src-group">${thumbHtml}${srcCarat(mpn, kind, sources)}</div>`;
}
return thumbHtml;
}
// Always-visible tappable source chip + popover of all source methods.
function srcCarat(mpn, kind, sources) {
if (!sources.length) return '';
const active = activeMethod(mpn, kind, sources);
const cur = sources.find(s => s.method === active) || sources.find(s => s.current) || sources[0];
const multi = sources.length > 1;
const opts = sources.map(s => {
const tsrc = s.thumb ? `${API_BASE}/thumb/${encodeURIComponent(mpn)}/${kind}${s.current ? '' : '.' + s.method}?t=${Date.now()}` : '';
const sel = s.method === active;
return `<div class="src-opt${sel ? ' chosen' : ''}" data-mpn="${escapeHtml(mpn)}" data-kind="${kind}" data-method="${escapeHtml(s.method)}" data-canonical="${s.current ? '1' : '0'}"
data-tooltip="${escapeHtml(s.label)} — ${sel ? 'the variant shown now.' : 'tap to make this the source shown on the tile + open it in adom-' + kind + '.'}">
${s.thumb ? `<img class="src-opt-thumb" src="${tsrc}" loading="lazy" onerror="this.outerHTML='<span class=\\'src-dot ${s.method === 'ds2sf' ? 'ds' : 'vendor'}\\'></span>'">` : `<span class="src-dot ${s.method === 'ds2sf' ? 'ds' : 'vendor'}"></span>`}
<span class="src-opt-lab">${escapeHtml(s.label)}</span>${sel ? '<span class="src-cur">●</span>' : ''}
</div>`;
}).join('');
return `<button class="src-chip" data-mpn="${escapeHtml(mpn)}" data-kind="${kind}"
data-tooltip="Source of this ${kind}: ${escapeHtml(cur.label)}.${multi ? ' ' + sources.length + ' methods — tap to switch.' : ' Tap for details.'}">
<span class="src-dot ${cur.method === 'ds2sf' ? 'ds' : 'vendor'}"></span><span class="src-chip-lab">${escapeHtml(cur.label)}</span>${multi ? '<span class="src-chip-car">▾</span>' : ''}
</button>
<div class="src-popover" data-mpn="${escapeHtml(mpn)}" style="display:none">${opts}</div>`;
}
async function setOrientation(mpn, axis) {
try {
// Show the spinner INSTANTLY (before the round-trip) — the server regenerates
// the outline+symbol in the background, so the click returns right away and
// the user keeps working while the SYM tile spins.
markSymRefreshing(mpn);
render();
const r = await fetch(API_BASE + '/api/orientation', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({mpn, axis}),
});
const j = await r.json().catch(() => ({}));
if (!r.ok || !j.ok) {
SYM_REFRESHING.delete(mpn);
alert('Could not set orientation: ' + (j.error || ('HTTP ' + r.status)));
render();
return;
}
const libR = await fetch(API_BASE + '/api/library');
LIB = await libR.json();
LAST_LIB_HASH = '';
render();
// Poll just until the re-rendered symbol lands (sym_rev bumps) — then the
// spinner self-clears. Capped so a failed regen can't poll forever.
const poll = setInterval(async () => {
if (!isSymRefreshing(mpn)) { clearInterval(poll); render(); return; }
try {
const lr = await fetch(API_BASE + '/api/library');
LIB = await lr.json();
LAST_LIB_HASH = '';
render();
} catch (e) { /* transient; keep polling */ }
}, 2500);
} catch (e) {
SYM_REFRESHING.delete(mpn);
render();
alert('Could not reach /api/orientation: ' + e.message);
}
}
// Bring the active card into view. Called whenever the heartbeat MPN
// changes — cards take more vertical real estate now that thumbnails
// land below the meta row, so they scroll off screen easily during a
// long batch. Only scroll if the card is genuinely off-screen so we
// don't fight the user's manual scroll while they're inspecting.
function scrollActiveCardIntoView() {
const card = document.querySelector('.card.active') || (ACTIVE_MPN && document.querySelector(`.card[data-mpn="${CSS.escape(ACTIVE_MPN)}"]`));
if (!card) return;
const rect = card.getBoundingClientRect();
const fullyVisible = rect.top >= 0 && rect.bottom <= window.innerHeight;
if (fullyVisible) return;
card.scrollIntoView({behavior: 'smooth', block: 'center'});
}
// Provenance carat: toggle the popover, or open a source variant.
document.addEventListener('click', (ev) => {
const chip = ev.target.closest('.src-chip');
if (chip) {
ev.stopPropagation(); ev.preventDefault();
const pop = chip.parentElement.querySelector('.src-popover');
document.querySelectorAll('.src-popover').forEach(p => { if (p !== pop) p.style.display = 'none'; });
if (pop) pop.style.display = pop.style.display === 'none' ? 'flex' : 'none';
return;
}
const opt = ev.target.closest('.src-opt');
if (opt) {
ev.stopPropagation();
const mpn = opt.dataset.mpn, kind = opt.dataset.kind, method = opt.dataset.method;
const canonical = opt.dataset.canonical === '1';
// Persist the pick so the carat + tile show this source (sticks across reloads).
SRC_SEL[mpn + '|' + kind] = method; saveSrcSel();
// Update the DOM in place: chip label, chosen highlight, main tile thumbnail.
const group = opt.closest('.thumb-src-group');
if (group) {
const label = opt.querySelector('.src-opt-lab').textContent;
const dotCls = method === 'ds2sf' ? 'ds' : 'vendor';
const chipLab = group.querySelector('.src-chip-lab'); if (chipLab) chipLab.textContent = label;
const chipDot = group.querySelector('.src-chip .src-dot'); if (chipDot) chipDot.className = 'src-dot ' + dotCls;
group.querySelectorAll('.src-opt').forEach(o => {
const on = o === opt;
o.classList.toggle('chosen', on);
let bullet = o.querySelector('.src-cur');
if (on && !bullet) { bullet = document.createElement('span'); bullet.className = 'src-cur'; bullet.textContent = '●'; o.appendChild(bullet); }
else if (!on && bullet) bullet.remove();
});
const img = group.querySelector('.thumb img');
if (img) img.src = API_BASE + '/thumb/' + encodeURIComponent(mpn) + '/' + kind + (canonical ? '' : '.' + method) + '?t=' + Date.now();
}
const pop = opt.closest('.src-popover'); if (pop) pop.style.display = 'none';
// Switching the source ONLY swaps the tile thumbnail — it must NOT open the
// app (clicking the menu kept launching adom-symbol). Use the explicit
// "Open in" buttons to launch an app.
return;
}
document.querySelectorAll('.src-popover').forEach(p => { if (!p.contains(ev.target)) p.style.display = 'none'; });
// Click anywhere outside an open orientation dropdown closes it.
document.querySelectorAll('.orient-popover').forEach(p => {
if (!p.contains(ev.target) && !(ev.target.closest && ev.target.closest('.orient-more-btn'))) p.style.display = 'none';
});
});
// Lightweight toast (bottom-center). Optional href makes it a tappable link.
let CF_TOAST_T = null;
function cfToast(msg, href) {
let t = document.getElementById('cf-toast');
if (!t) { t = document.createElement('div'); t.id = 'cf-toast'; document.body.appendChild(t); }
t.innerHTML = href ? `${msg} <a href="${href}" target="_blank" rel="noopener">open ↗</a>` : msg;
t.classList.add('show'); clearTimeout(CF_TOAST_T);
CF_TOAST_T = setTimeout(() => t.classList.remove('show'), href ? 8000 : 4200);
}
// The viewing context, for choosing + NAMING the open method.
function viewContext() {
const embedded = window.self !== window.top; // a Hydrogen webview is an iframe
const phone = matchMedia('(hover: none)').matches || innerWidth < 760;
return { embedded, phone };
}
// Open a chip in an Adom app. Opens in the user's CURRENT browser via
// window.open (works on a phone, in pup, or a Hydrogen webview) — the only
// universally-correct surface — and tells the user exactly how it opened.
async function openThumb(mpn, kind, source) {
const APP = { symbol: 'adom-symbol', footprint: 'adom-footprint', lbr: 'adom-lbr',
chip3d: 'adom-3D', 'chip3d-yup': 'adom-3D', datasheet: 'the datasheet', chipsmith: 'chipsmith', ds2sf: 'adom-symbol (ds2sf)' };
const app = APP[kind] || kind;
// Pre-open a blank tab INSIDE the click gesture so the browser doesn't block
// it; navigate it once the server has the chip loaded. (chipsmith has no URL —
// it opens its own Hydrogen webview server-side — so don't pre-open for it.)
const tab = kind === 'chipsmith' ? null : window.open('', '_blank');
try {
const r = await fetch(API_BASE + '/api/open', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mpn, kind, source }),
});
const j = await r.json().catch(() => ({}));
if (!r.ok || !j.ok) { if (tab) tab.close(); cfToast(`⚠ Couldn't open ${app}: ${j.error || ('HTTP ' + r.status)}`); return; }
if (kind === 'chipsmith') {
// chipsmith opens its own Hydrogen webview (on the machine running Adom
// Desktop / Hydrogen) — not a browser tab, so it won't appear on a phone.
cfToast(`Opened ${app} as a Hydrogen webview${j.desktopBridge ? ' on your Adom Desktop' : ' (needs Hydrogen — not visible on a phone)'}.`);
return;
}
const { embedded, phone } = viewContext();
const where = phone ? 'a new browser tab on your phone' : embedded ? 'a new tab from the embedded view' : 'a new browser tab';
if (tab && j.url) { tab.location.href = j.url; cfToast(`Opened ${app} in ${where} (window.open).`); }
else if (j.url) { cfToast(`${app} is ready — pop-up was blocked, tap to open:`, j.url); }
} catch (e) { if (tab) tab.close(); cfToast(`⚠ Couldn't reach the server: ${e.message}`); }
}
// Build per-file provenance lines from file_provenance JSON.
// Returns e.g. ".step from snapmagic\n.kicad_mod from snapmagic"
function fileProvenanceLines(fileProv, mpn) {
if (!fileProv || typeof fileProv !== 'object') return '';
var lines = [];
for (var filename in fileProv) {
var entry = fileProv[filename];
if (!entry) continue;
var ext = filename.replace(mpn + '.', '.').replace(mpn, '');
var src = entry.discovery_source || entry.content_origin || '?';
var origin = entry.content_origin || '';
if (origin && origin !== src) src += ' (made by ' + origin + ')';
lines.push(ext + ' from ' + src);
}
return lines.length ? '\nProvenance:\n' + lines.join('\n') : '';
}
// Summarize the sourcing chain from info.json into a one-line provenance string.
// Fallback when per-file provenance isn't available.
function provenanceSummary(info, fileProv, mpn) {
// Prefer per-file provenance when available
var perFile = fileProvenanceLines(fileProv, mpn);
if (perFile) return perFile;
// Fall back to chain summary
if (!info || !info.fetched_via_chain || !info.fetched_via_chain.length) return '';
var sources = info.fetched_via_chain.map(function(e) {
var s = e.source || '?';
var o = e.outcome || '';
if (o === 'attempted' || o === 'failed') s += ' (tried)';
if (o === 'success') s += ' (got it)';
return s;
});
return '\nSourced via: ' + sources.join(' > ');
}
// Resolve the BEST available STEP source from every signal we have, instead of
// showing a bare "—" when the data is sitting right there. Priority:
// 1) per-file provenance for the .step (who MADE it + where we FOUND it)
// 2) the step_source field on info.json
// 3) the fetched_via_chain entry that succeeded
// Returns { label, found } — found=false means it genuinely was never recorded.
// Always-visible "where did this 3D model come from" chip (KiCad lib vs Mfr
// direct vs SnapMagic, etc.) — color is the CAD author's, NOT the source, so
// we label the source explicitly. Reads the same provenance resolveStepSource does.
function srcChipHtml(c) {
if (!c.has_step) return '';
var r = resolveStepSource(c);
var s = (r.label || '').toLowerCase(), cls = 'other', short = r.label;
if (/manufacturer|mfr|direct/.test(s)) { cls = 'mfr'; short = 'Mfr direct'; }
else if (/kicad/.test(s)) { cls = 'kicad'; short = 'KiCad lib'; }
else if (/ultralib/.test(s)) { cls = 'ul'; short = 'Ultra Librarian'; }
else if (/snap/.test(s)) { cls = 'snap'; short = 'SnapMagic'; }
else if (/mouser|cse|samacsys/.test(s)) { cls = 'mouser'; short = 'Mouser CSE'; }
else if (/digikey/.test(s)) { cls = 'dk'; short = 'DigiKey'; }
else if (/lcsc/.test(s)) { cls = 'lcsc'; short = 'LCSC'; }
else if (!r.found || /not recorded|unknown|\?/.test(s)) { cls = 'unknown'; short = 'source ?'; }
var tip = '3D model source: ' + r.label + '\n\nThe blue/grey model color is the CAD author choice, not its source. This chip is the real source.';
return '<div class="src3d-chip src3d-' + cls + '" data-tooltip="' + escapeHtml(tip) + '">' +
'<span class="src3d-dot"></span>' + escapeHtml(short) + '</div>';
}
// Resolve the BEST recorded source for the file(s) matching `extRe`, the SAME
// way for every capsule: per-file provenance (who made it / where found) →
// optional `fallbackField` on the chip → the sourcing-chain entry that
// succeeded. Returns { label, found }; found=false = never recorded.
function fileSource(c, extRe, fallbackField, opts) {
opts = opts || {};
var fp = c.file_provenance, info = c.info || {};
function siteOf(url) { try { return (new URL(url)).hostname.replace(/^www\./, ''); } catch (e) { return ''; } }
var MFR = function (s) { return s === 'manufacturer' || s === 'mfr' || s === 'vendor'; };
var KIC = function (s) { return /^kicad(-library|_community)?$/.test(s); };
// Aggregators / CAD libraries — NEVER "the manufacturer". A datasheet found on
// one of these did NOT come from the maker's own site.
var AGG = function (s) { return KIC(s) || ['cse', 'componentsearch', 'cse_ul', 'samacsys',
'snapeda', 'snapmagic', 'ultralibrarian', 'ul', 'mouser', 'digikey', 'arrow', 'lcsc'].indexOf(s) > -1; };
function labelFor(e) {
var made = (e.content_origin || '').toLowerCase(), found = (e.discovery_source || '').toLowerCase();
var site = siteOf(e.url || '');
if (made === 'unknown') made = '';
if (found === 'unknown') found = '';
// A datasheet counts as "from the manufacturer" only when it was DISCOVERED on
// the maker's own site — never via a CAD library / aggregator (CSE may host
// the maker's PDF, but that's not downloading it from the manufacturer).
if (opts.requireManufacturer && (AGG(found) || AGG(made))) return null;
// The Adom Wiki — a reused, Adom-vetted published bundle.
if (made === 'adom-wiki' || found === 'adom-wiki') return 'Adom Wiki' + (site ? ' · ' + site : '');
// NOTE: we intentionally do NOT surface the internal `derived` flag here.
// "derived" just means this provenance record was reconstructed after a bad
// wholesale tag overwrote it — it does NOT mean the source is a guess. The
// attribution itself (e.g. the maker's own site) is genuine, so showing
// "(derived)" only made users doubt a real manufacturer source.
if (KIC(made) || KIC(found)) return 'KiCad standard library';
// Manufacturer-direct: NAME the site from the URL — never a vague "direct site".
if (MFR(made) || MFR(found)) return site ? 'manufacturer · ' + site : 'manufacturer (site unrecorded)';
if (site && (made || found)) return (made || found) + ' · ' + site;
if (made || found) {
var lbl = made || found;
if (made && found && found !== made) lbl = made + ' (via ' + found + ')';
return lbl;
}
return site || '';
}
if (fp && typeof fp === 'object') {
for (var fn in fp) {
if (!extRe.test(fn)) continue;
var l = labelFor(fp[fn] || {});
if (l) return { label: l, found: true }; // null = mis-tagged source, keep looking
}
}
if (fallbackField && !opts.requireManufacturer) {
var v = (c[fallbackField] || '').trim();
if (v && v !== '—' && v.toLowerCase() !== 'none') return { label: v, found: true };
}
if (!opts.requireManufacturer) {
var chain = info.fetched_via_chain || [];
for (var i = 0; i < chain.length; i++) {
var o = (chain[i].outcome || '').toLowerCase();
if (o.indexOf('ok') > -1 || o.indexOf('success') > -1 || o === 'got it') return { label: chain[i].source || '?', found: true };
}
}
if (opts.requireManufacturer) return { label: 'NOT from the manufacturer yet — refetch the datasheet from the maker’s site', found: false };
return { label: 'not recorded — refetch to capture it', found: false };
}
function resolveStepSource(c) { return fileSource(c, /\.step$/i, 'step_source'); }
// One badge per EDA tool: green if both halves present, amber if partial,
// grey "?" if missing. Fusion uses one .lbr containing both sym+mod, so
// callers pass the same flag twice for that tool.
function badgeForEda(label, hasSym, hasMod, info, fileProv, mpn) {
const fullName = {KICAD:'KiCad',ALTIUM:'Altium',FUSION:'Fusion 360'}[label]||label;
const extRe = {KICAD:/\.kicad_(sym|mod)$/i, ALTIUM:/\.(SchLib|PcbLib|IntLib)$/i, FUSION:/\.lbr$/i}[label] || /$.^/;
// Lead with WHERE these library files came from (same resolver as STEP/PDF).
const src = (hasSym || hasMod) ? 'Source: ' + fileSource({file_provenance: fileProv, info: info}, extRe).label + '\n\n' : '';
if (hasSym && hasMod) {
return `<span class="b ${label.toLowerCase()}" data-tooltip="${src}${fullName} schematic symbol + PCB footprint both present — a complete ${fullName} library, ready for import into your EDA tool.">${label}</span>`;
}
if (hasSym || hasMod) {
const has = hasSym ? 'schematic symbol' : 'PCB footprint';
const miss = hasSym ? 'PCB footprint' : 'schematic symbol';
return `<span class="b partial-eda" data-tooltip="${src}${fullName}: has ${has} but missing ${miss}.\nPartial library — use the refetch button to get the missing piece.">${label} ${hasSym?'sym':'fp'}</span>`;
}
return `<span class="b miss" data-tooltip="${fullName} schematic symbol + PCB footprint both missing.\nNo ${fullName} library files fetched yet.">${label} ?</span>`;
}
async function select(mpn) {
SELECTED = mpn;
updateSubject();
document.querySelectorAll('.card').forEach(c => c.classList.toggle('selected', c.dataset.mpn === mpn));
const r = await fetch(API_BASE + '/api/chip/' + encodeURIComponent(mpn));
if (!r.ok) {
document.getElementById('detail').innerHTML = '<div class="empty-state">failed to load</div>';
return;
}
const c = await r.json();
const aside = document.getElementById('detail');
aside.classList.remove('empty');
// Render every canonical info.json field. "?" placeholder when unknown so
// the user sees explicitly what we don't have, not a missing row.
const CANONICAL = [
['manufacturer', 'Manufacturer'],
['package', 'Package'],
['family', 'Family'],
['description', 'Description'],
['step_source', 'STEP source'],
['step_authoring', 'STEP authoring'],
['step_url', 'STEP URL'],
['step_note', 'STEP note'],
['datasheet_source','Datasheet source'],
['datasheet_url', 'Datasheet URL'],
['datasheet_note', 'Datasheet note'],
['sym_source', 'Symbol source'],
['sym_url', 'Symbol URL'],
['mod_source', 'Footprint source'],
['mod_url', 'Footprint URL'],
['ul_deeplink_source', 'UL deep-link source'],
['ul_deeplink_url', 'UL deep-link URL'],
['ul_deeplink_note', 'UL deep-link note'],
['ul_zip', 'UL zip'],
['fetched_at', 'Fetched at'],
['verified_by', 'Verified by'],
];
const info = c.info || {};
const fieldRows = CANONICAL.map(([k, label]) => {
let v = info[k];
if (v === undefined || v === null || v === '') v = '?';
const isQ = (v === '?');
const isUrl = typeof v === 'string' && /^https?:\/\//.test(v);
const valHtml = isUrl
? `<a href="${escapeHtml(v)}" target="_blank" rel="noopener">${escapeHtml(v)}</a>`
: escapeHtml(String(v));
return `<div class="field ${isQ?'unknown':''}">
<span class="field-key">${escapeHtml(label)}</span>
<span class="field-val">${valHtml}</span>
</div>`;
}).join('');
const tab = DETAIL_TAB || 'actions';
aside.innerHTML = `
<h2>${escapeHtml(c.mpn)}</h2>
<div class="vendor">${escapeHtml(c.vendor || '?')} · ${escapeHtml(c.package || '?')}${c.family && c.family !== '?' ? ' · ' + escapeHtml(c.family) : ''}</div>
<div class="dtabs" role="tablist">
<button class="dtab${tab==='actions'?' on':''}" data-tab="actions">Actions</button>
<button class="dtab${tab==='details'?' on':''}" data-tab="details">Details</button>
<button class="dtab${tab==='files'?' on':''}" data-tab="files">Files <span class="dtab-n">${c.files.length}</span></button>
</div>
<div class="dpanel${tab==='actions'?' on':''}" data-panel="actions">
<div class="section">
<div class="section-title">Open in</div>
<div class="openin-row" data-mpn="${escapeHtml(c.mpn)}">
${c.has_sym ? `<button class="openbtn" data-kind="symbol" data-tooltip="Open ${escapeHtml(c.mpn)}'s schematic symbol in the live adom-symbol app.">adom-symbol</button>` : ''}
${c.has_mod ? `<button class="openbtn" data-kind="footprint" data-tooltip="Open ${escapeHtml(c.mpn)}'s PCB footprint in the live adom-footprint app (layers + InstaPCB solder-paste dots).">adom-footprint</button>` : ''}
${(c.has_sym || c.has_mod) ? `<button class="openbtn" data-kind="lbr" data-tooltip="Build + open a true EAGLE .lbr (symbol + footprint) in adom-lbr.">adom-lbr</button>` : ''}
${c.has_step ? `<button class="openbtn" data-kind="chip3d" data-tooltip="Orbit the real 3D STEP model in the adom-step viewer.">adom-3D</button>` : ''}
${(c.has_step && c.has_mod) ? `<button class="openbtn" data-kind="chipsmith" data-tooltip="Align pin-1 in chipsmith.\nOpens the 3D STEP + footprint together so you can line up the chip's pin-1 with the footprint's pin-1, then sign off. chip-fetcher reads the result back: the Pin-1 ${c.pin1_signed ? '✓' : '?'} badge + a baked ${escapeHtml(c.mpn)}.chipsmith.step.">${c.pin1_signed ? 'Pin-1 ✓' : 'Align pin-1'}</button>` : (c.has_step ? `<button class="openbtn" data-kind="chipsmith" data-tooltip="Open in chipsmith (STEP-native detection + sign-off). A footprint (.kicad_mod) is needed to align pin-1.">chipsmith</button>` : '')}
${(c.symbol_sources||[]).some(s=>s.method==='ds2sf') ? `<button class="openbtn" data-kind="ds2sf" data-tooltip="View ds2sf's datasheet-extracted symbol (opens adom-symbol on the ds2sf source).">ds2sf extraction</button>` : ''}
</div>
</div>
<div class="section">
<div class="section-title">Send to</div>
<div class="sendto-row" data-mpn="${escapeHtml(c.mpn)}">
<button class="sendbtn" data-target="kicad" data-tooltip="Install the native KiCad symbol + footprint into your KiCad libraries via Adom Desktop.">KiCad</button>
<button class="sendbtn" data-target="fusion360" data-tooltip="Build a true EAGLE .lbr and open it in Fusion 360 Electronics.">Fusion</button>
<button class="sendbtn" data-target="altium" data-tooltip="Build the EAGLE .lbr + import steps (native Altium exporter on the roadmap).">Altium</button>
<button class="sendbtn" data-target="orcad" data-tooltip="Build the EAGLE .lbr (native OrCAD exporter on the roadmap).">OrCAD</button>
</div>
</div>
<div class="section">
<div class="section-title">Status</div>
<div class="badges">
<span class="b ${c.fully_bundled?'full':'stub'}">${c.fully_bundled?'FULL':'STUB'}</span>
<span class="b ${c.has_step?'step':'miss'}">STEP${c.has_step?'':' ?'}</span>
${badgeForEda('KICAD', c.has_sym, c.has_mod, c.info, c.file_provenance, c.mpn)}
${badgeForEda('ALTIUM', c.has_altium, c.has_altium, c.info, c.file_provenance, c.mpn)}
${badgeForEda('FUSION', c.has_fusion_lbr, c.has_fusion_lbr, c.info, c.file_provenance, c.mpn)}
<span class="b ${c.pdf_count>0?'pdf':'miss'}">${c.pdf_count>0 ? c.pdf_count : '?'} PDF${c.pdf_count===1?'':'s'}</span>
<span class="b src ${srcTier(c.step_source)}">source: ${escapeHtml(srcShort(c.step_source))}</span>
</div>
</div>
</div>
<div class="dpanel${tab==='details'?' on':''}" data-panel="details">
<div class="fields">${fieldRows}</div>
</div>
<div class="dpanel${tab==='files'?' on':''}" data-panel="files">
<ul class="filelist">
${c.files.map(f => {
const url = API_BASE + '/asset/' + encodeURIComponent(c.mpn) + '/' + encodeURIComponent(f);
return `<li><a href="${url}" target="_blank">${escapeHtml(f)}</a></li>`;
}).join('')}
</ul>
</div>
`;
aside.querySelectorAll('.dtab').forEach(t => t.onclick = () => {
DETAIL_TAB = t.dataset.tab;
aside.querySelectorAll('.dtab').forEach(x => x.classList.toggle('on', x === t));
aside.querySelectorAll('.dpanel').forEach(p => p.classList.toggle('on', p.dataset.panel === DETAIL_TAB));
});
}
document.getElementById('q').addEventListener('input', render);
document.getElementById('vendor').addEventListener('change', render);
document.getElementById('status').addEventListener('change', render);
// ---------------- open a chip in a specific Adom tool ----------------
// Delegated on document so it works in the detail panel AND the zoom overlay.
document.addEventListener('click', (ev) => {
const b = ev.target.closest('.openbtn');
if (!b) return;
const mpn = b.closest('.openin-row').dataset.mpn, kind = b.dataset.kind;
const prev = b.textContent; b.disabled = true; b.textContent = 'Opening…';
setTimeout(() => { b.disabled = false; b.textContent = prev; }, 2500);
// ds2sf has no viewer of its own — open its extracted symbol on the ds2sf source.
if (kind === 'ds2sf') openThumb(mpn, 'symbol', 'ds2sf');
else openThumb(mpn, kind);
});
// ---------------- send to desktop EDA tools ----------------
// Delegated on document so it works in the detail panel AND the zoom overlay.
document.addEventListener('click', async (ev) => {
const b = ev.target.closest('.sendbtn');
if (!b) return;
const row = b.closest('.sendto-row');
const mpn = row.dataset.mpn, target = b.dataset.target, label = b.textContent;
const all = [...row.querySelectorAll('.sendbtn')];
all.forEach(x => x.disabled = true);
const prev = b.textContent; b.textContent = 'Sending…';
try {
const r = await fetch(API_BASE + '/api/deliver', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({mpn, target}),
});
const j = await r.json().catch(() => ({}));
if (j.needsBridge) showBridgeModal(j);
else if (j.ok) b.textContent = '✓ ' + label;
else alert((j.message || j.error || 'Could not send to ' + label));
} catch (e) { alert('Send failed: ' + e.message); }
finally { setTimeout(() => { all.forEach(x => x.disabled = false); b.textContent = prev; }, 2500); }
});
function showBridgeModal(j) {
let m = document.getElementById('bridgeModal');
if (!m) { m = document.createElement('div'); m.id = 'bridgeModal'; document.body.appendChild(m); }
const dl = j.assetUrl
? `<a class="bm-go" href="${API_BASE + j.assetUrl}" download>⬇ Download the .lbr</a>` : '';
const hint = j.importHint ? `<div class="bm-hint">${j.importHint}</div>` : '';
const icon = j.exportWorks ? '📦' : '🌉';
m.innerHTML = '<div class="bm-card"><div class="bm-icon">' + icon + '</div>'
+ '<div class="bm-title">' + (j.exportWorks ? (j.target + ' library export') : ('Build the ' + (j.target || '') + ' bridge')) + '</div>'
+ '<div class="bm-body">' + (j.message || '') + '</div>'
+ hint
+ '<div class="bm-actions">' + dl
+ '<a class="bm-contribute" href="' + (j.contribute || 'https://wiki.adom.inc') + '" target="_blank" rel="noopener">Build the auto-import bridge — contribute on the wiki →</a>'
+ '<button class="bm-dismiss">Close</button></div></div>';
m.classList.add('show');
m.querySelector('.bm-dismiss').onclick = () => m.classList.remove('show');
m.onclick = e => { if (e.target === m) m.classList.remove('show'); };
}
// ---------------- reveal folder in VS Code Explorer ----------------
async function revealFolder(mpn) {
try {
const r = await fetch(API_BASE + '/api/reveal', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({mpn: mpn || ''}),
});
const j = await r.json().catch(() => ({}));
if (!r.ok || !j.ok) {
console.warn('reveal failed', j);
alert('Could not reveal folder: ' + (j.error || j.stderr || ('HTTP ' + r.status)));
}
} catch (e) {
console.warn('reveal request failed', e);
alert('Could not reach /api/reveal: ' + e.message);
}
}
document.getElementById('btn-reveal-lib').addEventListener('click', () => revealFolder(''));
// ---------------- ARCHITECTURE map — first-class in-app view ----------------
// Opens the tool/dependency map (served at /architecture) in a full-screen
// overlay so anyone editing chip-fetcher can see the whole structure — local
// CLIs, native libs, remote services, and the sub-apps they may need to touch.
document.getElementById('btn-arch').addEventListener('click', () => {
let ov = document.getElementById('arch-overlay');
if (!ov) {
ov = document.createElement('div');
ov.id = 'arch-overlay';
ov.style.cssText = 'position:fixed;inset:0;z-index:9999;background:rgba(3,6,10,.86);backdrop-filter:blur(3px);display:flex;flex-direction:column';
ov.innerHTML =
'<div style="display:flex;align-items:center;gap:12px;padding:10px 16px;border-bottom:1px solid #222a33;background:#0d1117">'
+ '<span style="font-weight:700;color:var(--accent,#00e6dc)">🗺 chip-fetcher — Architecture / dependency map</span>'
+ '<span style="color:var(--muted,#8b949e);font-size:12px">every tool this app depends on, and what runs where</span>'
+ '<a href="/architecture" target="_blank" style="margin-left:auto;color:var(--muted,#8b949e);font-size:12px;text-decoration:none">open in new tab ↗</a>'
+ '<button id="arch-close" style="background:none;border:1px solid #2a323c;color:#e6edf3;border-radius:8px;padding:4px 12px;cursor:pointer;font-size:16px;line-height:1">✕</button>'
+ '</div>'
+ '<iframe src="/architecture" style="flex:1;width:100%;border:0;background:#0a0e14"></iframe>';
document.body.appendChild(ov);
ov.addEventListener('click', (e) => { if (e.target === ov) ov.style.display = 'none'; });
ov.querySelector('#arch-close').addEventListener('click', () => { ov.style.display = 'none'; });
}
ov.style.display = 'flex';
});
// ---------------- VALIDATION tab — brutal self-audit ----------------
const REQ = [
['datasheet_pdf','Datasheet'], ['step_3d','STEP 3D'],
['kicad_symbol','Symbol'], ['kicad_footprint','Footprint'],
['thumb_symbol','Sym thumb'], ['thumb_footprint','FP thumb'], ['thumb_3d','3D thumb'],
];
let validationOn = false, validationTimer = null;
function setValidationView(on) {
validationOn = on;
document.getElementById('grid-view').style.display = on ? 'none' : '';
document.getElementById('validation-view').style.display = on ? 'block' : 'none';
document.getElementById('btn-validate').style.background = on ? 'var(--accent)' : '';
document.getElementById('btn-validate').style.color = on ? '#fff' : '';
if (on) { renderValidation(); validationTimer = setInterval(renderValidation, 3000); }
else if (validationTimer) { clearInterval(validationTimer); validationTimer = null; }
}
async function renderValidation() {
const host = document.getElementById('validation-view');
let data;
try { data = await (await fetch(API_BASE + '/api/validate?v=' + Date.now())).json(); }
catch (e) { host.innerHTML = '<div class="empty-state">validation API error</div>'; return; }
const s = data.summary || {};
const allGreen = s.total > 0 && s.incomplete === 0;
const cell = (ok) => `<td style="text-align:center;font-size:16px;font-weight:700;color:${ok ? 'var(--green,#34d17e)' : '#ff5252'}">${ok ? '✓' : '✗'}</td>`;
// adom-concur cross-source consensus cell: did KiCad / EAGLE / Altium / ds2sf agree?
const consensusCell = (con) => {
if (!con) return `<td style="text-align:center;font-size:11px;color:var(--muted)">— not run</td>`;
const golden = /golden/i.test(con.status || '');
const col = golden ? 'var(--green,#34d17e)' : '#ff5252';
const label = golden ? (con.status === 'golden' ? 'GOLDEN' : 'GOLDEN*') : (con.status || '?').toUpperCase();
const srcs = (con.sources || []).join(' · ') || '—';
return `<td style="text-align:center;white-space:nowrap" data-tooltip="adom-concur cross-source consensus.\nCompared: ${srcs}\nMissing: ${(con.missing||[]).join(', ')||'none'}\n${con.agree} axes agree, ${con.disagree} disagree of ${con.total_axes}. GOLDEN* = agree except a naming-convention variant.">
<span style="font-weight:700;color:${col};font-size:13px">${label}</span>
<span style="font-size:10px;color:var(--muted);display:block">${con.agree}✓/${con.disagree}✗ · ${(con.sources||[]).length} src</span></td>`;
};
// Per-source child row: which sym / fp / 3d we generated from THIS library.
// 3D with an asterisk = the library shipped no STEP, so we used the global
// chip 3D (hover explains). Normal check = the library had its own 3D.
const miniChk = (ok, why) => `<span${(!ok && why) ? ` data-tooltip="${escapeHtml(why)}" style="cursor:help;` : ` style="`}color:${ok?'var(--green,#34d17e)':'#ff5252'};font-weight:700;font-size:13px">${ok?'✓':'✗'}</span>`;
const chk3d = (s) => {
if (!s.three_d) return miniChk(false);
if (s.three_d_from_lib) return miniChk(true);
// No 3D in this library → we swapped in the global chip STEP. Name where it came from.
const where = s.step_source || 'another source';
const tip = `there was no 3D chip in this library — so we swapped in the STEP we downloaded from ${where}${s.step_url ? ' (' + s.step_url + ')' : ''}`;
return `<span data-tooltip="${escapeHtml(tip)}" style="color:var(--green,#34d17e);font-weight:700;font-size:13px;cursor:help">✓<span style="color:var(--accent)">*</span></span>`;
};
// Download links. ORIGINAL (the raw file/archive downloaded off the internet)
// is styled as a solid accent pill ⭳; EXTRACTED files are outline pills ⬇.
const dlLinks = (c, s) => (s.files || []).map(f => {
const url = API_BASE + '/asset/' + encodeURIComponent(c.mpn) + '/' + encodeURIComponent(f.file);
const tip = f.original
? 'ORIGINAL file exactly as downloaded from the internet — inspect it yourself: ' + f.file
: 'a specific file we extracted from the download: ' + f.file;
const style = f.original
? 'color:#06251f;background:var(--accent);font-weight:700;border:1px solid var(--accent);'
: 'color:var(--accent);border:1px solid rgba(0,184,177,.3);';
return `<a href="${url}" download="${escapeHtml(f.file)}" data-tooltip="${escapeHtml(tip)}" style="${style}font-size:10.5px;text-decoration:none;border-radius:4px;padding:1px 7px;margin-left:6px;white-space:nowrap">${f.original?'⭳':'⬇'} ${escapeHtml(f.name)}</a>`;
}).join('');
const childRows = (c) => (c.sources || []).map(s => `
<tr style="background:rgba(0,184,176,.035)">
<td colspan="11" style="padding:4px 0 4px 30px;border-top:1px solid rgba(255,255,255,.04)">
<span style="color:var(--accent);font-size:11px">↳</span>
<span style="color:var(--text2);font-size:11.5px;font-weight:600;margin:0 20px 0 6px;display:inline-block;min-width:215px">${s.label}</span>
<span style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px">sym</span> ${miniChk(s.sym, s.note)}
<span style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin-left:18px">fp</span> ${miniChk(s.fp, s.note)}
<span style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin-left:18px">3d chip</span> ${chk3d(s)}
<span style="margin-left:22px;display:inline-flex;align-items:center">${dlLinks(c, s)}</span>
</td>
</tr>`).join('');
const rows = (data.chips || []).map(c => {
const reqCells = REQ.map(([k]) => cell(!!(c.required && c.required[k]))).join('');
const nm = c.complete ? 'var(--green,#34d17e)' : '#ff5252';
return `<tr>
<td style="font-family:'JetBrains Mono',monospace;font-size:12px;white-space:nowrap;padding-right:14px;color:var(--text)">${c.mpn}</td>
${reqCells}
<td style="text-align:center">${cell(!!(c.extra && c.extra.fusion_lbr)).replace('<td','<span').replace('</td>','</span>')}</td>
${consensusCell(c.consensus)}
<td style="text-align:center;font-weight:700;color:${nm};white-space:nowrap">${c.complete ? '✓ COMPLETE' : '✗ INCOMPLETE'}</td>
</tr>${childRows(c)}`;
}).join('');
const head = REQ.map(([,l]) => `<th style="font-size:10px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);padding:0 4px">${l}</th>`).join('');
host.innerHTML = `
<div style="display:flex;align-items:center;gap:14px;margin-bottom:14px">
<div style="font-size:18px;font-weight:700;color:var(--text)">Validation${data.project ? ' — ' + data.project : ''}</div>
<div style="font-size:13px;font-weight:700;padding:5px 12px;border-radius:6px;background:${allGreen ? 'rgba(52,209,126,.18)' : 'rgba(255,82,82,.16)'};color:${allGreen ? 'var(--green,#34d17e)' : '#ff5252'};border:1px solid ${allGreen ? 'rgba(52,209,126,.5)' : 'rgba(255,82,82,.45)'}">
${s.complete || 0} / ${s.total || 0} fully complete${allGreen ? ' — ALL GREEN ✓' : ' — ' + (s.incomplete || 0) + ' INCOMPLETE'}
</div>
<div style="font-size:11px;color:var(--muted)">required: datasheet + STEP + symbol + footprint + 3 thumbnails. Auto-refreshes.</div>
</div>
<table style="border-collapse:collapse;width:100%">
<thead><tr>
<th style="text-align:left;font-size:11px;color:var(--muted)">MPN</th>${head}
<th style="font-size:10px;color:var(--muted)">.lbr</th>
<th style="font-size:10px;color:var(--muted)" data-tooltip="adom-concur — cross-source consensus: do the KiCad / EAGLE / Altium / ds2sf variants of this part agree?">CONSENSUS<br><span style="font-size:8px;color:var(--accent)">adom-concur</span></th>
<th style="font-size:11px;color:var(--muted)">VERDICT</th>
</tr></thead>
<tbody>${rows || '<tr><td colspan="10" style="color:var(--muted);padding:14px">no chips in scope</td></tr>'}</tbody>
</table>`;
}
document.getElementById('btn-validate').addEventListener('click', () => { if (sourcesOn) setSourcesView(false); setValidationView(!validationOn); });
// ---------------- Sourcing priority tab ----------------
// The exact, code-backed order chip-fetcher searches the internet for data.
// Rendered straight from /api/sources so it can NEVER drift from real behavior.
let sourcesOn = false;
const TIER_STYLE = {
'Manufacturer': { bg:'rgba(0,184,176,.16)', fg:'#00e6dc', bd:'rgba(0,184,176,.5)' },
'Adom tool': { bg:'rgba(140,107,247,.18)', fg:'#b9a4ff', bd:'rgba(140,107,247,.55)' },
'Aggregator skill':{ bg:'rgba(100,171,255,.15)', fg:'#8cc2ff', bd:'rgba(100,171,255,.5)' },
'Last resort': { bg:'rgba(255,82,82,.14)', fg:'#ff7676', bd:'rgba(255,82,82,.45)' },
};
function setSourcesView(on) {
sourcesOn = on;
if (on && validationOn) setValidationView(false);
document.getElementById('grid-view').style.display = on ? 'none' : '';
document.getElementById('sources-view').style.display = on ? 'block' : 'none';
document.getElementById('btn-sources').style.background = on ? 'var(--accent)' : '';
document.getElementById('btn-sources').style.color = on ? '#fff' : '';
if (on) renderSources();
}
async function renderSources() {
const host = document.getElementById('sources-view');
let data;
try { data = await (await fetch(API_BASE + '/api/sources?v=' + Date.now())).json(); }
catch (e) { host.innerHTML = '<div class="empty-state">sources API error</div>'; return; }
const badge = (tier) => {
const s = TIER_STYLE[tier] || { bg:'rgba(255,255,255,.06)', fg:'var(--muted)', bd:'var(--border)' };
return `<span style="font-size:9.5px;text-transform:uppercase;letter-spacing:.6px;font-weight:700;padding:2px 7px;border-radius:5px;background:${s.bg};color:${s.fg};border:1px solid ${s.bd};white-space:nowrap">${tier}</span>`;
};
const concerns = (data.concerns || []).map(c => {
const steps = (c.steps || []).map((st, i) => `
<div style="display:flex;gap:12px;align-items:flex-start;padding:9px 0;border-top:1px solid var(--border)">
<div style="font-family:'JetBrains Mono',monospace;font-size:13px;font-weight:700;color:var(--accent);min-width:22px;text-align:right">${i + 1}.</div>
<div style="min-width:118px;display:flex;flex-direction:column;gap:5px">${badge(st.tier)}</div>
<div style="flex:1;min-width:0">
<div style="font-size:14px;font-weight:700;color:var(--text)">${st.name}</div>
<div style="font-size:12.5px;color:var(--text2);line-height:1.45;margin-top:2px">${st.detail || ''}</div>
${st.cmd ? `<code style="display:inline-block;margin-top:5px;font-size:11.5px;color:var(--accent);background:rgba(0,184,176,.07);border:1px solid rgba(0,184,176,.2);border-radius:5px;padding:2px 7px">${st.cmd}</code>` : ''}
${st.skill ? `<span style="font-size:10.5px;color:var(--muted);margin-left:6px">↳ ${st.skill}</span>` : ''}
</div>
</div>`).join('');
return `
<div style="margin-bottom:26px;background:var(--card,rgba(255,255,255,.02));border:1px solid var(--border);border-radius:12px;padding:16px 18px">
<div style="font-size:16px;font-weight:800;color:var(--text)">${c.concern}</div>
<div style="font-size:12.5px;color:var(--text2);margin:4px 0 6px">${c.what || ''}</div>
<div style="font-size:12px;color:var(--accent);background:rgba(0,184,176,.07);border-left:3px solid var(--accent);padding:6px 10px;border-radius:0 6px 6px 0;margin-bottom:6px">▸ ${c.rule || ''}</div>
${steps}
</div>`;
}).join('');
host.innerHTML = `
<div style="max-width:920px">
<div style="font-size:20px;font-weight:800;color:var(--text);margin-bottom:4px">Sourcing priority — how chip-fetcher searches the internet</div>
<div style="font-size:12.5px;color:var(--muted);margin-bottom:18px">The real, code-backed order (served from <code>/api/sources</code>). Three concerns, each searched top-to-bottom. <b style="color:var(--text2)">Manufacturer-first for CAD; Adom data tools (keys baked in, always work) for metadata; manual-crawl skills are the fallback; LCSC is dead last.</b></div>
${concerns}
</div>`;
}
document.getElementById('btn-sources').addEventListener('click', () => setSourcesView(!sourcesOn));
// ---------------- credential vault ----------------
const credsModal = document.getElementById('creds-modal');
const credsMsg = document.getElementById('cred-msg');
document.getElementById('btn-creds').addEventListener('click', () => {
credsModal.classList.add('open');
credsMsg.textContent = '';
loadCreds();
});
document.getElementById('cred-close').addEventListener('click', () => credsModal.classList.remove('open'));
credsModal.addEventListener('click', (e) => { if (e.target === credsModal) credsModal.classList.remove('open'); });
async function loadCreds() {
const list = document.getElementById('creds-list');
list.innerHTML = '<div class="info">loading…</div>';
try {
const r = await fetch(API_BASE + '/api/credentials');
if (!r.ok) {
const err = await r.text();
list.innerHTML = '<div class="info">credential vault unavailable: ' + escapeHtml(err) + '</div>';
return;
}
const d = await r.json();
const creds = d.credentials || [];
if (creds.length === 0) {
list.innerHTML = '<div class="info">no credentials saved yet</div>';
return;
}
list.innerHTML = creds.map(c =>
`<div class="row"><div><code>${escapeHtml(c.host)}</code><div class="info">${escapeHtml(c.username)} · <span class="pw-mask" data-pw="${escapeHtml(c.password || '')}" data-tooltip="Click to reveal / hide this saved password." style="cursor:pointer; opacity:.7; user-select:none;">••••••••</span></div></div>` +
`<button data-host="${escapeHtml(c.host)}">delete</button></div>`
).join('');
list.querySelectorAll('button').forEach(btn => {
btn.onclick = () => deleteCred(btn.dataset.host);
});
list.querySelectorAll('.pw-mask').forEach(span => {
span.onclick = () => {
const masked = span.textContent === '••••••••';
span.textContent = masked ? span.dataset.pw : '••••••••';
span.setAttribute('data-tooltip', masked ? 'Click to hide this password.' : 'Click to reveal this password.');
span.style.userSelect = masked ? 'text' : 'none';
};
});
} catch (e) {
list.innerHTML = '<div class="info">load error: ' + escapeHtml(e.message) + '</div>';
}
}
async function deleteCred(host) {
if (!confirm('Delete credentials for ' + host + '?')) return;
const r = await fetch(API_BASE + '/api/credentials/' + encodeURIComponent(host), {method: 'DELETE'});
showMsg(r.ok ? 'deleted' : 'delete failed', r.ok);
loadCreds();
}
document.getElementById('cred-save').addEventListener('click', async () => {
const host = document.getElementById('cred-host').value.trim();
const username = document.getElementById('cred-user').value.trim();
const password = document.getElementById('cred-pass').value;
if (!host || !username || !password) {
showMsg('host, username, password all required', false);
return;
}
showMsg('saving…', true);
try {
const r = await fetch(API_BASE + '/api/credentials', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({host, username, password})
});
if (r.ok) {
showMsg('credentials saved', true);
document.getElementById('cred-pass').value = '';
loadCreds();
} else {
const err = await r.text();
showMsg('save failed: ' + err, false);
}
} catch (e) {
showMsg('save error: ' + e.message, false);
}
});
function showMsg(text, ok) {
credsMsg.className = 'msg ' + (ok ? 'ok' : 'err');
credsMsg.textContent = text;
}
load().catch(e => {
document.getElementById('cards').innerHTML = '<div class="empty-state">load error: ' + e.message + '</div>';
});
// ---- Tooltip system per human-ui-patterns skill ----
// Body-appended position:fixed div at z-index 99999, 600ms delay,
// auto-flip when near viewport edge. Every interactive element uses
// data-tooltip="..." for descriptive, newbie-friendly help text.
(function initTooltips() {
const tip = document.createElement('div');
tip.style.cssText = 'position:fixed;z-index:99999;background:#1a1f2e;color:#e6edf3;border:1px solid #3d4450;border-radius:6px;padding:6px 10px;font-size:12px;line-height:1.4;max-width:min(320px,calc(100vw - 40px));pointer-events:none;opacity:0;transition:opacity .15s;white-space:pre-wrap;font-family:Satoshi,-apple-system,BlinkMacSystemFont,sans-serif;box-shadow:0 4px 16px rgba(0,0,0,0.5)';
document.body.appendChild(tip);
let timer = null;
// Touch-primary devices (phones) emulate mouseover on tap but never fire
// mouseout, so hover-tooltips stick and cover content. Disable them there;
// touch users tap elements directly. Also dismiss on any tap/scroll.
const TOUCH = window.matchMedia && window.matchMedia('(hover: none)').matches;
const hide = () => { clearTimeout(timer); tip.style.opacity = '0'; };
document.addEventListener('touchstart', hide, { passive: true });
window.addEventListener('scroll', hide, { passive: true, capture: true });
document.addEventListener('mouseover', (ev) => {
if (TOUCH) return;
const el = ev.target.closest('[data-tooltip]');
if (!el) return;
clearTimeout(timer);
timer = setTimeout(() => {
tip.textContent = el.dataset.tooltip;
tip.style.opacity = '1';
const r = el.getBoundingClientRect();
let left = r.left;
tip.style.left = '0'; tip.style.top = '0'; // reset for measurement
const tw = tip.offsetWidth, th = tip.offsetHeight;
// Prefer ABOVE the element — these chips/badges/pulldowns sit at the top
// of their card, so a tooltip below covers the thumbnail. Flip below only
// when there genuinely isn't room above (element near the viewport top).
let top = r.top - th - 6;
if (top < 8) top = r.bottom + 6;
if (left + tw > window.innerWidth - 8) left = window.innerWidth - tw - 8;
if (left < 8) left = 8;
tip.style.left = left + 'px';
tip.style.top = top + 'px';
}, 600);
});
document.addEventListener('mouseout', (ev) => {
const el = ev.target.closest('[data-tooltip]');
if (!el) return;
clearTimeout(timer);
tip.style.opacity = '0';
});
})();
</script>
</body>
</html>