master
John Lauer Use generic 3rd party fab wording 5e567b6 20d ago
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Chip Fetcher</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23e6edf3' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'><rect x='7' y='7' width='10' height='10' rx='1.5'/><path d='M9 7V4M12 7V4M15 7V4M9 20v-3M12 20v-3M15 20v-3M7 9H4M7 12H4M7 15H4M20 9h-3M20 12h-3M20 15h-3'/></svg>">
<style>
/* Adom brand fonts. JetBrains Mono is not hosted on adom.inc (404), so the 400/500 files come from fontsource. */
@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:'Satoshi';src:url('https://adom.inc/fonts/satoshi/satoshi-700-normal.woff2') format('woff2');font-weight:700;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}

:root{
  --bg:#0d1117;--surface:#161b22;--elevated:#1c2128;--overlay:#21262d;--border:#30363d;
  --text:#e6edf3;--text-body:#c9d1d9;--text-2:#8b949e;--text-3:#484f58;
  --accent:#00b8b1;--accent-hover:#00d4cb;--accent-bright:#00e6dc;--on-accent:#05221f;
  --accent-10:rgba(0,184,177,.10);--accent-20:rgba(0,184,177,.20);--accent-35:rgba(0,184,177,.35);
  --green:#3fb950;--red:#f85149;--yellow:#d29922;--blue:#64abff;--purple:#8c6bf7;
  --green-10:rgba(63,185,80,.12);--green-35:rgba(63,185,80,.35);--red-10:rgba(248,81,73,.12);--red-35:rgba(248,81,73,.35);
  --yellow-10:rgba(210,153,34,.14);--yellow-35:rgba(210,153,34,.35);--neutral-10:rgba(139,148,158,.12);--purple-10:rgba(140,107,247,.14);--blue-10:rgba(100,171,255,.12);
  --scrim:rgba(13,17,23,.72);--shadow:0 12px 40px rgba(0,0,0,.55);
  --thumb-3d-bg:#404b55; /* pyrender's iso-pose backdrop; must match the baked PNG canvas */
  --row-title:#8fd3d4; /* settings-layout: the intentional light-teal row title */
  --brand-heading:'Familjen Grotesk',sans-serif;--brand-body:'Satoshi',sans-serif;--mono:'JetBrains Mono',monospace;
  --z-hud:50;--z-toolbar:80;--z-floating-menu:200;--z-toast:400;--z-modal:600;--z-tooltip:99999;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{background:var(--bg);color:var(--text-body);font:13px/1.45 var(--brand-body);-webkit-tap-highlight-color:transparent}
/* House scrollbar (scrollbars skill): thin, transparent track, translucent pill, teal on hover. */
*{scrollbar-width:thin;scrollbar-color:rgba(139,148,158,.38) transparent}
::-webkit-scrollbar{width:10px;height:10px}
::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:rgba(139,148,158,.32);border-radius:999px;border:2px solid transparent;background-clip:padding-box}
::-webkit-scrollbar-thumb:hover{background:rgba(0,184,177,.55);background-clip:padding-box}
::-webkit-scrollbar-corner{background:transparent}
/* Input feedback (input-feedback skill): hover one step up, press compresses instantly, keyboard ring. */
button{font-family:inherit;color:inherit;cursor:pointer}
button,select,input,.pill,.card,.choice,.say-item,.tpill{transition:background .15s ease,border-color .15s ease,color .15s ease,box-shadow .15s ease,transform .06s ease}
button:active:not(:disabled){transform:translateY(1px) scale(.97)}
:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
input:focus-visible,select:focus-visible{outline:none;border-color:var(--accent)}
button:disabled{opacity:.5;cursor:not-allowed}
a{color:var(--accent)}
code{font-family:var(--mono);font-size:.92em}
[hidden]{display:none !important}

/* ---- App shell ---- */
.app{position:fixed;inset:0;display:flex;flex-direction:column;background:var(--bg)}
header{flex:0 0 44px;height:44px;display:flex;align-items:center;gap:12px;padding:0 12px 0 16px;border-bottom:1px solid var(--border);background:var(--surface);position:relative;z-index:var(--z-toolbar);font-family:var(--brand-heading)}
.brand{display:flex;align-items:center;gap:8px;color:var(--accent);font-size:13px;font-weight:600;letter-spacing:.01em;cursor:default;flex-shrink:0;white-space:nowrap}
.brand svg.logo{width:16px;height:16px;flex-shrink:0;color:var(--text)}
.app-cf{color:var(--text-2);font-weight:500}
.brand .app-subtitle{color:var(--text-2);font-weight:500}
.brand .sep{color:var(--text-3);font-weight:400}
.vsep{width:1px;height:18px;background:var(--border);flex-shrink:0}
.subject{display:flex;align-items:center;gap:8px;min-width:0;flex:1;cursor:default;overflow:hidden}
.subject-name{color:var(--text);font:600 14px var(--brand-heading);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.subject-name.idle{color:var(--text-2);font-weight:500}
.state-pill{display:none;font:600 11px/1 var(--brand-heading);padding:4px 9px;border-radius:999px;flex-shrink:0;border:1px solid transparent}
.state-pill.show{display:inline-flex}
.state-pill.full{background:var(--green-10);color:var(--green);border-color:var(--green-35)}
.state-pill.stub{background:var(--yellow-10);color:var(--yellow);border-color:var(--yellow-35)}
.state-pill.fetching{background:var(--accent-10);color:var(--accent);border-color:var(--accent-35);animation:pillpulse 1.2s ease-in-out infinite}
@keyframes pillpulse{0%,100%{opacity:1}50%{opacity:.55}}
.subject-meta{display:block;min-width:0;overflow:hidden;text-overflow:ellipsis;color:var(--text-2);font:11px/44px var(--mono);white-space:nowrap}
.subject-meta>span{display:inline}
.subject-meta .sep{color:var(--text-3)}
.actions{display:flex;align-items:center;gap:4px;margin-left:auto;flex-shrink:0;background:var(--surface);position:relative}
@media (max-width:1180px){.subject-meta{display:none}}
@media (max-width:900px){.brand .app-subtitle,.brand .sep{display:none}}
@media (max-width:640px){.subject-name{display:none}}
.iconbtn{width:28px;height:28px;display:inline-flex;align-items:center;justify-content:center;background:none;border:1px solid transparent;border-radius:8px;color:var(--text-2);padding:0}
.iconbtn svg{width:18px;height:18px}
.iconbtn:hover{background:var(--elevated);border-color:var(--border);color:var(--text)}
.iconbtn:active:not(:disabled){transform:scale(.9);background:var(--overlay)}
.iconbtn.is-on{background:var(--accent-20);border-color:var(--accent-35);color:var(--accent)}
.hover-popover{position:absolute;top:calc(100% + 6px);left:16px;background:var(--elevated);border:1px solid var(--border);border-radius:10px;box-shadow:var(--shadow);padding:12px 14px;min-width:300px;max-width:min(440px,calc(100vw - 32px));font:12px var(--brand-body);color:var(--text-body);z-index:var(--z-tooltip);opacity:0;transform:translateY(-2px);pointer-events:none;transition:opacity .12s ease,transform .12s ease}
.hover-popover.show{opacity:1;transform:none;pointer-events:auto}
.popover-title{font:700 14px var(--brand-heading);color:var(--text);margin-bottom:8px}
.popover-row{display:flex;gap:12px;padding:3px 0;align-items:baseline}
.popover-row .k{color:var(--text-2);font:11px var(--mono);width:64px;flex-shrink:0}
.popover-row .v{color:var(--text-body);flex:1;line-height:1.5}
.popover-actions{margin-top:10px;padding-top:10px;border-top:1px solid var(--border);display:flex;gap:14px}
.popover-link{color:var(--accent);text-decoration:none;font:600 12px var(--brand-heading);display:inline-flex;align-items:center;gap:4px}
.popover-link svg{width:12px;height:12px}
.popover-link:hover{text-decoration:underline;text-underline-offset:3px}

/* ---- Filter row ---- */
.filterbar{flex:0 0 auto;display:flex;align-items:center;gap:8px;padding:8px 16px;border-bottom:1px solid var(--border);background:var(--bg);flex-wrap:wrap}
.filterbar input[type=search],.filterbar select{background:var(--surface);border:1px solid var(--border);color:var(--text);padding:6px 10px;border-radius:8px;font:12.5px var(--brand-body);min-height:30px}
.filterbar input[type=search]{min-width:220px;flex:1 1 220px;max-width:380px}
.filterbar input::placeholder{color:var(--text-2)}
.filterbar select{color-scheme:dark}
.filterbar input:hover,.filterbar select:hover{border-color:var(--text-2)}
.project-pills{display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin-left:auto}
.tpill{display:inline-flex;align-items:center;gap:6px;padding:5px 11px;border-radius:999px;border:1px solid var(--border);background:var(--surface);color:var(--text-body);font:500 12px var(--brand-body);min-height:28px}
.tpill .n{font:11px var(--mono);color:var(--text-2)}
.tpill:hover{border-color:var(--text-2);background:var(--elevated);color:var(--text)}
.tpill.active{border-color:var(--accent);background:var(--accent-10);color:var(--text)}
.tpill.active .n{color:var(--accent)}

/* ---- Body: content scroller + detail aside ---- */
.app-body{flex:1 1 auto;min-height:0;display:grid;grid-template-columns:minmax(0,1fr) 380px}
.app-body.noaside{grid-template-columns:minmax(0,1fr)}
.app-body.noaside aside{display:none}
.content{min-height:0;overflow-y:auto;padding:16px 20px 40px;scrollbar-gutter:stable}
.cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:12px}
.empty-state{color:var(--text-2);padding:40px;text-align:center;grid-column:1/-1}

/* ---- Say it strip (copied from the KiCad dashboard so every Adom app teaches the same way) ---- */
.say{background:var(--surface);border:1px solid var(--border);border-left:3px solid color-mix(in srgb,var(--accent) 45%,transparent);border-radius:12px;padding:14px 16px;margin-bottom:12px}
.say-head{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px 14px;margin-bottom:10px}
.say-head h2{font:600 17px var(--brand-heading);color:var(--text);margin:0}
.say-sub{font-size:12.5px;color:var(--text-2);max-width:62ch}
.say-sub b{color:var(--text);font-weight:600}
.say-list{display:flex;flex-wrap:wrap;gap:8px 10px}
.say-item{display:inline-flex;align-items:center;gap:2px;border:1px solid var(--border);border-radius:999px;background:var(--elevated);padding:2px 4px 2px 2px;max-width:100%}
.say-iconbtn{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;flex:none;border:none;border-radius:999px;background:none;color:var(--text-2);cursor:pointer;transition:color .15s ease,background .15s ease,transform .06s ease}
.say-iconbtn svg{width:16px;height:16px}
.say-iconbtn:hover{color:var(--accent);background:var(--overlay)}
.say-iconbtn:active{transform:translateY(1px) scale(.94)}
.say-iconbtn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.demo-row{margin:10px 0 12px}
.btn-demo-primary{display:inline-flex;align-items:center;gap:8px;background:var(--accent);color:var(--on-accent);border:none;border-radius:8px;padding:9px 16px;font:600 13px var(--brand-heading);cursor:pointer;transition:background .15s ease,transform .06s ease}
.btn-demo-primary svg{width:15px;height:15px}
.btn-demo-primary:hover{background:var(--accent-hover)}
.btn-demo-primary:active{transform:translateY(1px)}
.btn-demo-primary:disabled{opacity:.6;cursor:default}
@media (max-width:720px){.say-item{width:100%}.say-iconbtn{width:44px;height:44px}}
.say-item:hover{border-color:var(--accent)}
.say-phrase{display:inline-block;line-height:1.3;padding:7px 12px 7px 14px;background:none;border:none;color:var(--text);font:500 14px var(--brand-body);text-align:left}
.say-phrase em{font-style:normal;color:var(--accent)}
.say-phrase:hover{background:var(--overlay)}
.say-try{display:inline-flex;align-items:center;white-space:nowrap;padding:0 12px;border:none;border-left:1px solid var(--border);background:none;color:var(--text-2);font:600 12px var(--brand-heading)}
.say-send{display:inline-flex;align-items:center;white-space:nowrap;padding:0 12px;border:none;border-left:1px solid var(--border);background:none;color:var(--accent);font:600 12px var(--brand-heading)}
.say-send:hover{background:var(--accent-10)}
.say-send:active{transform:translateY(1px)}
.say-send:focus-visible{outline-offset:-2px}
.say.say-folded .say-sub,.say.say-folded .say-list,.say.say-folded h2,.say.say-folded .demo-row{display:none}
.say.say-folded{padding:6px 12px}
.say-fold{display:block;width:100%;text-align:left;background:none;border:none;color:var(--text-2);font:500 12.5px var(--brand-body);cursor:pointer;padding:4px 2px}
.say-fold:hover{color:var(--accent)}
.led-detail{flex-basis:100%;color:var(--text-2);font:12.5px/1.5 var(--brand-body);padding:2px 6px 6px;max-width:70ch}
.led-detail .btn{margin-left:8px}
.target-chip{display:none;align-items:center;gap:6px;font:11px var(--mono);color:var(--text-2);border:1px solid var(--border);border-radius:999px;padding:3px 9px;flex-shrink:0;white-space:nowrap}
.target-chip.show{display:inline-flex}
.target-chip .tdot{width:7px;height:7px;border-radius:50%;background:var(--green)}
.target-chip.down .tdot{background:var(--text-3)}
.say-try:hover{background:var(--overlay);color:var(--accent)}
.say-phrase:focus-visible,.say-try:focus-visible{outline-offset:-2px}

/* ---- Component LEDs (compact row, always visible) ---- */
.leds{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:14px}
.ledchip{display:inline-flex;align-items:center;gap:8px;padding:5px 11px 5px 9px;border:1px solid var(--border);border-radius:999px;background:var(--surface);font-size:12px;color:var(--text-body);cursor:default;min-height:28px}
.ledchip .lab{font-weight:500;color:var(--text)}
.ledchip .det{color:var(--text-2);font-size:11.5px}
.led{width:9px;height:9px;border-radius:999px;background:var(--text-2);flex:none}
.led.ok{background:var(--green);box-shadow:0 0 6px rgba(63,185,80,.5)}
.led.warn{background:var(--yellow)}
.led.fail{background:var(--red)}

/* ---- Guided tour: floaty transport + spotlight ---- */
.tour-hud{position:fixed;bottom:70px;left:50%;transform:translateX(-50%);z-index:var(--z-hud,300);max-width:520px;background:var(--overlay);border:1px solid var(--border);border-radius:12px;box-shadow:0 12px 32px rgba(0,0,0,.45);padding:8px 12px;display:flex;flex-direction:column;gap:6px}
.tour-grip{display:flex;align-items:center;justify-content:space-between;font:600 12px var(--brand-heading);gap:12px}
.tour-step-num{color:var(--text-2);font:500 11px var(--mono)}
.tour-text{font:12px/1.45 var(--brand-body);color:var(--text-2);max-width:60ch;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
.tour-controls{display:flex;gap:6px;align-items:center}
.tour-controls .iconbtn{width:34px;height:30px}
.tour-voice{display:flex;align-items:center;gap:8px;font:12px var(--brand-body);color:var(--text-2);margin-left:auto}
.tour-spotlight{outline:2px solid var(--accent);outline-offset:4px;border-radius:12px;transition:outline-color .2s ease}
@media (max-width:720px){.tour-hud{left:12px;right:12px;max-width:none;transform:none;bottom:12px}}
/* ---- The newbie panel: say strip + LEDs + getting started, one closable block ---- */
.newbie{position:relative;flex:0 0 auto;border-bottom:1px solid var(--border);background:var(--bg);padding:14px 20px 16px}
.newbie-inner{max-width:1100px;margin:0 auto}
.newbie-close{position:absolute;top:10px;right:14px;z-index:2}
.newbie-close{position:absolute;top:10px;right:10px;z-index:2}
/* ---- Welcome (first run) ---- */
.welcome{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin-bottom:16px;max-width:760px}
.welcome h2{font:600 18px var(--brand-heading);color:var(--text)}
.welcome-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px}
.welcome p{color:var(--text-2);margin-bottom:12px;max-width:62ch}
.welcome ol{list-style:none;display:flex;flex-direction:column;gap:10px}
.welcome li{display:flex;gap:12px;align-items:flex-start;line-height:1.5}
.welcome .num{flex:none;width:26px;height:26px;border-radius:999px;background:var(--accent-10);border:1px solid var(--accent-35);color:var(--accent);font:600 13px var(--brand-heading);display:inline-flex;align-items:center;justify-content:center}
.welcome .btn{margin-top:8px}

/* ---- Buttons ---- */
.btn{display:inline-flex;align-items:center;gap:8px;min-height:32px;padding:0 13px;border-radius:8px;border:1px solid var(--border);background:var(--elevated);color:var(--text);font:500 13px var(--brand-body)}
.btn svg{width:16px;height:16px}
a.btn{text-decoration:none}
.btn:hover{background:var(--overlay);border-color:var(--text-2)}
.btn:active:not(:disabled){transform:translateY(1px) scale(.98);background:var(--surface)}
.btn.primary{background:var(--accent);border-color:var(--accent);color:var(--on-accent);font-weight:600}
.btn.primary:hover{background:var(--accent-hover);border-color:var(--accent-hover)}
.btn.danger{color:var(--red);border-color:var(--red-35)}
.btn.danger:hover{background:var(--red-10)}
.btn.small{min-height:26px;padding:0 9px;font-size:12px}

