master
Colby Knox Widgets: safe to render inline in a wiki README (adom/wiki#229) 4408302 7d ago
/*!
 * <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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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&nbsp;↓</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&nbsp;→';
    }

    _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);
})();