master
John Lauer adom-verified: visible expand triangle on the verification log 8bc8a39 15d ago
/*!
 * <adom-verified> - the component "verified by" trust mark and verification log.
 *
 * Drop-in (wiki README):
 *   ::adom-verified{src="/OWNER/SLUG/render/verification.json"}
 *
 * Data contract (adom/verification@1), committed in the component page's repo:
 *   {
 *     "schema": "adom/verification@1",
 *     "part": "WS2812B-2020",
 *     "variants": [ { "mpn": "WS2812B-2020-V6", "note": "12 mA", "stocked": false } ],
 *     "entries": [ {
 *       "date": "2026-10-04", "by": { "name": "John Lauer", "handle": "john" },
 *       "status": "verified" | "open" | "superseded",
 *       "title": "Footprint matches Worldsemi's PCB Solder Pad drawing",
 *       "scope": ["footprint", "3d-model", "hero", "specs", "stock"],
 *       "variants": ["*"],                       // "*" = every variant on the page
 *       "checks": [ { "text": "0.7 x 0.7 mm pads, 0.40 mm gap", "result": "pass" | "fail" | "note" } ],
 *       "evidence": [ { "label": "pad proof", "href": "docs/img/ws2812-pad-proof.png" } ],
 *       "files": { "WS2812B-2020.kicad_mod": "<sha256 hex>" }   // what was verified, byte for byte
 *     } ]
 *   }
 * Relative evidence hrefs and file paths resolve against the manifest's own URL (the page's
 * render route). Each entry's files are fetched and hashed in the browser: if any differs,
 * the entry and the badge turn amber ("changed since verified").
 * Self-contained (Shadow DOM), Adom Studio brand tokens (adom-theme), matching the wiki's dark theme.
 */
