app
Adom Symbol
Public Made by Adomby adom
KiCad symbol creator with interactive viewer and delivery to KiCad/Fusion 360
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>adom-symbol</title>
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<style>
:root{
--bg:#0d1117;--panel:#11161f;--panel2:#0f141c;--border:#222b38;--border2:#2b3645;
--text:#e6edf3;--muted:#8b949e;--text3:#5b6675;--accent:#00b8b1;--accent2:#00e6dc;
}
*{box-sizing:border-box}
html,body{margin:0;height:100%}
body{background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;display:flex;flex-direction:column;overflow:hidden}
header.app{height:44px;flex:0 0 44px;display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:12px;padding:0 12px;border-bottom:1px solid var(--border);background:linear-gradient(180deg,#11161f,#0d1117);z-index:5}
header.app .ident{display:flex;align-items:center;gap:8px;min-width:0}
header.app .ident svg{width:16px;height:16px;color:var(--accent2);flex:0 0 16px}
header.app .app-name{font-weight:700;font-size:13px;white-space:nowrap}
header.app .app-sub{color:var(--muted);font-size:12px;white-space:nowrap}
header.app .app-sub::before{content:"·";margin:0 6px;color:var(--text3)}
header.app .subject{justify-self:center;display:flex;align-items:baseline;gap:9px;min-width:0}
header.app .subject-name{font-weight:600;font-size:13px;max-width:34vw;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
header.app .subject-meta{font-family:ui-monospace,monospace;font-size:11px;color:var(--muted);white-space:nowrap}
header.app .actions{display:flex;align-items:center;gap:5px}
.tbtn{display:inline-flex;align-items:center;justify-content:center;height:26px;min-width:26px;padding:0 7px;font-size:12px;font-weight:600;color:var(--muted);background:rgba(0,184,177,.10);border:1px solid rgba(0,184,177,.22);border-radius:6px;cursor:pointer;transition:.13s;white-space:nowrap}
.tbtn:hover{background:rgba(0,184,177,.18);color:#fff;border-color:rgba(0,184,177,.4)}
.tbtn.active{background:rgba(0,184,177,.22);color:var(--accent2);border-color:rgba(0,184,177,.5)}
.tbtn:disabled{opacity:.38;cursor:not-allowed;pointer-events:none}
.zlabel{font-family:ui-monospace,monospace;font-size:11px;color:var(--muted);min-width:40px;text-align:center;font-variant-numeric:tabular-nums}
.vsep{width:1px;height:20px;background:var(--border2);margin:0 3px}
.conn{display:inline-flex;align-items:center;gap:5px;font-size:11px;color:var(--muted);margin-left:2px}
.conn .dot{width:8px;height:8px;border-radius:50%;background:var(--text3);transition:.2s}
.conn.live .dot{background:var(--accent2);box-shadow:0 0 8px var(--accent2)}
/* ── responsive header: drop secondary controls as width shrinks ── */
@media (max-width:820px){
header.app{gap:8px;padding:0 8px}
header.app .actions{gap:4px}
.app-sub{display:none}
.subject-meta{display:none}
#zlbl,#zone,#bboxb,#hitsb,#connT{display:none}
}
@media (max-width:560px){
#zin,#zout{display:none} /* pinch handles zoom */
.subject-name{max-width:38vw;font-size:12px}
.tbtn{height:30px;min-width:32px;padding:0 10px} /* bigger tap targets */
}
@media (max-width:380px){ .app-name{display:none} } /* very narrow: icon only */
/* ── layout studio panel ── */
/* Layout panel: a DOCKED right sidebar (a flex item in .bodyrow) — it reserves
real space, so fit()/center() centre the symbol in the ACTUAL stage instead
of under a floaty. Toggling it hides it and the stage reclaims the width. */
#studio{order:2;flex:0 0 auto;width:min(86vw,300px);align-self:stretch;
overflow-y:auto;overflow-x:hidden;box-sizing:border-box;
background:var(--panel);border-left:1px solid var(--border);padding:11px 13px;
display:flex;flex-direction:column;gap:9px;z-index:1}
/* Sleek, subtle scrollbars for the docked panels (replaces the chunky default). */
#studio::-webkit-scrollbar,#chiplist::-webkit-scrollbar{width:9px;height:9px}
#studio::-webkit-scrollbar-track,#chiplist::-webkit-scrollbar-track{background:transparent}
#studio::-webkit-scrollbar-thumb,#chiplist::-webkit-scrollbar-thumb{background:rgba(255,255,255,.13);border-radius:7px;border:2px solid transparent;background-clip:padding-box}
#studio::-webkit-scrollbar-thumb:hover,#chiplist::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,.26);background-clip:padding-box}
#studio,#chiplist{scrollbar-width:thin;scrollbar-color:rgba(255,255,255,.18) transparent}
#studio[hidden]{display:none}
#studio .studio-head{display:flex;align-items:center;gap:8px;padding:0 0 8px;margin-bottom:2px;
border-bottom:1px solid var(--border);position:sticky;top:-11px;background:var(--panel);z-index:2}
#studio .studio-head .grip{display:none} /* docked, not draggable → no grip */
#studio .studio-head .studio-title{flex:1;font-size:12px;font-weight:700;color:var(--text)}
#studio .studio-head .shbtn{width:20px;height:20px;display:grid;place-items:center;border:0;background:transparent;
color:var(--muted);cursor:pointer;border-radius:4px;font-size:13px}
#studio .studio-head .shbtn:hover{background:rgba(255,255,255,.08);color:var(--text)}
#studio.collapsed > .srow{display:none}
#studio .srow{display:flex;align-items:center;gap:10px;justify-content:space-between;flex-wrap:wrap;min-width:0}
#studio .slab{font-size:12px;font-weight:600;color:var(--muted);flex:0 0 auto;cursor:help}
#studio .seg{display:inline-flex;background:var(--panel2);border:1px solid var(--border);border-radius:9px;padding:2px;gap:2px;flex-wrap:wrap;justify-content:flex-end}
#studio .seg button{appearance:none;border:0;background:transparent;color:var(--muted);font-size:11.5px;font-weight:600;
padding:5px 10px;border-radius:7px;cursor:pointer;transition:.12s;white-space:nowrap}
#studio .seg button:hover{color:#fff;background:rgba(0,184,177,.12)}
#studio .seg button.on{background:linear-gradient(180deg,#00b8b1,#009a94);color:#04201f;box-shadow:0 2px 8px rgba(0,184,177,.35)}
@media (max-width:560px){ #studio{width:min(92vw,260px)} #studio .srow{flex-wrap:wrap;gap:6px} }
main{flex:1;position:relative;min-height:0}
#stage{position:absolute;inset:0;overflow:hidden;cursor:grab;background:
radial-gradient(120% 120% at 50% 30%,rgba(0,184,177,.05),transparent 60%),
linear-gradient(rgba(255,255,255,.025) 1px,transparent 1px) 0 0/22px 22px,
linear-gradient(90deg,rgba(255,255,255,.025) 1px,transparent 1px) 0 0/22px 22px}
#stage.no-grid{background:radial-gradient(120% 120% at 50% 30%,rgba(0,184,177,.05),transparent 60%)}
#stage.drag{cursor:grabbing}
#content{position:absolute;left:0;top:0;transform-origin:0 0;will-change:transform}
#content svg{display:block}
/* The symbol's own glyphs/paths are decorative — ALL interaction is via the
.hit rects. Without this, apply3d() re-appends text groups above the hit
rects, so the cursor over a pin showed the stage's grab hand (not pointer)
and the text intercepted/blocked hover. */
#content svg text, #content svg g.stroked-text{pointer-events:none}
.hit{fill:rgba(0,230,220,0);stroke:none;cursor:pointer}
/* Hit-boxes debug overlay: teal = NUMBER hover box, magenta = NAME hover box. */
.hit.show{stroke-width:.12;vector-effect:non-scaling-stroke}
.hit.show.hit-num{fill:rgba(0,230,220,.16);stroke:#00e6dc}
.hit.show.hit-name{fill:rgba(255,43,214,.15);stroke:#ff2bd6}
#pinmark{pointer-events:none;fill:rgba(0,230,220,.18);stroke:#00e6dc;stroke-width:.12;opacity:0;transition:opacity .1s}
#bbox{pointer-events:none;fill:none;stroke:#00e6dc;stroke-width:.08;stroke-dasharray:.4,.3;opacity:0}
/* highlight the selected pin's NAME + NUMBER glyphs and its wire (high specificity to beat the embedded white-text rule) */
#content svg g.stroked-text.hl path{stroke:#00e6dc !important;fill:#00e6dc !important}
#content svg path.hl{stroke:#00e6dc !important;stroke-width:.22 !important}
.empty{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:var(--text3);font-size:13px;text-align:center;padding:30px}
#tip{position:fixed;z-index:9999;max-width:300px;background:rgba(22,27,34,.97);backdrop-filter:blur(14px);border:1px solid rgba(0,184,177,.3);border-radius:10px;padding:9px 12px;color:var(--text);font-size:12px;line-height:1.5;pointer-events:none;opacity:0;transition:opacity .12s;box-shadow:0 12px 40px rgba(0,0,0,.55)}
#tip.show{opacity:1}
#tip.sticky{pointer-events:auto;user-select:text;border-color:var(--accent2)}
#tip .r1{display:flex;align-items:baseline;gap:8px;justify-content:space-between}
#tip .nm{font-weight:700;font-size:13px}
#tip .pn{font-family:ui-monospace,monospace;font-size:11px;color:var(--muted)}
#tip .badge{display:inline-block;margin-top:5px;font-size:10px;font-weight:700;padding:2px 7px;border-radius:5px;letter-spacing:.2px}
#tip .desc{margin-top:6px;color:#aeb6c2;font-size:11.5px}
#tip .src{margin-top:7px;padding-top:6px;border-top:1px solid var(--border);font-size:11px;color:var(--muted)}
#tip .src a{color:var(--accent2);text-decoration:none}
#tip .hint{margin-top:6px;font-size:10px;color:var(--text3)}
#tip.sticky .hint{display:none}
footer{flex:0 0 auto;display:flex;align-items:center;gap:0;height:26px;border-top:1px solid var(--border);background:var(--panel2);font-size:11px;color:var(--muted);padding:0 12px;overflow:hidden}
footer .it{display:flex;align-items:center;gap:5px;white-space:nowrap}
footer .it+.it::before{content:"";width:1px;height:13px;background:var(--border2);margin:0 11px}
footer .k{color:var(--text3);text-transform:uppercase;font-size:9px;letter-spacing:.4px}
footer .v{color:var(--text)}
footer a{color:var(--accent2);text-decoration:none}
footer .cur{margin-left:auto;font-family:ui-monospace,monospace;color:var(--text3)}
#toast{position:fixed;left:50%;bottom:30px;transform:translateX(-50%) translateY(20px);z-index:100000;
max-width:min(92vw,460px);background:rgba(17,22,31,.98);backdrop-filter:blur(16px);border:1px solid var(--border2);
border-radius:12px;padding:12px 16px;color:var(--text);font-size:13px;line-height:1.45;box-shadow:0 16px 50px rgba(0,0,0,.6);
opacity:0;pointer-events:none;transition:opacity .2s,transform .2s;text-align:center}
#toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
#toast.ok{border-color:rgba(46,160,67,.5)}#toast.err{border-color:rgba(248,81,73,.5)}#toast.warn{border-color:rgba(210,153,34,.5)}
#bridgeModal{position:fixed;inset:0;z-index:100001;display:none;align-items:center;justify-content:center;background:rgba(2,5,10,.62);backdrop-filter:blur(4px);padding:20px}
#bridgeModal.show{display:flex;animation:pop .16s ease}
#bridgeModal .bm-card{max-width:min(94vw,440px);background:rgba(17,22,31,.99);border:1px solid var(--border2);border-radius:16px;padding:22px 22px 18px;box-shadow:0 30px 90px rgba(0,0,0,.7);text-align:center}
#bridgeModal .bm-icon{font-size:34px;margin-bottom:6px}
#bridgeModal .bm-title{font-size:16px;font-weight:700;margin-bottom:8px}
#bridgeModal .bm-body{font-size:13px;line-height:1.55;color:var(--muted);margin-bottom:16px}
#bridgeModal .bm-actions{display:flex;flex-direction:column;gap:9px}
#bridgeModal .bm-go{display:block;text-decoration:none;background:linear-gradient(180deg,#00b8b1,#009a94);color:#04201f;font-weight:700;font-size:13px;padding:10px;border-radius:10px;box-shadow:0 4px 16px rgba(0,184,177,.35)}
#bridgeModal .bm-dismiss{background:transparent;border:1px solid var(--border2);color:var(--muted);font-size:12.5px;padding:8px;border-radius:10px;cursor:pointer}
#bridgeModal .bm-dismiss:hover{color:var(--text);border-color:var(--text3)}
#dtt{position:fixed;z-index:99999;max-width:300px;background:rgba(22,27,34,.97);backdrop-filter:blur(14px);border:1px solid rgba(0,184,177,.25);border-radius:9px;padding:8px 11px;color:var(--text);font-size:11.5px;line-height:1.5;pointer-events:none;opacity:0;transition:opacity .15s;box-shadow:0 10px 34px rgba(0,0,0,.5);white-space:pre-wrap}
#dtt.show{opacity:1}
[data-tooltip]{cursor:help}
/* ── left chip-library switcher (in-flow: pushes the layout, no overlay) ── */
.bodyrow{display:flex;flex:1;min-height:0;min-width:0}
main{min-width:0}
#chiprail{display:flex;align-items:stretch;flex:0 0 auto;border-right:1px solid var(--border);background:var(--panel)}
#chiprail.collapsed{align-items:center}
#chiprailToggle{display:none;align-items:center;gap:7px;background:var(--panel2);color:var(--muted);border:0;border-right:1px solid var(--border);padding:11px 8px;cursor:pointer;font-size:11.5px;font-weight:700;writing-mode:vertical-rl;letter-spacing:.6px;height:100%}
#chiprailToggle:hover{color:#fff;background:#20283a}
#chiprailToggle .cr-arrow{writing-mode:horizontal-tb}
#chiprail.collapsed #chiprailToggle{display:flex}
#chiprail.collapsed #chiplistWrap{display:none}
#chiplistWrap{width:236px;background:var(--panel);display:flex;flex-direction:column}
.cr-head{display:flex;align-items:center;justify-content:space-between;padding:9px 12px;border-bottom:1px solid var(--border);font-size:11px;font-weight:700;color:var(--muted);letter-spacing:.4px;text-transform:uppercase}
#chiprailClose{background:transparent;border:0;color:var(--muted);font-size:16px;cursor:pointer;padding:0 4px;line-height:1}
#chiprailClose:hover{color:#fff}
.cr-project{margin:7px 10px 4px;padding:5px 8px;background:#0a0e14;color:var(--text);border:1px solid var(--border);border-radius:7px;font-size:11.5px;cursor:pointer}
.cr-project:hover{border-color:var(--accent)}
#chiplist{flex:1;overflow:auto;outline:none}
.cr-item{padding:7px 12px;cursor:pointer;font-size:12.5px;color:var(--text2,#aeb6c2);border-left:2px solid transparent;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.cr-item:hover{background:rgba(0,184,177,.10);color:#fff}
.cr-item.cur{background:linear-gradient(90deg,rgba(0,184,177,.20),transparent);border-left-color:var(--accent);color:#fff;font-weight:600}
.cr-hint{padding:7px 12px;border-top:1px solid var(--border);font-size:10px;color:var(--muted2,#6e7886)}
/* ── busy spinner (shown while a layout change re-renders) ── */
#busy{position:fixed;bottom:16px;left:50%;transform:translateX(-50%);z-index:9998;display:none;align-items:center;gap:8px;background:rgba(22,27,34,.96);border:1px solid rgba(0,184,177,.35);border-radius:999px;padding:7px 14px;color:var(--text);font-size:12px;font-weight:600;box-shadow:0 10px 32px rgba(0,0,0,.5)}
#busy.show{display:flex}
#busy .spin{width:13px;height:13px;border-radius:50%;border:2px solid rgba(0,184,177,.3);border-top-color:var(--accent2,#00e6dc);animation:busyspin .7s linear infinite}
@keyframes busyspin{to{transform:rotate(360deg)}}
/* LIGHT, non-blocking spinner positioned exactly where the 3D chip outline
renders (body centre). Represents ONLY the 3D outline (re)loading — it does
not dim or block the rest of the app. */
#chip3dspin{position:absolute;display:none;width:30px;height:30px;margin:-15px 0 0 -15px;border-radius:50%;
border:2.5px solid rgba(0,230,220,.20);border-top-color:var(--accent2,#00e6dc);
animation:busyspin .8s linear infinite;z-index:4;pointer-events:none;opacity:.85}
#chip3dspin.show{display:block}
/* Draggable Reference / chip-name (Value) field handles — shown only in
manual-fields mode. Positioned in stage-local px (pan baked in), like the
3D spinner. The user drags them to place each designator on the symbol. */
/* Field drag handle = a TRANSPARENT outline box drawn exactly AROUND the
label, so the word (Y? / Crystal_GND24) is never covered — you grab the
word itself. The grip tab hangs OUTSIDE the box (above-left), so it can't
touch the text either. */
.fldh{position:absolute;display:none;z-index:6;box-sizing:border-box;
border:1.5px dashed rgba(42,240,230,.85);border-radius:5px;background:rgba(0,230,220,.05);
cursor:grab;touch-action:none}
.fldh.show{display:block}
.fldh.dragging{cursor:grabbing;background:rgba(0,230,220,.16);border-style:solid}
.fldh .grip{position:absolute;left:-1.5px;top:-1.5px;transform:translateY(-100%);
background:linear-gradient(180deg,#00c2bb,#00a39c);color:#04201f;border:1px solid #2af0e6;
border-radius:6px 6px 6px 0;padding:1px 6px;font-size:9.5px;font-weight:700;letter-spacing:.3px;
white-space:nowrap;box-shadow:0 2px 8px rgba(0,0,0,.45);user-select:none}
body.manualfields #content svg{cursor:default}
/* ── save LED + regen spinner ── */
.savled{display:inline-flex;align-items:center;gap:5px;font-size:11px;font-weight:600;color:var(--muted);padding:0 4px}
.savled .led{width:8px;height:8px;border-radius:50%;background:#6e7886;box-shadow:0 0 0 transparent;transition:.18s}
.savled.saved{color:#3fb950}.savled.saved .led{background:#3fb950;box-shadow:0 0 8px rgba(63,185,80,.6)}
.savled.unsaved{color:#e3b341}.savled.unsaved .led{background:#e3b341;box-shadow:0 0 8px rgba(227,179,65,.6);animation:ledpulse 1.1s ease-in-out infinite}
.savled.saving{color:var(--accent2,#00e6dc)}.savled.saving .led{background:var(--accent2,#00e6dc);box-shadow:0 0 8px rgba(0,230,220,.6)}
@keyframes ledpulse{50%{opacity:.45}}
.rgnchk{display:inline-flex;align-items:center;gap:6px;font-size:11.5px;color:var(--muted);cursor:pointer;user-select:none}
.rgnchk input{accent-color:var(--accent2,#00e6dc);cursor:pointer}
#regenBtn.spinning{color:var(--accent2,#00e6dc);border-color:rgba(0,230,220,.45)}
#regenBtn.spinning::before{content:"";display:inline-block;width:11px;height:11px;margin-right:5px;border-radius:50%;border:2px solid rgba(0,230,220,.3);border-top-color:var(--accent2,#00e6dc);animation:busyspin .7s linear infinite;vertical-align:-2px}
</style>
</head>
<body>
<header class="app">
<div class="ident" data-tooltip="adom-symbol — a standalone Rust tool that generates KiCad schematic symbols and renders them via the shared service-kicad container. Hover any pin for its name, number and electrical type.">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="4" width="10" height="16" rx="1.5"/><path d="M7 8H3M7 12H3M7 16H3M21 8h-4M21 12h-4M21 16h-4"/></svg>
<span class="app-name">adom-symbol</span><span class="app-sub">symbol viewer</span>
</div>
<div class="subject">
<span class="subject-name" id="sName">—</span>
<span class="subject-meta" id="sMeta"></span>
</div>
<div class="actions">
<button class="tbtn" id="zout" data-tooltip="Zoom out (−)">−</button>
<span class="zlabel" id="zlbl">100%</span>
<button class="tbtn" id="zin" data-tooltip="Zoom in (+)">+</button>
<button class="tbtn" id="zfit" data-tooltip="Fit symbol to the window (F)">Fit</button>
<button class="tbtn" id="zone" data-tooltip="Reset to 100% (1)">1:1</button>
<span class="vsep"></span>
<button class="tbtn active" id="grid" data-tooltip="Toggle the background grid (G)">Grid</button>
<button class="tbtn" id="bboxb" data-tooltip="Toggle the symbol bounding box.">Box</button>
<button class="tbtn" id="hitsb" data-tooltip="Toggle the pin hover hit-boxes: TEAL = the box around each pin NUMBER, MAGENTA = the box around each pin NAME. These are exactly the areas that trigger the hover tooltip.">⊡ Hit boxes</button>
<span class="vsep"></span>
<button class="tbtn" id="studioBtn" data-tooltip="Layout — choose the source (auto-grouped / manufacturer / ds2sf), pin arrangement, grouping, reference position, and the 3D chip overlay.">⚙ Layout</button>
<span class="savled saved" id="savled" data-tooltip="All changes saved"><span class="led"></span><span id="savledT">Saved</span></span>
<span class="vsep"></span>
<span class="conn" id="conn" data-tooltip="Live link to the adom-symbol Rust server. Green = polling /state; the AI can push a new symbol or run /eval in real time."><span class="dot"></span><span id="connT">connecting…</span></span>
</div>
</header>
<!-- Body row (flex): left chip-library rail · main stage · docked Layout sidebar.
All three reserve real space, so fit()/center() centre the symbol in the
actual stage. #toast/#busy are position:fixed so they're unaffected here. -->
<div class="bodyrow">
<div id="studio" hidden>
<div class="studio-head" id="studioHead">
<span class="grip">⠿</span>
<span class="studio-title">Layout</span>
<button class="shbtn" id="studioCollapse" data-tooltip="Collapse / expand">▾</button>
<button class="shbtn" id="studioClose" data-tooltip="Close (reopen from ⚙ Layout)">✕</button>
</div>
<div class="srow">
<span class="slab" data-tooltip="Where the symbol comes from. Auto = our generator using ds2sf's logical groups (balanced sides). Manufacturer = the as-fetched vendor symbol, verbatim. ds2sf = generated from the datasheet-extracted pins.">Source</span>
<div class="seg" id="segSource"></div>
</div>
<div class="srow" data-auto>
<span class="slab" data-tooltip="Left/Right puts pins on two sides. Square balances them across all four sides (top/right/bottom/left).">Layout</span>
<div class="seg" id="segLayout">
<button data-v="left-right">Left / Right</button>
<button data-v="all-sides">Square</button>
</div>
</div>
<div class="srow" data-auto>
<span class="slab" data-tooltip="Group pins by function (POWER, GPIO ports P0/P1/P2, DEBUG…) with labels, or list them flat with no group labels.">Groups</span>
<div class="seg" id="segGroups">
<button data-v="1">Grouped</button>
<button data-v="0">Flat</button>
</div>
</div>
<div class="srow" data-auto>
<span class="slab" data-tooltip="Where the U? reference designator sits: pick a side (top/bottom/left/right or • center) and whether it's Outside or Inside the body.">Ref U?</span>
<div class="seg" id="segRefSide">
<button data-v="top" data-tooltip="Top">↑</button><button data-v="bot" data-tooltip="Bottom">↓</button>
<button data-v="left" data-tooltip="Left">←</button><button data-v="right" data-tooltip="Right">→</button>
<button data-v="center" data-tooltip="Center">•</button>
</div>
<div class="seg" id="segRefIO" style="margin-left:6px">
<button data-v="out">Out</button><button data-v="in">In</button>
</div>
</div>
<div class="srow" data-auto>
<span class="slab" data-tooltip="Where the chip NAME (Value) sits: pick a side and Outside/Inside the body.">Name</span>
<div class="seg" id="segValSide">
<button data-v="top" data-tooltip="Top">↑</button><button data-v="bot" data-tooltip="Bottom">↓</button>
<button data-v="left" data-tooltip="Left">←</button><button data-v="right" data-tooltip="Right">→</button>
<button data-v="center" data-tooltip="Center">•</button>
</div>
<div class="seg" id="segValIO" style="margin-left:6px">
<button data-v="out">Out</button><button data-v="in">In</button>
</div>
</div>
<div class="srow">
<span class="slab" data-tooltip="The Reference (U?/Y?) and chip-name labels. Auto-place positions both clear of the body and every pin. Manual lets you drag each one exactly where you want it on the symbol — it auto-saves to THIS chip.">Labels</span>
<div style="display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end">
<button class="tbtn" id="autoFields" data-tooltip="Auto-place the Reference + chip-name clear of the body and all pins (centred above / below). Saves to this symbol.">✦ Auto-place</button>
<button class="tbtn" id="manualFields" data-tooltip="Manual mode: drag the Reference + chip-name handles to position them exactly. Auto-saves to this chip. Click again to finish.">✥ Manual</button>
</div>
</div>
<div class="srow" data-auto>
<span class="slab" data-tooltip="Where the chip NAME shows. Real chips are laser-marked on their top face — 'On chip' laser-etches the name onto the 3D chip and hides the schematic label; 'In symbol' uses the schematic label (positioned above); 'Both' shows each; 'None' hides both. Needs the 3D chip on for the on-chip mark.">Chip name</span>
<div class="seg" id="segName">
<button data-v="chip" data-tooltip="Laser-mark the name on the 3D chip only">On chip</button>
<button data-v="symbol" data-tooltip="Schematic label only (positioned above)">In symbol</button>
<button data-v="both" data-tooltip="Laser mark + schematic label">Both</button>
<button data-v="none" data-tooltip="Hide the name everywhere">None</button>
</div>
</div>
<div class="srow">
<span class="slab" data-tooltip="Overlay a 3D representation of the chip in the centre of the body — the shaded iso render, or a crisp black-&-white vector OUTLINE icon (OCCT hidden-line removal) in several styles. All rendered at your chosen orientation.">3D chip</span>
<div class="seg" id="seg3d"><button data-v="off">Off</button></div>
<img id="td3dPrev" alt="3D preview" style="width:28px;height:28px;object-fit:contain;background:#6b7580;border:1px solid var(--border);border-radius:5px;margin-left:7px;display:none">
</div>
<div class="srow">
<span class="slab" data-tooltip="Size of the 3D chip in the body. Large is the standard size; pick Medium or Small when the chip crowds the pin names.">Chip size</span>
<div class="seg" id="segSize">
<button data-v="large">Large</button>
<button data-v="medium">Medium</button>
<button data-v="small">Small</button>
</div>
</div>
<div class="srow">
<span class="slab" data-tooltip="Regenerate THIS chip's vector 3D outline from its STEP (one OCCT pass via chip-thumbnailer). 'Regen now' forces it immediately; the checkbox auto-regens each time you switch to this chip. Off by default so browsing doesn't hammer the shared service-step2glb / chip-thumbnailer.">3D outline</span>
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;justify-content:flex-end;flex:1 1 auto">
<button class="tbtn" id="regenBtn" data-tooltip="Force-regenerate THIS chip's vector outline now (one OCCT pass via chip-thumbnailer).">⟳ Regen now</button>
<label class="rgnchk" data-tooltip="Sticky, off by default. When ON, regenerate just THIS chip's outline each time you load/switch to it — so it's fresh from the STEP."><input type="checkbox" id="regenOnSwitch"><span>auto on switch</span></label>
</div>
</div>
<div class="srow" style="border-top:1px solid var(--border);padding-top:9px">
<span class="slab" data-tooltip="Push this symbol to a desktop EDA tool via Adom Desktop. KiCad gets the native symbol; Fusion 360 / Altium / OrCAD get a true EAGLE .lbr library built by adom-lbr. (Adom Desktop must be running on your laptop.)">Send to</span>
<div class="seg" id="segSend">
<button data-v="kicad">KiCad</button>
<button data-v="fusion360">Fusion</button>
<button data-v="altium">Altium</button>
<button data-v="orcad">OrCAD</button>
</div>
</div>
<div class="srow">
<span class="slab" data-tooltip="Open the standalone INTERACTIVE viewer for this symbol in a new tab — the same read-only pan/zoom + pin-hover viewer you'd embed in a page or datasheet. It reflects the layout you see right now, and is auto-saved next to the chip (<mpn>-embed.html) on every Save.">Interactive viewer</span>
<button class="tbtn" id="openEmbed" data-tooltip="Open this symbol's interactive HTML viewer in a new browser tab. It shows exactly what the shared/embedded viewer looks like — pan, zoom, hover any pin. Auto-regenerated on every Save.">⧉ Open viewer ↗</button>
</div>
<div class="srow" style="justify-content:flex-end;flex-wrap:wrap;gap:6px">
<button class="tbtn" id="undoBtn" disabled data-tooltip="Undo the last change to THIS symbol — layout, 3D look, chip size, auto-place, or a label drag. Also Ctrl/⌘+Z. Steps back one edit at a time.">↶ Undo</button>
<button class="tbtn" id="applyAll" data-tooltip="Saves THIS chip's current Layout + 3D look as the per-chip look for every chip in the list right now — that's the current project filter ('All chips' = the whole library; pick a project first to scope it). It only writes each chip's info.json (no re-fetch, no regen), overrides their own saved look, and asks you to confirm with the exact count. Undo any chip afterward with Reset.">Apply to all</button>
<button class="tbtn" id="studioReset" data-tooltip="Un-pin THIS symbol's saved look so it follows the default again. Leaves other chips + your sticky default alone.">Reset</button>
</div>
<div class="srow" style="justify-content:flex-start"><span class="slab" id="stickyNote" data-tooltip="Each symbol REMEMBERS its own look once you change it — returning won't override it. Untouched symbols follow your last-used (sticky) choices. 'Apply to all' pushes the current look onto every chip; 'Reset' un-pins this one so it follows the default again.">per-symbol · sticky fills blanks</span></div>
</div>
<div id="toast"></div>
<!-- Left chip-library switcher. Collapsed rail by default; expand to arrow-nav
the whole chip-fetcher library. Switching auto-saves the current chip. -->
<div id="chiprail" class="collapsed">
<button id="chiprailToggle" title="Chip library — expand to switch chips (↑/↓)"><span class="cr-arrow">▸</span><span class="cr-lab">Chips</span></button>
<div id="chiplistWrap">
<div class="cr-head"><span id="cr-count">Library</span><button id="chiprailClose" title="Collapse">‹</button></div>
<select id="cr-project" class="cr-project" autocomplete="off" title="Filter by chip-fetcher project"><option value="">All chips</option></select>
<div id="chiplist" tabindex="0"></div>
<div class="cr-hint">↑ / ↓ to switch · auto-saves on leave</div>
</div>
</div>
<main>
<div id="stage">
<div id="content"></div>
<div id="chip3dspin" aria-label="3D chip outline loading"></div>
<div id="fldRef" class="fldh" title="Drag the Reference (U?/Y?) wherever you want it"><span class="grip">✥ Ref</span></div>
<div id="fldVal" class="fldh" title="Drag the chip name (Value) wherever you want it"><span class="grip">✥ Name</span></div>
</div>
<div class="empty" id="empty">Waiting for a symbol… run <code>adom-symbol create</code> or click a SYM tile in chip-fetcher.</div>
</main>
</div>
<footer id="foot" style="display:none"></footer>
<div id="tip"></div>
<div id="busy"><span class="spin"></span>rendering…</div>
<div id="dtt"></div>
<script>/*__VIEWER_CORE__*/</script>
<script>
(function(){
const post=(lvl,a)=>{try{navigator.sendBeacon('console',JSON.stringify({level:lvl,msg:a.map(x=>{try{return typeof x==='string'?x:JSON.stringify(x)}catch{return String(x)}}).join(' ')}))}catch(e){}};
['log','warn','error','info'].forEach(l=>{const o=console[l].bind(console);console[l]=(...a)=>{post(l,a);o(...a)}});
window.addEventListener('error',e=>post('error',[e.message+' @'+(e.filename||'')+':'+e.lineno]));
})();
// generic data-tooltip (buttons)
(function(){
const tt=document.getElementById('dtt');let t=null,cur=null;
function place(el){const r=el.getBoundingClientRect();tt.classList.add('show');const b=tt.getBoundingClientRect();let x=r.left+r.width/2-b.width/2,y=r.bottom+8;if(y+b.height>innerHeight-8)y=r.top-b.height-8;x=Math.max(8,Math.min(x,innerWidth-b.width-8));tt.style.left=x+'px';tt.style.top=y+'px'}
document.addEventListener('mouseover',e=>{const el=e.target.closest('[data-tooltip]');if(!el||el===cur)return;cur=el;clearTimeout(t);t=setTimeout(()=>{tt.textContent=el.getAttribute('data-tooltip');place(el)},500)});
document.addEventListener('mouseout',e=>{if(e.target.closest('[data-tooltip]')===cur){clearTimeout(t);tt.classList.remove('show');cur=null}});
})();
const MM=3.78;
const stage=document.getElementById('content'), stageWrap=document.getElementById('stage');
const tip=document.getElementById('tip');
let scale=1,panX=0,panY=0, svgEl=null, pins=[], offset=null, sticky=null, hitsOn=false, bboxOn=false;
let baseW=1, baseH=1, baseX=0, baseY=0, fittedSymbol=null; // viewBox origin+size (mm) + the chip we last fit
// The zoom readout is RELATIVE TO FIT: "100%" = the symbol fit nicely to the
// window (the natural full view). A symbol's true physical size (mm at screen
// DPI) is tiny on a big monitor, so an honest scale*100 read ~250%+ at fit and
// felt wrong. fitScale captures the fit() scale so 100% means "fit to window".
let fitScale=1;
let userAdjusted=false; // true once the user zooms/pans → don't auto-refit on resize
function setConn(v){const c=document.getElementById('conn');c.classList.toggle('live',v);document.getElementById('connT').textContent=v?'live':'offline'}
// Zoom by RESIZING the SVG (it re-renders its vectors crisply at any scale).
// Pan is the only CSS transform (translate never rasterizes). This is why the
// drawing stays sharp when you zoom instead of upscaling a 100% bitmap.
function applyT(){
if(svgEl){svgEl.style.width=(baseW*MM*scale)+'px';svgEl.style.height=(baseH*MM*scale)+'px';}
stage.style.transform=`translate(${panX}px,${panY}px)`;
document.getElementById('zlbl').textContent=Math.round(scale/(fitScale||1)*100)+'%';
placeChip3dSpin(); // keep the 3D-chip spinner pinned to the body centre through pan/zoom
placeFieldHandles(); // keep the draggable label handles pinned to their designators
}
// The 3D-chip outline spinner sits at the symbol-body centre (where the 3D chip
// renders) and tracks pan/zoom. Light + non-blocking (pointer-events:none).
function chip3dCenter(){
if(!window._body||!offset)return null;
const b=window._body, ux=offset.x+(b.x1+b.x2)/2, uy=offset.y-(b.y1+b.y2)/2;
return {x:panX+(ux-baseX)*MM*scale, y:panY+(uy-baseY)*MM*scale};
}
function placeChip3dSpin(){const el=document.getElementById('chip3dspin');if(!el||!el.classList.contains('show'))return;const c=chip3dCenter();if(c){el.style.left=c.x+'px';el.style.top=c.y+'px';}}
function showChip3dSpin(on){const el=document.getElementById('chip3dspin');if(!el)return;el.classList.toggle('show',!!on);placeChip3dSpin();}
// ── draggable Reference / chip-name field handles ──────────────────────────
// The designator positions in /state.refField/valueField are KiCad symbol-local
// mm (Y-up). Map them to stage-local px exactly like the 3D spinner, so a handle
// sits right on its label; the inverse map turns a drag back into mm to save.
function symField2Screen(fx,fy){
if(!offset)return null;
const ux=offset.x+fx, uy=offset.y-fy;
return {x:panX+(ux-baseX)*MM*scale, y:panY+(uy-baseY)*MM*scale};
}
function screen2symField(sx,sy){
if(!offset)return null;
const ux=(sx-panX)/(MM*scale)+baseX, uy=(sy-panY)/(MM*scale)+baseY;
return {x:ux-offset.x, y:offset.y-uy};
}
let manualFields=false, fieldDragging=false;
const FLD_PAD=4; // px of breathing room around the label inside the outline box
// Find the rendered <text> nearest a field's anchor (stage-local px) and return
// its on-screen box (centre + size). The Reference/Value are anchored exactly at
// refField/valueField, so the nearest text IS that label — we draw the outline
// around the REAL word (measured), never a fixed guess that could cover it.
function nearestFieldText(ax,ay,want){
if(!svgEl)return null;
const sr=stageWrap.getBoundingClientRect();
const wantT=(want||'').trim();
let best=null,bestD=1e18, match=null,matchD=1e18;
svgEl.querySelectorAll('text').forEach(t=>{
const r=t.getBoundingClientRect(); if(!r.width||!r.height)return;
const cx=(r.left+r.right)/2-sr.left, cy=(r.top+r.bottom)/2-sr.top;
const d=(cx-ax)*(cx-ax)+(cy-ay)*(cy-ay);
const box={cx,cy,w:r.width,h:r.height};
if(d<bestD){bestD=d;best=box;}
if(wantT && (t.textContent||'').trim()===wantT && d<matchD){matchD=d;match=box;}
});
// PREFER the <text> whose content is EXACTLY this designator (so we outline the
// right word and never latch onto a nearby pin name); fall back to nearest only
// if it's close to the anchor.
if(match)return match;
return (best && bestD < 70*70) ? best : null;
}
function positionFieldBox(el,field,want){
const a=symField2Screen(field.x,field.y);
const t=a?nearestFieldText(a.x,a.y,want):null;
if(!t){el.classList.remove('show');return;}
el.style.left=(t.cx-t.w/2-FLD_PAD)+'px'; el.style.top=(t.cy-t.h/2-FLD_PAD)+'px';
el.style.width=(t.w+FLD_PAD*2)+'px'; el.style.height=(t.h+FLD_PAD*2)+'px';
el.classList.add('show');
}
function placeFieldHandles(){
const rf=document.getElementById('fldRef'), vf=document.getElementById('fldVal');
if(!rf||!vf)return;
const s=window._lastState||{};
if(!manualFields||!offset||fieldDragging){ if(!fieldDragging){rf.classList.remove('show');vf.classList.remove('show');} return; }
if(s.refField)positionFieldBox(rf,s.refField,s.refText); else rf.classList.remove('show');
// The chip-name box only shows when the Value is actually drawn in the symbol.
const valHidden=(s.valuePos==='none')||!s.valueField;
if(!valHidden)positionFieldBox(vf,s.valueField,s.valueText); else vf.classList.remove('show');
}
// A field move is a real per-symbol edit: re-render + mark dirty so it SAVES to
// this chip's info.json — but it is NOT written to the global sticky prefs.
async function moveField(patch){
setBusy(true);
try{ await fetch('relayout',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(patch)}); }
catch(e){ setBusy(false); }
lastHash=''; markDirty();
poll(); // refresh _lastState now so a rapid 2nd drag snapshots the up-to-date override
}
function makeFieldDraggable(el,kind){
let sx=0,sy=0,ox=0,oy=0;
el.addEventListener('pointerdown',e=>{
if(!manualFields)return; e.preventDefault(); e.stopPropagation();
pushUndo();
fieldDragging=true; el.classList.add('dragging');
try{el.setPointerCapture(e.pointerId);}catch(_){}
sx=e.clientX; sy=e.clientY; ox=parseFloat(el.style.left)||0; oy=parseFloat(el.style.top)||0;
});
el.addEventListener('pointermove',e=>{
if(!fieldDragging)return;
el.style.left=(ox+(e.clientX-sx))+'px'; el.style.top=(oy+(e.clientY-sy))+'px';
});
const end=e=>{
if(!fieldDragging)return; fieldDragging=false; el.classList.remove('dragging');
try{el.releasePointerCapture(e.pointerId);}catch(_){}
// The box is centred on the label, so its CENTRE is the label position.
const cx=(parseFloat(el.style.left)||0)+el.offsetWidth/2;
const cy=(parseFloat(el.style.top)||0)+el.offsetHeight/2;
const mm=screen2symField(cx,cy);
if(!mm)return;
if(kind==='ref') moveField({refX:+mm.x.toFixed(3), refY:+mm.y.toFixed(3)});
else moveField({valueX:+mm.x.toFixed(3), valueY:+mm.y.toFixed(3)});
};
el.addEventListener('pointerup',end);
el.addEventListener('pointercancel',end);
}
makeFieldDraggable(document.getElementById('fldRef'),'ref');
makeFieldDraggable(document.getElementById('fldVal'),'value');
function setManualFields(on){
manualFields=!!on;
document.body.classList.toggle('manualfields',manualFields);
document.getElementById('manualFields').classList.toggle('active',manualFields);
placeFieldHandles();
if(manualFields)showToast('Manual labels: drag the outlined Ref / Name where you want it','ok');
}
document.getElementById('manualFields').onclick=()=>setManualFields(!manualFields);
document.getElementById('autoFields').onclick=async()=>{
pushUndo();
setBusy(true);
try{ await fetch('autofields',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}); }
catch(e){ setBusy(false); }
lastHash=''; markDirty();
showToast('✦ Auto-placed labels clear of the pins','ok');
};
// ── Undo ────────────────────────────────────────────────────────────────
// A per-symbol stack of layout snapshots taken BEFORE each user edit, so any
// change — a Layout toggle, 3D look, chip size, auto-place, or a label drag —
// can be stepped back (button + Ctrl/⌘+Z). Cleared when you switch chips.
let undoStack=[];
function captureState(){
const s=window._lastState||{};
if(!s.symbolName)return null;
return {symbol:s.symbolName, source:s.source, pinLayout:s.pinLayout, grouped:s.grouped,
refPos:s.refPos, valuePos:s.valuePos, laserName:s.laserName, threeD:s.threeD, chipSize:chip3dSize,
refOverride:s.refOverride||null, valueOverride:s.valueOverride||null};
}
function updateUndo(){const b=document.getElementById('undoBtn');if(b)b.disabled=undoStack.length===0;}
function pushUndo(){
const snap=captureState(); if(!snap)return;
const last=undoStack[undoStack.length-1];
if(last && JSON.stringify(last)===JSON.stringify(snap))return; // skip no-ops
undoStack.push(snap); if(undoStack.length>60)undoStack.shift(); updateUndo();
}
function clearUndo(){undoStack=[];updateUndo();}
async function restoreSnapshot(s){
chip3dSize=s.chipSize||chip3dSize; stickyChipSize=chip3dSize;
document.querySelectorAll('#segSize button').forEach(b=>b.classList.toggle('on',b.dataset.v===chip3dSize));
// refPos/valuePos restore the preset (and clear any override server-side); a
// remembered override is then re-applied via refX/refY — handler order makes
// that land on the exact saved spot.
const patch={source:s.source, pinLayout:s.pinLayout, grouped:s.grouped, laserName:s.laserName,
threeD:s.threeD, refPos:s.refPos, valuePos:s.valuePos};
if(s.refOverride){patch.refX=s.refOverride[0];patch.refY=s.refOverride[1];}
if(s.valueOverride){patch.valueX=s.valueOverride[0];patch.valueY=s.valueOverride[1];}
setBusy(true);
try{ await fetch('relayout',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(patch)}); }
catch(e){ setBusy(false); }
lastHash=''; markDirty();
}
async function doUndo(){
if(!undoStack.length)return;
const snap=undoStack.pop(); updateUndo();
await restoreSnapshot(snap);
showToast('↶ Undone','ok');
}
document.getElementById('undoBtn').onclick=doUndo;
document.addEventListener('keydown',e=>{
if((e.ctrlKey||e.metaKey)&&!e.shiftKey&&(e.key==='z'||e.key==='Z')){
if(e.target.tagName==='INPUT'||e.target.tagName==='TEXTAREA')return;
e.preventDefault(); doUndo();
}
});
function zoomAt(cx,cy,f){const ns=Math.max(.05,Math.min(scale*f,60));const r=ns/scale;panX=cx-(cx-panX)*r;panY=cy-(cy-panY)*r;scale=ns;userAdjusted=true;applyT()}
function center(){if(!svgEl)return;const w=stageWrap.clientWidth,h=stageWrap.clientHeight;const sw=baseW*MM*scale,sh=baseH*MM*scale;panX=(w-sw)/2;panY=(h-sh)/2;applyT()}
function fit(){if(!svgEl)return;const w=stageWrap.clientWidth,h=stageWrap.clientHeight;const sw=baseW*MM,sh=baseH*MM;scale=Math.max(.05,Math.min(Math.min(w/sw,h/sh)*0.86,60));fitScale=scale;userAdjusted=false;center()}
document.getElementById('zin').onclick=()=>{const r=stageWrap.getBoundingClientRect();zoomAt(r.width/2,r.height/2,1.2)};
document.getElementById('zout').onclick=()=>{const r=stageWrap.getBoundingClientRect();zoomAt(r.width/2,r.height/2,.83)};
document.getElementById('zfit').onclick=fit;
document.getElementById('zone').onclick=()=>{scale=1;center()};
document.getElementById('grid').onclick=e=>{stageWrap.classList.toggle('no-grid');e.currentTarget.classList.toggle('active')};
document.getElementById('bboxb').onclick=e=>{bboxOn=!bboxOn;e.currentTarget.classList.toggle('active',bboxOn);const b=document.getElementById('bbox');if(b)b.style.opacity=bboxOn?'1':'0'};
document.getElementById('hitsb').onclick=e=>{hitsOn=!hitsOn;e.currentTarget.classList.toggle('active',hitsOn);document.querySelectorAll('.hit').forEach(h=>h.classList.toggle('show',hitsOn))};
// Smooth, delta-PROPORTIONAL zoom. A fixed factor per event makes the
// Wheel/trackpad zoom feel lives in the SHARED viewer engine (viewer_core.js),
// which the embed export shares verbatim — one source of truth, no drift.
AdomSymViewer.attachWheelZoom(stageWrap, zoomAt);
let dragging=false,dsx=0,dsy=0,pStartX=0,pStartY=0;
stageWrap.addEventListener('mousedown',e=>{if(e.target.closest('.hit'))return;dragging=true;stageWrap.classList.add('drag');dsx=e.clientX;dsy=e.clientY;pStartX=panX;pStartY=panY});
window.addEventListener('mousemove',e=>{
if(dragging){panX=pStartX+(e.clientX-dsx);panY=pStartY+(e.clientY-dsy);userAdjusted=true;applyT()}
if(svgEl&&offset){const r=stageWrap.getBoundingClientRect();const ux=baseX+(e.clientX-r.left-panX)/scale/MM, uy=baseY+(e.clientY-r.top-panY)/scale/MM;const kx=ux-offset.x, ky=offset.y-uy;const c=document.getElementById('curpos');if(c)c.textContent=`X ${kx.toFixed(2)} Y ${ky.toFixed(2)} mm`}
});
window.addEventListener('mouseup',()=>{dragging=false;stageWrap.classList.remove('drag')});
// ── touch: 1-finger pan, 2-finger pinch-zoom, tap-to-inspect ──
function tdist(t){const dx=t[0].clientX-t[1].clientX,dy=t[0].clientY-t[1].clientY;return Math.hypot(dx,dy)}
function tmid(t){return{x:(t[0].clientX+t[1].clientX)/2,y:(t[0].clientY+t[1].clientY)/2}}
let tMode=null,tLastDist=0,tSX=0,tSY=0,tPX=0,tPY=0,tMoved=0;
stageWrap.addEventListener('touchstart',e=>{
if(e.touches.length===1){tMode='pan';tSX=e.touches[0].clientX;tSY=e.touches[0].clientY;tPX=panX;tPY=panY;tMoved=0;}
else if(e.touches.length>=2){tMode='pinch';tLastDist=tdist(e.touches);}
},{passive:false});
stageWrap.addEventListener('touchmove',e=>{
e.preventDefault();
if(tMode==='pan'&&e.touches.length===1){const nx=e.touches[0].clientX,ny=e.touches[0].clientY;tMoved+=Math.abs(nx-tSX)+Math.abs(ny-tSY);panX=tPX+(nx-tSX);panY=tPY+(ny-tSY);userAdjusted=true;applyT();}
else if(tMode==='pinch'&&e.touches.length>=2){const d=tdist(e.touches),m=tmid(e.touches),r=stageWrap.getBoundingClientRect();if(tLastDist>0)zoomAt(m.x-r.left,m.y-r.top,d/tLastDist);tLastDist=d;}
},{passive:false});
stageWrap.addEventListener('touchend',e=>{
if(tMode==='pan'&&tMoved<12){
// a tap — find the nearest pin (forgiving), not just exact hit
const t=e.changedTouches[0];const p=nearestPin(t.clientX,t.clientY);
if(p){if(sticky===p){sticky=null;tip.classList.remove('show','sticky');unmark();}else{sticky=p;showPin(p,t.clientX,t.clientY,true);mark(p);}}
else{sticky=null;tip.classList.remove('show','sticky');unmark();}
}
tMode=null;
});
document.addEventListener('keydown',e=>{
if(e.target.tagName==='INPUT')return;
if(e.key==='f'||e.key==='F')fit();
else if(e.key==='1'){scale=1;center()}
else if(e.key==='+'||e.key==='='){const r=stageWrap.getBoundingClientRect();zoomAt(r.width/2,r.height/2,1.2)}
else if(e.key==='-'){const r=stageWrap.getBoundingClientRect();zoomAt(r.width/2,r.height/2,.83)}
else if(e.key==='g'||e.key==='G')document.getElementById('grid').click();
else if(e.key==='Escape'){sticky=null;tip.classList.remove('show','sticky')}
});
// ── tooltips ──
function badge(p){return `<span class="badge" style="background:${p.color}22;color:${p.color};border:1px solid ${p.color}44">${esc(p.type)}</span>`}
function srcHtml(){const d=window._ds,m=window._mfr;if(!d)return '';return `<div class="src">Source: <a href="${esc(d)}" target="_blank">${esc(m||'Datasheet')}${m?' Datasheet':''}</a></div>`}
function showPin(p,x,y,sk){
let h=`<div class="r1"><span class="nm">${esc(p.name)}</span><span class="pn">Pin ${esc(p.number)}</span></div>${badge(p)}`;
if(p.desc)h+=`<div class="desc">${esc(p.desc)}</div>`;
h+=srcHtml();
if(!sk)h+=`<div class="hint">click to pin · select text</div>`;
tip.innerHTML=h;tip.classList.add('show');tip.classList.toggle('sticky',!!sk);posTip(x,y);
}
function posTip(x,y){const w=tip.offsetWidth||260,hh=tip.offsetHeight||110;let tx=x+14,ty=y+14;if(tx+w>innerWidth-14)tx=x-w-14;if(ty+hh>innerHeight-14)ty=y-hh-14;tip.style.left=Math.max(8,tx)+'px';tip.style.top=Math.max(8,ty)+'px'}
function mark(p){clearHL();const m=document.getElementById('pinmark');if(m&&offset){m.setAttribute('cx',offset.x+p.x);m.setAttribute('cy',offset.y-p.y);m.setAttribute('r',0.7);m.style.opacity='1'}highlightGlyphs(p)}
function unmark(){const m=document.getElementById('pinmark');if(m)m.style.opacity='0';clearHL()}
// Map the rendered text glyphs: invisible <text opacity="0">NAME</text> is
// immediately followed by its visible <g class="stroked-text"> glyphs. Build
// name/number → glyph-group map + pin → wire-path map for highlight-on-select.
let textMap=new Map(), wireMap=new Map();
function norm(s){return String(s||'').replace(/[~{}]/g,'').trim()}
// Pair each invisible <text opacity="0"> with its visible <g class="stroked-text">
// glyphs. They're NOT always adjacent siblings: ROTATED (top/bottom) pin text is
// wrapped in a rotate <g>, so the stroked-text group is the wrapper's next
// sibling, not the text's. So we walk the SVG in document order and pair each
// pending invisible text with the next stroked-text group. (nextElementSibling
// silently dropped every vertical pin → no hit box, no highlight.)
function collectGlyphLabels(){
const out=[]; let pending=null;
if(!svgEl)return out;
svgEl.querySelectorAll('text, g.stroked-text').forEach(n=>{
if(n.classList&&n.classList.contains('stroked-text')){
if(pending){const k=norm(pending.textContent);if(k)out.push({k,group:n});pending=null;}
}else if(n.getAttribute&&n.getAttribute('opacity')==='0'){
pending=n;
}
});
return out;
}
function buildMaps(){
textMap=new Map();wireMap=new Map();
if(!svgEl)return;
collectGlyphLabels().forEach(({k,group})=>{
if(!textMap.has(k))textMap.set(k,[]);
textMap.get(k).push(group);
});
if(offset){
const paths=[...svgEl.querySelectorAll('path')];
pins.forEach(p=>{
const tx=offset.x+p.x,ty=offset.y-p.y;
for(const pe of paths){
const m=(pe.getAttribute('d')||'').match(/^M(-?[\d.]+)\s+(-?[\d.]+)\s+L(-?[\d.]+)\s+(-?[\d.]+)\s*$/);
if(!m)continue;const x1=+m[1],y1=+m[2],x2=+m[3],y2=+m[4];
if((Math.abs(x1-tx)<.12&&Math.abs(y1-ty)<.12)||(Math.abs(x2-tx)<.12&&Math.abs(y2-ty)<.12)){wireMap.set(p,pe);break}
}
});
}
}
function highlightGlyphs(p){
[p.name,p.number].forEach(k=>{const a=textMap.get(norm(k));if(a)a.forEach(g=>g.classList.add('hl'))});
const w=wireMap.get(p);if(w)w.classList.add('hl');
}
function clearHL(){if(svgEl)svgEl.querySelectorAll('.hl').forEach(e=>e.classList.remove('hl'))}
document.addEventListener('click',e=>{if(!sticky)return;if(tip.contains(e.target))return;if(e.target.closest('.hit'))return;sticky=null;tip.classList.remove('show','sticky');unmark()});
function esc(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')}
// ── build hit zones on the SVG ──
// ONE continuous tight box per pin = the UNION of its NUMBER glyph box, its
// NAME glyph box, and the wire between them. Hugs the actual text (only as tall
// as the label), spans number→name with NO gaps (so the cursor never crosses
// dead space → no flicker), and never reaches into empty body. Hover is
// instant (no open delay). Boxes double as nearest-pin tap targets on touch.
let pinZones=[];
function buildHits(){
if(!svgEl||!offset)return;
pinZones=[];
const NS='http://www.w3.org/2000/svg';
const bb=computeBounds();
if(bb){const r=document.createElementNS(NS,'rect');r.id='bbox';r.setAttribute('x',bb.x);r.setAttribute('y',bb.y);r.setAttribute('width',bb.w);r.setAttribute('height',bb.h);svgEl.appendChild(r)}
const pm=document.createElementNS(NS,'circle');pm.id='pinmark';svgEl.appendChild(pm);
// Catalogue every rendered label's box via the document-order pairing (works
// for rotated top/bottom pins too). getBBox() is in SVG user units = our
// overlay space (the stroked-text paths are absolute root coords).
const labels=[];
collectGlyphLabels().forEach(({k,group})=>{
let box;try{box=group.getBBox();}catch(e){return;}
if(!box||!box.width)return;
labels.push({k,box,cx:box.x+box.width/2,cy:box.y+box.height/2,used:false});
});
// Nearest unused label whose string matches `k` to the anchor (handles
// duplicate names like multiple GND by proximity to each pin).
function pick(k,ax,ay){
k=norm(k);let best=null,bd=1e9;
for(const l of labels){if(l.used||l.k!==k)continue;const d=Math.hypot(l.cx-ax,l.cy-ay);if(d<bd){bd=d;best=l;}}
if(best)best.used=true;return best;
}
let hideT=null;
// One tight box hugging the NUMBER glyphs + one hugging the NAME glyphs, per
// pin. Both fire the same pin's tooltip; moving between a pin's two boxes is
// flicker-free (shared hide grace, cleared on enter). `kind` colours them in
// the Hit-boxes overlay so you can SEE exactly what's hover-able.
function addBox(box,p,idx,kind){
const pad=0.45,x=box.x-pad,y=box.y-pad,w=box.width+pad*2,h=box.height+pad*2;
pinZones.push({p,x,y,w,h});
const r=document.createElementNS(NS,'rect');
r.setAttribute('class','hit hit-'+kind+(hitsOn?' show':''));
r.setAttribute('x',x);r.setAttribute('y',y);r.setAttribute('width',w);r.setAttribute('height',h);r.setAttribute('rx',.3);
r.dataset.pi=idx;
r.addEventListener('mouseenter',ev=>{if(sticky)return;clearTimeout(hideT);showPin(p,ev.clientX,ev.clientY,false);mark(p)});
r.addEventListener('mousemove',ev=>{if(sticky)return;if(tip.classList.contains('show'))posTip(ev.clientX,ev.clientY)});
r.addEventListener('mouseleave',()=>{if(sticky)return;clearTimeout(hideT);hideT=setTimeout(()=>{tip.classList.remove('show');unmark()},45)});
r.addEventListener('click',ev=>{ev.stopPropagation();if(sticky===p){sticky=null;tip.classList.remove('show','sticky');unmark()}else{sticky=p;showPin(p,ev.clientX,ev.clientY,true);mark(p)}});
svgEl.appendChild(r);
}
pins.forEach((p,idx)=>{
const tipX=offset.x+p.x, tipY=offset.y-p.y;const L=p.length||2.54;
let dx=0,dy=0;
if(p.angle===0)dx=1; else if(p.angle===180)dx=-1; else if(p.angle===90)dy=-1; else if(p.angle===270)dy=1;
const nl=pick(p.number, tipX+dx*L*0.5, tipY+dy*L*0.5-(dx!==0?0.9:0)); // number: outside, over the wire
const ml=pick(p.name, tipX+dx*(L+2), tipY+dy*(L+2)); // name: inside the body
if(nl)addBox(nl.box,p,idx,'num');
if(ml)addBox(ml.box,p,idx,'name');
// Discrete parts hide pin name + number → no glyphs at all. Fall back to a
// box over the wire so the pin is still hoverable/tappable.
if(!nl&&!ml){
const ex=tipX+dx*L,ey=tipY+dy*L,HT=1.0;
addBox({x:Math.min(tipX,ex)-(dx===0?HT:0.3),y:Math.min(tipY,ey)-(dy===0?HT:0.3),
width:Math.abs(ex-tipX)+(dx===0?HT*2:0.6),height:Math.abs(ey-tipY)+(dy===0?HT*2:0.6)},p,idx,'num');
}
});
}
// Nearest pin to a screen point (for forgiving touch). Returns pin or null.
function nearestPin(clientX,clientY){
if(!svgEl||!offset||!pinZones.length)return null;
const r=stageWrap.getBoundingClientRect();
const ux=baseX+(clientX-r.left-panX)/scale/MM, uy=baseY+(clientY-r.top-panY)/scale/MM;
let best=null,bd=1e9;
for(const z of pinZones){
const ddx=Math.max(z.x-ux,0,ux-(z.x+z.w)), ddy=Math.max(z.y-uy,0,uy-(z.y+z.h));
const d=Math.hypot(ddx,ddy);
if(d<bd){bd=d;best=z.p}
}
return bd<1.3?best:null; // within 1.3mm of a pin zone
}
function computeBounds(){
if(!pins.length||!offset)return null;
let minX=1e9,maxX=-1e9,minY=1e9,maxY=-1e9;
pins.forEach(p=>{const tx=offset.x+p.x,ty=offset.y-p.y;let bx=tx,by=ty;const L=p.length||2.54;
if(p.angle===0)bx+=L;else if(p.angle===180)bx-=L;else if(p.angle===90)by-=L;else if(p.angle===270)by+=L;
minX=Math.min(minX,tx,bx);maxX=Math.max(maxX,tx,bx);minY=Math.min(minY,ty,by);maxY=Math.max(maxY,ty,by)});
if(window._body){const b=window._body;[b.x1,b.x2].forEach(x=>{minX=Math.min(minX,offset.x+x);maxX=Math.max(maxX,offset.x+x)});[b.y1,b.y2].forEach(y=>{minY=Math.min(minY,offset.y-y);maxY=Math.max(maxY,offset.y-y)})}
const pad=1.0;return{x:minX-pad,y:minY-pad,w:(maxX-minX)+pad*2,h:(maxY-minY)+pad*2};
}
// ── footer / metadata ──
function footer(s){
const items=[];
if(s.manufacturer)items.push(['Mfr',esc(s.manufacturer)]);
if(s.package)items.push(['Pkg',esc(s.package.split(':').pop())]);
items.push(['Pins',String(s.pinCount||(s.pins||[]).length)]);
if((s.groups||[]).length)items.push(['Groups',String(s.groups.length)]);
if(s.datasheet)items.push(['Datasheet',`<a href="${esc(s.datasheet)}" target="_blank">PDF</a>`]);
const desc=(s.description||'').replace(/\\r|\\n|[\r\n]+/g,' ').replace(/\s+/g,' ').trim();
if(desc)items.push(['Desc',`<span>${esc(desc.slice(0,70))}${desc.length>70?'…':''}</span>`]);
const f=document.getElementById('foot');
f.innerHTML=items.map(([k,v])=>`<span class="it">${k?`<span class="k">${k}</span>`:''}<span class="v">${v}</span></span>`).join('')+`<span class="cur" id="curpos"></span>`;
f.style.display='';
}
// ── state ── lightweight poll; heavy SVG + 3D assets fetched on demand ──
let lastRev=-1, cur3dMode=null, cur3dAsset='', cur3dLaser=null, lastSymbol=null;
async function poll(){
try{
const s=await (await fetch('state',{cache:'no-store'})).json();setConn(true);
window._lastState=s;
// Reflect the server's regen status on the Regen button spinner.
document.getElementById('regenBtn').classList.toggle('spinning',!!s.regen);
// A new chip just loaded → it's clean, and we auto-regen its 3D assets.
// Also reconcile the rail to server truth (unless a nav is mid-debounce).
if(s.symbolName && s.symbolName!==lastSymbol){
lastSymbol=s.symbolName; loadedMpn=s.symbolName; setSave('saved'); if(regenOnSwitch)scheduleRegen();
clearUndo(); // a fresh chip starts with an empty undo history
setManualFields(false); // and out of manual-label mode (no ghost handles on the new chip)
if(typeof navT!=='undefined' && !navT && chips.length && !chips.some(c=>c.current&&c.mpn===s.symbolName)) setCurrent(s.symbolName);
}
const has=!!s.symbolName;
document.getElementById('empty').style.display=has?'none':'';
document.getElementById('sName').textContent=has?s.symbolName:'—';
document.getElementById('sMeta').textContent=has?((s.pinCount||(s.pins||[]).length)+' pins'+(s.package?' · '+s.package.split(':').pop():'')):'';
footer(s); syncStudio(s);
if(!has){stage.innerHTML='';document.getElementById('foot').style.display='none';lastRev=-1;return}
if(s.rev!==lastRev){ // symbol regenerated → fetch the (heavy) SVG once
lastRev=s.rev;
const sv=await (await fetch('svg',{cache:'no-store'})).json();
renderSvg(s, sv.svg);
}
apply3d(s);
maybeApplyPrefs(s);
showChip3dSpin(!!s.regen); // 3D-outline spinner: ONLY while the outline is (re)generating
}catch(e){setConn(false)}
}
function renderSvg(s, svg){
pins=s.pins||[];offset=s.offset||null;window._ds=s.datasheet||'';window._mfr=s.manufacturer||'';window._body=s.bodyRect||null;
sticky=null;tip.classList.remove('show','sticky');
cur3dMode=null; cur3dAsset=''; cur3dLaser=null; // 3D geometry changes with the layout
stage.innerHTML=svg||'';
svgEl=stage.querySelector('svg');
if(svgEl){
const vb=(svgEl.getAttribute('viewBox')||'').split(/\s+/).map(Number);
if(vb.length===4){baseX=vb[0]||0;baseY=vb[1]||0;baseW=vb[2]||1;baseH=vb[3]||1;svgEl.removeAttribute('width');svgEl.removeAttribute('height')}
buildHits();
buildMaps();
// Fit only when the CHIP changed; a layout/3D re-render of the same chip
// keeps your current zoom + pan so the view doesn't jump under you.
if(s.symbolName!==fittedSymbol){fittedSymbol=s.symbolName;requestAnimationFrame(fit);}
else{applyT();}
}
setBusy(false); // the re-render landed → drop the spinner
}
setInterval(poll,1000);poll();
new ResizeObserver(()=>{if(svgEl){if(userAdjusted)applyT();else fit();}}).observe(stageWrap);
// ── left chip-library switcher ──
// Lists every chip in the chip-fetcher library root; the launched chip is
// auto-selected. Collapsed by default; expand to ↑/↓ between chips. Switching
// auto-saves the chip you're leaving (server side, in /load-chip).
// `chips` = the whole library; `projects` mirrors chip-fetcher's groupings, so
// the dropdown offers a global "All chips" list + the same per-project filters.
// `shown` is the currently-filtered list the ↑/↓ keys + clicks operate on.
let chips=[], projects={}, projFilter='', shown=[], chipIdx=-1;
const rail=document.getElementById('chiprail'), chiplist=document.getElementById('chiplist'), projSel=document.getElementById('cr-project');
function applyFilter(){
shown = (projFilter && projects[projFilter])
? chips.filter(c=>(projects[projFilter].mpns||[]).includes(c.mpn))
: chips.slice();
chipIdx = shown.findIndex(c=>c.current);
}
function renderChips(){
applyFilter();
chiplist.innerHTML=shown.map(c=>`<div class="cr-item${c.current?' cur':''}" data-mpn="${esc(c.mpn)}">${esc(c.mpn)}</div>`).join('')
|| '<div class="cr-hint" style="border:0">no chips in this project</div>';
document.getElementById('cr-count').textContent=(projFilter||'Library')+' · '+shown.length;
// Make the bulk button's SCOPE explicit: how many chips + which set.
const ab=document.getElementById('applyAll');
if(ab)ab.textContent = projFilter ? `Apply to ${shown.length} in "${projFilter}"` : `Apply to all ${shown.length}`;
const cur=chiplist.querySelector('.cr-item.cur'); if(cur)cur.scrollIntoView({block:'nearest'});
}
function renderProjectOptions(){
// Only projects that actually contain a chip present in THIS library (mirrors
// chip-fetcher showing just the relevant project groupings).
const names=Object.keys(projects)
.map(n=>[n,(projects[n].mpns||[]).filter(m=>chips.some(c=>c.mpn===m)).length])
.filter(([,cnt])=>cnt>0)
.sort((a,b)=>a[0].toLowerCase().localeCompare(b[0].toLowerCase()));
projSel.innerHTML='<option value="">All chips ('+chips.length+')</option>'+
names.map(([n,cnt])=>`<option value="${esc(n)}">${esc(n)} (${cnt})</option>`).join('');
// If the active filter no longer matches anything, fall back to All.
if(projFilter && !names.some(([n])=>n===projFilter))projFilter='';
projSel.value=projFilter;
}
async function loadChips(){
try{
const [cj,pj]=await Promise.all([
fetch('api/chips',{cache:'no-store'}).then(r=>r.json()),
fetch('api/projects',{cache:'no-store'}).then(r=>r.json()).catch(()=>({projects:{}})),
]);
chips=cj.chips||[]; projects=pj.projects||{};
renderProjectOptions(); renderChips();
}catch(e){}
}
let loadedMpn=null, navT=null;
function setCurrent(mpn){chips.forEach(c=>c.current=(c.mpn===mpn));renderChips();}
async function loadChipMpn(mpn){
if(!mpn||mpn===loadedMpn)return;
const leaving=loadedMpn;
// Leaving a chip: flush any pending edit so a DELIBERATE look is kept (its LED
// toast confirms it). A chip you only viewed stays clean → server won't pin it.
clearTimeout(saveT);
if(leaving && (savled.classList.contains('unsaved')||savled.classList.contains('saving'))) await doSave(leaving);
loadedMpn=mpn;
// Clear the previous chip's undo history + exit manual mode NOW (not later in
// poll()) so a Ctrl+Z in the gap before /state refreshes can't apply a stale
// layout to the freshly-loaded chip.
clearUndo(); setManualFields(false);
// No swap-spinner: the symbol (with its already-composited 3D outline) is what
// we're loading, and it's fast. We render it the moment /load-chip returns
// instead of waiting for the 1s poll tick, so switching feels instant. The 3D
// spinner only appears when the outline is actually (re)generating (s.regen).
try{
await fetch('load-chip',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mpn,chipSize:chip3dSize})});
poll(); // pull the new symbol + SVG right now — don't wait for the tick
}
catch(e){ loadedMpn=leaving; }
setSave('saved'); // freshly-loaded chip is clean
}
// Highlight instantly; debounce the heavy service-kicad load so ↑/↓ browsing
// only loads the chip you LAND on (not one per keypress).
function selectChip(mpn){ if(!mpn)return; setCurrent(mpn); clearTimeout(navT); navT=setTimeout(()=>{navT=null;loadChipMpn(mpn);},280); }
function switchByOffset(d){
if(!shown.length)return;
let i = chipIdx<0 ? (d>0?0:shown.length-1) : chipIdx+d; // first ↓→top, first ↑→bottom
i=Math.max(0,Math.min(shown.length-1,i));
selectChip(shown[i].mpn);
}
chiplist.addEventListener('click',e=>{const it=e.target.closest('.cr-item');if(it)selectChip(it.dataset.mpn)});
projSel.addEventListener('change',()=>{projFilter=projSel.value;renderChips();chiplist.focus();});
function setRail(open){ rail.classList.toggle('collapsed',!open); try{localStorage.setItem('adomSymChipRail',open?'1':'0')}catch(e){} if(open){loadChips();chiplist.focus();} }
document.getElementById('chiprailToggle').onclick=()=>setRail(true);
document.getElementById('chiprailClose').onclick=()=>setRail(false);
(function(){let p=null;try{p=localStorage.getItem('adomSymChipRail')}catch(e){} setRail(p==='1');})(); // collapsed by default
// ↑/↓ navigate the FILTERED chip list whenever the rail is expanded.
document.addEventListener('keydown',e=>{
if(e.target.tagName==='INPUT'||e.target.tagName==='SELECT'||rail.classList.contains('collapsed'))return;
if(e.key==='ArrowDown'){e.preventDefault();switchByOffset(1);}
else if(e.key==='ArrowUp'){e.preventDefault();switchByOffset(-1);}
});
// Flush the current chip's edits if the window is closed/navigated away — but
// ONLY if there's a GENUINE unsaved edit. Firing this unconditionally re-saved
// (= pinned) whatever chip was showing on every reload/navigate, silently
// mutating the library even when nothing was changed. (Root-caused 2026-06-29.)
window.addEventListener('beforeunload',()=>{
if(!(savled.classList.contains('unsaved')||savled.classList.contains('saving')))return;
try{navigator.sendBeacon('save',new Blob([JSON.stringify({chipSize:chip3dSize})],{type:'application/json'}));}catch(e){}
});
loadChips(); // populate immediately (the launched chip is flagged current)
// ── layout studio ──
const studio=document.getElementById('studio');
const SRC_LABEL={auto:'Auto · grouped',ds2sf:'ds2sf',manufacturer:'Manufacturer',
snapeda:'SnapEDA',snapmagic:'SnapEDA',ultralibrarian:'Ultra Librarian',cse_ul:'Ultra Librarian',
componentsearch:'Component Search',mouser:'Mouser',digikey:'DigiKey',arrow:'Arrow',lcsc:'LCSC',vendor:'Vendor'};
function setStudioOpen(open){studio.hidden=!open;document.getElementById('studioBtn').classList.toggle('active',open);try{localStorage.setItem('adomSymStudioOpen',open?'1':'0')}catch(e){};requestAnimationFrame(()=>{if(svgEl&&!userAdjusted)fit();});}
document.getElementById('studioBtn').onclick=()=>setStudioOpen(studio.hidden);
// The Layout studio is where you DO everything, so it's ALWAYS open on load —
// no screen-width gate, no stale stored "closed". You can still close it for
// more room during a session; the next load brings it back.
setStudioOpen(true);
// ── docked Layout sidebar: close + collapse (no drag; it reserves real space) ──
document.getElementById('studioClose').onclick=()=>setStudioOpen(false);
document.getElementById('studioCollapse').onclick=()=>{const c=studio.classList.toggle('collapsed');document.getElementById('studioCollapse').textContent=c?'▸':'▾'};
document.getElementById('studioHead').addEventListener('dblclick',e=>{if(!e.target.closest('.shbtn'))document.getElementById('studioCollapse').click()});
// sticky studio preferences (per browser)
const PREF_KEY='adomSymbolStudio3'; // bumped: new defaults are bold 3D chip + on-chip laser name
function loadPrefs(){try{return JSON.parse(localStorage.getItem(PREF_KEY)||'{}')||{}}catch{return {}}}
function savePrefs(p){try{localStorage.setItem(PREF_KEY,JSON.stringify(p))}catch(e){}}
let relaying=false, prefsAppliedFor=null;
// Auto-save on every tweak (debounced): persist the edited symbol + re-render
// the library thumbnail so chip-fetcher reloads it instantly and adom-lbr reads
// the latest. `remember=false` (prefs re-application) does NOT trigger a save.
// Save-state LED: green=saved, amber(pulsing)=unsaved, teal=saving. A toast is
// shown ONLY when we save because you're LEAVING a chip (so you know it landed
// even though that chip's LED is gone); in-page edits just move the LED.
const savled=document.getElementById('savled'), savledT=document.getElementById('savledT');
function setSave(state){
savled.className='savled '+state;
savledT.textContent={saved:'Saved',unsaved:'Unsaved',saving:'Saving…'}[state]||'';
savled.setAttribute('data-tooltip',{saved:'All changes saved',unsaved:'You have unsaved layout changes',saving:'Saving your changes…'}[state]||'');
}
let saveT=null, saveSeq=0;
function markDirty(){ setSave('unsaved'); scheduleSave(); }
function scheduleSave(){ clearTimeout(saveT); saveT=setTimeout(()=>doSave(),500); }
async function doSave(toastMpn){
const seq=++saveSeq; setSave('saving');
try{
await fetch('save',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({chipSize:chip3dSize})});
if(seq===saveSeq)setSave('saved');
if(toastMpn)showToast('✓ Saved '+toastMpn,'ok');
return true;
}catch(e){ if(seq===saveSeq)setSave('unsaved'); return false; }
}
// ── auto-regen the 3D chip assets (outlines + iso) via chip-thumbnailer ──
// It's multi-second per chip, so debounce: arrow-browsing many chips only
// regenerates the one you settle on. The ⟳ Regen button forces it; the button
// spins (driven off /state.regen) until the regen finishes.
let regenT=null;
function scheduleRegen(){ clearTimeout(regenT); regenT=setTimeout(()=>doRegen(),800); }
async function doRegen(){
document.getElementById('regenBtn').classList.add('spinning');
try{ await fetch('regen',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}); }
catch(e){ document.getElementById('regenBtn').classList.remove('spinning'); showToast('⚠ Regen failed to start','err'); }
}
document.getElementById('regenBtn').onclick=()=>doRegen();
// `heavy` (default true) = the change re-renders the symbol via service-kicad,
// so show the spinner until renderSvg clears it. 3D/size toggles pass heavy=false
// (instant, client-side) so no spinner flashes.
const busyEl=document.getElementById('busy'); let busySafety=null;
function setBusy(on){ busyEl.classList.toggle('show',on); if(on){clearTimeout(busySafety);busySafety=setTimeout(()=>busyEl.classList.remove('show'),6000);} }
async function relayout(patch,remember=true,heavy=true){
relaying=true;
if(heavy)setBusy(true);
if(remember){const p=loadPrefs();Object.assign(p,patch);savePrefs(p)}
try{await fetch('relayout',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(patch)});}
catch(e){console.warn('relayout failed',e);setBusy(false);_studioSig=null;} // force re-sync so a failed change doesn't latch the wrong button
lastHash=''; // force the next poll to re-render
if(remember)markDirty(); // a real user edit → mark unsaved + persist
setTimeout(()=>{relaying=false},200);
}
// Re-apply the user's remembered choices to each newly-loaded symbol.
function maybeApplyPrefs(s){
if(!s.symbolName || prefsAppliedFor===s.symbolName) return;
prefsAppliedFor=s.symbolName;
// Chip size is per-symbol when saved, else the sticky default.
chip3dSize = s.savedLayout ? (s.chipSize||stickyChipSize) : stickyChipSize;
document.querySelectorAll('#segSize button').forEach(b=>b.classList.toggle('on',b.dataset.v===chip3dSize));
if(window._lastState)apply3d(window._lastState);
// A DELIBERATELY-SET (saved) symbol must NOT be overridden by the sticky
// default — its own choice wins. Sticky only fills in untouched symbols.
if(s.savedLayout) return;
const p=loadPrefs(), patch={};
if(p.pinLayout && p.pinLayout!==s.pinLayout) patch.pinLayout=p.pinLayout;
if(typeof p.grouped==='boolean' && p.grouped!==s.grouped) patch.grouped=p.grouped;
if(p.refPos && p.refPos!==s.refPos) patch.refPos=p.refPos;
if(p.valuePos && p.valuePos!==s.valuePos) patch.valuePos=p.valuePos;
if(p.threeD && p.threeD!==s.threeD && (p.threeD==='off'||(s.threeDModes||[]).includes(p.threeD))) patch.threeD=p.threeD;
if(p.source && p.source!==s.source && (s.sources||[]).includes(p.source)) patch.source=p.source;
if(Object.keys(patch).length) relayout(patch,false); // remember=false → doesn't pin
}
// "Reset to default" = UN-PIN this symbol (drop its saved prefs) and revert it
// to the current default look. Leaves the global sticky + other chips alone.
document.getElementById('studioReset').onclick=()=>{
pushUndo();
fetch('unpin',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}).catch(()=>{});
const s=window._lastState||{};
const src=(s.sources||[]).includes('auto')?'auto':((s.sources||[])[0]||'manufacturer');
prefsAppliedFor=s.symbolName||'__reset__';
nameMode='chip'; chip3dSize=stickyChipSize;
document.querySelectorAll('#segSize button').forEach(b=>b.classList.toggle('on',b.dataset.v===chip3dSize));
relayout({source:src,pinLayout:'left-right',grouped:true,refPos:'out-top',valuePos:'none',threeD:'bold',laserName:true},false);
showToast('↺ Reset — this chip now follows the default','ok');
};
// Bulk: push this look onto the chips CURRENTLY SHOWN (respects the project
// filter), overriding their saved look. Explicit scope + count in the confirm.
document.getElementById('applyAll').onclick=async()=>{
const mpns=shown.map(c=>c.mpn);
if(!mpns.length){showToast('No chips in the current list','warn');return;}
const scope = projFilter ? `the ${mpns.length} chips in project "${projFilter}"` : `ALL ${mpns.length} chips in the library`;
if(!confirm(`Apply this chip's current Layout + 3D look to ${scope}?\n\n• Writes each chip's saved look (info.json) — no re-fetch/regen\n• Overrides any look you set per-chip\n• Undo a chip later with Reset`))return;
showToast('Applying to '+mpns.length+' chips…','warn');
try{const r=await (await fetch('apply-all',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({chipSize:chip3dSize,mpns})})).json();
showToast('✓ Applied to '+(r.count||0)+' chips','ok');
}catch(e){showToast('⚠ Apply-to-all failed','err');}
};
// Optimistic: highlight the clicked option INSTANTLY (before the server round
// trip) so the panel feels responsive; syncStudio later confirms from /state.
// `optimistic=false` for momentary actions (Send) that aren't a persistent state.
function seg(id,onClick,optimistic){
if(optimistic===undefined)optimistic=true;
document.getElementById(id).addEventListener('click',e=>{
const b=e.target.closest('button[data-v]');if(!b)return;
// Only state-changing segments (optimistic) snapshot for Undo; momentary
// actions like "Send to" (optimistic=false) don't.
if(optimistic){pushUndo();b.parentNode.querySelectorAll('button[data-v]').forEach(x=>x.classList.toggle('on',x===b));}
onClick(b.dataset.v);
});
}
seg('segSource',v=>relayout({source:v}));
seg('segLayout',v=>relayout({pinLayout:v}));
seg('segGroups',v=>relayout({grouped:v==='1'}));
// Ref + Name positioning: side (top/bot/left/right/center) × in/out
let refSide='top',refIO='out',valSide='bot',valIO='out';
function posOf(side,io){ return side==='center'?'center':(io+'-'+side); }
function splitPos(pos){ if(pos==='center')return{side:'center',io:'out'}; const m=/^(out|in)-(top|bot|left|right)$/.exec(pos||''); return m?{side:m[2],io:m[1]}:{side:'top',io:'out'}; }
seg('segRefSide',v=>{refSide=v;relayout({refPos:posOf(refSide,refIO)})});
seg('segRefIO',v=>{refIO=v;relayout({refPos:posOf(refSide,refIO)})});
seg('segValSide',v=>{valSide=v;if(nameMode==='symbol'||nameMode==='both')relayout({valuePos:posOf(valSide,valIO)})});
seg('segValIO',v=>{valIO=v;if(nameMode==='symbol'||nameMode==='both')relayout({valuePos:posOf(valSide,valIO)})});
// Chip-name display mode: maps to the schematic Value position + laser-on-chip flag.
let nameMode='chip';
seg('segName',v=>{ nameMode=v;
const showSym=(v==='symbol'||v==='both');
relayout({ valuePos: showSym?posOf(valSide,valIO):'none', laserName: (v==='chip'||v==='both') });
});
seg('seg3d',v=>relayout({threeD:v},true,false)); // 3D toggle is instant (client overlay) — no spinner
// Chip-size is a client-only display choice (no re-render): Large = standard,
// Medium/Small shrink the overlay when it crowds the pin names.
const SIZE_F={large:1,medium:0.78,small:0.58};
// stickyChipSize = the global default; chip3dSize = the CURRENT symbol's size
// (its own when the chip is saved, else the sticky). Changing size updates both
// and saves to the symbol (markDirty).
let stickyChipSize='large';try{stickyChipSize=localStorage.getItem('adomSymChipSize')||'large'}catch(e){}
let chip3dSize=stickyChipSize;
// Sticky "regen on switch" — OFF by default so casual browsing doesn't hammer
// the shared service-step2glb / chip-thumbnailer. Only when ON do we auto-regen
// the showing chip's vector outline as you switch chips.
let regenOnSwitch=false;try{regenOnSwitch=localStorage.getItem('adomSymRegenOnSwitch')==='1'}catch(e){}
(function(){const cb=document.getElementById('regenOnSwitch');if(cb){cb.checked=regenOnSwitch;cb.addEventListener('change',()=>{regenOnSwitch=cb.checked;try{localStorage.setItem('adomSymRegenOnSwitch',regenOnSwitch?'1':'0')}catch(e){};if(regenOnSwitch)doRegen();});}})();
seg('segSize',v=>{chip3dSize=v;stickyChipSize=v;try{localStorage.setItem('adomSymChipSize',v)}catch(e){};if(window._lastState)apply3d(window._lastState);markDirty()});
// Open the standalone interactive viewer for the current symbol in a new tab.
// Server /embed renders the CURRENT layout on demand (always in sync).
document.getElementById('openEmbed').onclick=()=>{ showToast('Opening interactive viewer…','warn'); window.open('embed','_blank'); };
// Send to desktop EDA tools
const toast=document.getElementById('toast');let toastT=null;
function showToast(msg,kind){toast.textContent=msg;toast.className=kind||'';toast.classList.add('show');clearTimeout(toastT);toastT=setTimeout(()=>toast.classList.remove('show'),4500)}
seg('segSend',async v=>{
const names={kicad:'KiCad',fusion360:'Fusion 360',altium:'Altium',orcad:'OrCAD'};
showToast('Sending to '+names[v]+'…','warn');
try{
const r=await fetch('deliver',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({target:v})});
const j=await r.json();
if(j.needsBridge){toast.classList.remove('show');showBridgeModal(j);}
else if(j.ok)showToast('✓ '+(j.message||('Sent to '+names[v])),'ok');
else showToast('⚠ '+(j.message||j.error||('Could not send to '+names[v])),j.offline?'warn':'err');
}catch(e){showToast('⚠ Send failed: '+e.message,'err')}
},false); // Send is a momentary action, not a persistent state — no optimistic latch
// Build-a-bridge modal: Altium/OrCAD have no Adom Desktop bridge yet → invite
// the user to build it and contribute on the wiki.
function showBridgeModal(j){
let m=document.getElementById('bridgeModal');
if(!m){ m=document.createElement('div'); m.id='bridgeModal'; document.body.appendChild(m); }
m.innerHTML='<div class="bm-card">'
+'<div class="bm-icon">🌉</div>'
+'<div class="bm-title">Build the '+(j.target||'')+' bridge</div>'
+'<div class="bm-body">'+(j.message||'')+'</div>'
+'<div class="bm-actions">'
+'<a class="bm-go" href="'+(j.contribute||'https://wiki.adom.inc')+'" target="_blank" rel="noopener">Contribute on the wiki →</a>'
+'<button class="bm-dismiss">Got it</button>'
+'</div></div>';
m.classList.add('show');
m.querySelector('.bm-dismiss').onclick=()=>m.classList.remove('show');
m.onclick=e=>{if(e.target===m)m.classList.remove('show')};
}
const TD_LABEL={off:'Off',iso:'Iso',thin:'Thin',bold:'Bold',blueprint:'Blue',teal:'Teal',dark:'Dark'};
const TD_ORDER=['off','iso','thin','bold','blueprint','teal','dark'];
let _studioSig=null, _studioOptSig=null;
function syncStudio(s){
// Only touch the panel when something ACTUALLY changed. Re-running every 1s
// poll churned the DOM and — while the server was still rendering your last
// click — kept reverting the button to the old value. Gating on a signature
// means your optimistic highlight stays put until the server confirms it.
const sig=JSON.stringify([s.sources,s.source,s.sourceLabels,s.pinLayout,s.grouped,s.refPos,s.valuePos,s.laserName,s.threeD,s.threeDModes,s.threeDAsset,chip3dSize]);
if(sig===_studioSig)return;
_studioSig=sig;
const ss=document.getElementById('segSource');
const avail=s.sources||[];
const lbl=s.sourceLabels||{};
// Only REBUILD the Source/3D button DOM when the available OPTIONS (or labels)
// change — not on every value change — so an in-progress optimistic click on
// these segments isn't wiped by an unrelated re-sync.
const optSig=JSON.stringify([avail,lbl,s.threeDModes]);
const modes=s.threeDModes||[];
const avail3d=TD_ORDER.filter(k=>k==='off'||modes.includes(k));
if(optSig!==_studioOptSig){
_studioOptSig=optSig;
ss.innerHTML=avail.map(v=>`<button data-v="${v}">${lbl[v]||SRC_LABEL[v]||v}</button>`).join('')||'<button data-v="manufacturer">Manufacturer</button>';
document.getElementById('seg3d').innerHTML=avail3d.map(k=>`<button data-v="${k}">${TD_LABEL[k]||k}</button>`).join('');
}
const setOn=(id,val)=>document.querySelectorAll('#'+id+' button').forEach(b=>b.classList.toggle('on',b.dataset.v===String(val)));
// live preview of the current 3D asset (verifies the path: if this shows, the overlay will too)
// live preview of the current 3D asset (verifies the path: if this shows, the overlay will too)
const prev=document.getElementById('td3dPrev');
if(s.threeD&&s.threeD!=='off'&&s.threeDAsset){prev.src=s.threeDAsset;prev.style.display='';}
else{prev.removeAttribute('src');prev.style.display='none';}
setOn('segSource',s.source);
setOn('segLayout',s.pinLayout);
setOn('segGroups',s.grouped?'1':'0');
const rp=splitPos(s.refPos);refSide=rp.side;refIO=rp.io;setOn('segRefSide',rp.side);setOn('segRefIO',rp.io);
const hiddenName=(s.valuePos==='none');
const vp=splitPos(hiddenName?'out-bot':s.valuePos);valSide=vp.side;valIO=vp.io;setOn('segValSide',vp.side);setOn('segValIO',vp.io);
// derive the chip-name display mode from (valuePos hidden?, laserName)
nameMode = s.laserName ? (hiddenName?'chip':'both') : (hiddenName?'none':'symbol');
setOn('segName', nameMode);
setOn('seg3d',s.threeD||'off');
setOn('segSize',chip3dSize);
// layout/groups/ref only apply to the generated sources
const gen=s.source==='auto'||s.source==='ds2sf';
studio.querySelectorAll('[data-auto]').forEach(r=>r.style.opacity=gen?'1':'.4');
studio.querySelectorAll('[data-auto] .seg button').forEach(b=>b.style.pointerEvents=gen?'auto':'none');
}
// 3D chip overlay in the symbol-body centre — asset comes straight from
// /state (the current mode only), so it's synchronous and reliable.
function apply3d(s){
const mode=s.threeD||'off';
const present=svgEl&&svgEl.querySelector('#iso3d');
const asset=s.threeDAsset||'';
// redraw when mode OR asset changed, or the overlay is missing
const laserKey=(s.laserName?'1':'0')+'|'+(s.symbolName||'')+'|'+chip3dSize;
if(mode===cur3dMode && cur3dAsset===asset && laserKey===cur3dLaser && (mode==='off'||present)) return;
cur3dMode=mode; cur3dAsset=asset; cur3dLaser=laserKey;
if(present)present.remove();
const oldBg=svgEl&&svgEl.querySelector('#iso3d-bg'); if(oldBg)oldBg.remove();
if(!svgEl||mode==='off'||!asset||!offset||!s.bodyRect)return;
const b=s.bodyRect;if(!b)return;
const cx=offset.x+(b.x1+b.x2)/2, cy=offset.y-(b.y1+b.y2)/2;
const w=Math.abs(b.x2-b.x1), h=Math.abs(b.y2-b.y1);
// Size to the body so it's prominent on tall/narrow parts too (BMI270 etc.),
// not just big square ones. ~72% of the smaller body dimension.
const sz=Math.min(w,h)*(mode==='iso'?0.72:0.74)*(SIZE_F[chip3dSize]||1);
const NS='http://www.w3.org/2000/svg';
const img=document.createElementNS(NS,'image');img.id='iso3d';
img.setAttribute('x',cx-sz/2);img.setAttribute('y',cy-sz/2);img.setAttribute('width',sz);img.setAttribute('height',sz);
img.setAttribute('href',asset);img.setAttributeNS('http://www.w3.org/1999/xlink','href',asset);img.setAttribute('preserveAspectRatio','xMidYMid meet');
img.setAttribute('opacity',mode==='iso'?'0.95':'1');img.style.pointerEvents='none';
// thin/bold are dark-stroke line art (made for light/print backgrounds) — invert
// them so they read as crisp WHITE line art on the dark symbol body.
if(mode==='thin'||mode==='bold'){ img.style.filter='invert(1) brightness(1.15)'; }
// Layering: body fill paints in a later group (it occludes anything below),
// so we append the chip ON TOP of the body, then lift every text/label group
// back above the chip → final order is body → chip → text (labels stay pure
// white and readable; chip sits over the body fill, not under it).
svgEl.appendChild(img);
const tops=new Set();
svgEl.querySelectorAll('text, .stroked-text').forEach(t=>{
if(t.id==='laser3d') return;
let a=t; while(a && a.parentNode && a.parentNode!==svgEl) a=a.parentNode;
if(a && a!==img && a.parentNode===svgEl) tops.add(a);
});
tops.forEach(a=>svgEl.appendChild(a)); // re-append in order → on top of the chip
// NOTE: the laser-marked name is NOT drawn here as SVG text. It is embossed
// as real geometry on the chip's STEP top face (occt-name-on-chip.py) and
// rendered into the "iso-named" asset, which the server serves as threeDAsset
// when laserName is on — so the mark is perspective-correct and matches what
// ships to KiCad. The <image> above already shows it.
}
// ── AI-driving API (ai-driven-apps contract): every user action has a named
// command that drives the real UI the same way a click does. The CLI calls
// these over the /eval channel; a person clicking produces identical results.
window.ui = {
studio(open){ setStudioOpen(open!==false&&open!=='close'); return 'studio '+(studio.hidden?'closed':'open'); },
set(key,val){
if(key==='source') relayout({source:val});
else if(key==='layout') relayout({pinLayout:val});
else if(key==='groups') relayout({grouped:val==='grouped'||val==='1'||val===true});
else if(key==='3d'||key==='threed') relayout({threeD:val});
else if(key==='name'){ nameMode=val; const showSym=(val==='symbol'||val==='both'); relayout({valuePos:showSym?posOf(valSide,valIO):'none', laserName:(val==='chip'||val==='both')}); }
else if(key==='ref') relayout({refPos:val});
else return 'unknown key: '+key;
return key+'='+val;
},
zoom(dir){ if(dir==='in')document.getElementById('zin').click(); else if(dir==='out')document.getElementById('zout').click(); else fit(); return 'zoom '+dir; },
send(target){ const b=document.querySelector('#segSend button[data-v="'+target+'"]'); if(b){ b.click(); return 'send to '+target; } return 'no send target: '+target; },
hover(num){ const p=(pins||[]).find(x=>String(x.number)===String(num)); if(p){ showPin(p, innerWidth/2, 200, true); mark(p); return 'pin '+num+' = '+p.name+' ('+p.type+')'; } return 'no pin '+num; },
toast(msg){ showToast(String(msg)); return 'toast: '+msg; },
click(id){ const e=document.getElementById(id); if(e){ e.click(); return 'clicked #'+id; } return 'no element #'+id; },
state(){ return window._lastState||{}; },
list(){ return Object.keys(window.ui).filter(k=>typeof window.ui[k]==='function'); }
};
// ── eval channel ──
async function evalLoop(){
try{const r=await fetch('eval/pending',{cache:'no-store'});const j=await r.json();
if(j&&j.id){let res,err=null;try{res=await(async()=>eval(j.code))()}catch(e){err=String(e&&e.stack||e)}
await fetch('eval/'+j.id+'/result',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({result:err?undefined:safe(res),error:err})})}
}catch(e){}
}
function safe(v){try{JSON.stringify(v);return v}catch{return String(v)}}
setInterval(evalLoop,400);evalLoop();
</script>
</body>
</html>