master
John Lauer 1.1.13: balanced grouped 2-column, 15 mm icon lane, body-centred designators, stroke_mm; widget gains a parity-tested JS engine for CSP-blocked wasm 47c81ac 1mo ago
/*!
 * <adom-symbol-variants> : alternate schematic-symbol layouts for a wiki component page.
 *
 *   ::adom-symbol-variants{src="/ray/ch340g/render/page.json"}
 *
 * Attributes:
 *   src      page.json of a component page (wiki render route). The widget reads
 *            component.mpn / parts.symbol / pins[] from it and fetches the default
 *            .kicad_sym from the same page. May also point straight at a .kicad_sym.
 *   mpn      override the part name shown / used as the Value.
 *   layout   initial layout: 2c | 2cg | sq | sqg  (default 2cg)
 *   outline  initial chip-outline size: off | small | medium | large (default large
 *            when the page has a <mpn>-3d-outline-*-named.svg, else off)
 *
 * The generator is adom-symbol's own `sym_gen` (Rust) compiled to wasm32 and baked
 * into this file, so the browser produces byte-identical .kicad_sym to the CLI:
 * grouping, DIP / QFP walks, square layouts, the text-free rectangle, Reference /
 * Value placement, the chip-outline fit. Rendering is a small SVG renderer here.
 * Nothing is written to the wiki: users download the variant, or copy the CLI
 * recipe to regenerate the whole set in their own Hydrogen container.
 */
