app
Component Widgets
Public Made by Adomby adom
Reusable component widgets for pinouts, thermal vias, molecule relationships, reviewed chip outlines and lightweight four-level LOD cache listings.
master
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366
/*!
* <adom-footprint-variants> — a reusable, dependency-free footprint-variant viewer.
*
* Renders a part's baseline footprint and its Adom-generated variants (heatsink
* vias, 300 µm solder-ball placement, rounded-rect pads, combined) straight from
* the page's .kicad_mod files: no pre-baked geometry JSON, the widget parses the
* s-expressions in the browser. Anything the variant adds or changes vs the
* baseline is highlighted.
*
* Drop-in:
* <script src="footprint-variants-widget.js"></script>
* <adom-footprint-variants src="/OWNER/SLUG/render/part.variants.json"></adom-footprint-variants>
*
* Data contract: the adom-footprint variants manifest (what `adom-footprint`'s
* heatsink / solderballs verbs emit next to the mods):
* {
* "part": "LAN7800", "package": "QFN-48 7x7mm",
* "variants": [
* { "suffix": "", "device": "baseline", "kicad_mod": "part.kicad_mod",
* "changed": "nothing; the page footprint as-is" },
* { "suffix": "-HV", "device": "heatsink vias", "kicad_mod": "part-HV.kicad_mod", ... }
* ]
* }
* Each variant's kicad_mod is fetched relative to the manifest's own URL, so one
* absolute `src` is all a README supplies. Solder balls are read from filled
* fp_circles on layer User.9 (Adom.SolderBalls); heatsink vias are the
* thru_hole pads.
*
* Attributes: src (manifest URL), variant (initial: a suffix like "-HV-SB-RR"
* or a device name like "solder balls"), heading (override title).
* Self-contained (Shadow DOM), themes to the host via prefers-color-scheme,
* cross-highlights pads on the shared adom-lbr hover bus.
*/
(() => {
const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
const num = v => parseFloat(v);
// --- minimal s-expression reader: text -> nested arrays, strings unquoted ---
function sexpr(text) {
const toks = text.match(/"(?:[^"\\]|\\.)*"|[()]|[^\s()"]+/g) || [];
let i = 0;
const walk = () => {
const out = [];
while (i < toks.length) {
const t = toks[i++];
if (t === '(') out.push(walk());
else if (t === ')') return out;
else out.push(t[0] === '"' ? t.slice(1, -1).replace(/\\(.)/g, '$1') : t);
}
return out;
};
const root = [];
while (i < toks.length) { const t = toks[i++]; if (t === '(') root.push(walk()); }
return root;
}
// --- .kicad_mod -> { pads, gfx (silk/fab/courtyard), balls (User.9) } ---
const BALL_LAYER = 'User.9'; // Adom.SolderBalls
function parseMod(text) {
const root = sexpr(text)[0] || [];
const find = (node, key) => node.find(x => Array.isArray(x) && x[0] === key);
const pads = [], gfx = [], balls = [];
for (const node of root) {
if (!Array.isArray(node)) continue;
if (node[0] === 'pad') {
const at = find(node, 'at'), size = find(node, 'size');
if (!at || !size) continue;
const drill = find(node, 'drill'), rr = find(node, 'roundrect_rratio');
const layers = (find(node, 'layers') || []).slice(1);
const cu = !layers.length || layers.some(l => /Cu$/.test(l));
pads.push({
n: node[1], kind: node[2], shape: node[3],
x: num(at[1]), y: num(at[2]), rot: at[3] != null ? num(at[3]) : 0,
w: num(size[1]), h: num(size[2]),
drill: drill ? num(drill[1]) : 0, rr: rr ? num(rr[1]) : 0,
cu, paste: layers.some(l => /Paste$/.test(l)),
});
} else if (node[0] === 'fp_circle') {
const layer = (find(node, 'layer') || [])[1] || '';
const c = find(node, 'center'), e = find(node, 'end');
if (!c || !e) continue;
const r = Math.hypot(num(e[1]) - num(c[1]), num(e[2]) - num(c[2]));
const filled = (find(node, 'fill') || [])[1] === 'yes';
if (layer === BALL_LAYER) balls.push({ x: num(c[1]), y: num(c[2]), r });
else gfx.push({ t: 'c', x: num(c[1]), y: num(c[2]), r, layer, filled });
} else if (node[0] === 'fp_line' || node[0] === 'fp_rect') {
const layer = (find(node, 'layer') || [])[1] || '';
const s = find(node, 'start'), e = find(node, 'end');
if (!s || !e) continue;
const stroke = find(node, 'stroke');
const w = stroke ? num((find(stroke, 'width') || [0, '0.12'])[1]) : 0.12;
gfx.push({ t: node[0] === 'fp_line' ? 'l' : 'r', x1: num(s[1]), y1: num(s[2]), x2: num(e[1]), y2: num(e[2]), layer, w });
}
}
return { name: root[1] || '', pads, gfx, balls };
}
// A stable identity for "same pad as the baseline": number + centre + geometry.
const padKey = p => `${p.n}@${p.x},${p.y}|${p.kind}:${p.shape}:${p.w}x${p.h}:${p.rr}:${p.drill}`;
class AdomFootprintVariants extends HTMLElement {
static parseMod = parseMod; // exposed for tests / structured consumers
async connectedCallback() {
// A host page that pins its color-scheme (the wiki is dark-only) decides the theme; inline, prefers-color-scheme
// would follow the reader's OS instead (a frame inherits the embedding page's scheme).
if (!this.hasAttribute('theme')) { const cs = getComputedStyle(document.documentElement).colorScheme.trim(); if (/^(only )?(dark|light)( only)?$/.test(cs)) this.setAttribute('theme', cs.replace(/only|\s/g, '')); }
if (this._init) return; this._init = true;
this.attachShadow({ mode: 'open' });
this._mods = Object.create(null); // suffix -> parsed mod (or Promise)
this._busInit();
const src = this.getAttribute('src');
this.shadowRoot.innerHTML = `<style>${this._css()}</style><div class="card"><div class="empty">loading footprint variants…</div></div>`;
if (!src) { this._fail('no src: point me at a .variants.json'); return; }
this._base = src.slice(0, src.lastIndexOf('/') + 1);
try { this._data = await (await fetch(src)).json(); }
catch (e) { this._fail('could not load ' + src); return; }
const vars = (this._data && this._data.variants) || [];
if (!vars.length) { this._fail('no variants in ' + src); return; }
const want = (this.getAttribute('variant') || '').trim().toLowerCase();
this._sel = Math.max(0, vars.findIndex(v =>
want && (String(v.suffix).toLowerCase() === want || String(v.device).toLowerCase() === want)));
this._render();
this._show(this._sel);
}
_fail(msg) { this.shadowRoot.innerHTML = `<style>${this._css()}</style><div class="card"><div class="empty">${esc(msg)}</div></div>`; }
async _mod(i) {
const v = this._data.variants[i], k = String(v.suffix);
if (!this._mods[k]) {
this._mods[k] = (async () => parseMod(await (await fetch(this._base + v.kicad_mod)).text()))();
}
return this._mods[k];
}
_render() {
const d = this._data;
const heading = this.getAttribute('heading') || d.part || 'Footprint variants';
this.shadowRoot.innerHTML = `<style>${this._css()}</style>
<div class="card">
<div class="hd"><span class="mpn">${esc(heading)}</span>${d.package ? `<span class="pkg">${esc(d.package)}</span>` : ''}<span class="count">${d.variants.length} variants</span><span class="tools"><button class="tbtn" data-act="copy" title="Copy the variant manifest as JSON">copy</button><button class="tbtn" data-act="dl" title="Download the variant manifest as JSON">JSON ↓</button></span></div>
<div class="chips">${d.variants.map((v, i) => `<button class="chip" data-i="${i}" title="${esc(v.kicad_mod)}">${esc(v.device || v.suffix || 'baseline')}</button>`).join('')}</div>
<div class="readout" data-readout></div>
<div class="stage" data-stage><div class="empty">…</div></div>
<div class="changed" data-changed></div>
<div class="legend" data-legend></div>
</div>`;
const root = this.shadowRoot;
root.querySelectorAll('.chip').forEach(b => { b.onclick = () => this._show(+b.dataset.i); });
const dl = root.querySelector('.tbtn[data-act="dl"]'); if (dl) dl.onclick = () => this._download();
const cp = root.querySelector('.tbtn[data-act="copy"]'); if (cp) cp.onclick = () => this._copy(cp);
}
async _show(i) {
const root = this.shadowRoot, d = this._data;
this._sel = i;
root.querySelectorAll('.chip').forEach(b => b.classList.toggle('on', +b.dataset.i === i));
const v = d.variants[i];
const ch = root.querySelector('[data-changed]');
ch.textContent = v.changed ? v.changed : '';
ch.hidden = !v.changed;
let mod, base;
try {
[mod, base] = await Promise.all([this._mod(i), this._mod(this._baselineIndex())]);
} catch (e) {
root.querySelector('[data-stage]').innerHTML = `<div class="empty">could not load ${esc(v.kicad_mod)}</div>`;
return;
}
if (this._sel !== i) return; // a faster click won
const baseKeys = new Set(base.pads.map(padKey));
const isBaseline = i === this._baselineIndex();
root.querySelector('[data-stage]').innerHTML = this._svg(mod, baseKeys, isBaseline);
this._wireStage(mod);
this._legend(mod, isBaseline);
this._setReadout(null);
}
_baselineIndex() {
const vars = this._data.variants;
const j = vars.findIndex(v => !v.suffix || /baseline/i.test(v.device || ''));
return j < 0 ? 0 : j;
}
_svg(mod, baseKeys, isBaseline) {
// bbox from the courtyard when present, else pads + margin (mm, KiCad y-down = SVG y-down)
let x0 = 1e9, y0 = 1e9, x1 = -1e9, y1 = -1e9;
const grow = (x, y) => { x0 = Math.min(x0, x); y0 = Math.min(y0, y); x1 = Math.max(x1, x); y1 = Math.max(y1, y); };
const cy = mod.gfx.filter(g => g.layer === 'F.CrtYd');
if (cy.length) cy.forEach(g => { if (g.t === 'c') { grow(g.x - g.r, g.y - g.r); grow(g.x + g.r, g.y + g.r); } else { grow(g.x1, g.y1); grow(g.x2, g.y2); } });
else mod.pads.forEach(p => { grow(p.x - p.w, p.y - p.h); grow(p.x + p.w, p.y + p.h); });
const M = 0.7; x0 -= M; y0 -= M; x1 += M; y1 += M;
let s = `<svg viewBox="${x0} ${y0} ${x1 - x0} ${y1 - y0}" class="fp" preserveAspectRatio="xMidYMid meet">`;
s += `<rect x="${x0}" y="${y0}" width="${x1 - x0}" height="${y1 - y0}" rx="0.3" class="board"/>`;
// graphics: fab (faint) under silk; courtyard dashed
const line = (g, cls) => g.t === 'c'
? `<circle cx="${g.x}" cy="${g.y}" r="${g.r}" class="${cls}" stroke-width="${g.w || 0.1}" fill="${g.filled ? 'currentColor' : 'none'}"/>`
: g.t === 'l'
? `<line x1="${g.x1}" y1="${g.y1}" x2="${g.x2}" y2="${g.y2}" class="${cls}" stroke-width="${g.w || 0.1}"/>`
: `<rect x="${Math.min(g.x1, g.x2)}" y="${Math.min(g.y1, g.y2)}" width="${Math.abs(g.x2 - g.x1)}" height="${Math.abs(g.y2 - g.y1)}" class="${cls}" stroke-width="${g.w || 0.1}" fill="none"/>`;
mod.gfx.forEach(g => {
if (g.layer === 'F.Fab') s += line(g, 'fab');
else if (g.layer === 'F.CrtYd') s += line(g, 'crtyd');
});
// pads: SMD gold; thru-hole (heatsink vias) copper ring + drill; changed-vs-baseline glows
const smd = mod.pads.filter(p => p.kind !== 'thru_hole' && p.cu);
const th = mod.pads.filter(p => p.kind === 'thru_hole');
const apertures = mod.pads.filter(p => p.kind !== 'thru_hole' && !p.cu && p.paste);
smd.sort((a, b) => (b.w * b.h) - (a.w * a.h)); // EP first so small pads stay hoverable
for (const p of smd) {
const hot = !isBaseline && !baseKeys.has(padKey(p)) ? ' diff' : '';
const rx = p.shape === 'oval' || p.shape === 'circle' ? Math.min(p.w, p.h) / 2
: p.shape === 'roundrect' ? p.rr * Math.min(p.w, p.h) : 0.02;
const tr = p.rot ? ` transform="rotate(${-p.rot} ${p.x} ${p.y})"` : '';
s += `<g class="pad${hot}" data-pad="${esc(p.n)}" data-info="${esc(`PAD ${p.n} · ${p.shape} ${p.w.toFixed(2)} × ${p.h.toFixed(2)} mm${p.rr ? ` · r=${p.rr}` : ''}`)}"${tr}>`
+ `<rect x="${p.x - p.w / 2}" y="${p.y - p.h / 2}" width="${p.w}" height="${p.h}" rx="${rx}" class="cu"/></g>`;
}
for (const p of th) {
const hot = !isBaseline && !baseKeys.has(padKey(p)) ? ' diff' : '';
s += `<g class="pad via${hot}" data-pad="${esc(p.n)}" data-info="${esc(`VIA on pad ${p.n} · Ø${p.w.toFixed(2)} mm, drill ${p.drill.toFixed(2)} mm`)}">`
+ `<circle cx="${p.x}" cy="${p.y}" r="${p.w / 2}" class="ring"/>`
+ `<circle cx="${p.x}" cy="${p.y}" r="${p.drill / 2}" class="hole"/></g>`;
}
// paste-only apertures (a split EP stencil): outlined, not copper
for (const p of apertures) {
s += `<rect x="${p.x - p.w / 2}" y="${p.y - p.h / 2}" width="${p.w}" height="${p.h}" rx="${p.shape === 'roundrect' ? p.rr * Math.min(p.w, p.h) : 0.02}" class="aperture"/>`;
}
// solder balls (User.9): where Adom jets the 300 µm balls
for (const b of mod.balls) s += `<circle cx="${b.x}" cy="${b.y}" r="${b.r}" class="ball"/>`;
// pin-1 marker: silk circle if the mod drew one, else corner dot
const p1 = mod.gfx.find(g => g.layer === 'F.SilkS' && g.t === 'c');
mod.gfx.forEach(g => { if (g.layer === 'F.SilkS') s += line(g, 'silk'); });
if (!p1) s += `<circle cx="${x0 + 0.55}" cy="${y0 + 0.55}" r="0.16" class="pin1"/>`;
return s + `</svg>`;
}
_legend(mod, isBaseline) {
const items = [['cu', 'copper pad']];
if (mod.pads.some(p => p.kind === 'thru_hole')) items.push(['ring', 'heatsink via']);
if (mod.pads.some(p => p.kind !== 'thru_hole' && !p.cu && p.paste)) items.push(['aperture', 'paste aperture']);
if (mod.balls.length) items.push(['ball', `solder ball × ${mod.balls.length}`]);
items.push(['silk', 'silkscreen']);
if (!isBaseline) items.push(['diff', 'changed vs baseline']);
this.shadowRoot.querySelector('[data-legend]').innerHTML =
items.map(([c, t]) => `<span><i class="sw ${c}"></i>${esc(t)}</span>`).join('');
}
_setReadout(el) {
const ro = this.shadowRoot.querySelector('[data-readout]');
if (!ro) return;
ro.innerHTML = el ? `<b>${esc(el.dataset.info)}</b>` : 'hover a pad →';
}
_wireStage(mod) {
const root = this.shadowRoot;
root.querySelectorAll('.pad[data-pad]').forEach(g => {
g.onmouseenter = () => { this._setReadout(g); this._hl(g.dataset.pad); this._emitBus(g.dataset.pad); };
g.onmouseleave = () => { this._setReadout(null); this._hl(''); this._emitBus(null); };
});
}
_hl(n) {
this.shadowRoot.querySelectorAll('.pad[data-pad]').forEach(g =>
g.classList.toggle('hot', n !== '' && g.dataset.pad === String(n)));
}
/** Public: drive the highlight from outside (e.g. the pinout widget's hover). */
highlight(n) { this._hl(n == null ? '' : n); }
/** Public: the variant manifest with live geometry counts — the structured handle. */
toJSON() {
const d = this._data || {};
return {
...(d.part ? { part: d.part } : {}), ...(d.package ? { package: d.package } : {}),
variants: (d.variants || []).map(v => ({ suffix: v.suffix, device: v.device, kicad_mod: v.kicad_mod, ...(v.changed ? { changed: v.changed } : {}) })),
};
}
_exportJson() { return JSON.stringify(this.toJSON(), null, 2); }
_download() {
// data: URL, not blob: — the wiki widget frame's CSP has no blob:.
const a = document.createElement('a');
a.href = 'data:application/json;charset=utf-8,' + encodeURIComponent(this._exportJson());
a.download = ((this._data && this._data.part) || 'footprint') + '.variants.json';
document.body.appendChild(a); a.click(); a.remove();
}
async _copy(btn) {
const txt = this._exportJson(), done = () => { const o = btn.textContent; btn.textContent = 'copied'; btn.classList.add('ok'); setTimeout(() => { btn.textContent = o; btn.classList.remove('ok'); }, 1100); };
try { await navigator.clipboard.writeText(txt); done(); return; } catch (e) { /* fall back */ }
try { const ta = document.createElement('textarea'); ta.value = txt; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); done(); }
catch (e2) { this._download(); }
}
// adom-lbr shared cross-highlight bus (same protocol as adom-pinout).
_busInit() {
this._self = 'w' + Math.random().toString(36).slice(2);
this._bus = ('BroadcastChannel' in window) ? new BroadcastChannel('lbr-hover') : null;
const on = e => { const d = e.data || {}; if (d.type === 'lbr-hover' && d.src !== this._self) this._hl(d.pin == null ? '' : d.pin); };
if (this._bus) this._bus.onmessage = on;
this._winmsg = on; window.addEventListener('message', on);
}
disconnectedCallback() { try { this._bus && this._bus.close(); } catch (_) { /*noop*/ } if (this._winmsg) window.removeEventListener('message', this._winmsg); }
_emitBus(n) {
const m = { type: 'lbr-hover', pin: (n === '' || n == null) ? null : String(n), mpn: (this._data && this._data.part) || '', src: this._self };
try { this._bus && this._bus.postMessage(m); } catch (_) { /*noop*/ }
// parent relay only from the wiki's sandboxed widget frame; inline, BroadcastChannel already reaches every widget
try { if (/^\/[^/]+\/[^/]+\/widget\/[^/]+\/?$/.test(location.pathname)) window.parent.postMessage(m, '*'); } catch (_) { /*noop*/ }
}
_css() {
return `
:host{display:block;
--bg:#0d1420;--panel:#121a26;--line:#26324a;--line2:#33425f;--text:#dbe2ec;--dim:#8b97ac;--dim2:#66738c;--accent:#00e6dc;
--board:#173a24;--gold:#d6b25e;--gold2:#a8873e;--copper:#c98a4b;--ball:#c3ccd6;--silk:#e8ecf2}
@media (prefers-color-scheme:light){:host{--bg:#f6f8fc;--panel:#fff;--line:#dbe2ee;--line2:#c3cede;--text:#1a2230;--dim:#5a6678;--dim2:#8592a6;--accent:#0d8f88;
--board:#1d4a2e;--silk:#f2f5f8}}
:host([theme="dark"]){--bg:#0d1420;--panel:#121a26;--line:#26324a;--line2:#33425f;--text:#dbe2ec;--dim:#8b97ac;--dim2:#66738c;--accent:#00e6dc}
:host([theme="light"]){--bg:#f6f8fc;--panel:#fff;--line:#dbe2ee;--line2:#c3cede;--text:#1a2230;--dim:#5a6678;--dim2:#8592a6;--accent:#0d8f88}
*{box-sizing:border-box}
.card{background:var(--bg);border:1px solid var(--line);border-radius:14px;padding:16px;color:var(--text);
font:14px/1.5 -apple-system,'Segoe UI',Roboto,Arial,sans-serif;container-type:inline-size}
.hd{display:flex;align-items:baseline;gap:10px;margin-bottom:10px;flex-wrap:wrap}
.hd .mpn{font:700 17px ui-monospace,'SF Mono',Menlo,monospace;letter-spacing:-.2px}
.hd .pkg{font:600 11px ui-monospace,monospace;color:var(--accent);background:color-mix(in srgb,var(--accent) 12%,transparent);border:1px solid color-mix(in srgb,var(--accent) 30%,transparent);padding:2px 8px;border-radius:6px}
.hd .count{margin-left:auto;font:12px ui-monospace,monospace;color:var(--dim2)}
.hd .tools{display:flex;gap:6px;align-self:center}
.tbtn{font:600 10.5px ui-monospace,monospace;letter-spacing:.03em;color:var(--dim);background:var(--panel);
border:1px solid var(--line);border-radius:6px;padding:3px 8px;cursor:pointer;white-space:nowrap;transition:color .12s,border-color .12s}
.tbtn:hover{color:var(--text);border-color:var(--line2)} .tbtn.ok{color:var(--accent);border-color:color-mix(in srgb,var(--accent) 45%,transparent)}
.chips{display:flex;flex-wrap:wrap;gap:7px;margin-bottom:10px}
.chip{font:600 12px ui-monospace,monospace;color:var(--dim);background:var(--panel);border:1px solid var(--line);
border-radius:999px;padding:4px 12px;cursor:pointer;transition:color .12s,border-color .12s,background .12s}
.chip:hover{color:var(--text);border-color:var(--line2)}
.chip.on{color:var(--accent);border-color:color-mix(in srgb,var(--accent) 55%,transparent);background:color-mix(in srgb,var(--accent) 10%,transparent)}
.readout{height:24px;display:flex;align-items:center;font:600 13px ui-monospace,monospace;color:var(--dim);padding:0 4px;margin-bottom:6px}
.readout b{color:var(--accent);font-weight:600}
.stage{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:10px}
svg.fp{width:100%;height:auto;display:block;max-height:520px}
.board{fill:var(--board);stroke:color-mix(in srgb,var(--board) 60%,#000);stroke-width:0.05}
.cu{fill:var(--gold);stroke:var(--gold2);stroke-width:0.02}
.pad{cursor:default}
.pad:hover .cu,.pad.hot .cu{fill:#f0d489;stroke:var(--accent);stroke-width:0.06}
.pad.diff .cu{stroke:var(--accent);stroke-width:0.05}
.via .ring{fill:var(--copper);stroke:color-mix(in srgb,var(--copper) 60%,#000);stroke-width:0.02}
.via .hole{fill:#0b0e11}
.via:hover .ring,.via.hot .ring{fill:#e6a866;stroke:var(--accent);stroke-width:0.06}
.via.diff .ring{stroke:var(--accent);stroke-width:0.05}
.aperture{fill:none;stroke:color-mix(in srgb,var(--silk) 55%,transparent);stroke-width:0.03;stroke-dasharray:0.08 0.06;pointer-events:none}
.ball{fill:var(--ball);fill-opacity:.9;pointer-events:none}
.silk{stroke:var(--silk);fill:none;color:var(--silk)} circle.silk{fill:var(--silk)}
.fab{stroke:color-mix(in srgb,var(--silk) 30%,transparent);fill:none;color:transparent}
.crtyd{stroke:color-mix(in srgb,var(--accent) 35%,transparent);stroke-dasharray:0.12 0.12;fill:none}
.pin1{fill:var(--accent)}
.changed{font:12px/1.5 ui-monospace,monospace;color:var(--dim);background:color-mix(in srgb,var(--accent) 6%,transparent);
border:1px solid color-mix(in srgb,var(--accent) 18%,transparent);border-radius:8px;padding:7px 10px;margin-top:10px}
.changed[hidden]{display:none}
.legend{display:flex;flex-wrap:wrap;gap:13px;margin:9px 2px 0;font-size:11.5px;color:var(--dim)}
.legend .sw{display:inline-block;width:10px;height:10px;border-radius:3px;margin-right:5px;vertical-align:middle}
.legend .sw.cu{background:var(--gold)} .legend .sw.ring{background:var(--copper)}
.legend .sw.ball{background:var(--ball);border-radius:50%} .legend .sw.silk{background:var(--silk)}
.legend .sw.diff{background:transparent;border:1.6px solid var(--accent)}
.legend .sw.aperture{background:transparent;border:1.4px dashed color-mix(in srgb,var(--silk) 70%,transparent)}
.empty{color:var(--dim2);padding:30px;text-align:center;font-family:ui-monospace,monospace}`;
}
}
if (!customElements.get('adom-footprint-variants')) customElements.define('adom-footprint-variants', AdomFootprintVariants);
})();