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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925
<!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)}
/* 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="probeBtn" title="Scan your computer for KiCad libraries">⌖ Probe libraries</button>
</header>
<div class="sync-banner" id="syncBanner"></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" 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>
<!-- 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 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 r=document.documentElement.style;
r.setProperty('--header-h', hh+'px'); // header sticks at 0
r.setProperty('--controls-top', (hh+bh)+'px'); // banner sticks under header, controls under banner
r.setProperty('--stick-top', (hh+bh+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(){
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 el=$('#pendingChip');
if(!add&&!del&&!chg){ 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>`);
el.innerHTML=parts.join(' · ');
}
function render(){
updateLibStatus(); updateSummary(); updateHistButtons(); updateSyncUI(); updatePendingChip();
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);
}
// ---------- 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());
$('#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.id==='descText'||a.id==='search'); }
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>