(() => {
  const WASM_B64 = "__WASM_B64__";
  const VERSION = "__VERSION__";

  // ── wasm generator ──────────────────────────────────────────────────────
  let wasmP = null;
  function wasm() {
    if (!wasmP) {
      const bin = Uint8Array.from(atob(WASM_B64), c => c.charCodeAt(0));
      wasmP = WebAssembly.instantiate(bin, {}).then(r => r.instance);
    }
    return wasmP;
  }
  /*__SYMGEN_JS__*/
  let engine = 'wasm';
  async function generateSym(input) {
    if (engine === 'js') return generateJs(input);
    try { return await generateWasm(input); }
    catch (e) {
      // The wiki widget frame's CSP has no 'wasm-unsafe-eval' yet (adom/wiki#163):
      // fall back to the JS port of the same generator (parity-tested each build).
      if (!(e && /WebAssembly|CSP|Content Security/i.test(String(e.message || e)))) throw e;
      engine = 'js';
      return generateJs(input);
    }
  }
  function generateJs(input) {
    const text = (root_AdomSymGen()).generateFromCreateJson(input);
    if (text.startsWith("ERROR:")) throw new Error(text);
    return text;
  }
  function root_AdomSymGen() { return (typeof AdomSymGen !== 'undefined') ? AdomSymGen : globalThis.AdomSymGen; }
  async function generateWasm(input) {
    const inst = await wasm();
    const ex = inst.exports;
    const enc = new TextEncoder().encode(JSON.stringify(input));
    const inPtr = ex.ab_alloc(enc.length);
    new Uint8Array(ex.memory.buffer, inPtr, enc.length).set(enc);
    const outPtr = ex.ab_generate(inPtr, enc.length);
    const len = new DataView(ex.memory.buffer).getUint32(outPtr, true);
    const text = new TextDecoder().decode(new Uint8Array(ex.memory.buffer, outPtr + 4, len));
    ex.ab_free(outPtr, len + 4);
    ex.ab_free(inPtr, enc.length);
    if (text.startsWith("ERROR:")) throw new Error(text);
    return text;
  }

  // ── .kicad_sym parsing (vendor or generated) ───────────────────────────
  const RE_PIN = /\(pin\s+(\w+)\s+\w+\s*\(at\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\)\s*\(length\s+(-?[\d.]+)\)[\s\S]*?\(name\s+"((?:[^"\\]|\\.)*)"[\s\S]*?\(number\s+"((?:[^"\\]|\\.)*)"/g;
  function parsePins(text) {
    const pins = [];
    const seen = new Set();
    for (const m of text.matchAll(RE_PIN)) {
      const num = m[7];
      if (seen.has(num)) continue;
      seen.add(num);
      pins.push({ type: m[1], x: +m[2], y: +m[3], angle: +m[4], length: +m[5], name: m[6], number: num });
    }
    return pins;
  }
  function prop(text, name) {
    const re = new RegExp('\\(property\\s+"' + name + '"\\s+"((?:[^"\\\\]|\\\\.)*)"\\s*\\(at\\s+(-?[\\d.]+)\\s+(-?[\\d.]+)\\s+(-?[\\d.]+)\\)([\\s\\S]*?)\\)\\s*\\)', '');
    const m = re.exec(text);
    if (!m) return null;
    const eff = m[5] || '';
    const just = /\(justify\s+(left|right)/.exec(eff);
    return { value: m[1], x: +m[2], y: +m[3], rot: +m[4], hidden: /\bhide\b|\(hide yes\)/.test(eff), justify: just ? just[1] : 'center' };
  }
  function parseRects(text) {
    const out = [];
    for (const m of text.matchAll(/\(rectangle\s*\(start\s+(-?[\d.]+)\s+(-?[\d.]+)\)\s*\(end\s+(-?[\d.]+)\s+(-?[\d.]+)\)/g))
      out.push({ x0: Math.min(+m[1], +m[3]), y0: Math.min(+m[2], +m[4]), x1: Math.max(+m[1], +m[3]), y1: Math.max(+m[2], +m[4]) });
    return out.sort((a, b) => (b.x1 - b.x0) * (b.y1 - b.y0) - (a.x1 - a.x0) * (a.y1 - a.y0));
  }
  function parseTexts(text) {
    const out = [];
    for (const m of text.matchAll(/\(text\s+"((?:[^"\\]|\\.)*)"\s*\(at\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\)\s*\(effects\s*\(font\s*\(size\s+(-?[\d.]+)[\s\S]*?\)(?:[\s\S]*?\(justify\s+(left|right)\))?/g))
      out.push({ text: m[1], x: +m[2], y: +m[3], rot: +m[4], size: +m[5], justify: m[6] || 'center' });
    return out;
  }
  function pinSortKey(n) { const v = parseInt(n, 10); return isNaN(v) ? [1, n] : [0, v]; }
  function sortPackageOrder(pins) {
    return pins.slice().sort((a, b) => {
      const ka = pinSortKey(a.number), kb = pinSortKey(b.number);
      return ka[0] - kb[0] || (ka[0] === 0 ? ka[1] - kb[1] : String(ka[1]).localeCompare(String(kb[1])));
    });
  }

  // ── group heuristics (used when the page carries no groups) ───────────
  const RULES = [
    [/^(VDD|VCC|VSS|GND|V3|VBAT|VDDA|VSSA|VREF\+?|VREF-|AVDD|AVSS|DVDD|DVSS|VIN|VOUT|V\+|V-|PVDD|PGND|AGND|DGND|VCAP\d*|VLCD|VDDUSB|VDDIO2?|SW|BST|BOOT|LX|FB|EN|COMP|SS|PG|PGOOD|EP|PAD)$/i, 'Power'],
    [/^(RTS|CTS|DSR|DTR|DCD|RI)$/i, 'Handshake'],
    [/^(TXD?|RXD?|UART|USART)/i, 'UART'],
    [/^(R232|CFG|CONFIG|MODE|SEL)/i, 'Config'],
    [/^(UD\+|UD-|D\+|D-|DP|DM|USB|VBUS)/i, 'USB'],
    [/^(XI|XO|XTAL|OSC|X1|X2|OSC_IN|OSC_OUT|PC14|PC15|PH0|PH1)/i, 'Crystal'],
    [/^(NRST|RST|RESET|BOOT0|BOOT1|NC|SWDIO|SWCLK|SWO|JTAG|TMS|TCK|TDI|TDO)/i, 'Control'],
    [/^(SCL|SDA|I2C)/i, 'I2C'],
    [/^(MOSI|MISO|SCK|CS|NSS|SPI)/i, 'SPI'],
    [/^(PA\d)/i, 'Port A'], [/^(PB\d)/i, 'Port B'], [/^(PC\d)/i, 'Port C'],
    [/^(PD\d)/i, 'Port D'], [/^(PE\d)/i, 'Port E'], [/^(PH\d)/i, 'Port H'],
    [/^(ADC|AIN|AN\d)/i, 'Analog'],
  ];
  function plainName(n) { return n.replace(/~\{([^}]*)\}/g, '$1').replace(/#$/, ''); }
  function groupFor(name, type) {
    const n = plainName(name);
    for (const [re, g] of RULES) if (re.test(n)) return g;
    if (type === 'power_in' || type === 'power_out') return 'Power';
    return 'IO';
  }

  // ── free-rect + chip-outline fit (same constants as sym_gen.rs / render.rs) ──
  const NAME_CHAR_W = 1.5, NAME_EXT_OFFSET = 1.016 + 0.6, LABEL_CHAR_W = 0.9, LABEL_INSET = 1.02;
  const OUTLINE_BODY_FRAC = 0.62;
  function freeRect(body, pins, texts) {
    let l = body.x0, b = body.y0, r = body.x1, t = body.y1;
    for (const p of pins) {
      const n = plainName(p.name).length; if (!n) continue;
      const ext = n * NAME_CHAR_W + NAME_EXT_OFFSET;
      const a = ((p.angle % 360) + 360) % 360;
      if (a === 0) l = Math.max(l, body.x0 + ext);
      else if (a === 180) r = Math.min(r, body.x1 - ext);
      else if (a === 270) t = Math.min(t, body.y1 - ext);
      else if (a === 90) b = Math.max(b, body.y0 + ext);
    }
    for (const x of texts) {
      if (x.justify === 'center') continue;
      const ext = x.text.length * LABEL_CHAR_W + LABEL_INSET;
      if (x.rot === 0 && x.justify === 'left') l = Math.max(l, body.x0 + ext);
      else if (x.rot === 0 && x.justify === 'right') r = Math.min(r, body.x1 - ext);
      else if (x.rot === 900 && x.justify === 'right') t = Math.min(t, body.y1 - ext);
      else if (x.rot === 900 && x.justify === 'left') b = Math.max(b, body.y0 + ext);
    }
    if (r - l < 2 || t - b < 2) return { l: body.x0, b: body.y0, r: body.x1, t: body.y1 };
    return { l, b, r, t };
  }
  function iconBox(body, free, ref, val, frac) {
    const f = { ...free };
    for (const d of [ref, val]) {
      if (!d || d.hidden) continue;
      if (d.x > body.x0 && d.x < body.x1 && d.y > body.y0 && d.y < body.y1) {
        const cy = (f.b + f.t) / 2;
        if (d.y >= cy) f.t = Math.min(f.t, d.y - 1.3); else f.b = Math.max(f.b, d.y + 1.3);
      }
    }
    const M = ICON_MARGIN;
    const fw = Math.max(1, f.r - f.l - 2 * M), fh = Math.max(1, f.t - f.b - 2 * M);
    const w = Math.min((body.x1 - body.x0) * frac, fw), h = Math.min((body.y1 - body.y0) * frac, fh);
    const bcx = (body.x0 + body.x1) / 2;
    const cx = (bcx - w / 2 >= free.l + M && bcx + w / 2 <= free.r - M) ? bcx : (f.l + f.r) / 2;
    return { cx, cy: (f.b + f.t) / 2, w, h };
  }
  const MIN_ICON_MM = 15.0, ICON_MARGIN = 1.0;
  const FRAC = { large: OUTLINE_BODY_FRAC, medium: OUTLINE_BODY_FRAC * 0.78, small: OUTLINE_BODY_FRAC * 0.58 };

  // ── SVG renderer (Adom dark theme, mm units, Y flipped) ────────────────
  const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
  const C = { body: '#1a2332', bodyStroke: '#3d4654', wire: '#7d8796', name: '#e6edf3', num: '#9fb0c8', label: '#8C6BF7', ref: '#8b949e', value: '#e6edf3', centred: '#646464', outline: '#e6edf3' };
  function kicadName(n) {
    // ~{X} -> overline
    const parts = [];
    let i = 0; const re = /~\{([^}]*)\}/g; let m;
    while ((m = re.exec(n))) { if (m.index > i) parts.push([n.slice(i, m.index), false]); parts.push([m[1], true]); i = m.index + m[0].length; }
    if (i < n.length) parts.push([n.slice(i), false]);
    return parts.map(([t, ov]) => ov ? `<tspan text-decoration="overline">${esc(t)}</tspan>` : esc(t)).join('');
  }
  function renderSvg(sym, opts) {
    const pins = parsePins(sym);
    const rects = parseRects(sym);
    const texts = parseTexts(sym);
    const ref = prop(sym, 'Reference'), val = prop(sym, 'Value');
    const body = rects[0] || null;
    const F = 1.27, FL = 0.762, SW = 0.2;
    let g = '';
    let minx = Infinity, miny = Infinity, maxx = -Infinity, maxy = -Infinity;
    const bump = (x, y) => { minx = Math.min(minx, x); maxx = Math.max(maxx, x); miny = Math.min(miny, -y); maxy = Math.max(maxy, -y); };
    if (body) {
      g += `<rect x="${body.x0}" y="${-body.y1}" width="${body.x1 - body.x0}" height="${body.y1 - body.y0}" rx="0.4" fill="${C.body}" stroke="${C.bodyStroke}" stroke-width="0.25"/>`;
      bump(body.x0, body.y0); bump(body.x1, body.y1);
    }
    // chip outline
    if (opts.outline && body && opts.outlineSize !== 'off') {
      const fr = freeRect(body, pins, texts);
      const ib = iconBox(body, fr, ref, val, FRAC[opts.outlineSize] || FRAC.large);
      // Same rule as the CLI: an icon under 0.6 x MIN_ICON_MM would draw as a blob; omit it.
      if (Math.min(ib.w, ib.h) >= MIN_ICON_MM * 0.6) {
        const href = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(opts.outline)));
        g += `<image x="${ib.cx - ib.w / 2}" y="${-ib.cy - ib.h / 2}" width="${ib.w}" height="${ib.h}" preserveAspectRatio="xMidYMid meet" opacity="0.96" href="${href}"/>`;
      }
    }
    for (const x of texts) {
      const isLabel = x.size < 1;
      const col = isLabel ? C.label : C.centred;
      const anchor = x.justify === 'left' ? 'start' : x.justify === 'right' ? 'end' : 'middle';
      const tf = x.rot === 900 ? ` transform="rotate(-90 ${x.x} ${-x.y})"` : '';
      g += `<text x="${x.x}" y="${-x.y}" font-size="${x.size}" font-weight="${isLabel ? 700 : 400}" fill="${col}" text-anchor="${anchor}" dominant-baseline="central"${tf}>${esc(x.text)}</text>`;
    }
    for (const p of pins) {
      const a = ((p.angle % 360) + 360) % 360;
      const rad = a * Math.PI / 180, dx = Math.round(Math.cos(rad)), dy = Math.round(Math.sin(rad));
      const ex = p.x + dx * p.length, ey = p.y + dy * p.length;
      g += `<line x1="${p.x}" y1="${-p.y}" x2="${ex}" y2="${-ey}" stroke="${C.wire}" stroke-width="${SW}"/>`;
      g += `<circle cx="${p.x}" cy="${-p.y}" r="0.25" fill="${C.wire}"/>`;
      bump(p.x, p.y); bump(ex, ey);
      const nx = ex + dx * 1.016, ny = ey + dy * 1.016;
      const mx = (p.x + ex) / 2, my = (p.y + ey) / 2;
      if (dy === 0) {
        g += `<text x="${nx}" y="${-ny}" font-size="${F}" fill="${C.name}" text-anchor="${dx > 0 ? 'start' : 'end'}" dominant-baseline="central">${kicadName(p.name)}</text>`;
        g += `<text x="${mx}" y="${-my - 0.45}" font-size="${F}" fill="${C.num}" text-anchor="middle">${esc(p.number)}</text>`;
        bump(p.x - (dx < 0 ? 0 : 2), p.y + 1.2); bump(p.x, p.y - 1.2);
      } else {
        // vertical: text reads bottom-to-top; top pins (a=270) anchor at the top end.
        g += `<text x="${nx}" y="${-ny}" font-size="${F}" fill="${C.name}" text-anchor="${dy < 0 ? 'end' : 'start'}" dominant-baseline="central" transform="rotate(-90 ${nx} ${-ny})">${kicadName(p.name)}</text>`;
        g += `<text x="${mx - 0.45}" y="${-my}" font-size="${F}" fill="${C.num}" text-anchor="middle" transform="rotate(-90 ${mx - 0.45} ${-my})">${esc(p.number)}</text>`;
        bump(p.x - 1.4, p.y); bump(p.x + 1.4, p.y);
      }
    }
    for (const [d, col] of [[ref, C.ref], [val, C.value]]) {
      if (!d || d.hidden) continue;
      const anchor = d.justify === 'left' ? 'start' : d.justify === 'right' ? 'end' : 'middle';
      g += `<text x="${d.x}" y="${-d.y}" font-size="${F}" fill="${col}" text-anchor="${anchor}" dominant-baseline="central">${esc(d.value)}</text>`;
      const w = d.value.length * NAME_CHAR_W;
      bump(d.x - (anchor === 'middle' ? w / 2 : anchor === 'end' ? w : 0), d.y + 0.9);
      bump(d.x + (anchor === 'middle' ? w / 2 : anchor === 'start' ? w : 0), d.y - 0.9);
    }
    if (!isFinite(minx)) { minx = -10; miny = -10; maxx = 10; maxy = 10; }
    const M = 3;
    const vb = `${minx - M} ${miny - M} ${maxx - minx + 2 * M} ${maxy - miny + 2 * M}`;
    return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${vb}" font-family="'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,monospace">${g}</svg>`;
  }

  // ── the element ─────────────────────────────────────────────────────────
  const LAYOUTS = [
    ['2c', '2-column', 'left-right', false],
    ['2cg', '2-column grouped', 'left-right', true],
    ['sq', 'Square', 'square', false],
    ['sqg', 'Square grouped', 'square-grouped', true],
  ];
  const SIZES = ['off', 'small', 'medium', 'large'];
  const CSS = `
    :host{display:block;--bg:#0d1117;--panel:#11161f;--line:#222b38;--text:#e6edf3;--dim:#8b949e;--accent:#00e6dc;--purple:#8C6BF7;font:14px 'Satoshi',-apple-system,'Segoe UI',Roboto,sans-serif;color:var(--text)}
    *{box-sizing:border-box}
    .card{background:var(--panel);border:1px solid var(--line);border-radius:12px;overflow:hidden}
    .hd{display:flex;align-items:center;gap:10px;height:44px;padding:0 14px;border-bottom:1px solid var(--line)}
    .hd .dot{width:8px;height:8px;border-radius:50%;background:var(--accent);box-shadow:0 0 10px var(--accent)}
    .hd .t{font-weight:700;letter-spacing:.2px}
    .hd .s{color:var(--dim);font:12px 'JetBrains Mono',ui-monospace,monospace}
    .hd .sp{flex:1}
    .hd .v{color:var(--dim);font:11px 'JetBrains Mono',ui-monospace,monospace}
    .ctl{display:flex;flex-wrap:wrap;gap:10px 18px;padding:10px 14px;border-bottom:1px solid var(--line)}
    .grp{display:flex;flex-direction:column;gap:5px}
    .grp .k{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--dim)}
    .seg{display:inline-flex;border:1px solid var(--line);border-radius:8px;overflow:hidden}
    .seg button{background:transparent;border:0;color:var(--dim);padding:6px 10px;font:inherit;font-size:13px;cursor:pointer;min-height:32px}
    .seg button+button{border-left:1px solid var(--line)}
    .seg button.on{background:#1a2332;color:var(--text)}
    .seg button:disabled{opacity:.35;cursor:not-allowed}
    .tog{display:inline-flex;align-items:center;gap:8px;cursor:pointer;min-height:32px;font-size:13px}
    .tog input{accent-color:var(--accent);width:16px;height:16px}
    .stage{background:var(--bg);background-image:linear-gradient(#ffffff08 1px,transparent 1px),linear-gradient(90deg,#ffffff08 1px,transparent 1px);background-size:20px 20px;padding:12px;min-height:220px;display:flex;align-items:center;justify-content:center}
    .stage svg{width:100%;max-height:520px;height:auto;display:block}
    .ft{display:flex;flex-wrap:wrap;align-items:center;gap:8px;padding:10px 14px;border-top:1px solid var(--line)}
    .ft .d{color:var(--dim);font-size:12px;flex:1;min-width:180px}
    .btn{background:#1a2332;border:1px solid var(--line);color:var(--text);border-radius:8px;padding:7px 12px;font:inherit;font-size:13px;cursor:pointer;min-height:32px}
    .btn.pri{border-color:var(--accent);color:var(--accent)}
    .btn:hover{border-color:#485162}
    .msg{padding:14px;color:var(--dim);font-size:13px}
    .err{color:#ef5350}
    pre{margin:0;padding:10px 14px;background:#0d1117;border-top:1px solid var(--line);color:var(--dim);font:11px 'JetBrains Mono',ui-monospace,monospace;white-space:pre-wrap;word-break:break-all;max-height:180px;overflow:auto}
    .hint{color:var(--dim);font-size:11px}
    .toast{position:absolute;left:50%;bottom:14px;transform:translateX(-50%);background:#1a2332;border:1px solid var(--accent);color:var(--text);padding:7px 12px;border-radius:8px;font-size:12px;opacity:0;transition:opacity .2s;pointer-events:none}
    .toast.on{opacity:1}
    .wrap{position:relative}`;

  class AdomSymbolVariants extends HTMLElement {
    static get observedAttributes() { return ['src', 'mpn', 'layout', 'outline']; }
    constructor() { super(); this.state = { layout: '2cg', outline: 'off', centred: false }; }
    connectedCallback() {
      if (this._init) return; this._init = true;
      this.attachShadow({ mode: 'open' });
      const l = this.getAttribute('layout'); if (l && LAYOUTS.some(x => x[0] === l)) this.state.layout = l;
      this._shell('Loading part data');
      this._load().catch(e => this._shell('Could not load: ' + (e && e.message || e), true));
    }
    _shell(msg, err) { this.shadowRoot.innerHTML = `<style>${CSS}</style><div class="card"><div class="hd"><span class="dot"></span><span class="t">Symbol variants</span><span class="sp"></span><span class="v">adom-symbol ${VERSION}</span></div><div class="msg${err ? ' err' : ''}">${esc(msg)}</div></div>`; }
    async _load() {
      const src = this.getAttribute('src') || '';
      if (!src) throw new Error('src attribute required (a page.json render URL)');
      const abs = new URL(src, location.href);
      const m = /^(.*?\/render)\//.exec(abs.pathname);
      const base = m ? abs.origin + m[1] + '/' : abs.href.replace(/[^/]*$/, '');
      let page = null, symUrl = abs.href, mpn = this.getAttribute('mpn') || '';
      if (/\.json$/i.test(abs.pathname)) {
        page = await (await fetch(abs.href)).json();
        const c = page.component || page;
        mpn = mpn || c.mpn || page.title || page.slug || '';
        const symFile = (c.parts && c.parts.symbol) || null;
        symUrl = base + (symFile || (mpn.toLowerCase() + '.kicad_sym'));
        this.pageMeta = { manufacturer: c.manufacturer || page.manufacturer || '', package: c.package || '', description: page.brief || page.description || c.description || '', datasheet: c.datasheet || '', pins: c.pins || [] };
      } else {
        this.pageMeta = { manufacturer: '', package: '', description: '', datasheet: '', pins: [] };
      }
      const symText = await (await fetch(symUrl)).text();
      const pins = sortPackageOrder(parsePins(symText));
      if (!pins.length) throw new Error('no pins found in ' + symUrl);
      if (!mpn) { const v = prop(symText, 'Value'); mpn = v ? v.value : 'SYMBOL'; }
      this.mpn = mpn; this.defaultSym = symText; this.pins = pins; this.base = base;
      const pkg = this.pageMeta.package || (prop(symText, 'Footprint') || { value: '' }).value.split(':').pop();
      this.pageMeta.package = pkg;
      // pin groups: page.json groups > name heuristics
      const byNum = new Map(this.pageMeta.pins.map(p => [String(p.number), p]));
      for (const p of pins) { const pp = byNum.get(String(p.number)); p.group = (pp && pp.group) || groupFor(p.name, p.type); }
      // chip outline asset (optional)
      this.outline = null;
      for (const style of ['heavy', 'dark', 'hairline', 'teal', 'bold']) {
        try {
          const r = await fetch(base + mpn + '-3d-outline-' + style + '-named.svg');
          if (r.ok) { const t = await r.text(); if (/<svg/i.test(t)) { this.outline = t; break; } }
        } catch (_) { /* none */ }
      }
      const o = this.getAttribute('outline');
      this.state.outline = this.outline ? (SIZES.includes(o) ? o : 'large') : 'off';
      await this._render();
    }
    _input() {
      const L = LAYOUTS.find(x => x[0] === this.state.layout) || LAYOUTS[1];
      const suffix = L[0].toUpperCase() + (this.state.centred ? 'C' : '');
      return {
        symbolName: this.mpn + '-' + suffix,
        manufacturer: this.pageMeta.manufacturer, package: this.pageMeta.package,
        description: this.pageMeta.description, datasheetUrl: this.pageMeta.datasheet,
        value: this.mpn, category: 'ic',
        pins: this.pins.map(p => ({ name: p.name, number: p.number, type: p.type, ...(L[3] ? { group: p.group } : {}) })),
        preferences: { pinLayout: L[2], chipNameCentered: this.state.centred },
      };
    }
    async _render() {
      const input = this._input();
      let sym, svg, err = '';
      try { sym = await generateSym(input); svg = renderSvg(sym, { outline: this.outline, outlineSize: this.state.outline }); }
      catch (e) { err = String(e && e.message || e); }
      this.sym = sym; this.input = input;
      const s = this.state;
      const seg = (name, items, cur, dis) => `<div class="seg" data-seg="${name}">${items.map(([v, lab]) => `<button data-v="${v}" class="${v === cur ? 'on' : ''}" ${dis ? 'disabled' : ''}>${lab}</button>`).join('')}</div>`;
      const nPins = this.pins.length;
      this.shadowRoot.innerHTML = `<style>${CSS}</style><div class="card wrap">
        <div class="hd"><span class="dot"></span><span class="t">Symbol variants</span><span class="s">${esc(this.mpn)} · ${nPins} pins${this.pageMeta.package ? ' · ' + esc(this.pageMeta.package) : ''}</span><span class="sp"></span><span class="v">adom-symbol ${VERSION} · ${engine}</span></div>
        <div class="ctl">
          <div class="grp"><span class="k">Layout</span>${seg('layout', LAYOUTS.map(x => [x[0], x[1]]), s.layout)}</div>
          <div class="grp"><span class="k">Chip outline</span>${seg('outline', SIZES.map(v => [v, v[0].toUpperCase() + v.slice(1)]), s.outline, !this.outline)}${this.outline ? '' : '<span class="hint">no 3D outline on this page yet (run adom-chip-thumbnailer)</span>'}</div>
          <div class="grp"><span class="k">Name</span><label class="tog"><input type="checkbox" id="centred" ${s.centred ? 'checked' : ''}> Centred in body</label></div>
        </div>
        <div class="stage">${err ? `<div class="msg err">${esc(err)}</div>` : svg}</div>
        <div class="ft">
          <span class="d">${esc(input.symbolName)}: ${esc(descNote(s, this.pins))}. Generated in your browser by the same engine as the adom-symbol CLI; the default symbol on this page is untouched.</span>
          <button class="btn pri" id="dl">Download .kicad_sym</button>
          <button class="btn" id="dlj">Download create JSON</button>
          <button class="btn" id="recipe">CLI recipe</button>
        </div>
        <pre id="pre" hidden></pre>
        <div class="toast" id="toast"></div>
      </div>`;
      const root = this.shadowRoot;
      root.querySelectorAll('.seg button').forEach(b => b.addEventListener('click', () => {
        const k = b.closest('.seg').dataset.seg; this.state[k] = b.dataset.v; this._render();
      }));
      root.getElementById('centred').addEventListener('change', e => { this.state.centred = e.target.checked; this._render(); });
      root.getElementById('dl').addEventListener('click', () => this._download(input.symbolName + '.kicad_sym', sym || ''));
      root.getElementById('dlj').addEventListener('click', () => this._download(input.symbolName + '.json', JSON.stringify(input, null, 1)));
      root.getElementById('recipe').addEventListener('click', () => {
        const pre = root.getElementById('pre');
        pre.hidden = !pre.hidden;
        pre.textContent = this._recipe();
      });
    }
    _recipe() {
      const i = this.input;
      return [
        '# Regenerate this variant (or the whole set) in your Hydrogen container:',
        'adom-wiki pkg install adom/adom-symbol',
        `adom-wiki repo clone ${this._pageRef()} --dir ${this.mpn}`,
        `cd ${this.mpn}`,
        `# create input = the "Download create JSON" file, saved as ${i.symbolName}.json`,
        `adom-symbol create --file ${i.symbolName}.json --out symbols/`,
        `# pixel-exact KiCad render + outline contract for adom-lbr:`,
        `adom-symbol render --file symbols/${i.symbolName}/${i.symbolName}.kicad_sym --out symbols/${i.symbolName}.svg${this.outline ? ` --iso ${this.mpn}-3d-outline-heavy-named.svg` : ''}`,
        `# Fusion/EAGLE library with every variant as one component:`,
        `adom-lbr generate --sym ${this._defaultSymName()} --alt-sym symbols/${i.symbolName}/${i.symbolName}.kicad_sym --output ${this.mpn}.lbr`,
        `# then: adom-wiki pr create ${this._pageRef()} ...  (additive: symbols/ only)`,
      ].join('\n');
    }
    _pageRef() { const m = /^https?:\/\/[^/]+\/([^/]+)\/([^/]+)\/render\//.exec(this.base); return m ? m[1] + '/' + m[2] : '<owner>/<slug>'; }
    _defaultSymName() { const c = this.pageMeta; return (c && c.symbolFile) || (this.mpn.toLowerCase() + '.kicad_sym'); }
    _download(name, text) {
      try {
        const blob = new Blob([text], { type: 'text/plain' });
        const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = name;
        this.shadowRoot.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 2000);
        this._toast('Downloading ' + name);
      } catch (e) {
        const pre = this.shadowRoot.getElementById('pre'); pre.hidden = false; pre.textContent = text;
        this._toast('Download blocked here; the file is shown below to copy');
      }
    }
    _toast(t) { const el = this.shadowRoot.getElementById('toast'); if (!el) return; el.textContent = t; el.classList.add('on'); clearTimeout(this._tt); this._tt = setTimeout(() => el.classList.remove('on'), 2200); }
    attributeChangedCallback() { if (this._init && this.pins) this._render(); }
  }
  function descNote(s, pins) {
    const grouped = s.layout.endsWith('g');
    const shape = s.layout.startsWith('sq') ? (grouped ? '4-side square symbol' : '4-side square symbol, pins in package order') : '2-column symbol';
    const parts = [shape]; if (grouped) parts.push('pins grouped by function'); if (s.centred) parts.push('chip name centred');
    return parts.join(', ');
  }
  if (!customElements.get('adom-symbol-variants')) customElements.define('adom-symbol-variants', AdomSymbolVariants);
})();