openvibers/openvibe.shared Commits

4.233.0: ov-metrics tables are cards in a narrow box; prices under a dollar keep two significant digits (#516)

OP OpenVibers committed 10 h ago · 469a175a80 · 7 files, +55 −15

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 2022139..af642ee 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,19 @@
# Changelog
++## 4.233.0 — 2026-11-13
++
++**`ov-metrics.js` on a phone, and prices under a dollar.**
++
++- **A narrow table is cards** (`OVMetrics.table`):
++ - in a box under 520 px wide (a container query, so a side column counts too), each row is a card: its name on top
++ and each value under its label;
++ - the column heads become a row of sort buttons, and the one in use is outlined;
++ - nothing scrolls sideways;
++ - the table, its rows, cells and heads carry their roles, so a screen reader still reads a table whatever the display.
++- **`format.usd` keeps two significant digits under a dollar**: $0.0118 is `$0.012`, not `$0.01`; $0.25 stays `$0.25`.
++ A per-call price no longer rounds away.
++- Test `ov-metrics-chrome`: the card form at 390 px, the labels, the roles, no sideways scroll, and the new prices.
++
## 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
ddiff --git a/README.md b/README.md
iindex 1d163dd..f5dd471 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.232.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.233.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 a194ac2..ba35783 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.232.0",
++ "version": "4.233.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/ov-metrics.js b/ov-metrics.js
iindex 7483bda..8a06a41 100644
---- a/ov-metrics.js
++++ b/ov-metrics.js
@@@ -25,7 +25,7 @@
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')}`; },
++ usd(v) { const n = num(v); if (n == null) return DASH; if (n === 0) return '$0'; if (Math.abs(n) < 1) { const d = Math.min(6, Math.max(2, 1 - Math.floor(Math.log10(Math.abs(n))))); return `$${n.toFixed(d).replace(/(\.\d\d\d*?)0+$/, '$1')}`; } 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)}%`; },
@@@ -58,11 +58,24 @@
'.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-wrap{overflow-x:auto;border:1px solid var(--border,#2a3346);border-radius:12px;container:ovm-table/inline-size}',
'.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}',
++ // A narrow box (a phone, a side column): each row is a card, its name on top and each value under its label;
++ // the heads become sort buttons. The roles keep it a table for a screen reader whatever its display. This
++ // block comes after the base table rules, so it wins the cascade at equal specificity.
++ '@container ovm-table (max-width:520px){.ovm-table,.ovm-table tbody,.ovm-table thead,.ovm-table td{display:block}'
++ + '.ovm-table caption{display:block;white-space:normal}.ovm-table thead tr{display:flex;flex-wrap:wrap;gap:6px;padding:8px 12px;border-bottom:1px solid var(--border,#2a3346)}'
++ + '.ovm-table thead th{display:block;border:0;padding:0}.ovm-table th button{min-height:36px;padding:0 10px;box-sizing:border-box;border:1px solid var(--border,#2a3346);border-radius:999px}'
++ + '.ovm-table th[aria-sort=ascending] button,.ovm-table th[aria-sort=descending] button{border-color:var(--accent,#5b8cff);color:var(--text-primary,#e6edf7)}'
++ + '.ovm-table th.is-num button{flex-direction:row}'
++ + '.ovm-table tbody tr{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px 16px;padding:10px 12px;border-bottom:1px solid var(--border,#2a3346)}.ovm-table tbody tr:last-child{border-bottom:0}'
++ + '.ovm-table td,.ovm-table td.is-num{border:0;padding:0;white-space:normal;text-align:left;min-width:0;overflow-wrap:anywhere}'
++ + '.ovm-table td:first-child{grid-column:1/-1;font-weight:600}'
++ + '.ovm-table td:not(:first-child)::before{content:attr(data-label);display:block;font-size:11.5px;font-weight:400;color:var(--text-muted,#8b96ad)}'
++ + '.ovm-table td .ovm-cellbar{display:block;width:100%;margin:4px 0 0}}',
'.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('');
@@@ -163,9 +176,9 @@
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');
++ const t = el('table', 'ovm-table', [o.caption ? el('caption', '', [o.caption]) : null], { role: 'table' });
++ const head = el('tr', '', [], { role: 'row' });
++ const tbody = el('tbody', '', [], { role: 'rowgroup' });
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) => {
@@@ -173,17 +186,17 @@
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);
-- }))));
++ return el('td', c.numeric ? 'is-num' : '', kids, { role: 'cell', 'data-label': c.label });
++ }), { role: 'row' })));
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 });
++ const th = el('th', c.numeric ? 'is-num' : '', [b], { scope: 'col', role: 'columnheader', 'data-key': c.key });
head.append(th);
}
-- t.append(el('thead', '', [head]), tbody);
++ t.append(el('thead', '', [head], { role: 'rowgroup' }), tbody);
drawBody();
return el('div', 'ovm-table-wrap', [t]);
}
ddiff --git a/package-lock.json b/package-lock.json
iindex abe8e9f..40f46a3 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.232.0",
++ "version": "4.233.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.232.0",
++ "version": "4.233.0",
"license": "MIT",
"dependencies": {
"clean-css": "^5.3.3",
ddiff --git a/package.json b/package.json
iindex 7a11184..5411c80 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.232.0",
++ "version": "4.233.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
ddiff --git a/test/ov-metrics-chrome.test.js b/test/ov-metrics-chrome.test.js
iindex 43ba4ba..0ef7dbe 100644
---- a/test/ov-metrics-chrome.test.js
++++ b/test/ov-metrics-chrome.test.js
@@@ -15,6 +15,10 @@ 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.usd(0.0118), '$0.012', 'two significant digits under a dollar: $0.0118 is not $0.01');
++assert.equal(M.format.usd(0.25), '$0.25');
++assert.equal(M.format.usd(0.1), '$0.10');
++assert.equal(M.format.usd(0.0000012), '$0.000001');
assert.equal(M.format.count(18420), '18k');
assert.equal(M.format.count(9100000), '9.1M');
assert.equal(M.format.rate(0.9941), '99.4%');
@@@ -63,7 +67,12 @@ document.getElementById('tb').append(OVMetrics.table([{ model: 'b', tps: 40 }, {
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') };
++ const wrap = document.querySelector('.ovm-table-wrap');
++ const card = getComputedStyle(document.querySelector('.ovm-table tbody tr')).display;
++ const labels = [...document.querySelector('.ovm-table tbody tr').children].map((td) => td.dataset.label).join('|');
++ const roles = [document.querySelector('.ovm-table').getAttribute('role'), document.querySelector('.ovm-table tbody tr').getAttribute('role'), document.querySelector('.ovm-table td').getAttribute('role')].join(',');
++ const sideways = wrap.scrollWidth > wrap.clientWidth;
++ return { deltas, bars, spark, marks, pctLabel, legend, before, desc, asc, sort, overflow, card, labels, roles, sideways, 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');
@@@ -73,6 +82,10 @@ document.getElementById('tb').append(OVMetrics.table([{ model: 'b', tps: 40 }, {
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.equal(got.card, 'grid', 'a narrow table shows each row as a card');
++ assert.equal(got.labels, 'Model|Speed', 'each value carries its column label');
++ assert.equal(got.roles, 'table,row,cell', 'still a table to a screen reader');
++ assert.equal(got.sideways, false, 'no sideways scroll inside the table 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 {