/* ---- Cards ---- */
.card{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:14px;cursor:pointer;position:relative}
.card:hover{border-color:var(--text-2)}
.card:active{transform:scale(.995)}
.card.selected{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent)}
.card.active{border-color:var(--accent);box-shadow:0 0 0 2px var(--accent-35)}
.mpn-row{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:2px}
.mpn{font:600 15px/1.3 var(--brand-heading);color:var(--text);overflow-wrap:anywhere;flex:1;min-width:0;padding-top:3px}
.card-actions{display:flex;gap:1px;align-items:center;flex:0 0 auto}
.cbtn{width:24px;height:24px;display:inline-flex;align-items:center;justify-content:center;background:none;border:1px solid transparent;border-radius:7px;color:var(--text-2);padding:0}
.cbtn svg{width:15px;height:15px}
.cbtn:hover{color:var(--text);background:var(--elevated);border-color:var(--border)}
.cbtn:active:not(:disabled){transform:scale(.9)}
.cbtn:disabled{cursor:wait}
.card .meta{color:var(--text-2);font-size:12px;margin-bottom:6px}
.card-desc{color:var(--text-2);font-size:11.5px;line-height:1.4;margin:2px 0 8px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.thumbs{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px;margin:4px 0 10px}
.thumb{background:var(--bg);border:1px solid var(--border);border-radius:8px;aspect-ratio:1/1;display:flex;align-items:center;justify-content:center;padding:8%;overflow:hidden;position:relative}
.thumb[data-thumb-kind^="chip3d"]{background:var(--thumb-3d-bg);padding:4%}
.thumb:not(.thumb-static){cursor:pointer}
.thumb:not(.thumb-static):hover{border-color:var(--accent)}
.thumb img{width:100%;height:100%;object-fit:contain;display:block}
.thumb-label{position:absolute;bottom:4px;right:6px;font:11px var(--mono);color:var(--text-2);letter-spacing:.04em;text-shadow:0 1px 2px rgba(0,0,0,.6);pointer-events:none}
.thumb.empty{border-style:dashed}
.thumb.empty .thumb-placeholder{color:var(--text-2);font:11px var(--mono)}
.thumb.failed{border-style:dashed;border-color:var(--red-35);flex-direction:column;gap:2px;background:var(--red-10)}
.thumb.failed .thumb-failed{color:var(--red);font:600 11px var(--mono)}
.thumb.failed .thumb-failed-sub{color:var(--text-2);font:11px var(--mono)}
.thumb.refreshing img{opacity:.3;transition:opacity .2s}
.thumb-spinner{position:absolute;top:50%;left:50%;width:22px;height:22px;margin:-11px 0 0 -11px;border:2px solid rgba(230,237,243,.22);border-top-color:var(--accent);border-radius:50%;animation:thumbspin .7s linear infinite;pointer-events:none;z-index:4}
@keyframes thumbspin{to{transform:rotate(360deg)}}
.thumb-src-group{position:relative;display:flex}
.thumb-src-group .thumb{flex:1}
.src-chip{position:absolute;top:3px;left:3px;z-index:4;display:inline-flex;align-items:center;gap:4px;font:600 11px var(--brand-body);color:var(--text-body);background:rgba(13,17,23,.85);border:1px solid var(--border);border-radius:999px;padding:1px 6px;max-width:92%;line-height:1.3}
.src-chip:hover{border-color:var(--accent);background:var(--accent-10)}
.src-chip-lab{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.src-chip svg{width:10px;height:10px;flex:none;color:var(--text-2)}
.src-dot{width:8px;height:8px;border-radius:50%;flex:0 0 8px;background:var(--text-2)}
.src-dot.vendor{background:var(--accent)}
.src-dot.ds{background:var(--yellow)}
.thumb-3d-group{position:relative}
.thumb-3d-group .thumb{width:100%}
.orient-more-btn{position:absolute;bottom:4px;left:4px;z-index:5;width:22px;height:22px;display:inline-flex;align-items:center;justify-content:center;background:rgba(13,17,23,.85);border:1px solid var(--border);color:var(--text-2);border-radius:6px;padding:0}
.orient-more-btn svg{width:13px;height:13px}
.orient-more-btn:hover{color:var(--accent);border-color:var(--accent)}
.card-foot{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
.pill{display:inline-flex;align-items:center;gap:4px;font:600 11px/1 var(--brand-body);padding:4px 8px;border-radius:999px;border:1px solid transparent;white-space:nowrap;cursor:default}
.pill svg{width:11px;height:11px}
.pill.ok{background:var(--green-10);color:var(--green);border-color:var(--green-35)}
.pill.warn{background:var(--yellow-10);color:var(--yellow);border-color:var(--yellow-35)}
.pill.bad{background:var(--red-10);color:var(--red);border-color:var(--red-35)}
.pill.accent{background:var(--accent-10);color:var(--accent);border-color:var(--accent-35)}
.pill.unk{background:var(--neutral-10);color:var(--text-2);border:1px dashed var(--border)}
.pill.fetching{animation:pillpulse 1.2s ease-in-out infinite}
.mini-actions{margin-left:auto;display:flex;gap:4px}
.pill-act{display:inline-flex;align-items:center;gap:4px;font:500 11px var(--brand-body);padding:3px 8px;border-radius:999px;border:1px solid var(--border);background:none;color:var(--text-2);min-height:22px}
.pill-act svg{width:12px;height:12px}
.pill-act:hover{color:var(--accent);border-color:var(--accent)}
.out-of-filter-note{grid-column:1/-1;background:var(--yellow-10);border:1px solid var(--yellow-35);color:var(--text-body);border-radius:8px;padding:8px 12px;font-size:12px;display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.out-of-filter-note b{color:var(--accent);font-family:var(--mono)}
.out-of-filter-note .btn{margin-left:auto}

/* ---- Floating menus (refetch, orientation, source picker): one body-level element at the floating-menu rung ---- */
.floating{position:fixed;z-index:var(--z-floating-menu);background:var(--overlay);border:1px solid var(--border);border-radius:10px;padding:6px;box-shadow:var(--shadow);max-width:min(560px,calc(100vw - 16px));max-height:min(70vh,520px);overflow:auto}
.floating .fhead{font:600 11px var(--brand-body);color:var(--text-2);padding:6px 10px 4px}
.fopt{display:flex;align-items:center;gap:10px;width:100%;text-align:left;background:none;border:1px solid transparent;border-radius:7px;padding:7px 10px;color:var(--text-body);font:13px var(--brand-body);min-height:32px}
.fopt:hover{background:var(--elevated);border-color:var(--border);color:var(--text)}
.fopt .fsub{color:var(--text-2);font-size:11.5px;margin-left:auto}
.fopt svg{width:14px;height:14px;color:var(--text-2);flex:none}
.fgal{display:flex;flex-wrap:wrap;gap:8px;padding:4px}
.gopt{display:flex;flex-direction:column;align-items:center;gap:6px;border:1px solid var(--border);border-radius:8px;padding:8px;width:152px;background:var(--surface);color:var(--text-body);font:12px var(--brand-body);text-align:center}
.gopt:hover{border-color:var(--accent);background:var(--elevated)}
.gopt.chosen{border-color:var(--green);box-shadow:inset 0 0 0 1px var(--green)}
.gopt img{width:132px;height:132px;object-fit:contain;background:var(--bg);border-radius:6px;flex:none}
.gopt.g3d img{background:var(--thumb-3d-bg)}
.gopt .glab{line-height:1.3;white-space:normal}
.gopt .gcur{color:var(--green);font-weight:600;display:inline-flex;align-items:center;gap:3px;font-size:11px}
.gopt .gcur svg{width:11px;height:11px}

/* ---- Card zoom ---- */
.card-zoom-backdrop{position:fixed;inset:0;z-index:var(--z-modal);background:rgba(13,17,23,0);cursor:pointer;transition:background .25s ease-out}
.card-zoom-backdrop.visible{background:var(--scrim)}
.card-zoomed-wrapper{position:fixed;z-index:1;pointer-events:auto;cursor:default;transition:top .3s ease-out,left .3s ease-out,transform .3s ease-out}
.card-zoomed-wrapper .card{transform-origin:top left;border:2px solid var(--accent);box-shadow:0 24px 80px rgba(0,0,0,.7);cursor:default}
.card-zoomed-wrapper .card:active{transform:none}
.card-zoom-x{position:fixed;top:12px;right:12px;z-index:2;width:44px;height:44px;border-radius:50%;border:1px solid var(--border);background:var(--surface);color:var(--text);display:inline-flex;align-items:center;justify-content:center;box-shadow:var(--shadow)}
.card-zoom-x svg{width:20px;height:20px}
.card-zoom-x:hover{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}
.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-fluid .zoom-col-media,.card.zoom-fluid .zoom-col-info{min-width:0;display:flex;flex-direction:column;gap:10px}
.card.zoom-fluid .thumbs{margin:0}
.card-zoom-backdrop .card-desc{-webkit-line-clamp:unset;display:block;overflow:visible;font-size:14px;line-height:1.5;color:var(--text-body);margin:6px 0 12px;max-width:60ch}
.zoom-status{position:fixed;top:18px;left:50%;transform:translateX(-50%);z-index:3;background:var(--surface);border:1px solid var(--accent-35);border-radius:999px;padding:8px 18px;font:600 13px var(--brand-body);color:var(--accent);box-shadow:var(--shadow);white-space:nowrap;display:inline-flex;align-items:center;gap:8px}
.zoom-status .led{background:var(--accent);animation:pillpulse 1.2s ease-in-out infinite}
.zoom-actions{margin-top:14px;border-top:1px solid var(--border);padding-top:12px}
.zoom-sec{font:600 11px var(--brand-heading);color:var(--text-2);margin:0 0 6px}
.zoom-actions .openin-row,.zoom-actions .sendto-row{margin-bottom:12px}
.openin-row,.sendto-row{display:flex;gap:6px;flex-wrap:wrap}
.openbtn,.sendbtn{background:var(--elevated);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:0 11px;min-height:30px;font:600 12px var(--brand-body);display:inline-flex;align-items:center;gap:6px}
.openbtn:hover{background:var(--accent-10);border-color:var(--accent);color:var(--accent)}
.sendbtn:hover{background:var(--accent);border-color:var(--accent);color:var(--on-accent)}
.openbtn:disabled,.sendbtn:disabled{cursor:progress}

/* ---- Detail aside ---- */
aside{background:var(--surface);border-left:1px solid var(--border);padding:18px 20px;overflow-y:auto;min-height:0}
aside.empty{display:flex;align-items:center;justify-content:center;color:var(--text-2);text-align:center;padding:24px}
.aside-head{display:flex;align-items:flex-start;gap:10px}
.aside-head h2{font:600 18px/1.25 var(--brand-heading);color:var(--text);margin:0 0 6px;word-break:break-all;flex:1}
.aside-close{display:none}
aside .vendor{color:var(--text-2);margin-bottom:12px}
.dtabs{display:flex;gap:4px;margin:10px 0 12px;border-bottom:1px solid var(--border)}
.dtab{background:none;border:none;border-bottom:2px solid transparent;color:var(--text-2);font:600 13px var(--brand-heading);padding:7px 10px;margin-bottom:-1px;display:inline-flex;align-items:center;gap:6px;border-radius:6px 6px 0 0;min-height:34px}
.dtab:hover{color:var(--text);background:var(--elevated)}
.dtab.on{color:var(--accent);border-bottom-color:var(--accent)}
.dtab:active:not(:disabled){transform:none}
.dtab-n{font:11px var(--mono);background:var(--elevated);border:1px solid var(--border);border-radius:999px;padding:1px 6px;color:var(--text-2)}
.dpanel{display:none}
.dpanel.on{display:block}
.section{margin-bottom:18px}
.section-title{font:600 11px var(--brand-heading);color:var(--text-2);margin-bottom:6px}
.filelist{margin:0;padding:0;list-style:none}
.filelist li{padding:4px 0;border-bottom:1px solid var(--border)}
.filelist li:last-child{border-bottom:none}
.filelist a{color:var(--accent);text-decoration:none;font-size:11.5px;word-break:break-all}
.filelist a:hover{text-decoration:underline}
.fields{display:flex;flex-direction:column}
.field{display:grid;grid-template-columns:120px 1fr;gap:10px;padding:5px 0;border-bottom:1px solid var(--border);font-size:12px;align-items:baseline}
.field:last-child{border-bottom:none}
.field-key{color:var(--text-2);font:11px var(--mono)}
.field-val{color:var(--text-body);word-break:break-word}
.field.unknown .field-val{color:var(--text-2);font-style:italic}
.pills{display:flex;gap:6px;flex-wrap:wrap}

/* ---- Validation view ---- */
.view{padding:4px 0}
.vhead{display:flex;align-items:center;gap:14px;margin-bottom:14px;flex-wrap:wrap}
.vtitle{font:600 18px var(--brand-heading);color:var(--text)}
.vsum{font:600 13px var(--brand-heading);padding:5px 12px;border-radius:999px;border:1px solid}
.vsum.ok{background:var(--green-10);color:var(--green);border-color:var(--green-35)}
.vsum.bad{background:var(--red-10);color:var(--red);border-color:var(--red-35)}
.vnote{font-size:12px;color:var(--text-2)}
.vscroll{overflow-x:auto}
.vtable{border-collapse:collapse;width:100%;min-width:760px}
.vtable th{font:600 11px var(--brand-body);color:var(--text-2);padding:4px 6px;text-align:center;white-space:nowrap}
.vtable th.l,.vtable td.l{text-align:left}
.vtable th .sub{display:block;font:11px var(--mono);color:var(--accent);font-weight:400}
.vtable td{padding:5px 6px;text-align:center;border-top:1px solid var(--border)}
.vtable td.mpn{font:12px var(--mono);color:var(--text);white-space:nowrap;padding-right:14px}
.vchk{display:inline-flex;align-items:center;justify-content:center;color:var(--green);vertical-align:middle}
.vchk svg{width:15px;height:15px}
.vchk.bad{color:var(--red)}
.vchk .star{color:var(--accent);font-weight:700;margin-left:1px}
.vcon{font:600 12px var(--brand-heading)}
.vcon.ok{color:var(--green)}.vcon.bad{color:var(--red)}
.vcon-sub{display:block;font:11px var(--mono);color:var(--text-2);font-weight:400;margin-top:2px}
.vverdict{font:600 12px var(--brand-heading);white-space:nowrap}
.vverdict.ok{color:var(--green)}.vverdict.bad{color:var(--red)}
.vsub td{padding:4px 6px 4px 30px;background:var(--accent-10);text-align:left}
.vsub .arrow{color:var(--accent);font:11px var(--mono)}
.vsub .slabel{color:var(--text-body);font-size:11.5px;font-weight:600;margin:0 20px 0 6px;display:inline-block;min-width:215px}
.vsub .k{font:600 11px var(--brand-body);color:var(--text-2);margin-left:18px}
.vsub .k:first-of-type{margin-left:0}
.vdl{display:inline-flex;align-items:center;gap:4px;font:600 11px var(--brand-body);text-decoration:none;border-radius:999px;padding:2px 8px;margin-left:6px;white-space:nowrap;color:var(--accent);border:1px solid var(--accent-35)}
.vdl svg{width:11px;height:11px}
.vdl.orig{color:var(--on-accent);background:var(--accent);border-color:var(--accent)}
.vdl:hover{border-color:var(--accent)}
.vdls{margin-left:22px;display:inline-flex;align-items:center;flex-wrap:wrap;gap:4px 0}

/* ---- Sourcing order view ---- */
.srcwrap{max-width:920px}
.srctitle{font:600 20px var(--brand-heading);color:var(--text);margin-bottom:4px}
.srcsub{font-size:12.5px;color:var(--text-2);margin-bottom:18px;line-height:1.5}
.srcsub b{color:var(--text-body)}
.src-concern{margin-bottom:22px;background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:16px 18px}
.src-concern h3{font:600 16px var(--brand-heading);color:var(--text)}
.src-what{font-size:12.5px;color:var(--text-2);margin:4px 0 8px}
.src-rule{font-size:12px;color:var(--accent);background:var(--accent-10);border-left:3px solid var(--accent);padding:6px 10px;border-radius:0 6px 6px 0;margin-bottom:6px}
.src-step{display:flex;gap:12px;align-items:flex-start;padding:9px 0;border-top:1px solid var(--border)}
.src-n{font:500 13px var(--mono);color:var(--accent);min-width:22px;text-align:right}
.src-tier{min-width:118px}
.tier{display:inline-block;font:600 11px var(--brand-body);padding:3px 8px;border-radius:999px;background:var(--neutral-10);color:var(--text-body);border:1px solid var(--border);white-space:nowrap}
.tier.t1{background:var(--accent-10);color:var(--accent);border-color:var(--accent-35)}
.tier.t2{background:var(--purple-10);color:var(--purple)}
.tier.t3{background:var(--blue-10);color:var(--blue)}
.tier.t4{background:var(--red-10);color:var(--red);border-color:var(--red-35)}
.src-body{flex:1;min-width:0}
.src-name{font:600 14px var(--brand-heading);color:var(--text)}
.src-detail{font-size:12.5px;color:var(--text-2);line-height:1.45;margin-top:2px}
.src-cmd{display:inline-block;margin-top:5px;font:11.5px var(--mono);color:var(--accent);background:var(--accent-10);border:1px solid var(--accent-35);border-radius:5px;padding:2px 7px}
.src-skill{font-size:11.5px;color:var(--text-2);margin-left:6px}

/* ---- Architecture overlay ---- */
#arch-overlay{position:fixed;inset:0;z-index:var(--z-modal);background:var(--scrim);display:flex;flex-direction:column}
.arch-bar{display:flex;align-items:center;gap:12px;padding:0 12px 0 16px;height:44px;border-bottom:1px solid var(--border);background:var(--surface)}
.arch-bar .t{font:600 14px var(--brand-heading);color:var(--text)}
.arch-bar .s{color:var(--text-2);font-size:12px}
.arch-bar a{margin-left:auto;color:var(--text-2);font-size:12px;text-decoration:none;display:inline-flex;align-items:center;gap:4px}
.arch-bar a svg{width:12px;height:12px}
.arch-bar a:hover{color:var(--accent)}
#arch-overlay iframe{flex:1;width:100%;border:0;background:var(--bg)}

/* ---- Dialogs (bridge, settings) ---- */
dialog{margin:auto;background:var(--surface);color:var(--text-body);border:1px solid var(--border);border-radius:12px;padding:0;max-width:none;box-shadow:var(--shadow)}
dialog:focus{outline:none}
/* Backdrop law: dim 20-30%, never blur, one value for every dialog in the app. */
dialog::backdrop{background:rgba(0,0,0,.25)}
#bridgeModal{width:min(94vw,460px)}
.bm-card{padding:22px 22px 18px;text-align:center}
.bm-icon{display:inline-flex;width:44px;height:44px;align-items:center;justify-content:center;border-radius:12px;background:var(--accent-10);color:var(--accent);margin-bottom:8px}
.bm-icon svg{width:24px;height:24px}
.bm-title{font:600 16px var(--brand-heading);color:var(--text);margin-bottom:8px}
.bm-body{font-size:13px;line-height:1.55;color:var(--text-2);margin-bottom:14px}
.bm-hint{font-size:12px;line-height:1.5;color:var(--text-body);background:var(--accent-10);border:1px solid var(--accent-35);border-radius:9px;padding:8px 10px;margin-bottom:14px;text-align:left}
.bm-actions{display:flex;flex-direction:column;gap:9px}
.bm-actions .btn{justify-content:center}

dialog.settings{width:min(680px,92vw);overflow:hidden}
.settings-head{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 14px 12px 18px;border-bottom:1px solid var(--border);cursor:move;user-select:none}
.settings-head h2{margin:0;font:600 15px/1.3 var(--brand-heading);color:var(--text);display:flex;align-items:center;gap:10px}
.settings-head .iconbtn{width:32px;height:32px}
.settings-body{max-height:70vh;overflow:auto;padding:18px}
.settings-card{background:var(--elevated);border:1px solid var(--border);border-left:3px solid color-mix(in srgb,var(--accent) 45%,transparent);border-radius:10px;padding:.9rem 1.1rem 1rem;margin-bottom:1rem;scroll-margin-top:.75rem}
.card-title{font:600 13px/1.3 var(--brand-heading);color:var(--text);display:flex;align-items:center;gap:10px;margin:0 0 .45rem}
.card-status{font:400 11px var(--brand-body);color:var(--text-2)}
.card-status.ok{color:var(--green)}
.card-status.warn{color:var(--yellow)}
.card-status.bad{color:var(--red)}
.card-desc-s{font:12px/1.55 var(--brand-body);color:var(--text-2);max-width:60ch;margin:0 0 .55rem}
.card-desc-s code{font:11px var(--mono);background:rgba(110,118,129,.2);padding:1px 4px;border-radius:4px;color:var(--text-body)}
.divider{height:1px;background:var(--border);margin:.55rem 0}
.wrapper{padding:.5rem 0;display:flex;flex-direction:column;gap:.3rem}
.name{margin:.125rem 0;font:600 .9375rem var(--brand-heading)}
.prefix{color:var(--text)}
.title{color:var(--row-title)}
.default-hint{font:.75rem var(--brand-body);color:var(--text-2)}
.setting-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.set-check-label{font:.875rem var(--brand-body);color:var(--text-body)}
.row-desc{font:12px/1.5 var(--brand-body);color:var(--text-2);max-width:60ch}
.set-input{background:var(--surface);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:6px 10px;font:13px var(--brand-body);min-height:32px;color-scheme:dark}
.set-input.num{width:110px;font-family:var(--mono)}
.toggle{background:none;border:none;padding:2px;flex-shrink:0}
.toggle-track{display:block;width:32px;height:18px;background:var(--overlay);border:1px solid var(--border);border-radius:9px;position:relative;transition:background .15s ease,border-color .15s ease}
.toggle:hover .toggle-track{border-color:var(--accent)}
.toggle.is-on .toggle-track{background:var(--accent);border-color:var(--accent)}
.toggle-thumb{position:absolute;top:2px;left:2px;width:12px;height:12px;background:#fff;border-radius:50%;transition:transform .18s ease,width .12s ease}
.toggle.is-on .toggle-thumb{transform:translateX(14px)}
.toggle:active .toggle-thumb{width:16px}
.toggle.is-on:active .toggle-thumb{transform:translateX(10px)}
.toggle:focus-visible{border-radius:10px}
.comp-row{display:flex;align-items:center;gap:10px;padding:.45rem 0;flex-wrap:wrap}
.comp-row .lab{font:600 13px var(--brand-heading);color:var(--text);min-width:96px}
.comp-row .det{color:var(--text-2);font-size:12px;flex:1;min-width:140px}
.console{display:none;margin:.25rem 0 .5rem;background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:8px 10px;font:11.5px/1.5 var(--mono);color:var(--text-body);max-height:220px;overflow:auto;white-space:pre-wrap;word-break:break-word}
.console.show{display:block}
.console .exit{color:var(--text-2)}
.console .exit.ok{color:var(--green)}.console .exit.bad{color:var(--red)}
.choice-set{border:none;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:.75rem}
.choice{flex:1 1 240px;display:flex;flex-direction:column;gap:.35rem;padding:.6rem;cursor:pointer;position:relative;background:var(--surface);border:1px solid var(--border);border-radius:8px}
.choice input{position:absolute;opacity:0;pointer-events:none}
.choice-shot{display:flex;align-items:center;justify-content:center;min-height:72px;border:1px dashed var(--border);border-radius:6px;background:var(--bg);color:var(--text-2);font:11.5px var(--brand-body);overflow:hidden;padding:6px}
.choice-shot img{display:block;max-width:100%;height:auto;border-radius:4px}
.choice-shot.loaded{border-style:solid}
.choice-name{font:600 .875rem var(--brand-heading);color:var(--text)}
.choice-desc{font:.75rem/1.5 var(--brand-body);color:var(--text-2)}
.choice:hover{border-color:var(--accent);background:var(--elevated)}
.choice:active{transform:translateY(1px) scale(.995)}
.choice[data-selected='true']{border-color:var(--accent);box-shadow:inset 0 0 0 1px var(--accent)}
.choice:focus-within{outline:2px solid var(--accent);outline-offset:2px}
.cred-list{display:flex;flex-direction:column;gap:6px;margin:.4rem 0}
.cred-row{display:flex;align-items:center;gap:10px;padding:7px 10px;background:var(--surface);border:1px solid var(--border);border-radius:8px}
.cred-row code{color:var(--text);font-size:12px}
.cred-row .u{color:var(--text-2);font-size:12px;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis}
.cred-row.pending{opacity:.4}
.inline-err{font-size:12px;color:var(--red);background:var(--red-10);border:1px solid var(--red-35);border-radius:8px;padding:8px 10px;margin:.4rem 0;line-height:1.5}
.inline-err .h{color:var(--text-body);display:block;margin-top:4px}
.settings-footer{margin-top:1.5rem;padding-top:1rem;border-top:1px solid var(--border);display:flex;flex-direction:column;gap:.5rem}
.footer-note{font:.75rem var(--brand-body);color:var(--text-2);margin:0;max-width:60ch}
.restore-btn{width:fit-content;display:inline-flex;align-items:center;gap:.4rem;padding:.4rem .85rem;background:transparent;border:1px solid var(--border);border-radius:.375rem;color:var(--text-body);font:600 .8125rem var(--brand-heading)}
.restore-btn svg{width:18px;height:18px}
.restore-btn:hover{background:var(--elevated)}
.restore-btn.armed{color:var(--red);border-color:var(--red-35);background:var(--red-10)}

/* ---- Toasts (toasts skill): bottom center, stack newest on top, three types, pause on hover ---- */
#toasts{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);display:flex;flex-direction:column-reverse;gap:8px;z-index:var(--z-toast);max-width:min(520px,calc(100vw - 24px));pointer-events:none}
.toast{display:flex;align-items:center;gap:10px;pointer-events:auto;background:var(--overlay);border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:10px;padding:10px 12px 10px 12px;color:var(--text);font-size:13px;box-shadow:var(--shadow);animation:toastin .18s ease}
@media (prefers-reduced-motion:reduce){.toast{animation:none}}
@keyframes toastin{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
.toast.ok{border-left-color:var(--green)}.toast.err{border-left-color:var(--red)}
.toast .ticon{flex:none;display:inline-flex;color:var(--accent)}
.toast.ok .ticon{color:var(--green)}.toast.err .ticon{color:var(--red)}
.toast .ticon svg{width:16px;height:16px}
.toast .tmsg{flex:1;min-width:0;word-break:break-word}
.toast .taction{background:none;border:none;color:var(--blue);font:600 13px var(--brand-body);padding:2px 6px;border-radius:6px;white-space:nowrap}
.toast .taction:hover{background:var(--elevated)}
.toast .tx{background:none;border:none;color:var(--text-2);padding:2px;display:inline-flex;border-radius:6px}
.toast .tx svg{width:14px;height:14px}
.toast .tx:hover{color:var(--text);background:var(--elevated)}

/* ---- Tooltip (tooltips skill): one body-appended fixed div, cursor-relative, clamped both axes ---- */
#tt{position:fixed;z-index:var(--z-tooltip);top:0;left:0;transform:translate(var(--tip-x,0),var(--tip-y,0));max-width:min(320px,calc(100vw - 40px));pointer-events:none;opacity:0;display:none;background:var(--overlay);color:var(--text-body);border:1px solid var(--border);border-radius:10px;padding:8px 12px;font:400 12.5px/1.45 var(--brand-body);box-shadow:var(--shadow);text-transform:none;letter-spacing:0;white-space:pre-wrap}
#tt.visible{display:block}
#tt.shown{opacity:1;transition:opacity .18s ease}
/* Touch: tap or long-press on a tooltip carrier opens this sheet instead (hover does not exist there). */
#infosheet{position:fixed;left:0;right:0;bottom:0;z-index:var(--z-modal);background:var(--surface);border-top:1px solid var(--border);border-radius:14px 14px 0 0;padding:14px 18px calc(18px + env(safe-area-inset-bottom));box-shadow:0 -12px 40px rgba(0,0,0,.5);transform:translateY(100%);transition:transform .22s ease;font-size:14px;line-height:1.5;color:var(--text-body);white-space:pre-wrap}
#infosheet.open{transform:none}
#infosheet .btn{margin-top:12px}

/* ---- Phone (responsive-ui skill): reshape, not shrink ---- */
@media (max-width:720px){
  header{padding:0 8px 0 12px;gap:8px}
  .brand .app-subtitle,.brand .sep,.subject-meta{display:none}
  .actions{gap:2px}
  .iconbtn{width:32px;height:32px}
  .app-body{grid-template-columns:minmax(0,1fr)}
  .content{padding:12px 12px 120px}
  .filterbar{padding:8px 12px}
  .filterbar input[type=search]{min-width:0;flex:1 1 100%;max-width:none;min-height:40px}
  .filterbar select{flex:1 1 40%;min-height:40px}
  .project-pills{margin-left:0;width:100%;flex-wrap:nowrap;overflow-x:auto;padding-bottom:2px;scrollbar-width:none}
  .project-pills::-webkit-scrollbar{display:none}
  .tpill{flex:none}
  .tpill{min-height:36px}
  aside{position:fixed;left:0;right:0;bottom:0;z-index:var(--z-hud);max-height:72vh;border-left:none;border-top:1px solid var(--border);border-radius:14px 14px 0 0;box-shadow:0 -12px 40px rgba(0,0,0,.5);transform:translateY(100%);transition:transform .25s ease;padding-bottom:calc(18px + env(safe-area-inset-bottom))}
  aside.open:not(.empty){transform:none}
  aside.empty{display:none}
  .aside-close{display:inline-flex;width:36px;height:36px}
  .cbtn{width:36px;height:36px}
  .cbtn svg{width:17px;height:17px}
  .pill-act,.pill{min-height:28px;font-size:12px}
  .openbtn,.sendbtn,.btn{min-height:40px}
  .dtab{min-height:40px}
  dialog.settings{width:calc(100vw - 24px)}
  .settings-body{padding:14px}
  .toggle-track{width:40px;height:22px}
  .toggle-thumb{width:16px;height:16px}
  .toggle.is-on .toggle-thumb{transform:translateX(18px)}
  .toggle.is-on:active .toggle-thumb{transform:translateX(14px)}
  .card.zoom-fluid{display:block}
}
@media (max-width:480px){
  .brand .app-name{display:none}
  .vsep{display:none}
}
@media (hover:none),(pointer:coarse){
  #tt{display:none !important}
}
</style>
</head>
<body>
<div class="app">
<header>
  <div class="brand" id="brand" tabindex="0" aria-describedby="app-info">
    <svg class="logo" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="7" y="7" width="10" height="10" rx="1.5"/><path d="M9 7V4M12 7V4M15 7V4M9 20v-3M12 20v-3M15 20v-3M7 9H4M7 12H4M7 15H4M20 9h-3M20 12h-3M20 15h-3"/></svg>
    <span class="app-name">Chip Fetcher <span class="app-cf">(cf)</span></span>
    <span class="sep">&middot;</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">No chip selected</span>
    <span class="state-pill" id="subjectPill"></span>
    <span class="target-chip" id="targetChip" data-tooltip="Which desktop box the Adom Bridge answers on; pup and the taskbar bar run there."><span class="tdot"></span><span id="targetName"></span></span>
    <span class="subject-meta" id="subjectMeta">
      <span class="sep">&middot;</span><span id="metaCards" data-tooltip="Each tile below is a card: one part's full CAD bundle. Tell your AI, for example, open the B2B-PH-K-S card.">0 cards</span>
      <span class="sep">&middot;</span><span id="metaFull" data-tooltip="Cards that have every core file: datasheet, STEP 3D, KiCad symbol and footprint, plus the Altium and Fusion exports.">0 full</span>
      <span class="sep">&middot;</span><span id="metaStubs" data-tooltip="Cards still missing one or more core files. Use the re-fetch menu on a card to fill the gaps.">0 stubs</span>
      <span class="sep">&middot;</span><span id="metaSize" data-tooltip="Total size of the library folder on disk.">0 MB</span>
    </span>
  </div>
  <div class="actions">
    <button class="iconbtn" id="btn-validate" aria-pressed="false" aria-label="Validation" data-tooltip="Validation: a check or cross for every required file of every chip (datasheet, STEP, symbol, footprint and the three thumbnails). It goes green only when truly complete, so done cannot be faked. Click again to return to the cards."><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l8 4v6c0 5-3.5 8.5-8 10-4.5-1.5-8-5-8-10V6z"/><path d="M9 12l2 2 4-4"/></svg></button>
    <button class="iconbtn" id="btn-sources" aria-pressed="false" aria-label="Sourcing order" data-tooltip="Sourcing order: the exact priority chip fetcher uses to search the 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, Ultra Librarian), with LCSC last. Click again to return to the cards."><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16M4 12h16M4 18h10"/></svg></button>
    <button class="iconbtn" id="btn-arch" aria-label="Architecture map" data-tooltip="Architecture map: every tool chip fetcher depends on. Local CLIs that run in your container, native Rust libraries, remote service containers called over HTTPS, and the sub-apps you may also need to modify. Open this before editing chip fetcher."><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 3L3 6v15l6-3 6 3 6-3V3l-6 3-6-3z"/><path d="M9 3v15M15 6v15"/></svg></button>
    <button class="iconbtn" id="btn-reveal-lib" aria-label="Library folder" data-tooltip="Reveal the chip fetcher library folder in the VS Code Explorer sidebar. Opens ~/project/chip-fetcher/library/ so you can see every chip directory."><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><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="iconbtn" id="btn-help" aria-pressed="false" aria-label="Getting started" data-tooltip="Show or hide the newbie panel: the say-it phrases, the component lights, and getting started with its demo fetch."><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M9.5 9a2.5 2.5 0 1 1 3.4 2.33c-.7.28-1.4.84-1.4 1.67v.5"/><path d="M12 17h.01"/></svg></button>
    <button class="iconbtn" id="btn-settings" aria-label="Settings" data-tooltip="Settings: components and their install buttons, the taskbar progress mode, dashboard behaviour, and vendor logins."><!-- the shared Adom cog (adom-ui-design assets/icons/settings-cog-outline.svg), inlined so it follows currentColor; the old filled-path version was a defect in the pack --><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3.2"/><path d="M12 2.6v2.2M12 19.2v2.2M21.4 12h-2.2M4.8 12H2.6M18.6 5.4l-1.6 1.6M7 17l-1.6 1.6M18.6 18.6L17 17M7 7L5.4 5.4"/></svg></button>
  </div>
  <div class="hover-popover" id="app-info" role="dialog" aria-label="App information">
    <div class="popover-title">Chip Fetcher</div>
    <div class="popover-row"><span class="k">about</span><span class="v">Downloads manufacturer-grade chip CAD bundles (STEP 3D, Altium IntLib, Fusion 360 .lbr, KiCad symbol and footprint, datasheet) into a structured library by driving Chrome through pup. Manufacturer-first sourcing, then SnapEDA, Mouser, DigiKey, Ultra Librarian and Component Search Engine.</span></div>
    <div class="popover-row"><span class="k">version</span><span class="v" id="popVersion">?</span></div>
    <div class="popover-row"><span class="k">library</span><span class="v" id="popLibrary">?</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.adom.inc/adom/adom-chip-fetcher" target="_blank" rel="noopener">Open wiki <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4h6v6M20 4l-9 9M18 13v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h6"/></svg></a>
      <a class="popover-link" href="https://github.com/adom-inc/chip-fetcher" target="_blank" rel="noopener">GitHub <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4h6v6M20 4l-9 9M18 13v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h6"/></svg></a>
    </div>
  </div>
</header>

<div class="filterbar" role="toolbar" aria-label="Library filters">
  <input type="search" id="q" placeholder="Filter your library: part number, vendor, package" data-tooltip="Filters the cards already in your library. Fetching a NEW part happens through your AI, not here: copy a phrase from the strip above.">
  <select id="vendor" data-tooltip="Show only one vendor's parts."><option value="">All vendors</option></select>
  <select id="status" data-tooltip="Show only full bundles, only stubs, or cards missing a specific file.">
    <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>
  <div class="project-pills" id="project-tabs" role="tablist" aria-label="Projects" hidden></div>
</div>

    <section class="newbie" id="newbie">
  <div class="newbie-inner">
  <button class="iconbtn newbie-close" id="btn-newbie-hide" aria-label="Close the newbie panel" data-tooltip="Tuck the whole panel away (phrases, component lights, getting started). The ? in the header brings it back."><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12"/></svg></button>
  <section class="say" id="say" aria-labelledby="sayTitle">
    <div class="say-head">
      <h2 id="sayTitle">Say it</h2>
      <span class="say-sub">You never need this page's buttons: just tell your AI what you want, using the word <b>cf</b> (chip fetcher). Copy a prompt, or send it straight to a new AI thread.</span>
    </div>
    <div class="demo-row"><button class="btn-demo-primary" id="btn-tour"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 4l14 8-14 8z"/></svg><span id="btnTourLabel">Play the 1-minute demo</span></button></div>
    <div class="say-list" id="sayList"></div>
    <button class="say-fold" id="sayFold" hidden>Say it: 6 phrases for your AI. Tap to expand.</button>
  </section>
  <div class="leds" id="leds" aria-label="Component status"></div>
  <section class="welcome" id="welcome">
    <div class="welcome-head">
      <h2>Getting started</h2>
    </div>
    <p>Once cf feels familiar, close this whole panel with the X in its corner; the ? in the header brings it back. The library starts with a few sample parts seeded from the Adom Wiki so the cards below are real; delete them any time.</p>
    <ol>
      <li><span class="num">1</span><div>Check the component lights above and fix anything that is not green. The Components card in Settings has an Install button for each one.<br><button class="btn" id="btn-welcome-settings">Open Settings to Components</button></div></li>
      <li><span class="num">2</span><div>Ask your AI for a chip by part number, using the word <b>cf</b>. Try it right now with a serious one, the STM32H757 (a 480 MHz dual-core H7):<br><button class="btn" id="btn-welcome-example">Copy: "get me all the files for the STM32H757XIH6 with cf"</button></div></li>
      <li><span class="num">3</span><div>Cards appear here as parts are fetched. Each card carries the datasheet, the KiCad symbol, the footprint and the 3D model, with one-click open and send-to-EDA buttons. The demo above runs one fetch for you, live.</div></li>
    </ol>
  </section>
  </div>
</section>
<div class="app-body" id="appBody">
  <div class="content" id="content">
    <div id="grid-view"><div class="cards" id="cards"><div class="empty-state">Loading the library</div></div></div>
    <div id="validation-view" class="view" hidden></div>
    <div id="sources-view" class="view" hidden></div>
  </div>
  <aside class="empty" id="detail">Select a card to see its files, details and actions.</aside>
</div>
</div>

<div class="tour-hud" id="tourHud" hidden role="dialog" aria-label="Guided tour">
  <div class="tour-grip" id="tourGrip"><span>Chip Fetcher tour</span><span class="tour-step-num" id="tourNum">1/7</span></div>
  <div class="tour-text" id="tourText"></div>
  <div class="tour-controls">
    <button class="iconbtn" id="tourPrev" aria-label="Previous step" data-tooltip="Previous step"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"/></svg></button>
    <button class="iconbtn" id="tourPlay" aria-label="Pause" data-tooltip="Pause or resume the tour"><svg id="tourPlayIco" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M10 5v14M15 5v14"/></svg></button>
    <button class="iconbtn" id="tourNext" aria-label="Next step" data-tooltip="Next step"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 6l6 6-6 6"/></svg></button>
    <button class="iconbtn" id="tourMute" aria-pressed="false" aria-label="Mute narration" data-tooltip="Mute or unmute the narration. Audio is on by default."><svg id="tourMuteIco" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5L6 9H3v6h3l5 4V5z"/><path d="M15.5 8.5a5 5 0 0 1 0 7M18 6a8 8 0 0 1 0 12"/></svg></button>
    <button class="iconbtn" id="tourExit" aria-label="Exit the tour" data-tooltip="Exit the tour"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12"/></svg></button>
  </div>
</div>
<dialog id="dlgSettings" class="settings" tabindex="-1" aria-labelledby="settingsTitle">
  <div class="settings-head" id="settingsHead">
    <h2 id="settingsTitle">Chip Fetcher Settings <span class="card-status" id="settingsVersion"></span></h2>
    <button class="iconbtn" id="btnCloseSettings" aria-label="Close settings" data-tooltip="Close settings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12"/></svg></button>
  </div>
  <div class="settings-body" id="settingsBody">

    <section class="settings-card" id="card-components">
      <h3 class="card-title">Components <span class="card-status" id="stComponents"></span></h3>
      <p class="card-desc-s">Chip fetcher (cf) is five pieces working together. The <b>Dashboard</b> is this page and its server. The <b>AI skills</b> are what your AI reads to drive the app: the main skill with its 14 sourcing playbooks, the Dock Bar skill, and <b>chip-fetcher-sites</b>, a pack of per-vendor crawl guides (SnapEDA, Mouser, DigiKey, Ultra Librarian and more) that any Adom user can extend with a site they have figured out. The <b>Sister tools</b> are thirteen command-line programs the wiki installs automatically as cf's dependencies (adom-symbol, adom-footprint, adom-step, adom-lbr, adom-chipsmith, adom-chip-thumbnailer, adom-concur, adom-ds2sf, adom-sfconvert, adom-parts-search, adom-mouser, adom-digikey, adom-jlcpcb); nothing to manage, the light only exists to catch a broken install, and it shows how much machinery one cf prompt drives. The <b>Desktop</b> is a connected Adom Bridge box where pup runs Chrome to fetch files. The <b>Library</b> is the folder of fetched parts behind the cards.</p>
      <p class="card-desc-s">These lights are cheap cached readings, not a background loop: file checks answer from disk, the desktop probe runs at most every five minutes, and only while this panel is open does the page re-read everything live (every fifteen seconds, so an Install you just ran turns green in front of you). Install buttons run the real command in the background and stream its output into a console under the row, ending with the exit code. Nothing here is silent.</p>
      <div id="compRows"></div>
    </section>

    <section class="settings-card" id="card-taskbar">
      <h3 class="card-title">Taskbar <span class="card-status" id="stTaskbar"></span></h3>
      <p class="card-desc-s">While your AI runs a part lookup, chip fetcher paints an indeterminate progress bar on the dashboard's Windows taskbar button, so you can see it is working without a false estimate. The bar is driven through the Windows taskbar API on the connected desktop and clears itself the moment the lookup ends.</p>
      <div class="wrapper">
        <h4 class="name" title="taskbar_progress"><span class="prefix">Taskbar: </span><span class="title">Progress during a lookup</span></h4>
        <fieldset class="choice-set" role="radiogroup" aria-labelledby="taskbarLabel">
          <legend id="taskbarLabel" class="set-check-label" style="padding:0;margin-bottom:.35rem">What the dashboard's taskbar button does while a lookup is running.</legend>
          <label class="choice" data-mode="indeterminate" data-selected="false">
            <input type="radio" name="taskbar_progress" value="indeterminate">
            <span class="choice-shot"><img src="" data-shot="taskbar-indeterminate.png" alt="The dashboard's taskbar button with a moving indeterminate progress marquee under the icon"></span>
            <span class="choice-name">Indeterminate bar</span>
            <span class="choice-desc">A moving marquee under the icon for as long as the lookup runs. Says "working", never a made-up percentage.</span>
          </label>
          <label class="choice" data-mode="off" data-selected="false">
            <input type="radio" name="taskbar_progress" value="off">
            <span class="choice-shot"><img src="" data-shot="taskbar-off.png" alt="The same taskbar button with no progress indication of any kind"></span>
            <span class="choice-name">Off</span>
            <span class="choice-desc">The taskbar button never changes. You rely on the header pill and the card ring on this page.</span>
          </label>
        </fieldset>
        <span class="default-hint" id="dhTaskbar">Default: Indeterminate bar</span>
      </div>
    </section>

    <section class="settings-card" id="card-dashboard">
      <h3 class="card-title">Dashboard <span class="card-status" id="stDashboard"></span></h3>
      <p class="card-desc-s">How this page behaves while your AI works. Every switch applies the instant you flip it and is written to the settings file on this container; the page you are looking at is the preview.</p>
      <div class="wrapper">
        <h4 class="name" title="auto_select_active"><span class="prefix">Dashboard: </span><span class="title">Follow the active lookup</span></h4>
        <p class="row-desc">When a heartbeat names a new part, select that card and scroll it into view, so the detail panel always shows the part being fetched. Turn it off if you are inspecting one card while a long batch runs.</p>
        <div class="setting-row"><button class="toggle" id="tglAutoSelect" role="switch" aria-checked="true" aria-label="Follow the active lookup"><span class="toggle-track"><span class="toggle-thumb"></span></span></button><span class="set-check-label">Select and scroll to the card being fetched</span></div>
        <span class="default-hint" id="dhAutoSelect">Default: On</span>
      </div>
      <div class="divider"></div>
      <div class="wrapper">
        <h4 class="name" title="auto_zoom"><span class="prefix">Dashboard: </span><span class="title">Auto-zoom the active card</span></h4>
        <p class="row-desc">Zoom the active card into the centred overlay whenever the AI moves to a new part, so you can watch progress at a readable size without clicking each card. Press Escape or the X to drop back to the grid at any time.</p>
        <div class="setting-row"><button class="toggle" id="tglAutoZoom" role="switch" aria-checked="false" aria-label="Auto-zoom the active card"><span class="toggle-track"><span class="toggle-thumb"></span></span></button><span class="set-check-label">Zoom the card being fetched</span></div>
        <span class="default-hint" id="dhAutoZoom">Default: Off</span>
      </div>
      <div class="divider"></div>
      <div class="wrapper">
        <h4 class="name" title="show_stock_badges"><span class="prefix">Dashboard: </span><span class="title">Stock and lifecycle badges on cards</span></h4>
        <p class="row-desc">Show a Mouser stock pill, and an NRND or end-of-life warning, on each card that has distributor data. With it off the cards stay lean and the same numbers remain in the detail panel's Status tab.</p>
        <div class="setting-row"><button class="toggle" id="tglStock" role="switch" aria-checked="true" aria-label="Stock and lifecycle badges on cards"><span class="toggle-track"><span class="toggle-thumb"></span></span></button><span class="set-check-label">Show stock and lifecycle pills on cards</span></div>
        <span class="default-hint" id="dhStock">Default: On</span>
      </div>
      <div class="divider"></div>
      <div class="wrapper">
        <h4 class="name" title="activity_stale_seconds"><span class="prefix">Dashboard: </span><span class="title">Heartbeat considered stale after</span></h4>
        <p class="row-desc">Fetch jobs post heartbeats with no terminal state, so the page stops showing "Fetching" once the last heartbeat is older than this many seconds. Raise it for slow vendor sites, lower it if a finished lookup lingers.</p>
        <div class="setting-row"><input class="set-input num" id="inpStale" type="number" min="10" max="3600" step="5" aria-label="Heartbeat considered stale after, in seconds"><span class="set-check-label">seconds</span></div>
        <span class="default-hint" id="dhStale">Default: 90</span>
      </div>
    </section>

    <section class="settings-card" id="card-logins">
      <h3 class="card-title">Logins <span class="card-status" id="stLogins"></span></h3>
      <p class="card-desc-s">Some vendor portals (Component Search Engine, Ultra Librarian, SnapEDA, TI, ST, Microchip) only hand out CAD files to a signed-in browser. pup keeps those logins in the desktop's OS keychain and fills them in the background when a lookup lands on a login page; chip fetcher stores none of them and never sees a password. This list shows host and username only.</p>
      <div id="credList" class="cred-list"></div>
      <div class="divider"></div>
      <div class="wrapper">
        <h4 class="name" title="import_logins"><span class="prefix">Logins: </span><span class="title">Import my browser logins</span></h4>
        <p class="row-desc">Copies the saved passwords from your desktop's Chrome or Edge into pup's vault. Expect one consent dialog and one UAC prompt on the desktop; the output streams below.</p>
        <div class="setting-row"><button class="btn" id="btnImportLogins">Import my browser logins</button></div>
        <div class="console" id="conImport"></div>
      </div>
      <div class="divider"></div>
      <div class="wrapper">
        <h4 class="name" title="login"><span class="prefix">Logins: </span><span class="title">Sign in to a vendor</span></h4>
        <p class="row-desc">Opens that vendor's sign-in page in the chip fetcher pup profile on your desktop and asks pup to fill the saved login. If there is none yet, sign in there once and pup captures it.</p>
        <div class="setting-row">
          <select class="set-input" id="selLogin" aria-label="Vendor to sign in to">
            <option value="google">Google</option><option value="ti">TI</option><option value="st">ST</option><option value="microchip">Microchip</option><option value="ultralib">Ultra Librarian</option><option value="snapeda">SnapEDA</option><option value="componentsearch">Component Search Engine</option>
          </select>
          <button class="btn" id="btnLogin">Sign in</button>
        </div>
      </div>
    </section>

    <footer class="settings-footer">
      <p class="footer-note">Settings are stored in <code>~/.config/adom-chip-fetcher</code> on this container. Restoring returns every option above to its default.</p>
      <button class="restore-btn" id="btnRestore" type="button"><svg viewBox="0 0 24 24" aria-hidden="true"><path id="restoreIcon" fill="currentColor" d="M13 3a9 9 0 0 0-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42A8.954 8.954 0 0 0 13 21a9 9 0 0 0 0-18m-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8z"/></svg><span id="restoreLabel">Restore defaults</span></button>
    </footer>
  </div>
</dialog>

<dialog id="bridgeModal" aria-labelledby="bmTitle"><div class="bm-card" id="bmCard"></div></dialog>
<div id="toasts" aria-live="polite"></div>
<div id="tt" role="tooltip"></div>
<div id="infosheet" role="dialog" aria-label="Explanation"><div id="infosheetText"></div><button class="btn" id="infosheetClose">Close</button></div>
<script>
'use strict';
const $ = s => document.querySelector(s);
const $id = id => document.getElementById(id);
function escapeHtml(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[m]); }
// Relative paths so the page works behind /proxy/<port>/ and inside a webview.
const API_BASE = location.pathname.replace(/index\.html$/, '').replace(/\/$/, '');
const UI_BUILD = '__CF_VERSION__'; // stamped by the server at serve time; the page reports it on load so a stale tab is provable
const TRY_IT_PARKED = true; // ship-night posture: no /ai-threads calls; Try it copies instead (John's ruling, 2026-08-23)
function copyViaTextarea(t) {
  // Must run synchronously inside the click gesture: execCommand('copy')
  // only works during a user gesture.
  try {
    const ta = document.createElement('textarea');
    ta.value = t; ta.style.position = 'fixed'; ta.style.opacity = '0';
    document.body.appendChild(ta); ta.select();
    const ok = document.execCommand('copy'); ta.remove(); return ok;
  } catch (e) { return false; }
}
async function copyText(t) {
  // Unfocused windows (a background pup tab, a webview panel) make
  // navigator.clipboard.writeText HANG with "document is not focused"
  // (parts-search 1.1.2 finding). Route synchronously by focus, and race
  // the async path at 800 ms so a hung promise never eats the gesture.
  if (!document.hasFocus()) return copyViaTextarea(t);
  try {
    const ok = await Promise.race([
      navigator.clipboard.writeText(t).then(() => true),
      new Promise(res => setTimeout(() => res('timeout'), 800)),
    ]);
    if (ok === true) return true;
  } catch (e) {}
  return copyViaTextarea(t);
}
const DASH = '\u2014'; // the data sentinel inventory.rs emits for "unknown"; never shown to a person
const isPhone = () => matchMedia('(hover: none)').matches || window.innerWidth < 720;

// ---- Monochrome 24-viewBox stroke icons (currentColor). No emoji, ever. ----
const ICONS = {
  check: '<polyline points="20 6 9 17 4 12"/>',
  x: '<path d="M18 6L6 18M6 6l12 12"/>',
  warn: '<path d="M12 9v4M12 17h.01M10.3 3.9L2.4 17.5A2 2 0 0 0 4.1 20.5h15.8a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/>',
  info: '<circle cx="12" cy="12" r="9"/><path d="M12 8h.01M11 12h1v4h1"/>',
  refresh: '<path d="M21 12a9 9 0 1 1-2.64-6.36M21 3v6h-6"/>',
  image: '<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/>',
  download: '<path d="M12 3v12M7 10l5 5 5-5M4 21h16"/>',
  folder: '<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"/>',
  zoom: '<circle cx="11" cy="11" r="7"/><path d="M21 21l-3.5-3.5M11 8v6M8 11h6"/>',
  chevron: '<path d="M6 9l6 6 6-6"/>',
  cube: '<path d="M12 2l9 5v10l-9 5-9-5V7z"/><path d="M12 12l9-5M12 12v10M12 12L3 7"/>',
  doc: '<path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><path d="M14 3v6h6"/>',
  pkg: '<path d="M21 8l-9-5-9 5v8l9 5 9-5z"/><path d="M3 8l9 5 9-5M12 13v8"/>',
  ext: '<path d="M14 4h6v6M20 4l-9 9M18 13v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h6"/>',
  trash: '<path d="M3 6h18M8 6V4h8v2M6 6l1 14h10l1-14"/>',
  bridge: '<path d="M3 18v-6a9 9 0 0 1 18 0v6M3 14h18M7 18v-4M12 18v-9M17 18v-4"/>',
  pin: '<circle cx="12" cy="12" r="3"/><path d="M12 2v4M12 18v4M2 12h4M18 12h4"/>',
  axis: '<path d="M12 20V6M8 10l4-4 4 4M4 20h16"/>',
};
function icon(name, size) { return `<svg viewBox="0 0 24 24" width="${size||16}" height="${size||16}" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICONS[name] || ''}</svg>`; }

// ---- Console relay: every console line and error also lands in GET /console (20/s cap, never recursive). ----
(function () {
  const orig = { log: console.log, warn: console.warn, error: console.error };
  const nativeFetch = window.fetch.bind(window);
  let bucket = 0, bucketAt = Date.now();
  const fmt = a => { if (a instanceof Error) return a.message + (a.stack ? ' ' + a.stack.split('\n')[1] : ''); if (typeof a === 'object') { try { return JSON.stringify(a).slice(0, 600); } catch (e) { return String(a); } } return String(a); };
  function post(level, msg) {
    const now = Date.now();
    if (now - bucketAt > 1000) { bucket = 0; bucketAt = now; }
    if (++bucket > 20) return;
    try { nativeFetch(API_BASE + '/console', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ level, msg: String(msg).slice(0, 2000) }), keepalive: true }).catch(() => {}); } catch (e) {}
  }
  ['log', 'warn', 'error'].forEach(k => { console[k] = function (...a) { try { orig[k].apply(console, a); } catch (e) {} post(k, a.map(fmt).join(' ')); }; });
  window.addEventListener('error', e => post('error', 'onerror: ' + (e.message || e) + ' @' + (e.filename || '').split('/').pop() + ':' + (e.lineno || '')));
  window.addEventListener('unhandledrejection', e => post('error', 'unhandledrejection: ' + fmt(e.reason)));
})();

// ---- Toasts (toasts skill): cfToast(msg, {type:'ok'|'err'|'info', action:{label,fn}, ttl, onExpire}) ----
const TOAST_CAP = 4;
function cfToast(msg, opts) {
  opts = opts || {};
  const type = opts.type === 'success' ? 'ok' : (opts.type === 'error' ? 'err' : (opts.type || 'info'));
  const host = $id('toasts');
  while (host.children.length >= TOAST_CAP) host.firstElementChild.remove();
  const t = document.createElement('div');
  t.className = 'toast ' + type;
  t.setAttribute('role', type === 'err' ? 'alert' : 'status');
  t.setAttribute('aria-live', type === 'err' ? 'assertive' : 'polite');
  t.innerHTML = `<span class="ticon">${icon(type === 'ok' ? 'check' : type === 'err' ? 'warn' : 'info')}</span><span class="tmsg"></span>`;
  t.querySelector('.tmsg').textContent = msg;
  if (opts.href) { const a = document.createElement('a'); a.className = 'taction'; a.href = opts.href; a.target = '_blank'; a.rel = 'noopener'; a.textContent = opts.hrefLabel || 'Open'; t.appendChild(a); }
  let expired = false, undone = false;
  const done = () => { if (expired) return; expired = true; t.remove(); if (!undone && opts.onExpire) opts.onExpire(); };
  if (opts.action) { const b = document.createElement('button'); b.className = 'taction'; b.textContent = opts.action.label; b.onclick = () => { undone = true; expired = true; t.remove(); opts.action.fn(); }; t.appendChild(b); }
  const x = document.createElement('button'); x.className = 'tx'; x.setAttribute('aria-label', 'Dismiss'); x.innerHTML = icon('x'); x.onclick = done; t.appendChild(x);
  const ttl = opts.ttl != null ? opts.ttl : (type === 'err' ? 0 : (opts.action ? 6000 : 4000));
  let timer = null, left = ttl, started = 0;
  const arm = () => { if (!ttl) return; started = Date.now(); timer = setTimeout(done, left); };
  t.addEventListener('mouseenter', () => { if (timer) { clearTimeout(timer); timer = null; left -= Date.now() - started; } });
  t.addEventListener('mouseleave', () => { if (ttl && !timer && !expired) arm(); });
  host.appendChild(t);
  arm();
  return t;
}

// ---- Tooltips (tooltips skill): body-level fixed div, 600 ms reveal, cursor-relative, clamped on both axes. ----
const tip = $id('tt');
let _cx = 0, _cy = 0, tipTimer = null, tipEl = null;
document.addEventListener('mousemove', e => { _cx = e.clientX; _cy = e.clientY; }, true);
function pickAnchor(trigger, tipW, tipH, cx, cy) {
  const O = 14, M = 8, vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight;
  const tr = trigger.getBoundingClientRect();
  const cands = [{ x: cx + O, y: cy + O }, { x: cx - tipW - O, y: cy + O }, { x: cx + O, y: cy - tipH - O }, { x: cx - tipW - O, y: cy - tipH - O }];
  const score = c => {
    let p = 0;
    if (c.x < M || c.x + tipW > vw - M) p += 1000;
    if (c.y < M || c.y + tipH > vh - M) p += 1000;
    const ox = Math.max(0, Math.min(c.x + tipW, tr.right) - Math.max(c.x, tr.left));
    const oy = Math.max(0, Math.min(c.y + tipH, tr.bottom) - Math.max(c.y, tr.top));
    return p + ox * oy;
  };
  const best = cands.map(c => ({ c, s: score(c) })).sort((a, b) => a.s - b.s)[0].c;
  best.x = Math.max(M, Math.min(best.x, vw - tipW - M));
  best.y = Math.max(M, Math.min(best.y, vh - tipH - M));
  return best;
}
function showTip(trigger) {
  if (isPhone()) return;
  tip.textContent = trigger.getAttribute('data-tooltip') || '';
  if (!tip.textContent) return;
  tip.classList.add('visible');
  const { x, y } = pickAnchor(trigger, tip.offsetWidth, tip.offsetHeight, _cx, _cy);
  tip.style.setProperty('--tip-x', x + 'px'); tip.style.setProperty('--tip-y', y + 'px');
  requestAnimationFrame(() => tip.classList.add('shown'));
}
function hideTip() { clearTimeout(tipTimer); tipTimer = null; tipEl = null; tip.classList.remove('visible', 'shown'); }
document.addEventListener('mouseover', e => {
  const t = e.target.closest('[data-tooltip]');
  if (!t) { hideTip(); return; }
  if (t === tipEl) return;
  hideTip(); tipEl = t;
  tipTimer = setTimeout(() => showTip(t), 600);
});
document.addEventListener('mousedown', hideTip, true);
document.addEventListener('click', hideTip, true);
window.addEventListener('scroll', hideTip, { passive: true, capture: true });
// Touch: a tap on a non-interactive carrier (badge, pill, label) or a 450 ms press on anything opens the info sheet.
(function () {
  const sheet = $id('infosheet'), txt = $id('infosheetText');
  let pressT = null, moved = false;
  const open = t => { txt.textContent = t.getAttribute('data-tooltip') || ''; sheet.classList.add('open'); };
  $id('infosheetClose').onclick = () => sheet.classList.remove('open');
  document.addEventListener('touchstart', e => {
    const t = e.target.closest('[data-tooltip]'); moved = false; clearTimeout(pressT);
    if (!t) return;
    pressT = setTimeout(() => { if (!moved) { open(t); if (navigator.vibrate) navigator.vibrate(8); } }, 450);
  }, { passive: true });
  document.addEventListener('touchmove', () => { moved = true; clearTimeout(pressT); }, { passive: true });
  document.addEventListener('touchend', () => clearTimeout(pressT), { passive: true });
  document.addEventListener('click', e => {
    if (!isPhone()) return;
    const t = e.target.closest('[data-tooltip]');
    if (t && !t.closest('button, a, select, input, .thumb, .choice, .toggle')) { open(t); e.stopPropagation(); }
    else if (!sheet.contains(e.target)) sheet.classList.remove('open');
  }, true);
})();

// ---- One floating menu for the whole app (refetch, orientation, source picker), clamped to the viewport. ----
let FLOAT = null;
function closeMenu() { if (FLOAT) { FLOAT.remove(); FLOAT = null; } }
function openMenu(anchor, html, opts) {
  closeMenu();
  const el = document.createElement('div'); el.className = 'floating'; el.innerHTML = html;
  if (opts && opts.width) el.style.width = opts.width + 'px';
  document.body.appendChild(el); FLOAT = el;
  const r = anchor.getBoundingClientRect(), M = 8, vw = innerWidth, vh = innerHeight;
  const w = el.offsetWidth, h = el.offsetHeight;
  let x = r.left, y = r.bottom + 6;
  if (y + h > vh - M) y = Math.max(M, r.top - h - 6);
  x = Math.max(M, Math.min(x, vw - w - M)); y = Math.max(M, Math.min(y, vh - h - M));
  el.style.left = x + 'px'; el.style.top = y + 'px';
  el.addEventListener('click', e => e.stopPropagation());
  return el;
}
document.addEventListener('click', e => { if (FLOAT && !FLOAT.contains(e.target)) closeMenu(); });
document.addEventListener('keydown', e => { if (e.key === 'Escape' && FLOAT) closeMenu(); });
window.addEventListener('resize', closeMenu);

// ---- Header popover: 600 ms show, 220 ms hide grace, reachable with the pointer. ----
(function () {
  const trigger = $id('brand'), target = $id('app-info');
  let showTimer = null, hideTimer = null;
  const show = () => { clearTimeout(hideTimer); if (target.classList.contains('show')) return; clearTimeout(showTimer); showTimer = setTimeout(() => target.classList.add('show'), 600); };
  const queueHide = () => { clearTimeout(showTimer); clearTimeout(hideTimer); hideTimer = setTimeout(() => target.classList.remove('show'), 220); };
  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'); });
})();

// ---- State. The server copy (GET /state) is the truth for the AI; the page reports its own view through POST /state/ui. ----
let LIB = null, SELECTED = null, DETAIL_TAB = 'actions';
let ACTIVE_MPN = null, ACTIVE_STAGE = '', PROJECTS = null, ACTIVE_PROJECT_FILTER = null;
let VIEW = 'grid', ZOOM_MPN = null, STATE_VERSION = null, STATUS = null, SERVER_VERSION = '';
const SETTINGS_DEFAULTS = { taskbar_progress: 'indeterminate', auto_zoom: false, auto_select_active: true, show_stock_badges: true, activity_stale_seconds: 90 };
let SETTINGS = Object.assign({}, SETTINGS_DEFAULTS), SETTINGS_META = { defaults: SETTINGS_DEFAULTS, labels: {}, enums: {} };
// Per-chip+kind source the user picked in the provenance chip (e.g. "ultralibrarian"). Keyed "mpn|kind".
let SRC_SEL = (() => { try { return JSON.parse(localStorage.getItem('cf_src_sel')) || {}; } catch (e) { return {}; } })();
function saveSrcSel() { try { localStorage.setItem('cf_src_sel', JSON.stringify(SRC_SEL)); } catch (e) {} }
function activeMethod(mpn, kind, sources) {
  const pick = SRC_SEL[mpn + '|' + kind];
  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);
}
function chipByMpn(mpn) { return ((LIB && LIB.chips) || []).find(x => x.mpn === mpn); }
async function api(path, opts) { const r = await fetch(API_BASE + path, opts); return r.json(); }
function postJson(path, body, extra) { return fetch(API_BASE + path, Object.assign({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }, extra || {})); }

let reportT = null;
function reportUi() {
  clearTimeout(reportT);
  reportT = setTimeout(() => { postJson('/state/ui', { selected_mpn: SELECTED, view: VIEW, settings_open: $id('dlgSettings').open, zoom_mpn: ZOOM_MPN }).catch(() => {}); }, 80);
}

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'; }

// Header subject zone: the part being fetched or selected, its state pill, and library counts.
function updateSubject() {
  const nameEl = $id('subjectName'), pillEl = $id('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', 'Your AI is fetching this part right now. Stage: ' + (ACTIVE_STAGE || 'starting'));
    } else {
      const chip = chipByMpn(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 and 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 or datasheet). Use the re-fetch button on the card to fetch the rest.' : 'Unknown part'); }
    }
  } else {
    nameEl.textContent = 'No chip selected'; nameEl.classList.add('idle'); pillEl.classList.remove('show');
  }
  if (LIB) {
    $id('metaCards').textContent = (LIB.fully_count + LIB.stub_count) + ' cards';
    $id('metaFull').textContent = LIB.fully_count + ' full';
    $id('metaStubs').textContent = LIB.stub_count + ' stubs';
    $id('metaSize').textContent = (LIB.total_size / 1024 / 1024).toFixed(0) + ' MB';
    $id('popLibrary').textContent = LIB.chips.length + ' parts, ' + LIB.fully_count + ' full';
  }
}

