app
Adom Library
Public Made by Adomby adom
adom-lbr — the EDA library translator. Bring a component in from any supported EDA tool and convert it to any other (KiCad ⇄ Altium ⇄ EAGLE/Fusion) through one canonical adom-lbr JSON — symbol + footprint + 3D + pin↔pad map. Altium is written natively (no Altium needed). Includes the adom-lbr Manager: browse wiki components and view symbol/footprint/3D + the per-EDA layer stackup.
master
Colby Knox
2.19.0: newbie-panel module 2.21.0, byte-faithful control proxies, per-path OPTIONS
8a26092
1mo ago
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Adom LBR Dashboard</title>
<link rel="icon" href="favicon.svg" type="image/svg+xml">
<script type="module" src="https://unpkg.com/@google/[email protected]/dist/model-viewer.min.js"></script>
<style>
:root{--bg:#0f1115;--panel:#171a21;--edge:#252a34;--ink:#e6e9ef;--mut:#8b93a3;--acc:#00b8b1;
--surface:#161b22;--elevated:#1c2128;--overlay:#21262d;--border:#30363d;--text:#e6edf3;--text-body:#c9d1d9;--text-2:#8b949e;--accent:#00b8b1;--on-accent:#05221f;--red:#f85149;--yellow:#d29922;--green:#3fb950;
--brand-heading:"Familjen Grotesk",-apple-system,Segoe UI,Roboto,sans-serif;--brand-body:Satoshi,-apple-system,Segoe UI,Roboto,sans-serif;
--line:#262d36;--muted:#8b949e;--teal:#00b8b1}
*{box-sizing:border-box}
/* House scrollbars everywhere (the wiki's slim dark pill; scrollbars sub-skill, John 2026-08-18) */
*{scrollbar-width:thin;scrollbar-color:rgba(139,148,158,.38) transparent}
::-webkit-scrollbar{width:10px;height:10px}
::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:rgba(139,148,158,.32);border-radius:999px;border:2px solid transparent;background-clip:padding-box}
::-webkit-scrollbar-thumb:hover{background:rgba(0,184,177,.55);background-clip:padding-box}
::-webkit-scrollbar-corner{background:transparent}
body{margin:0;font:14px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;background:var(--bg);color:var(--ink);height:100vh;display:flex;flex-direction:column}
#panes{display:flex;flex:1;min-height:0}
/* ---- the 44px app header: name left, live LEDs + version + the cog right ---- */
#hdr{height:44px;min-height:44px;display:flex;align-items:center;gap:12px;padding:0 16px;background:var(--panel);border-bottom:1px solid var(--edge);margin:0;border-radius:0}
#hdr .brand{position:relative;display:flex;align-items:center;gap:8px;font-family:var(--brand-heading);font-weight:600;font-size:15px;color:var(--text);letter-spacing:0}
#hdr .brand img{width:18px;height:18px;display:block}
#hdr .brand .bs{color:var(--mut);font-weight:400;font-size:13px;font-family:var(--brand-body);margin-left:0}
#brandpop{position:absolute;top:calc(100% + 8px);left:0;z-index:1500;background:var(--elevated);border:1px solid var(--border);border-radius:9px;padding:10px 13px;font:400 12px/1.6 var(--brand-body);color:var(--text-body);box-shadow:0 12px 32px #000a;white-space:nowrap}
#brandpop a{color:var(--acc)}
#hdr .chip{display:inline-flex;align-items:center;gap:6px;margin-left:14px;font:11.5px ui-monospace,monospace;color:var(--mut);background:#0d0f14;border:1px solid var(--edge);border-radius:999px;padding:3px 10px}
#hdr .chip i{width:7px;height:7px;border-radius:50%;background:#555;display:inline-block}
#hdr .chip.on i{background:var(--green);box-shadow:0 0 5px var(--green)}
#hdr .chip .tc{color:var(--text-2)}
#hdr .chip #tname{color:var(--ink)}
/* The bar is contract-fixed at 44px; in a narrow pane the subtitle, then the chip,
drop out rather than wrap past it (four-frame rule: 390px is a judged state). */
#hdr .brand .bn{white-space:nowrap}
@media (max-width:640px){ #hdr .brand .bs{display:none} }
@media (max-width:480px){ #hdr .chip{display:none} }
/* status pill row (status-leds contract) */
.ledrow{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 12px} /* 0, not 18: it sits INSIDE #content's 16px gutter like every other band. Measured at 34px against the panel's 16px in his real pane, which reads as a step. */
.led-pill{display:inline-flex;align-items:center;gap:7px;background:var(--panel);border:1px solid var(--edge);border-radius:999px;padding:4px 12px;font-size:12.5px;cursor:pointer;color:var(--ink)}
.led-pill .dot{width:8px;height:8px;border-radius:50%;background:#555}
.led-pill[data-state=ok] .dot{background:var(--green)}
.led-pill[data-state=busy] .dot{background:var(--yellow)}
.led-pill[data-state=bad] .dot{background:var(--red)}
.led-pill .st{color:var(--text-2)}
#led-pop{position:fixed;z-index:2500;max-width:420px;background:var(--elevated);border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:10px;padding:12px 14px;font:400 12.5px/1.6 var(--brand-body);color:var(--text-body);box-shadow:0 16px 40px #000c;display:none}
#led-pop b{color:var(--text)}
#led-pop .fix{color:var(--yellow);display:block;margin-top:6px}
/* graduation: collapsed scaffolding one-liners */
.foldline{display:none;margin:10px 18px 2px;padding:7px 14px;background:var(--panel);border:1px solid var(--edge);border-radius:999px;font-size:12px;color:var(--text-2);cursor:pointer;width:fit-content}
.foldline:hover{border-color:var(--acc);color:var(--ink)}
.iconbtn{background:none;border:1px solid transparent;color:var(--text-2);width:28px;height:28px;border-radius:8px;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;transition:background .12s,border-color .12s,transform .06s}
.iconbtn:hover{background:#1e222b;border-color:var(--edge);color:var(--acc)}
.iconbtn:active{transform:translateY(1px) scale(.96)}
.iconbtn:focus-visible{outline:2px solid var(--acc);outline-offset:2px}
.iconbtn.on{color:var(--teal);background:rgba(0,184,177,.10);border-color:rgba(0,184,177,.35)}
/* ---- The newbie panel is NOT built here any more (adom-ui-design 2.0.0) ----
Its markup, CSS, icons, clipboard path and send path live in newbie-panel.js, the one
shared implementation every dock dashboard mounts. Three times a missing identifier in
THIS file emptied the prompts table; that class of bug is now impossible here, because
this page does not build the table. Do not restyle .anp-* locally: fix it upstream in
adom-ui-design and every app gets the fix. The panel's own container query collapses it
at a 700px PANEL border-box width, which is a panel measurement, not a viewport one.
What stays mine is the CONTENT padding, because the page owns its own breathing room
whether the panel is open or closed.
THE HORIZONTAL PADDING IS 16px AND IS PART OF THE CONTRACT (newbie-panel module 2.3.0).
Four conformant panels measured at the SAME 744px pane came back 734, 735, 702 and 694
wide, purely because each page wrapped its mount point in a different gutter; at 694 one
app dropped to a single column while the others stayed at two, at an identical pane
width. So the panel's border box must be the pane minus 32, and selfCheck now fails a
correct panel inside a wrongly padded page. I was at 20px a side, which is 8px too much.
The vertical padding is still mine, and so is anything the app body wants BELOW the
panel, which is where extra margin belongs if this page ever needs it. */
#content{padding:20px 16px 24px}
/* ---- The send-evidence panel and the demo floaty ----
ALSO RESTORED, and also destroyed by 2.14.0's regex. Both are features the user asked
for by name: the progress bar, streaming console and evidence images that prove what
was actually sent to KiCad or Fusion, and the play/pause/next/prev floaty that lets a
newcomer stop the demo. Their markup and their JavaScript survived, so every gate I
have passed: the elements exist, the handlers run, the module mounts. Only their
appearance was gone, and appearance is the one thing none of my checks could see. */
#sendpanel{position:fixed;right:14px;bottom:14px;z-index:1500;width:min(480px,92vw);background:var(--elevated);border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:12px;box-shadow:0 16px 48px #000c;display:none}
#sendpanel.show{display:block}
#sendpanel .sp-head{display:flex;align-items:center;gap:10px;padding:10px 12px 8px 14px}
#sendpanel .sp-head h3{margin:0;font:600 13.5px var(--brand-heading);color:var(--text);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
#sendpanel .sp-body{padding:0 14px 12px}
.sp-bar{height:6px;border-radius:999px;background:var(--overlay);overflow:hidden;margin:2px 0 4px}
.sp-bar i{display:block;height:100%;background:var(--accent);border-radius:999px;width:0%;transition:width .4s ease}
.sp-bar.ind i{width:30%;animation:spslide 1.2s ease-in-out infinite alternate}
@keyframes spslide{from{margin-left:0}to{margin-left:70%}}
.sp-step{font:11.5px var(--brand-body);color:var(--text-2);margin-bottom:6px;min-height:15px}
#sp-console{background:#0b0e13;border:1px solid var(--border);border-radius:8px;padding:8px 10px;font:11px/1.5 ui-monospace,monospace;color:#c9d1d9;max-height:150px;overflow:auto;white-space:pre-wrap;margin-bottom:8px}
#sp-shots{display:flex;gap:8px;flex-wrap:wrap}
#sp-shots figure{margin:0;width:104px}
#sp-shots img{width:104px;height:70px;object-fit:cover;border:1px solid var(--border);border-radius:6px;background:#0d0f14;cursor:zoom-in;display:block}
#sp-shots img:hover{border-color:var(--accent)}
#sp-shots figcaption{font:10px var(--brand-body);color:var(--text-2);margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
#sp-zoom{position:fixed;inset:0;z-index:4000;background:rgba(0,0,0,.75);display:none;align-items:center;justify-content:center;cursor:zoom-out}
#sp-zoom img{max-width:92vw;max-height:92vh;border:1px solid var(--border);border-radius:8px}
#tour-card{position:fixed;z-index:3001;width:min(400px,90vw);background:var(--elevated);border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:12px;padding:14px 16px;box-shadow:0 16px 48px #000c;display:none}
#tour-card h3{margin:0 0 6px;font:700 15px var(--brand-heading);color:var(--text);display:flex;align-items:center;gap:8px}
#tour-card .step-n{font:600 10px var(--brand-body);color:var(--on-accent);background:var(--accent);border-radius:999px;padding:2px 8px}
#tour-card p{margin:0 0 12px;font:13px/1.55 var(--brand-body);color:var(--text-body)}
#tour-card p b{color:var(--text)}
#tour-card .row{display:flex;gap:8px;align-items:center}
#tour-card .row .spacer{flex:1;font:11px var(--brand-body);color:var(--text-2)}
#tour-card button{background:#1a2130;border:1px solid var(--border);color:var(--ink);border-radius:7px;padding:6px 14px;font:600 12.5px var(--brand-heading);cursor:pointer}
#tour-card button:hover{border-color:var(--accent)}
#tour-card button.primary{background:var(--accent);color:var(--on-accent);border-color:var(--accent)}
#tour-card button:active{transform:translateY(1px)}
#tour-bar{position:fixed;left:50%;bottom:18px;transform:translateX(-50%);z-index:3002;display:none;align-items:center;gap:6px;background:var(--elevated);border:1px solid var(--border);border-radius:999px;padding:6px 10px;box-shadow:0 12px 36px #000c}
#tour-bar.show{display:flex}
#tour-bar button{width:34px;height:34px;border-radius:50%;border:1px solid var(--border);background:#1a2130;color:var(--ink);cursor:pointer;display:inline-flex;align-items:center;justify-content:center;transition:border-color .12s,background .12s,transform .06s}
#tour-bar button:hover{border-color:var(--accent);color:var(--accent)}
#tour-bar button:active{transform:translateY(1px)}
#tour-bar button.play{background:var(--accent);color:var(--on-accent);border-color:var(--accent)}
#tour-bar .tn{font:600 11.5px var(--brand-body);color:var(--text-2);padding:0 6px;min-width:44px;text-align:center}
/* ---- Send to KiCad / Send to Fusion ----
RESTORED. These nineteen rules were deleted by the same over-greedy regex in 2.14.0 that
took the api() helper. That edit is now known to have destroyed TWO things, and only one
of them was ever found by a check: the missing function broke behaviour, so the gates
caught it, while the missing stylesheet broke only APPEARANCE, so the page kept passing
every structural, behavioural and conformance check for five releases while this bar
rendered as bare labels and unstyled white buttons in the user's dock. It was found by
LOOKING AT A SCREENSHOT of the real pane. A gate that asserts behaviour cannot see a
stylesheet that is simply gone, exactly as a byte-check could not see a missing
function. Both halves of that lesson cost a day. */
#sendbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:8px 16px;background:var(--panel);border-bottom:1px solid var(--edge);font-size:12px}
#sendbar .grp{display:flex;align-items:center;gap:6px}
#sendbar .lab{color:var(--mut);font:600 11px var(--brand-heading);text-transform:uppercase;letter-spacing:.05em}
#sendbar button{background:#1a2130;border:1px solid var(--edge);color:var(--ink);border-radius:7px;padding:5px 10px;font-size:12px;cursor:pointer;transition:border-color .12s,background .12s,transform .06s}
#sendbar button:hover:not(:disabled){border-color:var(--acc);background:#12302f}
#sendbar button:active:not(:disabled){transform:translateY(1px)}
#sendbar button:disabled{opacity:.45;cursor:not-allowed}
#sendbar .sep{width:1px;height:18px;background:var(--edge)}
#sendbar .abdot{display:inline-flex;align-items:center;gap:6px;margin-left:auto;color:var(--mut)}
#sendbar .abdot i{width:8px;height:8px;border-radius:50%;background:#555;display:inline-block}
#sendbar .abdot.on i{background:var(--green);box-shadow:0 0 6px var(--green)}
.splitgrp{position:relative;gap:0}
.splitgrp>button:first-child{border-radius:7px 0 0 7px;border-right:none}
.splitgrp .caret{border-radius:0 7px 7px 0;padding:5px 6px;display:inline-flex;align-items:center}
.dropmenu{position:absolute;top:calc(100% + 6px);left:0;z-index:1200;min-width:180px;background:var(--elevated);border:1px solid var(--border);border-radius:9px;box-shadow:0 12px 32px #000a;padding:5px;display:flex;flex-direction:column;gap:2px}
.dropmenu[hidden]{display:none}
.dropmenu button{text-align:left;background:none;border:none;border-radius:6px;padding:7px 10px;color:var(--ink);font-size:12.5px;cursor:pointer}
.dropmenu button:hover:not(:disabled){background:#12302f;color:var(--acc)}
.dropmenu button:disabled{opacity:.45;cursor:not-allowed}
/* tooltips (tooltips skill): 600ms, body-appended, clamped */
#tip2{position:fixed;z-index:5000;background:var(--overlay);border:1px solid var(--line);color:var(--text);font:12px var(--brand-body);padding:5px 9px;border-radius:6px;pointer-events:none;display:none;max-width:280px}
/* sidebar mode tabs: Projects (boards) | Parts (the whole wiki library) */
#modes{display:flex;gap:6px;padding:10px 12px 0}
#modes button{flex:1;background:#0d0f14;border:1px solid var(--edge);color:var(--mut);border-radius:8px;padding:6px 8px;font:600 12px var(--brand-heading);cursor:pointer}
#modes button:hover{border-color:var(--acc);color:var(--ink)}
#modes button.on{background:#12302f;border-color:var(--acc);color:var(--acc)}
.sample-note{background:var(--elevated);border:1px solid var(--border);border-left:3px solid color-mix(in srgb,var(--accent) 45%,transparent);border-radius:10px;padding:8px 12px;margin:0 0 12px;font-size:12.5px;color:var(--text-2)}
.sample-note b{color:var(--text)}
#toasts{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:400;display:flex;flex-direction:column-reverse;gap:8px;pointer-events:none}
.toast{display:flex;align-items:flex-start;gap:9px;background:var(--surface);border:1px solid var(--line);border-left:3px solid var(--muted);border-radius:9px;padding:10px 14px;font:13px var(--brand-body);color:var(--text);max-width:min(560px,86vw);box-shadow:0 12px 32px #0009;opacity:0;transform:translateY(8px);transition:opacity .18s ease,transform .18s ease;pointer-events:auto}
.toast.in{opacity:1;transform:translateY(0)}
.toast.out{opacity:0;transition:opacity .14s ease}
.toast svg{flex:none;margin-top:1px}
.toast.success{border-left-color:var(--green)} .toast.success svg{color:var(--green)}
.toast.error{border-left-color:var(--red)} .toast.error svg{color:var(--red)}
.toast.info{border-left-color:var(--teal)} .toast.info svg{color:var(--teal)}
@media (prefers-reduced-motion:reduce){.toast{transition:none;transform:none}}
/* ---- Settings modal: the Adom settings surface (settings-layout skill) ---- */
dialog.settings{width:min(680px,92vw);max-width:none;padding:0;overflow:hidden;border-radius:12px;background:var(--surface);border:1px solid var(--border);color:var(--text-body);box-shadow:0 20px 60px #000a}
dialog.settings::backdrop{background:rgba(0,0,0,.25)}
dialog.settings:focus{outline:none}
.settings-head{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 14px 12px 18px;border-bottom:1px solid var(--border);cursor:move;user-select:none}
.settings-head h2{margin:0;font:600 15px/1.3 var(--brand-heading);color:var(--text);display:flex;align-items:center;gap:10px}
.settings-body{max-height:70vh;overflow:auto;padding:18px;scrollbar-gutter:stable}
.settings-body::-webkit-scrollbar{width:8px}
.settings-body::-webkit-scrollbar-track{background:transparent;margin:12px 0}
.settings-body::-webkit-scrollbar-thumb{background:rgba(139,148,158,.35);border-radius:999px;border:2px solid transparent;background-clip:padding-box}
.settings-body::-webkit-scrollbar-thumb:hover{background:var(--accent);background-clip:padding-box}
.settings-card{background:var(--elevated);border:1px solid var(--border);border-left:3px solid color-mix(in srgb,var(--accent) 45%,transparent);border-radius:10px;padding:.9rem 1.1rem 1rem;margin-bottom:1rem}
.card-title{font-family:var(--brand-heading);font-size:13px;font-weight:600;display:flex;align-items:center;gap:10px;color:var(--text);margin:0 0 .35rem}
.card-status{font-family:var(--brand-body);font-size:11px;color:var(--text-2);font-weight:400}
.card-status.ok{color:var(--green)}
.card-status.warn{color:var(--yellow)}
.card-desc{font-family:var(--brand-body);font-size:12px;line-height:1.55;color:var(--text-2);margin:0 0 .6rem;max-width:60ch}
.card-desc code{font-size:11px;background:rgba(110,118,129,.2);padding:1px 4px;border-radius:4px}
.divider{height:1px;background:var(--border);margin:.55rem 0}
.setting-row{display:flex;align-items:center;gap:10px;padding:.35rem 0}
.setting-row .row-main{display:flex;flex-direction:column;gap:2px;flex:1;min-width:0}
.row-name{font-family:var(--brand-heading);font-size:.9375rem;font-weight:600;color:var(--text)}
.row-name .title{color:#8fd3d4}
.default-hint{font-family:var(--brand-body);font-size:.75rem;color:var(--text-2)}
.setting-children{margin-left:.75rem;padding-left:1rem;border-left:2px solid var(--border)}
.setting-children[data-enabled='false']{opacity:.55}
.setting-children[data-enabled='false'] .row-name{color:var(--text-2)}
.setting-children .why{font-family:var(--brand-body);font-size:.72rem;color:var(--text-2);margin:.1rem 0 .2rem}
.toggle{background:none;border:none;padding:2px;cursor:pointer;flex-shrink:0;transition:transform .06s ease}
.toggle-track{display:block;width:32px;height:18px;background:var(--overlay);border:1px solid var(--border);border-radius:9px;position:relative;transition:background .15s ease,border-color .15s ease}
.toggle:hover .toggle-track{border-color:var(--accent)}
.toggle:active{transform:translateY(1px) scale(.97)}
.toggle.active .toggle-track{background:var(--accent);border-color:var(--accent)}
.toggle-thumb{position:absolute;top:2px;left:2px;width:12px;height:12px;background:#fff;border-radius:50%;transition:transform .18s ease,width .12s ease}
.toggle.active .toggle-thumb{transform:translateX(14px)}
.toggle:active .toggle-thumb{width:16px}
.toggle.active:active .toggle-thumb{transform:translateX(10px)}
.toggle:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:10px}
.toggle:disabled{cursor:not-allowed}
.set-input{background:var(--surface);border:1px solid var(--border);color:var(--text);border-radius:7px;padding:6px 9px;font-size:13px;min-width:160px}
.set-input:focus{outline:none;border-color:var(--accent)}
.set-input:disabled{opacity:.6;cursor:not-allowed}
.act-btn{font-family:var(--brand-heading);font-size:.8125rem;font-weight:600;background:var(--accent);color:var(--on-accent);border:1px solid var(--accent);border-radius:.375rem;padding:.35rem .8rem;cursor:pointer;transition:filter .12s,transform .06s}
.act-btn:hover{filter:brightness(1.1)}
.act-btn:active{transform:translateY(1px)}
.act-btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.act-btn.danger{background:transparent;color:var(--red);border-color:rgba(248,81,73,.5)}
.act-btn.danger:hover{background:rgba(248,81,73,.08)}
.act-btn:disabled{opacity:.5;cursor:not-allowed}
.console{display:none;background:#0b0e13;border:1px solid var(--border);border-radius:8px;padding:8px 10px;font:11.5px/1.5 ui-monospace,monospace;color:#c9d1d9;max-height:200px;overflow:auto;white-space:pre-wrap;margin:.4rem 0 .2rem}
.console.show{display:block}
.details{font-family:var(--brand-body);font-size:11.5px;color:var(--text-2)}
.details summary{cursor:pointer;color:var(--text-body)}
.details code{font-size:11px}
.settings-footer{margin-top:1.5rem;padding-top:1rem;border-top:1px solid var(--border);display:flex;flex-direction:column;gap:.5rem}
.footer-note{font-family:var(--brand-body);font-size:.75rem;color:var(--text-2);margin:0}
.restore-btn{width:fit-content;display:inline-flex;align-items:center;gap:.4rem;padding:.4rem .85rem;background:transparent;border:1px solid var(--border);border-radius:.375rem;color:var(--text-body);font-family:var(--brand-heading);font-size:.8125rem;font-weight:600;cursor:pointer}
.restore-btn:hover{background:var(--elevated)}
.restore-btn.armed{color:var(--red);border-color:rgba(255,107,107,.5);background:rgba(255,107,107,.08)}
#list{width:300px;min-width:300px;border-right:1px solid var(--edge);background:var(--panel);display:flex;flex-direction:column}
#list h1{font-size:15px;margin:0;padding:14px 16px;border-bottom:1px solid var(--edge);letter-spacing:.02em}
#list h1 small{color:var(--mut);font-weight:400}
#refresh{float:right;background:#0d0f14;border:1px solid var(--edge);color:var(--ink);border-radius:6px;width:26px;height:24px;font-size:14px;cursor:pointer;line-height:1}
#refresh:hover{background:#243049;border-color:var(--acc)}
#crumb{padding:7px 16px 0;font-size:12px;color:var(--mut)}
#crumb a{color:var(--acc);text-decoration:none}
#crumb a:hover{text-decoration:underline}
#crumb .cactive{color:var(--ink);font-weight:600}
.row .tag{display:inline-block;font-size:10px;background:#243049;color:var(--acc);border-radius:4px;padding:1px 5px;margin-left:6px;vertical-align:1px}
.cathead{padding:7px 16px 5px;font-size:10px;text-transform:uppercase;letter-spacing:.08em;color:var(--acc);background:#12151c;border-bottom:1px solid var(--edge);font-weight:700;position:sticky;top:0;z-index:1}
.pubsearch{margin-top:6px;border-top:1px solid var(--edge)}
.pubsearch .pubrow{display:flex;gap:8px;margin:8px 12px}
.pubsearch input{flex:1;min-width:0;background:#0d0f14;border:1px solid var(--edge);color:var(--ink);border-radius:8px;padding:9px 10px;font-size:13px}
.pubsearch input:focus{outline:none;border-color:var(--acc)}
.pubsearch button{background:var(--acc);color:#08111f;border:none;border-radius:8px;padding:0 13px;font-size:19px;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center}
.pubsearch button:hover{filter:brightness(1.1)}
.row .selbox{margin-right:9px;vertical-align:middle;cursor:pointer}
#toolbar{border-bottom:1px solid var(--edge);padding:8px 12px;display:flex;flex-direction:column;gap:7px}
#toolbar .selall{font-size:12px;color:var(--mut);cursor:pointer;user-select:none;display:flex;align-items:center;gap:6px}
#toolbar .tbtns{display:flex;gap:7px}
#toolbar button{flex:1;background:#1a2130;border:1px solid var(--edge);color:var(--ink);border-radius:7px;padding:7px 8px;font-size:12px;cursor:pointer}
#toolbar button:hover{border-color:var(--acc);background:#22304a}
#toolbar button.primary{background:var(--acc);color:#08111f;border-color:var(--acc);font-weight:600}
#toolbar button:disabled{opacity:.45;cursor:not-allowed}
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.25);display:flex;align-items:center;justify-content:center;z-index:1000}
.modal{background:var(--panel);border:1px solid var(--edge);border-radius:14px;width:min(760px,92vw);max-height:88vh;overflow:auto;padding:22px 24px;box-shadow:0 20px 60px #000a}
.modal h2{margin:0 0 4px;font-size:18px}
.modal .msub{color:var(--mut);font-size:13px;margin-bottom:16px}
.modal .opts{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:16px}
.modal .opt{border:1px solid var(--edge);border-radius:9px;padding:9px 13px;cursor:pointer;font-size:13px;display:flex;gap:8px;align-items:center}
.modal .opt.sel{border-color:var(--acc);background:#1a2740}
.modal table{width:100%;margin:0 0 6px}
.modal .sure{margin:18px 0 10px;font-size:15px;font-weight:600}
.modal-actions{display:flex;gap:10px;justify-content:flex-end}
.modal-actions button{background:#1a2130;border:1px solid var(--edge);color:var(--ink);border-radius:8px;padding:9px 18px;font-size:14px;cursor:pointer}
.modal-actions button.primary{background:var(--acc);color:#08111f;border-color:var(--acc);font-weight:700}
.modal .lib{font:12px ui-monospace,monospace;color:#7ee0c0}
.modal .note{background:#12151c;border:1px solid var(--edge);border-radius:8px;padding:10px 12px;font-size:12px;color:var(--mut);margin:6px 0 4px}
#modal-dest{margin:0 0 16px}
#modal-dest label{display:block;font-size:11px;color:var(--mut);margin-bottom:6px;text-transform:uppercase;letter-spacing:.05em}
#modal-dest .destrow{display:flex;gap:8px}
#modal-dest select,#modal-dest input{background:#0d0f14;border:1px solid var(--edge);color:var(--ink);border-radius:8px;padding:8px 10px}
#modal-dest select{min-width:200px}
#modal-dest input{flex:1;font:12px ui-monospace,monospace}
#modal-dest input:focus,#modal-dest select:focus{outline:none;border-color:var(--acc)}
#refresh.spin{animation:sp .6s linear infinite}
@keyframes sp{to{transform:rotate(360deg)}}
#q{margin:10px;padding:8px 10px;background:#0d0f14;border:1px solid var(--edge);border-radius:8px;color:var(--ink)}
#rows{overflow:auto;flex:1;scrollbar-gutter:stable}
.row{padding:10px 16px;border-bottom:1px solid var(--edge);cursor:pointer}
.row:hover{background:#1e222b}
.row.sel{background:#243049;border-left:3px solid var(--acc);padding-left:13px}
.row .mpn{font-weight:600}
.row .sub{color:var(--mut);font-size:12px}
#view{flex:1;overflow:auto;padding:18px}
#hint{color:var(--mut);text-align:center;margin-top:20vh}
.cols{display:grid;grid-template-columns:1fr 1fr 1fr;gap:14px}
.card{background:var(--panel);border:1px solid var(--edge);border-radius:12px;overflow:hidden;display:flex;flex-direction:column}
.card h3{margin:0;padding:9px 12px;font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut);border-bottom:1px solid var(--edge)}
.card .body{aspect-ratio:1;width:100%;display:flex;align-items:center;justify-content:center;padding:10px;background:#161b22;overflow:hidden;cursor:grab;touch-action:none;position:relative}
.card .body svg{max-width:100%;max-height:100%;will-change:transform}
.card .hint2{position:absolute;bottom:6px;right:8px;font-size:10px;color:#b9c0cc;background:#0d0f14aa;border-radius:5px;padding:1px 6px;pointer-events:none}
.lfilter{display:flex;flex-wrap:wrap;gap:6px;padding:8px 10px;border-bottom:1px solid var(--edge)}
.chip{display:inline-flex;align-items:center;gap:5px;font-size:11px;color:var(--ink);background:#0d0f14;border:1px solid var(--edge);border-radius:6px;padding:3px 7px;cursor:pointer;user-select:none}
.chip input{margin:0;cursor:pointer}
.chip .sw{width:9px;height:9px;border-radius:2px;display:inline-block}
.chip:has(input:not(:checked)){opacity:.4;text-decoration:line-through}
model-viewer{width:100%;aspect-ratio:1;height:auto;background:#161b22}
h2.title{margin:0 0 14px}
h2.title small{color:var(--mut);font-weight:400;font-size:14px}
table{width:100%;border-collapse:collapse;margin-top:16px;background:var(--panel);border:1px solid var(--edge);border-radius:12px;overflow:hidden}
th,td{text-align:left;padding:10px 14px;border-bottom:1px solid var(--edge);font-size:13px}
th{color:var(--mut);font-weight:600;text-transform:uppercase;font-size:11px;letter-spacing:.05em}
tr:last-child td{border-bottom:none}
.swatch{display:inline-block;width:11px;height:11px;border-radius:3px;margin-right:7px;vertical-align:-1px}
.badge{font:12px ui-monospace,monospace;background:#0d0f14;border:1px solid var(--edge);border-radius:6px;padding:2px 7px}
.stacktitle{margin:22px 0 6px;font-size:13px;text-transform:uppercase;letter-spacing:.05em;color:var(--mut)}
.err{color:#f87171;padding:16px}
.card .body [data-num]{cursor:pointer}
.pinhi .pinvis{stroke:#6ea8fe !important;vector-effect:non-scaling-stroke;stroke-width:3}
rect.pad.pinhi{stroke:#6ea8fe;vector-effect:non-scaling-stroke;stroke-width:3}
#tip{position:fixed;z-index:9999;background:#0d1117;border:1px solid var(--acc);color:var(--ink);font:12px ui-monospace,monospace;padding:3px 8px;border-radius:6px;pointer-events:none;display:none;white-space:nowrap}
#tip b{color:var(--acc)}
</style>
</head>
<body>
<header id="hdr">
<div class="brand" id="brand"><img src="favicon.svg" alt=""><span class="bn">Adom LBR</span><span class="bs">· the EDA library converter</span>
<div id="brandpop" hidden><b>Adom LBR</b> <span id="pop-ver"></span><br><a href="https://wiki.adom.inc/adom/adom-lbr" target="_blank" rel="noopener">wiki.adom.inc/adom/adom-lbr</a><div id="pop-extra"></div></div>
</div>
<span class="chip" id="targetchip" title="Which machine Adom Bridge answers on for the Send-to buttons"><i id="tdot"></i><span class="tc">target:</span> <span id="tname">looking…</span></span>
<span style="margin-left:auto"></span>
<button class="iconbtn" id="btnHelp" aria-pressed="true" aria-label="Hide the prompts panel" data-tooltip="Hide the prompts panel"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M9.6 9.2a2.5 2.5 0 1 1 3.3 2.4c-.6.2-.9.7-.9 1.3v.6"/><path d="M12 17h.01"/></svg></button>
<button class="iconbtn" id="btnSettings" aria-label="Settings" data-tooltip="Settings"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/> <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
</header>
<nav id="sendbar" aria-label="Send the open part to your EDA tools">
<span class="lab">Send to KiCad</span>
<div class="grp splitgrp">
<button data-send="kicad-library" title="Install symbol + footprint + 3D and open all three: the working library. A library IS all three, so this is the one you usually want">Library</button>
<button class="caret" id="caret-kicad" aria-haspopup="menu" aria-expanded="false" title="Less often: send just one piece"><svg viewBox="0 0 24 24" width="12" height="12" aria-hidden="true"><path fill="currentColor" d="M7 10l5 5 5-5z"/></svg></button>
<div class="dropmenu" id="menu-kicad" role="menu" hidden>
<button role="menuitem" data-send="kicad-symbol" title="Install this part's symbol and open it in KiCad's Symbol Editor">Just the symbol</button>
<button role="menuitem" data-send="kicad-footprint" title="Install the footprint (with its 3D model wired) and open the Footprint Editor">Just the footprint</button>
<button role="menuitem" data-send="kicad-3d" title="Open the part's STEP model on its footprint in KiCad's 3D viewer">Just the 3D chip</button>
</div>
</div>
<span class="sep"></span>
<span class="lab">Send to Fusion</span>
<div class="grp">
<button data-send="fusion-library" title="Stage the .lbr + .step on your PC and have the Fusion bridge build and prove the library">Library</button>
</div>
</nav>
<div id="content">
<div id="scafwrap">
<div id="newbie"></div>
</div>
<div class="ledrow" id="ledrow" title="live status; tap a pill for the detail"></div>
<div id="led-pop" role="dialog"></div>
<div id="panes">
<aside id="list">
<h1>Library <small>boards and parts</small><button id="refresh" title="refresh list (fetch newly-pushed boards)">↻</button></h1>
<div id="modes"><button id="mode-projects" class="on">Projects</button><button id="mode-parts">Parts</button></div>
<div id="crumb"></div>
<input id="q" placeholder="filter boards…">
<div id="toolbar" style="display:none">
<label class="selall"><input type="checkbox" id="selall"> <span id="selcount">select all</span></label>
<div class="tbtns">
<button id="btn-addlib" disabled>Add to Library</button>
<button id="btn-addproj">Add Project ↓</button>
</div>
</div>
<div id="rows"></div>
</aside>
<main id="view"><div id="hint">← pick a board, then a component, to open its viewer</div></main>
</div>
</div>
<div id="toasts" role="status" aria-live="polite"></div>
<div id="tip2"></div>
<div id="sendpanel" role="status" aria-live="polite">
<div class="sp-head"><h3 id="sp-title"></h3><button class="iconbtn" id="sp-close" aria-label="Close" title="Close"><svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></button></div>
<div class="sp-body">
<div class="sp-bar" id="sp-bar"><i></i></div>
<div class="sp-step" id="sp-step"></div>
<pre id="sp-console"></pre>
<div id="sp-shots"></div>
</div>
</div>
<div id="sp-zoom"><img alt="evidence, full size"></div>
<div id="tour-ring"></div>
<div id="tour-card" role="dialog" aria-labelledby="tour-title">
<h3><span class="step-n" id="tour-n"></span><span id="tour-title"></span></h3>
<p id="tour-text" style="margin-bottom:0"></p>
</div>
<div id="tour-bar" role="toolbar" aria-label="Demo controls">
<button id="tour-prev" title="Previous step (left arrow)" aria-label="Previous"><svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M6 6h2v12H6zm3.5 6 8.5 6V6z"/></svg></button>
<button id="tour-play" class="play" title="Pause (space)" aria-label="Play or pause"><svg id="tour-play-ic" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg></button>
<button id="tour-next" title="Next step (right arrow)" aria-label="Next"><svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M16 6h2v12h-2zM6 18l8.5-6L6 6z"/></svg></button>
<span class="tn" id="tour-progress">1 / 13</span>
<button id="tour-mute" title="Mute or unmute the narration" aria-label="Mute"><svg id="tour-mute-ic" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0 0 14 7.97v8.05A4.5 4.5 0 0 0 16.5 12z"/></svg></button>
<button id="tour-exit" title="Exit the demo (Esc)" aria-label="Exit"><svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></button>
</div>
<dialog id="dlgSettings" class="settings" tabindex="-1" aria-labelledby="settingsTitle">
<div class="settings-head" id="settingsHead">
<h2 id="settingsTitle">adom-lbr Settings <span class="card-status" id="settingsVer"></span></h2>
<button class="iconbtn" id="btnCloseSettings" aria-label="Close settings" title="Close"><svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></button>
</div>
<div class="settings-body" id="settingsBody">
<section class="settings-card" id="card-say">
<h3 class="card-title">Say it strip <span class="card-status" id="st-say"></span></h3>
<p class="card-desc">The strip at the top of the page teaches the one thing worth knowing: you ask your AI for this app by saying <b>lbr</b>. Each phrase copies on click, and Try it stages it into this app's own AI thread without sending. Once you know the noun you do not need the lesson, so the X on the strip hides it for good; this switch brings it back.</p>
<div class="setting-row">
<div class="row-main"><span class="row-name" title="show_say_strip">Say it: <span class="title">Show the strip</span></span><span class="default-hint">Default: On</span></div>
<button class="toggle" id="tg-say" role="switch" aria-checked="true" aria-label="Show the Say it strip"><span class="toggle-track"><span class="toggle-thumb"></span></span></button>
</div>
</section>
<section class="settings-card" id="card-wiki">
<h3 class="card-title">Wiki identity and the library list <span class="card-status" id="st-wiki"></span></h3>
<p class="card-desc">The library pane lists every board and component page on wiki.adom.inc. <b>My projects</b> are the boards whose wiki author is you, read from the sign-in this container holds (<code>adom-wiki whoami</code>). If that is not the name the wiki shows on your pages, set it here and the grouping follows. The list itself is fetched in 200-row pages and cached so later opens are instant; the cache expires after the minutes below, and the refresh button in the library pane always fetches fresh.</p>
<div class="setting-row">
<div class="row-main"><span class="row-name" title="owner_override">Wiki: <span class="title">My projects author</span></span><span class="default-hint" id="owner-default">Default: your wiki sign-in</span></div>
<input class="set-input" id="in-owner" placeholder="leave empty for your sign-in">
</div>
<div class="divider"></div>
<div class="setting-row">
<div class="row-main"><span class="row-name" title="list_cache_minutes">Wiki: <span class="title">List cache</span></span><span class="default-hint">Default: 30 minutes</span></div>
<input class="set-input" id="in-cache" type="number" min="1" max="1440" style="min-width:90px"> <span class="default-hint">minutes</span>
</div>
</section>
<section class="settings-card" id="card-pkg">
<h3 class="card-title">The adom-lbr package <span class="card-status" id="st-pkg"></span></h3>
<p class="card-desc">This dashboard is the <code>adom-lbr</code> CLI, installed from the wiki page <code>adom/adom-lbr</code> into <code>~/.local/bin</code>, with the 3D viewer assets in <code>~/.local/share/adom-lbr</code>. Update fetches the latest published version with <code>adom-wiki pkg install</code>; the running dashboard keeps serving until the next launch from the dock, which restarts it on the new version. Uninstall removes the binary, the assets, and the bundled skills; it does not touch your libraries or your settings file. Each action streams its real output below the row and ends with a verify line.</p>
<div class="setting-row">
<div class="row-main"><span class="row-name">Package: <span class="title">adom-lbr</span></span><span class="default-hint" id="pkg-hint"></span></div>
<button class="act-btn" id="btn-update">Update</button>
<button class="act-btn danger" id="btn-uninstall">Uninstall</button>
</div>
<pre class="console" id="console-pkg"></pre>
<details class="details"><summary>Details</summary><div id="pkg-details"></div></details>
</section>
<footer class="settings-footer">
<p class="footer-note" id="footer-note">Settings are stored in this container and apply immediately. Restoring returns every option above to its default.</p>
<button class="restore-btn" id="btn-restore">Restore defaults</button>
</footer>
</div>
</dialog>
<div id="modal" class="modal-overlay" style="display:none"><div class="modal">
<h2 id="modal-title"></h2><div class="msub" id="modal-sub"></div>
<div class="opts" id="modal-opts"></div>
<div id="modal-dest"></div>
<div id="modal-preview"></div>
<div class="sure">Are you sure this is good?</div>
<div class="modal-actions"><button id="modal-cancel">Cancel</button><button class="primary" id="modal-go">Yes, add it</button></div>
</div></div>
<script src="newbie-panel.js"></script>
<script>
const COLORS={Silkscreen:'#d8b4fe',Courtyard:'#4ade80','Fabrication / Assembly':'#f472b6',Copper:'#f59e0b',Other:'#94a3b8'};
// Board-centric navigation: Boards (tagged board/molecule) ▸ a board's component
// dependencies ▸ the symbol/footprint/3D viewer.
let all=[], bySlug={}, boardSlug=null, curDeps=[], me='', pubQuery='';
const isBoard=c=>{const t=(c.tags||[]).map(x=>(''+x).toLowerCase());return t.includes('board')||t.includes('molecule');};
const qval=()=>document.getElementById('q').value.toLowerCase();
const rowsEl=()=>document.getElementById('rows');
function makeRow(slug,title,sub,onclick,tag,sel){
const d=document.createElement('div');d.className='row';d.dataset.slug=slug;
const cb=sel?`<input type="checkbox" class="selbox"${selected.has(slug)?' checked':''}>`:'';
d.innerHTML=`<div class="mpn">${cb}${title}${tag?`<span class="tag">${tag}</span>`:''}</div><div class="sub">${sub}</div>`;
d.onclick=(e)=>{ if(e.target.classList.contains('selbox')){toggleSel(slug,e.target.checked);e.stopPropagation();return;} onclick(slug,d); };
return d;
}
// component-type → the user's existing KiCad symbol library (fallback JLC_Misc_BLT)
const TYPE_TO_SYMLIB={'Capacitors':'JLC_Capacitors_BLT','Resistors':'JLC_Resistors_BLT','MOSFETs':'JLC_FETs_BLT','Transistors':'JLC_FETs_BLT','Inductors':'JLC_Inductors_BLT','LEDs':'JLC_LEDs_BLT','Diodes':'JLC_Diodes_BLT','Connectors':'JLC_Connectors_BLT','Microcontrollers':'JLC_MCUs_BLT','Power ICs':'JLC_Power_BLT','Crystals & Oscillators':'JLC_Oscillators_BLT','ICs':'JLC_Misc_BLT','Other':'JLC_Misc_BLT'};
const selected=new Set();
function symLibFor(c){return TYPE_TO_SYMLIB[typeOf(c)]||'JLC_Misc_BLT';}
function toggleSel(slug,on){on?selected.add(slug):selected.delete(slug);updateToolbar();}
function updateToolbar(){
const n=selected.size;
const cnt=document.getElementById('selcount'); if(cnt)cnt.textContent=n?`${n} selected · clear`:'select all';
const b=document.getElementById('btn-addlib'); if(b){b.disabled=n===0;b.textContent=n?`Add ${n} to Library`:'Add to Library';}
const sa=document.getElementById('selall'); if(sa)sa.checked=curDeps.length>0&&curDeps.every(s=>selected.has(s));
}
async function loadList(fresh){
const btn=document.getElementById('refresh'); if(btn&&fresh)btn.classList.add('spin');
if(!fresh) rowsEl().innerHTML='<div class="row" style="color:#8b93a3;cursor:default">loading boards from wiki…</div>';
try{
const r=await fetch('api/components'+(fresh?'?refresh=1':'')).then(r=>r.json());
all=r.components||[]; me=r.me||''; bySlug={}; all.forEach(c=>bySlug[c.slug]=c);
render();
}catch(e){
rowsEl().innerHTML='<div class="row" style="color:#f87171;cursor:default">failed to load — retry</div>';
}finally{ if(btn)btn.classList.remove('spin'); }
}
let sideMode='projects';
function render(){ if(sideMode==='parts'){renderParts();return;} boardSlug?renderDeps():renderBoards(); }
function setMode(m){
sideMode=m; boardSlug=null; selected.clear();
document.getElementById('mode-projects').classList.toggle('on',m==='projects');
document.getElementById('mode-parts').classList.toggle('on',m==='parts');
document.getElementById('q').placeholder=m==='parts'?'search all wiki parts…':'filter projects…';
render();
}
document.getElementById('mode-projects').onclick=()=>setMode('projects');
document.getElementById('mode-parts').onclick=()=>setMode('parts');
// The wiki IS the library: every component page is browsable directly, no board
// (and nothing local) required. Boards stay one click away on the Projects tab.
function renderParts(){
document.getElementById('toolbar').style.display='none';
document.getElementById('crumb').innerHTML='<span class="cactive">All wiki parts</span>';
const q=qval(),el=rowsEl();el.innerHTML='';
const parts=all.filter(c=>!isBoard(c)).filter(c=>!q||c.slug.includes(q)||(c.title||'').toLowerCase().includes(q)||(c.brief||'').toLowerCase().includes(q));
const h=document.createElement('div');h.className='cathead';h.textContent=`Parts · ${parts.length}${q?' matching':''}`;el.appendChild(h);
parts.slice(0,400).forEach(c=>el.appendChild(makeRow(c.slug,c.title||c.slug,c.slug,(sl,d)=>openPart(sl,d))));
if(parts.length>400){const d=document.createElement('div');d.className='row';d.style.cssText='color:#8b93a3;cursor:default';d.textContent=`… ${parts.length-400} more — type to narrow`;el.appendChild(d);}
}
function renderBoards(){
document.getElementById('toolbar').style.display='none';selected.clear();
document.getElementById('crumb').innerHTML='<span class="cactive">Projects</span>';
document.getElementById('q').placeholder='filter projects…';
const q=qval(),el=rowsEl();el.innerHTML='';
const boards=all.filter(isBoard);
const mineAll=boards.filter(c=>c.owner&&c.owner===me);
const pubAll=boards.filter(c=>!(c.owner&&c.owner===me));
// My projects — a normal list (filtered by the top box)
const mine=mineAll.filter(c=>!q||c.slug.includes(q)||(c.title||'').toLowerCase().includes(q));
const h=document.createElement('div');h.className='cathead';h.textContent=`My projects · ${mine.length}`;el.appendChild(h);
if(!mine.length){const d=document.createElement('div');d.className='row';d.style.cssText='color:#8b93a3;cursor:default;line-height:1.5';d.innerHTML='none yet — push a board tagged <b>board</b>/<b>molecule</b> to your wiki, then press ↻';el.appendChild(d);}
mine.forEach(c=>el.appendChild(makeRow(c.slug,c.title||c.slug,c.slug,openBoard,'board')));
// Public projects — type a query, press Enter or the button to load matches
const wrap=document.createElement('div');wrap.className='pubsearch';
wrap.innerHTML=`<div class="cathead">Public projects</div><div class="pubrow"><input id="pubq" placeholder="search public projects"><button id="pubgo" title="search">⏎</button></div><div id="pubresults"></div>`;
el.appendChild(wrap);
const pubq=document.getElementById('pubq');pubq.value=pubQuery;
const renderPub=()=>{
const pr=document.getElementById('pubresults');pr.innerHTML='';const pq=pubQuery.trim().toLowerCase();
if(!pq)return;
const m=pubAll.filter(c=>c.slug.includes(pq)||(c.title||'').toLowerCase().includes(pq)||(c.brief||'').toLowerCase().includes(pq));
if(!m.length){pr.innerHTML=`<div class="row" style="color:#8b93a3;cursor:default">no public projects match “${pubQuery.trim()}”</div>`;return;}
m.forEach(c=>pr.appendChild(makeRow(c.slug,c.title||c.slug,c.slug,openBoard,'board')));
};
const doPub=()=>{pubQuery=pubq.value;renderPub();};
pubq.onkeydown=e=>{if(e.key==='Enter')doPub();};
document.getElementById('pubgo').onclick=doPub;
renderPub();
}
async function openBoard(slug){
boardSlug=slug; curDeps=[]; selected.clear();
const title=(bySlug[slug]||{}).title||slug;
document.getElementById('crumb').innerHTML=`<a href="#" onclick="backToBoards();return false">← Boards</a> / <span class="cactive">${title}</span>`;
rowsEl().innerHTML='<div class="row" style="color:#8b93a3;cursor:default">loading dependencies…</div>';
try{ const r=await fetch('api/board/'+slug).then(r=>r.json()); curDeps=(r.deps||[]).map(d=>d.slug); }
catch(e){ curDeps=[]; }
renderDeps();
}
// Infer a component's type from its tags + brief + title (most parts carry no
// explicit category, so keywords in the brief are the signal).
const TYPE_RULES=[
[/microcontroller|\bmcu\b|\bmpu\b|\bsoc\b/,'Microcontrollers'],
[/regulator|\bldo\b|dc-?dc|buck|boost|power management|\bpmic\b/,'Power ICs'],
[/mosfet|\bfet\b/,'MOSFETs'],
[/transistor|\bbjt\b|\bnpn\b|\bpnp\b/,'Transistors'],
[/\bled\b/,'LEDs'],
[/diode|schottky|zener|\btvs\b|rectifier/,'Diodes'],
[/mlcc|capacitor|\bcap\b|tantalum/,'Capacitors'],
[/resistor|thick film|thin film/,'Resistors'],
[/inductor|chip coil|\bcoil\b|ferrite|choke|\bbead\b/,'Inductors'],
[/crystal|oscillator|resonator/,'Crystals & Oscillators'],
[/connector|header|receptacle|socket|\bjack\b|\busb\b/,'Connectors'],
[/switch|button|tactile/,'Switches'],
[/\bfuse\b/,'Fuses'],
[/amplifier|op-?amp|\bdriver\b|sensor|\badc\b|\bdac\b|transceiver|controller|\bgate\b|logic|\bic\b/,'ICs'],
];
const TYPE_ORDER=['Microcontrollers','ICs','Power ICs','MOSFETs','Transistors','Diodes','LEDs','Resistors','Capacitors','Inductors','Crystals & Oscillators','Connectors','Switches','Fuses','Other'];
function typeOf(c){
const hay=(((c.tags||[]).join(' '))+' '+(c.brief||'')+' '+(c.title||'')+' '+(c.slug||'')).toLowerCase();
for(const [re,label] of TYPE_RULES) if(re.test(hay)) return label;
return 'Other';
}
function renderDeps(){
document.getElementById('q').placeholder='filter components…';
document.getElementById('toolbar').style.display='flex';
const q=qval(),el=rowsEl();el.innerHTML='';
if(!curDeps.length){el.innerHTML='<div class="row" style="color:#8b93a3;cursor:default">this board lists no component dependencies</div>';updateToolbar();return;}
const items=curDeps.map(s=>({slug:s,c:bySlug[s]||{slug:s}}))
.filter(x=>!q||x.slug.includes(q)||((x.c.title||'').toLowerCase().includes(q))||((x.c.brief||'').toLowerCase().includes(q)));
const groups={};
items.forEach(x=>{const t=typeOf(x.c);(groups[t]=groups[t]||[]).push(x);});
const cats=Object.keys(groups).sort((a,b)=>{const ia=TYPE_ORDER.indexOf(a),ib=TYPE_ORDER.indexOf(b);return (ia<0?99:ia)-(ib<0?99:ib)||a.localeCompare(b);});
cats.forEach(cat=>{
const h=document.createElement('div');h.className='cathead';h.textContent=`${cat} · ${groups[cat].length}`;el.appendChild(h);
groups[cat].forEach(x=>{const on=bySlug[x.slug];el.appendChild(makeRow(x.slug,x.c.title||x.slug,on?x.slug:x.slug+' · not on wiki',openPart,null,true));});
});
updateToolbar();
}
function backToBoards(){boardSlug=null;curDeps=[];document.getElementById('q').value='';document.querySelector('#view #hint')||(document.getElementById('view').innerHTML='<div id="hint">← pick a board, then a component, to open its viewer</div>');render();}
let currentPart=null;
async function openPart(slug,el){
currentPart=slug; updateSendbar();
document.querySelectorAll('.row').forEach(r=>r.classList.remove('sel'));
if(el)el.classList.add('sel');
const v=document.getElementById('view');
v.innerHTML='<div id="hint">loading '+slug+'…</div>';
const p=await fetch('api/part/'+slug).then(r=>r.json());
if(p.status!=='ok'){v.innerHTML='<div class="err">'+(p.error||'failed')+'</div>';return;}
const glb=p.glb?`<model-viewer src="glb/${slug}" camera-controls auto-rotate ar shadow-intensity="1"></model-viewer>`:'<div class="body" style="color:#888;background:#0d0f14">no 3D model</div>';
let rows=(p.layers||[]).map(l=>`<tr>
<td><span class="swatch" style="background:${COLORS[l.role]||'#888'}"></span>${l.role}<span style="color:#8b93a3"> ×${l.count}</span></td>
<td><span class="badge">${l.kicad}</span></td>
<td><span class="badge">${l.altium}</span></td></tr>`).join('');
v.innerHTML=`
<h2 class="title">${p.mpn} <small>· ${p.pins} pins · ${p.pads} pads</small></h2>
<div class="cols">
<div class="card"><h3>Schematic Symbol</h3><div class="body">${p.symbol_svg}<span class="hint2">drag · scroll to zoom</span></div></div>
<div class="card"><h3>Footprint</h3>
<div class="lfilter">${(p.layers||[]).map(l=>`<label class="chip"><input type="checkbox" checked data-key="${l.key}"><span class="sw" style="background:${COLORS[l.role]||'#888'}"></span>${l.role}</label>`).join('')}</div>
<div class="body" id="fpbody">${p.footprint_svg}<span class="hint2">drag · scroll to zoom</span></div></div>
<div class="card"><h3>3D Model</h3>${glb}</div>
</div>
<div class="stacktitle">Footprint layer stackup — what each element becomes per EDA tool</div>
<table><thead><tr><th>Element (neutral role)</th><th>KiCad layer</th><th>Altium layer</th></tr></thead>
<tbody>${rows}</tbody></table>`;
v.querySelectorAll('.card .body').forEach(makePannable);
// footprint layer filter — toggle visibility of each layer's SVG elements
v.querySelectorAll('.lfilter input').forEach(cb=>{
cb.onchange=()=>{
v.querySelectorAll('#fpbody svg .ly-'+cb.dataset.key).forEach(el=>{el.style.display=cb.checked?'':'none';});
};
});
setupHighlight(v);
}
// Hover a pin (symbol) or pad (footprint) → highlight it + show its number and
// name/description in a tooltip. Pins/pads carry data-num + data-name.
function setupHighlight(v){
let tip=document.getElementById('tip');
if(!tip){tip=document.createElement('div');tip.id='tip';document.body.appendChild(tip);}
const clear=box=>box.querySelectorAll('.pinhi').forEach(x=>x.classList.remove('pinhi'));
v.querySelectorAll('.card .body').forEach(box=>{
box.addEventListener('pointermove',e=>{
const hit=e.target.closest('[data-num]');
clear(box);
if(hit){
hit.classList.add('pinhi');
const num=hit.dataset.num, nm=hit.dataset.name;
const kind=hit.closest('#fpbody')?'Pad':'Pin';
tip.innerHTML=(nm&&nm!=='~')?`<b>${kind} ${num}</b> · ${nm}`:`<b>${kind} ${num}</b>`;
tip.style.display='block';
tip.style.left=(e.clientX+14)+'px';
tip.style.top=(e.clientY+14)+'px';
} else { tip.style.display='none'; }
});
box.addEventListener('pointerleave',()=>{clear(box);tip.style.display='none';});
});
}
// Drag-to-pan + scroll-to-zoom on an SVG viewer box (pointer capture so a drag
// that leaves the box keeps tracking). Re-assigning on* handlers avoids stacking.
function makePannable(box){
const svg=box.querySelector('svg');
if(!svg) return;
let tx=0,ty=0,scale=1,sx=0,sy=0,drag=false;
const apply=()=>{svg.style.transform=`translate(${tx}px,${ty}px) scale(${scale})`;};
box.onpointerdown=e=>{drag=true;sx=e.clientX-tx;sy=e.clientY-ty;box.setPointerCapture(e.pointerId);box.style.cursor='grabbing';};
box.onpointermove=e=>{if(drag){tx=e.clientX-sx;ty=e.clientY-sy;apply();}};
box.onpointerup=box.onpointercancel=()=>{drag=false;box.style.cursor='grab';};
box.onwheel=e=>{e.preventDefault();const f=e.deltaY<0?1.12:0.89;scale=Math.max(0.25,Math.min(12,scale*f));apply();};
box.ondblclick=()=>{tx=ty=0;scale=1;apply();};
}
// ---- selection controls + add-to-library / add-project modal ----
document.getElementById('selall').onchange=(e)=>{const on=e.target.checked;curDeps.forEach(s=>on?selected.add(s):selected.delete(s));renderDeps();};
function previewTable(plan){
return `<table><thead><tr><th>Component</th><th>Type</th><th>Symbol → library</th><th>Footprint → library</th></tr></thead><tbody>${
plan.map(p=>`<tr><td>${p.title}</td><td>${p.type}</td><td><span class="lib">${p.symLib}.kicad_sym</span></td><td><span class="lib">${p.fpLib}.pretty</span></td></tr>`).join('')}</tbody></table>`;
}
function buildPlan(slugs,projectLib,projName){
return slugs.map(s=>{const c=bySlug[s]||{slug:s,title:s};const t=typeOf(c);
return {slug:s,title:c.title||s,type:t,symLib:projectLib?projName:symLibFor(c),fpLib:projectLib?projName:'adom_lbr'};});
}
let modalGo=null;
const showModal=()=>document.getElementById('modal').style.display='flex';
const hideModal=()=>{document.getElementById('modal').style.display='none';modalGo=null;const b=document.getElementById('applybar');if(b)b.remove();};
document.getElementById('modal-cancel').onclick=hideModal;
document.getElementById('modal-go').onclick=()=>{if(modalGo)modalGo();};
function selOpt(sel,attr,cb){document.querySelectorAll('#modal-opts .opt').forEach(o=>o.onclick=()=>{document.querySelectorAll('#modal-opts .opt').forEach(x=>x.classList.remove('sel'));o.classList.add('sel');o.querySelector('input').checked=true;cb(o.dataset[attr]);});}
// recommended destination parents for delivered projects (editable in the box)
const DEST_FOLDERS=['~/Documents/KiCad/Projects','~/Documents/Adom/Molecules','~/Documents/Adom/Electrical','~/Downloads'];
function openAddLib(){
const slugs=[...selected]; if(!slugs.length)return;
const plan=buildPlan(slugs,false);
document.getElementById('modal-dest').innerHTML='';
document.getElementById('modal-title').textContent='Add to Library';
document.getElementById('modal-sub').textContent=`${slugs.length} component(s) → your existing KiCad libraries`;
document.getElementById('modal-opts').innerHTML=`<div class="opt sel" data-eda="kicad"><input type="radio" name="eda" checked> KiCad</div><div class="opt" data-eda="altium"><input type="radio" name="eda"> Altium (soon)</div>`;
const go=document.getElementById('modal-go');
selOpt('eda','eda',v=>{document.getElementById('modal-preview').innerHTML=v==='altium'?'<div class="note">Altium add-to-library isn\'t wired up yet — pick KiCad for now.</div>':previewTable(plan);go.disabled=v==='altium';});
document.getElementById('modal-preview').innerHTML=previewTable(plan);
go.disabled=false;go.textContent='Yes, add it';
modalGo=()=>doApply(plan,'kicad',false);
showModal();
}
function openAddProj(){
const slugs=curDeps.slice();
const projName=((bySlug[boardSlug]||{}).title||boardSlug).replace(/[^A-Za-z0-9_-]+/g,'_');
let mode='personal';
document.getElementById('modal-title').textContent='Add Project to my Computer';
document.getElementById('modal-sub').textContent=`schematic + ${slugs.length} component(s) — symbols & footprints resolved so it opens clean`;
document.getElementById('modal-opts').innerHTML=`<div class="opt sel" data-mode="personal"><input type="radio" name="mode" checked> Integrate into my libraries</div><div class="opt" data-mode="project"><input type="radio" name="mode"> Project-only library</div>`;
// editable destination + recommended-folder presets
document.getElementById('modal-dest').innerHTML=`<label>Deliver project to</label><div class="destrow"><select id="destsel">${DEST_FOLDERS.map(f=>`<option value="${f}">${f}</option>`).join('')}</select><input id="destpath" spellcheck="false"></div>`;
const destpath=document.getElementById('destpath'), destsel=document.getElementById('destsel');
destpath.value=DEST_FOLDERS[0]+'/'+boardSlug;
destsel.onchange=()=>{destpath.value=destsel.value+'/'+boardSlug;};
const draw=()=>{
const plan=buildPlan(slugs,mode==='project',projName);
document.getElementById('modal-preview').innerHTML=`<div class="note">${mode==='project'?'A self-contained <b>'+projName+'</b> symbol + footprint library is created for this project — your personal libraries stay untouched.':'Symbols merge into your existing per-type libraries; footprints go to <b>adom_lbr.pretty</b>. The schematic library references get registered so nothing shows as broken.'}</div>`+previewTable(plan);
modalGo=()=>doApply(plan,'kicad',mode==='project',boardSlug,document.getElementById('destpath').value);
};
selOpt('mode','mode',v=>{mode=v;draw();});
document.getElementById('modal-go').disabled=false;document.getElementById('modal-go').textContent='Yes, add the project';
draw();showModal();
}
async function doApply(plan,eda,project,board,dest){
const go=document.getElementById('modal-go');go.disabled=true;go.textContent='Starting…';
const opts=document.getElementById('modal-opts');
const start=await fetch('api/apply',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({target:eda,project:!!project,board:board||null,dest:dest||'',items:plan.map(p=>({slug:p.slug,symLib:p.symLib,fpLib:p.fpLib}))})}).then(r=>r.json()).catch(()=>({status:'error',error:'request failed'}));
if(start.status!=='started'){alert(start.error||'failed');go.textContent='Yes, add it';go.disabled=false;return;}
// live status bar above the preview
let bar=document.getElementById('applybar');
if(!bar){bar=document.createElement('div');bar.id='applybar';bar.className='note';opts.parentNode.insertBefore(bar,opts.nextSibling);}
const poll=setInterval(async()=>{
let p; try{p=await fetch('api/apply-progress').then(r=>r.json());}catch(e){return;}
const pct=p.total?` · ${p.current}/${p.total}`:'';
go.textContent=(p.done?'✓ ':'')+(p.stage||'Working…').replace(/…$/,'').slice(0,22)+'…';
bar.innerHTML=`<b>${p.stage||'Working…'}</b>${pct}`;
if(p.done){
clearInterval(poll);
const r=p.result||{}, pf=(r.project&&r.project.files||[]);
go.textContent=`✓ Added ${r.count||0}`;
const folder=(r.project&&r.project.folder||'').replace(/\/home\/[^/]+/,'~');
bar.innerHTML=`<b>✓ Done.</b> Merged ${r.count||0} symbol(s) into your libraries.`+
(pf.length?`<br>Delivered ${pf.length} project file(s) → <span class="lib">${folder}</span>`:'')+
((r.errors&&r.errors.length)?`<br><span style="color:#f87171">${r.errors.length} issue(s): ${r.errors.slice(0,2).join('; ')}</span>`:'');
setTimeout(hideModal,pf.length||(r.errors&&r.errors.length)?3600:1600);
}
},350);
}
document.getElementById('btn-addlib').onclick=openAddLib;
document.getElementById('btn-addproj').onclick=openAddProj;
document.getElementById('q').oninput=render;
document.getElementById('refresh').onclick=()=>loadList(true);
// First load: show a real part in full glory instead of an empty pane. An
// industry-grade sample (STM32 H-line MCU; TMC stepper driver as fallback), and
// only while the user has not opened anything themselves.
let userNavigated=false;
document.getElementById('rows').addEventListener('click',()=>{userNavigated=true;},true);
async function loadSample(){
for (const slug of ['stm32h563rgtx','tmc2209','ads131m04']){
if(userNavigated) return;
try{
const probe=await fetch('api/part/'+slug).then(r=>r.json());
if(probe.status!=='ok') continue;
if(userNavigated) return;
await openPart(slug);
const v=document.getElementById('view');
const note=document.createElement('div');
note.className='sample-note';
note.innerHTML='<b>Sample part.</b> This is '+slug.toUpperCase()+' straight from the wiki — symbol, footprint, 3D and the per-EDA layer stackup, rendered live. Browse everything under <b>Parts</b> on the left, or your own boards under <b>Projects</b>.';
v.prepend(note);
return;
}catch(e){}
}
}
loadList(); loadSample();
// ===================== dock contract: LEDs, say, settings =====================
// toasts skill: bottom centre 24px, newest nearest the bottom, max 3 visible,
// colour ONLY in the 16px line icon and the 3px left border.
// The one fetch helper. It was DELETED by an over-greedy regex during the
// 2.14.0 toast rewrite, and because every call site swallows its own errors the
// page went silent instead of breaking loudly: zero prompt rows, no copy
// button, and a "not answering" status pill, all from this one missing
// function. Per-call timeout, because say-send needs 120 s while status wants 5.
async function api(path,opts){
const o=Object.assign({},opts||{});
const ms=o.timeoutMs||8000; delete o.timeoutMs;
const ac=new AbortController();
const t=setTimeout(()=>ac.abort(),ms);
try{
const r=await fetch(path,Object.assign(o,{signal:ac.signal}));
return await r.json();
} finally { clearTimeout(t); }
}
const TOAST_ICONS={
success:'<path d="M20 6 9 17l-5-5"/>',
error:'<circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16h.01"/>',
info:'<circle cx="12" cy="12" r="9"/><path d="M12 11v5M12 8h.01"/>'
};
function toast(msg,err,kind){
const k=kind||(err?'error':'info');
const wrap=document.getElementById('toasts');
while(wrap.children.length>=3)wrap.firstChild.remove();
const t=document.createElement('div');t.className='toast '+k;
t.innerHTML='<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">'+TOAST_ICONS[k]+'</svg><span></span>';
t.querySelector('span').textContent=msg;
t.onclick=()=>fade(t);
wrap.appendChild(t);
requestAnimationFrame(()=>t.classList.add('in'));
const life=k==='error'?7000:4000;
const timer=setTimeout(()=>fade(t),life);
t.addEventListener('mouseenter',()=>clearTimeout(timer));
function fade(el){el.classList.add('out');setTimeout(()=>el.remove(),160);}
}
// The LED's short label comes from the server (dock.rs led_short), which sends a `short`
// on every LED: "v2.14.3", "1525 parts", "not answering". This helper went missing in an
// edit and paintLeds called it anyway, so the whole LED row threw on every refresh. Found
// by tools/js-call-check.py, not by eye.
function shortState(l){return l.short||l.state||'';}
// How long an LED has been amber, so a warn that never clears can say "stuck?" instead of
// pretending to be a normal busy state. Declared here because it is state that must SURVIVE
// between refreshes; it was referenced but never declared, which threw inside paintLeds on
// every single status refresh and left the header showing one red "dashboard not answering"
// pill while the server was perfectly healthy.
let busySince={};
// The last /api/status payload, read by the settings dialog. It used to be an IMPLICIT
// global created by its own assignment, which works only while that assignment runs first:
// when the status call threw, the settings dialog hit a ReferenceError instead of simply
// finding nothing. Declared, so it is null rather than absent.
let lastStatus=null;
function paintLeds(st){
const row=document.getElementById('ledrow');row.innerHTML='';
(st.leds||[]).forEach(l=>{
let state=l.state==='ok'?'ok':(l.state==='warn'?'busy':'bad');
let stext=shortState(l);
if(state==='busy'){
busySince[l.label]=busySince[l.label]||Date.now();
if(Date.now()-busySince[l.label]>15*60*1000)stext='stuck?';
} else delete busySince[l.label];
const pill=document.createElement('button');
pill.className='led-pill';pill.dataset.state=state;
pill.innerHTML='<span class="dot"></span>'+l.label+' <span class="st">'+stext+'</span>';
pill.onclick=e=>{
e.stopPropagation();
const pop=document.getElementById('led-pop');
pop.innerHTML='<b>'+l.label+'</b><br>'+l.detail+(l._hint?'<span class="fix">Fix: '+l._hint+'</span>':'');
const r=pill.getBoundingClientRect();
pop.style.display='block';
pop.style.left=Math.min(innerWidth-440,Math.max(8,r.left))+'px';
pop.style.top=(r.bottom+8)+'px';
};
row.appendChild(pill);
});
}
document.addEventListener('click',()=>{document.getElementById('led-pop').style.display='none';});
async function refreshStatus(force){
try{
const s=await api('api/status'+(force?'?force=1':''));lastStatus=s;
document.getElementById('pop-ver').textContent='v'+s.version;
// Cache cross-check (dockbar.md, 2026-08-24): if the served build moved on
// while this document stayed cached, say so loudly rather than let anyone
// screenshot stale UI that reports a fresh version.
if(!window.__buildAtLoad)window.__buildAtLoad=s.version;
else if(window.__buildAtLoad!==s.version&&!window.__staleWarned){
window.__staleWarned=true;
toast('This page is running '+window.__buildAtLoad+' but the server now serves '+s.version+'. Reload to catch up.',true);
}
paintLeds(s);
paintSettingsStatus();
gradLaunchTick(s);
}catch(e){ document.getElementById('ledrow').innerHTML='<button class="led-pill" data-state="bad"><span class="dot"></span>dashboard <span class="st">not answering</span></button>'; }
}
refreshStatus(); setInterval(refreshStatus,15000);
// brand popover: version + links live HERE, never in the bar (dash-header)
(function(){
const b=document.getElementById('brand'),pop=document.getElementById('brandpop');let t=null;
b.addEventListener('mouseenter',()=>{t=setTimeout(()=>{pop.hidden=false;},600);});
b.addEventListener('mouseleave',()=>{clearTimeout(t);pop.hidden=true;});
b.addEventListener('click',()=>{pop.hidden=!pop.hidden;}); // tap on mobile
})();
// ---- newbie graduation (localStorage; the ? recalls; say strip folds LAST) ----
// Namespaced: behind the cloud proxy every dashboard PORT shares one origin,
// so the fleet key cross-talks between apps there (found live 2026-08-24:
// a sibling app's aiDriven landed in our state). Direct 127.0.0.1:port is
// per-origin as the contract assumes; the namespace makes both safe.
const GRAD_KEY='adom-lbr:dash-graduation';
function gradState(){try{return JSON.parse(localStorage.getItem(GRAD_KEY))??{}}catch(e){return{}}}
function gradSave(patch){try{localStorage.setItem(GRAD_KEY,JSON.stringify(Object.assign(gradState(),patch)))}catch(e){}}
let gradRecall=false; // ? pressed: Fresh for this session
let gradCounted=false; // one launch count per page load
function gradLaunchTick(st){
if(gradCounted||!st||st.state==='fail')return;
gradCounted=true;
gradSave({launches:(gradState().launches||0)+1});
applyGraduation();
}
// AI-driven graduation: the first Send-to run or Send-to-AI success counts.
function gradAiDriven(){ if(!gradState().aiDriven){gradSave({aiDriven:true});applyGraduation();} }
// ONE state, two controls (the panel X and the header toggle). Closing is a
// FULL close: no stub, no residual one-liner; the app UI moves up and the
// content padding is unchanged because it lives on #content, not the panel.
function panelOpen(){
const g=gradState(),n=g.launches||0,dism=(g.dismissed||{}),ai=!!g.aiDriven;
const allowed=settings?settings.show_say_strip!==false:true;
if(gradRecall)return true; // brought back in THIS session
if(!allowed)return false; // switched off in settings
if(dism.say)return false; // the user closed it, and that is durable
if(g.recalled)return true; // the user brought it back BEFORE, which also has to be
// durable: graduation must not re-fold a panel someone
// deliberately reopened, or the ? button looks broken
// one reload later.
return !ai&&n<5; // otherwise the policy decides
}
// The module owns the panel's open/closed rendering, its X, and the header toggle's
// aria-pressed and label. What stays here is GRADUATION, which is this app's policy: after
// enough green launches, or once the user drives by AI, the panel folds on its own. So this
// function no longer touches the panel's DOM, it asks the handle.
let npanel=null;
function applyGraduation(){
const open=panelOpen();
// remember:false, because graduation deciding to fold the panel is not the user choosing
// to dismiss it, and writing it to the stored preference would make a policy decision
// permanent. The module tells onToggle which of us moved it (newbie-panel 2.1.0).
if(npanel) open?npanel.open({remember:false}):npanel.close({remember:false});
// aria-pressed comes from the module; the VISIBLE difference is the app's own affordance,
// because a toggle that looks identical in both states is a lie (input-feedback skill).
document.getElementById('btnHelp').classList.toggle('on',open);
}
function closePanel(){
const g=gradState();gradSave({dismissed:Object.assign(g.dismissed||{},{say:true})});
gradRecall=false;applyGraduation();
}
loadSettings(); // the Say-it strip's visibility rides settings, so load them at startup
// ---- "Say it": phrases come from dockbar.json `say` via the server ----
function applySayVisibility(){ applyGraduation(); }
// ---- Mount the SHARED newbie panel (adom-ui-design 2.0.0) --------------------
// Phrases still come from dockbar.json through /api/say, so the dock contract stays the
// single source for what this app tells users to say. Everything else about the panel,
// including the two icon buttons, the clipboard fallback and the three-state send, belongs
// to the module now.
async function mountNewbie(){
let phrases=[];
try{ const r=await api('api/say'); phrases=(r&&r.say)||[]; }catch(e){}
if(!phrases.length){
// An empty panel is never a legitimate resting state. Say so instead of rendering a
// beautiful blank column, which is exactly how a deleted helper hid for a whole release.
toast('The prompts could not be loaded. Reload, or ask your AI directly using the word lbr.',true);
return;
}
npanel=AdomNewbiePanel.mount({
mount:'#newbie', app:'lbr', appName:'Adom LBR', slug:'lbr',
toggleButton:'#btnHelp',
demo:{label:'Play the demo', meta:'13 steps, about 2 minutes', onPlay:()=>{tourPlaying=true;tourShow(0);}},
phrases:phrases,
onToggle:(open,source)=>{
// aria-pressed, aria-label and the tooltip are the module's; the visible on-state is
// this app's own affordance, and it tracks BOTH sources.
document.getElementById('btnHelp').classList.toggle('on',open);
// Only a HUMAN closing the panel counts as dismissing it. 'api' means graduation is
// talking to itself, and recording that as intent pins the panel open (or shut)
// forever. The module reports the source so this no longer needs a flag app-side.
if(source!=='user')return;
// A user decision is DURABLE, both ways, and it is written where panelOpen() reads it
// so the module's stored preference and this app's policy cannot disagree after a
// reload. gradRecall alone was session-only: reopening the panel worked, then
// graduation folded it again on the next load and the control looked broken.
const g=gradState();
if(!open){ gradSave({dismissed:Object.assign(g.dismissed||{},{say:true}),recalled:false}); gradRecall=false; }
else { gradSave({dismissed:Object.assign(g.dismissed||{},{say:false}),recalled:true}); gradRecall=true; }
}
});
applyGraduation();
if(npanel.overBudget.length){
console.warn('[lbr] phrases over the 336px budget at the resting width:',npanel.overBudget);
}
}
mountNewbie();
// ---- Settings modal: native <dialog>, dim never blur, four dismissals ----
const dlg=document.getElementById('dlgSettings');
let settings=null, saveTimers={};
function openSettings(){
dlg.showModal(); dlg.focus();
const body=document.getElementById('settingsBody'); body.scrollTop=0; requestAnimationFrame(()=>body.scrollTop=0);
loadSettings(); refreshStatus(true);
}
document.getElementById('btnSettings').onclick=openSettings;
document.getElementById('btnCloseSettings').onclick=()=>dlg.close();
dlg.addEventListener('click',e=>{ if(e.target!==dlg)return; const r=dlg.getBoundingClientRect(); const inside=e.clientX>=r.left&&e.clientX<=r.right&&e.clientY>=r.top&&e.clientY<=r.bottom; if(!inside)dlg.close(); });
// draggable by its header
(function(){const h=document.getElementById('settingsHead');let sx,sy,ox,oy,drag=false;
h.addEventListener('pointerdown',e=>{if(e.target.closest('button'))return;drag=true;const r=dlg.getBoundingClientRect();sx=e.clientX;sy=e.clientY;ox=r.left;oy=r.top;h.setPointerCapture(e.pointerId);});
h.addEventListener('pointermove',e=>{if(!drag)return;const x=Math.max(0,Math.min(innerWidth-dlg.offsetWidth,ox+e.clientX-sx)),y=Math.max(0,Math.min(innerHeight-dlg.offsetHeight,oy+e.clientY-sy));dlg.style.position='fixed';dlg.style.margin='0';dlg.style.left=x+'px';dlg.style.top=y+'px';});
h.addEventListener('pointerup',()=>drag=false);})();
function setToggle(id,on){const b=document.getElementById(id);b.classList.toggle('active',!!on);b.setAttribute('aria-checked',on?'true':'false');}
function renderSettings(){
if(!settings)return;
applySayVisibility();
setToggle('tg-say',settings.show_say_strip!==false);
if(document.activeElement!==document.getElementById('in-owner'))document.getElementById('in-owner').value=settings.owner_override||'';
if(document.activeElement!==document.getElementById('in-cache'))document.getElementById('in-cache').value=settings.list_cache_minutes;
paintSettingsStatus();
}
function paintSettingsStatus(){
if(!settings)return;
const ss=document.getElementById('st-say');ss.textContent=settings.show_say_strip===false?'hidden':'shown';ss.className='card-status '+(settings.show_say_strip===false?'':'ok');
const s=lastStatus||{};const ident=(s.leds||[]).find(l=>l.label==='Identity')||{};
const sw=document.getElementById('st-wiki');sw.textContent=ident.detail||'';sw.className='card-status '+(ident.state||'');
document.getElementById('owner-default').textContent='Default: your wiki sign-in'+(ident.state==='ok'&&!settings.owner_override?' ('+ident.detail.replace('My projects = boards by ','')+')':'');
const sp=document.getElementById('st-pkg');sp.textContent=s.version?'v'+s.version+' running':'';sp.className='card-status ok';
document.getElementById('settingsVer').textContent=s.version?'v'+s.version:'';
const bin=(s.leds||[]).find(l=>l.label==='Binary')||{}, v3=(s.leds||[]).find(l=>l.label==='3D viewer')||{};
document.getElementById('pkg-details').innerHTML='<div>'+(bin.detail||'')+'</div><div>'+(v3.detail||'')+'</div><div>settings file: <code>'+(settings._path||'~/.config/adom-lbr/settings.json')+'</code></div><div>wiki page: <code>adom/adom-lbr</code></div>';
document.getElementById('footer-note').textContent='Settings are stored in this container at '+(settings._path||'~/.config/adom-lbr/settings.json')+' and apply immediately. Restoring returns every option above to its default.';
}
async function loadSettings(){
try{ const r=await api('api/settings'); settings=r.settings; settings._path=r.path; renderSettings(); }catch(e){ toast('Could not load settings',true); }
}
function save(patch){
if(!settings)settings={}; // a click can land before /api/settings resolves
Object.assign(settings,patch); renderSettings(); // optimistic, local first
const key=Object.keys(patch).join(',');clearTimeout(saveTimers[key]);
saveTimers[key]=setTimeout(async()=>{
try{ const r=await api('api/settings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(patch)});
if(r.status!=='ok'){toast('Save failed: '+(r.error||'?'),true);return;}
if(r.rejected&&r.rejected.length){toast('Rejected: '+r.rejected.join(', '),true);}
settings=Object.assign(r.settings,{_path:r.path}); renderSettings();
}catch(e){ toast('Save timed out; the dashboard kept your click but the file may be stale',true); }
},250);
}
document.getElementById('tg-say').onclick=()=>save({show_say_strip:settings.show_say_strip===false});
document.getElementById('in-owner').onchange=e=>{save({owner_override:e.target.value.trim()});loadList(true);};
document.getElementById('in-cache').onchange=e=>save({list_cache_minutes:Math.max(1,Math.min(1440,parseInt(e.target.value,10)||30))});
// two-click Restore defaults, auto-disarming after 4 s
(function(){const b=document.getElementById('btn-restore');let armed=false,t=null;
b.onclick=async()=>{ if(!armed){armed=true;b.classList.add('armed');b.textContent='Click again to confirm';t=setTimeout(()=>{armed=false;b.classList.remove('armed');b.textContent='Restore defaults';},4000);return;}
clearTimeout(t);armed=false;b.classList.remove('armed');b.textContent='Restore defaults';
try{const r=await api('api/settings/reset',{method:'POST'});settings=Object.assign(r.settings,{_path:r.path});renderSettings();toast('Settings restored to defaults');loadList(true);}catch(e){toast('Restore failed',true);} };})();
// lifecycle rows with a live console
function runJob(action){
const con=document.getElementById('console-pkg');con.className='console show';con.textContent='starting '+action+'...';
document.getElementById('btn-update').disabled=true;document.getElementById('btn-uninstall').disabled=true;
api('api/lifecycle/'+action,{method:'POST'}).then(r=>{
if(r.status!=='ok'){con.textContent=r.error||'failed';done();return;}
const poll=setInterval(async()=>{
try{const j=await api('api/job/'+action);con.textContent=(j.lines||[]).join('\n');con.scrollTop=con.scrollHeight;
if(j.done){clearInterval(poll);done();refreshStatus(true);toast(action+(j.exit===0?' finished':' failed (exit '+j.exit+')'),j.exit!==0);}}catch(e){}
},700);
}).catch(e=>{con.textContent='could not start: '+e;done();});
function done(){document.getElementById('btn-update').disabled=false;document.getElementById('btn-uninstall').disabled=false;}
}
// ===================== Send-to toolbar: Adom Bridge web control =====================
// The page talks to the Adom Bridge on THIS machine over loopback
// (http://127.0.0.1:24680). Detection is open; running a verb pops the
// Bridge's own Allow once / Allow 1 hr / Deny consent naming this page.
const AB='http://127.0.0.1:24680';
let abUp=false;
async function abPing(){
try{const r=await fetch(AB+'/health',{signal:AbortSignal.timeout(1500)});const j=await r.json();abUp=(j.service==='adom-bridge');}
catch(e){abUp=false;}
const chip=document.getElementById('targetchip');chip.classList.toggle('on',abUp);
document.getElementById('tname').textContent=abUp?'this machine':'no bridge';
chip.title=abUp?'Adom Bridge answers on this machine; the Send-to buttons stage files and drive KiCad / Fusion through it (it asks your permission in its own window)':'Adom Bridge is not detected on this machine, so the Send-to buttons are disabled. Detection needs the dashboard opened at its 127.0.0.1 address, which is how the dock opens it.';
updateSendbar();
}
function updateSendbar(){
const ok=abUp&&!!currentPart;
document.querySelectorAll('#sendbar button').forEach(b=>{
b.disabled=!ok||b.dataset.busy==='1';
if(!abUp)b.title2=b.title; // keep original titles; tooltip text below explains
});
}
abPing(); setInterval(abPing,20000);
async function abVerb(command,args){
const body={command,args:Object.assign({caller:{aiThread:'adom-lbr dashboard'}},args||{})};
const r=await fetch(AB+'/command',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
let j={}; try{j=await r.json();}catch(e){}
return {s:r.status,j};
}
// ---- staging: fetch the part's files from this server, write them to the PC ----
const exportCache={}, stagedCache={};
async function partExport(slug){
if(exportCache[slug])return exportCache[slug];
toast('Preparing '+slug.toUpperCase()+' (symbol, footprint, 3D, .lbr)…');
const r=await fetch('api/export/'+slug).then(x=>x.json());
if(r.status!=='ok')throw new Error(r.error||'export failed');
exportCache[slug]=r; return r;
}
const CHUNK_B64=Math.floor(1024*1024/3)*4; // ~1 MB raw per request, sliced on base64 boundaries
async function stageFile(dir,name,b64){
const path=dir+name, raw=Math.floor(b64.length*3/4);
if(raw<=1024*1024){
const r=await abVerb('write_file',{path,content:b64,encoding:'base64'});
if(!(r.s===200&&r.j&&r.j.success))throw new Error('write_file '+name+': '+((r.j&&(r.j.error||r.j.errorCode))||r.s));
return r.j.path||path;
}
let offset=0,i=0,total=Math.ceil(b64.length/CHUNK_B64),finalPath=path;
sp.log(' '+name+' is large; sending '+total+' chunks via write_file_chunk');
while(i<total){
const slice=b64.substr(i*CHUNK_B64,CHUNK_B64),last=(i===total-1);
const r=await abVerb('write_file_chunk',{path,dataB64:slice,offset,final:last});
if(!(r.s===200&&r.j&&r.j.success))throw new Error('write_file_chunk '+name+' @'+offset+': '+((r.j&&(r.j.error||r.j.errorCode))||r.s));
finalPath=r.j.path||finalPath; offset=r.j.nextOffset; i++;
sp.step('staging '+name+': chunk '+i+'/'+total);
}
return finalPath;
}
async function stagePart(slug){
if(stagedCache[slug])return stagedCache[slug];
const ex=await partExport(slug);
const dir='~/Documents/adom-lbr/'+ex.mpn+'/';
toast('Staging files onto your PC (the Bridge will ask your permission)…');
const out={mpn:ex.mpn,paths:{}};
const names=Object.keys(ex.files);
let n=0;
for(const [name,f] of Object.entries(ex.files)){
sp.step('staging '+name+' ('+(n+1)+'/'+names.length+')');
const path=await stageFile(dir,name,f.b64);
out.paths[name.split('.').pop()]=path;
sp.log(' staged '+name+' -> '+path); n++;
sp.bar(10+Math.round(30*n/names.length));
}
stagedCache[slug]=out; return out;
}
// ---- The progress panel: bar + streaming console + FINAL-frame evidence ----
// Matches ab's own web-control demo: the user is never running blind. Evidence
// is FINAL frames only; a mid-operation screenshot usually still shows the
// previous part and reads as "you opened the wrong thing" (John, 2026-08-20).
const sp={
open(title){this.reset();document.getElementById('sp-title').textContent=title;document.getElementById('sendpanel').classList.add('show');},
reset(){document.getElementById('sp-console').textContent='';document.getElementById('sp-shots').innerHTML='';this.bar(0);this.step('');},
close(){document.getElementById('sendpanel').classList.remove('show');},
bar(pct){const b=document.getElementById('sp-bar');if(pct==null){b.classList.add('ind');}else{b.classList.remove('ind');b.firstElementChild.style.width=Math.max(0,Math.min(100,pct))+'%';}},
step(t){document.getElementById('sp-step').textContent=t||'';},
log(line){const c=document.getElementById('sp-console');c.textContent+=(c.textContent?'\n':'')+line;c.scrollTop=c.scrollHeight;},
shot(img,label){const src=img.startsWith('data:')?img:'data:image/png;base64,'+img;
const f=document.createElement('figure');const im=document.createElement('img');im.src=src;im.alt=label;
im.onclick=()=>{const z=document.getElementById('sp-zoom');z.querySelector('img').src=src;z.style.display='flex';};
const cap=document.createElement('figcaption');cap.textContent=label;cap.title=label;
f.appendChild(im);f.appendChild(cap);document.getElementById('sp-shots').appendChild(f);},
};
document.getElementById('sp-close').onclick=()=>sp.close();
document.getElementById('sp-zoom').onclick=e=>{e.currentTarget.style.display='none';};
// Recursively find screenshot-shaped objects ({image|base64|png, title?}) in a verb reply.
function findShots(o,out,depth){out=out||[];depth=depth||0;
if(!o||typeof o!=='object'||depth>6)return out;
if(typeof (o.thumbnailDataUrl||o.image||o.base64||o.png)==='string'&&(o.thumbnailDataUrl||o.image||o.base64||o.png).length>200)out.push(o);
else for(const v of Object.values(o))findShots(v,out,depth+1);
return out;}
// Evidence honesty (ab demo doctrine, John 2026-08-20): render ONLY the frames
// the verb returned FOR THIS REQUEST, and verify their titles actually name the
// part. Never screenshot "whatever KiCad is showing" - that is how a picture of
// the WRONG part ends up under a green tick.
function renderEvidence(frames,expectTokens,cmd){
if(!frames.length){sp.log(' (no evidence frames came back from '+cmd+'; check the KiCad window itself)');return true;}
let allVerified=true;
frames.slice(0,4).forEach(x=>{
const title=x.title||x.step||'frame';
const unrendered=x.canvasRendered===false;
const hit=!unrendered&&expectTokens.some(t=>t&&title.toLowerCase().includes(t.toLowerCase()));
if(!hit)allVerified=false;
sp.shot(x.thumbnailDataUrl||x.image||x.base64||x.png,(unrendered?'⚠ canvas not painted: ':hit?'':'⚠ unverified: ')+title);
});
if(allVerified)sp.log(' evidence: '+frames.length+' frame(s), titles name the part');
else sp.log(' WARNING: some evidence titles do not name this part; the editor may be showing something else');
return allVerified;
}
// ---- KiCad: install once per part, then the show verbs ----
const kicadCache={};
async function kicadInstall(slug){
if(kicadCache[slug])return kicadCache[slug];
const st=await stagePart(slug);
sp.bar(50);sp.step('installing the symbol into KiCad…');
sp.log('kicad_install_symbol '+st.paths.kicad_sym);
const rs=await abVerb('kicad_install_symbol',{filePath:st.paths.kicad_sym,quietInstall:true});
if(!(rs.s===200&&rs.j&&rs.j.success))throw new Error('kicad_install_symbol: '+((rs.j&&rs.j.error)||rs.s));
const info={lib:rs.j.libraryName||'Adom',symbol:(rs.j.symbolNames&&rs.j.symbolNames[0])||st.mpn};
sp.log(' installed into the \''+info.lib+'\' library'+(info.symbol!==st.mpn?' as \''+info.symbol+'\'':''));
sp.bar(65);sp.step('installing the footprint (3D model wired by the bridge)…');
const fpArgs={filePath:st.paths.kicad_mod,quietInstall:true};
if(st.paths.step)fpArgs.model={filePath:st.paths.step};
sp.log('kicad_install_footprint '+st.paths.kicad_mod+(st.paths.step?' + model':''));
const rf=await abVerb('kicad_install_footprint',fpArgs);
if(!(rf.s===200&&rf.j&&rf.j.success))throw new Error('kicad_install_footprint: '+((rf.j&&rf.j.error)||rf.s));
info.footprint=rf.j.footprintName||st.mpn;
sp.log(' footprint registered as \''+info.footprint+'\'');
kicadCache[slug]=info; return info;
}
async function sendKicad(kind){
sp.open('Sending '+String(currentPart).toUpperCase()+' to KiCad — '+kind.replace('kicad-',''));
sp.bar(3);sp.step('preparing files…');
sp.log('GET api/export/'+currentPart);
const k=await kicadInstall(currentPart);
// capture:true is the designed web-control evidence contract (adom-bridge#91):
// the verb screenshots the window it opened, polls until the canvas actually
// painted, and returns thumbnailDataUrl frames a page can render directly.
const CAP={capture:true,captureMaxWidth:640};
const shows={
'kicad-symbol': ['kicad_show_symbol', Object.assign({symbolName:k.symbol,libraryName:k.lib},CAP)],
'kicad-footprint':['kicad_show_footprint',Object.assign({footprintName:k.footprint,libraryName:k.lib,library:k.lib},CAP)],
'kicad-3d': ['kicad_show_3d_chip', Object.assign({footprintName:k.footprint,libraryName:k.lib,library:k.lib},CAP),['3D Viewer']],
};
const failed=x=>!(x.s===200&&x.j&&x.j.success!==false&&!x.j.errorCode);
const bare=n=>String(n||'').split(':').pop();
const expect=[bare(k.symbol),k.footprint,k.lib+':'];
async function runShow(cmd,args,pct,extraExpect){
sp.bar(pct);sp.step('opening in KiCad…');
sp.log(cmd+' '+JSON.stringify(args));
let r=await abVerb(cmd,args);
if(failed(r)&&r.j&&(r.j.errorCode==='library_cache_stale'||String(r.j.error||'').includes('indexed its libraries'))){
// Stale editor index: the bridge's own remedy is rescan:true (reopens
// the editor; the cold re-index takes about a minute).
sp.log(' editor library index is stale; reopening with rescan:true (about a minute)');
sp.step('re-indexing KiCad’s libraries (about a minute)…');sp.bar(null);
r=await abVerb(cmd,Object.assign({rescan:true},args));
}
if(failed(r)){
if(r.j&&r.j._hint)sp.log(' hint: '+r.j._hint);
throw new Error(cmd+': '+((r.j&&(r.j.error||r.j.errorCode))||r.s));
}
sp.log(' '+cmd+' ok'+((r.j&&r.j.autoRescanned)?' (editor re-indexed)':''));
return renderEvidence(findShots(r.j),expect.concat(extraExpect||[]),cmd);
}
let verified;
if(kind==='kicad-library'){
// A library IS a symbol + a footprint + a 3D model (John's doctrine), so
// the Library send opens ALL THREE and renders one captured frame of each.
verified =await runShow('kicad_show_symbol', Object.assign({symbolName:k.symbol,libraryName:k.lib},CAP),78);
verified&=await runShow('kicad_show_footprint',Object.assign({footprintName:k.footprint,libraryName:k.lib,library:k.lib},CAP),86);
verified&=await runShow('kicad_show_3d_chip', Object.assign({footprintName:k.footprint,libraryName:k.lib,library:k.lib},CAP),94,['3D Viewer']);
} else {
const [cmd,args,extra]=shows[kind];
verified=await runShow(cmd,args,80,extra);
}
sp.bar(100);sp.step(verified?'done — open in KiCad':'done, but verify the KiCad window yourself');
toast('Open in KiCad: '+kind.replace('kicad-',''),false,verified?'success':undefined);
}
// ---- Fusion: one call builds + proves the library, then poll the verdict ----
async function sendFusion(){
sp.open('Sending '+String(currentPart).toUpperCase()+' to Fusion — library');
sp.bar(3);sp.step('preparing files…');
sp.log('GET api/export/'+currentPart);
const st=await stagePart(currentPart);
if(!st.paths.lbr)throw new Error('no .lbr could be generated for this part');
const args={libraryPath:st.paths.lbr,name:st.mpn};
if(st.paths.step)args.stepPath=st.paths.step;
sp.bar(45);sp.step('Fusion bridge: convert → bind 3D → prove…');
sp.log('fusion_create_library '+st.paths.lbr+(st.paths.step?' + step':''));
const r=await abVerb('fusion_create_library',args);
if(!(r.s===200&&r.j&&(r.j.started||r.j.success)))throw new Error('fusion_create_library: '+((r.j&&(r.j.error||r.j.errorCode))||r.s));
sp.bar(null); // the chain runs in the background with its own pacing
let lastStage='';
for(let i=0;i<80;i++){
await new Promise(res=>setTimeout(res,3000));
const pr=await abVerb('fusion_prove_result',{});
const j=pr.j||{};
if(j.stage&&j.stage!==lastStage){lastStage=j.stage;sp.step('Fusion: '+j.stage);sp.log(' '+j.stage);}
if(j.verdict||j.stage==='done'){
// FINAL fetch with inlineFrames: the four evidence frames (symbol,
// footprint, 3D chip, library) as browser-renderable thumbnails
sp.step('fetching the evidence frames…');
const fin=await abVerb('fusion_prove_result',{inlineFrames:true});
const shots=findShots(fin.j);
shots.slice(0,6).forEach(x=>sp.shot(x.thumbnailDataUrl||x.image||x.base64||x.png,x.title||x.step||'frame'));
if(shots.length)sp.log(' evidence: '+shots.length+' frame(s) rendered below');
const v=j.verdict||(fin.j&&fin.j.verdict);
sp.bar(100);sp.step(v?('verdict: '+v):'chain finished');
toast('Fusion library '+(v==='proven'?'built and PROVEN':('finished: '+(v||'done'))),v!=='proven',v==='proven'?'success':undefined);
return;
}
}
sp.step('still working — check the Fusion window');
toast('Fusion is still working; check the Fusion window',true);
}
// caret menu: the common action is Library; single-piece sends live behind it
(function(){
const caret=document.getElementById('caret-kicad'),menu=document.getElementById('menu-kicad');
caret.onclick=e=>{e.stopPropagation();const open=menu.hidden;menu.hidden=!open;caret.setAttribute('aria-expanded',open?'true':'false');};
document.addEventListener('click',e=>{if(!menu.hidden&&!menu.contains(e.target)&&e.target!==caret){menu.hidden=true;caret.setAttribute('aria-expanded','false');}});
document.addEventListener('keydown',e=>{if(e.key==='Escape'&&!menu.hidden){menu.hidden=true;caret.setAttribute('aria-expanded','false');}});
})();
document.querySelectorAll('#sendbar button[data-send]').forEach(b=>{
b.onclick=async()=>{
const menu=document.getElementById('menu-kicad');if(!menu.hidden){menu.hidden=true;document.getElementById('caret-kicad').setAttribute('aria-expanded','false');}
if(!currentPart){toast('Open a part first (Parts tab, or a board’s component)',true);return;}
b.dataset.busy='1';updateSendbar();
try{ b.dataset.send==='fusion-library'?await sendFusion():await sendKicad(b.dataset.send); }
catch(e){sp.log('FAILED: '+String(e.message||e));sp.bar(0);sp.step('failed — see the console above');toast(String(e.message||e),true);}
b.dataset.busy='';updateSendbar();
};
});
// ===================== The Demo: a guided, self-driving tour =====================
// John, 2026-08-23: "even i don't really know how to use this app" - the Demo
// button walks a newbie through EVERYTHING, driving the real UI as it goes.
const SAMPLE='stm32h563rgtx';
function demoEl(sel){return document.querySelector(sel);}
async function demoOpenSample(){
if(currentPart!==SAMPLE){ try{ await openPart(SAMPLE); }catch(e){} }
}
function demoHoverPin(){
// light a real pin the way a mouse hover does, so the highlight + tooltip show
const hit=document.querySelector('#view .card .body [data-num]');
if(!hit)return null;
document.querySelectorAll('#view .pinhi').forEach(x=>x.classList.remove('pinhi'));
const num=hit.dataset.num;
document.querySelectorAll('#view [data-num="'+CSS.escape(num)+'"]').forEach(x=>x.classList.add('pinhi'));
const tip=document.getElementById('tip');
if(tip){const r=hit.getBoundingClientRect();tip.innerHTML='<b>'+num+'</b> '+(hit.dataset.name||'');tip.style.display='block';tip.style.left=(r.right+10)+'px';tip.style.top=(r.top-6)+'px';}
return hit;
}
function demoUnhover(){document.querySelectorAll('#view .pinhi').forEach(x=>x.classList.remove('pinhi'));const tip=document.getElementById('tip');if(tip)tip.style.display='none';}
// dash-demo contract: each step carries action(pre), caption (ab caption at the
// BRIDGE DEFAULT size - never custom), pre-created adom-tts audio, min_ms floor.
const TOUR=[
{caption:"Welcome to Adom LBR: the tour drives the app for real.",sel:'#hdr .brand',title:'This is adom-lbr',text:'The <b>EDA library converter</b>: symbols, footprints and 3D chips, moved between <b>KiCad, Fusion and Altium</b>. Everything you see comes live from the wiki, which is the library. This tour drives the app for real; you just read along.'},
{caption:"These phrases hand the app to your AI. The word is lbr.",sel:'.anp',title:'You ask your AI',text:'You do not drive this dashboard by hand. You ask your AI using the word <b>lbr</b>. These are real prompts: click one (or Try it) and it is copied for you to paste into any Adom AI thread.',pre:async()=>{if(settings&&settings.show_say_strip===false){save({show_say_strip:true});}}},
{caption:"Projects are your boards; Parts is the whole wiki library.",sel:'#modes',title:'Projects and Parts',text:'<b>Projects</b> lists your boards from the wiki, each opening into its component list. <b>Parts</b> browses every component page on the wiki directly, nothing needs downloading or owning first.',pre:async()=>{setMode('projects');}},
{caption:"Searching the wiki library and opening an industrial MCU.",sel:'#rows',title:'The whole wiki library',text:'Every part page on the wiki, searchable. Type a part number or keyword to narrow it. Watch: the tour opens an industrial MCU, an STM32 H-line, for you.',pre:async()=>{setMode('parts');}},
{caption:"Symbol, footprint and 3D, rendered live from the wiki.",sel:'#view',title:'The part, in full',text:'One click renders everything from the part\'s canonical data: the schematic <b>symbol</b>, the <b>footprint</b>, and the <b>3D model</b>. Drag to pan, scroll to zoom on any of them; double-click resets.',pre:demoOpenSample},
{caption:"Hovering a pin: it lights in the symbol AND the footprint.",sel:'#view .card:nth-of-type(1)',title:'Hover any pin',text:'Pins and pads know who they are. Hover one and it lights up in BOTH the symbol and the footprint, with its number and name. The tour is holding one lit for you now.',pre:async()=>{await demoOpenSample();demoHoverPin();},post:demoUnhover},
{caption:"Layer chips show and hide the footprint layers.",sel:'#view .lfilter',title:'Layer filter',text:'The footprint\'s layers are live chips: copper, courtyard, silkscreen, fab. Untick one and that layer hides, so you can inspect exactly the geometry you care about.'},
{caption:"The STEP model of the part, orbitable in the page.",sel:'#view model-viewer, #view .card:nth-of-type(3)',title:'The 3D chip',text:'The part\'s real STEP model, rendered in the page. Orbit it with a drag; it auto-rotates when idle. The same model is what gets sent to your desktop tools.'},
{caption:"What each element becomes in KiCad and in Altium.",sel:'#view table',title:'The layer stackup',text:'The honest map of what each footprint element becomes <b>per EDA tool</b>: courtyard to F.CrtYd in KiCad and Mechanical 15 in Altium, and so on. This is the translator showing its work.',pre:async()=>{const t=demoEl('#view table');if(t)t.scrollIntoView({behavior:'smooth',block:'center'});}},
{caption:"Send to KiCad or Fusion; a live panel shows the evidence.",sel:'#sendbar',title:'Send it to your tools',text:'First-class buttons: install this part into <b>KiCad</b> (symbol, footprint, 3D wired) and pop it open in the editor, or have the <b>Fusion</b> bridge build and prove the library. Adom Bridge asks your permission first, and a live panel shows the <b>progress bar, streaming console and evidence screenshots</b> as it lands, so you are never running blind.'},
{caption:"Live health pills; every red one carries its fix.",sel:'#leds',title:'Live health',text:'Six LEDs, straight from the dashboard\'s own status: the wiki link, your sign-in, the library cache, the 3D viewer assets, the dock. Hover any LED for the detail, and the fix when one is not green.'},
{caption:"The cog opens Settings; every section explains itself.",sel:'#btnSettings',title:'Settings',text:'The cog holds the rest: the Say it strip switch, whose boards count as My projects, the list cache, and package Update/Uninstall with a live console. Every section explains itself.'},
{caption:"That is the tour. Ask your AI; the word is lbr.",sel:'.anp',title:'That is the tour',text:'From here: browse <b>Parts</b>, open anything, and when you want real work done, copy a phrase and ask your AI, the word is <b>lbr</b>. Enjoy.'}
];
let tourI=-1,tourPlaying=false,tourTimer=null;
let tourMuted=false; try{tourMuted=localStorage.getItem('adom-lbr-demo-muted')==='1';}catch(e){}
const tourAudio=new Audio();
// ab captions, fired per step at the BRIDGE DEFAULT size/position (fleet
// consistency ruling: never pass a custom size). Best-effort: no bridge, no
// consent, or a refusal all mean the in-page card still tells the story.
function tourCaption(text){
if(!abUp)return;
fetch(AB+'/command',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({command:'desktop_caption',args:{text:text||'',caller:{aiThread:'adom-lbr dashboard demo'}}})}).catch(()=>{});
}
function tourPaintMute(){
tourAudio.muted=tourMuted;
const ic=document.getElementById('tour-mute-ic');
ic.innerHTML=tourMuted?'<path fill="currentColor" d="M16.5 12A4.5 4.5 0 0 0 14 7.97v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51A8.8 8.8 0 0 0 21 12a9 9 0 0 0-7-8.77v2.06A7 7 0 0 1 19 12zM4.27 3 3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06a9 9 0 0 0 3.69-1.81L19.73 21 21 19.73 4.27 3zM12 4 9.91 6.09 12 8.18V4z"/>':'<path fill="currentColor" d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0 0 14 7.97v8.05A4.5 4.5 0 0 0 16.5 12z"/>';
}
tourAudio.preload='auto';
tourAudio.onended=()=>{ if(tourPlaying&&tourI>=0){ if(tourI<TOUR.length-1)tourShow(tourI+1); else tourStop(); } };
tourAudio.onerror=()=>{ if(tourPlaying&&tourI>=0){ clearTimeout(tourTimer); tourTimer=setTimeout(()=>{ if(tourPlaying&&tourI<TOUR.length-1)tourShow(tourI+1); else if(tourPlaying)tourStop(); },7000); } };
function tourPaintPlay(){
const ic=document.getElementById('tour-play-ic');
ic.innerHTML=tourPlaying?'<path fill="currentColor" d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>':'<path fill="currentColor" d="M8 5v14l11-7z"/>';
document.getElementById('tour-play').title=tourPlaying?'Pause (space)':'Play (space)';
}
function tourStop(){
tourI=-1;tourPlaying=false;clearTimeout(tourTimer);
try{tourAudio.pause();}catch(e){}
tourCaption(''); // never let a caption outlive the demo
demoUnhover();
document.getElementById('tour-ring').style.display='none';
document.getElementById('tour-card').style.display='none';
document.getElementById('tour-bar').classList.remove('show');
demoBtnRunning(false);
}
async function tourShow(i){
const prev=TOUR[tourI];
if(prev&&prev.post)try{prev.post();}catch(e){}
if(i<0||i>=TOUR.length){tourStop();return;}
tourI=i;const st=TOUR[i];
if(st.pre){try{await st.pre();}catch(e){}}
const el=demoEl(st.sel);
const ring=document.getElementById('tour-ring'),card=document.getElementById('tour-card');
if(el){el.scrollIntoView({behavior:'smooth',block:'nearest'});}
await new Promise(r=>setTimeout(r,380));
const el2=demoEl(st.sel);
if(el2){
const r=el2.getBoundingClientRect();
ring.style.display='block';
ring.style.left=(r.left-6)+'px';ring.style.top=(r.top-6)+'px';
ring.style.width=(r.width+12)+'px';ring.style.height=(r.height+12)+'px';
} else { ring.style.display='none'; }
document.getElementById('tour-n').textContent=(i+1)+' / '+TOUR.length;
document.getElementById('tour-title').textContent=st.title;
document.getElementById('tour-text').innerHTML=st.text;
document.getElementById('tour-progress').textContent=(i+1)+' / '+TOUR.length;
card.style.display='block';
document.getElementById('tour-bar').classList.add('show');
demoBtnRunning(true);
tourPaintPlay();
// pre-rendered narration (adom-tts, shipped with the app - nothing generated live)
clearTimeout(tourTimer);
try{tourAudio.pause();}catch(e){}
tourCaption(st.caption||'');
tourPaintMute();
tourAudio.src='tour/step-'+String(i+1).padStart(2,'0')+'.mp3';
if(tourPlaying){
tourAudio.play().catch(()=>{ // autoplay refused: keep the tour moving on a timer
tourTimer=setTimeout(()=>{ if(tourPlaying&&tourI<TOUR.length-1)tourShow(tourI+1); },7000);
});
}
// place the card near the ring, clamped on screen, never covering it
const rr=el2?el2.getBoundingClientRect():{left:innerWidth/2-200,top:innerHeight/2,bottom:innerHeight/2,width:0};
const cw=Math.min(400,innerWidth*0.9),ch=card.offsetHeight||170;
let x=Math.max(12,Math.min(innerWidth-cw-12,rr.left));
let y=rr.bottom+14;
if(y+ch>innerHeight-12)y=Math.max(12,rr.top-ch-14);
card.style.left=x+'px';card.style.top=y+'px';
}
// tooltips (tooltips skill): 600ms delay, fixed div, clamped to the viewport
(function(){
const tip=document.getElementById('tip2');let t=null;
document.addEventListener('mouseover',e=>{
const el=e.target.closest('[data-tooltip]');if(!el)return;
clearTimeout(t);t=setTimeout(()=>{
tip.textContent=el.dataset.tooltip;tip.style.display='block';
const r=el.getBoundingClientRect(),w=tip.offsetWidth,h=tip.offsetHeight;
tip.style.left=Math.max(8,Math.min(innerWidth-w-8,r.left+r.width/2-w/2))+'px';
tip.style.top=(r.bottom+8+h>innerHeight?r.top-h-8:r.bottom+8)+'px';
},600);
});
document.addEventListener('mouseout',e=>{if(e.target.closest('[data-tooltip]')){clearTimeout(t);tip.style.display='none';}});
document.addEventListener('click',()=>{clearTimeout(t);tip.style.display='none';});
})();
// The demo button belongs to the module now (it is .anp-demo, and its click is wired
// through mount's demo.onPlay). This only paints the RUNNING state, and it must keep the
// same footprint: the button never disappears mid-demo, it just stops being pressable.
function demoBtnRunning(on){
const b=document.querySelector('.anp-demo'); if(!b)return;
const l=b.querySelector('span'); if(!l)return;
b.disabled=on; l.textContent=on?'Demo running':'Play the demo';
}
document.getElementById('tour-next').onclick=()=>{ if(tourI<TOUR.length-1)tourShow(tourI+1); else tourStop(); };
document.getElementById('tour-prev').onclick=()=>{ if(tourI>0)tourShow(tourI-1); };
document.getElementById('tour-play').onclick=()=>{
tourPlaying=!tourPlaying;tourPaintPlay();
if(tourPlaying){ (tourAudio.src&&tourAudio.paused)?tourAudio.play().catch(()=>{}):tourShow(tourI); }
else { try{tourAudio.pause();}catch(e){} clearTimeout(tourTimer); }
};
document.getElementById('tour-mute').onclick=()=>{tourMuted=!tourMuted;try{localStorage.setItem('adom-lbr-demo-muted',tourMuted?'1':'0');}catch(e){}tourPaintMute();};
document.getElementById('tour-exit').onclick=tourStop;
document.addEventListener('keydown',e=>{if(tourI>=0){
if(e.key==='Escape')tourStop();
if(e.key==='ArrowRight'||e.key==='Enter'){e.preventDefault();document.getElementById('tour-next').click();}
if(e.key==='ArrowLeft'){e.preventDefault();document.getElementById('tour-prev').click();}
if(e.key===' '){e.preventDefault();document.getElementById('tour-play').click();}
}});
addEventListener('resize',()=>{if(tourI>=0)tourShow(tourI);});
document.getElementById('btn-update').onclick=()=>runJob('update');
(function(){const b=document.getElementById('btn-uninstall');let armed=false,t=null;
b.onclick=()=>{ if(!armed){armed=true;b.textContent='Click again to uninstall';t=setTimeout(()=>{armed=false;b.textContent='Uninstall';},4000);return;}
clearTimeout(t);armed=false;b.textContent='Uninstall';runJob('uninstall'); };})();
</script>
</body>
</html>