openvibers/openvibe.shared Commits

Kit 2: steps, tables, charts, rankings, meters, drop zones, receipts (4.36.0) (#172)

OP OpenVibers committed 15 days ago · 3ad0da3427 · 13 files, +808 −18

* seo: builders for Event, JobPosting, Recipe and Dataset (SEO-13)

* Kit 2: steps, tables, charts, rankings, meters, drop zones, receipts and the page shape (4.36.0)

The kit covered apps you live in (a conversation, a list, settings). Most OpenVibe pages are data and tools: a price
over time, a leaderboard, plans to compare, a picture to read, a few steps to verify a domain. Each site drew those in
its own CSS (10-90 KB apiece), and every open design entry asked for the same missing pieces.

- kit.steps: a flow of a few steps; done steps link back, later ones never link ahead; a phone gets dots and
  "Step 2 of 4 · Verify the domain".
- kit.table: a real table on wide screens; under 600 px each row is a card that names its cells.
- kit.chart: line, area, bar and scatter (with a dashed frontier). The lines are an SVG stretched to the box with
  non-scaling strokes; every label, point and bar is real HTML placed by percent, so text keeps its size from 320 to
  1280 px. Ticks fall on round numbers and round moments; points and bars are focusable; a table with the same numbers
  sits behind each chart for screen readers; kit-web.js adds the readout (pointer, tap, arrow keys) and hides a scatter
  label that would cover another.
- kit.rank (a leaderboard with ± interval, change and a proportional bar), kit.meter (tone 'auto' for use of a limit),
  kit.drop (a real file input; drag, paste and the phone camera added by kit-web.js; the camera input has no name so an
  unused one never posts an empty file), kit.receipt (the result, then one quiet line of what it took), kit.page and
  kit.num (money to the cent and two figures under a dime; a missing value is a dash, never 0).
- The gallery draws Kit 2 at 320/390/768/1280 in dark and in light (the default theme's light half).

Additive: no existing class, builder or served file changed. kit.VERSION is 2.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 6b5e01c..40ba2a3 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -6,6 +6,18 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
++## 4.36.0 — 2026-10-29
++
++- **Kit 2:** the pieces data and tool pages need, in `kit.js`/`kit.css` (`kit-web.js` adds their behaviour):
++ `steps` (a flow of a few steps), `table` (a real table that turns into labelled cards under 600 px), `chart`
++ (`line`, `area`, `bar`, `scatter` with a frontier; an SVG stretched to the box with every label as real text,
++ focusable points and bars, a screen-reader table, a hover and arrow-key readout), `rank` (a leaderboard),
++ `meter`, `drop` (a file input with drag, paste and the phone camera), `receipt` (a result and what it took),
++ `page` (the app page's whole shape) and `num` (money, compact, percent, ms). `kit.VERSION` is 2. Additive: no
++ existing class or builder changed. See docs/kit.md, "Kit 2".
++- **SEO:** `seo.jsonLd` gains `event`, `jobPosting`, `recipe` and `dataset` builders (the shapes Events, Today, Work,
++ Food and Bench shipped by hand on 2026-10-29), so the next site to need one uses the shared, tested shape.
++
## 4.35.0 — 2026-10-29
- **Showcase:** the page root clips the horizontal axis (`html { overflow-x: clip }`), so a narrow page whose
ddiff --git a/README.md b/README.md
iindex 4274481..1b4d8a1 100644
---- a/README.md
++++ b/README.md
@@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
--**Status:** alpha, v4.35.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.36.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 e002061..a741d69 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.35.0",
++ "version": "4.36.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/docs/kit.md b/docs/kit.md
iindex 8036db1..d7911d3 100644
---- a/docs/kit.md
++++ b/docs/kit.md
@@@ -245,6 +245,44 @@ the theme's mode:
`sc.room()` does this for a front door.
++### Kit 2: data and tool pages (4.36.0)
++
++The first kit was built for apps you live in: a conversation, a list, your settings. Most OpenVibe pages are something
++else: a price over time, a leaderboard, a table of plans, a picture to read, a few steps to verify a domain. Before
++4.36 every site drew those itself, about 10-90 KB of its own CSS each, and no two looked alike. Kit 2 is those pieces,
++built under the same rules as the rest of the kit: plain HTML that works without JavaScript, escaped text, the theme's
++tokens, and phone first. `node scripts/kit-gallery.js --shots <dir> --kit2` draws every piece at 320, 390, 768 and 1280
++px wide, light and dark.
++
++| Builder | What it is for | On a phone |
++| --- | --- | --- |
++| `steps({ items, current })` | A flow of a few steps: opt in, verify, choose, live. Done steps link back; later ones never link ahead. | Dots joined by a line, and "Step 2 of 4 · Verify the domain" |
++| `table({ columns, rows, caption })` | Plans, endpoints, prices: anything you compare across columns. | Each row is a card: the `primary` column is its title, and every other cell names its column |
++| `chart({ type, series \| points, x, y })` | `line` and `area` (history: a price, a count, a rating over time), `bar` (one bar per day or kind), `scatter` (quality against cost, with a dashed `frontier` through the best points) | The same chart, with its words at their real size |
++| `rank({ items, base, fmt })` | A leaderboard: the place, the name, the value ± its interval, the change since last time, and a bar in proportion | The bar runs under the name |
++| `meter({ value, max, tone })` | How full something is. `tone: 'auto'` for use of a limit (amber from 75 %, red from 90 %). Without a tone, more is simply more (a confidence) | |
++| `drop({ name, accept, camera, auto })` | Where a file goes: a real file input, plus drag and drop, paste and the phone's camera (kit-web.js). `auto` sends the form when a file is chosen | A "Take a photo" button |
++| `receipt({ title, body, facts, actions })` | A result and what it took: the answer first, then one quiet line of facts (model, cost, time, confidence, what is kept) | The facts wrap |
++| `page({ head, body, side, dock })` | The whole shape of an app page in one call; `side` sits beside the content from 1024 px and under it below that | |
++| `num(v, fmt)` | Numbers the way a reader wants them: `'money'` ($12.50, $0.10, $0.027), `'compact'` (1.2M), `'percent'`, `'ms'` (840 ms, 2.4 s), `'int'`. A missing value is "–", never 0 | |
++
++```js
++const kit = require('openvibe-shared/kit');
++kit.chart({ type: 'line', title: 'Price over 90 days', x: { time: true }, y: { fmt: 'money', zero: false },
++ series: [{ name: 'Price', points: rows.map((r) => [r.seen_at, r.price]) }] });
++kit.rank({ base: 900, fmt: 'int', items: models.map((m) => ({ name: m.name, href: `/m/${m.id}`, value: m.elo, interval: m.ci, delta: m.moved })) });
++kit.steps({ current: 1, items: [{ label: 'Opt in', href: '/merchant' }, { label: 'Verify the domain' }, { label: 'Live' }] });
++```
++
++How the charts are drawn, so they read on a phone: the lines and areas are an SVG stretched to the box
++(`preserveAspectRatio="none"`, and the stroke keeps its width with `vector-effect`), while every tick label, legend,
++point and bar is real HTML placed by percent. Text stays its real size from 320 px to 1280 px, points and bars are
++focusable and say their values, ticks fall on round numbers (1, 2 or 5 × 10ⁿ) and on round moments (hours, days, weeks,
++months), and a table with the same numbers sits behind each chart for screen readers. kit-web.js adds the readout on
++lines and areas (under the pointer, a tap, or ← → Home End) and hides a scatter label that would sit on one already
++shown, best points first. Colours: `--ovk-c1` is the theme's accent, then `--ovk-c2` … `--ovk-c5` (cyan, amber,
++violet, green), with deeper tones in light themes.
++
## The navbar's links
The 2026-10-28 sweep of all 38 sites' bars found three habits that made them feel crowded and repetitive:
ddiff --git a/kit-web.js b/kit-web.js
iindex 55d7278..142e203 100644
---- a/kit-web.js
++++ b/kit-web.js
@@@ -15,6 +15,8 @@
* share [data-ovk-share]: the system share sheet, else the link copied (the menu item says "Link copied")
* card previews .ovk-card video[data-src]: a muted loop while the card is hovered or focused (pointers only, never
* under reduced motion; the file loads only then)
++ * charts .ovk-chart-plot[data-ovk-chart]: the values under the pointer, a tap, or ← → Home End (Kit 2)
++ * drop zones [data-ovk-drop]: drag and drop, paste, the camera on phones, the file's name and picture (Kit 2)
*
* Idempotent and safe on a page that uses none of it. docs/kit.md describes the pieces.
*/
@@@ -157,6 +159,106 @@
}, () => {});
});
});
++ // Charts (Kit 2): a rule, a dot per series and the values under the pointer or the arrow keys, on lines and areas.
++ all('.ovk-chart-plot[data-ovk-chart]').forEach((plot) => {
++ if (!once(plot, 'ovkChart')) return;
++ let data; try { data = JSON.parse(plot.getAttribute('data-ovk-chart')); } catch (e) { return; }
++ if (!data || !data.x || !data.x.length) return;
++ const rule = plot.querySelector('.ovk-chart-rule'), tip = plot.querySelector('.ovk-chart-tip');
++ if (!rule || !tip) return;
++ let at = -1;
++ const show = (i) => {
++ at = Math.max(0, Math.min(data.x.length - 1, i));
++ const [px, label] = data.x[at];
++ rule.style.left = `${px}%`; rule.hidden = false;
++ rule.innerHTML = data.s.map((s) => (s.v[at] ? `<i class="ovk-chart-s${s.c}" style="bottom:${s.v[at][0]}%"></i>` : '')).join('');
++ tip.textContent = '';
++ const b = document.createElement('b'); b.textContent = label; tip.appendChild(b);
++ data.s.forEach((s) => { if (!s.v[at]) return; const line = document.createElement('span'); line.className = `ovk-chart-s${s.c}`; line.innerHTML = '<i></i>'; line.appendChild(document.createTextNode(`${s.n ? `${s.n} ` : ''}${s.v[at][1]}`)); tip.appendChild(line); });
++ tip.hidden = false;
++ const w = plot.clientWidth, tw = tip.offsetWidth, x = (px / 100) * w;
++ tip.style.left = `${Math.max(0, Math.min(w - tw, x + 12 + tw > w ? x - tw - 12 : x + 12))}px`;
++ };
++ const hide = () => { rule.hidden = true; tip.hidden = true; };
++ const nearest = (clientX) => {
++ const r = plot.getBoundingClientRect(); const p = ((clientX - r.left) / r.width) * 100;
++ let best = 0; for (let i = 1; i < data.x.length; i++) if (Math.abs(data.x[i][0] - p) < Math.abs(data.x[best][0] - p)) best = i;
++ return best;
++ };
++ plot.addEventListener('pointermove', (e) => show(nearest(e.clientX)));
++ plot.addEventListener('pointerdown', (e) => show(nearest(e.clientX)));
++ plot.addEventListener('pointerleave', hide);
++ plot.addEventListener('blur', hide);
++ plot.addEventListener('focus', () => show(at < 0 ? data.x.length - 1 : at));
++ plot.addEventListener('keydown', (e) => {
++ const step = { ArrowLeft: -1, ArrowRight: 1, Home: -1e9, End: 1e9 }[e.key];
++ if (step == null) { if (e.key === 'Escape') hide(); return; }
++ e.preventDefault(); show((at < 0 ? data.x.length - 1 : at) + step);
++ });
++ });
++ // Scatter labels (Kit 2): a label that would sit on one already shown is hidden (best points first); its dot still says it.
++ all('.ovk-chart--scatter .ovk-chart-plot').forEach((plot) => {
++ const names = Array.prototype.slice.call(plot.querySelectorAll('.ovk-chart-name'));
++ if (names.length < 2) return;
++ const place = () => {
++ names.forEach((n) => n.classList.remove('is-hidden'));
++ const order = names.slice().sort((a, b) => (b.parentNode.classList.contains('is-best') - a.parentNode.classList.contains('is-best')) || (parseFloat(b.parentNode.style.bottom) - parseFloat(a.parentNode.style.bottom)));
++ const kept = [];
++ order.forEach((n) => {
++ const r = n.getBoundingClientRect();
++ const hit = kept.some((k) => r.left < k.right + 4 && r.right + 4 > k.left && r.top < k.bottom + 2 && r.bottom + 2 > k.top);
++ if (hit) n.classList.add('is-hidden'); else kept.push(r);
++ });
++ };
++ if (!once(plot, 'ovkLabels')) return;
++ place();
++ if (typeof ResizeObserver !== 'undefined') new ResizeObserver(place).observe(plot);
++ });
++ // Drop zones (Kit 2): drag and drop, paste, the camera on phones, the chosen file's name and picture.
++ all('[data-ovk-drop]').forEach((box) => {
++ if (!once(box, 'ovkDrop')) return;
++ const input = box.querySelector('.ovk-drop-input'), zone = box.querySelector('.ovk-drop-zone'), name = box.querySelector('.ovk-drop-name');
++ if (!input || !zone) return;
++ box.classList.add('is-js');
++ let url = null;
++ const shown = () => {
++ const files = Array.prototype.slice.call(input.files || []);
++ box.classList.toggle('has-file', files.length > 0);
++ if (!name) return;
++ name.textContent = '';
++ if (url) { URL.revokeObjectURL(url); url = null; }
++ if (!files.length) return;
++ if (files.length === 1 && /^image\//.test(files[0].type) && window.URL) {
++ const img = document.createElement('img'); url = URL.createObjectURL(files[0]); img.src = url; img.alt = ''; name.appendChild(img);
++ }
++ name.appendChild(document.createTextNode(files.length === 1 ? files[0].name : `${files.length} files`));
++ };
++ const take = (list) => {
++ if (!list || !list.length || typeof DataTransfer === 'undefined') return;
++ const dt = new DataTransfer();
++ Array.prototype.slice.call(list, 0, input.multiple ? list.length : 1).forEach((f) => dt.items.add(f));
++ input.files = dt.files;
++ input.dispatchEvent(new Event('change', { bubbles: true }));
++ };
++ input.addEventListener('change', () => {
++ shown();
++ if (box.hasAttribute('data-ovk-auto') && input.files && input.files.length && input.form) (input.form.requestSubmit ? input.form.requestSubmit() : input.form.submit());
++ });
++ ['dragenter', 'dragover'].forEach((t) => zone.addEventListener(t, (e) => { e.preventDefault(); box.classList.add('is-over'); }));
++ ['dragleave', 'dragend'].forEach((t) => zone.addEventListener(t, () => box.classList.remove('is-over')));
++ zone.addEventListener('drop', (e) => { e.preventDefault(); box.classList.remove('is-over'); take(e.dataTransfer && e.dataTransfer.files); });
++ // Paste: only while this is the page's one drop zone, and never into a field someone is typing in.
++ document.addEventListener('paste', (e) => {
++ if (document.querySelectorAll('[data-ovk-drop]').length !== 1) return;
++ const t = e.target; if (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName))) return;
++ const files = e.clipboardData && e.clipboardData.files; if (files && files.length) { e.preventDefault(); take(files); }
++ });
++ const cam = box.querySelector('.ovk-drop-cam'), camInput = box.querySelector('[data-ovk-drop-cam]');
++ if (cam && camInput && window.matchMedia && matchMedia('(pointer: coarse)').matches) {
++ cam.hidden = false;
++ camInput.addEventListener('change', () => { take(camInput.files); camInput.value = ''; });
++ }
++ });
// Card previews: a muted loop while hovered or focused, on computers only, never under reduced motion.
if (fine) all('.ovk-card').forEach((c) => {
const v = c.querySelector('video.ovk-card-video[data-src]');
ddiff --git a/kit.css b/kit.css
iindex 398c63e..94f11ac 100644
---- a/kit.css
++++ b/kit.css
@@@ -253,3 +253,174 @@ div.ovk-set-row { cursor: default; }
}
@media (prefers-reduced-motion: reduce) { .ovk-switch, .ovk-switch::after, .ovk-fold > summary > .ovk-i, .ovk-head, .ovk-btn { transition: none; } }
@media print { .ovk-head, .ovk-dock, .ovk-save { position: static; background: none; -webkit-backdrop-filter: none; backdrop-filter: none; } }
++
++/* ── Kit 2 (4.36.0): steps, tables, charts, rankings, meters, drop zones, receipts ── */
++:root {
++ --ovk-c1: var(--ovk-accent); --ovk-c2: #22d3ee; --ovk-c3: #f59e0b; --ovk-c4: #a78bfa; --ovk-c5: #34d399;
++}
++:root[data-theme-mode="light"] { --ovk-c2: #0e7490; --ovk-c3: #b45309; --ovk-c4: #6d28d9; --ovk-c5: #047857; }
++.ovk-chart-s1 { --ovk-c: var(--ovk-c1); } .ovk-chart-s2 { --ovk-c: var(--ovk-c2); } .ovk-chart-s3 { --ovk-c: var(--ovk-c3); }
++.ovk-chart-s4 { --ovk-c: var(--ovk-c4); } .ovk-chart-s5 { --ovk-c: var(--ovk-c5); }
++
++/* Steps: dots joined by a line; a phone says "Step 2 of 4 · Verify", wider screens name every step */
++.ovk-steps { margin: 0 0 16px; color: var(--ovk-text); }
++.ovk-steps-now { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px; margin: 0 0 10px; font-size: .92rem; }
++.ovk-steps-now span { color: var(--ovk-muted); font-size: .8rem; font-weight: 700; letter-spacing: .02em; }
++.ovk-steps ol { display: flex; margin: 0; padding: 0; list-style: none; }
++.ovk-steps li { position: relative; flex: 1 1 0; min-width: 0; display: flex; justify-content: center; }
++/* The line between two dots stops short of both, so it never crosses a dot (or shows through a see-through panel). */
++.ovk-steps li + li::before { content: ''; position: absolute; top: 21px; right: calc(50% + 20px); width: calc(100% - 40px); height: 2px; border-radius: 2px; background: var(--ovk-line); }
++.ovk-steps li.is-done + li::before { background: var(--ovk-fill); }
++.ovk-steps li > a, .ovk-steps-item { position: relative; display: flex; flex-direction: column; align-items: center; gap: 6px; min-width: var(--ovk-tap); min-height: var(--ovk-tap); padding: 8px 4px 0; color: inherit; text-decoration: none; text-align: center; }
++.ovk-steps-n { display: grid; place-items: center; width: 28px; height: 28px; box-sizing: border-box; border-radius: 50%; border: 2px solid var(--ovk-line); background: var(--ovk-panel); color: var(--ovk-muted); font-size: .8rem; font-weight: 800; font-variant-numeric: tabular-nums; }
++.ovk-steps-n .ovk-i { width: 15px; height: 15px; stroke-width: 3; }
++.ovk-steps .is-done .ovk-steps-n { border-color: var(--ovk-fill); background: var(--ovk-fill); color: var(--ovk-on-fill); }
++.ovk-steps .is-current .ovk-steps-n { border-color: var(--ovk-accent); background: color-mix(in srgb, var(--ovk-accent) 16%, var(--ovk-panel)); color: var(--ovk-accent); box-shadow: 0 0 0 4px color-mix(in srgb, var(--ovk-accent) 14%, transparent); }
++.ovk-steps li > a:hover .ovk-steps-n { filter: brightness(1.1); }
++.ovk-steps li > a:focus-visible { outline: 2px solid var(--ovk-accent); outline-offset: 2px; border-radius: 12px; }
++.ovk-steps-l { position: absolute !important; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
++.ovk-steps-l small { display: block; color: var(--ovk-muted); font-size: .76rem; font-weight: 500; }
++@media (min-width: 600px) {
++ .ovk-steps-now { position: absolute !important; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
++ .ovk-steps-l { position: static !important; width: auto; height: auto; overflow: visible; clip: auto; white-space: normal; font-size: .86rem; font-weight: 600; line-height: 1.3; }
++ .ovk-steps .is-next .ovk-steps-l { color: var(--ovk-muted); }
++ .ovk-steps .is-current .ovk-steps-l { color: var(--ovk-text); font-weight: 800; }
++}
++
++/* Tables: a real table on wide screens; under 600 px each row is a card that names its cells */
++.ovk-table-wrap { margin: 0 0 14px; border: 1px solid var(--ovk-line); border-radius: var(--ovk-radius); background: var(--ovk-panel); overflow-x: auto; color: var(--ovk-text); }
++.ovk-table-wrap:focus-visible { outline: 2px solid var(--ovk-accent); outline-offset: 2px; }
++.ovk-table { width: 100%; border-collapse: collapse; font-size: .92rem; line-height: 1.4; }
++.ovk-table caption { padding: 12px 14px 6px; color: var(--ovk-muted); font-size: .74rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; text-align: left; }
++.ovk-table thead th { padding: 10px 14px; color: var(--ovk-muted); font-size: .72rem; font-weight: 800; letter-spacing: .05em; text-transform: uppercase; text-align: left; white-space: nowrap; border-bottom: 1px solid var(--ovk-line); }
++.ovk-table td, .ovk-table tbody th { padding: 11px 14px; text-align: left; vertical-align: top; border-top: 1px solid var(--ovk-line); }
++.ovk-table tbody tr:first-child > * { border-top: 0; }
++.ovk-table tbody th { font-weight: 700; }
++.ovk-table .is-num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
++.ovk-table a { color: var(--ovk-accent); text-decoration: none; }
++.ovk-table a:hover { text-decoration: underline; }
++.ovk-table tbody tr:hover > * { background: color-mix(in srgb, var(--ovk-panel-2) 55%, transparent); }
++.ovk-table-empty { margin: 0 0 14px; padding: 18px 16px; border: 1px dashed var(--ovk-line); border-radius: var(--ovk-radius); color: var(--ovk-muted); text-align: center; }
++@media (max-width: 599px) {
++ .ovk-table--stack { overflow: visible; }
++ .ovk-table--stack .ovk-table, .ovk-table--stack tbody, .ovk-table--stack tr, .ovk-table--stack td, .ovk-table--stack tbody th { display: block; }
++ .ovk-table--stack caption { display: block; }
++ .ovk-table--stack thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
++ .ovk-table--stack tbody tr { padding: 12px 14px; border-top: 1px solid var(--ovk-line); }
++ .ovk-table--stack tbody tr:first-child { border-top: 0; }
++ .ovk-table--stack tbody tr:hover > * { background: none; }
++ .ovk-table--stack td, .ovk-table--stack tbody th { display: flex; justify-content: space-between; align-items: baseline; gap: 14px; padding: 3px 0; border: 0; text-align: right; }
++ .ovk-table--stack td::before { content: attr(data-label); flex: none; max-width: 50%; color: var(--ovk-muted); font-size: .8rem; text-align: left; }
++ .ovk-table--stack .is-primary { display: block; padding: 0 0 4px; font-size: 1rem; font-weight: 800; text-align: left; }
++ .ovk-table--stack .is-primary::before { content: none; }
++ .ovk-table--stack .is-hide-sm { display: none; }
++}
++
++/* Charts: the lines are an SVG stretched to the box; every label is real text at its real size */
++.ovk-chart { margin: 0 0 14px; padding: 14px 14px 10px; border: 1px solid var(--ovk-line); border-radius: var(--ovk-radius); background: var(--ovk-panel); color: var(--ovk-text); font-family: var(--ovk-font); }
++.ovk-chart figcaption { display: grid; gap: 2px; margin: 0 0 8px; }
++.ovk-chart figcaption strong { font-size: 1rem; font-weight: 800; }
++.ovk-chart figcaption span { color: var(--ovk-muted); font-size: .84rem; }
++.ovk-chart-legend { display: flex; flex-wrap: wrap; gap: 4px 14px; margin: 0 0 10px; padding: 0; list-style: none; font-size: .84rem; color: var(--ovk-muted); }
++.ovk-chart-legend li { display: inline-flex; align-items: center; gap: 6px; }
++.ovk-chart-legend i { width: 14px; height: 3px; border-radius: 2px; background: var(--ovk-c); }
++.ovk-chart-legend b { color: var(--ovk-text); font-weight: 800; font-variant-numeric: tabular-nums; }
++.ovk-chart-yname { display: block; margin: 0 0 6px; color: var(--ovk-muted); font-size: .74rem; font-weight: 700; }
++.ovk-chart-xname { margin: 2px 0 0; color: var(--ovk-muted); font-size: .74rem; font-weight: 700; text-align: right; }
++.ovk-chart-plot { position: relative; height: var(--ovk-chart-h, 220px); margin: 8px 6px 26px var(--ovk-chart-g, 44px); outline: none; touch-action: pan-y; }
++.ovk-chart-plot:focus-visible { box-shadow: 0 0 0 2px var(--ovk-accent); border-radius: 4px; }
++.ovk-chart-svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
++.ovk-chart-grid { position: absolute; left: 0; right: 0; height: 0; border-top: 1px solid color-mix(in srgb, var(--ovk-line) 70%, transparent); }
++.ovk-chart-yt { position: absolute; right: calc(100% + 8px); transform: translateY(50%); color: var(--ovk-muted); font-size: .72rem; font-variant-numeric: tabular-nums; white-space: nowrap; line-height: 1; }
++.ovk-chart-xt { position: absolute; top: calc(100% + 8px); transform: translateX(-50%); color: var(--ovk-muted); font-size: .72rem; white-space: nowrap; line-height: 1; }
++.ovk-chart-line { fill: none; stroke: var(--ovk-c, var(--ovk-c1)); stroke-width: 2.25; stroke-linejoin: round; stroke-linecap: round; vector-effect: non-scaling-stroke; }
++.ovk-chart-area { fill: color-mix(in srgb, var(--ovk-c, var(--ovk-c1)) 16%, transparent); stroke: none; }
++.ovk-chart-front { fill: none; stroke: var(--ovk-c3); stroke-width: 1.5; stroke-dasharray: 5 4; vector-effect: non-scaling-stroke; }
++.ovk-chart-rule { position: absolute; top: 0; bottom: 0; width: 1px; background: color-mix(in srgb, var(--ovk-text) 35%, transparent); pointer-events: none; }
++.ovk-chart-rule[hidden], .ovk-chart-tip[hidden] { display: none; }
++.ovk-chart-rule i { position: absolute; left: -5px; width: 10px; height: 10px; box-sizing: border-box; border-radius: 50%; border: 2px solid var(--ovk-panel); background: var(--ovk-c); transform: translateY(50%); }
++.ovk-chart-tip, .ovk-chart-pop { position: absolute; z-index: 3; min-width: 96px; padding: 7px 10px; border: 1px solid var(--ovk-line); border-radius: 10px; background: var(--ovk-panel-2); color: var(--ovk-text); font-size: .8rem; line-height: 1.35; font-variant-numeric: tabular-nums; white-space: nowrap; box-shadow: 0 8px 24px rgba(0, 0, 0, .28); pointer-events: none; }
++.ovk-chart-tip { top: 0; }
++.ovk-chart-tip b, .ovk-chart-pop b { display: block; color: var(--ovk-muted); font-size: .74rem; font-weight: 700; }
++.ovk-chart-tip span { display: flex; align-items: center; gap: 6px; }
++.ovk-chart-tip span i { width: 8px; height: 8px; border-radius: 50%; background: var(--ovk-c); }
++.ovk-chart-pop { display: none; bottom: calc(100% + 8px); left: 50%; transform: translateX(-50%); }
++.ovk-chart-dot { position: absolute; z-index: 1; width: 12px; height: 12px; margin: 0 0 -6px -6px; box-sizing: border-box; border-radius: 50%; border: 2px solid var(--ovk-panel); background: color-mix(in srgb, var(--ovk-c1) 60%, var(--ovk-muted)); color: var(--ovk-text); outline: none; text-decoration: none; }
++.ovk-chart-dot::after { content: ''; position: absolute; inset: -12px; }
++.ovk-chart-dot.is-best { background: var(--ovk-c1); width: 14px; height: 14px; margin: 0 0 -7px -7px; }
++.ovk-chart-dot:hover, .ovk-chart-dot:focus-visible { z-index: 4; box-shadow: 0 0 0 3px color-mix(in srgb, var(--ovk-accent) 40%, transparent); }
++.ovk-chart-dot:hover .ovk-chart-pop, .ovk-chart-dot:focus-visible .ovk-chart-pop, .ovk-chart-bar:hover .ovk-chart-pop, .ovk-chart-bar:focus-visible .ovk-chart-pop { display: block; }
++.ovk-chart-dot.is-right .ovk-chart-pop { left: auto; right: -6px; transform: none; }
++.ovk-chart-name.is-hidden { visibility: hidden; }
++.ovk-chart-name { position: absolute; left: 14px; top: 50%; transform: translateY(-50%); color: var(--ovk-text); font-size: .74rem; font-weight: 600; white-space: nowrap; pointer-events: none; text-shadow: 0 0 3px var(--ovk-panel), 0 0 3px var(--ovk-panel); }
++.ovk-chart-dot.is-right .ovk-chart-name { left: auto; right: 14px; }
++.ovk-chart-bars { position: absolute; inset: 0; display: flex; align-items: flex-end; gap: clamp(1px, calc(12% / var(--n, 10)), 6px); }
++.ovk-chart-bar { position: relative; flex: 1 1 0; min-width: 2px; height: var(--h, 0%); border-radius: 3px 3px 0 0; background: var(--ovk-c1); outline: none; }
++.ovk-chart-bar:hover, .ovk-chart-bar:focus-visible { filter: brightness(1.15); box-shadow: 0 0 0 2px color-mix(in srgb, var(--ovk-accent) 45%, transparent); }
++.ovk-chart-bar.is-none { background: transparent; }
++.ovk-chart-blab { position: absolute; top: calc(100% + 8px); left: 50%; transform: translateX(-50%); color: var(--ovk-muted); font-size: .72rem; white-space: nowrap; line-height: 1; }
++.ovk-chart-bar .ovk-chart-pop { bottom: calc(100% + 6px); }
++
++/* Rankings: place, name, value ± interval, change, and a bar in proportion */
++.ovk-rank { margin: 0 0 14px; padding: 0; list-style: none; border: 1px solid var(--ovk-line); border-radius: var(--ovk-radius); background: var(--ovk-panel); overflow: hidden; color: var(--ovk-text); }
++.ovk-rank-row { position: relative; display: grid; grid-template-columns: 2.1em auto minmax(0, 1fr) auto auto; align-items: center; column-gap: 10px; row-gap: 6px; padding: 11px 14px 10px; border-top: 1px solid var(--ovk-line); }
++.ovk-rank-row:first-child { border-top: 0; }
++.ovk-rank-n { grid-column: 1; color: var(--ovk-muted); font-size: .95rem; font-weight: 800; font-variant-numeric: tabular-nums; text-align: center; }
++.ovk-rank-row.is-top .ovk-rank-n { color: var(--ovk-accent); }
++.ovk-rank-row > .ovk-mark { grid-column: 2; --ovk-mark: 34px; }
++.ovk-rank-main { grid-column: 3; min-width: 0; display: grid; gap: 1px; }
++.ovk-rank-name { display: flex; align-items: center; gap: 6px; min-width: 0; }
++.ovk-rank-name > strong, .ovk-rank-link { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 700; color: var(--ovk-text); text-decoration: none; }
++.ovk-rank-link::after { content: ''; position: absolute; inset: 0; }
++.ovk-rank-row:has(.ovk-rank-link):hover { background: var(--ovk-panel-2); }
++.ovk-rank-link:focus-visible { outline: none; }
++.ovk-rank-row:has(.ovk-rank-link:focus-visible) { box-shadow: inset 0 0 0 2px var(--ovk-accent); }
++.ovk-rank-main small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ovk-muted); font-size: .8rem; }
++.ovk-rank-val { grid-column: 4; display: grid; justify-items: end; line-height: 1.2; }
++.ovk-rank-val b { font-size: 1rem; font-weight: 800; font-variant-numeric: tabular-nums; white-space: nowrap; }
++.ovk-rank-val b small { color: var(--ovk-muted); font-size: .76rem; font-weight: 600; }
++.ovk-rank-val > small { color: var(--ovk-muted); font-size: .74rem; font-variant-numeric: tabular-nums; white-space: nowrap; }
++.ovk-rank-delta { grid-column: 5; display: inline-flex; align-items: center; gap: 1px; min-width: 2.6em; justify-content: flex-end; font-size: .78rem; font-weight: 800; font-variant-numeric: tabular-nums; }
++.ovk-rank-delta .ovk-i { width: 13px; height: 13px; stroke-width: 3; }
++.ovk-rank-delta.is-up { color: var(--ovk-ok); } .ovk-rank-delta.is-down { color: var(--ovk-bad); } .ovk-rank-delta.is-new { color: var(--ovk-accent); text-transform: uppercase; font-size: .68rem; letter-spacing: .04em; }
++.ovk-rank-bar { grid-column: 3 / -1; grid-row: 2; height: 4px; border-radius: 999px; background: color-mix(in srgb, var(--ovk-line) 60%, transparent); overflow: hidden; }
++.ovk-rank-bar::after { content: ''; display: block; height: 100%; width: calc(var(--v, 0) * 100%); border-radius: inherit; background: color-mix(in srgb, var(--ovk-c1) 55%, transparent); }
++.ovk-rank-row.is-top .ovk-rank-bar::after { background: var(--ovk-c1); }
++
++/* Meters */
++.ovk-meter { display: block; height: 8px; min-width: 56px; border-radius: 999px; background: color-mix(in srgb, var(--ovk-line) 70%, transparent); overflow: hidden; }
++.ovk-meter > span { display: block; height: 100%; width: calc(var(--v, 0) * 100%); border-radius: inherit; background: var(--ovk-fill); }
++.ovk-meter--ok > span { background: var(--ovk-ok); } .ovk-meter--warn > span { background: var(--ovk-warn); } .ovk-meter--bad > span { background: var(--ovk-bad); }
++
++/* Drop zones: a real file input; drag, paste and the camera arrive with kit-web.js */
++.ovk-drop { margin: 0 0 14px; color: var(--ovk-text); }
++.ovk-drop-input { display: block; width: 100%; margin: 0 0 8px; color: var(--ovk-muted); font: inherit; }
++.ovk-drop.is-js .ovk-drop-input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; overflow: hidden; }
++.ovk-drop-zone { display: grid; justify-items: center; gap: 6px; padding: 28px 16px; border: 2px dashed var(--ovk-line); border-radius: var(--ovk-radius); background: var(--ovk-panel); text-align: center; cursor: pointer; transition: border-color .12s, background .12s; }
++.ovk-drop-zone > .ovk-i { width: 30px; height: 30px; color: var(--ovk-accent); }
++.ovk-drop-zone strong { font-size: 1rem; font-weight: 800; }
++.ovk-drop-zone small { color: var(--ovk-muted); font-size: .84rem; }
++.ovk-drop-zone:hover, .ovk-drop.is-over .ovk-drop-zone { border-color: var(--ovk-accent); background: color-mix(in srgb, var(--ovk-accent) 8%, var(--ovk-panel)); }
++.ovk-drop-input:focus-visible + .ovk-drop-zone { outline: 2px solid var(--ovk-accent); outline-offset: 2px; }
++.ovk-drop-name:empty { display: none; }
++.ovk-drop-name { display: grid; justify-items: center; gap: 6px; margin-top: 4px; font-size: .86rem; font-weight: 600; word-break: break-all; }
++.ovk-drop-name img { max-width: 100%; max-height: 160px; border-radius: 10px; object-fit: contain; }
++.ovk-drop.has-file .ovk-drop-zone { border-style: solid; border-color: color-mix(in srgb, var(--ovk-accent) 55%, var(--ovk-line)); }
++.ovk-drop-cam { margin-top: 8px; }
++.ovk-drop-cam[hidden] { display: none; }
++
++/* Receipts: the result first, then one quiet line of what it took */
++.ovk-receipt { margin: 0 0 14px; border: 1px solid var(--ovk-line); border-radius: var(--ovk-radius); background: var(--ovk-panel); overflow: hidden; color: var(--ovk-text); }
++.ovk-receipt--ok { border-color: color-mix(in srgb, var(--ovk-ok) 45%, var(--ovk-line)); }
++.ovk-receipt--bad { border-color: color-mix(in srgb, var(--ovk-bad) 45%, var(--ovk-line)); }
++.ovk-receipt-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 8px 8px 0 14px; min-height: 44px; }
++.ovk-receipt-head h2 { margin: 0; font-size: 1rem; font-weight: 800; }
++.ovk-receipt-acts { display: inline-flex; gap: 2px; }
++.ovk-receipt-body { padding: 10px 14px 14px; line-height: 1.5; overflow-wrap: anywhere; }
++.ovk-receipt-body > :first-child { margin-top: 0; } .ovk-receipt-body > :last-child { margin-bottom: 0; }
++.ovk-receipt-facts { display: flex; flex-wrap: wrap; gap: 6px 16px; margin: 0; padding: 10px 14px; border-top: 1px solid var(--ovk-line); background: color-mix(in srgb, var(--ovk-panel-2) 45%, transparent); font-size: .8rem; }
++.ovk-receipt-facts > div { display: inline-flex; align-items: center; gap: 5px; }
++.ovk-receipt-facts dt { color: var(--ovk-muted); }
++.ovk-receipt-facts dd { display: inline-flex; align-items: center; gap: 6px; margin: 0; font-weight: 700; font-variant-numeric: tabular-nums; }
++.ovk-receipt-facts .ovk-meter { width: 56px; height: 6px; }
++@media (prefers-reduced-motion: reduce) { .ovk-drop-zone { transition: none; } }
ddiff --git a/kit.js b/kit.js
iindex e77116a..4cca87c 100644
---- a/kit.js
++++ b/kit.js
@@@ -378,10 +378,284 @@
+ `<button type="button" class="ovk-rail-btn ovk-rail-next" aria-label="Scroll on" hidden>${icon('forward')}</button></div></section>`;
}
++ // ── Kit 2 (4.36.0): the pieces data and tool pages need — steps, tables, charts, rankings, a drop zone, a result
++ // with its receipt, a meter, and the page's whole shape in one call. Same rules: plain HTML that works without
++ // JavaScript, escaped text, theme tokens, phone first (docs/kit.md "Kit 2").
++
++ /** A number the way a reader wants it: fmt 'int' | 'compact' | 'money' | 'percent' (0–100) | 'ms' | 'auto'. */
++ function num(v, fmt = 'auto') {
++ const n = Number(v);
++ if (v == null || v === '' || !Number.isFinite(n)) return '–';
++ if (typeof fmt === 'function') return String(fmt(n));
++ if (fmt === 'compact') return fmtCount(Math.round(n));
++ if (fmt === 'int') return Math.round(n).toLocaleString('en-US');
++ if (fmt === 'percent') return `${Math.abs(n) < 10 && n % 1 ? n.toFixed(1) : Math.round(n)}%`;
++ if (fmt === 'ms') return n < 1000 ? `${Math.round(n)} ms` : `${(n / 1000).toFixed(n < 1e4 ? 1 : 0)} s`;
++ if (fmt === 'money') {
++ // Exact enough to compare: cents from ten cents up ($0.10, $12.50), two significant figures below ($0.027, $0.0041).
++ const a = Math.abs(n);
++ const s = a === 0 ? '0.00' : a >= 0.1 ? a.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : String(Number(a.toPrecision(2)));
++ return `${n < 0 ? '−' : ''}$${s}`;
++ }
++ return n.toLocaleString('en-US', { maximumFractionDigits: Math.abs(n) < 10 ? 2 : Math.abs(n) < 100 ? 1 : 0 });
++ }
++
++ /**
++ * steps({ items: [{ label, href, note }], current: index, label }) — where someone is in a flow of a few steps.
++ * Done steps link back (when they have an href), the current one says aria-current="step", later ones are plain.
++ * A phone shows the dots and "Step 2 of 4 · Verify"; a tablet or computer shows every label.
++ */
++ function steps({ items = [], current = 0, label = 'Steps' } = {}) {
++ const list = (items || []).filter(Boolean);
++ if (!list.length) return '';
++ const at = Math.min(Math.max(0, Number(current) || 0), list.length - 1);
++ const li = list.map((s, i) => {
++ const state = i < at ? 'is-done' : i === at ? 'is-current' : 'is-next';
++ const inner = `<span class="ovk-steps-n" aria-hidden="true">${i < at ? icon('check') : i + 1}</span><span class="ovk-steps-l">${esc(s.label)}${s.note ? `<small>${esc(s.note)}</small>` : ''}</span>`;
++ const sr = i < at ? '<span class="ovk-sr"> (done)</span>' : '';
++ return `<li class="${state}"${i === at ? ' aria-current="step"' : ''}>${i < at && s.href ? `<a href="${esc(url(s.href))}">${inner}${sr}</a>` : `<span class="ovk-steps-item">${inner}${sr}</span>`}</li>`;
++ }).join('');
++ return `<nav class="ovk-steps" aria-label="${esc(label)}" style="--ovk-steps:${list.length}"><p class="ovk-steps-now"><span>Step ${at + 1} of ${list.length}</span> <strong>${esc(list[at].label)}</strong></p><ol>${li}</ol></nav>`;
++ }
++
++ /**
++ * table({ columns: [{ key, label, num, fmt, primary, hide: 'sm', html }], rows: [{…}], caption, label, empty, stack })
++ * A real table on wide screens. Under 600 px each row becomes a card (stack, the default): the primary column is its
++ * title and every other cell says its column's name. A cell is text (escaped), { text, href } (a link), or trusted
++ * markup when its column says html: true. num: right-aligned, tabular figures, formatted with fmt (kit.num).
++ */
++ function table({ columns = [], rows = [], caption = '', label = '', empty: none = '', stack = true, id = null } = {}) {
++ const cols = (columns || []).filter((c) => c && c.key != null);
++ if (!rows || !rows.length) return none ? `<p class="ovk-table-empty">${esc(none)}</p>` : '';
++ const primary = cols.find((c) => c.primary) || cols[0];
++ const cell = (c, v) => {
++ if (c.html) return v == null ? '' : String(v);
++ if (v && typeof v === 'object' && v.href) return `<a href="${esc(url(v.href))}">${esc(v.text != null ? v.text : v.href)}</a>`;
++ if (c.num) return esc(num(v, c.fmt || 'auto'));
++ return esc(v == null ? '' : v);
++ };
++ const cls = (c) => [c.num ? 'is-num' : '', c === primary ? 'is-primary' : '', c.hide === 'sm' ? 'is-hide-sm' : ''].filter(Boolean).join(' ');
++ const head = `<thead><tr>${cols.map((c) => `<th scope="col"${cls(c) ? ` class="${cls(c)}"` : ''}>${esc(c.label)}</th>`).join('')}</tr></thead>`;
++ const body = `<tbody>${rows.map((r) => `<tr>${cols.map((c) => {
++ const tag = c === primary ? 'th scope="row"' : 'td';
++ return `<${tag}${cls(c) ? ` class="${cls(c)}"` : ''} data-label="${esc(c.label)}">${cell(c, r[c.key])}</${tag.split(' ')[0]}>`;
++ }).join('')}</tr>`).join('')}</tbody>`;
++ const named = caption ? '' : ` aria-label="${esc(label || 'Table')}"`;
++ return `<div class="ovk-table-wrap${stack ? ' ovk-table--stack' : ''}" role="region" tabindex="0"${named}${id ? ` id="${esc(id)}"` : ''}><table class="ovk-table">${caption ? `<caption>${esc(caption)}</caption>` : ''}${head}${body}</table></div>`;
++ }
++
++ /** Three to six round ticks between lo and hi (1, 2, 5 × 10ⁿ). */
++ function niceTicks(lo, hi, count = 4) {
++ if (!(hi > lo)) { hi = lo === 0 ? 1 : lo + Math.abs(lo) * 0.1; }
++ const raw = (hi - lo) / count;
++ const mag = Math.pow(10, Math.floor(Math.log10(raw)));
++ const f = raw / mag;
++ const step = (f >= 7 ? 10 : f >= 3 ? 5 : f >= 1.5 ? 2 : 1) * mag;
++ const a = Math.floor(lo / step + 1e-9) * step, b = Math.ceil(hi / step - 1e-9) * step;
++ const ticks = [];
++ for (let v = a; v <= b + step / 2; v += step) ticks.push(Number(v.toPrecision(12)));
++ return { lo: a, hi: b, ticks };
++ }
++ const HOUR = 3600000;
++ const toMs = (x) => (x instanceof Date ? x.getTime() : typeof x === 'number' ? x : Date.parse(String(x)));
++ /** Ticks on a time axis at round moments (hours, days, weeks, months), with labels that say only what changes. */
++ function timeTicks(lo, hi) {
++ const span = hi - lo;
++ const steps = [HOUR, 3 * HOUR, 6 * HOUR, 12 * HOUR, DAY, 2 * DAY, 7 * DAY, 14 * DAY];
++ const out = [];
++ const d2 = (n) => String(n).padStart(2, '0');
++ if (span <= 70 * DAY) {
++ const step = steps.find((s) => span / s <= 5) || 14 * DAY;
++ const day = step >= DAY;
++ for (let t = Math.ceil(lo / step) * step; t <= hi; t += step) {
++ const d = new Date(t);
++ out.push([t, day ? `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}` : `${d2(d.getUTCHours())}:00`]);
++ }
++ return out;
++ }
++ const months = span <= 200 * DAY ? 1 : span <= 400 * DAY ? 3 : span <= 800 * DAY ? 6 : 12;
++ const s = new Date(lo);
++ let y = s.getUTCFullYear(), m = Math.ceil((s.getUTCMonth() + (s.getUTCDate() > 1 ? 1 : 0)) / months) * months;
++ for (;;) {
++ while (m >= 12) { m -= 12; y += 1; }
++ const t = Date.UTC(y, m, 1);
++ if (t > hi) break;
++ out.push([t, m === 0 || months === 12 ? `${MONTHS[m]} ${y}` : MONTHS[m]]);
++ m += months;
++ }
++ return out;
++ }
++ const pct = (v) => `${(Math.round(v * 100) / 100)}%`;
++
++ /**
++ * chart({ type: 'line' | 'area' | 'bar' | 'scatter', series, points, x: { time, label, fmt }, y: { label, fmt, zero },
++ * title, note, height, label })
++ * - line/area: series: [{ name, points: [[x, y], …] }] (y null = a gap). x.time: x is a time (ms, ISO or Date).
++ * - bar: series: [{ name, points: [[label or time, value], …] }], one bar per point (counts per day, per kind).
++ * - scatter: points: [{ x, y, label, href, mark: true }] — every point is focusable and says its values; mark:
++ * true writes its label beside it. frontier: true draws the line through the points marked best.
++ * The picture is an SVG stretched to the box (strokes keep their width); every word and number is real text at its
++ * real size, so a phone reads it as well as a computer. A table with the same numbers is there for screen readers;
++ * kit-web.js adds the hover and arrow-key readout on lines and areas.
++ */
++ function chart({ type = 'line', series = [], points = [], x: xo = {}, y: yo = {}, title = '', note = '', height = 220, label = '', id = null } = {}) {
++ const kind = ['line', 'area', 'bar', 'scatter'].includes(type) ? type : 'line';
++ const isTime = !!xo.time;
++ const fx = (v) => (isTime ? (() => { const d = new Date(toMs(v)); return `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}${xo.hours ? ` ${String(d.getUTCHours()).padStart(2, '0')}:00` : ''}`; })() : xo.fmt ? num(v, xo.fmt) : String(v));
++ const fy = (v) => num(v, yo.fmt || 'auto');
++ const name = title || label || 'Chart';
++ let plot = '', table = '', legend = '', data = null, yl = [];
++
++ if (kind === 'scatter') {
++ const ps = (points || []).filter((p) => p && Number.isFinite(Number(p.x)) && Number.isFinite(Number(p.y)));
++ if (!ps.length) return '';
++ const xs = niceTicks(Math.min(...ps.map((p) => +p.x)) * (xo.zero === false ? 1 : 0), Math.max(...ps.map((p) => +p.x)));
++ const ys = niceTicks(yo.zero === false ? Math.min(...ps.map((p) => +p.y)) : Math.min(0, ...ps.map((p) => +p.y)), Math.max(...ps.map((p) => +p.y)));
++ const X = (v) => ((v - xs.lo) / (xs.hi - xs.lo)) * 100, Y = (v) => ((v - ys.lo) / (ys.hi - ys.lo)) * 100;
++ const best = ps.filter((p) => p.best).sort((a, b) => a.x - b.x);
++ const line = best.length > 1 ? `<svg class="ovk-chart-svg" viewBox="0 0 1000 1000" preserveAspectRatio="none" aria-hidden="true" focusable="false"><path class="ovk-chart-front" d="M${best.map((p) => `${(X(p.x) * 10).toFixed(1)},${(1000 - Y(p.y) * 10).toFixed(1)}`).join('L')}"/></svg>` : '';
++ const dots = ps.map((p) => {
++ const say = `${p.label ? `${p.label}: ` : ''}${yo.label || 'y'} ${fy(p.y)}, ${xo.label || 'x'} ${fx(p.x)}`;
++ const tag = p.href ? `a href="${esc(url(p.href))}"` : 'span tabindex="0"';
++ const right = X(+p.x) > 70;
++ return `<${tag} class="ovk-chart-dot${p.best ? ' is-best' : ''}${right ? ' is-right' : ''}" style="left:${pct(X(+p.x))};bottom:${pct(Y(+p.y))}" aria-label="${esc(say)}">`
++ + `${p.mark && p.label ? `<span class="ovk-chart-name" aria-hidden="true">${esc(p.label)}</span>` : ''}<span class="ovk-chart-pop" aria-hidden="true"><b>${esc(p.label || '')}</b>${esc(fy(p.y))} · ${esc(fx(p.x))}</span></${tag.split(' ')[0]}>`;
++ }).join('');
++ yl = ys.ticks.map(fy);
++ plot = axes(xs.ticks.map((t) => [X(t), fx(t)]), ys.ticks.map((t) => [Y(t), fy(t)])) + line + dots;
++ table = `<thead><tr><th scope="col">${esc(xo.label ? 'Name' : '')}</th><th scope="col">${esc(xo.label || 'x')}</th><th scope="col">${esc(yo.label || 'y')}</th></tr></thead><tbody>${ps.map((p) => `<tr><th scope="row">${esc(p.label || '')}</th><td>${esc(fx(p.x))}</td><td>${esc(fy(p.y))}</td></tr>`).join('')}</tbody>`;
++ } else {
++ const ss = (series || []).filter((s) => s && Array.isArray(s.points) && s.points.length);
++ if (!ss.length) return '';
++ if (kind === 'bar') {
++ const s = ss[0];
++ const val = (p) => (p[1] == null || p[1] === '' ? NaN : Number(p[1])); // missing is not 0
++ const vals = s.points.map(val).filter(Number.isFinite);
++ const ys = niceTicks(Math.min(0, ...vals), Math.max(0, ...vals));
++ const Y = (v) => ((v - ys.lo) / (ys.hi - ys.lo)) * 100;
++ const every = Math.max(1, Math.ceil(s.points.length / 6));
++ const bars = s.points.map((p, i) => {
++ const v = val(p);
++ const lab = isTime ? fx(p[0]) : String(p[0]);
++ return `<span class="ovk-chart-bar${Number.isFinite(v) ? '' : ' is-none'}" tabindex="0" style="--h:${pct(Number.isFinite(v) ? Y(v) - Y(Math.max(0, ys.lo)) : 0)}" aria-label="${esc(`${lab}: ${fy(v)}`)}"><span class="ovk-chart-pop" aria-hidden="true"><b>${esc(lab)}</b>${esc(fy(v))}</span>${i % every === 0 ? `<span class="ovk-chart-blab" aria-hidden="true">${esc(lab)}</span>` : ''}</span>`;
++ }).join('');
++ yl = ys.ticks.map(fy);
++ plot = axes([], ys.ticks.map((t) => [Y(t), fy(t)])) + `<div class="ovk-chart-bars" style="--n:${s.points.length}">${bars}</div>`;
++ table = `<thead><tr><th scope="col">${esc(xo.label || '')}</th><th scope="col">${esc(s.name || yo.label || '')}</th></tr></thead><tbody>${s.points.map((p) => `<tr><th scope="row">${esc(isTime ? fx(p[0]) : p[0])}</th><td>${esc(fy(p[1]))}</td></tr>`).join('')}</tbody>`;
++ } else {
++ const all = ss.flatMap((s) => s.points);
++ const xv = all.map((p) => (isTime ? toMs(p[0]) : Number(p[0]))).filter(Number.isFinite);
++ const yNum = all.map((p) => (p[1] == null ? NaN : Number(p[1]))).filter(Number.isFinite);
++ if (!xv.length || !yNum.length) return '';
++ const x0 = Math.min(...xv), x1 = Math.max(...xv);
++ const ys = niceTicks(yo.zero === false ? Math.min(...yNum) : Math.min(0, ...yNum), Math.max(...yNum));
++ const X = (v) => (x1 > x0 ? ((v - x0) / (x1 - x0)) * 100 : 50), Y = (v) => ((v - ys.lo) / (ys.hi - ys.lo)) * 100;
++ const xt = isTime ? timeTicks(x0, x1).map(([t, l]) => [X(t), l]) : niceTicks(x0, x1).ticks.filter((t) => t >= x0 && t <= x1).map((t) => [X(t), fx(t)]);
++ const paths = ss.map((s, i) => {
++ const pts = s.points.map((p) => [X(isTime ? toMs(p[0]) : Number(p[0])), p[1] == null ? null : Y(Number(p[1]))]);
++ let d = '', open = false;
++ for (const [px, py] of pts) { if (py == null || !Number.isFinite(py)) { open = false; continue; } d += `${open ? 'L' : 'M'}${(px * 10).toFixed(1)},${(1000 - py * 10).toFixed(1)}`; open = true; }
++ const firstX = pts.find((p) => p[1] != null), lastX = [...pts].reverse().find((p) => p[1] != null);
++ const area = kind === 'area' && firstX && lastX && !pts.some((p) => p[1] == null) ? `<path class="ovk-chart-area" d="${d}L${(lastX[0] * 10).toFixed(1)},${(1000 - Y(Math.max(ys.lo, 0)) * 10).toFixed(1)}L${(firstX[0] * 10).toFixed(1)},${(1000 - Y(Math.max(ys.lo, 0)) * 10).toFixed(1)}Z"/>` : '';
++ return `<g class="ovk-chart-s${(i % 5) + 1}">${area}<path class="ovk-chart-line" d="${d}"/></g>`;
++ }).join('');
++ yl = ys.ticks.map(fy);
++ plot = axes(xt, ys.ticks.map((t) => [Y(t), fy(t)])) + `<svg class="ovk-chart-svg" viewBox="0 0 1000 1000" preserveAspectRatio="none" aria-hidden="true" focusable="false">${paths}</svg>`;
++ // The readout's data (kit-web.js): x positions and labels, and per series its y positions and values.
++ const xsAll = [...new Set(ss[0].points.map((p) => (isTime ? toMs(p[0]) : Number(p[0]))))];
++ data = { x: xsAll.map((v) => [Math.round(X(v) * 100) / 100, fx(v)]),
++ s: ss.map((s, i) => ({ n: s.name || '', c: (i % 5) + 1, v: xsAll.map((xv0) => { const p = s.points.find((q) => (isTime ? toMs(q[0]) : Number(q[0])) === xv0); return p && p[1] != null ? [Math.round(Y(Number(p[1])) * 100) / 100, fy(p[1])] : null; }) })) };
++ const last = (s) => [...s.points].reverse().find((p) => p[1] != null);
++ legend = `<ul class="ovk-chart-legend">${ss.map((s, i) => `<li class="ovk-chart-s${(i % 5) + 1}"><i aria-hidden="true"></i>${esc(s.name || '')}${last(s) ? ` <b>${esc(fy(last(s)[1]))}</b>` : ''}</li>`).join('')}</ul>`;
++ table = `<thead><tr><th scope="col">${esc(xo.label || '')}</th>${ss.map((s) => `<th scope="col">${esc(s.name || '')}</th>`).join('')}</tr></thead><tbody>${xsAll.map((xv0) => `<tr><th scope="row">${esc(fx(xv0))}</th>${ss.map((s) => { const p = s.points.find((q) => (isTime ? toMs(q[0]) : Number(q[0])) === xv0); return `<td>${esc(p ? fy(p[1]) : '–')}</td>`; }).join('')}</tr>`).join('')}</tbody>`;
++ }
++ }
++ // The left gutter fits the longest y label (about 7 px a character at the axis's size).
++ const gutter = Math.min(72, 14 + 7 * Math.max(2, ...yl.map((t) => String(t).length)));
++ const cap = title || note ? `<figcaption>${title ? `<strong>${esc(title)}</strong>` : ''}${note ? `<span>${esc(note)}</span>` : ''}</figcaption>` : '';
++ const axisNames = yo.label ? `<span class="ovk-chart-yname" aria-hidden="true">${esc(yo.label)}</span>` : '';
++ return `<figure class="ovk-chart ovk-chart--${kind}"${id ? ` id="${esc(id)}"` : ''} style="--ovk-chart-h:${Math.max(120, Math.min(600, Number(height) || 220))}px;--ovk-chart-g:${gutter}px">${cap}${legend}${axisNames}`
++ + `<div class="ovk-chart-plot"${data ? ` tabindex="0" role="img" aria-label="${esc(name)}" data-ovk-chart="${esc(JSON.stringify(data))}"` : ` role="group" aria-label="${esc(name)}"`}>${plot}${data ? '<span class="ovk-chart-rule" hidden></span><span class="ovk-chart-tip" hidden></span>' : ''}</div>`
++ + `${xo.label ? `<p class="ovk-chart-xname" aria-hidden="true">${esc(xo.label)}</p>` : ''}<table class="ovk-sr"><caption>${esc(name)}</caption>${table}</table></figure>`;
++ }
++ /** The grid and the tick labels: x [[pos%, text]], y [[pos%, text]] — real text, placed by percent. */
++ function axes(xt, yt) {
++ return yt.map(([p, t]) => `<span class="ovk-chart-grid" style="bottom:${pct(p)}" aria-hidden="true"></span><span class="ovk-chart-yt" style="bottom:${pct(p)}" aria-hidden="true">${esc(t)}</span>`).join('')
++ + xt.map(([p, t]) => `<span class="ovk-chart-xt" style="left:${pct(p)}" aria-hidden="true">${esc(t)}</span>`).join('');
++ }
++
++ /**
++ * rank({ items: [{ name, href, mark, value, fmt, interval, delta, note, badge }], fmt, base, label, start })
++ * A leaderboard: the place, the name, the value (± its interval), the change since last time, and a bar in
++ * proportion (from `base`, 0 by default — give the floor for a rating that never nears 0, e.g. an Elo).
++ */
++ function rank({ items = [], fmt = 'auto', base = 0, label = 'Ranking', start = 1, unit = '' } = {}) {
++ const list = (items || []).filter((x) => x && x.name != null);
++ if (!list.length) return '';
++ const top = Math.max(...list.map((x) => Number(x.value)).filter(Number.isFinite), base + 1e-9);
++ return `<ol class="ovk-rank" aria-label="${esc(label)}" start="${Number(start) || 1}">${list.map((x, i) => {
++ const v = Number(x.value);
++ const share = Number.isFinite(v) ? Math.max(0, Math.min(1, (v - base) / (top - base))) : 0;
++ const d = Number(x.delta);
++ const delta = Number.isFinite(d) && d !== 0 ? `<span class="ovk-rank-delta ${d > 0 ? 'is-up' : 'is-down'}" aria-label="${d > 0 ? 'up' : 'down'} ${Math.abs(d)}">${icon(d > 0 ? 'up' : 'down')}${Math.abs(d)}</span>` : x.delta === 'new' ? '<span class="ovk-rank-delta is-new">new</span>' : '';
++ const nameHtml = x.href ? `<a class="ovk-rank-link" href="${esc(url(x.href))}">${esc(x.name)}</a>` : `<strong>${esc(x.name)}</strong>`;
++ return `<li class="ovk-rank-row${i < 3 && start === 1 ? ' is-top' : ''}"><span class="ovk-rank-n" aria-hidden="true">${(Number(start) || 1) + i}</span>${x.mark || ''}`
++ + `<span class="ovk-rank-main"><span class="ovk-rank-name">${nameHtml}${x.badge || ''}</span>${x.note ? `<small>${esc(x.note)}</small>` : ''}</span>`
++ + `<span class="ovk-rank-val"><b>${esc(x.display != null ? x.display : num(v, x.fmt || fmt))}${unit ? ` <small>${esc(unit)}</small>` : ''}</b>${x.interval != null ? `<small>± ${esc(num(x.interval, x.fmt || fmt))}</small>` : ''}</span>${delta}`
++ + `<span class="ovk-rank-bar" style="--v:${share.toFixed(3)}" aria-hidden="true"></span></li>`;
++ }).join('')}</ol>`;
++ }
++
++ /**
++ * meter({ value, max, label, text, tone }) — how full something is. tone 'ok' | 'warn' | 'bad', or 'auto' for use of
++ * a limit (a quota, a budget, a disk): amber from 75 %, red from 90 %. Without a tone it is the accent, which suits
++ * a level where more is better (a confidence, a progress).
++ */
++ function meter({ value = 0, max = 100, label = '', text = '', tone = '' } = {}) {
++ const v = Math.max(0, Math.min(Number(max) || 100, Number(value) || 0));
++ const share = v / (Number(max) || 100);
++ const t = tone === 'auto' ? (share >= 0.9 ? 'bad' : share >= 0.75 ? 'warn' : '') : tone;
++ return `<span class="ovk-meter${t ? ` ovk-meter--${esc(t)}` : ''}" role="meter" aria-valuemin="0" aria-valuemax="${Number(max) || 100}" aria-valuenow="${v}" aria-label="${esc(label)}"${text ? ` aria-valuetext="${esc(text)}"` : ''}><span style="--v:${share.toFixed(3)}"></span></span>`;
++ }
++
++ /**
++ * drop({ name, accept, label, hint, multiple, camera, required, id, auto }) — the place a file goes: a real file input
++ * (it works without JavaScript), with drag and drop, paste and, on phones, the camera added by kit-web.js. auto:
++ * the form is sent as soon as a file is chosen. Put it inside the form that receives the file.
++ */
++ function drop({ name = 'file', accept = '', label = 'Choose a file', hint = '', multiple = false, camera = false, required = false, id = null, auto = false } = {}) {
++ const fid = id || `ovk-drop-${esc(name)}`;
++ const cam = camera ? `<label class="ovk-btn ovk-btn--quiet ovk-btn--small ovk-drop-cam" hidden>${icon('image')}<span>Take a photo</span><input type="file" accept="image/*" capture="environment" class="ovk-sr" data-ovk-drop-cam></label>` : '';
++ return `<div class="ovk-drop" data-ovk-drop${auto ? ' data-ovk-auto' : ''}><input type="file" class="ovk-drop-input" id="${esc(fid)}" name="${esc(name)}"${accept ? ` accept="${esc(accept)}"` : ''}${multiple ? ' multiple' : ''}${required ? ' required' : ''}>`
++ + `<label for="${esc(fid)}" class="ovk-drop-zone">${icon('upload')}<strong>${esc(label)}</strong>${hint ? `<small>${esc(hint)}</small>` : ''}<span class="ovk-drop-name" aria-live="polite"></span></label>${cam}</div>`;
++ }
++
++ /**
++ * receipt({ title, body, facts: [[label, value]], actions: [html], tone }) — a result and what it cost: the answer
++ * first, then one quiet line of facts (model, cost, time, confidence, how long it is kept). A value can be
++ * { html } (trusted markup, e.g. a kit.meter).
++ */
++ function receipt({ title = '', body = '', facts = [], actions = [], tone = '', id = null } = {}) {
++ const fs2 = (facts || []).filter((f) => f && f[1] != null && f[1] !== '');
++ const acts = (actions || []).filter(Boolean).join('');
++ return `<section class="ovk-receipt${tone ? ` ovk-receipt--${esc(tone)}` : ''}"${id ? ` id="${esc(id)}"` : ''}${title ? '' : ' aria-label="Result"'}>`
++ + `${title || acts ? `<div class="ovk-receipt-head">${title ? `<h2>${esc(title)}</h2>` : '<span></span>'}${acts ? `<span class="ovk-receipt-acts">${acts}</span>` : ''}</div>` : ''}`
++ + `<div class="ovk-receipt-body">${body}</div>`
++ + `${fs2.length ? `<dl class="ovk-receipt-facts">${fs2.map(([l, v]) => `<div><dt>${esc(l)}</dt><dd>${v && typeof v === 'object' && v.html != null ? v.html : esc(v)}</dd></div>`).join('')}</dl>` : ''}</section>`;
++ }
++
++ /**
++ * page({ head, body, side, dock, wide, id }) — the app page's whole shape in one call: the sticky head (with its tabs),
++ * the content, a side panel beside it on a computer (under it on a phone), and the dock.
++ */
++ const page = ({ head: h = '', body = '', side = '', dock: d = '', wide = false, id = 'main' } = {}) =>
++ `<main class="ovk-page${wide ? ' ovk-page--wide' : ''}" id="${esc(id)}">${h}${side ? `<div class="ovk-split"><div class="ovk-main">${body}</div><aside class="ovk-side">${side}</aside></div>` : body}${d}</main>`;
++
return {
esc, url, icon, ICONS, when, hueOf, mark, button, iconButton, action, badge, dot, card, rail, CARD_RATIO,
head, tabs, tabOf, list, row, section, fold, more,
switchRow, rangeRow, selectRow, seg, chips, saveBar, find, dock, stats, empty, notice, snippet, sheet, sheetButton,
-- enhance, VERSION: 1,
++ enhance, VERSION: 2,
++ num, steps, table, chart, rank, meter, drop, receipt, page,
};
});
ddiff --git a/package-lock.json b/package-lock.json
iindex c0a82ac..f8a578c 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.35.0",
++ "version": "4.36.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.35.0",
++ "version": "4.36.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/package.json b/package.json
iindex 13f914c..fdf725f 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.35.0",
++ "version": "4.36.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
ddiff --git a/scripts/kit-gallery.js b/scripts/kit-gallery.js
iindex 6718816..b3c991c 100644
---- a/scripts/kit-gallery.js
++++ b/scripts/kit-gallery.js
@@@ -42,9 +42,13 @@ ${k.fold({ title: 'Delete the room', tone: 'danger', body: '<p class="ovk-muted"
${k.empty({ icon: 'chat', title: 'No public rooms yet', text: 'Start the first one: public, or private to the people you invite.', action: { href: '#new', label: 'Start a room', icon: 'plus' } })}
<p>${k.button({ href: '#a', label: 'Primary' })} ${k.button({ href: '#b', label: 'Quiet', kind: 'quiet' })} ${k.button({ href: '#c', label: 'Small', kind: 'quiet', small: true, icon: 'plus' })} ${k.button({ type: 'button', label: 'Danger', kind: 'danger' })}</p>
${k.dock({ action: '#send', placeholder: 'Message #general' })}`;
++ return page(body, 'OpenVibe Kit');
++}
++
++function page(body, title) {
return `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
--<title>OpenVibe Kit</title>
++<title>${title}</title>
<script src="theme-loader.js"></script>
<link rel="stylesheet" href="kit.css">
<style>body { margin: 0; background: var(--bg-primary, #0b0f17); color: var(--text-primary, #e8ecf3); font-family: 'Inter', system-ui, sans-serif; }</style>
@@@ -60,33 +64,79 @@ ${k.dock({ action: '#send', placeholder: 'Message #general' })}`;
</body></html>`;
}
++/** Kit 2's pieces (4.36.0) on one page, with the kinds of numbers the sites really show. */
++function gallery2() {
++ const k = kit;
++ const day = 86400000, t0 = Date.UTC(2026, 6, 31);
++ const price = [];
++ let v = 129;
++ for (let i = 0; i <= 90; i++) { v = Math.max(79, v + Math.sin(i / 6) * 2.2 + (i === 40 ? -18 : 0) + (i === 72 ? 9 : 0) - 0.15); price.push([t0 + i * day, Math.round(v * 100) / 100]); }
++ const list = price.map(([t, x]) => [t, Math.round((x * 1.12 + 14) * 100) / 100]);
++ const perDay = Array.from({ length: 30 }, (_, i) => [t0 + 60 * day + i * day, Math.round(40 + 30 * Math.sin(i / 3) + i * 2.4 + (i % 7 === 5 ? 35 : 0))]);
++ const models = [
++ { label: 'gpt-6-sol', x: 0.42, y: 1086, best: true, mark: true }, { label: 'claude-sonnet-5-5', x: 0.31, y: 1071, best: true, mark: true },
++ { label: 'deepseek-v4-flash', x: 0.014, y: 1012, best: true, mark: true }, { label: 'gemini-3-flash', x: 0.05, y: 1024, best: true, mark: true },
++ { label: 'gpt-5-nano', x: 0.004, y: 948, best: true, mark: true }, { label: 'llama-5-70b', x: 0.09, y: 979 }, { label: 'mistral-m3', x: 0.12, y: 990 },
++ { label: 'qwen-4-max', x: 0.21, y: 1031 }, { label: 'MiniMax-M3.1', x: 0.03, y: 1003 },
++ ];
++ const body = `
++${k.head({ title: 'Kit 2', sub: 'Steps, tables, charts, rankings, meters, drop zones and receipts', back: { href: '/kit', label: 'The kit' } })}
++${k.steps({ label: 'Opt in as a merchant', current: 1, items: [{ label: 'Opt in', href: '#1' }, { label: 'Verify the domain', note: 'a DNS record' }, { label: 'Choose what we read' }, { label: 'Live' }] })}
++${k.chart({ type: 'line', title: 'Price over 90 days', note: 'Logitech MX Keys S · checked 4 times a day', x: { time: true }, y: { fmt: 'money', zero: false }, series: [{ name: 'Price', points: price }, { name: 'List price', points: list }] })}
++${k.chart({ type: 'scatter', title: 'Quality against cost', note: 'Elo in blind votes, against dollars per 1,000 answers · dashed: the best for the money', x: { label: 'Cost per 1,000 answers', fmt: 'money' }, y: { label: 'Elo', fmt: 'int', zero: false }, points: models, height: 260 })}
++${k.chart({ type: 'bar', title: 'Clips a day', note: 'Last 30 days', x: { time: true }, y: { fmt: 'int' }, series: [{ name: 'Clips', points: perDay }], height: 180 })}
++${k.rank({ label: 'Leaderboard: chat replies', base: 900, fmt: 'int', items: [
++ { name: 'gpt-6-sol', href: '#m1', note: 'OpenAI · 4,812 votes', value: 1086, interval: 14, delta: 1 },
++ { name: 'claude-sonnet-5-5', href: '#m2', note: 'Anthropic · 4,390 votes', value: 1071, interval: 15, delta: -1 },
++ { name: 'qwen-4-max', href: '#m3', note: 'Alibaba · 1,204 votes', value: 1031, interval: 28, delta: 'new' },
++ { name: 'gemini-3-flash', href: '#m4', note: 'Google · 3,977 votes', value: 1024, interval: 16 },
++ { name: 'deepseek-v4-flash', href: '#m5', note: 'DeepSeek · 6,201 votes', value: 1012, interval: 12, delta: 2 } ] })}
++${k.table({ caption: 'The API', label: 'The API', columns: [{ key: 'path', label: 'Endpoint', primary: true, html: true }, { key: 'what', label: 'What it does' }, { key: 'auth', label: 'Sign-in' }, { key: 'rate', label: 'Per minute', num: true, fmt: 'int' }], rows: [
++ { path: '<code>GET /api/v1/text/fancy</code>', what: 'Every style of a line of text', auth: 'none', rate: 120 },
++ { path: '<code>POST /api/v1/files/convert</code>', what: 'One file into another format', auth: 'an app token', rate: 30 },
++ { path: '<code>GET /api/v1/dns/:name</code>', what: 'A name’s records, now and before', auth: 'none', rate: 60 } ] })}
++<form method="post" action="#read" enctype="multipart/form-data">
++${k.drop({ name: 'image', accept: 'image/*', label: 'Drop a picture, or choose one', hint: 'JPEG, PNG or HEIC, up to 12 MB · paste works too', camera: true })}
++</form>
++${k.receipt({ title: 'What it says', actions: [k.iconButton({ type: 'button', icon: 'copy', label: 'Copy the text' }), k.iconButton({ type: 'button', icon: 'retry', label: 'Read it again' })],
++ body: '<p>OPEN 7 DAYS · 10AM – 9PM<br>Fresh noodles made here every morning</p>',
++ facts: [['Model', 'vision-ocr-2'], ['Cost', k.num(0.0021, 'money')], ['Time', k.num(840, 'ms')], ['Confidence', { html: k.meter({ value: 92, label: 'Confidence', text: '92%' }) + ' 92%' }], ['Kept', 'not stored']] })}
++${k.section({ title: 'Your quota', body: `<div class="ovk-set-row"><span class="ovk-set-text">AI answers this month<small>7,410 of 10,000</small></span><span style="width:40%">${k.meter({ value: 7410, max: 10000, label: 'AI answers this month', tone: 'auto' })}</span></div><div class="ovk-set-row"><span class="ovk-set-text">Storage<small>9.4 of 10 GB</small></span><span style="width:40%">${k.meter({ value: 9.4, max: 10, label: 'Storage', tone: 'auto' })}</span></div>` })}`;
++ return page(body, 'OpenVibe Kit 2');
++}
++
async function shots(dir, chromePath) {
const root = path.join(__dirname, '..');
-- const page = gallery();
++ const pages = { kit: gallery(), kit2: gallery2() };
const server = http.createServer((req, res) => {
const name = decodeURIComponent(new URL(req.url, 'http://x').pathname.slice(1));
-- if (!name || name === 'kit') { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(page); }
++ if (!name || pages[name]) { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(pages[name || 'kit']); }
const file = path.join(root, path.basename(name));
if (!fs.existsSync(file)) { res.writeHead(404); return res.end(); }
res.writeHead(200, { 'content-type': file.endsWith('.css') ? 'text/css' : 'text/javascript' });
fs.createReadStream(file).pipe(res);
}).listen(0, '127.0.0.1');
await new Promise((r) => server.once('listening', r));
-- const base = `http://127.0.0.1:${server.address().port}/kit`;
++ const origin = `http://127.0.0.1:${server.address().port}`;
const { chromium } = require(require.resolve('playwright', { paths: [process.cwd(), '/home/workstation/openvibe/work/OpenVibe.Games'] }));
const browser = await chromium.launch(chromePath ? { executablePath: chromePath } : {});
fs.mkdirSync(dir, { recursive: true });
-- for (const [w, h, mobile] of [[390, 844, true], [768, 1024, true], [1280, 900, false]]) {
-- const ctx = await browser.newContext({ viewport: { width: w, height: h }, isMobile: mobile, hasTouch: mobile, colorScheme: 'dark' });
++ const only = process.argv.includes('--kit2') ? ['kit2'] : ['kit', 'kit2'];
++ for (const name of only) for (const scheme of ['dark', 'light']) for (const [w, h, mobile] of [[320, 700, true], [390, 844, true], [768, 1024, true], [1280, 900, false]]) {
++ if (name === 'kit' && (scheme === 'light' || w === 320)) continue;
++ const ctx = await browser.newContext({ viewport: { width: w, height: h }, isMobile: mobile, hasTouch: mobile, colorScheme: scheme });
++ // Light: the default theme's light half (vibe → cloud), as the account menu's Light switch would save it.
++ if (scheme === 'light') await ctx.addInitScript(() => { try { localStorage.setItem('ov_theme', JSON.stringify({ id: 'cloud' })); } catch (e) { /* */ } });
const p = await ctx.newPage();
-- await p.goto(base, { waitUntil: 'networkidle' });
++ await p.goto(`${origin}/${name}`, { waitUntil: 'networkidle' });
await p.waitForTimeout(400);
-- await p.screenshot({ path: path.join(dir, `kit-${w}.png`), fullPage: true });
++ const tag = `${name}-${w}${name === 'kit' ? '' : `-${scheme}`}`;
++ await p.screenshot({ path: path.join(dir, `${tag}.png`), fullPage: true });
await p.evaluate(() => window.scrollTo(0, 900));
await p.waitForTimeout(250);
-- await p.screenshot({ path: path.join(dir, `kit-${w}-scrolled.png`) });
++ await p.screenshot({ path: path.join(dir, `${tag}-scrolled.png`) });
const m = await p.evaluate(() => ({ overflow: document.documentElement.scrollWidth - innerWidth, height: document.documentElement.scrollHeight, appbar: !!document.querySelector('.ovnav-appbar') && getComputedStyle(document.querySelector('.ovnav-appbar')).display !== 'none' }));
-- console.log(`${w}px: sideways ${m.overflow}, height ${m.height}, app bar ${m.appbar ? 'shown' : 'hidden'}`);
++ console.log(`${tag}: sideways ${m.overflow}, height ${m.height}, app bar ${m.appbar ? 'shown' : 'hidden'}`);
await ctx.close();
}
await browser.close();
@@@ -97,6 +147,6 @@ if (require.main === module) {
const a = process.argv.slice(2);
const i = a.indexOf('--shots');
if (i >= 0) shots(a[i + 1] || 'kit-shots', a.includes('--chrome') ? a[a.indexOf('--chrome') + 1] : null).catch((e) => { console.error(e); process.exit(1); });
-- else process.stdout.write(gallery());
++ else process.stdout.write(a.includes('--kit2') ? gallery2() : gallery());
}
module.exports = { gallery };
ddiff --git a/seo.js b/seo.js
iindex 55cf4a8..7840b2f 100644
---- a/seo.js
++++ b/seo.js
@@@ -157,6 +157,42 @@ const jsonLd = {
...(item.width != null ? { width: item.width } : {}), ...(item.height != null ? { height: item.height } : {}) };
});
},
++ /** An event that really is happening at a time the page shows. Only the parts the page has are written. */
++ event({ name, startDate, endDate, url, description, organizer, location, eventStatus, eventAttendanceMode, image } = {}) {
++ return { '@context': 'https://schema.org', '@type': 'Event', name, startDate,
++ ...(endDate ? { endDate } : {}), ...(description ? { description } : {}),
++ ...(eventStatus ? { eventStatus } : {}), ...(eventAttendanceMode ? { eventAttendanceMode } : {}),
++ ...(location ? { location } : {}),
++ ...(organizer ? { organizer: typeof organizer === 'string' ? { '@type': 'Organization', name: organizer } : organizer } : {}),
++ ...(image ? { image } : {}), ...(url ? { url } : {}) };
++ },
++ /** A public job listing. `description` is the page's own excerpt; nothing is invented for the fields it omits. */
++ jobPosting({ title, url, description, datePosted, validThrough, hiringOrganization, jobLocationType, employmentType, jobLocation, baseSalary, identifier, directApply } = {}) {
++ return { '@context': 'https://schema.org', '@type': 'JobPosting', title,
++ ...(description ? { description } : {}), ...(datePosted ? { datePosted } : {}), ...(validThrough ? { validThrough } : {}),
++ ...(hiringOrganization ? { hiringOrganization: typeof hiringOrganization === 'string' ? { '@type': 'Organization', name: hiringOrganization } : hiringOrganization } : {}),
++ ...(jobLocationType ? { jobLocationType } : {}), ...(employmentType ? { employmentType } : {}),
++ ...(jobLocation ? { jobLocation } : {}), ...(baseSalary ? { baseSalary } : {}),
++ ...(identifier ? { identifier } : {}), ...(directApply != null ? { directApply } : {}), ...(url ? { url } : {}) };
++ },
++ /** A recipe page's own ingredients and steps. Steps may be strings or ready HowToStep objects. */
++ recipe({ name, url, description, image, recipeIngredient, recipeInstructions, recipeYield, totalTime, author, datePublished } = {}) {
++ return { '@context': 'https://schema.org', '@type': 'Recipe', name,
++ ...(description ? { description } : {}), ...(image ? { image } : {}),
++ ...(recipeIngredient ? { recipeIngredient } : {}),
++ ...(recipeInstructions ? { recipeInstructions: recipeInstructions.map((s) => (typeof s === 'string' ? { '@type': 'HowToStep', text: s } : s)) } : {}),
++ ...(recipeYield ? { recipeYield } : {}), ...(totalTime ? { totalTime } : {}),
++ ...(author ? { author: typeof author === 'string' ? { '@type': 'Organization', name: author } : author } : {}),
++ ...(datePublished ? { datePublished } : {}), ...(url ? { url } : {}) };
++ },
++ /** An open dataset a page publishes. `license` only when the site names one. */
++ dataset({ name, url, description, license, creator, keywords, distribution } = {}) {
++ return { '@context': 'https://schema.org', '@type': 'Dataset', name,
++ ...(description ? { description } : {}), ...(license ? { license } : {}),
++ ...(creator ? { creator: typeof creator === 'string' ? { '@type': 'Organization', name: creator } : creator } : {}),
++ ...(keywords ? { keywords: Array.isArray(keywords) ? keywords.join(', ') : keywords } : {}),
++ ...(distribution ? { distribution } : {}), ...(url ? { url } : {}) };
++ },
};
const isAbs = (u) => typeof u === 'string' && /^https?:\/\//i.test(u);
ddiff --git a/test/kit.test.js b/test/kit.test.js
iindex afb24bc..6bb12b5 100644
---- a/test/kit.test.js
++++ b/test/kit.test.js
@@@ -109,4 +109,89 @@ const kit = require('../kit');
delete globalThis.OpenVibeKitWeb;
}
--console.log('kit: builders, escaping, tabs, rows, settings, find/dock, icons and time ok');
++// ── Kit 2: numbers ──
++{
++ assert.deepStrictEqual(['0.1', '0.027', '0.0041', '12.5', '1234.5'].map((v) => kit.num(Number(v), 'money')), ['$0.10', '$0.027', '$0.0041', '$12.50', '$1,234.50'], 'money keeps cents, and two figures under a dime');
++ assert.strictEqual(kit.num(840, 'ms'), '840 ms');
++ assert.strictEqual(kit.num(2400, 'ms'), '2.4 s');
++ assert.strictEqual(kit.num(1234567, 'compact'), '1.2M');
++ assert.strictEqual(kit.num(92, 'percent'), '92%');
++ assert.strictEqual(kit.num(null), '–', 'nothing is a dash, never 0');
++ assert.strictEqual(kit.num('x', 'int'), '–');
++}
++
++// ── Kit 2: steps ──
++{
++ const s = kit.steps({ current: 1, items: [{ label: 'Opt in', href: '/m/1' }, { label: 'Verify <it>', note: 'a DNS record' }, { label: 'Live', href: '/m/3' }] });
++ assert.ok(s.startsWith('<nav class="ovk-steps" aria-label="Steps"'));
++ assert.ok(s.includes('Step 2 of 3') && s.includes('<strong>Verify &lt;it&gt;</strong>'), 'the phone line says where you are, escaped');
++ assert.ok(s.includes('<li class="is-done"><a href="/m/1">') && s.includes('(done)'), 'a done step links back and says it is done');
++ assert.ok(s.includes('<li class="is-current" aria-current="step">'));
++ assert.ok(!s.includes('href="/m/3"'), 'a later step never links ahead');
++ assert.ok(kit.steps({ current: 9, items: [{ label: 'A' }, { label: 'B' }] }).includes('Step 2 of 2'), 'current is clamped');
++ assert.strictEqual(kit.steps({ items: [] }), '');
++}
++
++// ── Kit 2: tables ──
++{
++ const t = kit.table({ caption: 'Plans', columns: [{ key: 'name', label: 'Plan', primary: true }, { key: 'price', label: 'Per month', num: true, fmt: 'money' }, { key: 'site', label: 'Site', hide: 'sm' }, { key: 'raw', label: 'Raw', html: true }],
++ rows: [{ name: 'CX22 <b>', price: 4.5, site: { text: 'hetzner.com', href: 'https://hetzner.com' }, raw: '<code>x</code>' }, { name: 'Bad', price: null, site: { text: 'x', href: 'javascript:alert(1)' } }] });
++ assert.ok(t.includes('ovk-table--stack') && t.includes('<caption>Plans</caption>'));
++ assert.ok(t.includes('<th scope="row" class="is-primary" data-label="Plan">CX22 &lt;b&gt;</th>'), 'the primary cell heads its row, escaped');
++ assert.ok(t.includes('<td class="is-num" data-label="Per month">$4.50</td>'), 'numbers are formatted and right-aligned');
++ assert.ok(t.includes('<a href="https://hetzner.com">hetzner.com</a>') && !t.includes('javascript:'), 'links are checked');
++ assert.ok(t.includes('class="is-hide-sm"') && t.includes('<code>x</code>'), 'html columns pass trusted markup');
++ assert.ok(t.includes('data-label="Per month">–</td>'), 'a missing number is a dash');
++ assert.strictEqual(kit.table({ columns: [{ key: 'a', label: 'A' }], rows: [], empty: 'Nothing yet' }), '<p class="ovk-table-empty">Nothing yet</p>');
++}
++
++// ── Kit 2: charts ──
++{
++ const day = 86400000, t0 = Date.UTC(2026, 8, 1);
++ const line = kit.chart({ type: 'line', title: 'Price', x: { time: true }, y: { fmt: 'money', zero: false }, series: [{ name: 'Price <x>', points: [[t0, 12], [t0 + day, null], [t0 + 2 * day, 10.5], [t0 + 30 * day, 9.99]] }] });
++ assert.ok(line.startsWith('<figure class="ovk-chart ovk-chart--line"'));
++ assert.ok(/<path class="ovk-chart-line" d="M[^"]*M/.test(line), 'a null breaks the line in two');
++ assert.ok(line.includes('preserveAspectRatio="none"'), 'the picture stretches; strokes keep their width in CSS');
++ assert.ok(line.includes('class="ovk-chart-yt"') && line.includes('$12.00'), 'y ticks are real text, formatted');
++ assert.ok(line.includes('>Sep 1<') || line.includes('>Sep 2<'), 'x ticks fall on days');
++ assert.ok(line.includes('Price &lt;x&gt; <b>$9.99</b>'), 'the legend says the latest value, escaped');
++ assert.ok(line.includes('<table class="ovk-sr"><caption>Price</caption>'), 'a table with the same numbers for screen readers');
++ const raw = line.match(/data-ovk-chart="([^"]*)"/)[1].replace(/&quot;/g, '"').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&#39;/g, "'").replace(/&amp;/g, '&');
++ const data = JSON.parse(raw);
++ assert.strictEqual(data.x.length, 4);
++ assert.strictEqual(data.s[0].v[1], null, 'the readout knows the gap');
++ assert.strictEqual(data.x[0][0], 0);
++ assert.strictEqual(data.x[3][0], 100);
++ const sc = kit.chart({ type: 'scatter', x: { label: 'Cost', fmt: 'money' }, y: { label: 'Elo', fmt: 'int', zero: false }, points: [{ label: 'a', x: 0.1, y: 1000, best: true, mark: true }, { label: 'b <i>', x: 0.4, y: 1080, best: true, href: '/m/b' }, { label: 'c', x: 0.2, y: 990 }] });
++ assert.ok(sc.includes('class="ovk-chart-front"'), 'the frontier is drawn through the best points');
++ assert.ok(sc.includes('<a href="/m/b" class="ovk-chart-dot is-best is-right"'), 'a point can link; one on the right opens its label leftwards');
++ assert.ok(sc.includes('aria-label="b &lt;i&gt;: Elo 1,080, Cost $0.40"'), 'every point says its values');
++ const bar = kit.chart({ type: 'bar', y: { fmt: 'int' }, series: [{ name: 'Clips', points: [['Mon', 4], ['Tue', 8], ['Wed', null]] }] });
++ assert.strictEqual((bar.match(/class="ovk-chart-bar[ "]/g) || []).length, 3);
++ assert.ok(bar.includes('is-none') && bar.includes('aria-label="Tue: 8"'));
++ assert.strictEqual(kit.chart({ series: [] }), '', 'no data, no chart');
++}
++
++// ── Kit 2: rank, meter, drop, receipt, page ──
++{
++ const r = kit.rank({ base: 900, fmt: 'int', items: [{ name: 'gpt <x>', href: '/m/1', value: 1086, interval: 14, delta: 2, note: '4,812 votes' }, { name: 'b', value: 1000, delta: -1 }, { name: 'c', value: 900, delta: 'new' }] });
++ assert.ok(r.includes('<ol class="ovk-rank"') && r.includes('<a class="ovk-rank-link" href="/m/1">gpt &lt;x&gt;</a>'));
++ assert.ok(r.includes('<b>1,086</b><small>± 14</small>') && r.includes('aria-label="up 2"') && r.includes('is-new'));
++ assert.ok(r.includes('--v:1.000') && r.includes('--v:0.000'), 'bars run from the base to the top value');
++ assert.ok(kit.meter({ value: 95, label: 'Disk', tone: 'auto' }).includes('ovk-meter--bad'), 'auto: red near a limit');
++ assert.ok(!kit.meter({ value: 95, label: 'Confidence' }).includes('ovk-meter--'), 'without a tone, more is just more');
++ assert.ok(kit.meter({ value: 150, max: 100 }).includes('aria-valuenow="100"'), 'clamped');
++ const d = kit.drop({ name: 'image', accept: 'image/*', label: 'Drop <a> picture', camera: true, auto: true });
++ assert.ok(d.includes('type="file" class="ovk-drop-input" id="ovk-drop-image" name="image" accept="image/*"') && d.includes('data-ovk-auto'));
++ assert.ok(d.includes('<label for="ovk-drop-image" class="ovk-drop-zone">') && d.includes('Drop &lt;a&gt; picture'));
++ assert.ok(d.includes('capture="environment"') && d.includes('ovk-drop-cam" hidden'), 'the camera waits for kit-web on a phone');
++ assert.ok(!/data-ovk-drop-cam[^>]*name=/.test(d) && !/name="[^"]*"[^>]*data-ovk-drop-cam/.test(d), 'the camera input has no name, so an unused one never posts an empty file');
++ const rc = kit.receipt({ title: 'Read', body: '<p>Hi</p>', facts: [['Model', 'ocr <2>'], ['Cost', '$0.002'], ['Empty', ''], ['Confidence', { html: '<span class="x"></span>' }]] });
++ assert.ok(rc.includes('<dt>Model</dt><dd>ocr &lt;2&gt;</dd>') && rc.includes('<span class="x"></span>') && !rc.includes('Empty'), 'facts: escaped, trusted html when asked, blanks left out');
++ const pg = kit.page({ head: '<header>h</header>', body: 'B', side: 'S', wide: true });
++ assert.ok(pg.startsWith('<main class="ovk-page ovk-page--wide" id="main"><header>h</header><div class="ovk-split"><div class="ovk-main">B</div><aside class="ovk-side">S</aside>'));
++ assert.strictEqual(kit.page({ body: 'B' }), '<main class="ovk-page" id="main">B</main>');
++ assert.strictEqual(kit.VERSION, 2);
++}
++
++console.log('kit: builders, escaping, tabs, rows, settings, find/dock, icons, time and Kit 2 (steps, table, chart, rank, meter, drop, receipt, page) ok');
ddiff --git a/test/seo.test.js b/test/seo.test.js
iindex 29412d9..ce9bd5e 100644
---- a/test/seo.test.js
++++ b/test/seo.test.js
@@@ -93,4 +93,26 @@ assert.ok(vmap.includes('<url><loc>https://example.test/v/2</loc></url>'), 'an e
assert.ok(/<loc>https:\/\/example\.test\/v\/3<\/loc><video:video>(?:(?!<video:duration>).)*<\/video:video>/.test(vmap), 'a duration over 8 hours is left out');
assert.ok(seo.sitemapXml([{ loc: 'https://example.test/', videos: [{ thumbnail_loc: 'https://cdn.test/t.jpg', title: 'T', description: 'D'.repeat(3000), player_loc: 'https://example.test/' }] }]).includes(`${'D'.repeat(2048)}</video:description>`), 'descriptions are clipped to 2,048');
++// Rich-result nodes for the content surfaces that already exist: only the properties a page really has are written.
++const ev = seo.jsonLd.event({ name: 'A channel went live', startDate: '2026-10-29T10:00:00.000Z', url: 'https://example.test/#event-live.stream.started', organizer: 'OpenVibe.Live' });
++assert.strictEqual(ev['@type'], 'Event');
++assert.strictEqual(ev.name, 'A channel went live');
++assert.strictEqual(ev.startDate, '2026-10-29T10:00:00.000Z');
++assert.deepStrictEqual(ev.organizer, { '@type': 'Organization', name: 'OpenVibe.Live' });
++assert.ok(!('endDate' in ev) && !('eventStatus' in ev) && !('location' in ev), 'fields the page lacks are left out');
++
++const job = seo.jsonLd.jobPosting({ title: 'Engineer', url: 'https://example.test/jobs/1', description: 'Build things', datePosted: '2026-10-01', hiringOrganization: 'Acme', jobLocationType: 'TELECOMMUTE' });
++assert.deepStrictEqual([job['@type'], job.title, job.datePosted, job.jobLocationType], ['JobPosting', 'Engineer', '2026-10-01', 'TELECOMMUTE']);
++assert.deepStrictEqual(job.hiringOrganization, { '@type': 'Organization', name: 'Acme' });
++assert.ok(!('jobLocation' in job) && !('baseSalary' in job));
++
++const recipe = seo.jsonLd.recipe({ name: 'Beans', recipeIngredient: ['Beans', 'Rice'], recipeInstructions: ['Cook.', { '@type': 'HowToStep', text: 'Eat.' }], image: 'https://example.test/beans.jpg' });
++assert.deepStrictEqual([recipe['@type'], recipe.name, recipe.recipeIngredient, recipe.image], ['Recipe', 'Beans', ['Beans', 'Rice'], 'https://example.test/beans.jpg']);
++assert.deepStrictEqual(recipe.recipeInstructions, [{ '@type': 'HowToStep', text: 'Cook.' }, { '@type': 'HowToStep', text: 'Eat.' }]);
++
++const dataset = seo.jsonLd.dataset({ name: 'Design page arena', description: 'Blind votes', creator: 'OpenVibe.Bench', distribution: { '@type': 'DataDownload', contentUrl: 'https://example.test/d.jsonl' } });
++assert.deepStrictEqual([dataset['@type'], dataset.name, dataset.creator.name], ['Dataset', 'Design page arena', 'OpenVibe.Bench']);
++assert.ok(!('license' in dataset), 'no license claim when the site states none');
++assert.strictEqual(seo.headTags({ title: 'T', canonical: 'https://example.test/', jsonLd: [ev, job, recipe, dataset] }).match(/application\/ld\+json/g).length, 4, 'one script per node');
++
console.log('seo: all checks passed');