async function loadLibrary() {
  const newLib = await api('/api/library');
  const firstLoad = !LIB;
  LIB = newLib;
  const vsel = $id('vendor'), keep = vsel.value;
  const vendors = [...new Set(LIB.chips.map(c => c.vendor).filter(v => v && v !== '?' && v !== DASH))].sort();
  vsel.innerHTML = '<option value="">All vendors</option>' + vendors.map(v => `<option value="${escapeHtml(v)}">${escapeHtml(v)}</option>`).join('');
  vsel.value = keep;
  applyNewbiePanel();
  // The detail pane teaches by example: with nothing selected yet, open the
  // first sample card (or the first card) instead of an empty pane.
  if (!SELECTED && LIB.chips.length && !isPhone()) {
    const sample = LIB.chips.find(c => c.info && c.info.sample) || LIB.chips[0];
    select(sample.mpn);
  }
  updateSubject(); render(); renderProjectTabs();
  if (firstLoad) loadProjects();
  if (ACTIVE_MPN) { scrollActiveCardIntoView(); autoZoomIfEnabled(ACTIVE_MPN); }
  if (SELECTED && chipByMpn(SELECTED)) select(SELECTED, true);
}

async function loadProjects() {
  try {
    const r = await fetch(API_BASE + '/api/projects');
    if (!r.ok) return;
    PROJECTS = await r.json();
    if (!window._projInit) {
      window._projInit = true;
      if (PROJECTS && PROJECTS.active && PROJECTS.projects && PROJECTS.projects[PROJECTS.active]) ACTIVE_PROJECT_FILTER = PROJECTS.active;
    }
    renderProjectTabs(); render(); applyDeepLink();
  } catch (e) {}
}

