master
John Lauer <adom-states>: a component's visible states (adom/component-states@1), one model and picture per state e5358fa 13d ago
/*!
 * <adom-states> - the visible states of a component (an LED off and on), one model per state.
 *
 * Drop-in (wiki README):
 *   ::adom-states{src="/OWNER/SLUG/render/states.json"}
 *
 * Data contract (adom/component-states@1), committed in the component page's repo as states.json:
 *   { "schema": "adom/component-states@1", "mpn": "IN-S63BTG", "kind": "led", "default": "off",
 *     "states": [
 *       { "id": "off", "label": "Off", "summary": "...", "models": { "step": "IN-S63BTG-uncoloured.step", "glb": "states/IN-S63BTG-off.glb" },
 *         "image": "states/IN-S63BTG-state-off.png" },
 *       { "id": "on", "label": "On (green)", "summary": "...", "models": { "step": "IN-S63BTG.step", "glb": "states/IN-S63BTG-on.glb" },
 *         "image": "states/IN-S63BTG-state-on.png",
 *         "look": { "lit": "the materials the off model does not have (the lens)", "emissive_strength": 1.3, "glow": "#2bd17e" },
 *         "when": { "forward_current_mA": [0.1, 25], "text": "forward current through A to K above about 0.1 mA" } } ],
 *     "electrical": { "vf_V": 3.2, "if_max_mA": 25, "lambda_d_nm": 525 } }
 * Every state names its own models (a viewer swaps them; a simulator shows the state a test expects) and, for the
 * page, a rendered image. Self-contained (Shadow DOM), the same brand tokens as <adom-models>.
 */
(() => {
  const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
  const KIND = { led: 'LED', relay: 'Relay', switch: 'Switch', display: 'Display', lamp: 'Lamp' };

  class AdomStates 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 component states…</div>');
      if (!src) return this._paint('<div class="empty">no src: point me at a states.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;
      try { this._d = await (await fetch(abs)).json(); }
      catch (e) { return this._paint('<div class="empty">could not load ' + esc(src) + '</div>'); }
      if (this._d.schema !== 'adom/component-states@1') return this._paint('<div class="empty">not an adom/component-states@1 file</div>');
      this._cur = this.getAttribute('state') || this._d.default || this._d.states?.[0]?.id;
      this._render();
      this.shadowRoot.addEventListener('click', e => {
        const b = e.target.closest('button[data-s]'); if (!b) return;
        this._cur = b.dataset.s; this._render();
        this.dispatchEvent(new CustomEvent('statechange', { detail: { state: this._cur }, bubbles: true, composed: true }));
      });
    }
    _abs(p) { return p ? new URL(p, this._base).href : ''; }
    _dl(p) { return this._api ? `${this._api}/files/${p.split('/').map(encodeURIComponent).join('/')}` : this._abs(p); }
    _render() {
      const d = this._d, st = (d.states || []).find(s => s.id === this._cur) || d.states?.[0] || {};
      const glow = st.look?.glow || null;
      const tabs = (d.states || []).map(s => `<button data-s="${esc(s.id)}" class="${s.id === st.id ? 'on' : ''}" aria-pressed="${s.id === st.id}">${esc(s.label || s.id)}</button>`).join('');
      const img = st.image ? `<img src="${esc(this._abs(st.image))}" alt="${esc(d.mpn)} ${esc(st.label || st.id)}" loading="lazy">` : `<div class="nothumb">no image for this state</div>`;
      const files = Object.entries(st.models || {}).map(([fmt, p]) => `<a class="file" href="${esc(this._dl(p))}" download="${esc(p.split('/').pop())}"><span class="chip fmt ${esc(fmt)}">${esc(fmt.toUpperCase())}</span><code>${esc(p.split('/').pop())}</code></a>`).join('');
      const e = d.electrical || {};
      const elec = [e.vf_V != null && `Vf ${e.vf_V} V`, e.if_max_mA != null && `If max ${e.if_max_mA} mA`, e.lambda_d_nm != null && `λd ${e.lambda_d_nm} nm`].filter(Boolean).join(' · ');
      this._paint(`<div class="h">States<span class="part">${esc(d.mpn || '')} · ${esc(KIND[d.kind] || d.kind || 'component')}</span></div>
        <div class="sub">One 3D model per state, so a viewer or a simulation shows the part as it looks ${esc(d.kind === 'led' ? 'off and lit' : 'in each state')}.</div>
        <div class="tabs" role="group" aria-label="State">${tabs}</div>
        <div class="stage${glow ? ' lit' : ''}" style="${glow ? `--glow:${esc(glow)}` : ''}">${img}<span class="vlabel">${esc(st.label || st.id)}${st.id === d.default ? ' · default' : ''}</span></div>
        <div class="body">
          ${st.summary ? `<div class="sum">${esc(st.summary)}</div>` : ''}
          ${st.when?.text ? `<div class="when"><b>When:</b> ${esc(st.when.text)}</div>` : ''}
          ${files ? `<div class="files">${files}</div>` : ''}
          ${elec ? `<div class="meta">${esc(elec)}</div>` : ''}
        </div>`);
    }
    _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;
        --accent:#00b8b1;--accent-bright:#00e6dc;--blue:#64abff;
        --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)}
      .wrap{background:var(--bg);border:1px solid var(--line);border-radius:14px;padding:16px;color:var(--text);font:14px/1.5 var(--prose);max-width:560px}
      .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 10px;font-size:13px}
      .tabs{display:flex;gap:6px;margin:0 0 10px}.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)}
      .stage{position:relative;background:var(--panel);border:1px solid var(--line);border-radius:12px;overflow:hidden;aspect-ratio:1/1;max-height:320px;display:flex;align-items:center;justify-content:center}
      .stage img{width:100%;height:100%;object-fit:contain;display:block}
      .stage.lit{box-shadow:inset 0 0 60px -18px var(--glow)}
      .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}
      .nothumb{color:var(--dim);font-size:12px}
      .body{display:flex;flex-direction:column;gap:6px;margin-top:10px}
      .sum{color:var(--body)}.when{color:var(--body);font-size:13px}.when b{color:var(--text)}
      .files{display:flex;flex-wrap:wrap;gap:6px 14px}.file{display:inline-flex;gap:6px;align-items:center;text-decoration:none;color:var(--body)}
      code{font-family:var(--mono);font-size:12px;color:var(--body)}
      .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)}
      .meta{font-size:12px;color:var(--dim)}
      a{color:var(--accent)}`;
    }
  }
  if (!customElements.get('adom-states')) customElements.define('adom-states', AdomStates);
})();