openvibers/openvibe.shared Commits

openvibe-shared 4.232.0: ov-metrics.js, the network's one way to show numbers (#515)

OP OpenVibers committed 7 h ago · 2d7dbb718b · 8 files, +300 −5

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 13e75ce..2022139 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,23 @@
# Changelog
++## 4.232.0 — 2026-11-12
++
++**`ov-metrics.js`: the network's one way to show numbers** (ai-metrics.md). Report, Exchange, Compare and the admin panel
++draw their AI and usage numbers with it, and nobody draws a chart of their own. The look is the theme's (tokens only).
++Every piece works at 390 px and in both themes, and stays still with motion off.
++
++- `OVMetrics.tiles()`: label, value, unit, a delta that knows whether up is good, a sub-line and a trend.
++- `.spark()`: a trend line with its last point.
++- `.bars()`: a ranked bar list.
++- `.percentiles()`: p50, p90 and p95 on one scale, with a spoken summary.
++- `.split()`: one bar in parts, with a legend.
++- `.table()`: sortable on a click, with `aria-sort`, numeric columns and an optional in-cell bar.
++- `OVMetrics.format`: the same words everywhere, and servers get them from `require('openvibe-shared/ov-metrics')`:
++ - `ms` (540 ms · 1.3 s · 1 min 38 s), `usd` ($0.0042 · $61.20), `count` (18k · 9.1M), `tokens`, `rate` (99.4%);
++ - `tps` (86.0 tok/s), `bytes`, `delta` (+12%);
++ - unknown is a dash, never 0.
++- Test `ov-metrics-chrome`.
++
## 4.231.0 — 2026-11-12
**OpenVibe's own icons, and no Font Awesome drawing left** (the owner, 11-12: "make sure no remnants of font awesome or
ddiff --git a/README.md b/README.md
iindex 61f3ea0..1d163dd 100644
---- a/README.md
++++ b/README.md
@@@ -5,7 +5,7 @@
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Install and test:** `npm ci && npm test`
--**Status:** alpha, v4.231.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.232.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex a86a477..a194ac2 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.231.0",
++ "version": "4.232.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
ddiff --git a/files.js b/files.js
iindex 29764f1..895b533 100644
---- a/files.js
++++ b/files.js
@@@ -59,6 +59,7 @@ const BROWSER = Object.freeze([
'ov-thread.js', // one conversation UI (L55 C4): the drawer, search, composer, copy, streaming turns; markup from openvibe-shared/thread, drawn with thread.css
'ov-composer.js', // shared composer
'ov-run.js', // 4.224.0: the one run thread (OpenVibeRun), loaded beside the navbar
++ 'ov-metrics.js', // 4.232.0: the one way to show numbers (tiles, sparklines, bars, percentiles, splits, tables)
'ov-capture.js', // OVCapture: camera, microphone, screen, photo and recording for Live, Cam and the composer (4.216.0)
'ov-combo.js', // shared field dropdown
'ov-searchbox.js', // the one search field (4.229.0): pill, suggestions, recent searches, ghost completion; its list is ov-combo
ddiff --git a/ov-metrics.js b/ov-metrics.js
nnew file mode 100644
iindex 0000000..7483bda
---- /dev/null
++++ b/ov-metrics.js
@@@ -0,0 +1,192 @@
++/**
++ * openvibe-shared/ov-metrics.js — the network's one way to show numbers (ai-metrics.md, 2026-11-12): Report, Exchange,
++ * Compare and the admin panel draw their AI and usage numbers with these, and nobody draws a chart of their own. The
++ * look is the theme's (tokens only); every piece works at 390 px, in both themes, and with motion off.
++ *
++ * OVMetrics.tiles([{ label, value, unit?, sub?, trend?: [n], delta?: 0.12, better?: 'up'|'down', href? }])
++ * OVMetrics.spark(values, { label?, width?, height? }) a trend line with its last point
++ * OVMetrics.bars(rows, { label, value, format?, max?, note? }) a ranked bar list
++ * OVMetrics.percentiles({ p50, p90?, p95 }, { unit: 'ms'|'tps'|'usd', max?, label? }) p50 and p95 on one scale
++ * OVMetrics.split([{ label, value, tone? }], { format? }) one bar split into its parts, with a legend
++ * OVMetrics.table(rows, [{ key, label, format?, numeric?, bar? }], { sort?, caption? }) a sortable table
++ * OVMetrics.format.{ ms, usd, count, tokens, rate, tps, bytes, delta } the same words everywhere
++ *
++ * Builders return DOM elements (no innerHTML of data). In Node only `format` is there (servers write the same words).
++ */
++(function (root, factory) {
++ const api = factory(typeof document !== 'undefined' ? document : null);
++ if (typeof module === 'object' && module.exports) module.exports = api;
++ else root.OVMetrics = api;
++})(typeof globalThis !== 'undefined' ? globalThis : this, function (doc) {
++ 'use strict';
++
++ // ── The words ──
++ const num = (v) => (v == null || v === '' || !Number.isFinite(Number(v)) ? null : Number(v));
++ const DASH = '—';
++ const format = {
++ ms(v) { const n = num(v); if (n == null) return DASH; if (n < 1000) return `${Math.round(n)} ms`; if (n < 60000) return `${(n / 1000).toFixed(n < 10000 ? 1 : 0)} s`; return `${Math.floor(n / 60000)} min ${Math.round((n % 60000) / 1000)} s`; },
++ usd(v) { const n = num(v); if (n == null) return DASH; if (n === 0) return '$0'; if (Math.abs(n) < 0.01) return `$${n.toFixed(4).replace(/0+$/, '')}`; if (Math.abs(n) < 100) return `$${n.toFixed(2)}`; return `$${Math.round(n).toLocaleString('en-US')}`; },
++ count(v) { const n = num(v); if (n == null) return DASH; const a = Math.abs(n); if (a < 1000) return String(Math.round(n)); if (a < 1e6) return `${(n / 1e3).toFixed(a < 1e4 ? 1 : 0)}k`; if (a < 1e9) return `${(n / 1e6).toFixed(a < 1e7 ? 1 : 0)}M`; return `${(n / 1e9).toFixed(1)}B`; },
++ tokens(v) { const s = format.count(v); return s === DASH ? s : `${s} tokens`; },
++ rate(v) { const n = num(v); if (n == null) return DASH; const p = n * 100; return `${p >= 99.95 || p === 0 ? p.toFixed(0) : p >= 10 ? p.toFixed(1) : p.toFixed(2)}%`; },
++ tps(v) { const n = num(v); return n == null ? DASH : `${n >= 100 ? Math.round(n) : n.toFixed(1)} tok/s`; },
++ bytes(v) { const n = num(v); if (n == null) return DASH; const u = ['B', 'KB', 'MB', 'GB', 'TB']; let i = 0; let x = n; while (x >= 1024 && i < u.length - 1) { x /= 1024; i++; } return `${x.toFixed(x < 10 && i ? 1 : 0)} ${u[i]}`; },
++ delta(v) { const n = num(v); if (n == null) return ''; const p = n * 100; return `${p > 0 ? '+' : ''}${Math.abs(p) < 10 ? p.toFixed(1) : Math.round(p)}%`; },
++ };
++ if (!doc) return { format };
++
++ // ── The look: theme tokens only ──
++ const CSS = [
++ '.ovm-tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,150px),1fr));gap:10px}',
++ '.ovm-tile{display:grid;gap:4px;align-content:start;padding:12px 14px;border:1px solid var(--border,#2a3346);border-radius:14px;background:var(--bg-card,transparent);min-width:0;color:inherit;text-decoration:none}',
++ 'a.ovm-tile:hover,a.ovm-tile:focus-visible{border-color:var(--accent,#5b8cff)}',
++ '.ovm-label{font-size:12px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--text-muted,#8b96ad)}',
++ '.ovm-value{font-size:24px;font-weight:700;line-height:1.1;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}',
++ '.ovm-value small{font-size:13px;font-weight:600;color:var(--text-secondary,#b7c0d3);margin-left:3px}',
++ '.ovm-sub{font-size:12.5px;color:var(--text-secondary,#b7c0d3);font-variant-numeric:tabular-nums}',
++ '.ovm-delta{font-size:12px;font-weight:700;font-variant-numeric:tabular-nums}.ovm-delta.is-good{color:var(--success,#22c55e)}.ovm-delta.is-bad{color:var(--danger,#ef4444)}',
++ '.ovm-spark{display:block;width:100%;height:28px;overflow:visible}.ovm-spark path{fill:none;stroke:var(--accent,#5b8cff);stroke-width:2;stroke-linecap:round;stroke-linejoin:round;vector-effect:non-scaling-stroke}.ovm-spark .ovm-area{fill:var(--accent,#5b8cff);opacity:.12;stroke:none}.ovm-spark circle{fill:var(--accent,#5b8cff)}',
++ '.ovm-bars{list-style:none;margin:0;padding:0;display:grid;gap:6px}',
++ '.ovm-bar{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:2px 10px;align-items:center;font-size:13.5px}',
++ '.ovm-bar-l{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.ovm-bar-v{font-variant-numeric:tabular-nums;color:var(--text-secondary,#b7c0d3)}',
++ '.ovm-bar-t{grid-column:1/-1;height:6px;border-radius:999px;background:var(--bg-tertiary,rgba(127,127,127,.15));overflow:hidden}.ovm-bar-t>span{display:block;height:100%;border-radius:inherit;background:var(--accent,#5b8cff)}',
++ '.ovm-bar small{grid-column:1/-1;color:var(--text-muted,#8b96ad);font-size:12px}',
++ '.ovm-pct{display:grid;gap:6px;min-width:0}.ovm-pct-track{position:relative;height:10px;border-radius:999px;background:var(--bg-tertiary,rgba(127,127,127,.15))}',
++ '.ovm-pct-range{position:absolute;top:0;bottom:0;border-radius:999px;background:color-mix(in srgb,var(--accent,#5b8cff) 35%,transparent)}',
++ '.ovm-pct-mark{position:absolute;top:-3px;width:3px;height:16px;border-radius:2px;background:var(--accent,#5b8cff);transform:translateX(-50%)}.ovm-pct-mark.is-tail{background:var(--warning,#f59e0b)}',
++ '.ovm-pct-keys{display:flex;flex-wrap:wrap;gap:4px 14px;font-size:12.5px;color:var(--text-secondary,#b7c0d3);font-variant-numeric:tabular-nums}.ovm-pct-keys b{color:var(--text,inherit)}',
++ '.ovm-split{display:grid;gap:8px}.ovm-split-bar{display:flex;height:12px;border-radius:999px;overflow:hidden;background:var(--bg-tertiary,rgba(127,127,127,.15))}.ovm-split-bar>span{height:100%}',
++ '.ovm-split-legend{list-style:none;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:4px 14px;font-size:12.5px;color:var(--text-secondary,#b7c0d3)}.ovm-split-legend i{display:inline-block;width:9px;height:9px;border-radius:3px;margin-right:6px;vertical-align:0}',
++ '.ovm-t1{background:var(--accent,#5b8cff)}.ovm-t2{background:var(--success,#22c55e)}.ovm-t3{background:var(--warning,#f59e0b)}.ovm-t4{background:var(--text-muted,#8b96ad)}.ovm-t5{background:var(--danger,#ef4444)}',
++ '.ovm-table-wrap{overflow-x:auto;border:1px solid var(--border,#2a3346);border-radius:12px}',
++ '.ovm-table{border-collapse:collapse;width:100%;font-size:13.5px}.ovm-table caption{text-align:left;padding:10px 12px;font-weight:600}',
++ '.ovm-table th,.ovm-table td{padding:8px 12px;border-bottom:1px solid var(--border,#2a3346);text-align:left;white-space:nowrap}.ovm-table tr:last-child td{border-bottom:0}',
++ '.ovm-table th{font-size:12px;font-weight:600;color:var(--text-muted,#8b96ad)}.ovm-table th button{all:unset;cursor:pointer;display:inline-flex;gap:4px;align-items:center}.ovm-table th button:focus-visible{outline:2px solid var(--accent,#5b8cff);outline-offset:2px;border-radius:4px}',
++ '.ovm-table td.is-num,.ovm-table th.is-num{text-align:right;font-variant-numeric:tabular-nums}.ovm-table th.is-num button{flex-direction:row-reverse}',
++ '.ovm-cellbar{display:inline-block;width:48px;height:5px;margin-left:8px;border-radius:999px;background:var(--bg-tertiary,rgba(127,127,127,.15));vertical-align:middle;overflow:hidden}.ovm-cellbar>span{display:block;height:100%;background:var(--accent,#5b8cff)}',
++ '@media (prefers-reduced-motion:no-preference){html:not([data-ov-motion=off]) .ovm-bar-t>span,html:not([data-ov-motion=off]) .ovm-cellbar>span{transition:width .35s ease}}',
++ ].join('');
++ function style() {
++ if (doc.getElementById('ov-metrics-css')) return;
++ const s = doc.createElement('style'); s.id = 'ov-metrics-css'; s.textContent = CSS; (doc.head || doc.documentElement).appendChild(s);
++ }
++ function el(tag, cls, kids, attrs) {
++ const n = doc.createElement(tag);
++ if (cls) n.className = cls;
++ for (const k in attrs || {}) if (attrs[k] != null) n.setAttribute(k, attrs[k]);
++ (kids || []).forEach((c) => { if (c != null && c !== false) n.append(c); });
++ return n;
++ }
++ const SVG = 'http://www.w3.org/2000/svg';
++ const svgEl = (tag, attrs) => { const n = doc.createElementNS(SVG, tag); for (const k in attrs) n.setAttribute(k, attrs[k]); return n; };
++
++ function spark(values, o = {}) {
++ style();
++ const v = (Array.isArray(values) ? values : []).map(num).filter((x) => x != null);
++ const w = o.width || 120; const h = o.height || 28;
++ const svg = svgEl('svg', { class: 'ovm-spark', viewBox: `0 0 ${w} ${h}`, preserveAspectRatio: 'none', role: 'img', 'aria-label': o.label || (v.length ? `Trend: from ${v[0]} to ${v[v.length - 1]}` : 'No trend yet') });
++ if (v.length < 2) return svg;
++ const lo = Math.min(...v); const hi = Math.max(...v); const span = hi - lo || 1;
++ const pts = v.map((x, i) => [(i / (v.length - 1)) * (w - 4) + 2, h - 3 - ((x - lo) / span) * (h - 6)]);
++ const d = pts.map((p, i) => `${i ? 'L' : 'M'}${p[0].toFixed(1)} ${p[1].toFixed(1)}`).join(' ');
++ svg.append(svgEl('path', { class: 'ovm-area', d: `${d} L${pts[pts.length - 1][0].toFixed(1)} ${h} L${pts[0][0].toFixed(1)} ${h} Z` }), svgEl('path', { d }), svgEl('circle', { cx: pts[pts.length - 1][0].toFixed(1), cy: pts[pts.length - 1][1].toFixed(1), r: '2.5' }));
++ return svg;
++ }
++
++ function tiles(items) {
++ style();
++ const box = el('div', 'ovm-tiles', [], { role: 'list' });
++ for (const it of Array.isArray(items) ? items : []) {
++ const value = el('span', 'ovm-value', [String(it.value == null ? DASH : it.value), it.unit ? el('small', '', [it.unit]) : null]);
++ const kids = [el('span', 'ovm-label', [it.label]), value];
++ if (it.delta != null && Number.isFinite(Number(it.delta))) {
++ const good = (Number(it.delta) >= 0) === ((it.better || 'up') === 'up');
++ kids.push(el('span', `ovm-delta ${Number(it.delta) === 0 ? '' : good ? 'is-good' : 'is-bad'}`, [format.delta(it.delta)], { title: 'Against the period before' }));
++ }
++ if (it.sub) kids.push(el('span', 'ovm-sub', [it.sub]));
++ if (Array.isArray(it.trend) && it.trend.length > 1) kids.push(spark(it.trend, { label: `${it.label}: trend` }));
++ box.append(el(it.href ? 'a' : 'div', 'ovm-tile', kids, { role: 'listitem', href: it.href || null }));
++ }
++ return box;
++ }
++
++ function bars(rows, o = {}) {
++ style();
++ const list = (Array.isArray(rows) ? rows : []).filter(Boolean);
++ const val = (r) => num(typeof o.value === 'function' ? o.value(r) : r[o.value || 'value']) || 0;
++ const lab = (r) => String(typeof o.label === 'function' ? o.label(r) : r[o.label || 'label']);
++ const fmt = o.format || ((x) => format.count(x));
++ const max = num(o.max) || Math.max(1e-12, ...list.map(val));
++ const ul = el('ul', 'ovm-bars');
++ for (const r of [...list].sort((a, b) => val(b) - val(a))) {
++ const pct = Math.max(0, Math.min(100, (val(r) / max) * 100));
++ ul.append(el('li', 'ovm-bar', [el('span', 'ovm-bar-l', [lab(r)], { title: lab(r) }), el('span', 'ovm-bar-v', [fmt(val(r), r)]), el('span', 'ovm-bar-t', [el('span', '', [], { style: `width:${pct.toFixed(1)}%` })], { 'aria-hidden': 'true' }), o.note ? el('small', '', [o.note(r)]) : null]));
++ }
++ return ul;
++ }
++
++ function percentiles(p, o = {}) {
++ style();
++ const f = o.unit === 'tps' ? format.tps : o.unit === 'usd' ? format.usd : format.ms;
++ const p50 = num(p && p.p50); const p90 = num(p && p.p90); const p95 = num(p && p.p95);
++ const max = num(o.max) || Math.max(1e-9, p95 || 0, p90 || 0, p50 || 0) * 1.15;
++ const at = (x) => `${Math.max(0, Math.min(100, (x / max) * 100)).toFixed(1)}%`;
++ const track = el('div', 'ovm-pct-track', [], { 'aria-hidden': 'true' });
++ if (p50 != null && p95 != null) track.append(el('span', 'ovm-pct-range', [], { style: `left:${at(Math.min(p50, p95))};width:calc(${at(Math.abs(p95 - p50))})` }));
++ if (p50 != null) track.append(el('span', 'ovm-pct-mark', [], { style: `left:${at(p50)}` }));
++ if (p95 != null) track.append(el('span', 'ovm-pct-mark is-tail', [], { style: `left:${at(p95)}` }));
++ const keys = el('p', 'ovm-pct-keys', [el('span', '', ['p50 ', el('b', '', [f(p50)])]), p90 != null ? el('span', '', ['p90 ', el('b', '', [f(p90)])]) : null, el('span', '', ['p95 ', el('b', '', [f(p95)])]), p && p.n != null ? el('span', '', [`${format.count(p.n)} samples`]) : null]);
++ const box = el('div', 'ovm-pct', [o.label ? el('span', 'ovm-label', [o.label]) : null, track, keys], { role: 'group', 'aria-label': `${o.label || 'Distribution'}: median ${f(p50)}, 95th percentile ${f(p95)}` });
++ return box;
++ }
++
++ function split(parts, o = {}) {
++ style();
++ const list = (Array.isArray(parts) ? parts : []).filter((x) => x && num(x.value) > 0);
++ const total = list.reduce((s, x) => s + num(x.value), 0) || 1;
++ const fmt = o.format || format.count;
++ const bar = el('div', 'ovm-split-bar', [], { 'aria-hidden': 'true' });
++ const legend = el('ul', 'ovm-split-legend');
++ list.forEach((x, i) => {
++ const tone = `ovm-t${x.tone || (i % 5) + 1}`;
++ bar.append(el('span', tone, [], { style: `width:${((num(x.value) / total) * 100).toFixed(1)}%`, title: `${x.label}: ${fmt(x.value)}` }));
++ legend.append(el('li', '', [el('i', tone), `${x.label} ${fmt(x.value)} (${format.rate(num(x.value) / total)})`]));
++ });
++ return el('div', 'ovm-split', [bar, legend], { role: 'group', 'aria-label': o.label || 'Split' });
++ }
++
++ function table(rows, columns, o = {}) {
++ style();
++ const data = (Array.isArray(rows) ? rows : []).slice();
++ const cols = Array.isArray(columns) ? columns : [];
++ let sortKey = o.sort && o.sort.key ? o.sort.key : null;
++ let dir = o.sort && o.sort.dir === 'asc' ? 1 : -1;
++ const maxOf = {};
++ for (const c of cols) if (c.bar) maxOf[c.key] = Math.max(1e-12, ...data.map((r) => num(r[c.key]) || 0));
++ const t = el('table', 'ovm-table', [o.caption ? el('caption', '', [o.caption]) : null]);
++ const head = el('tr');
++ const tbody = el('tbody');
++ function drawBody() {
++ const sorted = sortKey ? [...data].sort((a, b) => { const x = a[sortKey]; const y = b[sortKey]; const nx = num(x); const ny = num(y); return (nx != null && ny != null ? nx - ny : String(x == null ? '' : x).localeCompare(String(y == null ? '' : y))) * dir; }) : data;
++ tbody.replaceChildren(...sorted.map((r) => el('tr', '', cols.map((c) => {
++ const v = r[c.key];
++ const text = c.format ? c.format(v, r) : (v == null ? DASH : String(v));
++ const kids = [text];
++ if (c.bar && num(v) != null) kids.push(el('span', 'ovm-cellbar', [el('span', '', [], { style: `width:${((num(v) / maxOf[c.key]) * 100).toFixed(1)}%` })], { 'aria-hidden': 'true' }));
++ return el('td', c.numeric ? 'is-num' : '', kids);
++ }))));
++ head.querySelectorAll('th').forEach((th) => th.setAttribute('aria-sort', th.dataset.key === sortKey ? (dir === 1 ? 'ascending' : 'descending') : 'none'));
++ }
++ for (const c of cols) {
++ const b = el('button', '', [c.label, el('span', '', ['↕'], { 'aria-hidden': 'true' })], { type: 'button' });
++ b.addEventListener('click', () => { if (sortKey === c.key) dir = -dir; else { sortKey = c.key; dir = c.numeric ? -1 : 1; } drawBody(); });
++ const th = el('th', c.numeric ? 'is-num' : '', [b], { scope: 'col', 'data-key': c.key });
++ head.append(th);
++ }
++ t.append(el('thead', '', [head]), tbody);
++ drawBody();
++ return el('div', 'ovm-table-wrap', [t]);
++ }
++
++ return { format, tiles, spark, bars, percentiles, split, table, CSS };
++});
ddiff --git a/package-lock.json b/package-lock.json
iindex b3ebd1f..abe8e9f 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.231.0",
++ "version": "4.232.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.231.0",
++ "version": "4.232.0",
"license": "MIT",
"dependencies": {
"clean-css": "^5.3.3",
ddiff --git a/package.json b/package.json
iindex 7be63c2..7a11184 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.231.0",
++ "version": "4.232.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
@@@ -153,6 +153,7 @@
"./ov-searchbox": "./ov-searchbox.js",
"./ov-composer": "./ov-composer.js",
"./ov-run": "./ov-run.js",
++ "./ov-metrics": "./ov-metrics.js",
"./ov-capture": "./ov-capture.js",
"./ov-ai-choice": "./ai-choice.js",
"./ov-ai-choice.js": "./ov-ai-choice.js",
ddiff --git a/test/ov-metrics-chrome.test.js b/test/ov-metrics-chrome.test.js
nnew file mode 100644
iindex 0000000..43ba4ba
---- /dev/null
++++ b/test/ov-metrics-chrome.test.js
@@@ -0,0 +1,83 @@
++'use strict';
++// ov-metrics.js (4.232.0, ai-metrics.md): the network's one way to show numbers. In Node, the same words servers write;
++// in a real headless Chrome, tiles with a delta and a trend, a ranked bar list, p50 and p95 on one scale, a split with
++// its legend and a table that sorts on a click, at 390 px with no page overflow.
++const assert = require('node:assert/strict');
++const fs = require('node:fs');
++const os = require('node:os');
++const path = require('node:path');
++const http = require('node:http');
++const h = require('../browser-harness');
++const M = require('../ov-metrics');
++
++assert.equal(M.format.ms(540), '540 ms');
++assert.equal(M.format.ms(1310), '1.3 s');
++assert.equal(M.format.ms(98000), '1 min 38 s');
++assert.equal(M.format.usd(0.0042), '$0.0042');
++assert.equal(M.format.usd(61.2), '$61.20');
++assert.equal(M.format.count(18420), '18k');
++assert.equal(M.format.count(9100000), '9.1M');
++assert.equal(M.format.rate(0.9941), '99.4%');
++assert.equal(M.format.tps(86), '86.0 tok/s');
++assert.equal(M.format.tps(133), '133 tok/s');
++assert.equal(M.format.delta(0.12), '+12%');
++assert.equal(M.format.ms(null), '—', 'unknown is a dash, never 0');
++assert.equal(typeof M.tiles, 'undefined', 'Node has the words only');
++assert.ok(require('../files').isBrowserFile('ov-metrics.js'), '/shared/ov-metrics.js');
++
++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) { console.log('ov-metrics-chrome: skipped (no Chrome; set CHROME_BIN)'); process.exit(0); }
++const root = path.join(__dirname, '..');
++const server = http.createServer((req, res) => {
++ if (req.url.startsWith('/ov-metrics.js')) { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(root, 'ov-metrics.js'))); }
++ res.writeHead(200, { 'content-type': 'text/html' });
++ res.end(`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><body style="margin:0;padding:16px;font:14px system-ui">
++<div id="t"></div><div id="b"></div><div id="p"></div><div id="s"></div><div id="tb"></div>
++<script src="/ov-metrics.js"></script><script>
++document.getElementById('t').append(OVMetrics.tiles([{ label: 'Calls', value: OVMetrics.format.count(18420), delta: 0.12, trend: [3, 5, 4, 8, 9] }, { label: 'TTFT p50', value: OVMetrics.format.ms(540), sub: 'p95 1.3 s', delta: 0.08, better: 'down' }]));
++document.getElementById('b').append(OVMetrics.bars([{ label: 'deepseek-v4-flash', value: 61.2 }, { label: 'gpt-5.1-mini', value: 12 }, { label: 'a-very-long-model-name-that-must-not-push-the-page-wider-than-the-phone', value: 3 }], { format: OVMetrics.format.usd }));
++document.getElementById('p').append(OVMetrics.percentiles({ p50: 540, p90: 980, p95: 1310, n: 13080 }, { label: 'Time to first token' }));
++document.getElementById('s').append(OVMetrics.split([{ label: 'Charged', value: 61.2 }, { label: 'Saved by cache', value: 19.8 }], { format: OVMetrics.format.usd }));
++document.getElementById('tb').append(OVMetrics.table([{ model: 'b', tps: 40 }, { model: 'a', tps: 133 }, { model: 'c', tps: 86 }], [{ key: 'model', label: 'Model' }, { key: 'tps', label: 'Speed', numeric: true, bar: true, format: OVMetrics.format.tps }]));
++</script>`);
++});
++(async () => {
++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-metrics-'));
++ const browser = await h.launch({ tmpDir });
++ try {
++ const page = await h.openPage(browser, await browser.newContext(), { width: 390, height: 900 });
++ await page.goto(`http://127.0.0.1:${server.address().port}/`, { maxMs: 5000 });
++ await new Promise((r) => setTimeout(r, 150));
++ const got = await page.evaluate(`(() => {
++ const deltas = [...document.querySelectorAll('.ovm-delta')].map((d) => [d.textContent, d.className.includes('is-good') ? 'good' : d.className.includes('is-bad') ? 'bad' : '']);
++ const bars = [...document.querySelectorAll('.ovm-bar-l')].map((b) => b.textContent);
++ const spark = !!document.querySelector('.ovm-tile .ovm-spark path');
++ const marks = document.querySelectorAll('.ovm-pct-mark').length;
++ const pctLabel = document.querySelector('.ovm-pct').getAttribute('aria-label');
++ const legend = document.querySelector('.ovm-split-legend').textContent;
++ const rows = () => [...document.querySelectorAll('.ovm-table tbody tr td:first-child')].map((td) => td.textContent).join('');
++ const before = rows();
++ document.querySelectorAll('.ovm-table th button')[1].click();
++ const desc = rows();
++ document.querySelectorAll('.ovm-table th button')[1].click();
++ const asc = rows();
++ const sort = document.querySelectorAll('.ovm-table th')[1].getAttribute('aria-sort');
++ const overflow = document.documentElement.scrollWidth > innerWidth;
++ return { deltas, bars, spark, marks, pctLabel, legend, before, desc, asc, sort, overflow, css: !!document.getElementById('ov-metrics-css') };
++ })()`);
++ assert.deepEqual(got.deltas, [['+12%', 'good'], ['+8.0%', 'bad']], 'more calls is good; a slower first token is bad');
++ assert.deepEqual(got.bars.slice(0, 2), ['deepseek-v4-flash', 'gpt-5.1-mini'], 'ranked by value');
++ assert.ok(got.spark, 'a trend line in the tile');
++ assert.equal(got.marks, 2, 'p50 and p95');
++ assert.match(got.pctLabel, /median 540 ms, 95th percentile 1.3 s/);
++ assert.match(got.legend, /Charged \$61\.20 \(75\.6%\).*Saved by cache \$19\.80/);
++ assert.deepEqual([got.before, got.desc, got.asc, got.sort], ['bac', 'acb', 'bca', 'ascending'], 'a click sorts, a second click turns it');
++ assert.equal(got.overflow, false, 'no page overflow at 390 px');
++ assert.ok(got.css);
++ console.log('ov-metrics-chrome: words in Node; tiles with deltas and trends, ranked bars, p50/p95, split with legend, a sortable table, no overflow at 390 px');
++ } finally {
++ await browser.close().catch(() => {});
++ server.close();
++ fs.rmSync(tmpDir, { recursive: true, force: true });
++ }
++})().catch((e) => { console.error(e); process.exit(1); });