// Deep links: ?project=<name>&chip=<MPN>&source=<method>&zoom=1
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'), 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 = $id('project-tabs');
  if (!PROJECTS || !PROJECTS.projects || !Object.keys(PROJECTS.projects).length) { bar.hidden = true; return; }
  bar.hidden = false; bar.innerHTML = '';
  const mk = (label, n, key, tipText) => {
    const b = document.createElement('button'); b.className = 'tpill' + (ACTIVE_PROJECT_FILTER === key ? ' active' : '');
    b.setAttribute('role', 'tab'); b.setAttribute('aria-selected', String(ACTIVE_PROJECT_FILTER === key)); b.setAttribute('data-tooltip', tipText);
    b.innerHTML = `${escapeHtml(label)}<span class="n">${n}</span>`;
    b.onclick = () => { ACTIVE_PROJECT_FILTER = key; renderProjectTabs(); render(); };
    bar.appendChild(b);
  };
  mk('Library', LIB ? LIB.chips.length : 0, null, 'Show every part in the library.');
  for (const [name, proj] of Object.entries(PROJECTS.projects)) mk(name, proj.mpns.length, name, 'Show only the parts in the ' + name + ' project' + (proj.description ? ': ' + proj.description : '.'));
}

// ---- Live channel: one 1 s poll of /api/activity carries the heartbeat, server toasts, UI commands and the library version. ----
let activityTimer = null;
async function pollActivity() {
  try {
    const a = await api('/api/activity');
    for (const t of (a._toasts || [])) cfToast(t.message, { type: t.kind });
    for (const c of (a._ui || [])) handleUiCommand(c);
    if (a._state_version != null && a._state_version !== STATE_VERSION) {
      const first = STATE_VERSION == null; STATE_VERSION = a._state_version;
      if (!first) loadLibrary().catch(e => console.warn('library refresh failed', e));
    }
    const mpn = a && a.mpn ? a.mpn : null, stage = a && a.stage ? a.stage : '';
    const age = (a && typeof a._age_seconds === 'number') ? a._age_seconds : null;
    const terminal = !stage || stage === 'idle' || stage === 'done' || stage === 'thumbnails ready';
    const stale = age != null && age > (Number(SETTINGS.activity_stale_seconds) || 90);
    const working = mpn && !terminal && !stale;
    if (working) {
      const stageChanged = stage !== ACTIVE_STAGE; ACTIVE_STAGE = stage;
      if (mpn !== ACTIVE_MPN) {
        ACTIVE_MPN = mpn; render(); updateSubject(); scrollActiveCardIntoView();
        if (SETTINGS.auto_select_active && chipByMpn(mpn)) select(mpn);
        autoZoomIfEnabled(mpn);
      } else if (stageChanged) updateSubject();
    } else if (ACTIVE_MPN !== null) { ACTIVE_MPN = null; ACTIVE_STAGE = ''; render(); updateSubject(); }
  } catch (e) { /* transient */ }
}
function handleUiCommand(c) {
  try {
    switch (c.cmd) {
      case 'toast': cfToast(c.message || '', { type: c.kind }); break;
      case 'click': { const el = $id(c.id); if (el) { el.click(); } else cfToast('No element with id ' + c.id, { type: 'err' }); break; }
      case 'select': if (c.mpn) { select(c.mpn); const card = document.querySelector(`.card[data-mpn="${CSS.escape(c.mpn)}"]`); if (card) card.scrollIntoView({ behavior: 'smooth', block: 'center' }); } break;
      case 'view': setView(c.view || 'grid'); break;
      case 'open_settings': openSettings(c.card); break;
      case 'close_settings': $id('dlgSettings').close(); break;
      case 'zoom': { const card = c.mpn && document.querySelector(`.card[data-mpn="${CSS.escape(c.mpn)}"]`); if (card) zoomCard(card); else cfToast('No card for ' + c.mpn, { type: 'err' }); break; }
      case 'unzoom': dismissZoom(); break;
      case 'reload': location.reload(); break;
      default: console.warn('unknown ui command', c);
    }
  } catch (e) { console.error('ui command failed', c.cmd, e); }
}

