236 lines
9.0 KiB
JavaScript
236 lines
9.0 KiB
JavaScript
// HOMECORE-UI shared component helpers — ADR-131 §3.3.
|
|
//
|
|
// Every panel imports from here so cards/pills/buttons/badges are
|
|
// byte-identical across the dashboard (the §3.3 "no visual seam"
|
|
// invariant). Pure DOM, no framework, no build step.
|
|
|
|
/** Hyperscript element factory. `h('div.card#x', {onClick}, ...children)`. */
|
|
export function h(spec, attrs, ...children) {
|
|
let tag = 'div', id = null;
|
|
const classes = [];
|
|
spec.replace(/([.#]?[^.#]+)/g, (tok) => {
|
|
if (tok[0] === '.') classes.push(tok.slice(1));
|
|
else if (tok[0] === '#') id = tok.slice(1);
|
|
else tag = tok;
|
|
return tok;
|
|
});
|
|
const node = document.createElement(tag);
|
|
if (id) node.id = id;
|
|
if (classes.length) node.className = classes.join(' ');
|
|
if (attrs && typeof attrs === 'object' && !(attrs instanceof Node) && !Array.isArray(attrs)) {
|
|
for (const [k, v] of Object.entries(attrs)) {
|
|
if (v == null || v === false) continue;
|
|
if (k === 'class') node.className += ' ' + v;
|
|
else if (k === 'html') node.innerHTML = v;
|
|
else if (k.startsWith('on') && typeof v === 'function') node.addEventListener(k.slice(2).toLowerCase(), v);
|
|
else if (k === 'style' && typeof v === 'object') Object.assign(node.style, v);
|
|
else node.setAttribute(k, v);
|
|
}
|
|
} else if (attrs != null) {
|
|
children.unshift(attrs);
|
|
}
|
|
append(node, children);
|
|
return node;
|
|
}
|
|
|
|
function append(node, children) {
|
|
for (const c of children.flat(Infinity)) {
|
|
if (c == null || c === false) continue;
|
|
node.appendChild(c instanceof Node ? c : document.createTextNode(String(c)));
|
|
}
|
|
}
|
|
|
|
export const txt = (s) => document.createTextNode(s == null ? '' : String(s));
|
|
export const mono = (s) => h('span.mono', String(s == null ? '' : s));
|
|
export const clear = (n) => { while (n.firstChild) n.removeChild(n.firstChild); return n; };
|
|
|
|
/** Status pill. kind ∈ cyan|green|amber|red|purple|grey. */
|
|
export function pill(text, kind = 'grey') {
|
|
return h(`span.pill.${kind}`, String(text));
|
|
}
|
|
|
|
/** Map a free-form status string to the platform colour convention. */
|
|
export function statusPill(status) {
|
|
const s = String(status || '').toLowerCase();
|
|
const map = {
|
|
running: 'green', online: 'green', ok: 'green', healthy: 'green', occupied: 'green', paired: 'green', connected: 'green', valid: 'green',
|
|
stale: 'amber', degraded: 'amber', updating: 'amber', warn: 'amber', warning: 'amber',
|
|
failed: 'red', offline: 'red', error: 'red', veto: 'red', vetoed: 'red', unreachable: 'red', invalid: 'red',
|
|
stopped: 'grey', absent: 'grey', unknown: 'grey', 'not trained': 'grey',
|
|
info: 'purple', epoch: 'purple', chain: 'purple',
|
|
};
|
|
return pill(status, map[s] || 'grey');
|
|
}
|
|
|
|
export function card({ title, tint, accent, clickable, onClick, children = [] } = {}) {
|
|
const cls = ['card'];
|
|
if (tint) cls.push('tint-' + tint);
|
|
if (clickable || onClick) cls.push('clickable');
|
|
const node = h('.' + cls.join('.'));
|
|
if (onClick) node.addEventListener('click', onClick);
|
|
if (accent) node.appendChild(accentBar());
|
|
if (title) node.appendChild(h('h2', title));
|
|
append(node, [children]);
|
|
return node;
|
|
}
|
|
|
|
function accentBar() {
|
|
const b = h('div');
|
|
b.style.height = '3px';
|
|
b.style.borderRadius = '3px';
|
|
b.style.margin = '-14px -10px 14px';
|
|
b.style.background = 'linear-gradient(90deg, var(--cyan), var(--purple))';
|
|
return b;
|
|
}
|
|
|
|
/** Section header with the cyan→purple featured gradient border (§3.3). */
|
|
export function sectionHeader(title, sub) {
|
|
return h('.section-header', h('h1', title), sub ? h('.sub', sub) : null);
|
|
}
|
|
|
|
/** Live metric card (§4.1). */
|
|
export function metric({ icon, value, label, color = 'cyan' }) {
|
|
return h('.metric',
|
|
icon ? h('.ico', icon) : null,
|
|
h(`.val${color === 'green' ? '.green' : ''}`, String(value)),
|
|
h('.lbl', label));
|
|
}
|
|
|
|
export function button(label, { variant = 'ghost', onClick, disabled } = {}) {
|
|
const b = h(`button.btn.${variant}`, label);
|
|
if (disabled) b.disabled = true;
|
|
if (onClick) b.addEventListener('click', onClick);
|
|
return b;
|
|
}
|
|
|
|
/**
|
|
* Progress bar with threshold colouring.
|
|
* thresholds: [{ lt, color }] evaluated in order against the 0..1 ratio.
|
|
*/
|
|
export function bar(value, max = 1, thresholds = null) {
|
|
const ratio = max > 0 ? Math.max(0, Math.min(1, value / max)) : 0;
|
|
let color = '';
|
|
if (thresholds) {
|
|
for (const t of thresholds) { if (ratio < t.lt) { color = t.color; break; } }
|
|
if (!color) color = thresholds[thresholds.length - 1].color;
|
|
}
|
|
const fill = h('span' + (color ? '.' + color : ''));
|
|
fill.style.width = (ratio * 100).toFixed(1) + '%';
|
|
return h('.bar', fill);
|
|
}
|
|
|
|
/** Small inline confidence bar — amber below 0.4 (§4.5). */
|
|
export function confidenceBar(conf) {
|
|
const c = Math.max(0, Math.min(1, conf || 0));
|
|
const fill = h('span' + (c < 0.4 ? '.amber' : ''));
|
|
fill.style.width = (c * 100).toFixed(0) + '%';
|
|
return h('.conf-bar', fill);
|
|
}
|
|
|
|
/**
|
|
* Provenance badge (§4.4 / §6) — ESP32 → SEED → COG → state machine.
|
|
* A first-class element, never collapsed. hailo:true marks Hailo-sourced
|
|
* inference visually distinct from CPU-only COGs (§6 invariant 5).
|
|
*/
|
|
export function provenanceBadge({ esp32, seed, cog, hailo } = {}) {
|
|
return h('span.prov',
|
|
esp32 ? txt(esp32) : null, esp32 ? h('span.arr', '→') : null,
|
|
seed ? txt(seed) : null, h('span.arr', '→'),
|
|
h(hailo ? 'span.hailo' : 'span', cog || 'cog'),
|
|
h('span.arr', '→'), txt('homecore'));
|
|
}
|
|
|
|
/** Tiny inline SVG sparkline. */
|
|
export function sparkline(values, { w = 120, hgt = 28, color = 'var(--cyan)' } = {}) {
|
|
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
svg.setAttribute('width', w); svg.setAttribute('height', hgt); svg.setAttribute('class', 'spark');
|
|
if (!values || values.length < 2) return svg;
|
|
const min = Math.min(...values), max = Math.max(...values), span = max - min || 1;
|
|
const step = w / (values.length - 1);
|
|
const pts = values.map((v, i) => `${(i * step).toFixed(1)},${(hgt - ((v - min) / span) * (hgt - 4) - 2).toFixed(1)}`).join(' ');
|
|
const pl = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
|
|
pl.setAttribute('points', pts); pl.setAttribute('fill', 'none');
|
|
pl.setAttribute('stroke', color); pl.setAttribute('stroke-width', '1.5');
|
|
svg.appendChild(pl);
|
|
return svg;
|
|
}
|
|
|
|
export function banner(text, kind = 'amber', extra) {
|
|
return h(`.banner.${kind}`, text, extra ? txt(' ') : null, extra || null);
|
|
}
|
|
|
|
export function row(k, v) {
|
|
return h('.row', h('span.k', k), v instanceof Node ? v : h('span.v', String(v == null ? '—' : v)));
|
|
}
|
|
|
|
export function kv(pairs) {
|
|
const node = h('.kv');
|
|
for (const [k, v] of pairs) {
|
|
node.appendChild(h('span.k', k));
|
|
node.appendChild(v instanceof Node ? v : h('span.v', String(v == null ? '—' : v)));
|
|
}
|
|
return node;
|
|
}
|
|
|
|
/** Collapsible section. */
|
|
export function collapsible(title, contentFn, open = false) {
|
|
const wrap = h('.collapsible' + (open ? '.open' : ''));
|
|
const head = h('.head', title);
|
|
const body = h('div');
|
|
wrap.appendChild(head); wrap.appendChild(body);
|
|
let built = false;
|
|
const toggle = () => {
|
|
wrap.classList.toggle('open');
|
|
if (wrap.classList.contains('open')) {
|
|
if (!built) { body.appendChild(contentFn()); built = true; }
|
|
body.classList.remove('hidden');
|
|
} else body.classList.add('hidden');
|
|
};
|
|
head.addEventListener('click', toggle);
|
|
if (open) { body.appendChild(contentFn()); built = true; } else body.classList.add('hidden');
|
|
return wrap;
|
|
}
|
|
|
|
/** Slide-over panel (§4.4 StateChanged detail). */
|
|
export function slideover(title, content) {
|
|
const back = h('.slideover-back');
|
|
const panel = h('.slideover', h('span.close', { onClick: close }, '✕'), h('h2', title), content);
|
|
function close() { back.remove(); panel.remove(); }
|
|
back.addEventListener('click', close);
|
|
document.body.appendChild(back);
|
|
document.body.appendChild(panel);
|
|
return { close };
|
|
}
|
|
|
|
/** Lag indicator (§4.1/§4.4 — broadcast channel vs 4096 capacity). */
|
|
export function lagIndicator(state, lagged) {
|
|
const cls = state === 'open' ? (lagged ? 'warn' : '') : 'err';
|
|
const label = state === 'open' ? (lagged ? 'WS lagging — events dropped' : 'WS live') : 'WS offline';
|
|
return h('span.lag', h(`span.dot${cls ? '.' + cls : ''}`), h('span.t2', label));
|
|
}
|
|
|
|
export function relTime(iso) {
|
|
if (!iso) return '—';
|
|
const t = Date.parse(iso);
|
|
if (Number.isNaN(t)) return String(iso);
|
|
const s = Math.round((Date.now() - t) / 1000);
|
|
if (s < 0) return 'in ' + fmtDur(-s);
|
|
if (s < 5) return 'just now';
|
|
return fmtDur(s) + ' ago';
|
|
}
|
|
function fmtDur(s) {
|
|
if (s < 60) return s + 's';
|
|
if (s < 3600) return Math.round(s / 60) + 'm';
|
|
if (s < 86400) return Math.round(s / 3600) + 'h';
|
|
return Math.round(s / 86400) + 'd';
|
|
}
|
|
|
|
/** Loading + error wrappers panels can await. */
|
|
export function loading(label = 'Loading…') { return h('.muted-empty', label); }
|
|
export function errorCard(e) { return banner('Unavailable — ' + (e && e.message ? e.message : e), 'red'); }
|
|
|
|
/** Distinguish "not trained" (null) from "unavailable" (error) — §6 invariant 3. */
|
|
export function notTrained(prompt = 'Calibrate to enable') {
|
|
return h('span.t3', 'Not trained ', button(prompt, { variant: 'ghost' }));
|
|
}
|