master
Colby Knox Widgets: safe to render inline in a wiki README (adom/wiki#229) 4408302 7d ago
/*!
 * <adom-pinout> — a reusable, dependency-free pinout widget.
 *
 * Drop-in:
 *   <script src="pinout-widget.js"></script>
 *   <adom-pinout src="part.json"></adom-pinout>
 *   <adom-pinout><script type="application/json">{ ...pin data... }</script></adom-pinout>
 *   // or: document.querySelector('adom-pinout').data = { ... }
 *
 * Data contract (everything except pins[].name is optional):
 *   {
 *     "mpn": "STM32F103C8T6",           // label shown in the chip body
 *     "package": "LQFP-48",             // label + hints the inferred layout (quad vs dual)
 *     "pins": [
 *       { "number": 1, "name": "VBAT",  // number+name are the essentials
 *         "type": "power",              // power|io|clock|ctrl|other  (auto-inferred from name if omitted)
 *         "xy": [-2.75, 4.25] }         // mm pad centre (footprint frame, +y up) — best placement.
 *                                       // omit xy → placed by inferred quad/dual layout.
 *     ]
 *   }
 *
 * Attributes: src (fetch JSON), layout ("auto"|"quad"|"dual"), heading (override title).
 * Self-contained (Shadow DOM), themes to the host via prefers-color-scheme.
 */
(() => {
  const TYPE_COLORS = {
    power: '#ff5c5c', io: '#00d3c8', clock: '#4c9dff', ctrl: '#b57cff', control: '#b57cff', other: '#8b97ac',
  };
  const TYPE_LABEL = { power: 'power', io: 'I/O', clock: 'clock', ctrl: 'control', control: 'control', other: 'other' };

  function inferType(name) {
    const n = String(name || '').toUpperCase();
    if (n === 'NC' || n === '') return 'other';
    if (/^(VDD|VSS|VCC|GND|VBAT|VDDA|VSSA|AVDD|AVSS|VREF|VEE|VBUS|V\+|V-|AGND|DGND)/.test(n)) return 'power';
    if (/(OSC|XTAL|CLKIN|CLKOUT|\bCLK\b|SCLK|MCLK)/.test(n)) return 'clock';
    if (/^(NRST|~?RESET|RST|BOOT|EN\b|~?CE|~?CS|MODE|TEST|WAKE|SHDN|~?SHDN|PDN)/.test(n)) return 'ctrl';
    return 'io';
  }

  // Exposed / thermal pad (QFN/DFN/SON/PowerPAD) — belongs in the chip centre, not on an edge.
  const isCenterPad = p => {
    const n = String(p.name || '').trim().toUpperCase();
    return /^(EP|EPAD|PAD|TAB|THERMAL|E[-_]?PAD|EXP|EXPPAD)$/.test(n) || /EXPOSED\s*PAD|THERMAL\s*PAD/.test(n);
  };

  // Return each pin as { ...pin, x, y, side } in a S×S box. Center pads get side:'center'.
  function layout(pins, pkg, forced) {
    const S = 400, pad = 60, mid = S / 2;
    const centers = pins.filter(isCenterPad);
    const edge = pins.filter(p => !isCenterPad(p));
    let laid;
    const hasXY = edge.length > 0 && edge.every(p => Array.isArray(p.xy) && p.xy.length === 2);
    if (hasXY && forced !== 'quad' && forced !== 'dual') {
      const xs = edge.map(p => p.xy[0]), ys = edge.map(p => p.xy[1]);
      const minx = Math.min(...xs), maxx = Math.max(...xs), miny = Math.min(...ys), maxy = Math.max(...ys);
      const span = Math.max(maxx - minx, maxy - miny) || 1, sc = (S - 2 * pad) / span;
      const cx = (maxx + minx) / 2, cy = (maxy + miny) / 2, off = (v, c) => mid + (v - c) * sc;
      laid = edge.map(p => {
        const x = off(p.xy[0], cx), y = S - off(p.xy[1], cy); // flip y (mm +y up → screen down)
        const side = p.xy[1] === maxy ? 'top' : p.xy[1] === miny ? 'bottom' : p.xy[0] === maxx ? 'right' : 'left';
        return { ...p, x, y, side };
      });
    } else {
      // The body is drawn inset 16 from the outermost pins, so a run distributed
      // across the whole inner..outer span puts the FIRST and LAST pin of each
      // side outside the body: at 12 per side they landed 4.3px past each corner
      // and collided with the end pin of the neighbouring side (1 into 48, 12
      // into 13, and so on). Real quad packages have a corner keep-out anyway,
      // which is why the xy path never showed this. Inset the run to match.
      const N = edge.length, inner = pad, outer = S - pad, at = (a, b, t) => a + (b - a) * t;
      const CORNER = 26, runLo = inner + CORNER, runHi = outer - CORNER;
      const quad = forced === 'quad' || (forced !== 'dual' && /QF|BGA|LGA|LCC|QFN|QFP/i.test(pkg || ''));
      if (quad) {
        const per = Math.ceil(N / 4);
        laid = edge.map((p, i) => {
          const s = Math.floor(i / per), t = ((i % per) + 0.5) / per;
          if (s === 0) return { ...p, x: inner, y: at(runLo, runHi, t), side: 'left' };
          if (s === 1) return { ...p, x: at(runLo, runHi, t), y: outer, side: 'bottom' };
          if (s === 2) return { ...p, x: outer, y: at(runHi, runLo, t), side: 'right' };
          return { ...p, x: at(runHi, runLo, t), y: inner, side: 'top' };
        });
      } else {
        const half = Math.ceil(N / 2); // DIP-style: left top→bottom, right bottom→top
        laid = edge.map((p, i) => i < half
          ? { ...p, x: inner, y: at(runLo, runHi, (i + 0.5) / half), side: 'left' }
          : { ...p, x: outer, y: at(runHi, runLo, (i - half + 0.5) / (N - half)), side: 'right' });
      }
    }
    centers.forEach(p => laid.push({ ...p, x: mid, y: mid, side: 'center' }));
    return laid;
  }

  const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));

  class AdomPinout extends HTMLElement {
    set data(d) { this._data = d; if (this.shadowRoot) this._render(); }
    get data() { return this._data; }

    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._busInit();
      if (!this._data) {
        const inline = this.querySelector('script[type="application/json"]');
        if (inline) { try { this._data = JSON.parse(inline.textContent); } catch (e) { /* ignore */ } }
        const src = this.getAttribute('src');
        if (!this._data && src) { try { this._data = await (await fetch(src)).json(); } catch (e) { this._err = 'could not load ' + src; } }
      }
      this._render();
    }

    _render() {
      const d = this._data;
      const root = this.shadowRoot;
      if (this._err || !d || !Array.isArray(d.pins)) {
        root.innerHTML = `<style>${this._css()}</style><div class="card"><div class="empty">${esc(this._err || 'no pin data')}</div></div>`;
        return;
      }
      const pins = d.pins.map(p => ({ ...p, type: (p.type || inferType(p.name)).toLowerCase() }));
      const laid = layout(pins, d.package, this.getAttribute('layout'));
      const typesPresent = [...new Set(pins.map(p => p.type))];
      const heading = this.getAttribute('heading') || d.mpn || '';
      root.innerHTML = `<style>${this._css()}</style>
        <div class="card">
          <div class="hd">${heading ? `<span class="mpn">${esc(heading)}</span>` : ''}${d.package ? `<span class="pkg">${esc(d.package)}</span>` : ''}<span class="count">${pins.length} pins</span><span class="tools"><button class="tbtn" data-act="copy" title="Copy the pin map as JSON">copy</button><button class="tbtn" data-act="dl" title="Download the pin map as JSON">JSON&nbsp;↓</button></span></div>
          <div class="body">
            <div class="diagram">
              <div class="readout" data-readout>hover a pin&nbsp;→</div>
              ${this._svg(laid, d)}
              <div class="legend">${typesPresent.map(t => `<span><i style="background:${TYPE_COLORS[t] || TYPE_COLORS.other}"></i>${TYPE_LABEL[t] || t}</span>`).join('')}</div>
            </div>
            <div class="tablewrap"><table>
              <thead><tr><th>#</th><th>Name</th><th>Type</th></tr></thead>
              <tbody>${pins.slice().sort((a, b) => a.number - b.number).map(p => `
                <tr data-pin="${esc(p.number)}"><td class="num">${esc(p.number)}</td><td class="nm">${esc(p.name)}</td>
                <td><span class="tp" style="color:${TYPE_COLORS[p.type] || TYPE_COLORS.other}">${TYPE_LABEL[p.type] || p.type}</span></td></tr>`).join('')}</tbody>
            </table></div>
          </div>
        </div>`;
      this._wire(pins);
    }

    _svg(laid, d) {
      const S = 400;
      const edge = laid.filter(p => p.side !== 'center');
      const cen = laid.filter(p => p.side === 'center');
      const xs = edge.map(p => p.x), ys = edge.map(p => p.y);
      const pminX = Math.min(...xs), pmaxX = Math.max(...xs), pminY = Math.min(...ys), pmaxY = Math.max(...ys);
      const sides = new Set(edge.map(p => p.side)), hasTB = sides.has('top') || sides.has('bottom');
      const bL = pminX + 16, bR = pmaxX - 16, bT = hasTB ? pminY + 16 : pminY - 12, bB = hasTB ? pmaxY - 16 : pmaxY + 12;
      const cx = (bL + bR) / 2, cyB = (bT + bB) / 2;
      let s = `<svg viewBox="0 0 ${S} ${S}" class="chip">`;
      s += `<rect x="${bL}" y="${bT}" width="${bR - bL}" height="${bB - bT}" rx="10" class="pkgbody"/>`;
      s += `<circle cx="${bL + 15}" cy="${bT + 15}" r="4" class="pin1"/>`;
      const lY = cen.length ? bT + 32 : cyB - 3;
      s += `<text x="${cx}" y="${lY}" text-anchor="middle" class="lbl">${esc(d.mpn || '')}</text>`;
      if (d.package) s += `<text x="${cx}" y="${lY + 13}" text-anchor="middle" class="lbl2">${esc(d.package)}</text>`;
      // center pad(s) — exposed/thermal pad, drawn in the body centre
      cen.forEach((p, i) => {
        const col = TYPE_COLORS[p.type] || TYPE_COLORS.other;
        const ex = cx + (i - (cen.length - 1) / 2) * 40, ey = cyB + (cen.length ? 22 : 0);
        s += `<g class="pin" data-pin="${esc(p.number)}">`
          + `<rect class="ep" x="${ex - 19}" y="${ey - 13}" width="38" height="26" rx="4" fill="${col}" fill-opacity="0.15" stroke="${col}"/>`
          + `<text class="ept" x="${ex}" y="${ey + 3}" text-anchor="middle">${esc(p.name)}·${esc(p.number)}</text></g>`;
      });
      // Hover target. What a pin actually draws is a 2px stub, a 3.1px dot and an
      // 8px label, so the pointer has to land nearly on the line to select it.
      // Each pin gets a transparent fat line down its whole lane instead, from the
      // body edge out past the number, and that is what catches the mouse. The
      // width tracks the real pin pitch so a wider target never bleeds into the
      // neighbouring pin on a dense package.
      const pitch = (() => {
        let m = Infinity;
        for (const side of ['left', 'right', 'top', 'bottom']) {
          const v = edge.filter(q => q.side === side)
            .map(q => (side === 'left' || side === 'right') ? q.y : q.x)
            .sort((a, b) => a - b);
          for (let i = 1; i < v.length; i++) m = Math.min(m, v[i] - v[i - 1]);
        }
        return isFinite(m) && m > 0 ? m : 16;
      })();
      const HIT = Math.max(7, Math.min(20, pitch * 0.86));
      const REACH = 30; // out past the pin number, which sits 9-14 out
      for (const p of edge) {
        const col = TYPE_COLORS[p.type] || TYPE_COLORS.other;
        let ex = p.x, ey = p.y, lx = p.x, ly = p.y, anc = 'middle';
        if (p.side === 'left') { ex = bL; lx = p.x - 11; anc = 'end'; ly = p.y + 3; }
        else if (p.side === 'right') { ex = bR; lx = p.x + 11; anc = 'start'; ly = p.y + 3; }
        else if (p.side === 'top') { ey = bT; ly = p.y - 9; }
        else { ey = bB; ly = p.y + 14; }
        let hx = p.x, hy = p.y;
        if (p.side === 'left') hx = p.x - REACH;
        else if (p.side === 'right') hx = p.x + REACH;
        else if (p.side === 'top') hy = p.y - REACH;
        else hy = p.y + REACH;
        s += `<g class="pin" data-pin="${esc(p.number)}">`
          + `<line class="hit" x1="${ex}" y1="${ey}" x2="${hx}" y2="${hy}" stroke-width="${HIT.toFixed(1)}"/>`
          + `<line class="stub" x1="${p.x}" y1="${p.y}" x2="${ex}" y2="${ey}" stroke="${col}"/>`
          + `<circle class="dot" cx="${p.x}" cy="${p.y}" r="3.1" fill="${col}"/>`
          + `<text class="pn" x="${lx}" y="${ly}" text-anchor="${anc}">${esc(p.number)}</text></g>`;
      }
      return s + `</svg>`;
    }

    // Set the highlighted pin. `emit` fires a `pinhover` event (internal hover);
    // external callers use highlight() which does NOT emit (avoids feedback loops).
    _set(n, emit) {
      const root = this.shadowRoot; if (!root) return;
      const s = (n == null ? '' : String(n));
      root.querySelectorAll('tr[data-pin]').forEach(r => r.classList.toggle('hot', r.dataset.pin === s));
      root.querySelectorAll('svg .pin[data-pin]').forEach(g => g.classList.toggle('hot', g.dataset.pin === s));
      const p = (this._pinsRendered || []).find(x => String(x.number) === s);
      const ro = root.querySelector('[data-readout]');
      if (ro) ro.innerHTML = p
        ? `<b>PIN ${esc(p.number)}</b> · ${esc(p.name)}<span class="rt" style="color:${TYPE_COLORS[p.type] || TYPE_COLORS.other}">${TYPE_LABEL[p.type] || p.type}</span>`
        : 'hover a pin&nbsp;→';
      if (emit) { this.dispatchEvent(new CustomEvent('pinhover', { detail: p || null, bubbles: true })); this._emitBus(s); }
    }

    /** Public: drive the highlight from outside (e.g. a linked footprint/symbol widget). */
    highlight(n) { this._set(n, false); }

    /** Public: the canonical pin map as a plain object — the structured handle for code/AI.
     *  Types are resolved (inferred filled in) so consumers get a complete map. No fabricated fields. */
    toJSON() {
      const d = this._data || {};
      const pins = (Array.isArray(d.pins) ? d.pins : [])
        .map(p => ({ number: p.number, name: p.name, type: (p.type || inferType(p.name)).toLowerCase(), ...(p.xy ? { xy: p.xy } : {}) }))
        .sort((a, b) => (Number(a.number) - Number(b.number)) || String(a.number).localeCompare(String(b.number)));
      const out = {};
      if (d.mpn) out.mpn = d.mpn;
      if (d.package) out.package = d.package;
      out.pins = pins;
      return out;
    }

    _exportJson() { return JSON.stringify(this.toJSON(), null, 2); }
    _exportName() { const d = this._data || {}; return ((d.mpn || 'pinout') + (d.package ? '-' + d.package : '')).replace(/[^\w.-]+/g, '_') + '.pins.json'; }
    _download() {
      // A data: URL, not a blob:. As a registered widget this renders inside the
      // wiki's sandboxed frame, whose CSP has no blob:, so createObjectURL is
      // refused at runtime and the button does nothing at all. data: is the only
      // download route that works in every host we render in.
      const a = document.createElement('a');
      a.href = 'data:application/json;charset=utf-8,' + encodeURIComponent(this._exportJson());
      a.download = this._exportName();
      document.body.appendChild(a); a.click(); a.remove();
    }
    async _copy(btn) {
      const txt = this._exportJson(), done = () => { if (!btn) return; 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) { /* sandboxed / denied — 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(); }   // last resort: hand them the file
    }

    // adom-lbr shared cross-highlight bus: BroadcastChannel('lbr-hover') + parent relay.
    _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._set(d.pin == null ? '' : d.pin, false); };
      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.mpn) || '', 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*/ }
    }

    _wire(pins) {
      this._pinsRendered = pins;
      const root = this.shadowRoot;
      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);
      root.querySelectorAll('tr[data-pin]').forEach(r => { r.onmouseenter = () => this._set(r.dataset.pin, true); r.onmouseleave = () => this._set('', true); });
      root.querySelectorAll('svg .pin[data-pin]').forEach(g => { g.onmouseenter = () => this._set(g.dataset.pin, true); g.onmouseleave = () => this._set('', true); });
      // Match the pin table's height to the chip diagram column so it fills the
      // space and scrolls, instead of a fixed short box next to a taller chip.
      const dia = root.querySelector('.diagram'), tw = root.querySelector('.tablewrap');
      if (dia && tw) {
        const fit = () => { const h = dia.getBoundingClientRect().height; if (h > 40) tw.style.maxHeight = Math.round(h) + 'px'; };
        requestAnimationFrame(fit); setTimeout(fit, 60);
        try { if (this._ro) this._ro.disconnect(); this._ro = new ResizeObserver(fit); this._ro.observe(dia); } catch (e) { /* */ }
      }
    }

    _css() {
      return `
      :host{display:block;
        --bg:#0d1420;--panel:#121a26;--line:#26324a;--line2:#33425f;--text:#dbe2ec;--dim:#8b97ac;--dim2:#66738c;--accent:#00e6dc;
        --hot:rgba(0,230,220,.13)}
      @media (prefers-color-scheme:light){:host{--bg:#f6f8fc;--panel:#fff;--line:#dbe2ee;--line2:#c3cede;--text:#1a2230;--dim:#5a6678;--dim2:#8592a6;--accent:#0d8f88;--hot:rgba(13,143,136,.10)}}
      :host([theme="dark"]){--bg:#0d1420;--panel:#121a26;--line:#26324a;--line2:#33425f;--text:#dbe2ec;--dim:#8b97ac;--dim2:#66738c;--accent:#00e6dc;--hot:rgba(0,230,220,.13)}
      :host([theme="light"]){--bg:#f6f8fc;--panel:#fff;--line:#dbe2ee;--line2:#c3cede;--text:#1a2230;--dim:#5a6678;--dim2:#8592a6;--accent:#0d8f88;--hot:rgba(13,143,136,.10)}
      *{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:12px}
      .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;transition:color .12s,border-color .12s,background .12s;white-space:nowrap}
      .tbtn:hover{color:var(--text);border-color:var(--line2)}
      .tbtn.ok{color:var(--accent);border-color:color-mix(in srgb,var(--accent) 45%,transparent)}
      .body{display:grid;grid-template-columns:minmax(300px,1fr) minmax(220px,1fr);gap:20px;align-items:start}
      /* stack diagram over table when the WIDGET (not the viewport) is narrow —
         e.g. side-by-side in a cross-link row — so the table never clips */
      @container (max-width:580px){.body{grid-template-columns:1fr}}
      .readout{height:24px;display:flex;align-items:center;gap:9px;font:600 13px ui-monospace,monospace;color:var(--dim);padding:0 4px}
      .readout b{color:var(--accent)} .readout .rt{margin-left:auto;font-size:11px;text-transform:uppercase;letter-spacing:.08em}
      .diagram{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:8px}
      svg.chip{width:100%;height:auto;display:block;max-height:440px}
      .pkgbody{fill:color-mix(in srgb,var(--text) 6%,var(--panel));stroke:var(--line2);stroke-width:1}
      .pin1{fill:var(--accent)}
      .lbl{fill:var(--dim);font:600 11px ui-monospace,monospace} .lbl2{fill:var(--dim2);font:9px ui-monospace,monospace}
      .hit{stroke:transparent;fill:none;stroke-linecap:round;pointer-events:stroke}
      .pin .stub{stroke-width:2} .pin .pn{fill:var(--dim2);font:8px ui-monospace,monospace}
      .pin:hover .stub,.pin.hot .stub{stroke-width:3.6} .pin:hover .pn,.pin.hot .pn{fill:var(--text);font-weight:700}
      .pin .ep{stroke-width:1.2} .pin .ept{fill:var(--dim);font:8px ui-monospace,monospace}
      .pin:hover .ep,.pin.hot .ep{fill-opacity:.34;stroke-width:2} .pin:hover .ept,.pin.hot .ept{fill:var(--text);font-weight:700}
      .pin{cursor:pointer}
      .legend{display:flex;flex-wrap:wrap;gap:13px;margin:8px 2px 2px;font-size:11.5px;color:var(--dim)}
      .legend i{display:inline-block;width:9px;height:9px;border-radius:2px;margin-right:5px;vertical-align:middle}
      .tablewrap{max-height:440px;overflow:auto;border:1px solid var(--line);border-radius:10px;scrollbar-width:thin;scrollbar-color:var(--line2) transparent}
      .tablewrap::-webkit-scrollbar{width:9px;height:9px}
      .tablewrap::-webkit-scrollbar-track{background:transparent}
      .tablewrap::-webkit-scrollbar-thumb{background:var(--line2);border-radius:9px;border:2px solid transparent;background-clip:padding-box}
      .tablewrap::-webkit-scrollbar-thumb:hover{background:var(--dim2);border:2px solid transparent;background-clip:padding-box}
      .tablewrap::-webkit-scrollbar-button{display:none;width:0;height:0}
      .tablewrap::-webkit-scrollbar-corner{background:transparent}
      table{width:100%;border-collapse:collapse;font-size:12.5px}
      thead th{position:sticky;top:0;background:var(--panel);text-align:left;color:var(--dim2);font:600 10px ui-monospace,monospace;letter-spacing:.08em;text-transform:uppercase;padding:7px 9px;border-bottom:1px solid var(--line)}
      td{padding:4px 9px;border-bottom:1px solid color-mix(in srgb,var(--line) 55%,transparent);font-family:ui-monospace,monospace}
      td.num{color:var(--dim2)} td.nm{color:var(--text)}
      tr[data-pin]{cursor:default} tr.hot td{background:var(--hot)}
      .tp{font-size:10px;text-transform:uppercase;letter-spacing:.05em}
      .empty{color:var(--dim2);padding:30px;text-align:center;font-family:ui-monospace,monospace}`;
    }
  }
  if (!customElements.get('adom-pinout')) customElements.define('adom-pinout', AdomPinout);
})();