// ---- "Say it": phrases come from dockbar.json via /api/say so the dock and this strip teach the same words. ----
async function buildSay() {
  let phrases = [];
  try { phrases = (await api('/api/say')).phrases || []; } catch (e) {}
  const list = $id('sayList'); list.innerHTML = '';
  if (!phrases.length) { $id('say').hidden = true; return; }
  $id('say').hidden = false;
  for (const p of phrases) {
    const item = document.createElement('div'); item.className = 'say-item';
    const b = document.createElement('button'); b.className = 'say-phrase'; b.type = 'button';
    b.setAttribute('data-tooltip', 'Copy this prompt to your clipboard');
    b.setAttribute('aria-label', 'Copy this prompt to your clipboard');
    b.innerHTML = '&ldquo;' + escapeHtml(p).replace(/chip ?fetcher|\bcf\b/gi, m => '<em>' + m + '</em>') + '&rdquo;';
    const doCopy = async () => {
      if (await copyText(p)) cfToast('Copied to clipboard. Paste it into any AI thread.', { type: 'ok' });
      else cfToast('Could not reach the clipboard. Select the prompt and copy it by hand.', { type: 'err' });
    };
    b.onclick = doCopy;
    // Icon + tooltip + toast is one contract (newbie-panel skill): an icon
    // gives none of the confirmation a labelled button does, so both of the
    // others are mandatory on every click.
    const copyBtn = document.createElement('button');
    copyBtn.className = 'say-iconbtn'; copyBtn.type = 'button';
    copyBtn.setAttribute('data-tooltip', 'Copy this prompt to your clipboard');
    copyBtn.setAttribute('aria-label', 'Copy this prompt to your clipboard');
    copyBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></svg>';
    copyBtn.onclick = doCopy;
    const sendBtn = document.createElement('button');
    sendBtn.className = 'say-iconbtn'; sendBtn.type = 'button';
    sendBtn.setAttribute('data-tooltip', 'Send this prompt to a new AI thread');
    sendBtn.setAttribute('aria-label', 'Send this prompt to a new AI thread');
    sendBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 3 10.5 13.5"/><path d="M21 3l-6.8 18-3.7-7.5L3 9.8z"/></svg>';
    sendBtn.onclick = async () => {
      sendBtn.disabled = true;
      try {
        const r = await (await postJson('/api/say-send', { phrase: p })).json();
        if (r.ok) { cfToast('Sent to a new AI thread.', { type: 'ok' }); gradSave({ aiDriven: true }); applyGraduation(); }
        else {
          if (await copyText(p)) cfToast((r.why || 'Could not reach the AI panel') + '; copied instead.', { type: 'err' });
          else cfToast(r.why || 'Could not reach the AI panel.', { type: 'err' });
        }
      } catch (e) {
        if (await copyText(p)) cfToast('Could not reach the AI panel; copied instead.', { type: 'err' });
      } finally { sendBtn.disabled = false; }
    };
    item.appendChild(b); item.appendChild(copyBtn); item.appendChild(sendBtn); list.appendChild(item);
  }
}

// ---- Component LEDs: the compact row under the Say-it strip, and the Components card in Settings. ----
const LED_ORDER = ['dashboard', 'skill', 'tools', 'bridge', 'library'];
function ledKeys(st) { const leds = (st && st.leds) || {}; return LED_ORDER.filter(k => leds[k]).concat(Object.keys(leds).filter(k => !LED_ORDER.includes(k))); }
function renderLeds() {
  const host = $id('leds'); host.innerHTML = '';
  if (!STATUS || !STATUS.leds) return;
  for (const k of ledKeys(STATUS)) {
    const l = STATUS.leds[k];
    const el = document.createElement('div'); el.className = 'ledchip';
    el.setAttribute('data-tooltip', (l._hint || '') + (l.state !== 'ok' ? '\nOpen Settings to see the Install button for this component.' : ''));
    el.innerHTML = `<span class="led ${escapeHtml(l.state)}"></span><span class="lab">${escapeHtml(l.label || k)}</span><span class="det">${escapeHtml(l.detail || '')}</span>`;
    el.onclick = () => {
      const open = el.classList.toggle('expanded');
      let d = el.nextElementSibling;
      if (open) {
        d = document.createElement('div'); d.className = 'led-detail';
        d.innerHTML = escapeHtml((l.detail || '') + '. ' + (l._hint || '')) + (l.state !== 'ok' ? ' <button class="btn" data-goto>Open Settings to fix it</button>' : '');
        el.after(d);
        const g = d.querySelector('[data-goto]'); if (g) g.onclick = (e) => { e.stopPropagation(); openSettings('card-components'); };
      } else if (d && d.classList.contains('led-detail')) d.remove();
    };
    el.style.cursor = 'pointer';
    host.appendChild(el);
  }
}
async function loadStatus(force) {
  try {
    STATUS = await api('/api/status' + (force ? '?force=1' : ''));
    if (STATUS.version) { SERVER_VERSION = STATUS.version; $id('popVersion').textContent = 'v' + SERVER_VERSION; $id('settingsVersion').textContent = 'v' + SERVER_VERSION; }
    renderLeds(); renderComponents();
  } catch (e) { console.warn('status failed', e); }
}

// ---- Provenance helpers ----
function srcTier(src) { src = src || ''; 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 === DASH) return '?'; return src.replace('-fallback', '').replace('mouser-', 'mouser/').replace('mfr-', ''); }
function fileSource(c, extRe, fallbackField, opts) {
  opts = opts || {};
  const fp = c.file_provenance, info = c.info || {};
  const siteOf = url => { try { return (new URL(url)).hostname.replace(/^www\./, ''); } catch (e) { return ''; } };
  const MFR = s => s === 'manufacturer' || s === 'mfr' || s === 'vendor';
  const KIC = s => /^kicad(-library|_community)?$/.test(s);
  const AGG = s => KIC(s) || ['cse', 'componentsearch', 'cse_ul', 'samacsys', 'snapeda', 'snapmagic', 'ultralibrarian', 'ul', 'mouser', 'digikey', 'arrow', 'lcsc'].includes(s);
  function labelFor(e) {
    let made = (e.content_origin || '').toLowerCase(), found = (e.discovery_source || '').toLowerCase();
    const site = siteOf(e.url || '');
    if (made === 'unknown') made = ''; if (found === 'unknown') found = '';
    if (opts.requireManufacturer && (AGG(found) || AGG(made))) return null;
    if (made === 'adom-wiki' || found === 'adom-wiki') return 'Adom Wiki' + (site ? ', ' + site : '');
    if (KIC(made) || KIC(found)) return 'KiCad standard library';
    if (MFR(made) || MFR(found)) return site ? 'manufacturer, ' + site : 'manufacturer (site unrecorded)';
    if (site && (made || found)) return (made || found) + ', ' + site;
    if (made || found) { let lbl = made || found; if (made && found && found !== made) lbl = made + ' (via ' + found + ')'; return lbl; }
    return site || '';
  }
  if (fp && typeof fp === 'object') for (const fn in fp) { if (!extRe.test(fn)) continue; const l = labelFor(fp[fn] || {}); if (l) return { label: l, found: true }; }
  if (fallbackField && !opts.requireManufacturer) { const v = (c[fallbackField] || '').trim(); if (v && v !== DASH && v.toLowerCase() !== 'none') return { label: v, found: true }; }
  if (!opts.requireManufacturer) for (const e of (info.fetched_via_chain || [])) { const o = (e.outcome || '').toLowerCase(); if (o.includes('ok') || o.includes('success') || o === 'got it') return { label: e.source || '?', found: true }; }
  if (opts.requireManufacturer) return { label: "not from the manufacturer yet; re-fetch the datasheet from the maker's site", found: false };
  return { label: 'not recorded; re-fetch to capture it', found: false };
}
function resolveStepSource(c) { return fileSource(c, /\.step$/i, 'step_source'); }
function src3dChip(c) {
  if (!c.has_step) return '';
  const r = resolveStepSource(c), s = (r.label || '').toLowerCase();
  let short = r.label;
  if (/manufacturer|mfr|direct/.test(s)) short = 'Mfr direct'; else if (/adom wiki/.test(s)) short = 'Adom'; else if (/kicad/.test(s)) short = 'KiCad lib'; else if (/ultralib/.test(s)) short = 'Ultra Librarian';
  else if (/snap/.test(s)) short = 'SnapMagic'; else if (/mouser|cse|samacsys/.test(s)) short = 'Mouser CSE'; else if (/digikey/.test(s)) short = 'DigiKey'; else if (/lcsc/.test(s)) short = 'LCSC';
  else if (!r.found || /not recorded|unknown|\?/.test(s)) short = 'source ?';
  return `<div class="src-chip" data-tooltip="${escapeHtml('3D model source: ' + r.label + '\nThe model colour is the CAD author\'s choice, not its source; this chip names the real source.')}"><span class="src-dot vendor"></span><span class="src-chip-lab">${escapeHtml(short)}</span></div>`;
}
function thumbErr(c, key) { const e = c && c.thumb_errors; return (e && typeof e === 'object' && e[key]) ? String(e[key]) : null; }
function axis3dInfo(axis) {
  return ({ z: { kind: 'chip3d', label: 'Z up' }, y: { kind: 'chip3d-yup', label: 'Y up' }, xup: { kind: 'chip3d-xup', label: 'X up' }, xdown: { kind: 'chip3d-xdown', label: 'X down' }, ydown: { kind: 'chip3d-ydown', label: 'Y down' }, zdown: { kind: 'chip3d-zdown', label: 'Z down' } })[axis] || { kind: 'chip3d-' + axis, label: axis };
}
const ALL_AXES = ['z', 'y', 'xup', 'xdown', 'ydown', 'zdown'];

// Symbol re-render spinner bookkeeping (after an up-axis switch), keyed by sym_rev; 95 s safety cap.
const SYM_REFRESHING = new Map();
function markSymRefreshing(mpn) { SYM_REFRESHING.set(mpn, { at: Date.now(), rev: (chipByMpn(mpn) || {}).sym_rev }); }
function isSymRefreshing(mpn) {
  const e = SYM_REFRESHING.get(mpn); if (!e) return false;
  const c = chipByMpn(mpn);
  if (Date.now() - e.at > 95000 || (c && c.sym_rev !== e.rev)) { SYM_REFRESHING.delete(mpn); return false; }
  return true;
}

