// 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) => `
  • ${escapeHtml(c.label)} ${escapeHtml(c.group || '')}
  • `).join(''); list.querySelectorAll('li').forEach(li => { li.addEventListener('click', () => { const idx = parseInt(li.dataset.i, 10); if (!Number.isNaN(idx)) runFiltered(idx); }); }); } function filterPalette(q) { q = (q || '').trim().toLowerCase(); if (!q) paletteFiltered = paletteCommands.slice(0, 30); else { paletteFiltered = paletteCommands.filter(c => { const hay = (c.label + ' ' + (c.group || '') + ' ' + (c.keywords || '')).toLowerCase(); return hay.includes(q); }).slice(0, 40); } paletteIndex = 0; renderPalette(); } function runFiltered(i) { const c = paletteFiltered[i]; if (!c) return; closePalette(); try { c.run(); } catch (e) { err(String(e)); } } export function openPalette(prefill = '') { const bg = document.getElementById('palette-bg'); const inp = document.getElementById('palette-input'); if (!bg || !inp) return; paletteOpen = true; bg.classList.add('open'); bg.setAttribute('aria-hidden', 'false'); inp.value = prefill; filterPalette(prefill); setTimeout(() => inp.focus(), 10); } export function closePalette() { const bg = document.getElementById('palette-bg'); if (!bg) return; paletteOpen = false; bg.classList.remove('open'); bg.setAttribute('aria-hidden', 'true'); } export function initPalette() { const trigger = document.getElementById('cmd-trigger'); const bg = document.getElementById('palette-bg'); const inp = document.getElementById('palette-input'); if (!trigger || !bg || !inp) return; trigger.addEventListener('click', () => openPalette()); bg.addEventListener('click', (e) => { if (e.target === bg) closePalette(); }); inp.addEventListener('input', (e) => filterPalette(e.target.value)); inp.addEventListener('keydown', (e) => { if (e.key === 'Escape') return closePalette(); if (e.key === 'ArrowDown') { e.preventDefault(); paletteIndex = Math.min(paletteFiltered.length - 1, paletteIndex + 1); renderPalette(); } if (e.key === 'ArrowUp') { e.preventDefault(); paletteIndex = Math.max(0, paletteIndex - 1); renderPalette(); } if (e.key === 'Enter') { e.preventDefault(); runFiltered(paletteIndex); } }); window.addEventListener('keydown', (e) => { const k = (e.key || '').toLowerCase(); if ((e.metaKey || e.ctrlKey) && k === 'k') { e.preventDefault(); paletteOpen ? closePalette() : openPalette(); } if (paletteOpen && e.key === 'Escape') closePalette(); }); } // ---------- helpers ---------- export function escapeHtml(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } export function skeleton(lines = 3) { return Array.from({ length: lines }).map((_, i) => `
    ` ).join(''); } export function fmtJSON(v) { if (v == null) return ''; if (typeof v === 'string') return v; try { return JSON.stringify(v, null, 2); } catch { return String(v); } } export function setHealthPill(state, text) { const pill = document.getElementById('health-pill'); const txt = document.getElementById('health-text'); if (!pill || !txt) return; pill.classList.remove('ok', 'warn', 'err', 'info', 'muted'); pill.classList.add(state || 'muted'); txt.textContent = text || state || '—'; } export function setBrandVer(text) { const el = document.getElementById('brand-ver'); if (el) el.textContent = text || '—'; } export function setFooterBuild(text) { const el = document.getElementById('footer-build'); if (el) el.textContent = text || 'build —'; } // ---------- additional reusable helpers ---------- export async function copyToClipboard(text, label = 'copied') { try { await navigator.clipboard.writeText(text); ok(label); } catch (_) { // fallback for non-secure contexts const ta = document.createElement('textarea'); ta.value = text; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); ok(label); } catch { err('copy failed'); } ta.remove(); } } // Wraps any `
    ` 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; }