master
John Lauer Add adom-verified, adom-models and adom-footprint-variants (moved from john/ pages) with their tools 7acd92d 16d ago
/*!
 * <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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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);
})();