function renderThumb(mpn, kind, present, label, errMsg, sources, tipText) {
  if (!present) {
    if (errMsg) return `<div class="thumb failed" data-tooltip="${escapeHtml(label + ' render failed: ' + errMsg + '\nUse the re-render thumbnails button on the card to try again.')}"><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 generated yet.\nUse the re-render thumbnails button on the card to render it from the current library files.')}"><span class="thumb-placeholder">${escapeHtml(label)}</span></div>`;
  }
  let methodSuffix = '';
  if (sources && sources.length) { const am = activeMethod(mpn, kind, sources), 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 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'"`;
  const noHover = (kind === 'symbol' || kind === 'footprint');
  const refreshing = (kind === 'symbol' && isSymRefreshing(mpn));
  const html = `<div class="thumb${noHover ? ' thumb-static' : ''}${refreshing ? ' refreshing' : ''}"${tipText ? ` data-tooltip="${escapeHtml(tipText)}"` : ''} data-thumb-mpn="${escapeHtml(mpn)}" data-thumb-kind="${kind}">
    <img src="${url}?t=${Date.now()}" alt="${escapeHtml(mpn + ' ' + label)}" loading="lazy" ${onerr}><span class="thumb-label">${escapeHtml(label)}</span>${refreshing ? '<span class="thumb-spinner" aria-label="re-rendering symbol"></span>' : ''}</div>`;
  if (noHover && sources && sources.length) return `<div class="thumb-src-group">${html}${srcChipHtml(mpn, kind, sources)}</div>`;
  return html;
}
function shortSrc(label) { const l = String(label || ''); if (/^kicad/i.test(l)) return 'KiCad'; if (/^adom wiki/i.test(l)) return 'Adom Wiki'; if (/ultra/i.test(l)) return 'Ultra Lib'; return l.split(/[\s,(]/)[0] || l; }
function srcChipHtml(mpn, kind, sources) {
  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;
  return `<button class="src-chip" type="button" data-mpn="${escapeHtml(mpn)}" data-kind="${kind}" data-tooltip="${escapeHtml('Source of this ' + kind + ': ' + cur.label + '.' + (multi ? ' ' + sources.length + ' sources available; click to compare and switch.' : ' Click for details.'))}"><span class="src-dot ${cur.method === 'ds2sf' ? 'ds' : 'vendor'}"></span><span class="src-chip-lab">${escapeHtml(shortSrc(cur.label))}</span>${multi ? icon('chevron', 10) : ''}</button>`;
}
// The 3D tile shows the chosen up-axis render; the picker (bottom-left) lists all six.
function render3dTile(c) {
  const chosen = ALL_AXES.includes(c.chosen_up_axis) ? c.chosen_up_axis : (ALL_AXES.includes(c.inferred_up_axis) ? c.inferred_up_axis : 'z');
  const ai = axis3dInfo(chosen);
  const present = chosen === 'z' ? c.has_thumb_chip3d : chosen === 'y' ? c.has_thumb_chip3d_yup : true;
  const tipText = `Open ${c.mpn} in the adom-step 3D viewer and orbit the real STEP model. Showing the ${ai.label} render` + (c.chosen_up_axis ? ' (you picked it)' : c.inferred_up_axis ? ' (auto-detected)' : '') + '.';
  const tile = renderThumb(c.mpn, ai.kind, present, '3D', thumbErr(c, '3d'), null, present ? tipText : null);
  const picker = c.has_step ? `<button class="orient-more-btn" type="button" data-mpn="${escapeHtml(c.mpn)}" aria-label="Pick the up-axis" data-tooltip="Pick the correct face-up orientation from all six renders. Your pick becomes the main 3D view and is saved to info.json for downstream tools.">${icon('axis', 13)}</button>` : '';
  return src3dChip(c) + tile + picker;
}
function filesMeter(c) {
  const parts = [['Datasheet', c.has_pdf], ['STEP 3D', c.has_step], ['KiCad symbol', c.has_sym], ['KiCad footprint', c.has_mod], ['Fusion .lbr', c.has_fusion_lbr]];
  const n = parts.filter(p => p[1]).length;
  const tipText = parts.map(p => (p[1] ? 'Present: ' : 'Missing: ') + p[0]).join('\n') + (n < parts.length ? '\nUse the re-fetch button on the card to fetch a missing file from a specific source.' : '');
  return `<span class="pill ${n === parts.length ? 'ok' : n === 0 ? 'unk' : 'warn'}" data-tooltip="${escapeHtml(tipText)}">${n}/${parts.length} files</span>`;
}
function validationPill(c) {
  const v = c.validation_status;
  if (v === 'golden') return `<span class="pill ok" data-tooltip="Golden: every source (ds2sf from the datasheet, the KiCad bundle, EAGLE, Altium) agrees on pin count, pad geometry, pin names and package family. Safe for downstream use.">${icon('check', 11)}Golden</span>`;
  if (v === 'golden_with_variants') { const vs = (c.validation_variants || []).map(x => x.replace(/_/g, ' ')).join(', '); return `<span class="pill ok" data-tooltip="${escapeHtml('Golden with explainable variants. Sources disagree on some axes, but every disagreement is benign: ' + vs + '. Safe for downstream; the variant list tells tools which source to prefer per axis.')}">${icon('check', 11)}Golden +${(c.validation_variants || []).length}</span>`; }
  if (v === 'divergent') return `<span class="pill warn" data-tooltip="Divergent: at least one source disagrees and the disagreement is not explained by a known variant. Use the re-validate button, or open the concur result.json for the conflict axis and suggested re-fetch sources.">${icon('warn', 11)}Divergent</span>`;
  if (v === 'unrecoverable') return `<span class="pill bad" data-tooltip="Unrecoverable: concur could not parse enough sources to run the comparison. Check the chip directory for missing or corrupt files.">${icon('x', 11)}Failed</span>`;
  return `<span class="pill unk" data-tooltip="Not validated yet. Use the re-validate button: it runs ds2sf (extracts symbol and footprint from the datasheet PDF) then concur (cross-checks against KiCad, EAGLE and Altium). About 30 to 60 seconds per chip.">Not validated</span>`;
}
function pin1Pill(c) {
  if (!(c.has_step && c.has_mod)) return '';
  const fp = c.fp_pin1_corner || null;
  if (c.pin1_signed) {
    const corner = c.pin1_corner || '?';
    const when = c.pin1_signed_at ? ' (signed ' + String(c.pin1_signed_at).slice(0, 10) + ')' : '';
    const meas = c.pin1_measure_ok === false ? '\nNote: chipsmith flagged a measurement mismatch.' : '';
    if (fp && corner !== '?' && corner !== fp) return `<span class="pill bad" data-tooltip="${escapeHtml('Pin-1 mismatch' + when + '. The 3D chip\'s pin-1 was signed off in the ' + corner + ' corner, but the footprint\'s pin-1 is in the ' + fp + ' corner. Re-align in chipsmith so the chip and footprint agree.' + meas)}">${icon('warn', 11)}Pin-1 chip ${escapeHtml(corner)} / fp ${escapeHtml(fp)}</span>`;
    return `<span class="pill accent" data-tooltip="${escapeHtml('Pin-1 aligned and signed off in chipsmith' + when + '. Pin-1 sits in the ' + corner + ' corner' + (fp ? ' (matches the footprint)' : '') + '; ' + c.mpn + '.chipsmith.step is the oriented model downstream tools use.' + meas)}">${icon('check', 11)}Pin-1 ${escapeHtml(corner)}</span>`;
  }
  const tipText = fp ? `Pin-1 not aligned yet. The footprint pin-1 is in the ${fp} corner (that is the target). Use Align pin-1 in the detail panel (opens chipsmith) to line up the 3D chip's pin-1 and sign off.` : 'Pin-1 not aligned yet. The 3D chip and footprint are both here. Use Align pin-1 in the detail panel (opens chipsmith) to line up pin-1 and sign off.';
  return `<span class="pill unk" data-tooltip="${escapeHtml(tipText)}">Pin-1 ?${fp ? ', fp ' + escapeHtml(fp) : ''}</span>`;
}
function stockInfo(c) {
  const m = (c.stock && c.stock.mouser) || null;
  if (!m) return null;
  const s = m.stock || 0, lc = (m.lifecycle || '').toLowerCase();
  const info = { stock: s, price: m.unit_price || null, lifecycle: m.lifecycle || '', cls: s >= 1000 ? 'ok' : s > 0 ? 'warn' : 'bad', label: s > 0 ? (s >= 1000 ? (s / 1000).toFixed(s >= 10000 ? 0 : 1) + 'k in stock' : s + ' in stock') : 'No stock' };
  if (/end of life|obsolete|discontinued|last.?time/.test(lc)) info.lc = { cls: 'bad', text: 'End of life', tipText: 'End of life (' + m.lifecycle + '). This chip is discontinued; do not use it in new designs.' };
  else if (/not recommended|nrnd|phased.?out|mature/.test(lc)) info.lc = { cls: 'warn', text: 'NRND', tipText: 'Not recommended for new designs (' + m.lifecycle + '). Still available, but the manufacturer is phasing it out.' };
  else if (/new product|new release/.test(lc)) info.lc = { cls: 'ok', text: 'New', tipText: 'New product (' + m.lifecycle + '). Recently released; stock may be limited while supply ramps.' };
  return info;
}
function stockPills(c) {
  const st = stockInfo(c); if (!st) return '';
  let h = `<span class="pill ${st.cls}" data-tooltip="${escapeHtml('Mouser stock: ' + st.stock + ' units. ' + (st.stock >= 1000 ? 'Well stocked, safe for production quantities.' : st.stock > 0 ? 'Low stock; check lead time before committing.' : 'Out of stock at Mouser; check DigiKey or JLCPCB.'))}">${escapeHtml(st.label)}</span>`;
  if (st.lc) h += `<span class="pill ${st.lc.cls}" data-tooltip="${escapeHtml(st.lc.tipText)}">${escapeHtml(st.lc.text)}</span>`;
  return h;
}

function render() {
  if (!LIB) return;
  hideTip();
  const q = $id('q').value.toLowerCase(), v = $id('vendor').value, s = $id('status').value;
  const cards = $id('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) {
    const q = ($id('q').value || '').trim();
    if (LIB.chips.length && q) {
      const phrase = 'fetch ' + q.toUpperCase() + ' with cf';
      cards.innerHTML = '<div class="empty-state">Nothing in your library matches "' + escapeHtml(q) + '".<br>Not fetched yet? Ask your AI: <button class="btn" id="btn-fetch-q">Copy: "' + escapeHtml(phrase) + '"</button></div>';
      const b = $id('btn-fetch-q');
      if (b) b.onclick = async () => { if (await copyText(phrase)) cfToast('Copied: ' + phrase + '. Paste it to your AI.', { type: 'ok' }); };
    } else {
      cards.innerHTML = `<div class="empty-state">${LIB.chips.length ? 'No chips match these filters.' : 'No parts in the library yet.'}</div>`;
    }
    return;
  }
  if (ACTIVE_MPN && !filtered.find(c => c.mpn === ACTIVE_MPN)) {
    const activeChip = chipByMpn(ACTIVE_MPN);
    if (activeChip) {
      filtered.unshift(activeChip);
      const where = ACTIVE_PROJECT_FILTER ? 'the ' + ACTIVE_PROJECT_FILTER + ' project' : 'the current filter';
      const note = document.createElement('div'); note.className = 'out-of-filter-note';
      note.innerHTML = `Showing <b>${escapeHtml(ACTIVE_MPN)}</b> because it is being fetched, although it is not in ${escapeHtml(where)}. <button class="btn small" type="button">Clear filter</button>`;
      note.querySelector('button').onclick = () => { ACTIVE_PROJECT_FILTER = null; $id('q').value = ''; $id('vendor').value = ''; $id('status').value = ''; renderProjectTabs(); render(); };
      cards.appendChild(note);
    }
  }
  for (const c of filtered) cards.appendChild(buildCard(c));
}
function buildCard(c) {
  const card = document.createElement('div');
  card.className = 'card' + (SELECTED === c.mpn ? ' selected' : '') + (ACTIVE_MPN === c.mpn ? ' active' : '');
  card.dataset.mpn = c.mpn;
  const mpn = escapeHtml(c.mpn);
  const pkg = (c.package && c.package !== '?' && c.package !== DASH) ? c.package : '?';
  const size = c.size_bytes > 0 ? (c.size_bytes / 1024 / 1024).toFixed(1) + ' MB' : '? MB';
  const desc = c.info && (c.info.summary || c.info.description);
  const statusPill = ACTIVE_MPN === c.mpn
    ? `<span class="pill accent fetching" data-tooltip="${escapeHtml('Your AI is fetching this part now. Stage: ' + (ACTIVE_STAGE || 'starting'))}">Fetching</span>`
    : (c.fully_bundled ? '<span class="pill ok" data-tooltip="Fully bundled: STEP, KiCad symbol and footprint, Altium and the Fusion .lbr are all here, so every downstream tool (chipsmith, 3rd party fab quoting, molecules) can use it.">Full</span>'
      : '<span class="pill warn" data-tooltip="Stub: one or more core CAD files are missing (STEP, KiCad symbol or footprint, Altium, Fusion). Use the re-fetch button to fetch the missing pieces from a specific source.">Stub</span>');
  card.innerHTML = `
    <div class="mpn-row">
      <div class="mpn">${mpn}</div>
      <div class="card-actions">
        <button class="cbtn" type="button" data-action="validate" data-mpn="${mpn}" aria-label="Re-validate" data-tooltip="Re-validate this chip. Runs ds2sf (extracts symbol and footprint from the datasheet PDF) then concur (cross-checks against the KiCad, EAGLE and Altium libraries). The validation pill on this card updates when it finishes.">${icon('refresh')}</button>
        <button class="cbtn" type="button" data-action="thumbnails" data-mpn="${mpn}" aria-label="Re-render thumbnails" data-tooltip="Re-render thumbnails. Regenerates the symbol SVG, the footprint SVG and all six orientation 3D renders from the current library files. Useful after importing a new STEP or .kicad_sym.">${icon('image')}</button>
        <button class="cbtn refetch-btn" type="button" data-mpn="${mpn}" aria-label="Re-fetch a file" data-tooltip="Re-fetch one file from a specific source. Opens a menu of what to fetch and where from; the source's product page opens in pup and the download is watched for.">${icon('download')}</button>
        <button class="cbtn reveal-btn" type="button" data-reveal-mpn="${mpn}" aria-label="Reveal folder in VS Code" data-tooltip="Reveal this chip's folder in the VS Code Explorer, library/${mpn}/, so you can see every file: STEP, KiCad symbol and footprint, datasheets, info.json and provenance logs.">${icon('folder')}</button>
        <button class="cbtn zoom-btn" type="button" data-zoom-mpn="${mpn}" aria-label="Zoom this card" data-tooltip="Zoom this card into a centred overlay for close inspection. Escape, the X or the backdrop closes it. On a phone this is the way to enlarge a card.">${icon('zoom')}</button>
      </div>
    </div>
    <div class="meta">${[(c.vendor && c.vendor !== '?' && c.vendor !== DASH) ? escapeHtml(c.vendor) : null, pkg !== '?' ? escapeHtml(pkg) : null].filter(Boolean).join(' &middot; ') || 'vendor and package not recorded'} &middot; ${size}</div>
    ${desc ? `<div class="card-desc">${escapeHtml(desc)}</div>` : ''}
    <div class="thumbs">
      ${renderThumb(c.mpn, 'symbol', c.has_thumb_symbol, 'SYM', thumbErr(c, 'sym'), c.symbol_sources)}
      ${renderThumb(c.mpn, 'footprint', c.has_thumb_footprint, 'FP', thumbErr(c, 'fp'), c.footprint_sources)}
      <div class="thumb-3d-group">${render3dTile(c)}</div>
    </div>
    <div class="card-foot">
      ${statusPill}${validationPill(c)}${filesMeter(c)}${pin1Pill(c)}${SETTINGS.show_stock_badges ? stockPills(c) : ''}
      <span class="mini-actions">
        ${c.has_pdf ? `<button class="pill-act" type="button" data-act="datasheet" data-mpn="${mpn}" data-tooltip="${escapeHtml('Open the datasheet PDF in the viewer. ' + c.pdf_count + ' datasheet' + (c.pdf_count > 1 ? 's' : '') + ' in the chip directory. Source: ' + fileSource(c, /\.pdf$/i).label)}">${icon('doc', 12)}Datasheet</button>` : ''}
        ${(c.has_sym || c.has_mod) ? `<button class="pill-act" type="button" data-act="lbr" data-mpn="${mpn}" data-tooltip="Build a true EAGLE .lbr (symbol plus footprint) for this part and open it in the adom-lbr app: the one EAGLE library Fusion 360, Altium and OrCAD all import directly. KiCad uses its own native files, which you already have.">${icon('pkg', 12)}.lbr</button>` : ''}
      </span>
    </div>`;
  return card;
}

// ---- Delegated card interactions: one handler serves the grid AND the zoom clone (a dead DOM copy). ----
document.addEventListener('click', ev => {
  const t = ev.target;
  let b;
  if ((b = t.closest('.cbtn[data-action]'))) { ev.stopPropagation(); runCardAction(b); return; }
  if ((b = t.closest('.refetch-btn'))) { ev.stopPropagation(); openRefetchMenu(b); return; }
  if ((b = t.closest('.reveal-btn'))) { ev.stopPropagation(); revealFolder(b.dataset.revealMpn); return; }
  if ((b = t.closest('.zoom-btn'))) { ev.stopPropagation(); const card = document.querySelector(`#cards .card[data-mpn="${CSS.escape(b.dataset.zoomMpn)}"]`); if (card) zoomCard(card); return; }
  if ((b = t.closest('.orient-more-btn'))) { ev.stopPropagation(); openOrientMenu(b); return; }
  if ((b = t.closest('.src-chip[data-kind]'))) { ev.stopPropagation(); openSrcMenu(b); return; }
  if ((b = t.closest('.pill-act'))) { ev.stopPropagation(); if (b.dataset.act === 'datasheet') openThumb(b.dataset.mpn, 'datasheet'); else openThumb(b.dataset.mpn, 'lbr'); return; }
  if ((b = t.closest('.thumb[data-thumb-kind]'))) { ev.stopPropagation(); if (b.dataset.thumbKind === 'symbol' || b.dataset.thumbKind === 'footprint') return; openThumb(b.dataset.thumbMpn, b.dataset.thumbKind); return; }
  if ((b = t.closest('.openbtn'))) { ev.stopPropagation(); onOpenBtn(b); return; }
  if ((b = t.closest('.sendbtn'))) { ev.stopPropagation(); onSendBtn(b); return; }
  const card = t.closest('.card');
  if (card && card.dataset.mpn && !t.closest('.card-zoom-backdrop')) select(card.dataset.mpn);
});
document.addEventListener('dblclick', ev => {
  const card = ev.target.closest('#cards .card');
  if (!card || ev.target.closest('button, .thumb, a')) return;
  zoomCard(card);
});
function runCardAction(b) {
  const action = b.dataset.action, mpn = b.dataset.mpn;
  b.disabled = true;
  cfToast((action === 'validate' ? 'Re-validating ' : 'Re-rendering thumbnails for ') + mpn + ' in the background. The card updates when it finishes.');
  fetch(API_BASE + '/api/' + action + '/' + encodeURIComponent(mpn), { method: 'POST' }).then(r => r.json())
    .then(j => console.log('[' + action + '] kicked:', j))
    .catch(e => { console.error('[' + action + '] failed:', e); cfToast('Could not start ' + action + ': ' + e.message, { type: 'err' }); })
    .finally(() => setTimeout(() => { b.disabled = false; }, 3000));
}
const REFETCH_OPTIONS = [
  ['manufacturer', 'kicad_sym', 'Manufacturer', 'Schematic symbol'], ['manufacturer', 'kicad_mod', 'Manufacturer', 'PCB footprint'],
  ['snapmagic', 'kicad_sym', 'SnapMagic', 'Schematic symbol'], ['snapmagic', 'kicad_mod', 'SnapMagic', 'PCB footprint'],
  ['mouser', 'kicad_sym', 'Mouser', 'Schematic symbol'], ['digikey', 'kicad_sym', 'DigiKey', 'Schematic symbol'], ['cse_ul', 'kicad_sym', 'Ultra Librarian', 'Schematic symbol'],
  ['manufacturer', 'step', 'Manufacturer', '3D STEP model'], ['manufacturer', 'pdf', 'Manufacturer', 'Datasheet PDF'],
];
function openRefetchMenu(b) {
  const mpn = b.dataset.mpn;
  const el = openMenu(b, `<div class="fhead">Re-fetch for ${escapeHtml(mpn)}: what, and from where</div>` + REFETCH_OPTIONS.map((o, i) => `<button class="fopt" type="button" data-i="${i}">${icon('download', 14)}<span>${o[3]}</span><span class="fsub">${o[2]}</span></button>`).join(''), { width: 300 });
  el.querySelectorAll('.fopt').forEach(opt => opt.onclick = () => {
    const [source, format, sl, fl] = REFETCH_OPTIONS[+opt.dataset.i]; closeMenu();
    cfToast('Re-fetching the ' + fl.toLowerCase() + ' for ' + mpn + ' from ' + sl + '. pup opens the product page on your desktop.');
    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 => cfToast('Re-fetch failed: ' + e.message, { type: 'err' }));
  });
}
function openOrientMenu(b) {
  const mpn = b.dataset.mpn, c = chipByMpn(mpn) || {};
  const chosen = c.chosen_up_axis;
  const el = openMenu(b, `<div class="fhead">Which render has the chip face up? Your pick becomes the 3D tile.</div><div class="fgal">` + ALL_AXES.map(ax => {
    const ai = axis3dInfo(ax), url = API_BASE + '/thumb/' + encodeURIComponent(mpn) + '/' + ai.kind + '?t=' + Date.now();
    const sel = chosen === ax, inf = c.inferred_up_axis === ax;
    return `<button class="gopt g3d${sel ? ' chosen' : ''}" type="button" data-axis="${ax}"><img src="${url}" alt="${escapeHtml(mpn + ' ' + ai.label)}" onerror="this.parentElement.style.display='none'"><span class="glab">${ai.label}${inf ? ' (auto-detected)' : ''}</span>${sel ? `<span class="gcur">${icon('check', 11)}current</span>` : ''}</button>`;
  }).join('') + '</div>', { width: 520 });
  el.querySelectorAll('.gopt').forEach(opt => opt.onclick = () => { closeMenu(); setOrientation(mpn, opt.dataset.axis); });
}
function openSrcMenu(b) {
  const mpn = b.dataset.mpn, kind = b.dataset.kind, c = chipByMpn(mpn) || {};
  const sources = (kind === 'symbol' ? c.symbol_sources : c.footprint_sources) || [];
  if (!sources.length) return;
  const active = activeMethod(mpn, kind, sources);
  const el = openMenu(b, `<div class="fhead">Sources for the ${kind}: pick the one the tile should show</div><div class="fgal">` + 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 `<button class="gopt${sel ? ' chosen' : ''}" type="button" data-method="${escapeHtml(s.method)}" data-canonical="${s.current ? '1' : '0'}">${tsrc ? `<img src="${tsrc}" alt="${escapeHtml(s.label)}" loading="lazy">` : `<span class="src-dot ${s.method === 'ds2sf' ? 'ds' : 'vendor'}"></span>`}<span class="glab">${escapeHtml(s.label)}</span>${sel ? `<span class="gcur">${icon('check', 11)}shown now</span>` : ''}</button>`;
  }).join('') + '</div>', { width: Math.min(520, sources.length * 170 + 20) });
  el.querySelectorAll('.gopt').forEach(opt => opt.onclick = () => {
    const method = opt.dataset.method;
    SRC_SEL[mpn + '|' + kind] = method; saveSrcSel(); closeMenu();
    render(); cfToast('Showing the ' + (sources.find(s => s.method === method) || {}).label + ' ' + kind + ' on the tile.', { type: 'ok' });
  });
}
async function setOrientation(mpn, axis) {
  try {
    markSymRefreshing(mpn); render();
    const r = await postJson('/api/orientation', { mpn, axis });
    const j = await r.json().catch(() => ({}));
    if (!r.ok || !j.ok) { SYM_REFRESHING.delete(mpn); render(); cfToast('Could not set the orientation: ' + (j.error || ('HTTP ' + r.status)), { type: 'err' }); return; }
    cfToast('Up-axis set to ' + axis3dInfo(axis).label + ' for ' + mpn + '. The symbol re-renders in the background.', { type: 'ok' });
    await loadLibrary();
    const poll = setInterval(async () => {
      if (!isSymRefreshing(mpn)) { clearInterval(poll); render(); return; }
      try { await loadLibrary(); } catch (e) {}
    }, 2500);
  } catch (e) { SYM_REFRESHING.delete(mpn); render(); cfToast('Could not reach /api/orientation: ' + e.message, { type: 'err' }); }
}
function scrollActiveCardIntoView() {
  const card = document.querySelector('#cards .card.active') || (ACTIVE_MPN && document.querySelector(`#cards .card[data-mpn="${CSS.escape(ACTIVE_MPN)}"]`));
  if (!card) return;
  const r = card.getBoundingClientRect();
  if (r.top >= 0 && r.bottom <= window.innerHeight) return;
  card.scrollIntoView({ behavior: 'smooth', block: 'center' });
}

