← Commit history

<adom-states>: a component's visible states (adom/component-states@1), one model and picture per state

John Lauer ·e5358faa94 ·13d ago ·parent 8bc8a39
2 files changed +97
README.md+1
@@ -23,6 +23,7 @@ adom-wiki pkg install adom/component-widgets | `<adom-moleculize>` | Board → workcell-molecule preview (reads a dropped `.kicad_pcb` client-side, no backend) + AI-thread generate | drop a `.kicad_pcb`, or `{ name, width_mm, height_mm }` | | `<adom-verified>` | "Verified by" trust mark + verification log; amber when a verified file changes | `verification.json` (adom/verification@1), `tools/verify_log.py` | | `<adom-models>` | Every 3D file on the page: STEP / GLB tabs, top + underside previews, what is unique, freshness | `models.json` (adom/models@1), `tools/models_json.py` |+| `<adom-states>` | A component's visible states (an LED off and lit): a toggle, a picture of each state, when it applies, its STEP and GLB | `states.json` (adom/component-states@1) | | `<adom-footprint-variants>` | The footprint's variant family (heatsink vias, solder balls, rounded pads) | `variants.json`, `tools/make-variants.py` |  Each is one custom element: Shadow DOM, no dependencies, light + dark (or
widgets/states-widget.jsadded+96
@@ -0,0 +1,96 @@+/*!+ * <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);+})();