openvibers/openvibe.shared Commits
Kit 2: steps, tables, charts, rankings, meters, drop zones, receipts (4.36.0) (#172)
Changes
@@@ -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@@@ -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 deployedconsumer installs a tagged release and none keeps a vendored copy([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` namesthe tag it pins.@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.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)",@@@ -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 linksThe 2026-10-28 sweep of all 38 sites' bars found three habits that made them feel crowded and repetitive:@@@ -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]');@@@ -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; } }@@@ -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,};});@@@ -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",@@@ -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": {@@@ -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 };@@@ -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);@@@ -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 <it></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 <b></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 <x> <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(/"/g, '"').replace(/</g, '<').replace(/>/g, '>').replace(/'/g, "'").replace(/&/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 <i>: 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 <x></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 <a> 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 <2></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');@@@ -93,4 +93,26 @@ assert.ok(vmap.includes('<url><loc>https://example.test/v/2</loc></url>'), 'an eassert.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');
@@ -6,6 +6,18 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
@@ -6,6 +6,18 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
## 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
## 4.35.0 — 2026-10-29
- **Showcase:** the page root clips the horizontal axis (`html { overflow-x: clip }`), so a narrow page whose
- **Showcase:** the page root clips the horizontal axis (`html { overflow-x: clip }`), so a narrow page whose
@@ -2,7 +2,7 @@
@@ -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.
> 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
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.35.0",
+ "version": "4.36.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
@@ -245,6 +245,44 @@ the theme's mode:
@@ -245,6 +245,44 @@ the theme's mode:
`sc.room()` does this for a front door.
`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 navbar's links
The 2026-10-28 sweep of all 38 sites' bars found three habits that made them feel crowded and repetitive:
The 2026-10-28 sweep of all 38 sites' bars found three habits that made them feel crowded and repetitive:
@@ -15,6 +15,8 @@
@@ -15,6 +15,8 @@
* share [data-ovk-share]: the system share sheet, else the link copied (the menu item says "Link copied")
* 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
* 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)
* 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.
* Idempotent and safe on a page that uses none of it. docs/kit.md describes the pieces.
*/
*/
@@ -157,6 +159,106 @@
@@ -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.
// Card previews: a muted loop while hovered or focused, on computers only, never under reduced motion.
if (fine) all('.ovk-card').forEach((c) => {
if (fine) all('.ovk-card').forEach((c) => {
const v = c.querySelector('video.ovk-card-video[data-src]');
const v = c.querySelector('video.ovk-card-video[data-src]');
@@ -253,3 +253,174 @@ div.ovk-set-row { cursor: default; }
@@ -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 (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; } }
@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; } }
@@ -378,10 +378,284 @@
@@ -378,10 +378,284 @@
+ `<button type="button" class="ovk-rail-btn ovk-rail-next" aria-label="Scroll on" hidden>${icon('forward')}</button></div></section>`;
+ `<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 {
return {
esc, url, icon, ICONS, when, hueOf, mark, button, iconButton, action, badge, dot, card, rail, CARD_RATIO,
esc, url, icon, ICONS, when, hueOf, mark, button, iconButton, action, badge, dot, card, rail, CARD_RATIO,
head, tabs, tabOf, list, row, section, fold, more,
head, tabs, tabOf, list, row, section, fold, more,
switchRow, rangeRow, selectRow, seg, chips, saveBar, find, dock, stats, empty, notice, snippet, sheet, sheetButton,
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,
};
};
});
});
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.35.0",
+ "version": "4.36.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.35.0",
+ "version": "4.36.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"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",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -42,9 +42,13 @@ ${k.fold({ title: 'Delete the room', tone: 'danger', body: '<p class="ovk-muted"
@@ -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' } })}
${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>
<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' })}`;
${k.dock({ action: '#send', placeholder: 'Message #general' })}`;
+ return page(body, 'OpenVibe Kit');
+}
+
+function page(body, title) {
return `<!doctype html>
return `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<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>
<script src="theme-loader.js"></script>
<link rel="stylesheet" href="kit.css">
<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>
<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' })}`;
@@ -60,33 +64,79 @@ ${k.dock({ action: '#send', placeholder: 'Message #general' })}`;
</body></html>`;
</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) {
async function shots(dir, chromePath) {
const root = path.join(__dirname, '..');
const root = path.join(__dirname, '..');
- const page = gallery();
+ const pages = { kit: gallery(), kit2: gallery2() };
const server = http.createServer((req, res) => {
const server = http.createServer((req, res) => {
const name = decodeURIComponent(new URL(req.url, 'http://x').pathname.slice(1));
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));
const file = path.join(root, path.basename(name));
if (!fs.existsSync(file)) { res.writeHead(404); return res.end(); }
if (!fs.existsSync(file)) { res.writeHead(404); return res.end(); }
res.writeHead(200, { 'content-type': file.endsWith('.css') ? 'text/css' : 'text/javascript' });
res.writeHead(200, { 'content-type': file.endsWith('.css') ? 'text/css' : 'text/javascript' });
fs.createReadStream(file).pipe(res);
fs.createReadStream(file).pipe(res);
}).listen(0, '127.0.0.1');
}).listen(0, '127.0.0.1');
await new Promise((r) => server.once('listening', r));
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 { chromium } = require(require.resolve('playwright', { paths: [process.cwd(), '/home/workstation/openvibe/work/OpenVibe.Games'] }));
const browser = await chromium.launch(chromePath ? { executablePath: chromePath } : {});
const browser = await chromium.launch(chromePath ? { executablePath: chromePath } : {});
fs.mkdirSync(dir, { recursive: true });
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();
const p = await ctx.newPage();
- await p.goto(base, { waitUntil: 'networkidle' });
+ await p.goto(`${origin}/${name}`, { waitUntil: 'networkidle' });
await p.waitForTimeout(400);
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.evaluate(() => window.scrollTo(0, 900));
await p.waitForTimeout(250);
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' }));
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 ctx.close();
}
}
await browser.close();
await browser.close();
@@ -97,6 +147,6 @@ if (require.main === module) {
@@ -97,6 +147,6 @@ if (require.main === module) {
const a = process.argv.slice(2);
const a = process.argv.slice(2);
const i = a.indexOf('--shots');
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); });
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 };
module.exports = { gallery };
@@ -157,6 +157,42 @@ const jsonLd = {
@@ -157,6 +157,42 @@ const jsonLd = {
...(item.width != null ? { width: item.width } : {}), ...(item.height != null ? { height: item.height } : {}) };
...(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);
const isAbs = (u) => typeof u === 'string' && /^https?:\/\//i.test(u);
@@ -109,4 +109,89 @@ const kit = require('../kit');
@@ -109,4 +109,89 @@ const kit = require('../kit');
delete globalThis.OpenVibeKitWeb;
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 <it></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 <b></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 <x> <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(/"/g, '"').replace(/</g, '<').replace(/>/g, '>').replace(/'/g, "'").replace(/&/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 <i>: 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 <x></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 <a> 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 <2></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');
@@ -93,4 +93,26 @@ assert.ok(vmap.includes('<url><loc>https://example.test/v/2</loc></url>'), 'an e
@@ -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(/<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');
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');
console.log('seo: all checks passed');