// ---- Card zoom: a clone animates from the grid position to the centre; delegated handlers keep it live. ----
let ZOOM_DISMISS = null;
function dismissZoom() { if (ZOOM_DISMISS) ZOOM_DISMISS(); }
function autoZoomIfEnabled(mpn) {
  if (!SETTINGS.auto_zoom) return;
  const card = document.querySelector(`#cards .card[data-mpn="${CSS.escape(mpn)}"]`);
  if (card) setTimeout(() => zoomCard(card), 100);
}
function zoomCard(cardEl) {
  const alreadyOpen = document.querySelector('.card-zoom-backdrop');
  if (alreadyOpen) { if (alreadyOpen.dataset.mpn === cardEl.dataset.mpn) return; dismissZoom(); alreadyOpen.remove(); }
  const rect = cardEl.getBoundingClientRect(), phone = isPhone();
  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';
  clone.classList.remove('selected');
  const zchip = chipByMpn(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" type="button" 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" type="button" data-target="${t}">${l}</button>`).join('') + '</div>';
    clone.appendChild(sec);
  }
  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';
  } else backdrop.classList.add('fullscreen');
  wrapper.style.left = rect.left + 'px'; wrapper.style.top = rect.top + 'px'; clone.style.transform = 'scale(1)';
  const zoomMpn = cardEl.dataset.mpn;
  function syncZoomStatus() {
    const working = ACTIVE_MPN === zoomMpn && ACTIVE_STAGE;
    let bar = backdrop.querySelector('.zoom-status');
    if (working) { if (!bar) { bar = document.createElement('div'); bar.className = 'zoom-status'; backdrop.appendChild(bar); } bar.innerHTML = '<span class="led"></span><span></span>'; bar.lastChild.textContent = 'Working on this part: ' + ACTIVE_STAGE; }
    else if (bar) bar.remove();
  }
  syncZoomStatus();
  const statusPoll = setInterval(syncZoomStatus, 1500);
  wrapper.appendChild(clone); backdrop.appendChild(wrapper);
  const onResize = () => fitZoom();
  function dismiss() {
    clearInterval(statusPoll); window.removeEventListener('resize', onResize); document.removeEventListener('keydown', escHandler);
    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);
    if (ZOOM_DISMISS === dismiss) ZOOM_DISMISS = null;
    ZOOM_MPN = null; reportUi();
  }
  ZOOM_DISMISS = dismiss; ZOOM_MPN = zoomMpn; reportUi();
  const xbtn = document.createElement('button'); xbtn.className = 'card-zoom-x'; xbtn.type = 'button'; xbtn.setAttribute('aria-label', 'Close'); xbtn.setAttribute('data-tooltip', 'Close the zoomed card (Escape also works).'); xbtn.innerHTML = icon('x', 20);
  xbtn.onclick = e => { e.stopPropagation(); dismiss(); };
  backdrop.appendChild(xbtn); document.body.appendChild(backdrop);
  function fitZoom() {
    if (phone) return;
    const naturalW = clone.offsetWidth || rect.width, naturalH = clone.offsetHeight || rect.height;
    const scale = Math.min((window.innerWidth * 0.94) / naturalW, (window.innerHeight * 0.94) / naturalH);
    wrapper.style.left = Math.max(8, (window.innerWidth - naturalW * scale) / 2) + 'px';
    wrapper.style.top = Math.max(8, (window.innerHeight - naturalH * scale) / 2) + 'px';
    clone.style.transition = 'transform .3s ease-out'; clone.style.transform = `scale(${scale})`;
  }
  backdrop.classList.add('visible');
  requestAnimationFrame(() => requestAnimationFrame(fitZoom));
  clone.querySelectorAll('img').forEach(img => { if (!img.complete) img.addEventListener('load', fitZoom, { once: true }); });
  window.addEventListener('resize', onResize);
  backdrop.onclick = ev => { if (ev.target === backdrop) dismiss(); };
  const escHandler = ev => { if (ev.key === 'Escape') dismiss(); };
  document.addEventListener('keydown', escHandler);
}

// ---- Detail aside ----
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']];
function edaPill(label, hasSym, hasMod, c) {
  const extRe = { KiCad: /\.kicad_(sym|mod)$/i, Altium: /\.(SchLib|PcbLib|IntLib)$/i, 'Fusion 360': /\.lbr$/i }[label] || /$.^/;
  const src = (hasSym || hasMod) ? 'Source: ' + fileSource(c, extRe).label + '\n' : '';
  if (hasSym && hasMod) return `<span class="pill ok" data-tooltip="${escapeHtml(src + label + ' schematic symbol and PCB footprint are both present: a complete ' + label + ' library, ready to import.')}">${escapeHtml(label)}</span>`;
  if (hasSym || hasMod) return `<span class="pill warn" data-tooltip="${escapeHtml(src + label + ': has the ' + (hasSym ? 'schematic symbol' : 'PCB footprint') + ' but is missing the ' + (hasSym ? 'PCB footprint' : 'schematic symbol') + '. Use the re-fetch button to get the missing piece.')}">${escapeHtml(label)} ${hasSym ? 'sym' : 'fp'}</span>`;
  return `<span class="pill unk" data-tooltip="${escapeHtml(label + ' schematic symbol and PCB footprint are both missing. No ' + label + ' library files fetched yet.')}">${escapeHtml(label)} ?</span>`;
}
async function select(mpn, silent) {
  const changed = SELECTED !== mpn;
  SELECTED = mpn; updateSubject(); reportUi();
  document.querySelectorAll('#cards .card').forEach(c => c.classList.toggle('selected', c.dataset.mpn === mpn));
  const aside = $id('detail');
  const r = await fetch(API_BASE + '/api/chip/' + encodeURIComponent(mpn));
  if (!r.ok) { aside.classList.remove('empty'); aside.innerHTML = '<div class="empty-state">Could not load ' + escapeHtml(mpn) + ' (HTTP ' + r.status + ').</div>'; return; }
  const c = await r.json();
  aside.classList.remove('empty'); aside.classList.add('open');
  const info = c.info || {};
  const fieldRows = CANONICAL.map(([k, label]) => {
    let v = info[k]; if (v === undefined || v === null || v === '' || v === DASH) v = '?';
    const isUrl = typeof v === 'string' && /^https?:\/\//.test(v);
    return `<div class="field${v === '?' ? ' unknown' : ''}"><span class="field-key">${escapeHtml(label)}</span><span class="field-val">${isUrl ? `<a href="${escapeHtml(v)}" target="_blank" rel="noopener">${escapeHtml(v)}</a>` : escapeHtml(String(v))}</span></div>`;
  }).join('');
  const st = stockInfo(c);
  const tab = DETAIL_TAB || 'actions';
  const stepSrc = resolveStepSource(c);
  aside.innerHTML = `
    <div class="aside-head"><h2>${escapeHtml(c.mpn)}</h2><button class="iconbtn aside-close" type="button" aria-label="Close details">${icon('x', 18)}</button></div>
    <div class="vendor">${[(c.vendor && c.vendor !== '?' && c.vendor !== DASH) ? escapeHtml(c.vendor) : null, (c.package && c.package !== '?' && c.package !== DASH) ? escapeHtml(c.package) : null, (c.family && c.family !== '?' && c.family !== DASH) ? escapeHtml(c.family) : null].filter(Boolean).join(' &middot; ') || 'vendor and package not recorded'}</div>
    <div class="dtabs" role="tablist">
      <button class="dtab${tab === 'actions' ? ' on' : ''}" role="tab" aria-selected="${tab === 'actions'}" data-tab="actions">Actions</button>
      <button class="dtab${tab === 'details' ? ' on' : ''}" role="tab" aria-selected="${tab === 'details'}" data-tab="details">Details</button>
      <button class="dtab${tab === 'files' ? ' on' : ''}" role="tab" aria-selected="${tab === 'files'}" data-tab="files">Files <span class="dtab-n">${c.files.length}</span></button>
    </div>
    <div class="dpanel${tab === 'actions' ? ' on' : ''}" data-panel="actions" role="tabpanel">
      <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" type="button" data-kind="symbol" data-tooltip="Open the schematic symbol in the live adom-symbol app.">adom-symbol</button>` : ''}
          ${c.has_mod ? `<button class="openbtn" type="button" data-kind="footprint" data-tooltip="Open the PCB footprint in the live adom-footprint app (layers plus solder-paste dots).">adom-footprint</button>` : ''}
          ${(c.has_sym || c.has_mod) ? `<button class="openbtn" type="button" data-kind="lbr" data-tooltip="Build and open a true EAGLE .lbr (symbol plus footprint) in adom-lbr.">adom-lbr</button>` : ''}
          ${c.has_step ? `<button class="openbtn" type="button" data-kind="chip3d" data-tooltip="Orbit the real 3D STEP model in the adom-step viewer.">adom-3D</button>` : ''}
          ${(c.has_step && c.has_mod) ? `<button class="openbtn" type="button" data-kind="chipsmith" data-tooltip="${escapeHtml('Align pin-1 in chipsmith. Opens the 3D STEP and the footprint together so you can line up the chip\'s pin-1 with the footprint\'s pin-1, then sign off. Chip fetcher reads the result back as the Pin-1 pill and a baked ' + c.mpn + '.chipsmith.step.')}">${c.pin1_signed ? 'Pin-1 signed' : 'Align pin-1'}</button>` : (c.has_step ? `<button class="openbtn" type="button" data-kind="chipsmith" data-tooltip="Open in chipsmith (STEP-native pin detection and sign-off). A footprint is needed before pin-1 can be aligned.">chipsmith</button>` : '')}
          ${(c.symbol_sources || []).some(s => s.method === 'ds2sf') ? `<button class="openbtn" type="button" data-kind="ds2sf" data-tooltip="View the symbol ds2sf extracted from the datasheet (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" type="button" data-target="kicad" data-tooltip="Install the native KiCad symbol and footprint into your KiCad libraries through Adom Desktop.">KiCad</button>
          <button class="sendbtn" type="button" data-target="fusion360" data-tooltip="Build a true EAGLE .lbr and open it in Fusion 360 Electronics.">Fusion</button>
          <button class="sendbtn" type="button" data-target="altium" data-tooltip="Build the EAGLE .lbr plus import steps (a native Altium exporter is on the roadmap).">Altium</button>
          <button class="sendbtn" type="button" data-target="orcad" data-tooltip="Build the EAGLE .lbr (a native OrCAD exporter is on the roadmap).">OrCAD</button>
        </div></div>
      <div class="section"><div class="section-title">Status</div>
        <div class="pills">
          <span class="pill ${c.fully_bundled ? 'ok' : 'warn'}">${c.fully_bundled ? 'Full' : 'Stub'}</span>
          <span class="pill ${c.has_step ? 'ok' : 'unk'}" data-tooltip="${escapeHtml(c.has_step ? 'STEP source: ' + stepSrc.label + '. The 3D STEP model is used by chipsmith for pin detection and footprint validation, and by chip-thumbnailer for the orientation renders.' : '3D STEP model missing. Needed for chipsmith validation and 3D thumbnails.')}">STEP${c.has_step ? '' : ' ?'}</span>
          ${edaPill('KiCad', c.has_sym, c.has_mod, c)}${edaPill('Altium', c.has_altium, c.has_altium, c)}${edaPill('Fusion 360', c.has_fusion_lbr, c.has_fusion_lbr, c)}
          <span class="pill ${c.pdf_count > 0 ? 'ok' : 'unk'}" data-tooltip="${escapeHtml(c.pdf_count > 0 ? 'Source: ' + fileSource(c, /\.pdf$/i).label : 'No datasheet PDF yet. Use re-fetch, Manufacturer, Datasheet PDF.')}">${c.pdf_count > 0 ? c.pdf_count : '?'} PDF${c.pdf_count === 1 ? '' : 's'}</span>
          <span class="pill ${srcTier(c.step_source) === 'unk' ? 'unk' : 'accent'}" data-tooltip="${escapeHtml(stepSrc.found ? 'STEP source: ' + stepSrc.label + '. Ladder priority: manufacturer, SnapMagic, Mouser, DigiKey, Arrow, CSE and UL, then LCSC.' : 'STEP source ' + stepSrc.label)}">source: ${escapeHtml(srcShort(c.step_source))}</span>
        </div></div>
      <div class="section"><div class="section-title">Stock and price (Mouser)</div>
        <div class="fields">
          <div class="field${st ? '' : ' unknown'}"><span class="field-key">Stock</span><span class="field-val">${st ? escapeHtml(st.label) : 'not fetched yet'}</span></div>
          <div class="field${st && st.price ? '' : ' unknown'}"><span class="field-key">Unit price</span><span class="field-val">${st && st.price ? escapeHtml(st.price) : '?'}</span></div>
          <div class="field${st && st.lifecycle ? '' : ' unknown'}"><span class="field-key">Lifecycle</span><span class="field-val">${st && st.lifecycle ? escapeHtml(st.lifecycle) : '?'}</span></div>
        </div></div>
    </div>
    <div class="dpanel${tab === 'details' ? ' on' : ''}" data-panel="details" role="tabpanel"><div class="fields">${fieldRows}</div></div>
    <div class="dpanel${tab === 'files' ? ' on' : ''}" data-panel="files" role="tabpanel"><ul class="filelist">${c.files.map(f => `<li><a href="${API_BASE + '/asset/' + encodeURIComponent(c.mpn) + '/' + encodeURIComponent(f)}" target="_blank" rel="noopener">${escapeHtml(f)}</a></li>`).join('')}</ul></div>`;
  const tabs = [...aside.querySelectorAll('.dtab')];
  const activate = t => { DETAIL_TAB = t.dataset.tab; tabs.forEach(x => { x.classList.toggle('on', x === t); x.setAttribute('aria-selected', String(x === t)); }); aside.querySelectorAll('.dpanel').forEach(p => p.classList.toggle('on', p.dataset.panel === DETAIL_TAB)); };
  tabs.forEach((t, i) => { t.onclick = () => activate(t); t.onkeydown = e => { if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') { const n = tabs[(i + (e.key === 'ArrowRight' ? 1 : tabs.length - 1)) % tabs.length]; n.focus(); activate(n); } }; });
  aside.querySelector('.aside-close').onclick = () => { aside.classList.remove('open'); };
  if (changed && !silent && isPhone()) aside.classList.add('open');
}

function onOpenBtn(b) {
  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);
  if (kind === 'ds2sf') openThumb(mpn, 'symbol', 'ds2sf'); else openThumb(mpn, kind);
}
async function onSendBtn(b) {
  const row = b.closest('.sendto-row'), 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 j = await (await postJson('/api/deliver', { mpn, target })).json().catch(() => ({}));
    if (j.needsBridge) showBridgeModal(j);
    else if (j.ok) { b.textContent = 'Sent'; cfToast('Sent ' + mpn + ' to ' + label + '.', { type: 'ok' }); }
    else cfToast(j.message || j.error || ('Could not send to ' + label), { type: 'err' });
  } catch (e) { cfToast('Send failed: ' + e.message, { type: 'err' }); }
  finally { setTimeout(() => { all.forEach(x => x.disabled = false); b.textContent = prev; }, 2500); }
}
function showBridgeModal(j) {
  const m = $id('bridgeModal'), card = $id('bmCard');
  card.innerHTML = `<div class="bm-icon">${icon(j.exportWorks ? 'pkg' : 'bridge', 24)}</div><div class="bm-title" id="bmTitle"></div><div class="bm-body"></div>`
    + (j.importHint ? `<div class="bm-hint"></div>` : '') + `<div class="bm-actions">`
    + (j.assetUrl ? `<a class="btn primary" href="${escapeHtml(API_BASE + j.assetUrl)}" download>${icon('download')}Download the .lbr</a>` : '')
    + `<a class="btn" href="${escapeHtml(j.contribute || 'https://wiki.adom.inc')}" target="_blank" rel="noopener">${icon('ext')}Build the auto-import bridge: contribute on the wiki</a>`
    + `<button class="btn bm-dismiss" type="button">Close</button></div>`;
  card.querySelector('.bm-title').textContent = j.exportWorks ? (j.target + ' library export') : ('Build the ' + (j.target || '') + ' bridge');
  card.querySelector('.bm-body').textContent = j.message || '';
  if (j.importHint) card.querySelector('.bm-hint').textContent = j.importHint;
  card.querySelector('.bm-dismiss').onclick = () => m.close();
  m.onclick = e => { if (e.target !== m) return; const r = m.getBoundingClientRect(); if (!(e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom)) m.close(); };
  if (!m.open) m.showModal();
}
function viewContext() { return { embedded: window.self !== window.top, phone: isPhone() }; }
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.startsWith('chip3d') ? 'adom-3D' : kind);
  const tab = kind === 'chipsmith' ? null : window.open('', '_blank');
  try {
    const r = await postJson('/api/open', { mpn, kind: kind.startsWith('chip3d') ? 'chip3d' : kind, source });
    const j = await r.json().catch(() => ({}));
    if (!r.ok || !j.ok) { if (tab) tab.close(); cfToast('Could not open ' + app + ': ' + (j.error || ('HTTP ' + r.status)), { type: 'err' }); return; }
    if (kind === 'chipsmith') { cfToast('Opened ' + app + ' as a Hydrogen webview' + (j.desktopBridge ? ' on your Adom Desktop.' : ' (needs Hydrogen, so it will not appear on a phone).'), { type: 'ok' }); return; }
    const { embedded, phone } = viewContext();
    const where = phone ? 'a new 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 + '.', { type: 'ok' }); }
    else if (j.url) cfToast(app + ' is ready, but the pop-up was blocked.', { href: j.url, hrefLabel: 'Open it', ttl: 8000 });
  } catch (e) { if (tab) tab.close(); cfToast('Could not reach the server: ' + e.message, { type: 'err' }); }
}
async function revealFolder(mpn) {
  try {
    const r = await postJson('/api/reveal', { mpn: mpn || '' });
    const j = await r.json().catch(() => ({}));
    if (!r.ok || !j.ok) { console.warn('reveal failed', j); cfToast('Could not reveal the folder: ' + (j.error || j.stderr || ('HTTP ' + r.status)), { type: 'err' }); }
    else cfToast('Revealed ' + (mpn ? 'library/' + mpn : 'the library folder') + ' in the VS Code Explorer.', { type: 'ok' });
  } catch (e) { console.warn('reveal request failed', e); cfToast('Could not reach /api/reveal: ' + e.message, { type: 'err' }); }
}
$id('btn-reveal-lib').addEventListener('click', () => revealFolder(''));

// ---- Architecture map overlay (served at /architecture) ----
$id('btn-arch').addEventListener('click', () => {
  let ov = $id('arch-overlay');
  if (!ov) {
    ov = document.createElement('div'); ov.id = 'arch-overlay';
    ov.innerHTML = `<div class="arch-bar"><span class="t">Chip Fetcher architecture and dependency map</span><span class="s">every tool this app depends on, and what runs where</span><a href="${API_BASE}/architecture" target="_blank" rel="noopener">open in a new tab ${icon('ext', 12)}</a><button class="iconbtn" id="arch-close" type="button" aria-label="Close the architecture map" data-tooltip="Close the architecture map">${icon('x', 18)}</button></div><iframe src="${API_BASE}/architecture" title="Architecture map"></iframe>`;
    document.body.appendChild(ov);
    ov.querySelector('#arch-close').addEventListener('click', () => { ov.style.display = 'none'; });
  }
  ov.style.display = 'flex';
});

// ---- Views: grid | validation | sources ----
function setView(view) {
  if (!['grid', 'validation', 'sources'].includes(view)) view = 'grid';
  VIEW = view;
  $id('grid-view').hidden = view !== 'grid';
  $id('validation-view').hidden = view !== 'validation';
  $id('sources-view').hidden = view !== 'sources';
  $id('appBody').classList.toggle('noaside', view !== 'grid');
  for (const [id, v] of [['btn-validate', 'validation'], ['btn-sources', 'sources']]) { const b = $id(id); b.classList.toggle('is-on', view === v); b.setAttribute('aria-pressed', String(view === v)); }
  if (validationTimer) { clearInterval(validationTimer); validationTimer = null; }
  if (view === 'validation') { renderValidation(); validationTimer = setInterval(renderValidation, 3000); }
  if (view === 'sources') renderSources();
  reportUi();
}
$id('btn-validate').addEventListener('click', () => setView(VIEW === 'validation' ? 'grid' : 'validation'));
$id('btn-sources').addEventListener('click', () => setView(VIEW === 'sources' ? 'grid' : 'sources'));

const REQ = [['datasheet_pdf', 'Datasheet'], ['step_3d', 'STEP 3D'], ['kicad_symbol', 'Symbol'], ['kicad_footprint', 'Footprint'], ['thumb_symbol', 'Symbol thumb'], ['thumb_footprint', 'Footprint thumb'], ['thumb_3d', '3D thumb']];
let validationTimer = null;
const chk = (ok, tipText, star) => `<span class="vchk${ok ? '' : ' bad'}"${tipText ? ` data-tooltip="${escapeHtml(tipText)}"` : ''}>${icon(ok ? 'check' : 'x', 15)}${star ? '<span class="star">*</span>' : ''}</span>`;
async function renderValidation() {
  const host = $id('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">The validation API did not answer.</div>'; return; }
  const s = data.summary || {}, allGreen = s.total > 0 && s.incomplete === 0;
  const consensusCell = con => {
    if (!con) return '<td><span class="vnote">not run</span></td>';
    const golden = /golden/i.test(con.status || '');
    const label = golden ? (con.status === 'golden' ? 'Golden' : 'Golden*') : (con.status || '?');
    const tipText = 'adom-concur cross-source consensus.\nCompared: ' + ((con.sources || []).join(', ') || 'none') + '\nMissing: ' + ((con.missing || []).join(', ') || 'none') + '\n' + con.agree + ' axes agree, ' + con.disagree + ' disagree of ' + con.total_axes + '. Golden* = agree except for a naming-convention variant.';
    return `<td data-tooltip="${escapeHtml(tipText)}"><span class="vcon ${golden ? 'ok' : 'bad'}">${escapeHtml(label)}</span><span class="vcon-sub">${con.agree} agree / ${con.disagree} disagree, ${(con.sources || []).length} src</span></td>`;
  };
  const chk3d = src => {
    if (!src.three_d) return chk(false);
    if (src.three_d_from_lib) return chk(true);
    return chk(true, 'There was no 3D chip in this library, so the STEP downloaded from ' + (src.step_source || 'another source') + (src.step_url ? ' (' + src.step_url + ')' : '') + ' was swapped in.', true);
  };
  const dlLinks = (c, src) => (src.files || []).map(f => `<a class="vdl${f.original ? ' orig' : ''}" href="${API_BASE + '/asset/' + encodeURIComponent(c.mpn) + '/' + encodeURIComponent(f.file)}" download="${escapeHtml(f.file)}" data-tooltip="${escapeHtml((f.original ? 'Original file exactly as downloaded from the internet; inspect it yourself: ' : 'A file extracted from the download: ') + f.file)}">${icon('download', 11)}${escapeHtml(f.name)}</a>`).join('');
  const childRows = c => (c.sources || []).map(src => `<tr class="vsub"><td colspan="11"><span class="arrow">&rarr;</span><span class="slabel">${escapeHtml(src.label)}</span><span class="k">sym</span> ${chk(src.sym, src.sym ? '' : src.note)}<span class="k">fp</span> ${chk(src.fp, src.fp ? '' : src.note)}<span class="k">3D chip</span> ${chk3d(src)}<span class="vdls">${dlLinks(c, src)}</span></td></tr>`).join('');
  const rows = (data.chips || []).map(c => `<tr><td class="mpn l">${escapeHtml(c.mpn)}</td>${REQ.map(([k]) => '<td>' + chk(!!(c.required && c.required[k])) + '</td>').join('')}<td>${chk(!!(c.extra && c.extra.fusion_lbr))}</td>${consensusCell(c.consensus)}<td><span class="vverdict ${c.complete ? 'ok' : 'bad'}">${c.complete ? 'Complete' : 'Incomplete'}</span></td></tr>${childRows(c)}`).join('');
  host.innerHTML = `
    <div class="vhead"><div class="vtitle">Validation${data.project ? ': ' + escapeHtml(data.project) : ''}</div>
      <div class="vsum ${allGreen ? 'ok' : 'bad'}">${s.complete || 0} / ${s.total || 0} fully complete${allGreen ? ', all green' : ', ' + (s.incomplete || 0) + ' incomplete'}</div>
      <div class="vnote">Required: datasheet, STEP, symbol, footprint and three thumbnails. Refreshes every 3 seconds.</div></div>
    <div class="vscroll"><table class="vtable"><thead><tr><th class="l">MPN</th>${REQ.map(([, l]) => `<th>${l}</th>`).join('')}<th>.lbr</th><th data-tooltip="adom-concur cross-source consensus: do the KiCad, EAGLE, Altium and ds2sf variants of this part agree?">Consensus<span class="sub">adom-concur</span></th><th>Verdict</th></tr></thead>
    <tbody>${rows || '<tr><td colspan="11" class="l"><span class="vnote">No chips in scope.</span></td></tr>'}</tbody></table></div>`;
}
const TIER_CLASS = { 'Manufacturer': 't1', 'Adom tool': 't2', 'Aggregator skill': 't3', 'Last resort': 't4' };
async function renderSources() {
  const host = $id('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">The sources API did not answer.</div>'; return; }
  const concerns = (data.concerns || []).map(c => `<div class="src-concern"><h3>${escapeHtml(c.concern)}</h3><div class="src-what">${escapeHtml(c.what || '')}</div><div class="src-rule">${escapeHtml(c.rule || '')}</div>`
    + (c.steps || []).map((st, i) => `<div class="src-step"><div class="src-n">${i + 1}.</div><div class="src-tier"><span class="tier ${TIER_CLASS[st.tier] || ''}">${escapeHtml(st.tier)}</span></div><div class="src-body"><div class="src-name">${escapeHtml(st.name)}</div><div class="src-detail">${escapeHtml(st.detail || '')}</div>${st.cmd ? `<code class="src-cmd">${escapeHtml(st.cmd)}</code>` : ''}${st.skill ? `<span class="src-skill">skill: ${escapeHtml(st.skill)}</span>` : ''}</div></div>`).join('') + '</div>').join('');
  host.innerHTML = `<div class="srcwrap"><div class="srctitle">Sourcing order: how chip fetcher searches the internet</div><div class="srcsub">The real, code-backed order, served from <code>/api/sources</code> so it can never drift from behaviour. Three concerns, each searched top to bottom. <b>Manufacturer first for CAD; the Adom data tools (keys baked in, always work) for metadata; the manual-crawl skills are the fallback; LCSC is last.</b></div>${concerns}</div>`;
}

// ---- Guided tour: floaty transport (play/pause/next/prev/exit), ab captions
// on the desktop via the server, pre-baked adom-tts voiceover (checkbox).
const TOUR = [
  { text: "Welcome to Chip Fetcher, cf for short. You never drive this page by hand. You tell your AI something like: \"get me all the files for the STM32H757XIH6\", and cf takes it from there.", dur: 13000, act: () => spotlight('say') },
  { text: "Your AI injects the part into cf. Then cf opens pup, the AI's own browser on your desktop, and goes to town: crawling the web for every library, datasheet, and 3D model it can find.", dur: 12000, act: () => spotlight('newbie') },
  { text: "It always tries the manufacturer's website first; those files are the most reliable. There is a crawl skill for each vendor site, and the bin of skills is still filling up: figure out a site, contribute the skill, and every Adom user benefits.", dur: 14000, act: () => spotlight('leds') },
  { text: "Watch a fetch land right now: cf is pulling a part bundle from the Adom Wiki. The lights up top are cf's health check; green means it is ready to crawl for real.", dur: 12000, act: tourRunFetch },
  { text: "Every part becomes a card: the datasheet, the KiCad symbol, the footprint, and the 3D model, with checks that only go green when the bundle is complete.", dur: 11000, act: tourSelectDemo },
  { text: "Downstream is one click: adom-lbr converts the bundle, and the side pane sends the part straight to KiCad, Fusion 360, or Altium.", dur: 10000, act: () => spotlight('detail') },
  { text: "That is Chip Fetcher. Copy the phrase up top, ask your AI for that STM32H7, or any part you actually need, and watch it land here. Close the tour with the X.", dur: 10000, act: () => spotlight('say') },
];
let tourIdx = -1, tourTimer = null, tourPaused = false, tourSpot = null;
function spotlight(id) {
  if (tourSpot) tourSpot.classList.remove('tour-spotlight');
  tourSpot = $id(id);
  if (tourSpot) { tourSpot.classList.add('tour-spotlight'); tourSpot.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); }
}
function tourRunFetch() {
  spotlight('cards');
  postJson('/api/jobs', { action: 'demo_fetch' }).catch(() => {});
}
function tourSelectDemo() {
  const c = LIB && LIB.chips.find(x => x.mpn === '0603WAF100JT5E');
  if (c) { select(c.mpn); spotlight('cards'); } else spotlight('cards');
}
function demoMuted() { try { return localStorage.getItem('cf-demo-muted') === '1'; } catch (e) { return false; } }
function setDemoMuted(m) {
  try { localStorage.setItem('cf-demo-muted', m ? '1' : '0'); } catch (e) {}
  $id('tourMute').setAttribute('aria-pressed', String(m));
  $id('tourMute').classList.toggle('is-on', m);
  $id('tourMuteIco').innerHTML = m
    ? '<path d="M11 5L6 9H3v6h3l5 4V5z"/><path d="M22 9l-6 6M16 9l6 6"/>'
    : '<path d="M11 5L6 9H3v6h3l5 4V5z"/><path d="M15.5 8.5a5 5 0 0 1 0 7M18 6a8 8 0 0 1 0 12"/>';
  if (m) postJson('/api/demo/quiet', {}).catch(() => {});
}
function voiceOn() { return !demoMuted(); }
async function tourShow(i) {
  tourIdx = Math.max(0, Math.min(TOUR.length - 1, i));
  const st = TOUR[tourIdx];
  $id('tourNum').textContent = (tourIdx + 1) + '/' + TOUR.length;
  $id('tourText').textContent = st.text;
  postJson('/api/demo/quiet', {}).then(() => {
    postJson('/api/demo/caption', { text: st.text }).catch(() => {});
    if (voiceOn()) postJson('/api/demo/say', { text: st.text, step: tourIdx }).catch(() => {});
  }).catch(() => {});
  try { st.act && st.act(); } catch (e) {}
  clearTimeout(tourTimer);
  if (!tourPaused) tourTimer = setTimeout(() => { tourIdx >= TOUR.length - 1 ? tourExit(true) : tourShow(tourIdx + 1); }, st.dur);
  postJson('/state/ui', { tour_step: tourIdx }).catch(() => {});
}
function tourSetPaused(p) {
  tourPaused = p;
  $id('tourPlayIco').innerHTML = p ? '<path d="M8 5v14l11-7z" fill="currentColor" stroke="none"/>' : '<path d="M10 5v14M15 5v14"/>';
  $id('tourPlay').setAttribute('aria-label', p ? 'Resume' : 'Pause');
  clearTimeout(tourTimer);
  if (!p && tourIdx >= 0) tourTimer = setTimeout(() => { tourIdx >= TOUR.length - 1 ? tourExit(true) : tourShow(tourIdx + 1); }, 4000);
  if (p) postJson('/api/demo/quiet', {}).catch(() => {});
}
function tourStart() {
  const tb = $id('btn-tour'); if (tb) { tb.disabled = true; $id('btnTourLabel').textContent = 'Demo running'; }
  setDemoMuted(demoMuted());
  $id('tourHud').hidden = false;
  tourSetPausedIcon();
  tourPaused = false;
  tourShow(0);
}
function tourSetPausedIcon() { $id('tourPlayIco').innerHTML = '<path d="M10 5v14M15 5v14"/>'; }
function tourExit(finished) {
  const tb = $id('btn-tour'); if (tb) { tb.disabled = false; $id('btnTourLabel').textContent = 'Play the 1-minute demo'; }
  clearTimeout(tourTimer); tourTimer = null; tourIdx = -1; tourPaused = false;
  $id('tourHud').hidden = true;
  if (tourSpot) { tourSpot.classList.remove('tour-spotlight'); tourSpot = null; }
  postJson('/api/demo/quiet', {}).catch(() => {});
  postJson('/state/ui', { tour_step: null }).catch(() => {});
  if (finished) cfToast('Tour done. Copy a phrase above and ask your AI for a part.', { type: 'ok' });
}
$id('btn-tour').onclick = tourStart;
$id('tourExit').onclick = () => tourExit(false);
$id('tourNext').onclick = () => { if (tourIdx >= 0) tourShow(tourIdx + 1); };
$id('tourPrev').onclick = () => { if (tourIdx > 0) tourShow(tourIdx - 1); };
$id('tourPlay').onclick = () => tourSetPaused(!tourPaused);
$id('tourMute').onclick = () => setDemoMuted(!demoMuted());
window.addEventListener('beforeunload', () => { if (tourIdx >= 0) navigator.sendBeacon && navigator.sendBeacon(API_BASE + '/api/demo/quiet', '{}'); });

