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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
/*!
* <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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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);
})();