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)
Changes
@@@ -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@@@ -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 deployedconsumer 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` namesthe tag it pins.@@@ -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)",@@@ -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]);}@@@ -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",@@@ -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": {@@@ -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 {
@@ -1,5 +1,19 @@
@@ -1,5 +1,19 @@
# Changelog
# 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
## 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
**`ov-metrics.js`: the network's one way to show numbers** (ai-metrics.md). Report, Exchange, Compare and the admin panel
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Install and test:** `npm ci && npm test`
**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
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
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.232.0",
+ "version": "4.233.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"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)",
"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)",
@@ -25,7 +25,7 @@
@@ -25,7 +25,7 @@
const DASH = '—';
const DASH = '—';
const format = {
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`; },
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`; },
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`; },
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)}%`; },
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 @@
@@ -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{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-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-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{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,.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 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-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)}',
'.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}}',
'@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('');
].join('');
@@ -163,9 +176,9 @@
@@ -163,9 +176,9 @@
let dir = o.sort && o.sort.dir === 'asc' ? 1 : -1;
let dir = o.sort && o.sort.dir === 'asc' ? 1 : -1;
const maxOf = {};
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));
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() {
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;
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) => {
tbody.replaceChildren(...sorted.map((r) => el('tr', '', cols.map((c) => {
@@ -173,17 +186,17 @@
@@ -173,17 +186,17 @@
const text = c.format ? c.format(v, r) : (v == null ? DASH : String(v));
const text = c.format ? c.format(v, r) : (v == null ? DASH : String(v));
const kids = [text];
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' }));
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'));
head.querySelectorAll('th').forEach((th) => th.setAttribute('aria-sort', th.dataset.key === sortKey ? (dir === 1 ? 'ascending' : 'descending') : 'none'));
}
}
for (const c of cols) {
for (const c of cols) {
const b = el('button', '', [c.label, el('span', '', ['↕'], { 'aria-hidden': 'true' })], { type: 'button' });
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(); });
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);
head.append(th);
}
}
- t.append(el('thead', '', [head]), tbody);
+ t.append(el('thead', '', [head], { role: 'rowgroup' }), tbody);
drawBody();
drawBody();
return el('div', 'ovm-table-wrap', [t]);
return el('div', 'ovm-table-wrap', [t]);
}
}
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.232.0",
+ "version": "4.233.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.232.0",
+ "version": "4.233.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"clean-css": "^5.3.3",
"clean-css": "^5.3.3",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"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",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -15,6 +15,10 @@ assert.equal(M.format.ms(1310), '1.3 s');
@@ -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.ms(98000), '1 min 38 s');
assert.equal(M.format.usd(0.0042), '$0.0042');
assert.equal(M.format.usd(0.0042), '$0.0042');
assert.equal(M.format.usd(61.2), '$61.20');
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(18420), '18k');
assert.equal(M.format.count(9100000), '9.1M');
assert.equal(M.format.count(9100000), '9.1M');
assert.equal(M.format.rate(0.9941), '99.4%');
assert.equal(M.format.rate(0.9941), '99.4%');
@@ -63,7 +67,12 @@ document.getElementById('tb').append(OVMetrics.table([{ model: 'b', tps: 40 }, {
@@ -63,7 +67,12 @@ document.getElementById('tb').append(OVMetrics.table([{ model: 'b', tps: 40 }, {
const asc = rows();
const asc = rows();
const sort = document.querySelectorAll('.ovm-table th')[1].getAttribute('aria-sort');
const sort = document.querySelectorAll('.ovm-table th')[1].getAttribute('aria-sort');
const overflow = document.documentElement.scrollWidth > innerWidth;
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.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.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 }, {
@@ -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.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.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.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);
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');
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 {
} finally {