// Taskbar choice-card screenshots: real captures of the dashboard's taskbar
// button on a Windows desktop (settings-layout: never fabricate). The PNGs are
// native device pixels from a 150%-scaled display; render at
// naturalWidth / captureDpr so they sit at the physical size of the real
// taskbar. If a capture is missing, the dashed "screenshot pending" box stays.
const SHOT_CAPTURE_DPR = 1.5; // the display scale the captures were taken at
function loadChoiceShots() {
  document.querySelectorAll('.choice-shot img[data-shot]').forEach(img => {
    if (img.dataset.wired) return; img.dataset.wired = '1';
    img.onload = () => { img.style.width = (img.naturalWidth / SHOT_CAPTURE_DPR) + 'px'; img.closest('.choice-shot').classList.add('loaded'); };
    img.onerror = () => { img.closest('.choice-shot').textContent = 'screenshot pending'; };
    img.src = API_BASE + '/assets/shots/' + img.dataset.shot + '?v=' + (SERVER_VERSION || '');
    if (img.complete && img.naturalWidth) img.onload();
  });
}

// ---- Settings modal (settings-layout skill): native dialog, four dismissals, optimistic bounded saves. ----
const dlg = $id('dlgSettings');
const ENUM_LABEL = { taskbar_progress: { indeterminate: 'Indeterminate bar', off: 'Off' } };
function fmtDefault(key, v) { if (ENUM_LABEL[key]) return ENUM_LABEL[key][v] || v; if (typeof v === 'boolean') return v ? 'On' : 'Off'; return String(v); }
function applySettingsToPage() {
  if (LIB) render();
}
function renderSettings() {
  const d = SETTINGS_META.defaults || SETTINGS_DEFAULTS;
  const mode = SETTINGS.taskbar_progress === 'off' ? 'off' : 'indeterminate';
  dlg.querySelectorAll('.choice').forEach(c => { const sel = c.dataset.mode === mode; c.dataset.selected = String(sel); c.querySelector('input').checked = sel; });
  $id('stTaskbar').textContent = fmtDefault('taskbar_progress', mode); $id('stTaskbar').className = 'card-status' + (mode !== 'off' ? ' ok' : '');
  const setTgl = (id, on) => { const t = $id(id); t.classList.toggle('is-on', !!on); t.setAttribute('aria-checked', String(!!on)); };
  setTgl('tglAutoSelect', SETTINGS.auto_select_active); setTgl('tglAutoZoom', SETTINGS.auto_zoom); setTgl('tglStock', SETTINGS.show_stock_badges);
  if (document.activeElement !== $id('inpStale')) $id('inpStale').value = SETTINGS.activity_stale_seconds;
  $id('stDashboard').textContent = [SETTINGS.auto_select_active ? 'following' : null, SETTINGS.auto_zoom ? 'auto-zoom' : null, SETTINGS.show_stock_badges ? 'stock pills' : null].filter(Boolean).join(', ') || 'all off';
  $id('dhTaskbar').textContent = 'Default: ' + fmtDefault('taskbar_progress', d.taskbar_progress);
  $id('dhAutoSelect').textContent = 'Default: ' + fmtDefault('auto_select_active', d.auto_select_active);
  $id('dhAutoZoom').textContent = 'Default: ' + fmtDefault('auto_zoom', d.auto_zoom);
  $id('dhStock').textContent = 'Default: ' + fmtDefault('show_stock_badges', d.show_stock_badges);
  $id('dhStale').textContent = 'Default: ' + d.activity_stale_seconds + ' seconds';
}
async function loadSettings() {
  try {
    const j = await api('/api/settings');
    if (j && j.settings) { Object.assign(SETTINGS, j.settings); SETTINGS_META = { defaults: Object.assign({}, SETTINGS_DEFAULTS, j.defaults || {}), labels: j.labels || {}, enums: j.enums || {} }; }
    renderSettings(); applySettingsToPage();
  } catch (e) { console.warn('settings load failed', e); }
}
// Optimistic: the DOM already shows the click; a failed or stalled round trip toasts and reverts.
async function saveSetting(key, value) {
  const prev = SETTINGS[key];
  SETTINGS[key] = value; renderSettings(); applySettingsToPage();
  const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 5000);
  try {
    const r = await postJson('/api/settings', { [key]: value }, { signal: ctrl.signal });
    const j = await r.json().catch(() => ({}));
    if (!r.ok || !j.ok) throw new Error(j.error || 'the server refused the change');
    if (j.settings) { Object.assign(SETTINGS, j.settings); renderSettings(); applySettingsToPage(); }
    cfToast((SETTINGS_META.labels[key] || key) + ': ' + fmtDefault(key, SETTINGS[key]), { type: 'ok' });
    return true;
  } catch (e) {
    SETTINGS[key] = prev; renderSettings(); applySettingsToPage();
    cfToast('Setting not saved: ' + (e.name === 'AbortError' ? 'the server did not answer within 5 s' : e.message), { type: 'err' });
    return false;
  } finally { clearTimeout(timer); }
}
function openSettings(cardId) {
  renderSettings(); renderComponents(); loadCredentials(); loadChoiceShots(); loadStatus(true);
  dlg.style.margin = ''; dlg.style.left = ''; dlg.style.top = '';
  if (!dlg.open) dlg.showModal();
  dlg.focus();
  const body = $id('settingsBody'); body.scrollTop = 0;
  requestAnimationFrame(() => { body.scrollTop = 0; if (cardId && $id(cardId)) $id(cardId).scrollIntoView({ block: 'start' }); });
  reportUi();
}
$id('btn-settings').onclick = () => openSettings();
$id('btn-welcome-settings').onclick = () => openSettings('card-components');
// ---- Newbie graduation (newbie-graduation skill; John's one-X ruling kept:
// the panel's single X dismisses everything, and the say strip survives as
// the canonical one-line thread back). localStorage, per-origin, try/catch.
const GRAD_KEY = 'adom-dash-graduation';
function gradState() { try { return JSON.parse(localStorage.getItem(GRAD_KEY)) || {}; } catch (e) { return {}; } }
function gradSave(patch) { try { localStorage.setItem(GRAD_KEY, JSON.stringify(Object.assign(gradState(), patch))); } catch (e) {} }
let NEWBIE_SESSION_FRESH = false; // the ? restores Fresh for this session
function graduatedStart() { const g = gradState(); return !!(g.dismissed && g.dismissed.start) || (g.launches || 0) >= 3 || !!g.aiDriven; }
function graduatedSay() { const g = gradState(); return !!(g.dismissed && g.dismissed.say) || (g.launches || 0) >= 5 || !!g.aiDriven; }
function applyGraduation() {
  const fresh = NEWBIE_SESSION_FRESH;
  $id('welcome').hidden = !fresh && graduatedStart();
  const sayFolded = !fresh && graduatedSay();
  $id('say').classList.toggle('say-folded', sayFolded);
  $id('sayFold').hidden = !sayFolded;
  $id('newbie').hidden = false;
  $id('btn-help').setAttribute('aria-pressed', String(fresh));
}
function applyNewbiePanel() { applyGraduation(); }
$id('sayFold').onclick = () => { NEWBIE_SESSION_FRESH = true; applyGraduation(); };
// Count a successful launch: page loaded AND the status row green within the first minute.
(function () {
  let counted = false;
  const t0 = Date.now();
  const iv = setInterval(() => {
    if (counted || Date.now() - t0 > 60000) { clearInterval(iv); return; }
    if (STATUS && (STATUS.state === 'ok' || STATUS.state === 'warn')) {
      counted = true; clearInterval(iv);
      gradSave({ launches: (gradState().launches || 0) + 1 });
    }
  }, 3000);
})();
$id('btn-newbie-hide').onclick = () => {
  NEWBIE_SESSION_FRESH = false;
  gradSave({ dismissed: { say: true, start: true } });
  applyGraduation();
  cfToast('Tucked away. The ? in the header brings it back any time.', { type: 'ok' });
};
$id('btn-welcome-example').onclick = async () => {
  const p = 'get me all the files for the STM32H757XIH6 with cf';
  if (await copyText(p)) cfToast('Copied: ' + p + '. Paste it to your AI.', { type: 'ok' });
  else cfToast('Could not copy; type it into your AI instead.', { type: 'err' });
};
$id('btn-help').onclick = () => {
  NEWBIE_SESSION_FRESH = !NEWBIE_SESSION_FRESH;
  applyGraduation();
  if (NEWBIE_SESSION_FRESH) $id('newbie').scrollIntoView({ block: 'start' });
};
$id('btnCloseSettings').onclick = () => dlg.close();
dlg.addEventListener('close', reportUi);
dlg.addEventListener('click', e => {
  if (e.target !== dlg) return;
  const r = dlg.getBoundingClientRect();
  const inside = e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom;
  if (!inside) dlg.close();
});
(() => { const head = $id('settingsHead'); let drag = null;
  head.addEventListener('pointerdown', e => { if (e.target.closest('button')) return; const r = dlg.getBoundingClientRect(); drag = { dx: e.clientX - r.left, dy: e.clientY - r.top }; head.setPointerCapture(e.pointerId); });
  head.addEventListener('pointermove', e => { if (!drag) return; const r = dlg.getBoundingClientRect(); const x = Math.max(0, Math.min(e.clientX - drag.dx, innerWidth - r.width)), y = Math.max(0, Math.min(e.clientY - drag.dy, innerHeight - r.height)); dlg.style.margin = '0'; dlg.style.left = x + 'px'; dlg.style.top = y + 'px'; });
  head.addEventListener('pointerup', () => { drag = null; }); head.addEventListener('pointercancel', () => { drag = null; });
})();
dlg.querySelectorAll('.choice input').forEach(inp => inp.addEventListener('change', () => saveSetting('taskbar_progress', inp.value)));
$id('tglAutoSelect').onclick = () => saveSetting('auto_select_active', !SETTINGS.auto_select_active);
$id('tglAutoZoom').onclick = () => saveSetting('auto_zoom', !SETTINGS.auto_zoom);
$id('tglStock').onclick = () => saveSetting('show_stock_badges', !SETTINGS.show_stock_badges);
$id('inpStale').addEventListener('change', () => { const v = Math.max(10, Math.min(3600, parseInt($id('inpStale').value, 10) || 90)); $id('inpStale').value = v; saveSetting('activity_stale_seconds', v); });
(() => { const b = $id('btnRestore'); let armed = false, timer = null;
  const RESTORE = 'M13 3a9 9 0 0 0-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42A8.954 8.954 0 0 0 13 21a9 9 0 0 0 0-18m-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8z';
  const ALERT = 'M11 15h2v2h-2zm0-8h2v6h-2zm1-5C6.47 2 2 6.5 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2m0 18a8 8 0 0 1-8-8a8 8 0 0 1 8-8a8 8 0 0 1 8 8a8 8 0 0 1-8 8';
  function disarm() { armed = false; b.classList.remove('armed'); $id('restoreLabel').textContent = 'Restore defaults'; $id('restoreIcon').setAttribute('d', RESTORE); clearTimeout(timer); }
  b.onclick = async () => {
    if (!armed) { armed = true; b.classList.add('armed'); $id('restoreLabel').textContent = 'Click again to confirm'; $id('restoreIcon').setAttribute('d', ALERT); timer = setTimeout(disarm, 4000); return; }
    disarm();
    const prev = Object.assign({}, SETTINGS);
    Object.assign(SETTINGS, SETTINGS_META.defaults || SETTINGS_DEFAULTS); renderSettings(); applySettingsToPage();
    try { const j = await (await postJson('/api/settings/reset', {})).json(); if (!j.ok) throw new Error(j.error || 'reset refused'); if (j.settings) Object.assign(SETTINGS, j.settings); renderSettings(); applySettingsToPage(); cfToast('Settings restored to their defaults.', { type: 'ok' }); }
    catch (e) { Object.assign(SETTINGS, prev); renderSettings(); applySettingsToPage(); cfToast('Could not restore defaults: ' + e.message, { type: 'err' }); }
  };
})();

// ---- Components card: one row per LED facet, Install / Update rows with a live console. ----
const JOB_FOR = { tools: 'install_tools', skill: 'install_skill' };
function renderComponents() {
  const host = $id('compRows'); if (!STATUS || !STATUS.leds) { host.innerHTML = '<div class="row-desc">Reading component status</div>'; return; }
  const keys = ledKeys(STATUS), bad = keys.filter(k => STATUS.leds[k].state !== 'ok').length;
  const st = $id('stComponents'); st.textContent = bad ? bad + ' need attention' : 'all green'; st.className = 'card-status ' + (bad ? 'warn' : 'ok');
  const existing = {}; host.querySelectorAll('.comp-row').forEach(r => existing[r.dataset.key] = r);
  host.innerHTML = '';
  keys.forEach((k, i) => {
    const l = STATUS.leds[k];
    let row = existing[k];
    if (!row) {
      row = document.createElement('div'); row.className = 'comp-row'; row.dataset.key = k;
      row.innerHTML = `<span class="led"></span><span class="lab"></span><span class="det"></span>` + (JOB_FOR[k] ? `<button class="btn small" type="button" data-job="${JOB_FOR[k]}"></button>` : '') + `<div class="console" style="flex-basis:100%"></div>`;
      const btn = row.querySelector('button[data-job]');
      if (btn) btn.onclick = () => runJob(btn.dataset.job, row.querySelector('.console'), btn);
    }
    row.querySelector('.led').className = 'led ' + l.state;
    row.querySelector('.lab').textContent = l.label || k;
    row.querySelector('.det').textContent = l.detail || '';
    row.setAttribute('data-tooltip', l._hint || '');
    const btn = row.querySelector('button[data-job]');
    if (btn && !btn.dataset.busy) btn.textContent = l.state === 'ok' ? 'Update' : 'Install';
    host.appendChild(row);
    if (i < keys.length - 1) { const d = document.createElement('div'); d.className = 'divider'; host.appendChild(d); }
  });
}
async function runJob(action, con, btn) {
  if (btn) { btn.dataset.busy = '1'; btn.disabled = true; btn.textContent = 'Running'; }
  con.classList.add('show'); con.textContent = '$ ' + action + '\n';
  let id;
  try { const j = await (await postJson('/api/jobs', { action })).json(); if (!j.ok) throw new Error(j.error || 'job refused'); id = j.id; }
  catch (e) { con.textContent += 'could not start: ' + e.message + '\n'; cfToast('Could not start ' + action + ': ' + e.message, { type: 'err' }); if (btn) { delete btn.dataset.busy; btn.disabled = false; renderComponents(); } return; }
  cfToast('Started ' + action.replace(/_/g, ' ') + '; output streams in Settings.');
  const poll = setInterval(async () => {
    try {
      const j = await api('/api/jobs/' + id);
      const lines = j.lines || [];
      con.textContent = '$ ' + action + '\n' + lines.join('\n') + (lines.length ? '\n' : '');
      if (j.state === 'done' || j.state === 'failed') {
        clearInterval(poll);
        const ok = j.state === 'done' && (j.exit_code === 0 || j.exit_code == null);
        const ex = document.createElement('span'); ex.className = 'exit ' + (ok ? 'ok' : 'bad'); ex.textContent = 'exit code ' + (j.exit_code == null ? '?' : j.exit_code) + (ok ? '' : ' (failed)'); con.appendChild(ex);
        con.scrollTop = con.scrollHeight;
        cfToast(action.replace(/_/g, ' ') + (ok ? ' finished.' : ' failed with exit code ' + j.exit_code + '.'), { type: ok ? 'ok' : 'err' });
        if (btn) { delete btn.dataset.busy; btn.disabled = false; }
        await loadStatus(true); renderComponents();
      } else con.scrollTop = con.scrollHeight;
    } catch (e) { /* keep polling */ }
  }, 700);
}

// ---- Logins: pup's vault is the store; this page shows host + username only. ----
let CRED_ERR_SHOWN = false;
async function loadCredentials() {
  const host = $id('credList'), st = $id('stLogins');
  try {
    const r = await fetch(API_BASE + '/api/credentials');
    const d = await r.json().catch(() => ({ ok: false, error: 'HTTP ' + r.status }));
    if (!d.ok) {
      st.textContent = 'desktop not connected'; st.className = 'card-status warn';
      if (!CRED_ERR_SHOWN) { CRED_ERR_SHOWN = true; host.innerHTML = `<div class="inline-err"><span>${escapeHtml(d.error || 'The credential vault is unavailable.')}</span>${d._hint ? `<span class="h">${escapeHtml(d._hint)}</span>` : ''}</div>`; }
      return;
    }
    CRED_ERR_SHOWN = false;
    const creds = d.credentials || [];
    st.textContent = creds.length ? creds.length + ' saved in pup' : 'none saved yet'; st.className = 'card-status' + (creds.length ? ' ok' : '');
    host.innerHTML = creds.length ? '' : `<div class="row-desc">No vendor logins saved yet. Import them from your browser below, or sign in to a vendor once and pup captures it.${d._hint ? ' ' + escapeHtml(d._hint) : ''}</div>`;
    for (const c of creds) {
      const row = document.createElement('div'); row.className = 'cred-row';
      row.innerHTML = `<code></code><span class="u"></span><button class="btn small danger" type="button" data-tooltip="Remove this login from pup's vault on the desktop. The toast offers Undo for a few seconds before it is actually removed.">${icon('trash', 13)}Remove</button>`;
      row.querySelector('code').textContent = c.host; row.querySelector('.u').textContent = c.username || '';
      row.querySelector('button').onclick = () => {
        row.classList.add('pending');
        cfToast('Removing the login for ' + c.host + '.', { action: { label: 'Undo', fn: () => row.classList.remove('pending') }, onExpire: async () => {
          try { const r2 = await fetch(API_BASE + '/api/credentials/' + encodeURIComponent(c.host), { method: 'DELETE' }); if (!r2.ok) throw new Error('HTTP ' + r2.status); row.remove(); cfToast('Removed the login for ' + c.host + '.', { type: 'ok' }); loadCredentials(); }
          catch (e) { row.classList.remove('pending'); cfToast('Could not remove ' + c.host + ': ' + e.message, { type: 'err' }); }
        } });
      };
      host.appendChild(row);
    }
  } catch (e) { st.textContent = 'unavailable'; st.className = 'card-status bad'; if (!CRED_ERR_SHOWN) { CRED_ERR_SHOWN = true; host.innerHTML = `<div class="inline-err">Could not read the vault: ${escapeHtml(e.message)}</div>`; } }
}
$id('btnImportLogins').onclick = () => runJob('import_logins', $id('conImport'), $id('btnImportLogins'));
$id('btnLogin').onclick = async () => {
  const source = $id('selLogin').value, label = $id('selLogin').selectedOptions[0].textContent, b = $id('btnLogin');
  b.disabled = true;
  try { const j = await (await postJson('/api/login', { source })).json(); if (!j.ok) throw new Error(j.error || 'refused'); cfToast('Opening the ' + label + ' sign-in page in pup on your desktop.', { type: 'ok' }); }
  catch (e) { cfToast('Could not start the ' + label + ' sign-in: ' + e.message, { type: 'err' }); }
  finally { setTimeout(() => { b.disabled = false; }, 1500); }
};

// ---- Filters ----
$id('q').addEventListener('input', render);
$id('vendor').addEventListener('change', render);
$id('status').addEventListener('change', render);
document.addEventListener('keydown', e => { if (e.key === 'Escape' && isPhone()) $id('detail').classList.remove('open'); });

// ---- Boot: first paint never waits on one slow endpoint; each source loads independently. ----
buildSay();
loadStatus();
postJson('/state/ui', { ui_build: UI_BUILD, view: 'grid' }).catch(() => {});
loadSettings();
loadLibrary().catch(e => { $id('cards').innerHTML = '<div class="empty-state">Could not load the library: ' + escapeHtml(e.message) + '</div>'; });
api('/state').then(s => { if (s && s.version) { SERVER_VERSION = s.version; $id('popVersion').textContent = 'v' + s.version; $id('settingsVersion').textContent = 'v' + s.version; } }).catch(() => {});
pollActivity(); activityTimer = setInterval(pollActivity, 1000);
// Ambient: a cached /api/status read every 60 s (the server holds its own
// 5-minute cache on the one expensive probe). Live recompute (?force=1)
// only while the settings panel is open, every 15 s, so Install results
// turn green in front of the user without a background process treadmill.
setInterval(() => { if (dlg.open) loadStatus(true); }, 15000);
setInterval(() => { if (!dlg.open) loadStatus(); }, 60000);
reportUi();
</script>
</body>
</html>