(() => {
  const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
  const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
  const fmtDate = d => { const m = /^(\d{4})-(\d\d)-(\d\d)/.exec(d || ''); return m ? `${+m[3]} ${MONTHS[+m[2] - 1]} ${m[1]}` : esc(d); };
  const SCOPE = { footprint: 'Footprint', '3d-model': '3D model', hero: 'Hero', specs: 'Specs', stock: 'Stock', symbol: 'Symbol', pinout: 'Pinout', datasheet: 'Datasheet' };

  async function sha256(buf) {
    const h = await crypto.subtle.digest('SHA-256', buf);
    return [...new Uint8Array(h)].map(b => b.toString(16).padStart(2, '0')).join('');
  }

  class AdomVerified extends HTMLElement {
    async connectedCallback() {
      if (this._init) return; this._init = true;
      this.attachShadow({ mode: 'open' });
      const src = this.getAttribute('src');
      this._paint('<div class="empty">loading verification log…</div>');
      if (!src) return this._paint('<div class="empty">no src: point me at a verification.json</div>');
      this._base = new URL(src.slice(0, src.lastIndexOf('/') + 1), location.href).href;
      try { this._d = await (await fetch(src)).json(); }
      catch (e) { return this._paint('<div class="empty">could not load ' + esc(src) + '</div>'); }
      this._fileState = {};            // path -> 'ok' | 'changed' | 'missing' | 'checking'
      this._render();
      this._checkFiles();
    }

    _abs(href) { try { return new URL(href, this._base).href; } catch (_) { return href; } }

    async _checkFiles() {
      const want = {};
      for (const e of this._d.entries || []) for (const [p, h] of Object.entries(e.files || {})) (want[p] = want[p] || new Set()).add(h);
      const paths = Object.keys(want);
      for (const p of paths) this._fileState[p] = { state: 'checking' };
      this._render();
      await Promise.all(paths.map(async p => {
        // /render/ refuses large files (413 "File too large to render", e.g. a 7 MB STEP); the page's files API
        // serves the same bytes, so fall back to it. Not fetchable at all is "unchecked", never "changed".
        const urls = [this._abs(p)], m = this._base.match(/^(https?:\/\/[^/]+)\/([^/]+)\/([^/]+)\/render\//);
        if (m) urls.push(`${m[1]}/api/pages/${m[2]}/${m[3]}/files/${p.split('/').map(encodeURIComponent).join('/')}`);
        let status = 0;
        for (const u of urls) {
          try {
            const r = await fetch(u);
            if (r.status === 404) { status = status || 404; continue; }
            if (!r.ok) { status = r.status; continue; }
            this._fileState[p] = { state: 'hashed', sha: await sha256(await r.arrayBuffer()) }; return;
          } catch (_) { status = status || -1; }
        }
        this._fileState[p] = { state: status === 404 ? 'missing' : 'unreachable' };
      }));
      this._render();
    }

    _entryState(e) {
      const files = Object.entries(e.files || {});
      if (!files.length) return 'unpinned';
      let checking = false, unchecked = false;
      for (const [p, h] of files) {
        const f = this._fileState[p];
        if (!f || f.state === 'checking') { checking = true; continue; }
        if (f.state === 'unreachable') { unchecked = true; continue; }
        if (f.state === 'missing' || f.sha !== h) return 'changed';
      }
      return checking ? 'checking' : unchecked ? 'unchecked' : 'current';
    }

    _render() {
      const d = this._d, entries = [...(d.entries || [])].sort((a, b) => String(b.date).localeCompare(String(a.date)));
      const verified = entries.filter(e => e.status === 'verified');
      const open = entries.filter(e => e.status === 'open');
      const latest = verified[0];
      const states = verified.map(e => this._entryState(e));
      const stale = states.includes('changed');
      const checking = states.includes('checking');
      const people = [...new Map(verified.map(e => [e.by && (e.by.handle || e.by.name), e.by])).values()].filter(Boolean);
      const scopes = [...new Set(verified.flatMap(e => e.scope || []))];
      const tone = !latest ? 'none' : stale ? 'stale' : 'ok';
      const icon = tone === 'ok'
        ? '<svg viewBox="0 0 48 48" aria-hidden="true"><circle cx="24" cy="24" r="22"/><path d="M14 25l7 7 13-15" fill="none" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/></svg>'
        : '<svg viewBox="0 0 48 48" aria-hidden="true"><circle cx="24" cy="24" r="22"/><path d="M24 13v14M24 34v1" fill="none" stroke-width="5" stroke-linecap="round"/></svg>';
      const headline = !latest ? 'Not verified yet' : stale ? 'Verified, but files changed since' : 'Verified';
      const sub = latest
        ? `by ${people.map(p => esc(p.name)).join(', ')} · last ${fmtDate(latest.date)} · ${verified.length} record${verified.length === 1 ? '' : 's'}${checking ? ' · checking files…' : stale ? '' : states.includes('unchecked') ? ' · some files could not be fetched to check' : ' · files match'}`
        : 'no verification records on this page';
      const scopeChips = scopes.map(s => `<span class="chip">${esc(SCOPE[s] || s)}</span>`).join('');

      const vs = d.variants || [];
      const covered = mpn => verified.filter(e => (e.variants || ['*']).some(v => v === '*' || v === mpn));
      const varRows = vs.length ? `<div class="vars">${vs.map(v => {
        const c = covered(v.mpn), sc = new Set(c.flatMap(e => e.scope || []));
        return `<div class="var${v.stocked ? ' stocked' : ''}"><div class="vm">${esc(v.mpn)}${v.stocked ? '<span class="tag">Adom Basic Part</span>' : ''}</div>
          <div class="vn">${esc(v.note || '')}</div>
          <div class="vs">${sc.size ? [...sc].map(s => `<span class="mini ok">${esc(SCOPE[s] || s)}</span>`).join('') : '<span class="mini none">not verified</span>'}</div></div>`;
      }).join('')}</div>` : '';

      const log = entries.map(e => {
        const st = e.status === 'verified' ? this._entryState(e) : e.status;
        const badge = e.status === 'open' ? '<span class="pill open">open</span>'
          : e.status === 'superseded' ? '<span class="pill sup">superseded</span>'
          : st === 'changed' ? '<span class="pill stale">changed since</span>'
          : st === 'checking' ? '<span class="pill">checking</span>'
          : st === 'current' ? '<span class="pill ok">files match</span>' : '<span class="pill ok">verified</span>';
        const checks = (e.checks || []).map(c => `<li class="${esc(c.result || 'pass')}"><span class="mk">${c.result === 'fail' ? '✕' : c.result === 'note' ? '•' : '✓'}</span>${esc(c.text)}</li>`).join('');
        const ev = (e.evidence || []).map(x => `<a href="${esc(this._abs(x.href))}" target="_blank" rel="noopener">${esc(x.label)}</a>`).join('');
        const files = Object.entries(e.files || {}).map(([p, h]) => {
          const f = this._fileState[p] || {}, ok = f.state === 'hashed' && f.sha === h;
          const un = f.state === 'unreachable';
          return `<span class="file ${ok ? 'ok' : f.state === 'checking' || un ? '' : 'bad'}" title="${un ? 'could not be fetched to check; ' : ''}sha256 ${esc(h)}">${ok ? '✓' : f.state === 'checking' ? '…' : un ? '?' : '!'} ${esc(p)}</span>`;
        }).join('');
        const vars = (e.variants || ['*']).map(v => v === '*' ? 'all variants' : v).join(', ');
        return `<details class="entry ${esc(e.status)}"><summary>
            <span class="date">${fmtDate(e.date)}</span><span class="who">${esc(e.by && e.by.name)}</span>
            <span class="title">${esc(e.title)}</span>${badge}</summary>
          <div class="body"><div class="meta">${(e.scope || []).map(s => `<span class="chip">${esc(SCOPE[s] || s)}</span>`).join('')}<span class="for">for ${esc(vars)}</span></div>
          ${checks ? `<ul class="checks">${checks}</ul>` : ''}
          ${ev ? `<div class="ev"><span class="lbl">Evidence</span>${ev}</div>` : ''}
          ${files ? `<div class="files"><span class="lbl">Verified files</span>${files}</div>` : ''}</div></details>`;
      }).join('');

      this._paint(`<div class="badge ${tone}"><div class="icon">${icon}</div><div class="hb">
          <div class="h">${headline}${d.part ? ` <span class="part">${esc(d.part)}</span>` : ''}</div>
          <div class="sub">${sub}</div><div class="scopes">${scopeChips}${open.length ? `<span class="chip warn">${open.length} open item${open.length === 1 ? '' : 's'}</span>` : ''}</div></div></div>
        ${varRows}
        <details class="logwrap"${this._logOpen ? ' open' : ''}><summary class="logh">Verification log <span class="logn">${entries.length} record${entries.length === 1 ? '' : 's'}${open.length ? ` · ${open.length} open` : ''}</span></summary>
        <div class="log">${log || '<div class="empty">no entries</div>'}</div></details>`);
      const lw = this.shadowRoot.querySelector('details.logwrap');   // collapsed by default; keep the reader's choice across re-renders
      if (lw) lw.addEventListener('toggle', () => { this._logOpen = lw.open; });
    }

    _paint(inner) { this.shadowRoot.innerHTML = `<style>${this._css()}</style><div class="card">${inner}</div>`; }

    _css() {
      return `
      :host{display:block;--bg:#161b22;--panel:#0d1117;--line:#30363d;--text:#e6edf3;--body:#c9d1d9;--dim:#8b949e;--ok:#3fb950;--okbg:rgba(63,185,80,.10);--warn:#d29922;--warnbg:rgba(210,153,34,.10);--bad:#f85149;--accent:#00b8b1;--accent-bright:#00e6dc;--prose:var(--font-body,Satoshi,-apple-system,'Segoe UI',sans-serif);--display:var(--font-display,'Familjen Grotesk',Satoshi,sans-serif);--mono:var(--font-mono,'JetBrains Mono',Consolas,ui-monospace,monospace)}
      *{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 var(--prose)}
      .empty{color:var(--dim);padding:8px}
      .badge{display:flex;gap:16px;align-items:center;padding:14px;border-radius:12px;border:1px solid var(--line);background:var(--panel)}
      .badge.ok{background:var(--okbg);border-color:color-mix(in srgb,var(--ok) 45%,transparent)}
      .badge.stale{background:var(--warnbg);border-color:color-mix(in srgb,var(--warn) 45%,transparent)}
      .icon svg{width:56px;height:56px;display:block}
      .badge.ok .icon circle{fill:var(--ok)} .badge.ok .icon path{stroke:#fff}
      .badge.stale .icon circle,.badge.none .icon circle{fill:var(--warn)} .badge.stale .icon path,.badge.none .icon path{stroke:#fff}
      .h{font:700 21px/1.2 var(--display);letter-spacing:-.01em}.part{font-weight:500;color:var(--dim);font-size:14px;margin-left:6px}
      .sub{color:var(--dim);margin:2px 0 8px}
      .scopes,.meta{display:flex;flex-wrap:wrap;gap:6px;align-items:center}
      .chip{font-size:12px;padding:2px 8px;border-radius:999px;border:1px solid var(--line);background:var(--bg)}
      .chip.warn{border-color:var(--warn);color:var(--warn)}
      .vars{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:8px;margin:14px 0}
      .var{border:1px solid var(--line);border-radius:10px;padding:9px 10px;background:var(--panel)}
      .var.stocked{border-color:var(--ok)}
      .vm{font-weight:600;display:flex;gap:6px;align-items:center;flex-wrap:wrap}
      .tag{font-size:11px;font-weight:600;color:#0d1117;background:var(--ok);border-radius:6px;padding:1px 6px}
      .vn{color:var(--dim);font-size:12px;margin:2px 0 6px}
      .vs{display:flex;flex-wrap:wrap;gap:4px}
      .mini{font-size:11px;padding:1px 6px;border-radius:6px}.mini.ok{background:var(--okbg);color:var(--ok)}.mini.none{background:var(--warnbg);color:var(--warn)}
      .logh{font:700 16px/1.2 var(--display);margin:16px 0 6px;cursor:pointer}.logn{font:500 13px var(--prose);color:var(--dim);margin-left:6px}details.logwrap>summary{list-style:none;display:flex;align-items:baseline;gap:6px}details.logwrap>summary::-webkit-details-marker{display:none}details.logwrap>summary::before{content:'\\25B8';color:var(--accent);font-size:14px;transition:transform .15s;display:inline-block}details.logwrap[open]>summary::before{transform:rotate(90deg)}
      .entry{border:1px solid var(--line);border-radius:10px;background:var(--panel);margin:6px 0}
      .entry.open{border-color:color-mix(in srgb,var(--warn) 55%,transparent)}
      summary{cursor:pointer;list-style:none;display:flex;gap:10px;align-items:center;padding:9px 12px;flex-wrap:wrap}
      summary::-webkit-details-marker{display:none}
      .date{font-family:var(--mono);font-size:12px;color:var(--dim);min-width:86px}.who{font-weight:600}.title{flex:1;min-width:200px}
      .pill{font-size:11px;padding:2px 8px;border-radius:999px;border:1px solid var(--line);color:var(--dim)}
      .pill.ok{color:var(--ok);border-color:var(--ok)}.pill.open,.pill.stale{color:var(--warn);border-color:var(--warn)}
      .body{padding:0 12px 12px}
      .for{font-size:12px;color:var(--dim)}
      .checks{margin:8px 0;padding:0;list-style:none}.checks li{display:flex;gap:8px;padding:2px 0}
      .mk{color:var(--ok);font-weight:700;width:14px;flex:none}.checks li.fail .mk{color:var(--bad)}.checks li.note .mk{color:var(--dim)}
      .ev,.files{display:flex;flex-wrap:wrap;gap:6px 12px;align-items:center;margin-top:6px;font-size:13px}
      .lbl{color:var(--dim);font-size:12px}
      a{color:var(--accent)}a:hover{color:var(--accent-bright)}
      .file{font-family:var(--mono);font-size:12px}.file.ok{color:var(--ok)}.file.bad{color:var(--warn)}`;
    }
  }
  if (!customElements.get('adom-verified')) customElements.define('adom-verified', AdomVerified);
})();