refactor(web-ui): extract shared component primitives

Introduce components.js with barTrack, barRow, barRankList, metricPill,
metricStrip, and chartHeader helpers. Migrate dashboard.js and usage.js
to use these primitives, replacing 13 families of duplicated CSS
(stat-list, fw-bar, token-bar, metric-pill, chart-insight, chart-header,
usage-chart-total, etc.) with a unified .am-* namespace. Net: -256 lines.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
William Valentin
2026-05-22 12:21:48 -07:00
parent 8753c0c9d5
commit c44e7fe72e
4 changed files with 384 additions and 538 deletions
+102
View File
@@ -0,0 +1,102 @@
// ── components.js — shared UI primitives ─────────────────
//
// One-stop helpers for the repeating bar / pill / chart-header
// patterns across pages. Renderers return HTML strings — callers
// inject via innerHTML and wire events on the resulting DOM.
import { escapeHTML } from './utils.js';
// ── Bar primitives ───────────────────────────────────────
// barTrack — just the percentage bar (no label/count head).
// value: numeric value to display
// max: ceiling for percentage; 0/falsy means 0%
// fwClass: optional framework slug (openclaw, claude-code, hermes, …)
// — applied as fw-<slug> on the fill for color
// size: 'xs' | 'sm' | 'md' | 'lg' (default 'md')
// modifier: extra class on the fill (e.g. 'model', 'input', 'output')
export function barTrack({ value = 0, max = 0, fwClass = '', size = 'md', modifier = '' } = {}) {
const pct = max > 0 ? Math.min(100, (value / max) * 100).toFixed(1) : '0';
const fillClasses = ['am-bar-fill'];
if (fwClass) fillClasses.push('fw-' + fwClass);
if (modifier) fillClasses.push(modifier);
return `<div class="am-bar-track am-bar-track--${escapeHTML(size)}"><div class="${fillClasses.join(' ')}" style="width:${pct}%"></div></div>`;
}
// barRow — head (name + count) plus track.
// countDisplay overrides the rendered count text (e.g. "238" vs "238 events").
export function barRow({ name = '', count = 0, countDisplay, max = 0, fwClass = '', size = 'sm', modifier = '' } = {}) {
const display = countDisplay != null ? countDisplay : count;
return `
<div class="am-bar-row">
<div class="am-bar-row-head">
<span class="am-bar-row-name">${escapeHTML(String(name))}</span>
<span class="am-bar-row-count">${escapeHTML(String(display))}</span>
</div>
${barTrack({ value: count, max, fwClass, size, modifier })}
</div>`;
}
// barRankList — ranked list of barRows wrapped in <ul class="am-bar-list">.
// items: array of arbitrary objects
// mapItem: fn(item) → { name, count, countDisplay?, fwClass?, modifier? }
// maxOverride: ceiling override; default = max of items' counts
// size: bar height (default 'xs' for ranked lists)
// emptyText: shown when items is empty
export function barRankList(items, { mapItem, maxOverride, size = 'xs', emptyText = 'No data' } = {}) {
if (!items || items.length === 0) {
return `<p class="empty-state" style="padding:0.5rem 0;font-size:0.8rem">${escapeHTML(emptyText)}</p>`;
}
const mapped = items.map(item => (mapItem ? mapItem(item) : { name: item.name, count: item.count }));
const max = maxOverride != null ? maxOverride : Math.max(1, ...mapped.map(m => Number(m.count) || 0));
const rows = mapped.map(m => `<li>${barRow({ ...m, max, size })}</li>`).join('');
return `<ul class="am-bar-list">${rows}</ul>`;
}
// ── Pill primitives ──────────────────────────────────────
// metricPill — small label + bold value, optional meta line.
// value: pre-escaped HTML when valueHTML=true, else escaped as text
// valueId: id attribute (for animateCounter targets)
// variant: 'insight' | 'total' | 'range' | '' (default — standalone metric pill)
// alert: adds .alert to the pill (turns value red)
export function metricPill({ label, value = '-', valueId = '', valueHTML = false, meta = '', variant = '', alert = false } = {}) {
const classes = ['am-pill'];
if (variant) classes.push('am-pill--' + variant);
if (alert) classes.push('alert');
const idAttr = valueId ? ` id="${escapeHTML(valueId)}"` : '';
const rendered = valueHTML ? value : escapeHTML(String(value));
const metaLine = meta ? `<span class="am-pill-meta">${escapeHTML(String(meta))}</span>` : '';
return `
<div class="${classes.join(' ')}">
<span class="am-pill-label">${escapeHTML(String(label))}</span>
<strong class="am-pill-value"${idAttr}>${rendered}</strong>
${metaLine}
</div>`;
}
// metricStrip — flex/grid container around a list of pill specs.
// variant: '' | 'insights' (4-up grid) | 'totals' (tight inline pills)
export function metricStrip(pills, { variant = '', className = '' } = {}) {
const classes = ['am-pill-strip'];
if (variant) classes.push('am-pill-strip--' + variant);
if (className) classes.push(className);
return `<div class="${classes.join(' ')}">${pills.map(p => metricPill(p)).join('')}</div>`;
}
// ── Chart header ─────────────────────────────────────────
// chartHeader — title + optional subtitle on the left, raw controls HTML on the right.
// controls is passed through unchanged (legend/buttons/tabs vary by chart).
export function chartHeader({ title = '', subtitle = '', controls = '' } = {}) {
const subtitleHTML = subtitle ? `<span class="am-chart-subtitle">${escapeHTML(subtitle)}</span>` : '';
const controlsHTML = controls ? `<div class="am-chart-controls">${controls}</div>` : '';
return `
<div class="am-chart-header">
<div class="am-chart-title-group">
<span class="am-chart-title">${escapeHTML(title)}</span>
${subtitleHTML}
</div>
${controlsHTML}
</div>`;
}