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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}

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