app
KiCad Library Manager
Public Made by Adomby adom
Organize your KiCad symbol / footprint / 3D libraries fast — three priority views, inline editing, live previews, and one-click sync.
master
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>KiCad Library Manager</title>
<style>
/* Adom brand fonts — Familjen Grotesk (headlines), Satoshi (body), JetBrains Mono (code). */
@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-500-normal.woff2') format('woff2');font-weight:500;font-display:swap}
@font-face{font-family:'Satoshi';src:url('https://adom.inc/fonts/satoshi/satoshi-400-normal.woff2') format('woff2');font-weight:400;font-display:swap}
@font-face{font-family:'Satoshi';src:url('https://adom.inc/fonts/satoshi/satoshi-500-normal.woff2') format('woff2');font-weight:500;font-display:swap}
@font-face{font-family:'JetBrains Mono';src:url('https://fonts.gstatic.com/s/jetbrainsmono/v24/tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPx3cwhsk.woff2') format('woff2');font-weight:400;font-display:swap}
:root{
--bg:#0d1117; --surface:#161b22; --elevated:#1c2128; --overlay:#21262d;
--border:#30363d; --border-muted:#21262d;
--text:#e6edf3; --text-muted:#8b949e; --text-dim:#484f58;
--accent:#00b8b0; --accent-bright:#00e6dc; --accent-dim:rgba(0,184,176,.12); --accent-border:rgba(0,184,176,.25);
--green:#3fb950; --green-dim:rgba(63,185,80,.14);
--yellow:#d29922; --yellow-dim:rgba(210,153,34,.14);
--red:#f85149; --red-dim:rgba(248,81,73,.14);
--purple:#8C6BF7; --purple-dim:rgba(140,107,247,.16); --row-hover:#1c2128;
}
*{box-sizing:border-box}
html{height:100%}
body{margin:0;min-height:100%;background:var(--bg);color:var(--text);font-family:'Satoshi',-apple-system,Segoe UI,Roboto,sans-serif;font-size:13px}
.mono{font-family:'JetBrains Mono',ui-monospace,Menlo,Consolas,monospace}
h1,h2,h3{font-family:'Familjen Grotesk',sans-serif;margin:0}
button{font-family:inherit;cursor:pointer}
.app-header{display:flex;align-items:center;gap:12px;height:46px;padding:0 14px;background:var(--surface);border-bottom:1px solid var(--border);position:sticky;top:0;z-index:30}
.brand{display:flex;align-items:center;gap:9px}
.logo-chip{display:block;color:var(--accent-bright)}
.app-name{font-family:'Familjen Grotesk',sans-serif;font-weight:600;font-size:15px;letter-spacing:.2px;color:var(--accent-bright)}
.subject{margin-left:2px;color:var(--text-muted);font-size:12px}
.subject b{color:var(--text);font-weight:600}
.spacer{flex:1}
.lib-status{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--text-muted)}
.lib-status .dot{width:8px;height:8px;border-radius:50%;background:var(--text-dim);flex:none}
.lib-status.ok .dot{background:var(--green);box-shadow:0 0 6px var(--green)}
.lib-status.warn .dot{background:var(--yellow)}
.lib-status.none .dot{background:var(--red)}
.btn{background:var(--surface);border:1px solid var(--border);color:var(--text);border-radius:7px;padding:7px 12px;font-size:12.5px;font-weight:600;transition:.15s;white-space:nowrap}
.btn:hover{border-color:var(--accent-border);background:var(--elevated)}
.btn.primary{background:var(--accent);border-color:var(--accent);color:#04231f}
.btn.primary:hover{background:var(--accent-bright)}
.btn.danger:hover{border-color:var(--red);color:var(--red)}
.btn.warn{border-color:rgba(210,153,34,.4)}
.btn:disabled{opacity:.4;cursor:not-allowed}
.btn.on{background:var(--accent-dim);border-color:var(--accent-border);color:var(--text)}
.btn.syncing{background:var(--yellow-dim);border-color:var(--yellow);color:var(--text);animation:pulse 1.6s ease-in-out infinite}
@keyframes pulse{50%{opacity:.6}}
.icon-btn{background:var(--surface);border:1px solid var(--border);color:var(--text-muted);border-radius:6px;width:32px;height:30px;display:inline-flex;align-items:center;justify-content:center;font-size:14px;font-weight:600}
.icon-btn:hover{border-color:var(--accent-border);color:var(--text)}
.icon-btn:disabled{opacity:.3;cursor:not-allowed}
.icon-btn:disabled:hover{border-color:var(--border);color:var(--text-muted)}
.hist-group{display:inline-flex;gap:4px}
.controls{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:9px 14px;background:var(--bg);border-bottom:1px solid var(--border-muted);position:sticky;top:var(--controls-top,46px);z-index:20}
.pills{display:flex;gap:6px;background:var(--surface);border:1px solid var(--border);border-radius:9px;padding:3px}
.pill{background:transparent;border:1px solid transparent;color:var(--text-muted);border-radius:7px;padding:6px 13px;font-size:12.5px;font-weight:600}
.pill.active{color:var(--text);background:var(--accent-dim);border-color:var(--accent-border)}
.pill:hover:not(.active){color:var(--text)}
.search{flex:1;min-width:200px;max-width:420px}
input[type=text],input.cell{background:var(--surface);border:1px solid var(--border);color:var(--text);border-radius:7px;padding:7px 10px;font-size:13px;width:100%}
input[type=text]:focus,input.cell:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-dim)}
.seltools{display:flex;align-items:center;gap:8px}
.selcount{font-size:12px;color:var(--text-muted);min-width:84px}
.wrap{padding:0 0 80px}
table{width:100%;border-collapse:collapse}
thead th{position:sticky;top:var(--stick-top,94px);z-index:10;background:var(--surface);color:var(--text-muted);font-weight:600;font-size:11px;text-transform:uppercase;letter-spacing:.5px;text-align:left;padding:8px 12px;border-bottom:1px solid var(--border)}
.cat-row td{background:var(--elevated);border-top:1px solid var(--border);border-bottom:1px solid var(--border-muted);padding:6px 12px}
.cat-name{font-family:'Satoshi',sans-serif;font-weight:600;font-size:12px;letter-spacing:.5px;color:var(--accent-bright)}
.cat-path{color:var(--text-dim);font-size:10.5px;margin-left:9px}
.cat-count{color:var(--text-dim);font-size:11px;margin-left:8px}
.catwrap{display:flex;align-items:center;gap:0}
.lib-sync{margin-left:auto;background:transparent;border:1px solid var(--border);color:var(--text-muted);border-radius:6px;padding:3px 10px;font-size:11px;font-weight:600;opacity:0;transition:.15s}
.lib-del{margin-left:6px;background:transparent;border:1px solid var(--border);color:var(--text-muted);border-radius:6px;padding:3px 10px;font-size:11px;font-weight:600;opacity:0;transition:.15s}
.cat-row:hover .lib-sync,.cat-row:hover .lib-del{opacity:1}
.lib-sync:hover{border-color:var(--accent);color:var(--accent-bright);background:var(--accent-dim)}
.lib-sync:disabled{opacity:.6;cursor:not-allowed}
.lib-del:hover{border-color:var(--red);color:var(--red);background:var(--red-dim)}
.flag{display:inline-block;font-size:9.5px;font-weight:700;text-transform:uppercase;letter-spacing:.3px;border-radius:4px;padding:1px 6px;margin-left:8px;vertical-align:1px}
.flag.def{color:var(--purple);border:1px solid rgba(140,107,247,.45);background:var(--purple-dim)}
.flag.warn{color:var(--yellow);border:1px solid rgba(210,153,34,.45);background:var(--yellow-dim)}
.namewrap .flag{margin-left:6px}
tbody tr.item{border-bottom:1px solid var(--border-muted)}
tbody tr.item:hover{background:var(--row-hover)}
tbody tr.item.sel{background:var(--accent-dim);box-shadow:inset 3px 0 0 var(--accent)}
tbody tr.item.pending-add{background:var(--green-dim);box-shadow:inset 3px 0 0 var(--green)}
tbody tr.item.pending-del{background:var(--red-dim);box-shadow:inset 3px 0 0 var(--red)}
tbody tr.item.pending-del td .cell,tbody tr.item.pending-del td .model-name,tbody tr.item.pending-del td .name{opacity:.6}
.cat-row.cat-del .cat-name{color:var(--red)}
.pending-chip{display:inline-flex;align-items:center;gap:6px;font-size:11.5px;font-weight:600;padding:3px 9px;border-radius:999px;border:1px solid var(--accent-border);background:var(--accent-dim);color:var(--text)}
.pending-chip .pa{color:var(--green)} .pending-chip .pd{color:var(--red)} .pending-chip .pc{color:var(--yellow)}
td{padding:6px 12px;vertical-align:middle}
td.sel-col{width:34px;text-align:center}
input[type=checkbox]{accent-color:var(--accent);width:15px;height:15px;cursor:pointer}
.name{font-weight:600;font-family:'JetBrains Mono',monospace;font-size:12.5px}
input.cell{padding:5px 8px;font-size:12px;font-family:'JetBrains Mono',ui-monospace,monospace}
input.cell.ref{width:64px;text-align:center}
.lcsc-pop{position:fixed;z-index:85;display:none;min-width:220px;max-width:300px;background:var(--overlay);border:1px solid var(--border);border-radius:9px;box-shadow:0 12px 34px rgba(0,0,0,.55);padding:10px 12px;font-size:12px}
.lcsc-pop.show{display:block}
.lcsc-hd{display:flex;align-items:center;gap:7px;font-family:'JetBrains Mono',monospace;font-weight:600;color:var(--accent-bright);margin-bottom:7px}
.lcsc-hd .badge{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.3px;border-radius:4px;padding:1px 5px}
.lcsc-hd .badge.basic{color:var(--green);border:1px solid var(--green)}
.lcsc-hd .badge.pref{color:var(--accent-bright);border:1px solid var(--accent-border)}
.lcsc-hd .badge.ext{color:var(--text-dim);border:1px solid var(--border)}
.lcsc-row{display:flex;justify-content:space-between;gap:14px;padding:2px 0;color:var(--text-muted)}
.lcsc-row b{color:var(--text);font-family:'JetBrains Mono',monospace}
.lcsc-row b.stock{color:var(--green)} .lcsc-row b.nostock{color:var(--red)}
.lcsc-tiers{margin-top:5px;padding-top:6px;border-top:1px solid var(--border-muted);color:var(--text-dim);font-size:10.5px;line-height:1.6;font-family:'JetBrains Mono',monospace}
.lcsc-desc{margin-top:6px;color:var(--text-muted);font-size:11px;line-height:1.4}
.lcsc-link{display:inline-block;margin-top:7px;color:var(--accent);font-size:11px;text-decoration:none}
.lcsc-link:hover{text-decoration:underline}
.fp-auto{position:fixed;z-index:80;background:var(--overlay);border:1px solid var(--border);border-radius:9px;box-shadow:0 12px 34px rgba(0,0,0,.55);padding:5px;max-height:320px;overflow:auto}
.fp-hd{font-size:10px;text-transform:uppercase;letter-spacing:.4px;color:var(--text-dim);padding:5px 8px 4px}
.fp-opt{display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:6px;cursor:pointer}
.fp-opt:hover,.fp-opt.on{background:var(--accent-dim)}
.fp-nm{font-size:12px;color:var(--text)}
.fp-tag{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.3px;border-radius:4px;padding:0 5px;margin-left:auto;white-space:nowrap}
.fp-tag.best{color:var(--accent-bright);border:1px solid var(--accent-border);background:var(--accent-dim)}
.fp-tag.ext{color:var(--text-dim);border:1px solid var(--border)}
input.cell.fpdef{background:rgba(140,107,247,.09);border-color:rgba(140,107,247,.22)}
/* orange outline on a field changed since the last sync */
input.cell.edited{border-color:#e8923c;box-shadow:0 0 0 2px rgba(232,146,60,.35)}
input.cell.edited:focus{border-color:#e8923c;box-shadow:0 0 0 3px rgba(232,146,60,.45)}
.ds-icon.edited{border-color:#e8923c;box-shadow:0 0 0 2px rgba(232,146,60,.35)}
.desc-cell.edited{outline:2px solid rgba(232,146,60,.55);outline-offset:2px;border-radius:6px}
input.cell.fpdef:focus{border-color:rgba(140,107,247,.5);box-shadow:0 0 0 2px rgba(140,107,247,.14)}
input.cell.short{max-width:150px}
td.col-ref{width:70px}
th.col-ds,td.col-ds{width:46px;text-align:center}
.ds-icon{display:inline-flex;align-items:center;justify-content:center;width:28px;height:26px;border:1px solid var(--border);border-radius:6px;color:var(--text-dim);cursor:pointer;background:var(--surface)}
.ds-icon.on{color:var(--green);border-color:rgba(63,185,80,.4)}
.ds-icon:hover{border-color:var(--accent-border);background:var(--elevated)}
.link{color:var(--accent);text-decoration:none}
.link:hover{text-decoration:underline}
.muted{color:var(--text-dim)}
.pill-count{display:inline-flex;align-items:center;gap:5px;border:1px solid var(--border);background:var(--surface);border-radius:20px;padding:3px 10px;font-size:12px;font-weight:600;cursor:pointer;color:var(--text)}
.pill-count:hover{border-color:var(--accent-border)}
.pill-count.zero{color:var(--text-dim);cursor:default}
.pill-count .car{font-size:9px;transition:.15s}
tr.subrow td{background:#10151c;padding:0 12px 0 46px;border-bottom:1px solid var(--border-muted)}
.userlist{display:flex;flex-direction:column;gap:0;padding:6px 0}
.userlist .u{display:flex;align-items:center;gap:10px;padding:5px 8px;border-radius:6px;font-size:12px}
.userlist .u:hover{background:var(--row-hover)}
.userlist .u .ref{font-family:'JetBrains Mono',monospace;color:var(--accent);width:36px}
.userlist .u .nm{font-family:'JetBrains Mono',monospace;color:var(--text)}
.userlist .u .lb{color:var(--text-dim);margin-left:auto;font-size:11px}
.namewrap{display:flex;align-items:center;gap:5px}
.dup-btn{flex:none;border:1px solid var(--border);background:var(--surface);color:var(--text-muted);border-radius:5px;width:24px;height:24px;display:inline-flex;align-items:center;justify-content:center;font-size:12px}
.dup-btn:hover{border-color:var(--accent-border);color:var(--accent-bright)}
.tabs{display:flex;gap:4px;margin-bottom:16px;background:var(--surface);border:1px solid var(--border);border-radius:9px;padding:3px}
.tab{flex:1;background:transparent;border:1px solid transparent;color:var(--text-muted);border-radius:7px;padding:8px 12px;font-size:13px;font-weight:600}
.tab.active{color:var(--text);background:var(--accent-dim);border-color:var(--accent-border)}
.tabpane{display:none;flex-direction:column;gap:11px}
.tabpane.active{display:flex}
.field{display:flex;flex-direction:column;gap:5px}
.field label{font-size:11px;text-transform:uppercase;letter-spacing:.4px;color:var(--text-dim)}
.field select,.field input[type=text]{background:var(--surface);border:1px solid var(--border);color:var(--text);border-radius:7px;padding:8px 10px;font-size:13px;width:100%}
.field select:focus,.field input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-dim)}
.addmsg{font-size:12px;min-height:16px;color:var(--text-muted)}
.addmsg.err{color:var(--red)} .addmsg.ok{color:var(--green)}
.desc-cell{max-width:280px;display:flex;align-items:center;gap:6px}
.desc-text{color:var(--text-muted);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}
.desc-exp{flex:none;border:1px solid var(--border);background:var(--surface);color:var(--text-muted);border-radius:5px;width:22px;height:22px;display:inline-flex;align-items:center;justify-content:center;font-size:11px}
.desc-exp:hover{border-color:var(--accent-border);color:var(--text)}
.model-name{font-family:'JetBrains Mono',monospace;font-size:12.5px;font-weight:600}
.modeltag{font-size:9.5px;font-weight:700;text-transform:uppercase;letter-spacing:.3px;border-radius:4px;padding:1px 5px;margin-left:7px;border:1px solid var(--border);color:var(--text-dim)}
.modeltag.step{color:var(--accent-bright);border-color:var(--accent-border)}
.modeltag.wrl{color:var(--purple);border-color:rgba(140,107,247,.4)}
.nomodel{color:var(--text-dim);font-style:italic;font-size:12px}
.empty-state{text-align:center;color:var(--text-muted);padding:90px 20px}
.empty-state h2{font-size:19px;margin-bottom:8px;color:var(--text)}
.empty-state .big{font-size:34px;margin-bottom:14px}
/* opaque tints: stack the translucent colour over an opaque base so table rows don't show through the sticky bar */
.sync-banner{display:none;align-items:center;gap:10px;padding:8px 14px;background:linear-gradient(var(--yellow-dim),var(--yellow-dim)),var(--surface);border-bottom:1px solid rgba(210,153,34,.4);font-size:12.5px;color:var(--text);position:sticky;top:var(--header-h,46px);z-index:25}
.sync-banner.show{display:flex}
.sync-banner.synced{background:linear-gradient(var(--green-dim),var(--green-dim)),var(--surface);border-color:var(--green)}
.sync-banner.errbar{background:linear-gradient(var(--red-dim),var(--red-dim)),var(--surface);border-color:var(--red)}
.sync-banner .bmsg{flex:1;display:flex;align-items:center;gap:8px;min-width:0}
.sync-banner .log{color:var(--text-muted);font-size:11.5px;margin-left:auto;max-width:55%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.banner-x{flex:none;background:transparent;border:1px solid transparent;color:var(--text-muted);border-radius:6px;width:24px;height:24px;display:inline-flex;align-items:center;justify-content:center;font-size:13px;line-height:1}
.banner-x:hover{border-color:var(--border);color:var(--text);background:rgba(255,255,255,.06)}
.modal-bg{position:fixed;inset:0;background:rgba(1,4,9,.7);backdrop-filter:blur(6px);display:none;align-items:center;justify-content:center;z-index:50}
.modal-bg.show{display:flex}
.modal{background:var(--overlay);border:1px solid var(--border);border-radius:12px;padding:22px;width:560px;max-width:92vw;box-shadow:0 20px 60px rgba(0,0,0,.5)}
.modal h2{font-size:17px;margin-bottom:6px}
.modal p{color:var(--text-muted);margin:0 0 16px;line-height:1.5}
.modal textarea{width:100%;min-height:150px;background:var(--surface);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:11px;font-size:13px;font-family:'JetBrains Mono',monospace;line-height:1.5;resize:vertical}
.modal textarea:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-dim)}
.modal .descmeta{font-size:11px;color:var(--text-dim);margin:0 0 8px}
.modal-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:16px}
.modal .eda-pick{display:flex;flex-direction:column;gap:10px;margin-bottom:6px}
.modal .eda-pick label{display:flex;align-items:center;gap:9px;padding:10px 12px;border:1px solid var(--border);border-radius:8px;cursor:pointer;font-size:13px}
.modal .eda-pick label:hover{border-color:var(--accent-border);background:var(--accent-dim)}
/* ---- Project Doctor ---- */
.doc-banner{display:none;flex-direction:column;gap:9px;padding:11px 14px;background:linear-gradient(var(--accent-dim),var(--accent-dim)),var(--surface);border-bottom:1px solid var(--accent-border);position:sticky;top:var(--doc-top,46px);z-index:26}
.doc-banner.show{display:flex}
.doc-top{display:flex;align-items:center;gap:11px}
.doc-proj{font-family:'Familjen Grotesk',sans-serif;font-weight:600;font-size:14px;color:var(--accent-bright);display:inline-flex;align-items:center;gap:7px}
.doc-path{color:var(--text-dim);font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:40vw}
.health-wrap{display:flex;align-items:center;gap:13px}
.health-pct{font-family:'Familjen Grotesk',sans-serif;font-weight:700;font-size:18px;min-width:54px}
.health-bar{flex:1;max-width:460px;height:11px;border-radius:7px;background:var(--overlay);border:1px solid var(--border);overflow:hidden;display:flex}
.health-seg{height:100%;transition:width .4s}
.health-seg.g{background:var(--green)} .health-seg.a{background:var(--yellow)} .health-seg.r{background:var(--red)}
.health-break{color:var(--text-muted);font-size:12px;display:inline-flex;gap:12px}
.health-break b{font-family:'JetBrains Mono',monospace}
.health-break .hg b{color:var(--green)} .health-break .ha b{color:var(--yellow)} .health-break .hr b{color:var(--red)}
/* doctor rows */
tbody tr.doc-row.bad{box-shadow:inset 3px 0 0 var(--red);background:var(--red-dim)}
tbody tr.doc-row.stockrow{box-shadow:inset 3px 0 0 var(--yellow);background:var(--yellow-dim)}
tbody tr.doc-row.okrow{box-shadow:inset 3px 0 0 var(--green)}
tbody tr.doc-row.edited-row{box-shadow:inset 3px 0 0 #e8923c;background:rgba(232,146,60,.09)}
tbody tr.doc-row:hover{filter:brightness(1.12)}
.dedit{display:flex;align-items:center;gap:0;flex-wrap:wrap}
input.dcell{background:var(--surface);border:1px solid var(--border);color:var(--text);border-radius:7px;padding:5px 8px;font-size:12px;font-family:'JetBrains Mono',ui-monospace,monospace;max-width:340px}
input.dcell::placeholder{color:var(--text-dim)}
input.dcell:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-dim)}
input.dcell.edited{border-color:#e8923c;box-shadow:0 0 0 2px rgba(232,146,60,.35)}
input.dcell.edited:focus{border-color:#e8923c;box-shadow:0 0 0 3px rgba(232,146,60,.45)}
.edit-tag{font-size:9.5px;font-weight:700;text-transform:uppercase;letter-spacing:.3px;border-radius:4px;padding:1px 6px;margin-left:8px;white-space:nowrap}
.edit-tag.change{color:var(--green);border:1px solid rgba(63,185,80,.5);background:var(--green-dim)}
.edit-tag.rename{color:var(--yellow);border:1px solid rgba(210,153,34,.5);background:var(--yellow-dim)}
td.dstat-col{width:150px}
.dstat{display:inline-flex;align-items:center;gap:5px;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.3px;border-radius:5px;padding:2px 8px;white-space:nowrap}
.dstat.have,.dstat.imported{color:var(--green);border:1px solid rgba(63,185,80,.5);background:var(--green-dim)}
.dstat.stock{color:var(--yellow);border:1px solid rgba(210,153,34,.5);background:var(--yellow-dim)}
.dstat.stockfp{color:var(--accent-bright);border:1px solid var(--accent-border);background:var(--accent-dim)}
.dstat.missing{color:var(--red);border:1px solid rgba(248,81,73,.55);background:var(--red-dim)}
.doc-name{font-family:'JetBrains Mono',monospace;font-size:12.5px;font-weight:600}
.refs{font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--text-muted)}
.refs .more{color:var(--text-dim)}
.reco{display:flex;align-items:center;gap:7px;flex-wrap:wrap}
.sug-chip{font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--text);border:1px solid var(--border);background:var(--surface);border-radius:5px;padding:2px 7px;cursor:default}
.sug-chip .sc{color:var(--text-dim);margin-left:6px;font-size:9.5px}
.sug-none{color:var(--text-dim);font-size:11px;font-style:italic}
.imp-btn{border:1px solid var(--accent-border);background:var(--accent-dim);color:var(--accent-bright);border-radius:6px;padding:4px 10px;font-size:11px;font-weight:700;white-space:nowrap}
.imp-btn:hover{background:var(--accent);color:#04231f;border-color:var(--accent)}
.imp-btn:disabled{opacity:.35;cursor:not-allowed}
.proj-list{max-height:330px;overflow:auto;border:1px solid var(--border);border-radius:9px;background:var(--surface)}
.proj-item{display:flex;flex-direction:column;gap:2px;padding:9px 12px;border-bottom:1px solid var(--border-muted);cursor:pointer}
.proj-item:last-child{border-bottom:none}
.proj-item:hover{background:var(--accent-dim)}
.proj-item .pn{font-weight:600;color:var(--text);font-size:13px}
.proj-item .pp{font-size:10.5px;color:var(--text-dim);font-family:'JetBrains Mono',monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.proj-item .dl{color:var(--accent-bright);font-weight:700}
.proj-empty{padding:20px;text-align:center;color:var(--text-muted);font-size:12.5px}
/* preview dock — styled like the wiki component-page viewer (toolbar + gridded dark canvas) */
#previewDock{position:fixed;left:0;right:0;bottom:0;height:46vh;background:var(--bg);border-top:1px solid var(--border);z-index:45;display:none;flex-direction:column;box-shadow:0 -14px 44px rgba(0,0,0,.55)}
#previewDock.show{display:flex}
body.pv-open .wrap{padding-bottom:49vh}
.pv-bar{display:flex;align-items:center;gap:10px;padding:8px 14px;background:var(--surface);border-bottom:1px solid var(--border);flex-shrink:0}
.pv-title{font-family:'Familjen Grotesk',sans-serif;font-weight:600;color:var(--accent-bright);font-size:14px}
.pv-sub{color:var(--text-muted);font-size:12px;font-family:'JetBrains Mono',monospace}
.pv-body{flex:1;display:flex;min-height:0}
.pv-pane{flex:1;display:flex;flex-direction:column;min-width:0;border-right:1px solid var(--border)}
.pv-pane:last-child{border-right:none}
.pv-pane-hd{display:flex;align-items:center;gap:8px;padding:6px 12px;background:var(--surface);border-bottom:1px solid var(--border-muted);font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--text-muted);flex-shrink:0}
.pv-pane-hd .pn{color:var(--text);text-transform:none;letter-spacing:0;font-family:'JetBrains Mono',monospace;font-size:11px;margin-left:auto}
.pv-canvas{flex:1;position:relative;overflow:hidden;display:flex;align-items:center;justify-content:center;padding:22px;background-color:#0d1117;background-image:radial-gradient(ellipse at 50% 50%,rgba(0,184,176,.045) 0%,transparent 70%),linear-gradient(rgba(255,255,255,.016) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.016) 1px,transparent 1px);background-size:100% 100%,20px 20px,20px 20px}
.pv-canvas svg{width:100%;height:100%;display:block}
.pv-canvas.is3d{padding:0}
.pv-pane.hide{display:none}
.pv-empty{color:var(--text-dim);font-size:13px;font-style:italic}
.pv-foot{display:flex;align-items:center;gap:12px;padding:9px 14px;background:var(--surface);border-top:1px solid var(--border);flex-shrink:0}
.pv-foot .pv-sub{font-family:'Satoshi',sans-serif}
#toast{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);background:var(--elevated);border:1px solid var(--accent-border);color:var(--text);padding:10px 16px;border-radius:9px;font-size:12.5px;z-index:60;opacity:0;transition:.2s;pointer-events:none;max-width:80vw}
#toast.show{opacity:1}
.spin{width:13px;height:13px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;display:inline-block;animation:sp .7s linear infinite;vertical-align:-2px}
@keyframes sp{to{transform:rotate(360deg)}}
</style>
</head>
<body>
<header class="app-header">
<div class="brand">
<svg class="logo-chip" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<rect x="7" y="7" width="10" height="10" rx="1.5"/><rect x="10.5" y="10.5" width="3" height="3" rx=".5"/>
<line x1="10" y1="7" x2="10" y2="4"/><line x1="14" y1="7" x2="14" y2="4"/>
<line x1="10" y1="17" x2="10" y2="20"/><line x1="14" y1="17" x2="14" y2="20"/>
<line x1="7" y1="10" x2="4" y2="10"/><line x1="7" y1="14" x2="4" y2="14"/>
<line x1="17" y1="10" x2="20" y2="10"/><line x1="17" y1="14" x2="20" y2="14"/>
</svg>
<span class="app-name">KiCad Library Manager</span>
</div>
<span class="subject" id="summary"></span>
<span class="spacer"></span>
<span class="lib-status" id="libStatus"><span class="dot"></span><span class="txt">not probed</span></span>
<div class="hist-group">
<button class="icon-btn" id="undoBtn" title="Undo (Ctrl+Z)" disabled>↶</button>
<button class="icon-btn" id="redoBtn" title="Redo (Ctrl+Shift+Z)" disabled>↷</button>
</div>
<button class="btn warn" id="revertBtn" title="Revert all libraries to the initial probed state" disabled>⟲ Revert to backup</button>
<span class="pending-chip" id="pendingChip" style="display:none"></span>
<button class="btn primary" id="syncBtn" title="Write ALL your edits to disk">⇅ Sync all to disk</button>
<button class="btn" id="doctorBtn" title="Reconcile a downloaded project against your library">Project Doctor</button>
<button class="btn" id="probeBtn" title="Scan your computer for KiCad libraries">⌖ Probe libraries</button>
</header>
<div class="sync-banner" id="syncBanner"></div>
<!-- Project Doctor banner: project name, health bar, exit -->
<div class="doc-banner" id="docBanner">
<div class="doc-top">
<span class="doc-proj"><span id="docName"></span></span>
<span class="doc-path mono" id="docPath"></span>
<span class="spacer"></span>
<button class="btn" id="docRescan" title="Re-scan this project">⟳ Re-scan</button>
<button class="btn warn" id="docExit" title="Exit Project Doctor, back to your library">✕ Exit Doctor</button>
</div>
<div class="health-wrap">
<span class="health-pct" id="healthPct">—</span>
<div class="health-bar"><div class="health-fill" id="healthFill" style="width:0%"></div></div>
<span class="health-break" id="healthBreak"></span>
</div>
</div>
<div class="controls">
<div class="pills" id="modePills">
<button class="pill active" data-mode="symbol">◈ Symbols</button>
<button class="pill" data-mode="footprint">▣ Footprints</button>
<button class="pill" id="pillModel3d" data-mode="model3d">⬡ 3D Models</button>
</div>
<input type="text" class="search" id="search" placeholder="Filter…" autocomplete="off" />
<button class="btn primary" id="addBtn" title="Add a component from the Adom wiki, or a blank symbol/footprint">+ Add</button>
<span class="spacer"></span>
<div class="seltools">
<button class="btn" id="hideFlaggedBtn" title="Hide rows flagged for deletion">⦸ Hide flagged</button>
<span class="selcount" id="selCount">0 selected</span>
<button class="btn" id="selectAll">✓ Select all</button>
<button class="btn danger" id="deleteBtn" disabled>✕ Delete</button>
</div>
</div>
<div class="wrap">
<table id="tbl"><thead id="thead"></thead><tbody id="tbody"></tbody></table>
<div class="empty-state" id="empty" style="display:none">
<div class="big">◎</div>
<h2>No libraries loaded yet</h2>
<div>Click <b>Probe libraries</b> and I'll scan your computer (via adom-desktop) for your KiCad<br>symbol & footprint tables and your personal libraries.</div>
</div>
</div>
<!-- probe modal -->
<div class="modal-bg" id="probeModal">
<div class="modal">
<h2>Probe your KiCad libraries?</h2>
<p>I'll scan your computer for your <b>sym-lib-table</b> / <b>fp-lib-table</b> and your personal symbol, footprint and 3D-model libraries, then load them here. Nothing is changed on disk until you press <b>Sync</b>.</p>
<div class="modal-actions">
<button class="btn" id="probeSkip">Not now</button>
<button class="btn primary" id="probeGo">⌖ Probe my libraries</button>
</div>
</div>
</div>
<!-- add modal -->
<div class="modal-bg" id="addModal">
<div class="modal">
<h2>Add a component</h2>
<div class="tabs">
<button class="tab active" data-tab="wiki">⌕ From Adom wiki</button>
<button class="tab" data-tab="blank">+ Blank entry</button>
</div>
<div class="tabpane active" id="pane-wiki">
<p style="margin:0 0 4px">Fetch an MPN's symbol, footprint & 3D model from the Adom wiki and file them into your libraries. Written to disk on <b>Sync</b>.</p>
<div class="field"><label>MPN / part number</label><input type="text" id="wMpn" placeholder="e.g. STM32G473CEU6" autocomplete="off"></div>
<div class="field"><label>Target symbol library</label><select id="wSymLib"></select></div>
<div class="field"><label>Target footprint library</label><select id="wFpLib"></select></div>
<div class="addmsg" id="wMsg"></div>
<div class="modal-actions"><button class="btn" id="addClose1">Close</button><button class="btn primary" id="wGo">⌕ Fetch & add</button></div>
</div>
<div class="tabpane" id="pane-blank">
<p style="margin:0 0 4px">Create an empty symbol or footprint in a library, then fill in the fields inline.</p>
<div class="field"><label>Kind</label><select id="bKind"><option value="symbol">Symbol</option><option value="footprint">Footprint</option></select></div>
<div class="field"><label>Target library</label><select id="bLib"></select></div>
<div class="field"><label>Name</label><input type="text" id="bName" placeholder="e.g. MyPart_QFN48" autocomplete="off"></div>
<div class="addmsg" id="bMsg"></div>
<div class="modal-actions"><button class="btn" id="addClose2">Close</button><button class="btn primary" id="bGo">+ Create</button></div>
</div>
</div>
</div>
<!-- datasheet modal -->
<div class="modal-bg" id="dsModal">
<div class="modal" style="width:480px">
<h2 id="dsTitle">Datasheet</h2>
<p class="descmeta" id="dsMeta"></p>
<div class="field"><label>Datasheet URL / path</label><input type="text" id="dsInput" placeholder="https://…" spellcheck="false"></div>
<div class="modal-actions">
<button class="btn" id="dsCancel">Close</button>
<a class="btn" id="dsOpen" target="_blank" rel="noopener" style="display:none;text-decoration:none">↗ Open</a>
<button class="btn primary" id="dsSave">Save</button>
</div>
</div>
</div>
<!-- description modal -->
<div class="modal-bg" id="descModal">
<div class="modal">
<h2 id="descTitle">Description</h2>
<p class="descmeta" id="descMeta"></p>
<textarea id="descText" spellcheck="false"></textarea>
<div class="modal-actions">
<button class="btn" id="descCancel">Close</button>
<button class="btn primary" id="descSave">Save</button>
</div>
</div>
</div>
<!-- Project Doctor: project picker -->
<div class="modal-bg" id="projModal">
<div class="modal" style="width:660px">
<h2>Project Doctor</h2>
<p>Pick a KiCad project to reconcile against <b>your</b> library. I'll flag every symbol & footprint that isn't in your library, recommend substitutes you already own, and score the project's health.</p>
<div class="field"><label>Projects found on your computer</label><div class="proj-list" id="projList"><div class="proj-empty"><span class="spin"></span> scanning…</div></div></div>
<div class="field"><label>…or paste a .kicad_pro path</label><input type="text" id="projPath" placeholder="/home/you/Downloads/board/board.kicad_pro" autocomplete="off"></div>
<div class="addmsg" id="projMsg"></div>
<div class="modal-actions"><button class="btn" id="projClose">Close</button><button class="btn primary" id="projGoPath">Open path</button></div>
</div>
</div>
<!-- preview dock — symbol (left) + linked footprint (right), styled like the wiki viewer -->
<div id="previewDock">
<div class="pv-bar">
<span class="pv-title">Preview</span>
<span class="pv-sub" id="pvSub"></span>
<span class="spacer"></span>
<button class="banner-x" id="pvClose" title="Close preview">✕</button>
</div>
<div class="pv-body">
<div class="pv-pane" id="pvPaneLeft"><div class="pv-pane-hd"><span id="pvLeftHd">Schematic Symbol</span> <span class="pn" id="pvLeftName"></span></div><div class="pv-canvas" id="pvLeft"></div></div>
<div class="pv-pane" id="pvPaneRight"><div class="pv-pane-hd"><span id="pvRightHd">PCB Footprint</span> <span class="pn" id="pvRightName"></span></div><div class="pv-canvas" id="pvRight"></div></div>
</div>
</div>
<div id="toast"></div>
<script>
const $ = s => document.querySelector(s);
let STATE = { libs:{symbolLibs:[],footprintLibs:[],models3d:[]}, probe:{status:'idle'}, sync:{active:false}, history:{}, hasBackup:false };
let MODE = 'symbol';
let DOCTOR = false; // Project Doctor mode active
let syncDismiss = null;
let HIDE_FLAGGED = false;
function findItem(mode,id){
if(mode==='symbol'){ for(const l of STATE.libs.symbolLibs){ const s=l.symbols.find(x=>x.id===id); if(s) return s; } }
else if(mode==='footprint'){ for(const l of STATE.libs.footprintLibs){ const f=l.footprints.find(x=>x.id===id); if(f) return f; } }
else { return (STATE.libs.models3d||[]).find(m=>m.id===id); }
return null;
}
let SEL = { symbol:new Set(), footprint:new Set(), model3d:new Set() };
let EXPANDED = new Set(); // footprint ids whose user-list subrow is open
let descId = null;
function toast(msg){ const t=$('#toast'); t.textContent=msg; t.classList.add('show'); clearTimeout(t._t); t._t=setTimeout(()=>t.classList.remove('show'),2800); }
function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,m=>({'&':'&','<':'<','>':'>','"':'"'}[m])); }
function apiUrl(path){ return new URL('api'+path, document.baseURI).href; }
async function api(path, opts){ const r = await fetch(apiUrl(path), opts); const j = await r.json().catch(()=>({})); if(!r.ok) throw Object.assign(new Error(j.error||r.statusText),{status:r.status}); return j; }
const DS_SVG = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>`;
const MAG_SVG = `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="10" cy="10" r="7"/><line x1="20" y1="20" x2="15" y2="15"/></svg>`;
function isUrl(v){ return /^(https?:\/\/|www\.|ftp:\/\/)/i.test((v||'').trim()); }
function curSel(){ return SEL[MODE]; }
function modelExt(name){ const e=(name||'').toLowerCase(); if(e.endsWith('.step')||e.endsWith('.stp'))return'step'; if(e.endsWith('.wrl'))return'wrl'; if(e.endsWith('.glb'))return'glb'; if(e.endsWith('.stl'))return'stl'; return''; }
function updateStickyOffsets(){
const h=$('.app-header'), c=$('.controls'); if(!h||!c) return;
const hh=h.offsetHeight, ch=c.offsetHeight;
const sb=$('#syncBanner'); const bh=(sb&&sb.classList.contains('show'))?sb.offsetHeight:0;
const db=$('#docBanner'); const dh=(db&&db.classList.contains('show'))?db.offsetHeight:0;
const r=document.documentElement.style;
r.setProperty('--header-h', hh+'px'); // header sticks at 0
r.setProperty('--doc-top', (hh+bh)+'px'); // doctor banner sticks under header + sync banner
r.setProperty('--controls-top', (hh+bh+dh)+'px'); // controls under whatever banners show
r.setProperty('--stick-top', (hh+bh+dh+ch)+'px'); // column-header row sticks under controls
}
window.addEventListener('resize', updateStickyOffsets);
function updateLibStatus(){
const el=$('#libStatus'); const pr=STATE.probe||{}; let cls,txt;
if(pr.status==='probing'){ cls='warn'; txt='probing…'; }
else if(pr.status==='error'){ cls='none'; txt='probe error'; }
else if(pr.status==='done'){ const c=pr.counts||{}; cls='ok'; txt=`${c.symbols||0} symbols · ${c.footprints||0} footprints · ${c.models3d||0} models`; }
else { cls='none'; txt='not probed'; }
el.className='lib-status '+cls; el.querySelector('.txt').textContent=txt;
}
function updateSummary(){
if(DOCTOR && STATE.project){ const c=STATE.project.counts||{}; const n=MODE==='footprint'?c.footprints:c.symbols; $('#summary').innerHTML=`· <b>${n||0}</b> ${MODE==='footprint'?'footprints':'symbols'} in project`; return; }
const L=STATE.libs;
const counts={ symbol:L.symbolLibs.reduce((n,l)=>n+l.symbols.length,0), footprint:L.footprintLibs.reduce((n,l)=>n+l.footprints.length,0), model3d:L.models3d.length };
$('#summary').innerHTML = STATE.probe.status==='done' ? `· <b>${counts[MODE]}</b> ${MODE==='model3d'?'3D models':MODE+'s'}` : '';
}
function updateHistButtons(){
const h=STATE.history||{};
$('#undoBtn').disabled=!h.canUndo; $('#redoBtn').disabled=!h.canRedo;
$('#undoBtn').title = h.canUndo ? `Undo: ${h.undoLabel||''} (Ctrl+Z)` : 'Nothing to undo (Ctrl+Z)';
$('#redoBtn').title = h.canRedo ? `Redo: ${h.redoLabel||''} (Ctrl+Shift+Z)` : 'Nothing to redo (Ctrl+Shift+Z)';
$('#revertBtn').disabled=!STATE.hasBackup;
}
function updateSyncUI(){
const s=STATE.sync||{}; const b=$('#syncBtn'); const banner=$('#syncBanner');
const running = s.status==='running'||s.status==='reverting';
b.disabled=running; b.classList.toggle('syncing',running); b.classList.toggle('primary',!running);
b.textContent = running ? '● Syncing…' : '⇅ Sync all to disk';
const sig=syncSig(s);
if(running){ banner.className='sync-banner show'; banner.innerHTML=`<span class="spin"></span> ${s.status==='reverting'?'Undoing on-disk changes…':'Writing libraries to disk…'}`; return; }
if(syncDismiss===sig){ banner.className='sync-banner'; return; } // user closed this one
const closeX=`<button class="banner-x" data-closebanner title="Dismiss">✕</button>`;
if(s.status==='synced'){ banner.className='sync-banner show synced'; banner.innerHTML=`<span class="bmsg">✓ Synced to disk${s.lastSyncedAt?' at '+new Date(s.lastSyncedAt).toLocaleTimeString():''} — ready to sync again<span class="log">${esc((s.log||[]).slice(-1)[0]||'')}</span></span>${closeX}`; }
else if(s.status==='error'){ banner.className='sync-banner show errbar'; banner.innerHTML=`<span class="bmsg">⚠ Sync error: ${esc(s.error||'')}</span>${closeX}`; }
else { banner.className='sync-banner'; }
}
function syncSig(s){ s=s||{}; return `${s.status}|${s.active}|${s.lastSyncedAt}|${(s.log||[]).slice(-1)[0]||''}`; }
$('#syncBanner').addEventListener('click', e=>{ if(e.target.closest('[data-closebanner]')){ syncDismiss=syncSig(STATE.sync); $('#syncBanner').className='sync-banner'; updateStickyOffsets(); } });
// ---------- rendering
const THEAD = {
symbol:`<tr><th class="sel-col"></th><th>Symbol</th><th class="col-ref">Ref</th><th>Footprint</th><th class="col-ds" title="Datasheet">DS</th><th>LCSC / JLC PN</th><th>Description</th></tr>`,
footprint:`<tr><th class="sel-col"></th><th>Footprint</th><th>3D model</th><th>Symbols using it</th></tr>`,
model3d:`<tr><th class="sel-col"></th><th>3D model</th><th>Used by footprints</th></tr>`,
};
function matchSearch(text){ const q=$('#search').value.trim().toLowerCase(); if(!q) return true; return (text||'').toLowerCase().includes(q); }
function updatePendingChip(){
const L=STATE.libs; let add=0,del=0,chg=0;
for(const lib of L.symbolLibs) for(const s of lib.symbols){ if(s.pendingDelete)del++; else if(s.pendingAdd)add++; else if(s.pendingEdit)chg++; }
for(const lib of L.footprintLibs) for(const f of lib.footprints){ if(f.pendingDelete)del++; else if(f.added)add++; }
for(const md of L.models3d){ if(md.pendingDelete)del++; else if(md.added)add++; }
const pe=(STATE.project&&STATE.project.counts&&STATE.project.counts.edits)||0;
const el=$('#pendingChip');
if(!add&&!del&&!chg&&!pe){ el.style.display='none'; return; }
el.style.display='inline-flex';
const parts=[];
if(add) parts.push(`<span class="pa">${add} new</span>`);
if(del) parts.push(`<span class="pd">${del} deleted</span>`);
if(chg) parts.push(`<span class="pc">${chg} changed</span>`);
if(pe) parts.push(`<span class="pc">${pe} project edit${pe===1?'':'s'}</span>`);
el.innerHTML=parts.join(' · ');
}
function render(){
updateLibStatus(); updateSummary(); updateHistButtons(); updateSyncUI(); updatePendingChip();
applyDoctorChrome();
if(DOCTOR){ renderDoctor(); updateStickyOffsets(); return; }
const has = STATE.libs.symbolLibs.length||STATE.libs.footprintLibs.length||STATE.libs.models3d.length;
$('#empty').style.display = has?'none':'block';
$('#tbl').style.display = has?'table':'none';
$('#thead').innerHTML = THEAD[MODE];
const tbody=$('#tbody'); tbody.innerHTML='';
if(MODE==='symbol') renderSymbol(tbody);
else if(MODE==='footprint') renderFootprint(tbody);
else renderModel(tbody);
updateSelCount(); updateStickyOffsets();
}
function pendClass(it){ return it.pendingDelete?' pending-del':((it.pendingAdd||it.added)?' pending-add':''); }
function catRow(name, path, count, span, lib, del){
const tr=document.createElement('tr'); tr.className='cat-row'+(lib&&lib.pendingDelete?' cat-del':'');
let badges='';
if(lib){
if(lib.pendingDelete) badges+='<span class="flag warn" style="color:var(--red);border-color:rgba(248,81,73,.45);background:var(--red-dim)">flagged for deletion</span>';
if(lib.isDefault) badges+='<span class="flag def" title="A KiCad default / stock library, not one of your personal libraries">KiCad default</span>';
if(lib.inTable===false) badges+='<span class="flag warn" title="This library is not registered in your sym-lib-table / fp-lib-table">not in lib table</span>';
}
const libBtns = del ? `<button class="lib-sync" data-synclib="${esc(del.mode)}" data-nick="${esc(del.nick)}" title="Sync just this library to disk">⇅ Sync</button><button class="lib-del" data-dellib="${esc(del.mode)}" data-nick="${esc(del.nick)}" title="Delete this library">✕ Delete library</button>` : '';
tr.innerHTML=`<td colspan="${span}"><div class="catwrap"><span class="cat-name">${esc(name)}</span>${badges}${path?`<span class="cat-path mono">${esc(path)}</span>`:''}<span class="cat-count">${count}</span>${libBtns}</div></td>`;
return tr;
}
function selBox(id){ return `<td class="sel-col"><input type="checkbox" data-sel="${esc(id)}" ${curSel().has(id)?'checked':''}></td>`; }
function renderSymbol(tbody){
for(const lib of STATE.libs.symbolLibs){
const rows=lib.symbols.filter(s=>!(HIDE_FLAGGED&&s.pendingDelete) && matchSearch(`${s.name} ${s.ref} ${s.value} ${s.footprint} ${s.mpn} ${s.datasheet} ${s.description}`));
if(!rows.length) continue;
tbody.appendChild(catRow(lib.nick, lib.path, lib.symbols.length, 7, lib, {mode:'symbol',nick:lib.nick}));
for(const s of rows){
const tr=document.createElement('tr'); tr.className='item'+(curSel().has(s.id)?' sel':'')+pendClass(s); tr.dataset.id=s.id;
const fpDefault = s.fpNick && (STATE.libs.defaultFpLibs||[]).some(n=>n.toLowerCase()===s.fpNick.toLowerCase());
const ec=f=>(s.editedFields&&s.editedFields[f])?' edited':'';
tr.innerHTML = selBox(s.id)+
`<td><div class="namewrap"><input class="cell name${ec('name')}" data-id="${esc(s.id)}" data-field="name" value="${esc(s.name)}" spellcheck="false"><button class="dup-btn" data-previewsym="${esc(s.id)}" title="Preview symbol + footprint">${MAG_SVG}</button><button class="dup-btn" data-dup="${esc(s.id)}" title="Duplicate this symbol">⧉</button></div></td>`+
`<td class="col-ref"><input class="cell ref${ec('ref')}" data-id="${esc(s.id)}" data-field="ref" value="${esc(s.ref)}" spellcheck="false"></td>`+
`<td><input class="cell${fpDefault?' fpdef':''}${ec('footprint')}" data-id="${esc(s.id)}" data-field="footprint" value="${esc(s.footprint)}" spellcheck="false" placeholder="lib:footprint" title="${fpDefault?'Linked footprint — KiCad default library':'Linked footprint (lib:name)'}"></td>`+
`<td class="col-ds"><span class="ds-icon ${isUrl(s.datasheet)?'on':''}${ec('datasheet')}" data-dsedit="${esc(s.id)}" title="${s.datasheet?'Datasheet: '+esc(s.datasheet)+' — click to edit':'No datasheet — click to add'}">${DS_SVG}</span></td>`+
`<td><input class="cell short${ec('mpn')}" data-id="${esc(s.id)}" data-field="mpn" value="${esc(s.mpn)}" spellcheck="false" placeholder="—"></td>`+
`<td><div class="desc-cell${ec('description')}"><span class="desc-text" title="${esc(s.description)}">${s.description?esc(s.description):'<span class=muted>—</span>'}</span><button class="desc-exp" data-desc="${esc(s.id)}" title="View / edit full description">⤢</button></div></td>`;
tbody.appendChild(tr);
}
}
}
function renderFootprint(tbody){
for(const lib of STATE.libs.footprintLibs){
const rows=lib.footprints.filter(f=>!(HIDE_FLAGGED&&f.pendingDelete) && matchSearch(`${f.name} ${f.model} ${(f.usedBy||[]).map(u=>u.name).join(' ')}`));
if(!rows.length) continue;
tbody.appendChild(catRow(lib.nick+'.pretty', lib.path, lib.footprints.length, 4, lib, {mode:'footprint',nick:lib.nick}));
for(const f of rows){
const tr=document.createElement('tr'); tr.className='item'+(curSel().has(f.id)?' sel':'')+pendClass(f); tr.dataset.id=f.id;
const mt=modelExt(f.model);
const model = f.model ? `<span class="mono">${esc(f.model.split('/').pop())}</span>${mt?`<span class="modeltag ${mt}">${mt}</span>`:''}` : '<span class="nomodel">no 3D model</span>';
const n=f.useCount||0; const open=EXPANDED.has(f.id);
const count = n ? `<span class="pill-count" data-exp="${esc(f.id)}"><span class="car">${open?'▼':'▶'}</span>${n} symbol${n===1?'':'s'}</span>` : `<span class="pill-count zero">0 symbols</span>`;
tr.innerHTML = selBox(f.id)+
`<td class="name"><div class="namewrap">${esc(f.name)}<button class="dup-btn" data-previewfp="${esc(f.id)}" title="Preview footprint">${MAG_SVG}</button></div></td>`+
`<td title="${esc(f.model||'')}">${model}</td>`+
`<td>${count}</td>`;
tbody.appendChild(tr);
if(open && n){
const sub=document.createElement('tr'); sub.className='subrow';
sub.innerHTML=`<td colspan="4"><div class="userlist">${f.usedBy.map(u=>`<div class="u"><span class="ref">${esc(u.ref||'')}</span><span class="nm">${esc(u.name)}</span><span class="lb">${esc(u.lib)}${u.value?' · '+esc(u.value):''}</span></div>`).join('')}</div></td>`;
tbody.appendChild(sub);
}
}
}
}
function renderModel(tbody){
const groups={};
for(const md of STATE.libs.models3d){ const f=md.folder||'(root)'; (groups[f]=groups[f]||[]).push(md); }
for(const folder of Object.keys(groups).sort()){
const list=groups[folder].filter(md=>!(HIDE_FLAGGED&&md.pendingDelete) && matchSearch(`${md.name} ${(md.usedBy||[]).map(u=>u.name).join(' ')}`));
if(!list.length) continue;
tbody.appendChild(catRow(folder, groups[folder][0].path?groups[folder][0].path.replace(/\/[^/]*$/,''):'', groups[folder].length, 3, null, {mode:'model3d',nick:folder}));
for(const md of list){
const tr=document.createElement('tr'); tr.className='item'+(curSel().has(md.id)?' sel':'')+pendClass(md); tr.dataset.id=md.id;
const mt=modelExt(md.name);
const users=(md.usedBy||[]);
const usedBy = users.length ? users.map(u=>`<span class="mono">${esc(u.name)}</span>`).join(' <span class=muted>·</span> ') : '<span class="muted">not referenced by any footprint</span>';
tr.innerHTML = selBox(md.id)+
`<td title="${esc(md.path||'')}"><div class="namewrap"><span class="model-name">${esc(md.name)}</span>${mt?`<span class="modeltag ${mt}">${mt}</span>`:''}<button class="dup-btn" data-previewmodel="${esc(md.id)}" title="Preview 3D model">${MAG_SVG}</button></div></td>`+
`<td>${usedBy}</td>`;
tbody.appendChild(tr);
}
}
}
function selectionAllFlagged(){
const ids=[...curSel()]; if(!ids.length) return false;
return ids.every(id=>{ const it=findItem(MODE,id); return it && it.pendingDelete; });
}
function updateSelCount(){
const n=curSel().size;
$('#selCount').textContent = `${n} selected`;
$('#deleteBtn').disabled = n===0;
const restore = selectionAllFlagged();
$('#deleteBtn').textContent = restore ? '↻ Restore' : '✕ Delete';
$('#deleteBtn').classList.toggle('danger', !restore);
}
// ---------- Project Doctor
const DOC_THEAD = {
symbol:`<tr><th class="dstat-col">Status</th><th>Symbol</th><th>Footprint</th><th>Value</th><th>Used by</th></tr>`,
footprint:`<tr><th class="dstat-col">Status</th><th>Footprint</th><th>Used by</th></tr>`,
};
function applyDoctorChrome(){
const on = DOCTOR;
if(on && MODE==='model3d') MODE='symbol';
$('#docBanner').classList.toggle('show', on && !!STATE.project);
$('#doctorBtn').classList.toggle('on', on);
$('#pillModel3d').style.display = on?'none':'';
$('#addBtn').style.display = on?'none':'';
const st=document.querySelector('.seltools'); if(st) st.style.display = on?'none':'';
[...$('#modePills').children].forEach(pl=>pl.classList.toggle('active',pl.dataset.mode===MODE));
if(on && STATE.project) updateHealthBar();
}
function updateHealthBar(){
const p=STATE.project; if(!p) return; const c=p.counts||{}; const total=c.parts||0;
const g=c.green||0, a=c.amber||0, r=c.red||0, pct=c.pct||0;
$('#docName').textContent = p.name||'';
$('#docPath').textContent = p.dir||'';
$('#healthPct').textContent = total? pct+'%':'—';
$('#healthPct').style.color = pct>=85?'var(--green)':pct>=50?'var(--yellow)':'var(--red)';
const w=x=>total?(100*x/total):0;
const bar=$('#healthFill'); bar.style.cssText='width:100%;height:100%;display:flex';
bar.innerHTML = `<div class="health-seg g" style="width:${w(g)}%"></div><div class="health-seg a" style="width:${w(a)}%"></div><div class="health-seg r" style="width:${w(r)}%"></div>`;
$('#healthBreak').innerHTML = `<span class="hg"><b>${g}</b> your own</span><span class="ha"><b>${a}</b> stock symbol</span><span class="hr"><b>${r}</b> missing</span>`;
}
function docStatus(it, mode){
if(it.status==='imported') return {row:'okrow', cls:'imported', label:'✓ added'};
if(it.status==='have') return {row:'okrow', cls:'have', label:'✓ in your library'};
if(it.status==='missing') return {row:'bad', cls:'missing', label:'✕ missing'};
if(mode==='footprint') return {row:'', cls:'stockfp', label:'stock ✓'}; // stock footprint = healthy
return {row:'stockrow', cls:'stock', label:'stock'}; // stock symbol = flagged
}
function docFindItem(kind,id){ if(!STATE.project) return null; for(const g of (kind==='symbol'?STATE.project.symbolLibs:STATE.project.footprintLibs)){ const it=g.items.find(x=>x.id===id); if(it) return it; } return null; }
async function postProjectEdit(kind,id,field,value){
try{ const r=await api('/project-edit',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({kind,id,field,value})}); STATE.project=r.project; render(); }
catch(e){ toast('Edit failed: '+e.message); }
}
function docEditCell(it, pkind, field, origVal){
const edited = !!(it.edit && it.edit[field]!=null);
const cur = edited ? it.edit[field] : (origVal||'');
const ei = it.editInfo && it.editInfo[field];
let tag='';
if(edited){
if(ei && ei.action==='change') tag=`<span class="edit-tag change" title="repoints the project to your ${esc(ei.libId||ei.fpId||'')}">↳ change</span>`;
else tag=`<span class="edit-tag rename" title="renames it to “${esc(cur)}” inside the project (not in your library)">✎ rename</span>`;
}
return `<td><div class="dedit"><input class="dcell${edited?' edited':''}" data-pkind="${esc(pkind)}" data-pid="${esc(it.id)}" data-pfield="${esc(field)}" value="${esc(cur)}" spellcheck="false" placeholder="${esc(origVal||'')}" autocomplete="off" title="Type or pick a ${field==='symbol'?'symbol':'footprint'} from your library to remap onto — clear to undo">${tag}</div></td>`;
}
function docRow(it, mode){
const st=docStatus(it,mode);
const tr=document.createElement('tr'); tr.className=('item doc-row '+(it.edit?'edited-row':st.row)).trim(); tr.dataset.id=it.id;
const refs=(it.refs||[]);
const refsTxt=(refs.slice(0,8).map(esc).join(' ')||'<span class=muted>—</span>')+(refs.length>8?` <span class="more">+${refs.length-8}</span>`:'')+(it.count>1?` <span class="more">(${it.count}×)</span>`:'');
const statusTd=`<td class="dstat-col"><span class="dstat ${st.cls}">${st.label}</span></td>`;
const usedByTd=`<td class="refs">${refsTxt}</td>`;
if(mode==='symbol'){
const valDisp = it.value ? `<span class="refs">${esc(it.value)}${it.valMulti?' <span class="more">…</span>':''}</span>` : '<span class="muted">—</span>';
tr.innerHTML = statusTd + docEditCell(it,'symbol','symbol',it.name) + docEditCell(it,'symbol','footprint',it.footprint) + `<td>${valDisp}</td>` + usedByTd;
} else tr.innerHTML = statusTd + docEditCell(it,'footprint','footprint',it.name) + usedByTd;
return tr;
}
function docCatRow(g, mode, count){
const tr=document.createElement('tr'); tr.className='cat-row';
const span = mode==='symbol'?5:3;
tr.innerHTML=`<td colspan="${span}"><div class="catwrap"><span class="cat-name">${esc(g.name)}</span>${count>1?`<span class="cat-count">${count}</span>`:''}</div></td>`;
return tr;
}
function renderDoctor(){
const p=STATE.project;
$('#tbl').style.display = p?'table':'none';
$('#empty').style.display = p?'none':'block';
const tbody=$('#tbody'); tbody.innerHTML='';
if(!p){ $('#thead').innerHTML=''; return; }
const mode = MODE==='footprint'?'footprint':'symbol';
$('#thead').innerHTML = DOC_THEAD[mode];
for(const g of (mode==='symbol'?p.symbolLibs:p.footprintLibs)){
const items=g.items.filter(it=>matchSearch(`${it.name} ${it.nick} ${(it.refs||[]).join(' ')} ${(it.suggestions||[]).map(s=>s.name).join(' ')}`));
if(!items.length) continue;
tbody.appendChild(docCatRow(g, mode, items.length));
for(const it of items) tbody.appendChild(docRow(it, mode));
}
}
async function openDoctor(){
$('#projModal').classList.add('show'); $('#projMsg').textContent=''; $('#projPath').value='';
$('#projList').innerHTML='<div class="proj-empty"><span class="spin"></span> scanning your computer for projects…</div>';
try{
const r=await api('/projects',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});
const list=r.projects||[];
if(!list.length){ $('#projList').innerHTML='<div class="proj-empty">No .kicad_pro files found under Documents / Downloads / KiCad.</div>'; return; }
$('#projList').innerHTML=list.map(p=>`<div class="proj-item" data-pro="${esc(p.pro)}"><span class="pn">${esc(p.name)}${/(^|\/)Downloads(\/|$)/.test(p.dir)?' <span class="dl">· Downloads</span>':''}</span><span class="pp">${esc(p.dir)}</span></div>`).join('');
}catch(e){ $('#projList').innerHTML=`<div class="proj-empty">Couldn't list projects: ${esc(e.message)}</div>`; }
}
async function pickProject(pro){
if(!pro) return;
$('#projMsg').className='addmsg'; $('#projMsg').innerHTML='<span class="spin"></span> scanning project…';
try{
const r=await api('/project-scan',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({pro})});
STATE.project=r.project; DOCTOR=true; if(MODE==='model3d')MODE='symbol';
ensureStockFps(); // preload KiCad default footprints for the remap autocomplete
$('#projModal').classList.remove('show'); render();
}catch(e){ $('#projMsg').className='addmsg err'; $('#projMsg').textContent='Scan failed: '+e.message; }
}
async function exitDoctor(){ DOCTOR=false; STATE.project=null; try{ await api('/project-close',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}); }catch(e){} render(); }
async function rescanProject(){ if(!STATE.project) return; toast('Re-scanning…'); await pickProject(STATE.project.pro); }
// doctor inline-edit autocomplete: a dropdown of YOUR library symbols/footprints to remap onto
const docDD=document.createElement('div'); docDD.className='fp-auto'; docDD.style.display='none'; document.body.appendChild(docDD);
let docInput=null, docItems=[], docSel=-1;
function docSuggestions(item, field){ // the right recommendation list for this cell
if(!item) return [];
if(field==='footprint') return item.fpSuggestions || item.suggestions || []; // symbol-row fp vs fp-row
return item.suggestions || [];
}
let STOCK_FPS=null, STOCK_FPS_LOADING=false;
async function ensureStockFps(){ if(STOCK_FPS||STOCK_FPS_LOADING) return; STOCK_FPS_LOADING=true; try{ const r=await api('/stock-footprints'); STOCK_FPS=r.footprints||[]; }catch(e){ STOCK_FPS=[]; } finally{ STOCK_FPS_LOADING=false; } }
function docCandidates(field, query, item){
const q=(query||'').trim().toLowerCase();
const pool=[];
if(field==='symbol'){ for(const l of STATE.libs.symbolLibs){ if(l.isDefault) continue; for(const s of l.symbols) pool.push({value:l.nick+':'+s.name, name:s.name}); } }
else {
for(const l of STATE.libs.footprintLibs){ if(l.isDefault) continue; for(const f of l.footprints) pool.push({value:l.nick+':'+f.name, name:f.name}); }
if(STOCK_FPS) for(const s of STOCK_FPS) pool.push(s); // + the KiCad default footprints
}
if(!q){
// recommendations first (straight from the scorer, so KiCad-stock matches show even though
// they're not in your local pool), then the rest of your library
const recs=docSuggestions(item, field);
const seen=new Set(); const first=[];
for(const r of recs){ const val=(r.nick?r.nick+':':'')+r.name; if(seen.has(val))continue; seen.add(val); first.push({value:val, name:r.name, rec:true, why:r.why}); }
const rest=pool.filter(p=>!seen.has(p.value));
return [...first,...rest].slice(0,16);
}
return pool.filter(p=>p.name.toLowerCase().includes(q)||p.value.toLowerCase().includes(q))
.sort((a,b)=>((a.name.toLowerCase().startsWith(q)?0:1)-(b.name.toLowerCase().startsWith(q)?0:1))||a.name.length-b.name.length)
.slice(0,16);
}
function showDocAuto(input, seed){
const item=docFindItem(input.dataset.pkind, input.dataset.pid);
const q = seed ? '' : input.value; // on focus/click: show recommendations, ignore current value
const list=docCandidates(input.dataset.pfield, q, item); docItems=list; docSel=-1; docInput=input;
if(!list.length){ hideDocAuto(); return; }
const field=input.dataset.pfield;
docDD.innerHTML=`<div class="fp-hd">recommended ${field==='symbol'?'symbols':'footprints'} in your library — pick one</div>`+list.map((o,i)=>`<div class="fp-opt" data-i="${i}"><span class="fp-nm">${esc(o.value)}</span>${o.rec?`<span class="fp-tag best">${o.why?esc(o.why):'match'}</span>`:''}</div>`).join('');
const r=input.getBoundingClientRect(); docDD.style.left=r.left+'px'; docDD.style.top=(r.bottom+4)+'px'; docDD.style.minWidth=Math.max(r.width,260)+'px'; docDD.style.display='block';
}
function hideDocAuto(){ docDD.style.display='none'; docInput=null; docSel=-1; docItems=[]; }
function docHi(){ [...docDD.querySelectorAll('.fp-opt')].forEach((el,i)=>el.classList.toggle('on',i===docSel)); }
function applyDocPick(i){ if(!docInput||!docItems[i]) return; const inp=docInput; inp.value=docItems[i].value; hideDocAuto(); inp.dispatchEvent(new Event('change',{bubbles:true})); }
docDD.addEventListener('mousedown', e=>{ const o=e.target.closest('[data-i]'); if(o){ e.preventDefault(); applyDocPick(+o.dataset.i); } });
// ---------- events
$('#modePills').addEventListener('click', e=>{ const b=e.target.closest('.pill'); if(!b)return; MODE=b.dataset.mode; [...$('#modePills').children].forEach(p=>p.classList.toggle('active',p.dataset.mode===MODE)); render(); });
$('#search').addEventListener('input', ()=>render());
// Project Doctor wiring
$('#doctorBtn').onclick=()=>openDoctor();
$('#docExit').onclick=()=>exitDoctor();
$('#docRescan').onclick=()=>rescanProject();
$('#projClose').onclick=()=>$('#projModal').classList.remove('show');
$('#projGoPath').onclick=()=>pickProject($('#projPath').value.trim());
$('#projPath').addEventListener('keydown', e=>{ if(e.key==='Enter') pickProject($('#projPath').value.trim()); });
$('#projList').addEventListener('click', e=>{ const it=e.target.closest('[data-pro]'); if(it) pickProject(it.dataset.pro); });
// doctor inline edits + autocomplete (dcell inputs; distinct from the library's .cell handlers)
$('#tbody').addEventListener('input', e=>{ if(e.target.classList&&e.target.classList.contains('dcell')) showDocAuto(e.target,false); });
$('#tbody').addEventListener('focusin', e=>{ if(e.target.classList&&e.target.classList.contains('dcell')){ const inp=e.target; if(inp.dataset.pfield==='footprint') ensureStockFps(); showDocAuto(inp,true); try{ inp.select(); }catch(_){} } });
$('#tbody').addEventListener('focusout', e=>{ if(e.target.classList&&e.target.classList.contains('dcell')){ const inp=e.target; setTimeout(()=>{ if(document.activeElement!==inp) hideDocAuto(); },150); } });
$('#tbody').addEventListener('change', e=>{ const t=e.target; if(t.classList&&t.classList.contains('dcell')) postProjectEdit(t.dataset.pkind,t.dataset.pid,t.dataset.pfield,t.value); });
$('#tbody').addEventListener('keydown', e=>{
const t=e.target; if(!t.classList||!t.classList.contains('dcell')) return;
if(docDD.style.display==='block' && docItems.length){
if(e.key==='ArrowDown'){ e.preventDefault(); docSel=Math.min(docItems.length-1,docSel+1); docHi(); return; }
if(e.key==='ArrowUp'){ e.preventDefault(); docSel=Math.max(0,docSel-1); docHi(); return; }
if(e.key==='Enter'){ e.preventDefault(); if(docSel>=0) applyDocPick(docSel); else t.blur(); return; }
if(e.key==='Escape'){ hideDocAuto(); return; }
} else if(e.key==='Enter'){ t.blur(); }
});
$('#tbody').addEventListener('click', e=>{
const sb=e.target.closest('[data-sel]'); if(sb){ const id=sb.dataset.sel; if(sb.checked) curSel().add(id); else curSel().delete(id); e.target.closest('tr').classList.toggle('sel',sb.checked); updateSelCount(); return; }
const exp=e.target.closest('[data-exp]'); if(exp){ const id=exp.dataset.exp; if(EXPANDED.has(id))EXPANDED.delete(id); else EXPANDED.add(id); render(); return; }
const dx=e.target.closest('[data-desc]'); if(dx){ openDesc(dx.dataset.desc); return; }
const dup=e.target.closest('[data-dup]'); if(dup){ duplicate(dup.dataset.dup); return; }
const psym=e.target.closest('[data-previewsym]'); if(psym){ openPreview({symbolId:psym.dataset.previewsym}); return; }
const pfp=e.target.closest('[data-previewfp]'); if(pfp){ openPreview({footprintId:pfp.dataset.previewfp}); return; }
const pmd=e.target.closest('[data-previewmodel]'); if(pmd){ openPreview({modelId:pmd.dataset.previewmodel}); return; }
const dse=e.target.closest('[data-dsedit]'); if(dse){ openDs(dse.dataset.dsedit); return; }
const dl=e.target.closest('[data-dellib]'); if(dl){ deleteLibrary(dl.dataset.dellib, dl.dataset.nick); return; }
const sl=e.target.closest('[data-synclib]'); if(sl){ syncLibrary(sl.dataset.synclib, sl.dataset.nick, sl); return; }
});
async function syncLibrary(mode, nick, btn){
const what = mode==='model3d' ? `the 3D-model folder "${nick}"` : `the ${mode} library "${nick}"`;
if(!confirm(`Sync ${what} to disk now? Existing files get a .klm.bak backup; this only writes this one library.`)) return;
if(btn){ btn.disabled=true; btn.textContent='⏳ syncing…'; }
try{ const r=await api('/sync-library',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mode,nick})});
toast(`Synced "${nick}" → ${(r.log||[]).join('; ')||'done'}`); poll();
}catch(e){ toast('Sync failed: '+e.message); if(btn){ btn.disabled=false; btn.textContent='⇅ Sync'; } }
}
async function deleteLibrary(mode, nick){
try{ const r=await api('/delete-library',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mode,nick})});
STATE.history=r.history; toast(`Toggled "${nick}" delete flag — applies on Sync`); poll();
}catch(e){ toast('Failed: '+e.message); }
}
async function duplicate(id){
try{ const r=await api('/duplicate-symbol',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id})});
STATE.history=r.history; toast('Duplicated → '+r.symbol.name); poll();
}catch(e){ toast('Duplicate failed: '+e.message); }
}
// inline field edits (symbol mode)
$('#tbody').addEventListener('change', async e=>{
const t=e.target; if(!t.classList.contains('cell')) return;
const id=t.dataset.id, field=t.dataset.field, value=t.value;
try{ const r=await api(`/symbol/${encodeURIComponent(id)}/field`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({field,value})});
STATE.history=r.history; updateHistButtons();
// update local symbol so a poll-free re-render stays consistent; id may change on rename
applyLocalSymbolEdit(id, r.symbol);
toast(`${field} updated`);
}catch(err){ toast('Edit failed: '+err.message); poll(); }
});
$('#tbody').addEventListener('keydown', e=>{
const t=e.target; if(!t.classList.contains('cell')) return;
// footprint cell with an open suggestion list: arrows/enter/esc drive the dropdown
if(t.dataset.field==='footprint' && fpDD.style.display!=='none'){
if(e.key==='ArrowDown'){ e.preventDefault(); fpSel=Math.min(fpItems.length-1,fpSel+1); fpHighlight(); return; }
if(e.key==='ArrowUp'){ e.preventDefault(); fpSel=Math.max(0,fpSel-1); fpHighlight(); return; }
if(e.key==='Enter'){ if(fpSel>=0){ e.preventDefault(); applyFp(fpSel); return; } hideFpAuto(); }
if(e.key==='Escape'){ e.preventDefault(); hideFpAuto(); return; }
}
if(e.key==='Enter') t.blur();
});
function applyLocalSymbolEdit(oldId, ns){
for(const lib of STATE.libs.symbolLibs){ const i=lib.symbols.findIndex(s=>s.id===oldId); if(i>=0){ lib.symbols[i]=ns; if(oldId!==ns.id && curSel().has(oldId)){ curSel().delete(oldId); curSel().add(ns.id);} break; } }
}
$('#selectAll').onclick=()=>{
const ids=visibleIds(); const sel=curSel();
const allOn=ids.length && ids.every(id=>sel.has(id));
if(allOn){ ids.forEach(id=>sel.delete(id)); } else { ids.forEach(id=>sel.add(id)); }
$('#selectAll').textContent = allOn?'✓ Select all':'✕ Deselect all';
render();
};
function visibleIds(){
const ids=[];
const ok=it=>!(HIDE_FLAGGED&&it.pendingDelete);
if(MODE==='symbol') for(const l of STATE.libs.symbolLibs) for(const s of l.symbols){ if(ok(s)&&matchSearch(`${s.name} ${s.ref} ${s.value} ${s.footprint} ${s.mpn} ${s.datasheet} ${s.description}`)) ids.push(s.id); }
else if(MODE==='footprint') for(const l of STATE.libs.footprintLibs) for(const f of l.footprints){ if(ok(f)&&matchSearch(`${f.name} ${f.model}`)) ids.push(f.id); }
else for(const md of STATE.libs.models3d){ if(ok(md)&&matchSearch(md.name)) ids.push(md.id); }
return ids;
}
$('#deleteBtn').onclick=async()=>{
const ids=[...curSel()]; if(!ids.length) return;
const noun = MODE==='model3d'?'3D model':MODE;
const restore = selectionAllFlagged();
try{ const r=await api('/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mode:MODE,ids})});
curSel().clear(); STATE.history=r.history;
toast(restore ? `Restored ${r.removed} ${noun}${r.removed===1?'':'s'}` : `Flagged ${r.removed} ${noun}${r.removed===1?'':'s'} for deletion — applies on Sync`); poll();
}catch(err){ toast('Failed: '+err.message); }
};
$('#hideFlaggedBtn').onclick=()=>{ HIDE_FLAGGED=!HIDE_FLAGGED; $('#hideFlaggedBtn').classList.toggle('on',HIDE_FLAGGED); $('#hideFlaggedBtn').textContent=HIDE_FLAGGED?'⦸ Showing active':'⦸ Hide flagged'; render(); };
$('#undoBtn').onclick=()=>doHist('/undo');
$('#redoBtn').onclick=()=>doHist('/redo');
async function doHist(path){ try{ const r=await api(path,{method:'POST'}); STATE.history=r.history; if(!r.ok){ toast(path==='/undo'?'Nothing to undo':'Nothing to redo'); return; } toast(path==='/undo'?'Undone':'Redone'); poll(); }catch(e){ toast('Failed: '+e.message); } }
window.addEventListener('keydown', e=>{
if(e.target && /INPUT|TEXTAREA/.test(e.target.tagName) && !(e.ctrlKey||e.metaKey)) return;
const k=e.key.toLowerCase();
if((e.ctrlKey||e.metaKey) && k==='z' && !e.shiftKey){ e.preventDefault(); doHist('/undo'); }
else if((e.ctrlKey||e.metaKey) && ((k==='z'&&e.shiftKey)||k==='y')){ e.preventDefault(); doHist('/redo'); }
});
$('#revertBtn').onclick=async()=>{
if(!confirm('Revert ALL libraries back to the initial probed state? Your edits and deletions in the app will be discarded (this itself is undoable). Disk is only touched on Sync.')) return;
try{ const r=await api('/revert',{method:'POST'}); STATE.history=r.history; toast('Reverted to backup'); poll(); }catch(e){ toast('Revert failed: '+e.message); }
};
$('#syncBtn').onclick=async()=>{
if(STATE.sync&&STATE.sync.status==='running') return;
if(!confirm('Write the app\'s pending changes to your computer?\n\nExisting files get a .klm.bak backup; deletions move to a reversible .klm-trash folder.')) return;
try{ await api('/sync',{method:'POST'}); toast('Syncing to disk…'); poll(); }
catch(e){ toast('Sync failed: '+e.message); }
};
// ---------- description modal
function findSym(id){ for(const l of STATE.libs.symbolLibs){ const s=l.symbols.find(x=>x.id===id); if(s) return s; } return null; }
function openDesc(id){ const s=findSym(id); if(!s) return; descId=id; $('#descTitle').textContent='Description — '+s.name; $('#descMeta').textContent=`${s.lib} · ${s.ref||'?'} · ${s.value||''}`; $('#descText').value=s.description||''; $('#descModal').classList.add('show'); $('#descText').focus(); }
$('#descCancel').onclick=()=>{ $('#descModal').classList.remove('show'); descId=null; };
$('#descSave').onclick=async()=>{
if(!descId) return; const value=$('#descText').value;
try{ const r=await api(`/symbol/${encodeURIComponent(descId)}/field`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({field:'description',value})});
STATE.history=r.history; applyLocalSymbolEdit(descId,r.symbol); $('#descModal').classList.remove('show'); descId=null; toast('Description updated'); render();
}catch(e){ toast('Save failed: '+e.message); }
};
// ---------- LCSC stock/price hover popover
const lcscPop=document.createElement('div'); lcscPop.className='lcsc-pop'; document.body.appendChild(lcscPop);
const LCSC_DATA=new Map(); let lcscTimer=null, lcscHideTimer=null, lcscCur=null;
function fmtNum(n){ return (n==null)?'—':Number(n).toLocaleString(); }
function fmtPrice(p){ return (p==null)?'—':('$'+Number(p).toFixed(4).replace(/0+$/,'').replace(/\.$/,'')); }
function positionLcsc(el){ const r=el.getBoundingClientRect(); lcscPop.style.left=Math.min(r.left, window.innerWidth-310)+'px'; let top=r.bottom+6; if(top>window.innerHeight-180) top=Math.max(8,r.top-186); lcscPop.style.top=top+'px'; }
function renderLcsc(d){
if(!d || !d.found){ lcscPop.innerHTML=`<div class="lcsc-hd">${esc(d&&d.part||'')}</div><div class="lcsc-row"><span>Not found on JLCPCB</span></div>`; return; }
const tier=(d.tiers&&d.tiers[0])||{}; const inStock=d.stock>0;
const badge = d.basic?'<span class="badge basic">basic</span>':d.preferred?'<span class="badge pref">preferred</span>':'<span class="badge ext">extended</span>';
lcscPop.innerHTML=
`<div class="lcsc-hd">${esc(d.part)}${badge}</div>`+
`<div class="lcsc-row"><span>Stock</span><b class="${inStock?'stock':'nostock'}">${fmtNum(d.stock)}</b></div>`+
`<div class="lcsc-row"><span>Unit price</span><b>${fmtPrice(d.unitPrice)}</b></div>`+
(d.mfr?`<div class="lcsc-row"><span>Mfr PN</span><b>${esc(d.mfr)}</b></div>`:'')+
(d.package?`<div class="lcsc-row"><span>Package</span><b>${esc(d.package)}</b></div>`:'')+
(d.tiers&&d.tiers.length?`<div class="lcsc-tiers">${d.tiers.map(t=>`${fmtNum(t.q)}+ ${fmtPrice(t.p)}`).join('<br>')}</div>`:'')+
(d.url?`<a class="lcsc-link" href="${esc(d.url)}" target="_blank" rel="noopener">View on JLCPCB ↗</a>`:'');
}
async function showLcsc(el){
const part=(el.value||'').trim(); if(!/^C\d{3,}$/i.test(part)){ hideLcsc(); return; }
lcscCur=part; positionLcsc(el); lcscPop.classList.add('show');
if(LCSC_DATA.has(part)){ renderLcsc(LCSC_DATA.get(part)); return; }
lcscPop.innerHTML=`<div class="lcsc-hd">${esc(part)}</div><div class="lcsc-row"><span><span class="spin"></span> fetching stock & price…</span></div>`;
try{ const d=await api('/lcsc?part='+encodeURIComponent(part)); LCSC_DATA.set(part,d); if(lcscCur===part) renderLcsc(d); }
catch(e){ if(lcscCur===part) lcscPop.innerHTML=`<div class="lcsc-row"><span>lookup failed</span></div>`; }
}
function hideLcsc(){ lcscPop.classList.remove('show'); lcscCur=null; }
$('#tbody').addEventListener('mouseover', e=>{
const t=e.target; if(!(t.classList&&t.classList.contains('cell')&&t.dataset.field==='mpn')) return;
clearTimeout(lcscHideTimer); clearTimeout(lcscTimer); lcscTimer=setTimeout(()=>showLcsc(t), 350);
});
$('#tbody').addEventListener('mouseout', e=>{
const t=e.target; if(!(t.classList&&t.classList.contains('cell')&&t.dataset.field==='mpn')) return;
clearTimeout(lcscTimer); lcscHideTimer=setTimeout(hideLcsc, 200);
});
lcscPop.addEventListener('mouseover', ()=>clearTimeout(lcscHideTimer));
lcscPop.addEventListener('mouseout', ()=>{ lcscHideTimer=setTimeout(hideLcsc, 200); });
window.addEventListener('scroll', ()=>{ if(lcscPop.classList.contains('show')) hideLcsc(); }, true);
// ---------- footprint autocomplete / recommendations
let FP_CANDS=null, fpInput=null, fpSel=-1, fpItems=[];
const fpDD=document.createElement('div'); fpDD.className='fp-auto'; fpDD.style.display='none'; document.body.appendChild(fpDD);
function fpCandidates(){
const m=new Map();
for(const lib of STATE.libs.footprintLibs) for(const f of lib.footprints){ const k=lib.nick+':'+f.name; if(!m.has(k)) m.set(k,{fp:k,personal:true}); }
// also offer footprints that other symbols already reference (captures KiCad-stock libs like LED_SMD:…)
for(const lib of STATE.libs.symbolLibs) for(const s of lib.symbols){ if(s.footprint&&s.footprint.includes(':')&&!m.has(s.footprint)) m.set(s.footprint,{fp:s.footprint,personal:false}); }
return [...m.values()];
}
const SIZE_RE=/(01005|0201|0402|0603|0805|1206|1210|1812|2010|2512|2920|qfn|tqfp|lqfp|qfp|sot-?23|sot-?223|sot-?89|soic|sop|tssop|msop|son|dfn|bga|wlcsp|csp|to-?252|to-?263|dpak|d2pak|sod-?\d+|sma|smb|smc|udfn|dip)/gi;
function sizeToks(str){ const out=new Set(); let m; const s=(str||'').toLowerCase(); while((m=SIZE_RE.exec(s))) out.add(m[1].replace(/-/g,'')); return [...out]; }
const FAMILY={R:['resistor','res','_r_'],C:['capacitor','cap','_c_'],L:['inductor','ind','ferrite','choke','_l_'],FB:['ferrite','bead'],D:['led','diode','sod','sma','smb','smc'],LED:['led'],Y:['crystal','osc','resonator'],X:['crystal','osc'],U:['qfn','qfp','soic','sop','tssop','dfn','son','bga','sot','dip','lga'],Q:['sot','to-','dpak','transistor'],J:['connector','header','jst','molex','conn','receptacle','pin','usb'],P:['connector','header','pin'],SW:['switch','button','tactile'],K:['relay'],TP:['testpoint','test_point']};
function subseq(h,n){ let i=0; for(let k=0;k<h.length&&i<n.length;k++) if(h[k]===n[i]) i++; return i>=n.length; }
function rankFps(sym, query){
if(!FP_CANDS) FP_CANDS=fpCandidates();
const q=(query||'').toLowerCase().trim();
const toks=sizeToks(`${sym.name} ${sym.value} ${sym.footprint}`);
const fam=FAMILY[(sym.ref||'').replace(/[^A-Za-z]/g,'').toUpperCase()]||[];
const out=[];
for(const c of FP_CANDS){
const name=c.fp.toLowerCase(); let score=0;
if(q){ const i=name.indexOf(q); if(i===0) score+=60; else if(i>0) score+=28; else if(subseq(name,q)) score+=5; else continue; }
for(const t of toks) if(name.includes(t)) score+=18;
for(const k of fam) if(name.includes(k)) score+=8;
if(c.personal) score+=3;
out.push({fp:c.fp, personal:c.personal, score});
}
out.sort((a,b)=>b.score-a.score||a.fp.localeCompare(b.fp));
return out.slice(0,8);
}
function showFpAuto(input){
fpInput=input; const sym=findSym(input.dataset.id); if(!sym){ hideFpAuto(); return; }
const list=rankFps(sym, input.value); fpItems=list; fpSel=-1;
if(!list.length){ hideFpAuto(); return; }
const typed=input.value.trim();
fpDD.innerHTML=`<div class="fp-hd">${typed?'Matching footprints':'Suggested for '+esc(sym.ref||'this symbol')+(sizeToks(sym.name+' '+sym.value).length?' · '+sizeToks(sym.name+' '+sym.value).join(' '):'')}</div>`+
list.map((c,i)=>`<div class="fp-opt" data-i="${i}"><span class="fp-nm mono">${esc(c.fp)}</span>${i===0&&!typed?'<span class="fp-tag best">best match</span>':(c.personal?'':'<span class="fp-tag ext">used elsewhere</span>')}</div>`).join('');
const r=input.getBoundingClientRect();
fpDD.style.left=r.left+'px'; fpDD.style.top=(r.bottom+4)+'px'; fpDD.style.minWidth=Math.max(r.width,260)+'px'; fpDD.style.display='block';
}
function hideFpAuto(){ fpDD.style.display='none'; fpInput=null; fpSel=-1; fpItems=[]; }
function fpHighlight(){ [...fpDD.querySelectorAll('.fp-opt')].forEach((el,i)=>el.classList.toggle('on',i===fpSel)); }
function applyFp(i){ if(!fpInput||!fpItems[i]) return; const inp=fpInput; inp.value=fpItems[i].fp; hideFpAuto(); inp.dispatchEvent(new Event('change',{bubbles:true})); }
fpDD.addEventListener('mousedown', e=>{ const o=e.target.closest('.fp-opt'); if(o){ e.preventDefault(); applyFp(+o.dataset.i); } });
$('#tbody').addEventListener('focusin', e=>{ if(e.target.classList&&e.target.classList.contains('cell')&&e.target.dataset.field==='footprint'){ FP_CANDS=null; showFpAuto(e.target); } });
$('#tbody').addEventListener('input', e=>{ if(e.target.classList&&e.target.classList.contains('cell')&&e.target.dataset.field==='footprint') showFpAuto(e.target); });
$('#tbody').addEventListener('focusout', e=>{ if(e.target.classList&&e.target.classList.contains('cell')&&e.target.dataset.field==='footprint'){ const inp=e.target; setTimeout(()=>{ if(document.activeElement!==inp) hideFpAuto(); },150); } });
window.addEventListener('scroll', ()=>{ if(fpDD.style.display!=='none') hideFpAuto(); }, true);
// ---------- preview dock
function stripSvg(s){
if(!s) return ''; let i=s.indexOf('<svg'); if(i<0) return ''; s=s.slice(i);
// drop the root width/height (in mm) so the viewBox drives scaling and the SVG auto-fits the canvas
s=s.replace(/(<svg\b[^>]*?)\s+width="[^"]*"/i,'$1').replace(/(<svg\b[^>]*?)\s+height="[^"]*"/i,'$1');
if(!/preserveAspectRatio=/i.test(s)) s=s.replace(/<svg\b/i,'<svg preserveAspectRatio="xMidYMid meet"');
return s;
}
function renderPane(canvasEl, nameEl, pane, kind){
const svg=stripSvg(pane&&pane.svg);
canvasEl.classList.remove('is3d');
if(nameEl) nameEl.textContent = (pane&&pane.name)||'';
if(svg){ canvasEl.innerHTML=svg; }
else { canvasEl.innerHTML=`<span class="pv-empty">${pane&&pane.reason?esc(pane.reason):('no '+kind+' to preview')}</span>`; }
}
// ---- 3D viewer (Adom Babylon viewer, loaded from the wiki CDN — same engine as the wiki component pages)
const VIEWER_ESM='https://wiki.adom.inc/static/vendor/adom-3d-viewer-babylon9/adom-3d-viewer-babylon9.esm.js?v=20260611d';
const VIEWER_CSS='https://wiki.adom.inc/static/vendor/adom-3d-viewer-babylon9/style.css';
let viewerLibP=null, cur3d=null;
function ensureViewerLib(){
if(viewerLibP) return viewerLibP;
viewerLibP=(async()=>{
window.process=window.process||{env:{NODE_ENV:'production'}}; window.global=window.global||window;
const css=document.createElement('link'); css.rel='stylesheet'; css.href=VIEWER_CSS; document.head.appendChild(css);
// it's an ES module (with relative chunk imports) — must be imported, not loaded as a classic script
await import(VIEWER_ESM);
if(!window.Adom3DViewerBabylon9 || !window.Adom3DViewerBabylon9.init) throw new Error('viewer global not set');
return window.Adom3DViewerBabylon9;
})();
return viewerLibP;
}
async function show3D(canvasEl, glbUrl){
canvasEl.classList.add('is3d');
canvasEl.innerHTML='<span class="pv-empty"><span class="spin"></span> loading 3D…</span>';
try{
const probe=await fetch(glbUrl,{headers:{Range:'bytes=0-3'}});
if(!probe.ok) throw new Error(probe.status===404?'no 3D model':('HTTP '+probe.status));
const V=await ensureViewerLib(); if(!V||!V.init) throw new Error('viewer unavailable');
canvasEl.innerHTML='';
const host=document.createElement('div'); host.style.cssText='position:absolute;inset:0'; canvasEl.appendChild(host);
try{ if(cur3d&&cur3d.dispose) cur3d.dispose(); }catch(e){}
const v=V.init(host,{zUp:true,showViewCube:true,showGround:true}); cur3d=v;
await v.loadModel(glbUrl); try{v.frameModel();}catch(e){} try{ if(v.toggleAxes) v.toggleAxes('world',false); }catch(e){}
}catch(e){ canvasEl.innerHTML=`<span class="pv-empty">${e.message==='no 3D model'?'no 3D model linked to this footprint':('3D unavailable: '+esc(e.message))}</span>`; }
}
async function openPreview(q){
$('#previewDock').classList.add('show'); document.body.classList.add('pv-open'); updateStickyOffsets();
$('#pvLeftName').textContent=''; $('#pvRightName').textContent=''; $('#pvSub').textContent='';
if(q.symbolId){
$('#pvPaneRight').classList.remove('hide'); $('#pvLeftHd').textContent='Schematic Symbol'; $('#pvRightHd').textContent='PCB Footprint';
$('#pvLeft').innerHTML='<span class="spin"></span>'; $('#pvRight').innerHTML='<span class="spin"></span>';
try{ const r=await api('/preview/pair?symbolId='+encodeURIComponent(q.symbolId));
renderPane($('#pvLeft'),$('#pvLeftName'),r.left,'symbol'); renderPane($('#pvRight'),$('#pvRightName'),r.right,'footprint');
$('#pvSub').textContent=(r.left&&r.left.name)||'';
}catch(e){ $('#pvLeft').innerHTML='<span class="pv-empty">render failed</span>'; toast('Preview failed: '+e.message); }
} else if(q.footprintId){
$('#pvPaneRight').classList.remove('hide'); $('#pvLeftHd').textContent='PCB Footprint'; $('#pvRightHd').textContent='3D Model';
$('#pvLeft').innerHTML='<span class="spin"></span>'; $('#pvRightName').textContent='';
try{ const r=await api('/preview/footprint?id='+encodeURIComponent(q.footprintId));
renderPane($('#pvLeft'),$('#pvLeftName'),r,'footprint'); $('#pvSub').textContent=r.name||'';
}catch(e){ $('#pvLeft').innerHTML='<span class="pv-empty">render failed</span>'; }
show3D($('#pvRight'), apiUrl('/model.glb?footprintId='+encodeURIComponent(q.footprintId)));
} else if(q.modelId){
$('#pvPaneRight').classList.add('hide'); $('#pvLeftHd').textContent='3D Model';
const md=(STATE.libs.models3d||[]).find(m=>m.id===q.modelId);
$('#pvLeftName').textContent=md?md.name:''; $('#pvSub').textContent=md?md.name:'';
show3D($('#pvLeft'), apiUrl('/model.glb?modelId='+encodeURIComponent(q.modelId)));
}
}
$('#pvClose').onclick=()=>{ try{ if(cur3d&&cur3d.dispose) cur3d.dispose(); }catch(e){} cur3d=null; $('#previewDock').classList.remove('show'); document.body.classList.remove('pv-open'); updateStickyOffsets(); };
// ---------- datasheet modal
let dsId=null;
function openDs(id){ const s=findSym(id); if(!s) return; dsId=id;
$('#dsTitle').textContent='Datasheet — '+s.name;
$('#dsMeta').textContent=`${s.lib} · ${s.ref||'?'}`;
$('#dsInput').value=s.datasheet||'';
const o=$('#dsOpen'); if(isUrl(s.datasheet)){ o.style.display='inline-flex'; o.href=s.datasheet; } else o.style.display='none';
$('#dsModal').classList.add('show'); $('#dsInput').focus();
}
$('#dsInput').addEventListener('input',()=>{ const v=$('#dsInput').value.trim(); const o=$('#dsOpen'); if(isUrl(v)){ o.style.display='inline-flex'; o.href=v; } else o.style.display='none'; });
$('#dsCancel').onclick=()=>{ $('#dsModal').classList.remove('show'); dsId=null; };
$('#dsSave').onclick=async()=>{
if(!dsId) return; const value=$('#dsInput').value.trim();
try{ const r=await api(`/symbol/${encodeURIComponent(dsId)}/field`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({field:'datasheet',value})});
STATE.history=r.history; applyLocalSymbolEdit(dsId,r.symbol); $('#dsModal').classList.remove('show'); dsId=null; toast('Datasheet updated'); render();
}catch(e){ toast('Save failed: '+e.message); }
};
// ---------- add modal
function fillLibSelect(sel, libs, placeholder){ sel.innerHTML = libs.length ? libs.map(l=>`<option value="${esc(l.nick)}">${esc(l.nick)}</option>`).join('') : `<option value="">${placeholder}</option>`; }
function openAdd(){
fillLibSelect($('#wSymLib'), STATE.libs.symbolLibs, 'no symbol libraries');
fillLibSelect($('#wFpLib'), STATE.libs.footprintLibs, 'no footprint libraries');
syncBlankLib();
$('#wMsg').textContent=''; $('#bMsg').textContent='';
$('#addModal').classList.add('show'); $('#wMpn').focus();
}
function syncBlankLib(){ const kind=$('#bKind').value; fillLibSelect($('#bLib'), kind==='symbol'?STATE.libs.symbolLibs:STATE.libs.footprintLibs, 'no libraries'); }
$('#addBtn').onclick=()=>{ if(STATE.probe.status!=='done'){ toast('Probe your libraries first'); return; } openAdd(); };
$('#bKind').onchange=syncBlankLib;
document.querySelectorAll('#addModal .tab').forEach(t=>t.onclick=()=>{
document.querySelectorAll('#addModal .tab').forEach(x=>x.classList.toggle('active',x===t));
document.querySelectorAll('#addModal .tabpane').forEach(p=>p.classList.toggle('active',p.id==='pane-'+t.dataset.tab));
});
$('#addClose1').onclick=$('#addClose2').onclick=()=>$('#addModal').classList.remove('show');
$('#wGo').onclick=async()=>{
const mpn=$('#wMpn').value.trim(); if(!mpn){ $('#wMsg').className='addmsg err'; $('#wMsg').textContent='Enter an MPN'; return; }
$('#wMsg').className='addmsg'; $('#wMsg').innerHTML='<span class="spin"></span> searching the wiki…'; $('#wGo').disabled=true;
try{ const r=await api('/wiki-add',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mpn,symLib:$('#wSymLib').value,fpLib:$('#wFpLib').value})});
STATE.history=r.history; const a=r.added; const got=[a.symbol&&'symbol',a.footprint&&'footprint',a.model3d&&'3D model'].filter(Boolean);
$('#wMsg').className='addmsg ok'; $('#wMsg').textContent=got.length?`Added ${got.join(', ')} from ${a.slug}. Sync to write to disk.`:`Found ${a.slug} but no CAD files.`;
$('#wMpn').value=''; poll();
}catch(e){ $('#wMsg').className='addmsg err'; $('#wMsg').textContent='Failed: '+e.message; }
finally{ $('#wGo').disabled=false; }
};
$('#bGo').onclick=async()=>{
const kind=$('#bKind').value, lib=$('#bLib').value, name=$('#bName').value.trim();
if(!lib){ $('#bMsg').className='addmsg err'; $('#bMsg').textContent='No target library'; return; }
try{ const r=await api(kind==='symbol'?'/add-symbol':'/add-footprint',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({lib,name})});
STATE.history=r.history; const nm=(r.symbol||r.footprint).name; $('#bMsg').className='addmsg ok'; $('#bMsg').textContent=`Created ${kind} "${nm}" in ${lib}.`;
$('#bName').value=''; if(kind==='symbol') MODE='symbol'; else MODE='footprint';
[...$('#modePills').children].forEach(p=>p.classList.toggle('active',p.dataset.mode===MODE)); poll();
}catch(e){ $('#bMsg').className='addmsg err'; $('#bMsg').textContent='Failed: '+e.message; }
};
// ---------- probe
$('#probeBtn').onclick=()=>$('#probeModal').classList.add('show');
$('#probeSkip').onclick=()=>$('#probeModal').classList.remove('show');
$('#probeGo').onclick=async()=>{
$('#probeModal').classList.remove('show');
try{ await api('/probe-request',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({edas:['kicad']})}); toast('Probing your libraries…'); poll(); }
catch(e){ toast('Probe failed: '+e.message); }
};
// ---------- poll
function focusGuard(){ const a=document.activeElement; return a && (a.classList.contains('cell')||a.classList.contains('dcell')||a.id==='descText'||a.id==='search'||a.id==='projPath'); }
async function poll(){
try{ const s=await api('/state'); STATE=s; if(!focusGuard()) render(); else { updateLibStatus(); updateHistButtons(); updateSyncUI(); } }catch(e){}
}
async function init(){
await poll();
const pr=STATE.probe||{};
const has=STATE.libs.symbolLibs.length||STATE.libs.footprintLibs.length;
if(!has && pr.status!=='probing') $('#probeModal').classList.add('show');
setInterval(poll, 3000);
}
init();
</script>
</body>
</html>