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.
← Commit history
adom-table: bundle the adom-widget runtime into the served script
The wiki's widget frame loads exactly ONE script and provides no runtime, so a
registered script has to be self-contained. All six pre-existing registered
widgets are; adom-table was not, and its first line is a silent
'if (!window.AdomWidget) return', so <adom-table> defined nothing and rendered
NOTHING on a real page.
Measured in a replica of the live frame: before, defined=false hasRuntime=false
rows=0. After, defined=true shadow=true rows=8 fetched through src.
1 file changed
+484
widgets/adom-table.js+484@@ -1,3 +1,487 @@+/*! adom-table -- bundled: adom-widget runtime + the widget.+ * The wiki's widget frame loads ONE script and provides no runtime, so a registered+ * script must be self-contained. Every other registered widget is. Shipping the widget+ * alone made <adom-table> render nothing at all on a real page.+ */+/*!+ * adom-widget - the runtime every Adom widget is built on.+ *+ * A widget is an embeddable component that renders on a page. It has two modes:+ *+ * front-end mode the output is a pure function of data already on the page.+ * No server. Cannot go stale, cannot break, works offline.+ * back-end mode the widget calls an app's back-end, because the work is+ * something a browser cannot do: run a binary, hold shared+ * consensus, or answer a question whose answer changes+ * without the page changing.+ *+ * Choose front-end mode unless one of those three is true. That is the whole+ * design rule, and this runtime makes front-end mode the path of least effort.+ *+ * Zero dependencies. Never bundles a framework. Works in three hosts:+ * a wiki readme frame, a wiki section frame, and a standalone page or+ * dashboard. Host-specific mechanics are detected, never assumed, so a widget+ * written for a wiki page also runs inside adom-lbr or a dock dashboard.+ */+(function (global) {+ 'use strict';++ var VERSION = '1.0.0';++ /* ---------------------------------------------------------------- host --+ * Detected, not configured. A widget must never ask which host it is in+ * to decide what it renders; it may ask to decide how it talks outward.+ */+ var host = (function () {+ var framed = false;+ try { framed = global.parent && global.parent !== global; } catch (_) { framed = true; }+ // An opaque-origin frame (the readme sandbox) throws on storage access.+ var opaque = false;+ try { global.localStorage.getItem('_aw'); } catch (_) { opaque = true; }+ return {+ framed: !!framed,+ opaque: opaque,+ // The readme frame is sandboxed to an opaque origin; a section frame is not.+ kind: !framed ? 'standalone' : (opaque ? 'readme' : 'framed')+ };+ }());++ /* Storage that degrades instead of throwing. localStorage is unavailable at+ * the readme frame's opaque origin, and a bare access there is a hard throw+ * that kills widget startup before anything renders. */+ var store = (function () {+ var mem = {};+ var real = null;+ try {+ global.localStorage.setItem('_aw', '1');+ global.localStorage.removeItem('_aw');+ real = global.localStorage;+ } catch (_) { real = null; }+ return {+ persistent: !!real,+ get: function (k) { try { return real ? real.getItem(k) : (k in mem ? mem[k] : null); } catch (_) { return null; } },+ set: function (k, v) { try { if (real) real.setItem(k, v); else mem[k] = String(v); } catch (_) { mem[k] = String(v); } },+ del: function (k) { try { if (real) real.removeItem(k); else delete mem[k]; } catch (_) { delete mem[k]; } }+ };+ }());++ /* ----------------------------------------------------------- host bridge --+ * Height reporting. A framed widget must tell its host how tall it is or it+ * renders into a fixed-height box and clips. Every widget shipped before this+ * runtime carried a byte-identical copy of this; now none of them do.+ */+ var bridge = {+ reportHeight: function () {+ if (!host.framed) return;+ var h = 0;+ try { h = document.body ? document.body.scrollHeight : 0; } catch (_) { }+ h = Math.max(120, Math.min(16000, h || 200));+ try { global.parent.postMessage({ type: 'readme-height', height: h }, '*'); } catch (_) { }+ },+ // Zoom an image to the host's lightbox. Falls back to a new tab when the+ // host offers no lightbox, so this is safe to call from any host.+ lightbox: function (src, alt) {+ if (host.framed) {+ try { global.parent.postMessage({ type: 'readme-lightbox', src: src, alt: alt || '' }, '*'); return; } catch (_) { }+ }+ try { global.open(src, '_blank', 'noopener'); } catch (_) { }+ }+ };++ // Auto-grow. Observed rather than polled, with a settle timer for late assets.+ var autoHeight = (function () {+ var armed = false;+ return function () {+ if (armed || !host.framed) return;+ armed = true;+ var fire = function () { bridge.reportHeight(); };+ if (global.ResizeObserver && document.body) {+ try { new global.ResizeObserver(fire).observe(document.body); } catch (_) { }+ }+ global.addEventListener('load', fire);+ [0, 60, 250, 800, 2000].forEach(function (t) { setTimeout(fire, t); });+ };+ }());++ /* ------------------------------------------------------------------ bus --+ * The cross-highlight bus. Hovering pin 7 in a pinout lights pad 7 in the+ * footprint, pin 7 in the symbol, and lead 7 in the 3D view, even though+ * each of those is a separate frame. BroadcastChannel carries it same-origin;+ * a parent relay carries it across frames.+ */+ var bus = (function () {+ // Wire protocol, as deployed by adom-lbr's symbol/footprint/3D viewers:+ // { type: 'lbr-hover', pin: <string|null>, mpn: <string>, src: <sender id> }+ // `src` is the sender's own id, so a sender ignores its own echo instead of+ // ping-ponging. Do not "improve" this shape; other frames already speak it.+ var TYPE = 'lbr-hover';+ var chan = null;+ try { chan = ('BroadcastChannel' in global) ? new global.BroadcastChannel(TYPE) : null; } catch (_) { chan = null; }+ var subs = [];+ var onMsg = function (ev) {+ var d = ev && ev.data;+ if (!d || d.type !== TYPE) return;+ subs.forEach(function (s) {+ if (d.src && d.src === s.id) return; // our own echo+ try { s.fn(d); } catch (_) { }+ });+ };+ if (chan) chan.onmessage = onMsg;+ global.addEventListener('message', onMsg);+ var seq = 0;+ return {+ newId: function () { return 'w' + (++seq) + Math.random().toString(36).slice(2, 8); },+ emit: function (id, detail) {+ var msg = Object.assign({ type: TYPE, src: id }, detail || {});+ try { if (chan) chan.postMessage(msg); } catch (_) { }+ try { if (host.framed) global.parent.postMessage(msg, '*'); } catch (_) { }+ },+ on: function (id, fn) {+ var s = { id: id, fn: fn };+ subs.push(s);+ return function () { var i = subs.indexOf(s); if (i >= 0) subs.splice(i, 1); };+ },+ close: function () { try { if (chan) chan.close(); } catch (_) { } }+ };+ }());++ /* -------------------------------------------------------------- backend --+ * Back-end mode. The single most confusing failure in this system is a+ * blocked fetch: the browser reports a bare TypeError, which reads like the+ * server is down when in fact the page never allowed the origin. This turns+ * that into the sentence that names the fix.+ */+ function Backend(origin) {+ // Deliberately does not throw. Constructing this happens at define() time,+ // and a throw there kills the whole script before any element upgrades:+ // the widget then renders nothing at all, with no clue why. An unusable+ // origin is recorded and reported when something actually calls it.+ this.origin = String(origin || '').replace(/\/+$/, '') + '/';+ this.invalid = (!/^https:\/\//.test(this.origin) &&+ !/^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?\//.test(this.origin))+ ? 'back-end origin must be https (got "' + origin + '"). A widget opened from ' ++ 'a file:// page has no origin to infer; pass one explicitly.'+ : null;+ }+ Backend.prototype.url = function (path) { return this.origin + String(path || '').replace(/^\/+/, ''); };+ Backend.prototype.call = function (path, opts) {+ var self = this;+ var o = opts || {};+ if (this.invalid) return Promise.reject(new Error(this.invalid));+ return global.fetch(this.url(path), o).then(function (r) {+ if (!r.ok) {+ return r.text().then(function (t) {+ throw new Error('back-end ' + r.status + ' from ' + self.origin + (t ? ': ' + t.slice(0, 200) : ''));+ });+ }+ var ct = r.headers.get('content-type') || '';+ return ct.indexOf('json') >= 0 ? r.json() : r.text();+ }).catch(function (err) {+ // A CSP block and a dead server both surface as TypeError. Say which.+ if (err instanceof TypeError) {+ throw new Error(+ 'Could not reach ' + self.origin + '. If the back-end is up, this page has not ' ++ 'allowed the origin: add it to widget_connect in page.json.'+ );+ }+ throw err;+ });+ };+ Backend.prototype.health = function () { return this.call('health'); };++ /* ----------------------------------------------------------------- data --+ * How a widget receives input, in priority order:+ * 1. an inline <script type="application/json"> child <- use this on a wiki page+ * 2. a src="" URL+ * 3. a data-* attribute holding JSON+ *+ * Inline is first because the readme frame's connect-src is 'self' plus the+ * declared back-ends: a widget that fetches its own data from elsewhere is+ * blocked, and it is the failure people hit first.+ */+ function readData(el) {+ // Set imperatively (el.data = {...}). Wins over markup, which is what a+ // dashboard host needs: it owns the data and re-feeds the same element.+ if (el._props && el._props.data != null) return { data: el._props.data, from: 'property' };+ var inline = el.querySelector('script[type="application/json"]');+ if (inline && inline.textContent.trim()) {+ try { return { data: JSON.parse(inline.textContent), from: 'inline' }; }+ catch (e) { return { error: 'inline JSON is malformed: ' + e.message }; }+ }+ var raw = el.getAttribute('data-json');+ if (raw) {+ try { return { data: JSON.parse(raw), from: 'attribute' }; }+ catch (e) { return { error: 'data-json is malformed: ' + e.message }; }+ }+ var src = el.getAttribute('src');+ if (src) return { src: src, from: 'src' };+ return { data: null, from: 'none' };+ }++ /* ---------------------------------------------------------------- theme --+ * One palette, so widgets from different apps sitting on one page look like+ * one product. Consumers override by setting these on any ancestor.+ */+ var THEME = [+ '--aw-bg:#0d1117', '--aw-panel:#161b22', '--aw-line:#30363d',+ '--aw-tx:#e6edf3', '--aw-tx2:#9198a1', '--aw-tx3:#6e7681',+ '--aw-teal:#00e6dc', '--aw-blue:#58a6ff', '--aw-purple:#bc8cff',+ '--aw-red:#f85149', '--aw-green:#3fb950', '--aw-amber:#d29922',+ '--aw-radius:10px',+ '--aw-font:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif',+ '--aw-mono:ui-monospace,SFMono-Regular,"JetBrains Mono",Menlo,monospace'+ ].join(';');++ var BASE_CSS =+ ':host{display:block;' + THEME + ';font-family:var(--aw-font);color:var(--aw-tx);' ++ 'box-sizing:border-box;contain:content}' ++ '*,*::before,*::after{box-sizing:border-box}' ++ '.aw-card{background:var(--aw-panel);border:1px solid var(--aw-line);' ++ 'border-radius:var(--aw-radius);overflow:hidden}' ++ '.aw-state{padding:18px;font-size:13px;line-height:1.5;color:var(--aw-tx2);' ++ 'display:flex;align-items:center;gap:9px}' ++ '.aw-state.err{color:var(--aw-red)}' ++ '.aw-spin{width:13px;height:13px;border:2px solid var(--aw-line);' ++ 'border-top-color:var(--aw-teal);border-radius:50%;animation:aw-sp .7s linear infinite;flex:none}' ++ '@keyframes aw-sp{to{transform:rotate(360deg)}}' ++ '@media (prefers-reduced-motion:reduce){.aw-spin{animation:none}}';++ function esc(s) {+ return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {+ return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];+ });+ }++ /* --------------------------------------------------------------- define --+ * The whole authoring surface. A widget supplies css + render(), and gets+ * shadow DOM, data loading, states, the bus, height reporting, cleanup and+ * a re-registration guard for free.+ */+ function define(tag, spec) {+ if (!/^[a-z][a-z0-9]*(-[a-z0-9]+)+$/.test(tag)) {+ throw new Error('adom-widget: "' + tag + '" is not a valid custom element name');+ }+ if (global.customElements && global.customElements.get(tag)) return global.customElements.get(tag);++ var backend = spec.backend ? new Backend(spec.backend.origin || spec.backend) : null;++ function Widget() { return Reflect.construct(HTMLElement, [], Widget); }+ Widget.prototype = Object.create(HTMLElement.prototype);+ Widget.prototype.constructor = Widget;+ Object.setPrototypeOf(Widget, HTMLElement);++ Object.defineProperty(Widget, 'observedAttributes', {+ get: function () { return (spec.attributes || []).concat(['src', 'data-json']); }+ });++ // Imperative properties. Assigning re-renders, and assigning before the+ // element is upgraded still works (the value is replayed on connect).+ ['data'].concat(spec.props || []).forEach(function (name) {+ Object.defineProperty(Widget.prototype, name, {+ configurable: true,+ get: function () { return this._props ? this._props[name] : undefined; },+ set: function (v) {+ if (!this._props) this._props = {};+ this._props[name] = v;+ if (this._aw) this._aw.reload();+ }+ });+ });++ Widget.prototype.attributeChangedCallback = function (name, oldV, newV) {+ if (this._aw && oldV !== newV) this._aw.reload();+ };++ Widget.prototype.connectedCallback = function () {+ var el = this;+ if (el._aw) return;+ // A property assigned before the definition loaded sits as an own+ // property, shadowing the prototype accessor. Replay it.+ ['data'].concat(spec.props || []).forEach(function (n) {+ if (Object.prototype.hasOwnProperty.call(el, n)) {+ var v = el[n];+ delete el[n];+ el[n] = v;+ }+ });+ var root = el.shadowRoot || el.attachShadow({ mode: 'open' });+ var teardown = [];++ var ctx = {+ version: VERSION,+ host: host,+ store: store,+ bus: (function () {+ var id = bus.newId();+ return {+ id: id,+ emit: function (detail) { bus.emit(id, detail); },+ on: function (fn) { var off = bus.on(id, fn); teardown.push(off); return off; }+ };+ }()),+ backend: backend,+ lightbox: bridge.lightbox,+ esc: esc,+ attr: function (n, dflt) { var v = el.getAttribute(n); return v == null ? dflt : v; },+ // --- back-end mode helpers -------------------------------------+ // Run a back-end call behind a button. Disables the button, shows+ // progress, renders failure in place, and always re-enables. Every+ // back-end widget was hand-rolling this, and the ones that forgot the+ // re-enable left a dead button after the first error.+ task: function (btn, outEl, fn, opts) {+ var o = opts || {};+ if (btn) { btn.disabled = true; }+ if (outEl) {+ outEl.innerHTML = '<div class="aw-state"><div class="aw-spin"></div><div>' ++ esc(o.busy || 'Working') + '</div></div>';+ }+ return Promise.resolve().then(fn).catch(function (e) {+ if (outEl) {+ outEl.innerHTML = '<div class="aw-state err">' + esc(e.message || String(e)) + '</div>';+ }+ throw e;+ }).then(function (v) {+ if (btn) { btn.disabled = false; if (o.again) btn.textContent = o.again; }+ return v;+ }, function (e) {+ if (btn) { btn.disabled = false; if (o.again) btn.textContent = o.again; }+ console.error('[' + tag + '] task:', e);+ });+ },++ // A wiki issue link, prefilled. Widgets are how most readers meet a+ // part, so they are also where a wrong part gets reported.+ reportIssueUrl: function (o) {+ o = o || {};+ var page = o.page || ctx.attr('page', '');+ if (!page) return null;+ return 'https://wiki.adom.inc/' + page + '/issues?category=' ++ encodeURIComponent(o.category || 'bug-report') ++ '&title=' + encodeURIComponent(o.title || '') ++ '&body=' + encodeURIComponent(o.body || '');+ },++ // Hand the reader a file. The readme frame grants allow-downloads but+ // blocks navigation, so a data: URL is the only route that works in+ // every host. Never a link to another origin.+ fileUrl: function (bytesOrB64, mime) {+ var b64 = typeof bytesOrB64 === 'string'+ ? bytesOrB64+ : global.btoa(String.fromCharCode.apply(null, new Uint8Array(bytesOrB64)));+ return 'data:' + (mime || 'application/octet-stream') + ';base64,' + b64;+ },++ bytes: function (n) {+ if (n == null) return '';+ return n < 1024 ? n + ' B' : (n / 1024).toFixed(1) + ' KB';+ },++ // Ask the host to resize after content changes height (images, 3D, tabs).+ resize: bridge.reportHeight,+ onTeardown: function (fn) { teardown.push(fn); }+ };++ var paint = function (bodyHtml) {+ root.innerHTML = '<style>' + BASE_CSS + (spec.css || '') + '</style>' + bodyHtml;+ bridge.reportHeight();+ };+ var state = function (msg, isErr) {+ paint('<div class="aw-card"><div class="aw-state' + (isErr ? ' err' : '') + '">' ++ (isErr ? '' : '<div class="aw-spin"></div>') + '<div>' + esc(msg) + '</div></div></div>');+ };++ var render = function (data) {+ try {+ var out = spec.render.call(el, data, ctx);+ var done = function (html) {+ paint(html == null ? '' : html);+ if (spec.mounted) {+ try { spec.mounted.call(el, root, data, ctx); }+ catch (e) { console.error('[' + tag + '] mounted:', e); }+ }+ bridge.reportHeight();+ };+ if (out && typeof out.then === 'function') out.then(done, function (e) { state(e.message || String(e), true); });+ else done(out);+ } catch (e) {+ console.error('[' + tag + '] render:', e);+ state(e.message || String(e), true);+ }+ };++ var load = function () {+ var d = readData(el);+ if (d.error) return state(d.error, true);+ if (d.from === 'src') {+ state(spec.loadingText || 'Loading');+ return global.fetch(d.src)+ .then(function (r) {+ if (!r.ok) throw new Error('could not load ' + d.src + ' (' + r.status + ')');+ return r.json();+ })+ .then(render)+ .catch(function (e) {+ var m = (e instanceof TypeError)+ ? 'Could not load ' + d.src + '. On a wiki page, put the data in an inline ' ++ 'JSON script tag instead: the frame may only fetch from its own origin.'+ : (e.message || String(e));+ state(m, true);+ });+ }+ if (d.data == null && spec.requiresData !== false) {+ return state(spec.emptyText || 'No data supplied to ' + tag, true);+ }+ render(d.data);+ };++ el._aw = {+ ctx: ctx, reload: load, state: state, paint: paint,+ destroy: function () { teardown.forEach(function (f) { try { f(); } catch (_) { } }); teardown = []; }+ };++ autoHeight();++ // The HTML parser fires connectedCallback on the OPENING tag, so an inline+ // <script type="application/json"> child does not exist yet: readData() finds+ // nothing and the widget reports "No data supplied" for data sitting right+ // there in the markup. Only reachable while the document is still parsing,+ // which is why nobody hit it -- the wiki injects widget scripts after parse,+ // and test/framed.html happens to put its <script> tags AFTER the element.+ // Put them in <head> and every inline-data widget breaks.+ //+ // Defer to DOMContentLoaded, but ONLY when nothing is resolvable yet, so a+ // widget whose data is already in hand still paints at parse time.+ if (document.readyState === 'loading' && readData(el).from === 'none') {+ document.addEventListener('DOMContentLoaded', function () {+ if (el._aw) load();+ }, { once: true });+ } else {+ load();+ }+ };++ Widget.prototype.disconnectedCallback = function () {+ if (this._aw) { this._aw.destroy(); this._aw = null; }+ };++ global.customElements.define(tag, Widget);+ return Widget;+ }++ global.AdomWidget = {+ version: VERSION,+ define: define,+ host: host,+ store: store,+ bus: bus,+ backend: function (origin) { return new Backend(origin); },+ lightbox: bridge.lightbox,+ reportHeight: bridge.reportHeight,+ esc: esc,+ theme: THEME+ };+}(typeof window !== 'undefined' ? window : this));+ /*! * <adom-table> — render a JSON array as a filterable, sortable table. *