app
Adom Footprint
Public Made by Adomby adom
KiCad footprint creator with layer HUD viewer and delivery to KiCad/Fusion 360
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>adom-footprint</title>
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<style>
:root {
--bg:#0d1117; --panel:#11161f; --panel2:#0f141c; --border:#222b38; --border2:#2b3645;
--text:#e6edf3; --muted:#8b949e; --text3:#5b6675; --accent:#00b8b1; --accent2:#00e6dc;
}
* { box-sizing:border-box; }
html,body { margin:0; height:100%; }
body { background:var(--bg); color:var(--text); font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; display:flex; flex-direction:column; }
header.app {
height:44px; flex:0 0 44px; display:grid; grid-template-columns:auto 1fr auto;
align-items:center; gap:14px; padding:0 14px; border-bottom:1px solid var(--border);
background:linear-gradient(180deg,#11161f,#0d1117);
}
header.app .ident { display:flex; align-items:center; gap:8px; min-width:0; }
header.app .ident svg { width:16px; height:16px; color:var(--accent2); flex:0 0 16px; }
header.app .app-name { font-weight:700; font-size:13px; letter-spacing:.2px; white-space:nowrap; }
header.app .app-sub { color:var(--muted); font-size:12px; white-space:nowrap; }
header.app .app-sub::before { content:"·"; margin:0 6px; color:var(--text3); }
header.app .subject { justify-self:center; display:flex; align-items:baseline; gap:10px; min-width:0; }
header.app .subject-name { font-weight:600; font-size:13px; max-width:42vw; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
header.app .subject-meta { font-family:'JetBrains Mono',ui-monospace,monospace; font-size:11px; color:var(--muted); white-space:nowrap; }
header.app .actions { display:flex; align-items:center; gap:8px; }
header.app .conn { display:inline-flex; align-items:center; gap:6px; font-size:11px; color:var(--muted); }
header.app .conn .dot { width:8px; height:8px; border-radius:50%; background:var(--text3); transition:background .2s, box-shadow .2s; }
header.app .conn.live .dot { background:var(--accent2); box-shadow:0 0 8px var(--accent2); }
header.app .sendto { display:inline-flex; align-items:center; gap:4px; }
header.app .sendto .sendlabel { font-size:11px; color:var(--muted); margin-right:2px; }
header.app .sendto .sbtn { font:inherit; font-size:11px; color:var(--text2,#cfd6e4); background:var(--surface2,#1c2230); border:1px solid var(--border,#2a3140); border-radius:6px; padding:3px 8px; cursor:pointer; transition:background .15s,border-color .15s,color .15s; }
header.app .sendto .sbtn:hover { background:var(--accent2,#37c8a6); border-color:var(--accent2,#37c8a6); color:#0b0f17; }
header.app .sendto .sbtn:disabled { opacity:.5; cursor:progress; }
#toast { position:fixed; left:50%; bottom:18px; transform:translateX(-50%) translateY(20px); background:#1c2230; color:#e8ecf4; border:1px solid #2a3140; border-radius:10px; padding:10px 16px; font-size:13px; max-width:84vw; box-shadow:0 8px 28px rgba(0,0,0,.45); opacity:0; pointer-events:none; transition:opacity .25s, transform .25s; z-index:9999; }
#toast.show { opacity:1; transform:translateX(-50%) translateY(0); }
#toast.ok { border-color:#37c8a6; } #toast.err { border-color:#e0698a; }
#bridgeModal{position:fixed;inset:0;z-index:100001;display:none;align-items:center;justify-content:center;background:rgba(2,5,10,.62);backdrop-filter:blur(4px);padding:20px}
#bridgeModal.show{display:flex}
#bridgeModal .bm-card{max-width:min(94vw,440px);background:rgba(17,22,31,.99);border:1px solid var(--border2,#2b3645);border-radius:16px;padding:22px 22px 18px;box-shadow:0 30px 90px rgba(0,0,0,.7);text-align:center}
#bridgeModal .bm-icon{font-size:34px;margin-bottom:6px}
#bridgeModal .bm-title{font-size:16px;font-weight:700;margin-bottom:8px;color:var(--text,#e6edf3)}
#bridgeModal .bm-body{font-size:13px;line-height:1.55;color:var(--muted,#8b949e);margin-bottom:16px}
#bridgeModal .bm-actions{display:flex;flex-direction:column;gap:9px}
#bridgeModal .bm-go{display:block;text-decoration:none;background:linear-gradient(180deg,#00b8b1,#009a94);color:#04201f;font-weight:700;font-size:13px;padding:10px;border-radius:10px}
#bridgeModal .bm-dismiss{background:transparent;border:1px solid var(--border2,#2b3645);color:var(--muted,#8b949e);font-size:12.5px;padding:8px;border-radius:10px;cursor:pointer}
#bridgeModal .bm-dismiss:hover{color:var(--text,#e6edf3)}
/* ── Floaty Layers HUD (per adom-ui-design / floating-windows) ── */
:root { --z-hud:50; }
.hud { position:fixed; z-index:var(--z-hud); display:flex; flex-direction:column; width:236px; max-height:calc(100% - 80px);
background:#161b22; border:1px solid #30363d; border-radius:12px; box-shadow:0 18px 50px rgba(0,0,0,.5); user-select:none; }
.hud-head { cursor:grab; display:flex; align-items:center; gap:8px; padding:9px 11px; border-bottom:1px solid #232c39; }
.hud-head:active { cursor:grabbing; }
.hud-head .grip { color:var(--text3); font-size:13px; letter-spacing:1px; }
.hud-head .htitle { font-weight:700; font-size:12px; flex:1; }
.hud-head .hbtn { width:18px; height:18px; display:grid; place-items:center; border:none; background:transparent; color:var(--muted); cursor:pointer; border-radius:4px; font-size:13px; line-height:1; }
.hud-head .hbtn:hover { background:#222c3a; color:var(--text); }
.hud-body { flex:1 1 auto; min-height:0; overflow:auto; padding:7px; }
.hud.collapsed .hud-body, .hud.collapsed .hud-foot { display:none; }
.layer-row { display:flex; align-items:center; gap:8px; padding:6px 7px; border-radius:7px; cursor:pointer; }
.layer-row:hover { background:#1c2230; }
.layer-row .sw { width:13px; height:13px; border-radius:3px; flex:0 0 13px; border:1px solid rgba(255,255,255,.15); }
.layer-row .sw.ring { background:transparent; }
.layer-row .lname { flex:1; font-size:12px; color:var(--text); }
.layer-row .lmeta { font-size:10px; color:var(--text3); }
.layer-row .chk { width:15px; height:15px; flex:0 0 15px; border-radius:4px; border:1.5px solid #3a4757; display:grid; place-items:center; color:#0b0f17; font-size:11px; }
.layer-row.on .chk { background:var(--accent2); border-color:var(--accent2); }
.layer-row.off .lname, .layer-row.off .sw { opacity:.4; }
.hud-foot { padding:7px 11px; border-top:1px solid #232c39; font-size:10px; color:var(--text3); line-height:1.4; }
.hud-foot b { color:#7ef0d2; font-weight:600; }
.layers-reopen { background:var(--surface2,#1c2230); border:1px solid var(--border2); color:var(--text); font-size:11px; border-radius:7px; padding:4px 9px; cursor:pointer; display:inline-flex; align-items:center; gap:5px; }
.layers-reopen:hover { border-color:var(--accent2); color:var(--accent2); }
/* Full-bleed: the board canvas owns the whole layout area — no centered
"card" wrapper (that was a holdover from the small thumbnail-viewer page). */
main { flex:1; display:flex; min-height:0; padding:0; }
.card { background:transparent; border:0; border-radius:0; padding:0; box-shadow:none; max-width:none; width:100%; flex:1; display:flex; flex-direction:column; min-height:0; }
#content { flex:1; display:flex; flex-direction:column; min-height:0; }
.card .name, .card .meta, .card .desc { display:none; } /* name + pad count already live in the top header */
/* Full-bleed board canvas: no border, no rounding, edge-to-edge. */
.svgbox { border:0; border-radius:0; background:radial-gradient(circle at 50% 42%,#121a26 0%,#0d1117 72%); overflow:hidden; position:relative; flex:1; min-height:0; cursor:grab; }
/* ── left chip-fetcher library rail (in-flow: pushes the canvas, no overlay) ── */
.bodyrow { display:flex; flex:1; min-height:0; min-width:0; }
main { min-width:0; }
#chiprail { display:flex; align-items:stretch; flex:0 0 auto; border-right:1px solid var(--border); background:var(--panel); }
#chiprail.collapsed { align-items:center; }
#chiprailToggle { display:none; align-items:center; gap:7px; background:var(--panel2); color:var(--muted); border:0; border-right:1px solid var(--border); padding:11px 8px; cursor:pointer; font-size:11.5px; font-weight:700; writing-mode:vertical-rl; letter-spacing:.6px; height:100%; }
#chiprailToggle:hover { color:#fff; background:#1a2230; }
#chiprailToggle .cr-arrow { writing-mode:horizontal-tb; }
#chiprail.collapsed #chiprailToggle { display:flex; }
#chiprail.collapsed #chiplistWrap { display:none; }
#chiplistWrap { width:232px; background:var(--panel); display:flex; flex-direction:column; }
.cr-head { display:flex; align-items:center; justify-content:space-between; padding:9px 12px; border-bottom:1px solid var(--border); font-size:11px; font-weight:700; color:var(--muted); letter-spacing:.4px; text-transform:uppercase; }
#chiprailClose { background:transparent; border:0; color:var(--muted); font-size:16px; cursor:pointer; padding:0 4px; line-height:1; }
#chiprailClose:hover { color:#fff; }
.cr-search, .cr-project { margin:7px 10px 2px; padding:5px 8px; background:#0a0e14; color:var(--text); border:1px solid var(--border); border-radius:7px; font-size:11.5px; }
.cr-search:focus, .cr-project:focus { outline:none; border-color:var(--accent2); }
.cr-project { margin-top:3px; cursor:pointer; }
#chiplist { flex:1; overflow:auto; outline:none; }
.cr-item { padding:7px 12px; cursor:pointer; font-size:12.5px; color:#aeb6c2; border-left:2px solid transparent; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.cr-item:hover { background:rgba(0,184,177,.10); color:#fff; }
.cr-item.cur { background:linear-gradient(90deg,rgba(0,184,177,.20),transparent); border-left-color:var(--accent2); color:#fff; font-weight:600; }
.cr-hint { padding:7px 12px; border-top:1px solid var(--border); font-size:10px; color:var(--text3); }
/* ── docked right details panel (Layers · Text · Chip) — reserves real space ── */
#rightpanel { order:3; flex:0 0 auto; width:300px; align-self:stretch; display:flex; border-left:1px solid var(--border); background:var(--panel); }
#rightpanel.collapsed { width:auto; }
#rpToggle { display:none; align-items:center; gap:7px; background:var(--panel2); color:var(--muted); border:0; border-left:1px solid var(--border); padding:11px 8px; cursor:pointer; font-size:11.5px; font-weight:700; writing-mode:vertical-rl; letter-spacing:.6px; }
#rpToggle:hover { color:#fff; background:#1a2230; }
#rightpanel.collapsed #rpToggle { display:flex; }
#rightpanel.collapsed #rpInner { display:none; }
#rpInner { flex:1; display:flex; flex-direction:column; overflow-y:auto; min-width:0; }
#rpInner::-webkit-scrollbar { width:9px; } #rpInner::-webkit-scrollbar-thumb { background:rgba(255,255,255,.13); border-radius:7px; border:2px solid transparent; background-clip:padding-box; }
#rpHead { display:flex; align-items:center; justify-content:space-between; padding:9px 12px; border-bottom:1px solid var(--border); font-size:11px; font-weight:700; color:var(--muted); text-transform:uppercase; letter-spacing:.5px; flex:0 0 auto; }
#rpCollapse { background:transparent; border:0; color:var(--muted); font-size:15px; cursor:pointer; line-height:1; padding:0 4px; } #rpCollapse:hover { color:#fff; }
/* Dock the (formerly floaty) Layers + Text HUDs as in-flow sections. */
#rightpanel .hud { position:static; width:auto; max-height:none; box-shadow:none; border:0; border-radius:0; border-bottom:1px solid var(--border); z-index:auto; backdrop-filter:none; background:transparent; flex:0 0 auto; }
#rightpanel .hud-head { cursor:pointer; }
#rightpanel .hud-head .grip { display:none; }
#rightpanel .hud.collapsed .hud-body, #rightpanel .hud.collapsed .hud-foot { display:none; }
/* Chip details section */
#chippanel { padding:10px 12px 18px; }
.rp-sec-head { font-size:11px; font-weight:700; color:var(--muted); text-transform:uppercase; letter-spacing:.4px; margin:0 0 9px; }
#chip3dwrap { position:relative; background:radial-gradient(circle at 50% 38%,#16202c,#0d1117); border:1px solid var(--border); border-radius:10px; min-height:150px; display:grid; place-items:center; overflow:hidden; }
#chip3d { max-width:100%; max-height:230px; display:none; }
#chip3dempty { color:var(--text3); font-size:11px; padding:30px; text-align:center; }
#chipinfo { margin-top:11px; }
#chipinfo .ci-mpn { font-size:14px; font-weight:700; color:var(--text); word-break:break-all; }
#chipinfo .ci-mfr { color:var(--accent2); font-size:12px; margin-top:1px; }
#chipinfo .ci-desc { color:#aeb6c2; font-size:11.5px; line-height:1.45; margin:8px 0 6px; }
#chipinfo .ci-row { display:flex; justify-content:space-between; gap:10px; padding:4px 0; border-top:1px solid #1a2230; font-size:12px; }
#chipinfo .ci-k { color:var(--text3); flex:0 0 auto; } #chipinfo .ci-v { color:var(--text); text-align:right; word-break:break-word; }
#chipinfo a.ci-ds { display:inline-block; margin-top:9px; color:var(--accent2); text-decoration:none; font-size:11.5px; border:1px solid rgba(0,184,177,.4); border-radius:7px; padding:4px 9px; }
#chipinfo a.ci-ds:hover { background:rgba(0,184,177,.12); }
.svgbox.grab { cursor:grabbing; }
/* DIRECT child only — must not match the zoom-toolbar button icon <svg>s,
which live inside .zoomtools inside .svgbox (else they'd all absolutely
stack at 0,0 and only one would be visible). */
.svgbox > svg { position:absolute; left:0; top:0; width:100%; height:100%; transform-origin:0 0; display:block; }
.zoomtools { position:absolute; left:10px; top:10px; display:flex; gap:6px; z-index:5; }
.zoomtools button { width:30px; height:30px; border-radius:8px; border:1px solid var(--border); background:var(--surface2,#1c2230); color:#cfd8e2; font-size:16px; cursor:pointer; display:grid; place-items:center; }
.zoomtools button:hover { border-color:var(--accent2); color:var(--accent2); }
.zoomtools button svg { width:15px; height:15px; display:block; }
.zoomtools button:focus, .zoomtools button:focus-visible { outline:none; }
.panhint { position:absolute; right:10px; bottom:9px; color:var(--text3); font-size:11px; z-index:5; pointer-events:none; }
#svgbox [data-pad] { cursor:pointer; }
#svgbox [data-pad]:hover > * { stroke:#fff !important; stroke-width:0.12 !important; }
.empty { color:var(--text3); font-size:13px; padding:40px; }
footer { flex:0 0 auto; text-align:center; color:var(--text3); font-size:11px; padding:8px; border-top:1px solid var(--border); }
#tt { position:fixed; z-index:99999; max-width:340px; background:rgba(22,27,34,0.97); backdrop-filter:blur(16px);
border:1px solid rgba(0,184,177,0.25); border-radius:10px; padding:10px 14px; color:#e6edf3;
font-family:-apple-system,sans-serif; font-size:12px; font-weight:400; letter-spacing:0; text-transform:none;
line-height:1.55; white-space:pre-wrap; pointer-events:none; opacity:0; transition:opacity .18s ease;
box-shadow:0 12px 40px rgba(0,0,0,0.55); }
#tt.show { opacity:1; }
[data-tooltip] { cursor:help; }
/* ── >NAME / >VALUE editable text labels (overlaid in footprint space) ── */
.fp-textlabel { cursor:grab; }
.fp-textlabel.dragging { cursor:grabbing; }
.fp-textlabel text { paint-order:stroke; stroke:#0d1117; stroke-width:0.22; font-family:monospace; font-weight:700; user-select:none; }
.fp-textlabel.ref text { fill:#36e08a; }
.fp-textlabel.val text { fill:#e6b84a; }
.fp-textlabel rect { fill:transparent; }
.fp-textlabel.sel rect { fill:rgba(0,184,177,0.08); stroke:var(--accent2); stroke-width:0.05; }
/* Text HUD controls */
#textBody .tbtn { width:100%; box-sizing:border-box; border:1px solid var(--border); background:var(--surface2,#1c2230); color:#cfd8e2; border-radius:8px; padding:7px; font-size:12.5px; cursor:pointer; }
#textBody .tbtn:hover { border-color:var(--accent2); color:var(--accent2); }
#textBody .tbtn-primary { background:linear-gradient(180deg,#10485f,#0e3346); color:#aef3ec; border-color:#1d6f7a; font-weight:700; margin-bottom:10px; }
#textBody .trow { display:flex; align-items:center; gap:6px; margin:7px 0; font-size:12px; }
#textBody .tlab { width:64px; display:flex; align-items:center; gap:5px; color:#cfd8e2; font-family:monospace; }
#textBody .tdot { width:9px; height:9px; border-radius:2px; flex:0 0 9px; }
#textBody .tdot.ref { background:#36e08a; } #textBody .tdot.val { background:#e6b84a; }
#textBody label { display:flex; align-items:center; gap:3px; color:var(--text3); font-size:10px; }
#textBody input { width:46px; background:#0d1117; border:1px solid var(--border); border-radius:6px; color:#e6edf3; padding:3px 4px; font-size:11px; }
#textBody input:focus { outline:none; border-color:var(--accent2); }
#textBody .tnudge { display:flex; gap:2px; }
#textBody .tnudge button { width:20px; height:22px; border:1px solid var(--border); background:var(--surface2,#1c2230); color:#cfd8e2; border-radius:5px; cursor:pointer; font-size:11px; line-height:1; }
#textBody .tnudge button:hover { border-color:var(--accent2); color:var(--accent2); }
#textBody .tstatus { margin-top:9px; font-size:11.5px; border-radius:6px; padding:6px 8px; }
#textBody .tstatus.ok { background:rgba(54,224,138,0.10); color:#7ef0b6; border:1px solid rgba(54,224,138,0.3); }
#textBody .tstatus.warn { background:rgba(230,160,74,0.12); color:#f0c477; border:1px solid rgba(230,160,74,0.35); }
#textBody .ttoolrow { margin:0 0 10px; padding-bottom:9px; border-bottom:1px solid var(--border); }
#textBody select#toolSel { flex:1; background:#0d1117; border:1px solid var(--border); border-radius:6px; color:#e6edf3; padding:4px 6px; font-size:12px; cursor:pointer; }
#textBody select#toolSel:focus { outline:none; border-color:var(--accent2); }
#textBody .tterm { font-family:monospace; }
#textBody .thistrow { display:flex; gap:6px; margin:8px 0 2px; }
#textBody .tmini { flex:1; padding:5px; font-size:11.5px; }
#textBody .tbtn:disabled { opacity:0.4; cursor:default; }
#textBody .tcheck { display:flex; align-items:center; gap:7px; margin:9px 0 4px; font-size:12px; color:#cfd8e2; cursor:pointer; user-select:none; }
#textBody .tcheck input { accent-color:var(--accent2); width:14px; height:14px; cursor:pointer; }
#textBody .tcheck-note { color:var(--text3); font-size:11px; }
#textBody .savebtn { width:100%; box-sizing:border-box; margin-top:10px; border-radius:8px; padding:7px; font-size:12.5px; font-weight:700; }
#textBody .savebtn.saved { background:transparent; border:1px solid var(--border); color:var(--text3); cursor:default; }
#textBody .savebtn.unsaved { background:linear-gradient(180deg,#1c7a4a,#155f39); border:1px solid #2a9c63; color:#dffff0; cursor:pointer; box-shadow:0 2px 10px rgba(40,180,110,.25); }
#textBody .savebtn.saving { background:var(--surface2,#1c2230); border:1px solid var(--border); color:#cfd8e2; cursor:default; }
/* Shared viewer core (layer rows, zoom tools, viewport) — same as the embed. */
/*__AV_CSS__*/
</style>
</head>
<body>
<header class="app">
<div class="ident" data-tooltip="adom-footprint — a standalone Rust tool that creates KiCad PCB footprints (.kicad_mod) from structured pad data and renders them via the shared service-kicad container. No local KiCad install needed.">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><rect x="6.5" y="9" width="3" height="6" rx="0.6"/><rect x="14.5" y="9" width="3" height="6" rx="0.6"/><path d="M6 6.2h1.2"/></svg>
<span class="app-name">adom-footprint</span>
<span class="app-sub">PCB footprint viewer</span>
</div>
<div class="subject" id="subject">
<span class="subject-name" id="subjectName">—</span>
<span class="subject-meta" id="subjectMeta"></span>
</div>
<div class="actions">
<span class="sendto" id="sendto" style="display:none" data-tooltip="Send this footprint to a desktop EDA tool via Adom Desktop. KiCad receives the native .kicad_mod; Fusion 360 / Altium / OrCAD receive a true EAGLE .lbr (a symbol is synthesized from the pads so it's placeable).">
<span class="sendlabel">Send to</span>
<button class="sbtn" data-target="kicad">KiCad</button>
<button class="sbtn" data-target="fusion360">Fusion</button>
<button class="sbtn" data-target="altium">Altium</button>
<button class="sbtn" data-target="orcad">OrCAD</button>
</span>
<button class="layers-reopen" id="layersBtn" style="display:none" data-tooltip="Show the Layers panel — toggle copper, silkscreen, fab, courtyard, heatsink vias, paste apertures, and the 300µm solder-paste jet dots (off by default).">▦ Layers</button>
<button class="layers-reopen" id="textBtn" style="display:none" data-tooltip="Edit the >NAME (RefDes) and >VALUE text placement — drag the labels on the board, nudge with arrows/inputs, or hit Auto-layout to snap them clear of the pads. Auto-saves into the .kicad_mod so every tool (KiCad/Fusion/Altium/OrCAD) shows it.">⌖ Text</button>
<button class="layers-reopen" id="embedBtn" style="display:none" data-tooltip="Open the standalone interactive embed viewer for this footprint in a new tab — the lean, self-contained widget (canvas + Layers) you can drop into a wiki page. Same shared viewer core as this app.">⧉ Embed</button>
<span class="conn" id="conn" data-tooltip="Live connection to the adom-footprint Rust server. Green = the UI is polling /state and the AI can drive this view in real time (push a new footprint, run /eval, read the console).">
<span class="dot"></span><span id="connText">connecting…</span>
</span>
</div>
</header>
<div class="hud" id="layersHud" style="display:none; right:22px; top:64px">
<div class="hud-head" id="layersHead">
<span class="grip">⠿</span>
<span class="htitle">Layers</span>
<button class="hbtn" id="layersCollapse" data-tooltip="Collapse / expand">▾</button>
<button class="hbtn" id="layersClose" data-tooltip="Hide the Layers panel (reopen from the ▦ Layers button).">✕</button>
</div>
<div class="hud-body" id="layersBody"></div>
<div class="hud-foot">
Solder-paste dispensing dots — <b>300µm</b> each — computed for every footprint and carried <i>with</i> it across the Adom ecosystem. Off by default.
</div>
</div>
<div class="hud" id="textHud" style="display:none; right:22px; top:64px; width:258px">
<div class="hud-head" id="textHead">
<span class="grip">⠿</span>
<span class="htitle">Text placement</span>
<button class="hbtn" id="textClose" data-tooltip="Hide (reopen from the ⌖ Text button).">✕</button>
</div>
<div class="hud-body" id="textBody">
<div class="trow ttoolrow">
<span class="tlab">Tool</span>
<select id="toolSel" data-tooltip="Show the labels using your EDA tool's own wording — the placement geometry is identical, only the names differ. KiCad: Reference/Value · Fusion 360 / EAGLE: >NAME/>VALUE · Altium: Designator/Comment · OrCAD: RefDes/Value.">
<option value="kicad">KiCad</option>
<option value="fusion">Fusion 360 / EAGLE</option>
<option value="altium">Altium</option>
<option value="orcad">OrCAD</option>
</select>
</div>
<button id="autoLayoutBtn" class="tbtn tbtn-primary" data-tooltip="Snap the RefDes above the top edge and the Value below the bottom edge, ~1 mm clear of the whole footprint (pads ∪ courtyard ∪ body).">✦ Auto-layout</button>
<label class="tcheck" data-tooltip="While ON, opening/navigating to a footprint auto-runs Auto-layout AND saves it to the library file — so you can sweep the library fixing placement. Uncheck when you're happy. Off by default.">
<input type="checkbox" id="autoOnOpen"> Auto-layout on open <span class="tcheck-note">· auto-saves</span>
</label>
<div class="thistrow">
<button id="undoBtn" class="tbtn tmini" disabled data-tooltip="Undo the last placement change — Ctrl/Cmd+Z.">↶ Undo</button>
<button id="redoBtn" class="tbtn tmini" disabled data-tooltip="Redo — Ctrl/Cmd+Shift+Z.">↷ Redo</button>
</div>
<div class="trow" data-which="reference">
<span class="tlab"><span class="tdot ref"></span><span class="tterm"></span></span>
<label>X<input type="number" step="0.1" class="tx"></label>
<label>Y<input type="number" step="0.1" class="ty"></label>
<span class="tnudge"><button data-d="up">↑</button><button data-d="down">↓</button></span>
</div>
<div class="trow" data-which="value">
<span class="tlab"><span class="tdot val"></span><span class="tterm"></span></span>
<label>X<input type="number" step="0.1" class="tx"></label>
<label>Y<input type="number" step="0.1" class="ty"></label>
<span class="tnudge"><button data-d="up">↑</button><button data-d="down">↓</button></span>
</div>
<div id="textStatus" class="tstatus"></div>
<button id="saveBtn" class="tbtn savebtn saved" data-tooltip="Save this footprint's current text placement back to its library .kicad_mod (the original is backed up once to <file>.orig). adom-lbr then exports the saved placement to Fusion/Altium/OrCAD.">✓ Saved to library</button>
</div>
<div class="hud-foot">
Drag a label on the board, or edit X/Y. Saved into the <code>.kicad_mod</code> — adom-lbr carries it to Fusion/Altium/OrCAD.
</div>
</div>
<div class="bodyrow">
<!-- Left chip-fetcher library rail. Collapsed by default; expand to browse /
filter / arrow-nav the whole library. The AI can deep-link via ?mpn=. -->
<div id="chiprail" class="collapsed" style="display:none">
<button id="chiprailToggle" title="Chip library — expand to browse the chip-fetcher library (↑/↓)"><span class="cr-arrow">▸</span><span>Library</span></button>
<div id="chiplistWrap">
<div class="cr-head"><span id="cr-count">Library</span><button id="chiprailClose" title="Collapse">‹</button></div>
<input id="cr-search" class="cr-search" placeholder="Filter chips…" autocomplete="off" spellcheck="false">
<select id="cr-project" class="cr-project" autocomplete="off" title="Filter by chip-fetcher project"><option value="">All chips</option></select>
<div id="chiplist" tabindex="0"></div>
<div class="cr-hint">↑ / ↓ to switch · click to load · ?mpn= to deep-link</div>
</div>
</div>
<main>
<div class="card" id="card">
<div class="empty" id="empty">Waiting for a footprint… run <code>adom-footprint create</code>, open the Library, or the AI will push one here.</div>
<div id="content" style="display:none">
<div class="name" id="cName"></div>
<div class="meta" id="cMeta"></div>
<div class="desc" id="cDesc"></div>
<div class="svgbox" id="svgbox"></div>
</div>
</div>
</main>
<!-- Docked right details panel. Layers + Text HUDs are moved in here by JS;
the Chip section (3D preview + metadata) tells you WHAT part this is. -->
<div id="rightpanel">
<button id="rpToggle" title="Show the details panel"><span>›</span><span>Details</span></button>
<div id="rpInner">
<div id="rpHead"><span>Details</span><button id="rpCollapse" title="Collapse the panel">›</button></div>
<div id="chippanel">
<div class="rp-sec-head">Chip</div>
<div id="chip3dwrap"><img id="chip3d" alt="3D chip preview"><div id="chip3dempty">No 3D preview for this part</div></div>
<div id="chipinfo"></div>
</div>
</div>
</div>
</div>
<footer>rendered locally in Rust — no KiCad install, no service round-trip</footer>
<div id="tt"></div>
<div id="toast"></div>
<!-- Shared viewer core (window.AV): layer list, toggle logic, pan/zoom, icons —
the SAME core the static embed uses, so the two viewers cannot drift. -->
<script>/*__AV_JS__*/</script>
<script>
(function(){
const post = (level,args)=>{ try{ navigator.sendBeacon('console', JSON.stringify({level,msg:args.map(a=>{try{return typeof a==='string'?a:JSON.stringify(a)}catch{return String(a)}}).join(' ')})); }catch(e){} };
['log','warn','error','info'].forEach(l=>{ const o=console[l].bind(console); console[l]=(...a)=>{ post(l,a); o(...a); }; });
window.addEventListener('error', e=> post('error',[e.message+' @'+(e.filename||'')+':'+e.lineno]));
})();
(function(){
const tt=document.getElementById('tt'); let timer=null, cur=null;
function place(el){ const r=el.getBoundingClientRect(); tt.style.left='0px'; tt.style.top='0px'; tt.classList.add('show');
const tr=tt.getBoundingClientRect(); let x=r.left+r.width/2-tr.width/2; let y=r.top-tr.height-8;
x=Math.max(8,Math.min(x,innerWidth-tr.width-8)); if(y<8) y=r.bottom+8;
tt.style.left=x+'px'; tt.style.top=y+'px'; }
document.addEventListener('mouseover',e=>{ const el=e.target.closest('[data-tooltip]'); if(!el||el===cur) return; cur=el;
clearTimeout(timer); timer=setTimeout(()=>{ tt.textContent=el.getAttribute('data-tooltip'); place(el); },500); });
document.addEventListener('mouseout',e=>{ if(e.target.closest('[data-tooltip]')===cur){ clearTimeout(timer); tt.classList.remove('show'); cur=null; } });
// Pad hover — number / shape / size / mount, like the original footprint app.
document.addEventListener('mouseover',e=>{ const g=e.target.closest('[data-pad]'); if(!g) return;
tt.innerHTML='<div style="font-weight:700;font-size:14px;color:#37c8a6">Pad '+g.getAttribute('data-pad')+'</div>'
+'<div style="font-size:11.5px;color:#aeb6c2;margin-top:3px">'+(g.getAttribute('data-shape')||'')+' · '+(g.getAttribute('data-size')||'')+' mm · '+(g.getAttribute('data-mount')||'')+'</div>';
place(g); });
document.addEventListener('mouseout',e=>{ if(e.target.closest('[data-pad]')) tt.classList.remove('show'); });
})();
let lastHash='';
async function pollState(){
try{
const r=await fetch('state',{cache:'no-store'}); const s=await r.json();
setConn(true);
window._lastState=s;
const hash=JSON.stringify([s.footprintName,(s.svg||'').length]);
if(hash!==lastHash){ lastHash=hash; render(s); }
}catch(e){ setConn(false); }
}
function setConn(live){ const c=document.getElementById('conn'); c.classList.toggle('live',live); document.getElementById('connText').textContent=live?'live':'offline'; }
function render(s){
const has=!!s.footprintName;
document.getElementById('empty').style.display=has?'none':'';
document.getElementById('content').style.display=has?'':'none';
document.getElementById('subjectName').textContent=has?s.footprintName:'—';
document.getElementById('subjectMeta').textContent=has&&s.padCount?(s.padCount+' pads'):'';
if(!has) return;
document.getElementById('cName').textContent=s.footprintName;
document.getElementById('cMeta').textContent=[s.padCount?s.padCount+' pads':'', s.tags].filter(Boolean).join(' · ');
document.getElementById('cDesc').textContent=s.description||'';
document.getElementById('svgbox').innerHTML=s.svg||'';
document.getElementById('sendto').style.display=(has&&s.hasMod)?'inline-flex':'none';
setupLayers(s);
initPanZoom();
setupTextEditor(s);
renderChipPanel(s);
}
setInterval(pollState,1000); pollState();
// ── Pan / zoom the footprint (drag to pan, scroll to zoom, +/-/fit) ──
let _fpScale=1,_fpTx=0,_fpTy=0,_panZoomWired=false;
function _fpApply(){ const sv=document.querySelector('#svgbox svg'); if(sv) sv.style.transform='translate('+_fpTx+'px,'+_fpTy+'px) scale('+_fpScale+')'; }
function _fpZoom(cx,cy,f){ _fpTx=cx-(cx-_fpTx)*f; _fpTy=cy-(cy-_fpTy)*f; _fpScale*=f; _fpApply(); }
function _fpFit(){ _fpScale=1;_fpTx=0;_fpTy=0;_fpApply(); }
function initPanZoom(){
const box=document.getElementById('svgbox'); if(!box) return;
const sv=box.querySelector('svg'); if(sv){ sv.removeAttribute('width'); sv.removeAttribute('height'); }
_fpFit();
if(box.querySelector('.zoomtools')===null){
const t=document.createElement('div'); t.className='zoomtools';
// Icons from the shared core — incl. a proper zoom-to-FIT glyph (magnifier
// + inner square), NOT the four-corner "fullscreen/expand" icon.
t.innerHTML='<button id="fpZin" title="Zoom in">'+AV.ICON_ZIN+'</button><button id="fpZout" title="Zoom out">'+AV.ICON_ZOUT+'</button><button id="fpFit" title="Zoom to fit">'+AV.ICON_FIT+'</button>';
box.appendChild(t);
const h=document.createElement('div'); h.className='panhint'; h.textContent='drag to pan · scroll to zoom'; box.appendChild(h);
t.querySelector('#fpZin').onclick=()=>{const r=box.getBoundingClientRect();_fpZoom(r.width/2,r.height/2,1.2)};
t.querySelector('#fpZout').onclick=()=>{const r=box.getBoundingClientRect();_fpZoom(r.width/2,r.height/2,1/1.2)};
t.querySelector('#fpFit').onclick=_fpFit;
}
if(_panZoomWired) return; _panZoomWired=true;
// Smooth, delta-PROPORTIONAL zoom (matches adom-symbol) — exp of the actual
// scroll amount, device-unit normalised + per-event clamped, so the trackpad
// no longer leaps. ZOOM_K=0.0022: lower = gentler.
box.addEventListener('wheel',e=>{ e.preventDefault(); let d=e.deltaY; if(e.deltaMode===1)d*=16; else if(e.deltaMode===2)d*=box.clientHeight; d=Math.max(-50,Math.min(50,d)); const r=box.getBoundingClientRect(); _fpZoom(e.clientX-r.left,e.clientY-r.top,Math.exp(-d*0.0022)); },{passive:false});
let drag=false,lx=0,ly=0;
box.addEventListener('pointerdown',e=>{ if(e.target.closest('.zoomtools'))return; drag=true;lx=e.clientX;ly=e.clientY;box.classList.add('grab');box.setPointerCapture(e.pointerId); });
box.addEventListener('pointermove',e=>{ if(!drag)return; _fpTx+=e.clientX-lx;_fpTy+=e.clientY-ly;lx=e.clientX;ly=e.clientY;_fpApply(); });
box.addEventListener('pointerup',()=>{ drag=false;box.classList.remove('grab'); });
}
// ── Floaty Layers HUD ──
// Layer definitions: KiCad-colored geometry is matched by color; the
// Adom-computed paste overlays are matched by their group class.
// Every layer is its own <g class="adom-layer-…"> in the self-rendered SVG,
// in the original adom-footprint palette.
// Layer list comes from the shared viewer core (window.AV) — ONE source of
// truth, identical to the embed. Redefining it here is what caused the drift
// (stale names, missing layers, wrong toggle colours), so don't.
const LAYER_DEFS=AV.LAYER_DEFS;
// v2: reset once — earlier prefs were saved while vendor silk/courtyard/fab
// were (buggily) empty, leaving critical layers stuck off. Start fresh so the
// correct defaults (copper/silk/fab/courtyard ON, paste-dots OFF) apply.
const LPREF='adomFpLayers_v2';
function loadLayerState(){ try{ return JSON.parse(localStorage.getItem(LPREF))||{}; }catch{ return {}; } }
function saveLayerState(st){ try{ localStorage.setItem(LPREF, JSON.stringify(st)); }catch{} }
function svgEl(){ return document.querySelector('#svgbox svg'); }
// Toggle helpers delegate to the shared viewer core so behaviour is IDENTICAL
// to the embed (group-based show/hide; empty groups treated as absent).
function applyLayer(def, on){ AV.applyLayer(svgEl(), def, on); }
function groupHasGeo(def){ return AV.groupHasGeo(svgEl(), def); }
function layerPresent(def){ return AV.layerPresent(svgEl(), def); }
let layerState=loadLayerState();
function setupLayers(s){
const hud=document.getElementById('layersHud'), btn=document.getElementById('layersBtn');
if(!s.footprintName){ hud.style.display='none'; btn.style.display='none'; return; }
btn.style.display='inline-flex';
const body=document.getElementById('layersBody'); body.innerHTML='';
LAYER_DEFS.forEach(def=>{
// Skip layers with no geometry in THIS footprint (e.g. drill on an SMD
// part) — a permanently-dim, empty row just looks broken and is the source
// of "why is this dimmed and unclickable?". Computed overlays (paste dots)
// are special-cased and always kept so the user can enable them.
if(def.kind==='group' && !def.special && !groupHasGeo(def)) return;
// Standard layers default visible (unless turned off); the paste-dots
// overlay is opt-in → default OFF so it never obscures the copper (the
// dots are always computed regardless — this only toggles their display).
const on = def.special ? (layerState[def.key]===true) : (layerState[def.key]!==false);
const meta = def.key==='paste-dots' ? ((s.pasteDots||0)+' @ 300µm')
: def.key==='paste-ap' ? ((s.pasteApertures||0)+' openings') : '';
const row=document.createElement('div');
row.className='layer-row '+(on?'on':'off');
row.innerHTML='<span class="sw'+(def.ring?' ring':'')+'" style="'+(def.ring?('border-color:'+def.sw):('background:'+def.sw))+'"></span>'
+'<span class="lname">'+def.name+'</span>'
+(meta?'<span class="lmeta">'+meta+'</span>':'')
+'<span class="chk">'+(on?'✓':'')+'</span>';
row.onclick=()=>{ const nowOn=!row.classList.contains('on'); row.classList.toggle('on',nowOn); row.classList.toggle('off',!nowOn); row.querySelector('.chk').textContent=nowOn?'✓':''; layerState[def.key]=nowOn; saveLayerState(layerState); applyLayer(def,nowOn); };
body.appendChild(row);
applyLayer(def,on); // apply current state to the freshly injected svg
});
// Layers is the primary panel — always shown by default on load.
hud.style.display = 'flex';
}
// Header buttons
// Docked section: ▦ Layers expands the panel + jumps to Layers; ✕/▾ and a
// header click collapse just that section.
function _collapseSec(id,arrowId){ const h=document.getElementById(id); h.classList.toggle('collapsed'); if(arrowId)document.getElementById(arrowId).textContent=h.classList.contains('collapsed')?'▸':'▾'; }
document.getElementById('layersBtn').onclick=()=>{ document.getElementById('rightpanel').classList.remove('collapsed'); const h=document.getElementById('layersHud'); h.classList.remove('collapsed'); document.getElementById('layersCollapse').textContent='▾'; h.scrollIntoView({block:'nearest'}); };
document.getElementById('layersClose').onclick=()=>_collapseSec('layersHud','layersCollapse');
document.getElementById('layersCollapse').onclick=()=>_collapseSec('layersHud','layersCollapse');
document.getElementById('layersHead').addEventListener('click',e=>{ if(!e.target.closest('.hbtn')) document.getElementById('layersCollapse').click(); });
// AI-drivable
window.layers={ toggle:(key,on)=>{ const def=LAYER_DEFS.find(d=>d.key===key); if(!def)return 'no layer '+key; layerState[key]=on; saveLayerState(layerState); applyLayer(def,on); setupLayers({footprintName:document.getElementById('subjectName').textContent}); return key+'='+on; }, show:()=>document.getElementById('layersBtn').onclick(), state:()=>layerState };
// ── Send to desktop EDA tools ──
let toastTimer=null;
function showToast(msg,kind){ const t=document.getElementById('toast'); t.textContent=msg; t.className='show '+(kind||''); clearTimeout(toastTimer); toastTimer=setTimeout(()=>t.className=t.className.replace('show','').trim(),4200); }
document.getElementById('sendto').addEventListener('click',async e=>{
const b=e.target.closest('.sbtn'); if(!b) return;
const target=b.dataset.target, label=b.textContent;
const all=[...document.querySelectorAll('.sbtn')]; all.forEach(x=>x.disabled=true);
showToast('Sending to '+label+'…');
try{
const r=await fetch('deliver',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({target})});
const j=await r.json();
if(j.needsBridge){ showBridgeModal(j); }
else if(j.ok){ showToast('✓ '+(j.message||('Delivered to '+label)),'ok'); }
else { showToast('✕ '+(j.message||j.error||('Could not deliver to '+label)),'err'); }
}catch(err){ showToast('✕ Delivery failed: '+err,'err'); }
finally{ all.forEach(x=>x.disabled=false); }
});
function showBridgeModal(j){
let m=document.getElementById('bridgeModal');
if(!m){ m=document.createElement('div'); m.id='bridgeModal'; document.body.appendChild(m); }
m.innerHTML='<div class="bm-card"><div class="bm-icon">🌉</div>'
+'<div class="bm-title">Build the '+(j.target||'')+' bridge</div>'
+'<div class="bm-body">'+(j.message||'')+'</div>'
+'<div class="bm-actions"><a class="bm-go" href="'+(j.contribute||'https://wiki.adom.inc')+'" target="_blank" rel="noopener">Contribute on the wiki →</a>'
+'<button class="bm-dismiss">Got it</button></div></div>';
m.classList.add('show');
m.querySelector('.bm-dismiss').onclick=()=>m.classList.remove('show');
m.onclick=e=>{if(e.target===m)m.classList.remove('show')};
}
// ── >NAME / >VALUE text-placement editor ──────────────────────────────
// Drag the labels on the board, edit X/Y, nudge, or Auto-layout. Saved into
// the loaded .kicad_mod (POST /text) so delivery + export carry it.
let _txt = { reference:null, value:null, bbox:null, sel:'reference', dirty:false };
const _r2 = v => Math.round((v||0)*100)/100;
function fpSvg(){ return document.querySelector('#svgbox svg'); }
// Each EDA names the two texts differently — show the user's tool's wording
// (the geometry/.kicad_mod is identical; reference→RefDes, value→Value).
const TOOL_TERMS={ kicad:{ref:'Reference',val:'Value'}, fusion:{ref:'>NAME',val:'>VALUE'}, altium:{ref:'Designator',val:'Comment'}, orcad:{ref:'RefDes',val:'Value'} };
function curTool(){ try{ return localStorage.getItem('adomFpTool')||'kicad'; }catch{ return 'kicad'; } }
function terms(){ return TOOL_TERMS[curTool()]||TOOL_TERMS.kicad; }
function updateHudTerms(){ const t=terms();
[['reference',t.ref],['value',t.val]].forEach(([w,term])=>{
const row=document.querySelector('#textBody .trow[data-which="'+w+'"]'); if(!row)return;
const tt=row.querySelector('.tterm'); if(tt)tt.textContent=term;
row.style.display=(_txt[w]&&_txt[w].hidden)?'none':''; // hide the row for a hidden label
});
const s=document.getElementById('toolSel'); if(s)s.value=curTool(); }
// Undo / redo for placement edits (Ctrl/Cmd+Z · Ctrl/Cmd+Shift+Z).
let _undo=[], _redo=[], _txtName=null;
function _snap(){ return { reference:_txt.reference?{..._txt.reference}:null, value:_txt.value?{..._txt.value}:null }; }
function _pushUndo(before){ if(!before||(!before.reference&&!before.value))return; _undo.push(before); if(_undo.length>60)_undo.shift(); _redo=[]; _refreshUndoBtns(); }
function _refreshUndoBtns(){ const u=document.getElementById('undoBtn'),r=document.getElementById('redoBtn'); if(u)u.disabled=!_undo.length; if(r)r.disabled=!_redo.length; }
function _restore(s){ if(!s)return; if(s.reference)_txt.reference={...s.reference}; if(s.value)_txt.value={...s.value}; _txt.dirty=true; injectLabels(); syncInputs(); updateStatus(); applyText(); }
function undoText(){ if(!_undo.length){ showToast('Nothing to undo'); return 'nothing to undo'; } _redo.push(_snap()); _restore(_undo.pop()); _refreshUndoBtns(); showToast('↶ Undid placement change'); return 'undone'; }
function redoText(){ if(!_redo.length){ showToast('Nothing to redo'); return 'nothing to redo'; } _undo.push(_snap()); _restore(_redo.pop()); _refreshUndoBtns(); showToast('↷ Redid placement change'); return 'redone'; }
function setupTextEditor(s){
const btn=document.getElementById('textBtn'), hud=document.getElementById('textHud');
const embedB=document.getElementById('embedBtn');
const loaded = !!(s.footprintName && s.hasMod);
if(embedB) embedB.style.display = loaded ? 'inline-flex' : 'none';
if(!s.footprintName || !s.hasMod || !s.text){ btn.style.display='none'; hud.style.display='none'; return; }
btn.style.display='inline-flex';
const fresh = s.footprintName!==_txtName;
if(fresh){ _txtName=s.footprintName; _undo=[]; _redo=[]; } // fresh part → fresh history
if(!_txt.dirty){
if(s.text.reference) _txt.reference={...s.text.reference};
if(s.text.value) _txt.value={...s.text.value};
}
_txt.bbox = s.bbox || _txt.bbox;
_canSave = (s.canSave!==false);
injectLabels(); updateHudTerms(); syncInputs(); updateStatus(); _refreshUndoBtns();
hud.style.display='flex'; // always shown as a docked section in the right panel
const cb=document.getElementById('autoOnOpen'); if(cb)cb.checked=autoOnOpenChecked();
// Only (re)set the chip on a FRESH part — never on a routine poll, or it
// clobbers an in-flight 'unsaved'/'saving' and cancels the debounced auto-save.
if(fresh) setSaveState(_canSave ? 'saved' : 'nofile');
else if(!_canSave) setSaveState('nofile');
// While "Auto-layout on open" is checked, every freshly-opened footprint gets
// auto-laid-out and auto-saved — so you can sweep the library fixing placement.
if(fresh && autoOnOpenChecked() && _txt.reference && _txt.value){ autoLayout(true); }
}
function injectLabels(){
const sv=fpSvg(); if(!sv) return;
sv.querySelectorAll('.fp-textlabel').forEach(e=>e.remove());
const NS='http://www.w3.org/2000/svg', _t=terms();
[['reference',_t.ref,_txt.reference],['value',_t.val,_txt.value]].forEach(([which,label,pos])=>{
if(!pos || pos.hidden) return; // don't draw hidden labels (vendor value at 0,0)
const sz=Math.max(0.8,pos.size||1);
const g=document.createElementNS(NS,'g'); g.setAttribute('class','fp-textlabel '+(which==='reference'?'ref':'val')); g.dataset.which=which;
const w=label.length*0.62*sz, h=sz*1.5;
const r=document.createElementNS(NS,'rect'); r.setAttribute('x',pos.x-w/2); r.setAttribute('y',pos.y-h/2); r.setAttribute('width',w); r.setAttribute('height',h); r.setAttribute('rx',0.2);
const t=document.createElementNS(NS,'text'); t.setAttribute('x',pos.x); t.setAttribute('y',pos.y); t.setAttribute('font-size',sz); t.setAttribute('text-anchor','middle'); t.setAttribute('dominant-baseline','central'); t.textContent=label;
g.appendChild(r); g.appendChild(t); sv.appendChild(g); wireDrag(g);
});
sv.querySelectorAll('.fp-textlabel').forEach(g=>g.classList.toggle('sel', g.dataset.which===_txt.sel));
recomputeViewBox();
}
// Grow the SVG viewBox to enclose the footprint AND the (possibly far-flung)
// text labels + a margin, so the labels never clip and "fit" shows everything.
function recomputeViewBox(){
const sv=fpSvg(); if(!sv||!_txt.bbox) return;
let minx=_txt.bbox.minx, miny=_txt.bbox.miny, maxx=_txt.bbox.maxx, maxy=_txt.bbox.maxy;
sv.querySelectorAll('.fp-textlabel text').forEach(t=>{ try{ const b=t.getBBox(); minx=Math.min(minx,b.x); miny=Math.min(miny,b.y); maxx=Math.max(maxx,b.x+b.width); maxy=Math.max(maxy,b.y+b.height); }catch(e){} });
const px=(maxx-minx)*0.05+0.6, py=(maxy-miny)*0.05+0.6;
sv.setAttribute('viewBox',(minx-px)+' '+(miny-py)+' '+(maxx-minx+2*px)+' '+(maxy-miny+2*py));
sv.setAttribute('preserveAspectRatio','xMidYMid meet');
}
function clientToMm(e){
const sv=fpSvg(); if(!sv||!sv.getScreenCTM) return null;
const m=sv.getScreenCTM(); if(!m) return null;
const pt=sv.createSVGPoint(); pt.x=e.clientX; pt.y=e.clientY;
const p=pt.matrixTransform(m.inverse()); return {x:p.x,y:p.y};
}
function moveLabel(which){
const sv=fpSvg(); if(!sv)return; const g=sv.querySelector('.fp-textlabel[data-which="'+which+'"]'); if(!g)return;
const pos=_txt[which], t=g.querySelector('text'), r=g.querySelector('rect');
t.setAttribute('x',pos.x); t.setAttribute('y',pos.y);
const w=+r.getAttribute('width'), h=+r.getAttribute('height');
r.setAttribute('x',pos.x-w/2); r.setAttribute('y',pos.y-h/2);
}
function wireDrag(g){
const which=g.dataset.which;
g.addEventListener('pointerdown',e=>{
e.stopPropagation(); e.preventDefault(); // don't pan the board
_txt.sel=which; injectLabels();
const gg=fpSvg().querySelector('.fp-textlabel[data-which="'+which+'"]');
gg.classList.add('dragging'); gg.setPointerCapture(e.pointerId);
const start=clientToMm(e), orig={..._txt[which]}, before=_snap(); let moved=false;
const move=ev=>{ const m=clientToMm(ev); if(!m||!start)return; _txt[which].x=_r2(orig.x+(m.x-start.x)); _txt[which].y=_r2(orig.y+(m.y-start.y)); moved=true; _txt.dirty=true; moveLabel(which); syncInputs(); updateStatus(); };
const up=()=>{ gg.classList.remove('dragging'); gg.removeEventListener('pointermove',move); gg.removeEventListener('pointerup',up); if(moved) _pushUndo(before); recomputeViewBox(); applyText(); };
gg.addEventListener('pointermove',move); gg.addEventListener('pointerup',up);
});
}
function syncInputs(){
document.querySelectorAll('#textBody .trow[data-which]').forEach(row=>{ const p=_txt[row.dataset.which]; if(!p)return; row.querySelector('.tx').value=p.x; row.querySelector('.ty').value=p.y; });
}
function updateStatus(){
const st=document.getElementById('textStatus'), b=_txt.bbox, _t=terms();
if(!b){ st.className='tstatus'; st.textContent=''; return; }
const r=_txt.reference, v=_txt.value, rVis=r&&!r.hidden, vVis=v&&!v.hidden;
if(!rVis&&!vVis){ st.className='tstatus'; st.textContent='Both labels are hidden in this footprint.'; return; }
const bad=[];
if(rVis && !(r.y<=b.miny)) bad.push(_t.ref);
if(vVis && !(v.y>=b.maxy)) bad.push(_t.val);
if(!bad.length){ st.className='tstatus ok'; st.textContent='✓ Clear of the footprint (keep-out top '+b.miny.toFixed(2)+' / bottom '+b.maxy.toFixed(2)+' mm)'; }
else { st.className='tstatus warn'; st.textContent='⚠ '+bad.join(' & ')+' over the pads — Auto-layout to clear.'; }
}
// Save state: edits go to the in-memory working copy (/text); "Save" persists
// it to the library .kicad_mod (/save). 'saved' | 'unsaved' | 'saving' | 'nofile'.
let _saveState='saved', _canSave=true;
function setSaveState(st){ _saveState=st; const b=document.getElementById('saveBtn'); if(!b)return;
if(st==='nofile'){ b.style.display='none'; return; }
b.style.display=''; b.disabled=(st!=='unsaved'); b.className='tbtn savebtn '+st;
// Auto-saving, so the chip reports status rather than prompting a click.
b.textContent = st==='saving' ? 'Saving…' : st==='saved' ? '✓ Saved automatically' : 'Saving…';
}
async function saveFootprint(quiet){
if(_saveState==='saving' || !_canSave) return false;
setSaveState('saving');
try{ const r=await fetch('save',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});
if(r.ok){ setSaveState('saved'); if(!quiet)showToast('✓ Saved to the library footprint','ok'); return true; }
const j=await r.json().catch(()=>({})); setSaveState('unsaved'); if(!quiet)showToast('✕ '+(j.error||'Save failed'),'err'); return false;
}catch(e){ setSaveState('unsaved'); if(!quiet)showToast('✕ Save failed: '+e,'err'); return false; }
}
function _txtItem(p){ return {x:p.x, y:p.y, size:p.size}; }
async function applyTextNow(){
if(!_txt.reference||!_txt.value) return;
try{ await fetch('text',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({reference:_txtItem(_txt.reference),value:_txtItem(_txt.value)})}); _txt.dirty=false; setSaveState('unsaved'); scheduleSave(); }catch(e){}
}
// Auto-save (adom-symbol style): every edit persists to the library .kicad_mod
// on a short debounce — no manual Save click needed. The button is just a live
// status chip. Nav away also flushes a pending save (see loadChip).
// Track pending-save with a dedicated flag, NOT _saveState — the /state poll
// resets the visible chip and would otherwise cancel a debounced auto-save.
let _saveTimer=null, _needsSave=false;
function scheduleSave(){ if(!_canSave) return; _needsSave=true; clearTimeout(_saveTimer); _saveTimer=setTimeout(()=>{ if(_needsSave && _canSave){ _needsSave=false; saveFootprint(true); } },600); }
function flushSave(){ if(_saveTimer){ clearTimeout(_saveTimer); _saveTimer=null; } if(_needsSave && _canSave){ _needsSave=false; return saveFootprint(true); } }
// Normalized text height: 1 mm default, scaled down so a label never dwarfs a tiny part.
function _textSize(b){ const me=Math.max(b.maxx-b.minx, b.maxy-b.miny); return Math.round(Math.max(0.5,Math.min(1.0, me/2.5))*10)/10; }
async function autoLayout(save){
const b=_txt.bbox; if(!b||!_txt.reference||!_txt.value)return;
_pushUndo(_snap());
const sz=_textSize(b);
_txt.reference.x=0; _txt.reference.y=_r2(b.miny-1); _txt.reference.size=sz;
_txt.value.x=0; _txt.value.y=_r2(b.maxy+1); _txt.value.size=sz;
_txt.dirty=true; injectLabels(); syncInputs(); updateStatus();
await applyTextNow();
if(save && _canSave) await saveFootprint(true); // "auto-layout on open" → quiet auto-save
}
function autoOnOpenChecked(){ try{ return localStorage.getItem('adomFpAutoOpen')==='1'; }catch(e){ return false; } }
let _applyTimer=null;
function applyText(){ // debounced — edits go to the working copy; click Save to persist
if(!_txt.reference||!_txt.value) return;
clearTimeout(_applyTimer);
_applyTimer=setTimeout(()=>{ applyTextNow(); },220);
}
// HUD wiring
document.getElementById('textBtn').onclick=()=>{ document.getElementById('rightpanel').classList.remove('collapsed'); const h=document.getElementById('textHud'); h.classList.remove('collapsed'); h.scrollIntoView({block:'nearest'}); };
// Open the standalone embed viewer for the current footprint in a new tab.
document.getElementById('embedBtn').onclick=()=>window.open('embed','_blank','noopener');
document.getElementById('textClose').onclick=()=>document.getElementById('textHud').classList.toggle('collapsed');
document.getElementById('textHead').addEventListener('click',e=>{ if(!e.target.closest('.hbtn')) document.getElementById('textHud').classList.toggle('collapsed'); });
document.getElementById('autoLayoutBtn').onclick=()=>autoLayout(false); // manual: update working copy; click Save to persist
document.getElementById('saveBtn').onclick=()=>saveFootprint(false);
document.getElementById('autoOnOpen').addEventListener('change',e=>{ try{ localStorage.setItem('adomFpAutoOpen', e.target.checked?'1':'0'); }catch{} if(e.target.checked) autoLayout(true); });
document.getElementById('undoBtn').onclick=undoText;
document.getElementById('redoBtn').onclick=redoText;
document.getElementById('toolSel').addEventListener('change',e=>{ try{ localStorage.setItem('adomFpTool',e.target.value); }catch{} updateHudTerms(); injectLabels(); updateStatus(); });
// Keyboard undo/redo (ignored while typing in an input/select).
document.addEventListener('keydown',e=>{
if(e.target&&/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName)) return;
const z=(e.key==='z'||e.key==='Z');
if((e.ctrlKey||e.metaKey)&&z&&!e.shiftKey){ e.preventDefault(); undoText(); }
else if((e.ctrlKey||e.metaKey)&&((e.key==='y'||e.key==='Y')||(z&&e.shiftKey))){ e.preventDefault(); redoText(); }
});
document.querySelectorAll('#textBody .trow[data-which]').forEach(row=>{
const w=row.dataset.which;
const onInput=()=>{ if(!_txt[w])return; _pushUndo(_snap()); _txt[w].x=_r2(parseFloat(row.querySelector('.tx').value)||0); _txt[w].y=_r2(parseFloat(row.querySelector('.ty').value)||0); _txt.dirty=true; _txt.sel=w; injectLabels(); updateStatus(); applyText(); };
row.querySelector('.tx').addEventListener('change',onInput);
row.querySelector('.ty').addEventListener('change',onInput);
row.querySelectorAll('.tnudge button').forEach(btn=>btn.onclick=()=>{ if(!_txt[w])return; _pushUndo(_snap()); _txt[w].y=_r2(_txt[w].y+(btn.dataset.d==='up'?-0.5:0.5)); _txt.dirty=true; _txt.sel=w; injectLabels(); syncInputs(); updateStatus(); applyText(); });
});
// ── dock the Layers + Text HUDs into the right panel + the Chip details ──
(function(){
const inner=document.getElementById('rpInner'), chip=document.getElementById('chippanel'), panel=document.getElementById('rightpanel');
[document.getElementById('layersHud'), document.getElementById('textHud')].forEach(h=>{
if(!h) return;
['position','right','top','left','bottom','width'].forEach(p=>h.style[p]=''); // drop floaty positioning
h.style.display='none'; inner.insertBefore(h, chip);
});
function setPanel(open){ panel.classList.toggle('collapsed',!open); try{localStorage.setItem('adomFpPanel',open?'1':'0')}catch(e){} }
document.getElementById('rpCollapse').onclick=()=>setPanel(false);
document.getElementById('rpToggle').onclick=()=>setPanel(true);
(function(){ let p=null; try{p=localStorage.getItem('adomFpPanel')}catch(e){} setPanel(p!=='0'); })(); // open by default
})();
// Chip details (3D preview + metadata) — so you can see WHICH part this is.
function renderChipPanel(s){
const wrap=document.getElementById('chippanel'); if(!wrap) return;
const c=s&&s.chip, img=document.getElementById('chip3d'), empty=document.getElementById('chip3dempty'), info=document.getElementById('chipinfo');
if(!c||!c.mpn){ wrap.style.display='none'; return; }
wrap.style.display='';
if(c.has3d){ empty.style.display='none'; img.style.display='block'; img.src='api/chip3d?mpn='+encodeURIComponent(c.mpn); }
else { img.style.display='none'; empty.style.display=''; img.removeAttribute('src'); }
const bd=c.bodyDimensions, dim=(bd&&bd.x&&bd.y)?(bd.x+' × '+bd.y+(bd.thickness_max?' × '+bd.thickness_max:'')+' mm'):null;
const rows=[['Package',c.package],['Body',dim],['Pads',(c.padCount!=null?c.padCount:(s.padCount||null))],['Family',c.family]].filter(r=>r[1]!=null&&r[1]!=='');
info.innerHTML='<div class="ci-mpn">'+_esc(c.mpn)+'</div>'
+(c.manufacturer?'<div class="ci-mfr">'+_esc(c.manufacturer)+'</div>':'')
+(c.description?'<div class="ci-desc">'+_esc(c.description)+'</div>':'')
+rows.map(r=>'<div class="ci-row"><span class="ci-k">'+r[0]+'</span><span class="ci-v">'+_esc(String(r[1]))+'</span></div>').join('')
+(c.datasheet?'<a class="ci-ds" href="'+_esc(c.datasheet)+'" target="_blank" rel="noopener">Datasheet ↗</a>':'');
}
// ── left chip-fetcher library rail ──────────────────────────────────────
// Lists every chip in the chip-fetcher library; click / ↑↓ / filter to load
// any footprint. The AI deep-links via ?mpn=<MPN>; navigating updates the URL.
function _esc(s){ return String(s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); }
let chips=[], projects={}, projFilter='', search='', shown=[], chipIdx=-1, loadedMpn=null, navT=null, _urlChecked=false;
const rail=document.getElementById('chiprail'), chiplist=document.getElementById('chiplist'), projSel=document.getElementById('cr-project'), crSearch=document.getElementById('cr-search');
function applyFilter(){
const q=search.trim().toLowerCase();
const base=(projFilter&&projects[projFilter])?chips.filter(c=>(projects[projFilter].mpns||[]).includes(c.mpn)):chips.slice();
shown=base.filter(c=>!q||c.mpn.toLowerCase().includes(q));
chipIdx=shown.findIndex(c=>c.current);
}
function renderChips(){
applyFilter();
chiplist.innerHTML=shown.map(c=>'<div class="cr-item'+(c.current?' cur':'')+'" data-mpn="'+_esc(c.mpn)+'">'+_esc(c.mpn)+'</div>').join('')||'<div class="cr-hint" style="border:0">no matching chips</div>';
document.getElementById('cr-count').textContent=(projFilter||'Library')+' · '+shown.length;
const cur=chiplist.querySelector('.cr-item.cur'); if(cur)cur.scrollIntoView({block:'nearest'});
}
function renderProjectOptions(){
const names=Object.keys(projects).map(n=>[n,(projects[n].mpns||[]).filter(m=>chips.some(c=>c.mpn===m)).length]).filter(a=>a[1]>0).sort((a,b)=>a[0].toLowerCase().localeCompare(b[0].toLowerCase()));
projSel.innerHTML='<option value="">All chips ('+chips.length+')</option>'+names.map(a=>'<option value="'+_esc(a[0])+'">'+_esc(a[0])+' ('+a[1]+')</option>').join('');
if(projFilter&&!names.some(a=>a[0]===projFilter))projFilter='';
projSel.value=projFilter;
}
async function loadChips(){
try{
const [cj,pj]=await Promise.all([
fetch('api/chips',{cache:'no-store'}).then(r=>r.json()),
fetch('api/projects',{cache:'no-store'}).then(r=>r.json()).catch(()=>({projects:{}})),
]);
chips=cj.chips||[]; projects=pj.projects||{};
if(!chips.length){ rail.style.display='none'; return; } // nothing to browse
rail.style.display='';
renderProjectOptions(); renderChips();
if(!_urlChecked){ _urlChecked=true; const want=new URLSearchParams(location.search).get('mpn');
if(want && chips.some(c=>c.mpn===want)){ const cur=(window._lastState&&window._lastState.mpn)||''; if(want!==cur) loadChipMpn(want); } }
}catch(e){}
}
function setCurrent(mpn){ chips.forEach(c=>c.current=(c.mpn===mpn)); renderChips(); }
function updateUrl(mpn){ try{ const u=new URL(location.href); u.searchParams.set('mpn',mpn); history.replaceState(null,'',u); }catch(e){} }
async function loadChipMpn(mpn){
if(!mpn||mpn===loadedMpn)return;
try{ await flushSave(); }catch(e){} // auto-save the chip we're leaving
const prev=loadedMpn; loadedMpn=mpn;
try{ const r=await fetch('load-chip',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mpn})});
if(!r.ok){ loadedMpn=prev; showToast('✕ '+mpn+' not in library','err'); return; }
_txt.dirty=false; updateUrl(mpn); pollState(); }
catch(e){ loadedMpn=prev; }
}
// Highlight instantly; debounce the heavier load so ↑/↓ browsing only loads the chip you LAND on.
function selectChip(mpn){ if(!mpn)return; setCurrent(mpn); clearTimeout(navT); navT=setTimeout(()=>{navT=null;loadChipMpn(mpn);},260); }
function switchByOffset(d){ if(!shown.length)return; let i=chipIdx<0?(d>0?0:shown.length-1):chipIdx+d; i=Math.max(0,Math.min(shown.length-1,i)); selectChip(shown[i].mpn); }
chiplist.addEventListener('click',e=>{const it=e.target.closest('.cr-item');if(it)selectChip(it.dataset.mpn);});
projSel.addEventListener('change',()=>{projFilter=projSel.value;renderChips();chiplist.focus();});
crSearch.addEventListener('input',()=>{search=crSearch.value;renderChips();});
crSearch.addEventListener('keydown',e=>{ if(e.key==='ArrowDown'){e.preventDefault();switchByOffset(1);} else if(e.key==='ArrowUp'){e.preventDefault();switchByOffset(-1);} else if(e.key==='Enter'){e.preventDefault(); if(shown[chipIdx])loadChipMpn(shown[chipIdx].mpn); else if(shown[0])selectChip(shown[0].mpn);} });
function setRail(open){ rail.classList.toggle('collapsed',!open); try{localStorage.setItem('adomFpChipRail',open?'1':'0')}catch(e){} if(open){loadChips();setTimeout(()=>crSearch.focus(),30);} }
document.getElementById('chiprailToggle').onclick=()=>setRail(true);
document.getElementById('chiprailClose').onclick=()=>setRail(false);
(function(){let p=null;try{p=localStorage.getItem('adomFpChipRail')}catch(e){} setRail(p==='1');})(); // collapsed by default
document.addEventListener('keydown',e=>{
if(e.target&&/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName))return;
if(rail.classList.contains('collapsed'))return;
if(e.key==='ArrowDown'){e.preventDefault();switchByOffset(1);}
else if(e.key==='ArrowUp'){e.preventDefault();switchByOffset(-1);}
});
loadChips(); // populate immediately (so ?mpn deep-link works + rail hides if empty)
// ── AI-driving API (ai-driven-apps contract) — drive the real UI from the CLI ──
window.ui = {
layer(name, on){
const def=LAYER_DEFS.find(d=>d.key===name||d.name.toLowerCase()===String(name).toLowerCase()||d.name.toLowerCase().indexOf(String(name).toLowerCase())>=0);
if(!def) return 'no layer: '+name+' (have: '+LAYER_DEFS.map(d=>d.key).join(', ')+')';
const want=(on!==false&&on!=='off'&&on!=='0'&&on!=='false');
const row=[].slice.call(document.querySelectorAll('.layer-row')).find(r=>{const n=r.querySelector('.lname');return n&&n.textContent.indexOf(def.name)>=0;});
if(row && row.classList.contains('on')!==want) row.click();
return def.name+'='+want;
},
zoom(dir){ const b=document.getElementById(dir==='in'?'fpZin':dir==='out'?'fpZout':'fpFit'); if(b)b.click(); return 'zoom '+dir; },
send(target){ const b=document.querySelector('.sbtn[data-target="'+target+'"]'); if(b){ b.click(); return 'send to '+target; } return 'no send target: '+target; },
toast(m){ showToast(String(m)); return 'toast: '+m; },
text(){ document.getElementById('textBtn').click(); return 'text editor open'; },
textAuto(save){ autoLayout(!!save); return 'auto-layout'+(save?'+save':'')+': >NAME '+JSON.stringify(_txt.reference)+' >VALUE '+JSON.stringify(_txt.value); },
textSave(){ saveFootprint(false); return 'saving'; },
autoSaveOnOpen(on){ const v=(on!==false); try{localStorage.setItem('adomFpAutoOpen',v?'1':'0')}catch(e){} const cb=document.getElementById('autoOnOpen'); if(cb)cb.checked=v; return 'auto-layout-on-open '+(v?'ON':'OFF'); },
textSet(which,x,y){ const w=(which==='value'||which==='>VALUE')?'value':'reference'; if(!_txt[w])return 'no '+w; _pushUndo(_snap()); if(x!=null)_txt[w].x=_r2(+x); if(y!=null)_txt[w].y=_r2(+y); _txt.dirty=true; _txt.sel=w; injectLabels(); syncInputs(); updateStatus(); applyText(); return w+' → '+_txt[w].x+','+_txt[w].y; },
textState(){ return {reference:_txt.reference, value:_txt.value, bbox:_txt.bbox, tool:curTool(), terms:terms()}; },
textTool(t){ const v=['kicad','fusion','altium','orcad'].includes(t)?t:'kicad'; try{ localStorage.setItem('adomFpTool',v); }catch{} updateHudTerms(); injectLabels(); updateStatus(); return 'tool: '+v+' ('+terms().ref+' / '+terms().val+')'; },
undo(){ return undoText(); },
redo(){ return redoText(); },
loadChip(mpn){ if(!mpn)return 'mpn required'; setCurrent(mpn); loadChipMpn(mpn); return 'loading '+mpn; },
chips(){ return chips.map(c=>c.mpn); },
library(open){ setRail(open!==false&&open!=='off'); return 'library '+(open!==false?'open':'closed'); },
state(){ return window._lastState||{}; },
list(){ return Object.keys(window.ui).filter(k=>typeof window.ui[k]==='function'); }
};
async function evalLoop(){
try{
const r=await fetch('eval/pending',{cache:'no-store'}); const j=await r.json();
if(j&&j.id){
let result, err=null;
try{ result=await (async()=>{ return eval(j.code); })(); }catch(e){ err=String(e&&e.stack||e); }
await fetch('eval/'+j.id+'/result',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({result: err?undefined:safe(result), error:err})});
}
}catch(e){}
}
function safe(v){ try{ JSON.stringify(v); return v; }catch{ return String(v); } }
setInterval(evalLoop,400); evalLoop();
</script>
</body>
</html>