// lib/ui.js — toasts, command palette, helpers. const toastsEl = () => document.getElementById('toasts'); export function toast({ kind = 'info', title = '', body = '', ttl = 3800 } = {}) { const host = toastsEl(); if (!host) return; const el = document.createElement('div'); el.className = `toast ${kind}`; el.innerHTML = `
`; el.querySelector('.ttl').textContent = title || ({ ok: 'Success', warn: 'Warning', err: 'Error', info: 'Info', }[kind] || 'Info'); el.querySelector('.body').textContent = body || ''; host.appendChild(el); setTimeout(() => { el.style.opacity = '0'; el.style.transform = 'translateY(8px)'; el.style.transition = 'all 200ms'; setTimeout(() => el.remove(), 220); }, ttl); } export const ok = (body, title) => toast({ kind: 'ok', body, title }); export const warn = (body, title) => toast({ kind: 'warn', body, title }); export const err = (body, title) => toast({ kind: 'err', body, title }); // ---------- Command palette ---------- const paletteCommands = []; let paletteOpen = false; let paletteFiltered = []; let paletteIndex = 0; export function registerCommand(cmd) { // cmd: { id, label, group, run(), keywords?: string } paletteCommands.push(cmd); } function renderPalette() { const list = document.getElementById('palette-list'); if (!list) return; list.innerHTML = paletteFiltered.map((c, i) => `` so it gets a copy button on hover.
export function withCopy(html, id = '') {
const safeId = id || `c${Math.random().toString(36).slice(2, 7)}`;
return `
${html}
`;
}
// Wire up any .copy-btn[data-copy-target] inside `host` after rendering.
export function bindCopyButtons(host) {
host.querySelectorAll('.copy-btn[data-copy-target]').forEach(btn => {
if (btn.dataset.bound) return;
btn.dataset.bound = '1';
btn.addEventListener('click', () => {
const t = host.querySelector('#' + btn.dataset.copyTarget);
if (t) copyToClipboard(t.textContent || '');
});
});
}
// Renders a tiny sparkline (svg) for an array of numbers (0..N values).
export function sparkline(values, { w = 220, h = 36 } = {}) {
if (!values || values.length === 0) return ``;
const min = Math.min(...values);
const max = Math.max(...values);
const span = max - min || 1;
const dx = w / Math.max(1, values.length - 1);
const pts = values.map((v, i) => `${(i * dx).toFixed(1)},${(h - ((v - min) / span) * (h - 4) - 2).toFixed(1)}`);
const line = `M ${pts.join(' L ')}`;
const area = `${line} L ${w},${h} L 0,${h} Z`;
return ``;
}
// Render a horizontal meter row (0..100). State: ok/warn/err/none.
export function meterRow(label, pct, value, state = '') {
const safe = Math.max(0, Math.min(100, Number(pct) || 0));
return `
${escapeHtml(label)}
${escapeHtml(value)}
`;
}
// Pretty-print numbers (bytes / counts).
export function fmtBytes(n) {
if (!n && n !== 0) return '—';
const u = ['B','KB','MB','GB','TB'];
let i = 0, v = +n;
while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; }
return `${v.toFixed(v >= 100 || i === 0 ? 0 : v >= 10 ? 1 : 2)} ${u[i]}`;
}
export function fmtNum(n) {
if (n == null || Number.isNaN(+n)) return '—';
return (+n).toLocaleString();
}
// Tiny localStorage-backed history of items (most recent first).
export function historyStore(key, max = 25) {
const read = () => {
try { return JSON.parse(localStorage.getItem(key) || '[]'); } catch { return []; }
};
return {
list: read,
push(item) {
const arr = read();
arr.unshift({ ...item, _ts: Date.now() });
while (arr.length > max) arr.pop();
try { localStorage.setItem(key, JSON.stringify(arr)); } catch {}
},
clear() { try { localStorage.removeItem(key); } catch {} },
};
}
// Generate a quick HTML form input set from a JSON Schema (best-effort, flat).
export function inputFromSchema(name, schema) {
const lbl = name + (schema?.description ? ` — ${schema.description}` : '');
if (!schema) return ``;
if (schema.enum?.length) {
const opts = schema.enum.map(v => ``).join('');
return ``;
}
if (schema.type === 'boolean') {
return ``;
}
if (schema.type === 'integer' || schema.type === 'number') {
return ``;
}
if (schema.type === 'object' || schema.type === 'array') {
return ``;
}
return ``;
}
export function collectSchemaForm(container) {
const out = {};
container.querySelectorAll('[data-k]').forEach(el => {
const k = el.dataset.k;
const kind = el.dataset.kind || (el.type === 'number' ? 'num' : '');
let v;
if (el.type === 'checkbox') v = el.checked;
else v = el.value;
if (v === '' || v == null) return;
if (kind === 'num') v = Number(v);
else if (kind === 'json') { try { v = JSON.parse(v); } catch { /* leave as string */ } }
out[k] = v;
});
return out;
}