12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222
<!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; }
  .card .thumb-row.secondary { grid-template-columns: 2fr 1fr; align-items: stretch; }
  .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); }
  /* 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; }
  .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.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: 8px; display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; 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; }
  .card .src-popover { position: absolute; top: 24px; left: 4px; z-index: 30;
    background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 6px; min-width: 168px;
    display: flex; flex-direction: column; gap: 4px; box-shadow: 0 10px 28px rgba(0,0,0,.55); }
  .card .src-opt { cursor: pointer; display: flex; align-items: center; gap: 8px; border: 1px solid var(--border-muted); border-radius: 6px;
    padding: 5px 7px; font-size: 11px; color: var(--text); transition: border-color .12s, background .12s; white-space: nowrap; }
  .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: 30px; height: 30px; object-fit: contain; background: #0d1117; border-radius: 4px; flex: 0 0 30px; }
  .card .src-opt-lab { flex: 1; }
  .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: 48px; height: 48px; object-fit: contain; display: block; margin: 0 auto 2px; }
  .card .orient-opt span { font-size: 9px; 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="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 &mdash; STEP 3D + Altium IntLib + Fusion 360 .lbr + KiCad symbol/footprint + datasheet &mdash; 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>
  <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" checked> 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 chip 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('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.has_thumb_footprint, c.has_thumb_chip3d, c.validation_status]));
    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
    // 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">
            ${renderThumb(c.mpn, 'chip3d',     c.has_thumb_chip3d,     'Z↑', { upAxis: 'z', chosen: c.chosen_up_axis, inferred: c.inferred_up_axis }, thumbErr(c, '3d'))}
            ${renderThumb(c.mpn, 'chip3d-yup', c.has_thumb_chip3d_yup, 'Y↑', { upAxis: 'y', chosen: c.chosen_up_axis, inferred: c.inferred_up_axis }, thumbErr(c, '3d'))}
            <button class="orient-more-btn" data-tooltip="Pick from all 6 face-up orientations.\nZ↑ and Y↑ cover 98% of chips. For unusual STEP files (WAGO connectors, through-hole parts), try X↑, X↓, Y↓, or Z↓. The chosen orientation is 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">
              ${['xup','xdown','ydown','zdown'].map(o => {
                const kind = 'chip3d-' + o;
                const label = o.replace('up','↑').replace('down','↓').toUpperCase().replace('X','X ').replace('Y','Y ').replace('Z','Z ');
                const url = API_BASE + '/thumb/' + encodeURIComponent(c.mpn) + '/' + kind + '?t=' + Date.now();
                const isChosen = c.chosen_up_axis === o;
                return `<div class="orient-opt${isChosen?' chosen':''}" data-mpn="${escapeHtml(c.mpn)}" data-axis="${o}" data-tooltip="Mark the ${label} 3D rendering as the correct up-axis for this chip.">
                  <img src="${url}" onerror="this.parentElement.style.display='none'" />
                  <span>${label}</span>
                </div>`;
              }).join('')}
            </div>
          </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 — lints it for Fusion 360 / Altium / OrCAD, then one-click Send to your desktop EDA tool." 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?'3D STEP model present.\nUsed by chipsmith for pin detection + footprint validation, and by chip-thumbnailer for the 3D orientation renders you see above.' + (c.step_source ? '\nSource: ' + c.step_source : '') + provenanceSummary(c.info, c.file_provenance, c.mpn):'3D STEP model missing.\nNeeded for chipsmith validation + 3D thumbnails.' + provenanceSummary(c.info, c.file_provenance, c.mpn)}">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?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="STEP file source: ${escapeHtml(c.step_source)}.\n${c.step_source==='—'?'No STEP source recorded yet.':'This is where the 3D model came from. The sourcing ladder (Mfr → SnapMagic → Mouser → DigiKey → Arrow → CSE/UL → LCSC) determines priority.'}">${escapeHtml(srcShort(c.step_source))}</span>
      </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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[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;
}

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 === 'chip3d' || kind === 'chip3d-yup')) {
    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)}"`;
  const thumbHtml = `<div class="thumb${noHover ? ' thumb-static' : ''}"${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}
  </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 {
    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) {
      alert('Could not set orientation: ' + (j.error || ('HTTP ' + r.status)));
      return;
    }
    const libR = await fetch(API_BASE + '/api/library');
    LIB = await libR.json();
    LAST_LIB_HASH = '';
    render();
  } catch (e) {
    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'; });
});

// 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(' > ');
}

// 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;
  var prov = provenanceSummary(info, fileProv, mpn);
  if (hasSym && hasMod) {
    return `<span class="b ${label.toLowerCase()}" data-tooltip="${fullName} schematic symbol + PCB footprint both present.\nThis chip has a complete ${fullName} library — ready for import into your EDA tool.${prov}">${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="${fullName}: has ${has} but missing ${miss}.\nPartial library — use the refetch button to get the missing piece.${prov}">${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.${prov}">${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 ? `<button class="openbtn" data-kind="chipsmith" data-tooltip="Open in chipsmith — STEP-native pin/footprint detection + sign-off.">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(''));

// ---------------- 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>