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
John Lauer
Add adom-verified, adom-models and adom-footprint-variants (moved from john/ pages) with their tools
7acd92d
16d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174
/*!
* <adom-models> - every 3D file a component page carries, one card each: what it is, what makes it different,
* how it relates to the others, and whether the page still serves exactly the file that was described.
*
* Drop-in (wiki README):
* ::adom-models{src="/OWNER/SLUG/render/models.json"}
*
* Data contract (adom/models@1), committed in the component page's repo as models.json and written by
* tools/models_json.py on adom/component-widgets:
* { "schema": "adom/models@1", "part": "WS2812B-2020", "updated": "2026-10-04",
* "models": [ { "path": "WS2812B-2020.step", "format": "step" | "glb", "title": "...",
* "status": "current" | "previous", "variant": "etched" | "plain" | "hero" | "showcase" | "insertion" | "lod" | "original",
* "role": "manufacturer" | "datasheet-built" | "photo-matched" | "derived", "slot": "model_3d_step" | null,
* "marking": { "text": "WS2812B-2020", "surface": "side wall", "colour": "dark grey" } | null,
* "colours": true, "unique": "one line: what this file has that its siblings do not",
* "use": ["board-cad", "render", "wiki-hero", "board-insertion", "archive"], "derived_from": "path" | null,
* "source": "...", "sha256": "...", "git_blob": "<git blob SHA-1>", "bytes": 7190828, "thumb": "docs/models/x.png" } ] }
* Freshness: the page's recursive file listing (/api/pages/OWNER/SLUG/files?recursive=1) reports each file's git
* blob hash and last commit, so nothing is downloaded to check it. A file whose hash differs is marked "changed
* since described"; a listed path that is gone is "missing". Downloads go through the files API, which serves
* large STEPs the render route refuses.
* Self-contained (Shadow DOM), Adom Studio brand tokens (adom-theme), the wiki's own fonts.
*/
(() => {
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 fmtTs = t => { const d = new Date(t * 1000); return isNaN(d) ? '' : `${d.getDate()} ${MONTHS[d.getMonth()]} ${d.getFullYear()}`; };
const fmtSize = b => b == null ? '' : b >= 1048576 ? (b / 1048576).toFixed(1) + ' MB' : Math.max(1, Math.round(b / 1024)) + ' KB';
const VARIANT = { etched: 'Etched', plain: 'Unmarked', hero: 'Wiki hero', showcase: 'Showcase scene', insertion: 'Assembly scene',
lod: 'Level of detail', original: 'Original' };
const ROLE = { manufacturer: "Maker's model", 'datasheet-built': 'Built from the datasheet', 'photo-matched': 'Matched to photos', derived: 'Derived' };
const SLOT = { step: 'Page STEP', model_3d_step: 'Page STEP', model_3d_step_plain: 'Page plain STEP', model_3d: 'Page 3D viewer',
model_3d_plain: 'Page plain GLB', model_3d_showcase: 'Page showcase' };
const USE = { 'board-cad': 'Board CAD', render: 'Renders', 'wiki-hero': 'Wiki viewer', 'board-insertion': 'Assembly', archive: 'Archive' };
class AdomModels 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 3D models…</div>');
if (!src) return this._paint('<div class="empty">no src: point me at a models.json</div>');
const abs = new URL(src, location.href).href;
this._base = abs.slice(0, abs.lastIndexOf('/') + 1);
const m = abs.match(/^(https?:\/\/[^/]+)\/([^/]+)\/([^/]+)\/render\//);
this._api = m ? `${m[1]}/api/pages/${m[2]}/${m[3]}` : null;
this._page = m ? `${m[1]}/${m[2]}/${m[3]}` : null;
try { this._d = await (await fetch(abs)).json(); }
catch (e) { return this._paint('<div class="empty">could not load ' + esc(src) + '</div>'); }
this._live = null; this._showPrev = false;
this._render();
if (this._api) {
try {
const r = await fetch(this._api + '/files?recursive=1');
const L = await r.json(); const list = Array.isArray(L) ? L : (L.files || []);
this._live = Object.fromEntries(list.filter(f => f.type === 'blob').map(f => [f.path, f]));
} catch (_) { this._live = {}; }
this._render();
}
}
_abs(p) { try { return new URL(p, this._base).href; } catch (_) { return p; } }
_dl(p) { return this._api ? `${this._api}/files/${p.split('/').map(encodeURIComponent).join('/')}` : this._abs(p); }
_state(m) {
if (!this._live) return 'checking';
const f = this._live[m.path];
if (!f) return Object.keys(this._live).length ? 'missing' : 'unknown';
if (!m.git_blob) return 'unknown';
return f.hash === m.git_blob ? 'current' : 'changed';
}
_card(m) {
const st = this._state(m), f = this._live && this._live[m.path];
const mark = m.marking && m.marking.text
? `<span class="chip mark" title="${esc(m.marking.colour || '')}">${esc(m.marking.text)} on the ${esc(m.marking.surface || 'part')}</span>`
: '';
const chips = [`<span class="chip fmt ${esc(m.format)}">${esc((m.format || '').toUpperCase())}</span>`,
`<span class="chip var">${esc(VARIANT[m.variant] || m.variant || '')}</span>`, mark,
m.colours ? '<span class="chip">Coloured dies</span>' : '',
m.slot ? `<span class="chip slot" title="page.json component.parts.${esc(m.slot)}">${esc(SLOT[m.slot] || 'Page file')}</span>` : ''].join('');
const fresh = st === 'current' ? `<span class="fresh ok" title="git blob ${esc(m.git_blob)}">✓ as described${f && f.lastCommit ? ' · ' + fmtTs(f.lastCommit.timestamp) : ''}</span>`
: st === 'changed' ? `<span class="fresh bad" title="the page now serves a different file than the one described">changed since described</span>`
: st === 'missing' ? '<span class="fresh bad">missing from the page</span>'
: st === 'checking' ? '<span class="fresh">checking…</span>' : '';
const uses = (m.use || []).map(u => esc(USE[u] || u)).join(' · ');
const from = m.derived_from ? `<div class="from">from <code>${esc(m.derived_from)}</code></div>` : '';
return `<div class="card ${st}">
<a class="thumb" href="${esc(this._page ? this._page + '/files/' + m.path : this._abs(m.path))}" target="_blank" rel="noopener" title="Open ${esc(m.path)}">
${m.thumb ? `<img loading="lazy" src="${esc(this._abs(m.thumb))}" alt="${esc(m.title || m.path)}, top">` : '<div class="nothumb">no preview</div>'}
${m.thumb ? '<span class="vlabel">Top</span>' : ''}</a>
${m.thumb_bottom ? `<a class="thumb under" href="${esc(this._abs(m.thumb_bottom))}" target="_blank" rel="noopener" title="Underside of ${esc(m.path)}">
<img loading="lazy" src="${esc(this._abs(m.thumb_bottom))}" alt="${esc(m.title || m.path)}, underside"><span class="vlabel">Underside</span></a>` : ''}
<div class="body">
<div class="title">${esc(m.title || m.path)}</div>
<div class="path"><code>${esc(m.path)}</code> · ${fmtSize(m.bytes)}</div>
<div class="chips">${chips}</div>
${m.unique ? `<div class="unique">${esc(m.unique)}</div>` : ''}
<div class="meta">${ROLE[m.role] ? `<span>${esc(ROLE[m.role])}</span>` : ''}${uses ? `<span>For: ${uses}</span>` : ''}</div>
${from}
<div class="foot">${fresh}<a class="dl" href="${esc(this._dl(m.path))}" download="${esc(m.path.split('/').pop())}">Download</a></div>
</div></div>`;
}
_render() {
const d = this._d, all = d.models || [];
const nStep = all.filter(m => m.format === 'step').length, nGlb = all.filter(m => m.format === 'glb').length;
this._tab = this._tab || (nStep ? 'step' : 'glb');
const ms = all.filter(m => (m.format === 'step') === (this._tab === 'step'));
const cur = ms.filter(m => m.status !== 'previous'), prev = ms.filter(m => m.status === 'previous');
const order = ['etched', 'plain', 'original', 'hero', 'showcase', 'insertion', 'lod'];
cur.sort((a, b) => order.indexOf(a.variant) - order.indexOf(b.variant)); prev.sort((a, b) => String(b.path).localeCompare(String(a.path)));
const states = this._live ? all.map(m => this._state(m)) : [];
const bad = states.filter(s => s === 'changed' || s === 'missing').length;
const head = `<div class="head"><div class="h">${esc(this.getAttribute('heading') || '3D models')}<span class="part">${esc(d.part || '')}</span></div>
<div class="sub">${d.updated ? 'described ' + esc(d.updated) : ''}${
!this._live ? ' · checking files…' : bad ? ` · <span class="warn">${bad} changed since described</span>` : states.length ? ' · <span class="okt">all files as described</span>' : ''}</div>
<div class="tabs" role="tablist">
<button role="tab" data-tab="step" class="${this._tab === 'step' ? 'on' : ''}"${nStep ? '' : ' disabled'}>STEP <span class="n">${nStep}</span></button>
<button role="tab" data-tab="glb" class="${this._tab === 'glb' ? 'on' : ''}"${nGlb ? '' : ' disabled'}>GLB <span class="n">${nGlb}</span></button>
</div>
<div class="tabnote">${this._tab === 'step' ? 'CAD models for your board tools: open them in KiCad, Fusion, Altium or any MCAD tool.' : 'Meshes for viewers and renders: the wiki hero, scenes and plain chip GLBs.'}</div></div>`;
const prevBlock = prev.length ? `<details class="prev"${this._showPrev ? ' open' : ''}><summary>Previous versions (${prev.length})</summary>
<div class="grid">${prev.map(m => this._card(m)).join('')}</div></details>` : '';
this._paint(`${head}<div class="grid">${cur.map(m => this._card(m)).join('') || '<div class="empty">none</div>'}</div>${prevBlock}`);
this.shadowRoot.querySelectorAll('.tabs button').forEach(b => b.addEventListener('click', () => { this._tab = b.dataset.tab; this._render(); }));
const det = this.shadowRoot.querySelector('details.prev');
if (det) det.addEventListener('toggle', () => { this._showPrev = det.open; });
}
_paint(inner) { this.shadowRoot.innerHTML = `<style>${this._css()}</style><div class="wrap">${inner}</div>`; }
_css() {
return `
:host{display:block;--bg:#161b22;--panel:#0d1117;--line:#30363d;--text:#e6edf3;--body:#c9d1d9;--dim:#8b949e;
--ok:#3fb950;--warn:#d29922;--accent:#00b8b1;--accent-bright:#00e6dc;--blue:#64abff;--purple:#8c6bf7;
--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}
.wrap{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}
.h{font:700 21px/1.2 var(--display);letter-spacing:-.01em}.part{font:500 14px var(--prose);color:var(--dim);margin-left:8px}
.sub{color:var(--dim);margin:2px 0 12px}.warn{color:var(--warn)}.okt{color:var(--ok)}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:12px}
.card{background:var(--panel);border:1px solid var(--line);border-radius:12px;overflow:hidden;display:flex;flex-direction:column}
.card.changed,.card.missing{border-color:color-mix(in srgb,var(--warn) 55%,transparent)}
.thumb{display:block;position:relative;background:#0d1117;aspect-ratio:4/3;border-bottom:1px solid var(--line)}
.vlabel{position:absolute;left:8px;top:6px;font-size:11px;color:var(--dim);background:rgba(13,17,23,.7);padding:0 6px;border-radius:6px}
.tabs{display:flex;gap:6px;margin:10px 0 4px}.tabs button{font:600 13px var(--prose);color:var(--body);background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:5px 14px;cursor:pointer}
.tabs button.on{color:#05221f;background:var(--accent);border-color:var(--accent)}.tabs button:disabled{opacity:.4;cursor:default}
.tabs .n{font-weight:500;opacity:.75;margin-left:2px}.tabnote{color:var(--dim);font-size:12px;margin-bottom:12px}
.thumb img{width:100%;height:100%;object-fit:contain;display:block}
.nothumb{display:flex;align-items:center;justify-content:center;height:100%;color:var(--dim);font-size:12px}
.body{padding:10px 12px 12px;display:flex;flex-direction:column;gap:6px;flex:1}
.title{font-weight:700;line-height:1.3}
.path{font-size:12px;color:var(--dim)}code{font-family:var(--mono);font-size:12px;color:var(--body)}
.chips{display:flex;flex-wrap:wrap;gap:4px}
.chip{font-size:11px;padding:1px 7px;border-radius:999px;border:1px solid var(--line);color:var(--body)}
.chip.fmt.step{color:var(--accent);border-color:var(--accent)}.chip.fmt.glb{color:var(--blue);border-color:var(--blue)}
.chip.var{color:var(--text)}.chip.mark{color:var(--purple);border-color:color-mix(in srgb,var(--purple) 60%,transparent)}
.chip.slot{color:var(--dim)}
.unique{color:var(--body);font-size:13px}
.meta{display:flex;flex-wrap:wrap;gap:4px 12px;font-size:12px;color:var(--dim)}
.from{font-size:12px;color:var(--dim)}
.foot{margin-top:auto;display:flex;align-items:center;justify-content:space-between;gap:8px;padding-top:4px}
.fresh{font-size:12px;color:var(--dim)}.fresh.ok{color:var(--ok)}.fresh.bad{color:var(--warn)}
.dl{font-size:12px;font-weight:600;color:#05221f;background:var(--accent);border-radius:8px;padding:4px 10px;text-decoration:none;white-space:nowrap}
.dl:hover{background:var(--accent-bright)}
details.prev{margin-top:14px}details.prev summary{cursor:pointer;color:var(--body);font-weight:600;margin-bottom:10px}
a{color:var(--accent)}`;
}
}
if (!customElements.get('adom-models')) customElements.define('adom-models', AdomModels);
})();