openvibers/openvibe.design Commits

Patterns: Honest numbers, five rules with live demos (#226)

OP OpenVibers committed 4 days ago · ab99d97314 · 4 files, +73 −5

A column earns its place, a missing number is not zero, a starting value is not a result, a number names its
source, and a price says when it was checked: each with its rule, the 11-09 bug that taught it, a live demo with
the bug one tap away, tokens and native examples. The matching rules on /rules link to them.

Changes

ddiff --git a/public/css/patterns.css b/public/css/patterns.css
iindex b71dfae..2114f31 100644
---- a/public/css/patterns.css
++++ b/public/css/patterns.css
@@@ -127,3 +127,24 @@
.six-demo span{padding:4px 8px;border-radius:999px;color:var(--text-secondary)}.six-more{border:1px solid var(--border)}
@media (prefers-reduced-motion:reduce){.sheet-demo,.tray-demo li i{transition:none}.menu-demo.open .menu-panel{animation:none}}
.status-word{font-weight:700;font-size:15px}
++
++/* Honest numbers (2026-11-09) */
++.hn-quiet{color:var(--text-muted);font-weight:400}
++.hn-table td,.hn-table th{white-space:nowrap}
++.hn-rank{list-style:decimal;margin:0;padding-left:1.4em;display:grid;gap:6px}
++.hn-rank li{padding:8px 10px;border:1px solid var(--border);border-radius:10px;background:var(--bg-card)}
++.hn-rank li b{margin-right:10px}
++.hn-rank li span{font-variant-numeric:tabular-nums}
++.hn-pick{margin:0 0 10px;font-weight:700}
++.hn-pick.hn-wrong{color:var(--danger,#ef4444)}
++.hn-card{display:grid;gap:4px;max-width:280px;padding:14px;border:1px solid var(--border);border-radius:14px;background:var(--bg-card)}
++.hn-score{font-size:1.6rem;font-weight:800;font-variant-numeric:tabular-nums}
++.hn-score.hn-quiet{font-size:1.05rem;font-weight:600}
++.hn-card small{color:var(--text-muted)}
++.hn-price b{font-size:1.6rem;font-variant-numeric:tabular-nums}
++.hn-price.stale{border-style:dashed}
++.hn-price.stale small{color:var(--warning,#f59e0b)}
++.hn-fig{display:grid;gap:2px;text-align:left;max-width:320px;padding:14px;border:1px solid var(--border);border-radius:14px;background:var(--bg-card);color:inherit;font:inherit;cursor:pointer}
++.hn-fig b{font-size:1.6rem;font-variant-numeric:tabular-nums}
++.hn-fig .hn-src{color:var(--text-muted);max-height:0;overflow:hidden;transition:max-height var(--ov-dur-2,260ms) ease}
++.hn-fig.open .hn-src{max-height:4em}
ddiff --git a/public/js/patterns.js b/public/js/patterns.js
iindex a5a14ee..bf99e82 100644
---- a/public/js/patterns.js
++++ b/public/js/patterns.js
@@@ -231,6 +231,44 @@
draw(); widget.append(bar, work);
control('Next state', () => { k = (k + 1) % states.length; draw(); }, true);
control('The bug', () => { work.classList.add('covered'); say('A blurred cover over the work: nobody can watch it being made.'); });
++ } else if (id === 'column-earns-place') {
++ const rows = [['atlas-1', '$0.40', '1.4 s'], ['nimbus-2', '$2.00', '2.6 s'], ['orbit', '$1.20', null]];
++ const table = node('table', 'num-demo hn-table'); widget.append(table);
++ const draw = (bug) => { const cols = bug ? ['Model', 'Price', 'Reply time', 'Tokens/s', 'First token', 'Licence'] : ['Model', 'Price', 'Reply time'];
++ const head = node('tr'); cols.forEach((h) => head.append(node('th', '', h))); const thead = node('thead'); thead.append(head); const body = node('tbody');
++ rows.forEach((r) => { const tr = node('tr'); cols.forEach((_, i) => { const v = r[i]; const td = node('td', v == null ? 'hn-quiet' : '', v == null ? (i < 3 ? 'No traffic yet' : 'Not measured yet') : v); tr.append(td); }); body.append(tr); });
++ table.replaceChildren(thead, body); };
++ draw(false); say('Three columns, each with values; one missing value says why.');
++ control('Columns with values', () => { draw(false); say('Three columns, each with values; one missing value says why.'); }, true);
++ control('The bug', () => { draw(true); say('Three more columns, nine cells of “Not measured yet”: the real numbers are harder to find.'); });
++ } else if (id === 'missing-not-zero') {
++ const data = [['atlas-1', 1400], ['nimbus-2', 2600], ['orbit', null]];
++ const list = node('ol', 'hn-rank'); const pick = node('p', 'hn-pick'); widget.append(pick, list);
++ const draw = (bug) => { const val = (v) => (v == null ? (bug ? 0 : Infinity) : v); const sorted = [...data].sort((a, b) => val(a[1]) - val(b[1]));
++ list.replaceChildren(...sorted.map(([m, v]) => { const li = node('li'); li.append(node('b', '', m), node('span', v == null ? 'hn-quiet' : '', v == null ? (bug ? '0 ms' : 'No traffic yet') : `${(v / 1000).toFixed(1)} s`)); return li; }));
++ pick.textContent = `Fastest: ${sorted[0][0]}`; pick.classList.toggle('hn-wrong', bug); };
++ draw(false); say('The unmeasured model sorts last and wins nothing.');
++ control('Missing sorts last', () => { draw(false); say('The unmeasured model sorts last and wins nothing.'); }, true);
++ control('The bug', () => { draw(true); say('null became 0: a model nobody measured is “Fastest”.'); });
++ } else if (id === 'not-rated-yet') {
++ const card = node('div', 'hn-card'); const name = node('b', '', 'orbit'); const score = node('span', 'hn-score'); const sub = node('small', ''); card.append(name, score, sub); widget.append(card);
++ const draw = (games, bug) => { if (games || bug) { score.textContent = String(1000 + games * 6); score.classList.remove('hn-quiet'); sub.textContent = games ? `± ${Math.max(12, 80 - games * 4)} · ${games} games` : '0 games'; } else { score.textContent = 'Not rated yet'; score.classList.add('hn-quiet'); sub.textContent = 'No votes so far'; } };
++ let games = 0; draw(0, false); say('No games: the words say so.');
++ control('Play a game', () => { games += 1; draw(games, false); say(`${games} ${games === 1 ? 'game' : 'games'}: now there is a rating, with its spread.`); }, true);
++ control('The bug', () => { games = 0; draw(0, true); say('1000 with no games reads as a result. It is only where everyone starts.'); });
++ } else if (id === 'number-names-source') {
++ const fig = node('button', 'hn-fig'); fig.type = 'button'; const v = node('b', '', '1.4 s'); const what = node('span', '', 'typical reply'); const src = node('small', 'hn-src', 'Real traffic through the Exchange · 13,208 requests · last 7 days'); fig.append(v, what, src); widget.append(fig);
++ fig.addEventListener('click', () => { fig.classList.toggle('open'); say(fig.classList.contains('open') ? 'Where it came from, how much it rests on, and when.' : 'Tap the number for its source.'); });
++ fig.classList.add('open'); say('The value, then what it rests on.');
++ control('With its source', () => { fig.classList.add('open'); fig.classList.remove('bare'); say('The value, then what it rests on.'); }, true);
++ control('The bug', () => { fig.classList.remove('open'); fig.classList.add('bare'); say('A bare “1.4 s”: measured how, on what, when?'); });
++ } else if (id === 'price-freshness') {
++ const card = node('div', 'hn-card hn-price'); const p = node('b', '', '$0.90'); const unit = node('span', '', 'per 1M output tokens'); const when = node('small', ''); card.append(p, unit, when); widget.append(card);
++ const draw = (hours, bug) => { card.classList.toggle('stale', hours > 72 && !bug); when.textContent = bug ? '' : hours < 24 ? `Checked ${hours} hours ago on the provider’s page` : `Checked ${Math.round(hours / 24)} days ago · may have changed`; };
++ draw(3, false); say('Checked 3 hours ago.');
++ control('Checked today', () => { draw(3, false); say('Checked 3 hours ago.'); }, true);
++ control('Nine days later', () => { draw(216, false); say('Old: the price says it may have changed.'); });
++ control('The bug', () => { draw(216, true); say('No date: a nine-day-old price looks as live as one we set.'); });
} else if (id === 'mark-system') {
const row=node('div','mark-demo'),mark=node('span','ov-mark','ov');row.append(mark,node('span','','OpenVibe'));widget.append(row);control('Loading state',()=>{mark.classList.toggle('loading');say(mark.classList.contains('loading')?'Loading state active.':'Loading state stopped.');});control('Change site',()=>{mark.textContent=mark.textContent==='ov'?'lv':'ov';mark.style.borderRadius=mark.textContent==='lv'?'50%':'16px';say('The mark morphs for the active site.');});control('Open five studies',()=>{window.location.href='/patterns/mark/studies';});
}
ddiff --git a/server/patterns/catalog.js b/server/patterns/catalog.js
iindex 868caeb..1cc57c4 100644
---- a/server/patterns/catalog.js
++++ b/server/patterns/catalog.js
@@@ -60,6 +60,11 @@ const patterns = [
['ask-your-agent','Agents','Ask your agent, with the question waiting','Where a page shows one thing (a stream, a video, a file, a thread), one quiet chip beside Share opens the person\u2019s agent with a sentence about that thing already in its box. It is never sent for them: they read it, change it, and press Send. Signed out, the chip signs in first.', '2026-11-07 · Agents could do things on every site, but every ask started from a blank box on another page.', 'One chip, the context in the box, never sent.', t('radiusPill', 'tap')],
['levels-with-a-floor','Agents','Levels with a floor that never moves','An agent\u2019s freedom is four levels in words a person can act on (asks first, edits on its own, a second model checks, full trust), as four cards with one line each. Under them, always in view, the four things that ask at every level: spending money, a password or key, deleting something, and messaging someone new. The floor is enforced on the server, not drawn as an option.', '2026-11-07 · Approval levels existed in the API, and nothing on the page said what full trust still could not do.', 'Four cards, and the floor in view.', t('tap', 'spaceMd')],
['agent-line','Agents','The agent\u2019s line leaves the work in view','While an agent works on something the person is looking at (a space, a document, a page), its state is one line above the work, not a cover over it: a live dot while it builds, amber while it asks, green when done, red when stuck, with the Dock one tap away. The person watches the work change as the agent makes it.', '2026-11-07 · Space\u2019s builder covered the preview with a blurred \u201cbuilding\u201d screen, so nobody could watch their agent build.', 'One line above the work, four states.', t('durationBase', 'tap')],
++ ['column-earns-place','Honest numbers','A column earns its place','A table shows a column only when some row has a value for it, and a section only when something fills it. When everything is missing, one line says why and what would fill it.', '2026-11-09 · Report’s AI leaderboard had twelve columns of “Not measured yet” around the three numbers that were real.', 'Empty columns stay out; one plain line says what is missing.', t('text','surface')],
++ ['missing-not-zero','Honest numbers','A missing number is not zero','A value nobody reported is left out or said in words. It never sorts, ranks or wins as zero: in code, null must not become 0.', '2026-11-09 · A model with no measured speed was named “Fastest” because null had become 0 ms.', 'Missing sorts last and wins nothing.', t('text','accent')],
++ ['not-rated-yet','Honest numbers','A starting value is not a result','A score with nothing behind it reads “Not rated yet”. The 1000 every entrant starts at appears only once a game has been played.', '2026-11-09 · The leaderboard rated models at 1000 on boards that said “No votes yet”.', 'No games, no rating: the words say so.', t('text')],
++ ['number-names-source','Honest numbers','A number names its source','Beside or one tap from every number: where it came from, the day, and how much it rests on. A rating says its games, a price whose page, a speed that it is real traffic.', '2026-11-09 · Comparison pages showed prices and speeds nobody could check.', 'The value, then what it rests on.', t('text','surface')],
++ ['price-freshness','Honest numbers','A price says when it was checked','A price read from someone else’s page shows the day it was checked, and says it may have changed once that is old. It never looks as live as a price we set.', '2026-11-09 · Second-hand prices drifted from what providers charged, with nothing on the page to say so.', 'Checked today, or “may have changed”.', t('text','accent')],
].map(([slug,group,title,rule,bug,summary,tokens]) => ({ slug, group, title, rule, bug, summary, tokens: [...new Set([...tokens, 'tap', 'spaceMd', 'durationBase'])] }));
function esc(s) { return String(s).replace(/[&<>"']/g, (c) => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c])); }
ddiff --git a/server/rules/register.js b/server/rules/register.js
iindex c038b27..fad3111 100644
---- a/server/rules/register.js
++++ b/server/rules/register.js
@@@ -38,16 +38,19 @@ const RULES = Object.freeze([
R('missing-is-not-zero', 'honest', 'A missing number is left out or said in words, never shown or sorted as zero.',
'When a source did not report a value, the page says "No traffic yet" or "Not measured", and sorting and picks skip it. In code, null must not become 0.',
'2026-11-09', 'A model with no measured speed was named "Fastest" because null had become 0 ms.',
-- [{ label: 'Open stats', href: 'https://openvibe.network/stats' }], ['Report test: picks use measured values']),
++ [{ label: 'A missing number is not zero', href: '/patterns/missing-not-zero' }, { label: 'Open stats', href: 'https://openvibe.network/stats' }], ['Report test: picks use measured values']),
R('number-has-source', 'honest', 'Every number names its source, its date and how much it rests on.',
'A rating says how many games; a price says whose page and when it was checked; a speed says it is from real traffic over seven days. One tap shows it.',
-- '2026-11-09', 'Comparison pages showed numbers nobody could check.'),
++ '2026-11-09', 'Comparison pages showed numbers nobody could check.',
++ [{ label: 'A number names its source', href: '/patterns/number-names-source' }]),
R('not-a-rating-yet', 'honest', 'A starting value is not a result.',
'A model with no votes shows "Not rated yet", not the 1000 every entrant starts at. The same goes for any score with no samples behind it.',
-- '2026-11-09', 'The leaderboard rated models at 1000 on boards that said "No votes yet".'),
++ '2026-11-09', 'The leaderboard rated models at 1000 on boards that said "No votes yet".',
++ [{ label: 'A starting value is not a result', href: '/patterns/not-rated-yet' }]),
R('columns-earn-their-place', 'honest', 'A column or section appears when something fills it.',
'A table shows a column only when some row has a value for it; a section with nothing in it is not drawn. When everything is missing, one plain line says why and what would fill it.',
-- '2026-11-09', 'Twelve columns of "Not measured yet" buried the three numbers that were real.'),
++ '2026-11-09', 'Twelve columns of "Not measured yet" buried the three numbers that were real.',
++ [{ label: 'A column earns its place', href: '/patterns/column-earns-place' }]),
R('state-in-words', 'honest', 'Status is a word, never colour alone.',
'A dot or colour helps the eye; the word carries the meaning for everyone.',
'2026-10-30', 'Colour alone made live, done and failed look alike.',
@@@ -179,7 +182,8 @@ const RULES = Object.freeze([
// ── Data, AI and consent ──
R('own-page-prices', 'data', 'A price comes from the provider’s own page, with the day it was checked.',
'Never an aggregator’s price; a price older than its check window says it may have changed.',
-- '2026-11-08', 'Second-hand prices drifted from what providers charged.'),
++ '2026-11-08', 'Second-hand prices drifted from what providers charged.',
++ [{ label: 'A price says when it was checked', href: '/patterns/price-freshness' }]),
R('measured-on-real-traffic', 'data', 'Speed, errors and uptime are measured on real requests.',
'Synthetic probes fill gaps only where traffic is thin, and say so.',
'2026-11-09', 'Real traffic is the number a person will actually get.'),