openvibers/openvibe.shared Commits

The tool frame, and kit-web wires copy, sheets and confirms again (4.55.0) (#203)

OP OpenVibers committed 13 days ago · eb688083c5 · 11 files, +462 −11

- kit.toolHead, toolInput, tries, answer, family and tool: one order for every tool page (owner ask 11), in Kit 2
  (kit-data.css, kit-data-web.js).
- kit-web.js and kit-data-web.js: once() marked an element under the attribute's own dataset name, so copy buttons,
  sheet buttons, confirms and titled shares were never wired. test/kit-web-chrome.test.js checks them in Chrome.
- The Dock no longer sends popped (#202).

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex c2b2e0e..4b379f3 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -6,9 +6,36 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
--- **The Dock keeps "in a window" to the page.** The pop-out window belongs to one page in one browser, closes with it,
-- and no other device can open it again (the browser wants a click), so the Dock no longer sends `popped` to the
-- Network. That also ends the refused PUT and its retry that 4.54.0 made on every pop-out, since the Network's
++## 4.55.0 — 2026-10-30
++
++- **The tool frame: one order for every tool page** (owner ask 11). It has six new kit builders: `kit.toolHead`,
++ `toolInput`, `tries`, `answer`, `family` and `tool` (docs/kit.md, "The tool frame").
++ - **The order:**
++ - the head, with a crumb to the family, the name, and one sentence saying what the tool does;
++ - the box you type in, on the first phone screen, at 17 px so phones do not zoom in, with its clear button and
++ the tool's one button;
++ - two or three **Try** examples: a tap fills the box and runs the tool, and without JavaScript each one is a
++ `?q=` link;
++ - the answer, as labelled rows with the fact that matters marked, then the next step;
++ - the family's other tools: one sideways row on a phone, a column beside the tool from 1024 px;
++ - the About, questions and API folds, then "People also use".
++ - **One look.** The family's tint is only on the icon tile, as a hue number that never reaches the style attribute
++ as raw text; buttons keep the site's own accent. That ends the cyan, purple and orange tool pages.
++ - Everything is plain HTML with escaped text, so it works without JavaScript. It is part of Kit 2: the look is in
++ `kit-data.css`, and the clear button and the examples are in `kit-data-web.js`. `kit.css` and `kit-web.js` weigh
++ what they did.
++- **Fixed: kit-web.js never wired copy buttons, sheet buttons, confirms or titled share links.** Its `once()` marked
++ an element under the same dataset name as the attribute that asks for the behaviour: `data-ovk-copy="#id"` is
++ `dataset.ovkCopy`. The markup had therefore already "marked" every one of them:
++ - copy buttons stayed hidden;
++ - a sheet button opened nothing;
++ - a kit form with `data-ovk-confirm` ("Delete it?") was sent **without asking**.
++
++ The mark now has a name of its own (`…Wired`), in `kit-web.js` and in `kit-data-web.js`.
++ `test/kit-web-chrome.test.js` checks each one in Chrome; it fails on 4.54.0.
++- **The Dock keeps "in a window" to the page.** The pop-out window belongs to one page in one browser and closes with
++ it, and no other device can open it again (the browser wants a click). So the Dock no longer sends `popped` to the
++ Network. That also ends the refused PUT, and its retry, that 4.54.0 made on every pop-out: the Network's
`network.dock-state@1` contract has no such key.
## 4.54.0 — 2026-10-30
ddiff --git a/README.md b/README.md
iindex 54732ec..f603a46 100644
---- a/README.md
++++ b/README.md
@@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
--**Status:** alpha, v4.54.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.55.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex 03adc9c..4a7fcb7 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.54.0",
++ "version": "4.55.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
ddiff --git a/docs/kit.md b/docs/kit.md
iindex 94dc505..d84e303 100644
---- a/docs/kit.md
++++ b/docs/kit.md
@@@ -299,6 +299,56 @@ lines and areas (under the pointer, a tap, or ← → Home End) and hides a scat
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 tool frame (4.55.0)
++
++A tool page has one job: a person types or drops something in and gets an answer. Before 4.55 every OpenVibe tool drew
++its own page. A whois lookup had a centred cyan icon over a wall of sibling-tool chips, the JSON tools were purple, and
++the converters orange. On a phone the box you type in was often below the first screen. The tool frame puts every
++tool page in the same order:
++
++1. **The head.** A crumb back to the family ("Network tools ›"), the tool's name, and one sentence saying what it does.
++ The icon tile carries the family's tint; nothing else does.
++2. **The box.** A big field (17 px, so phones do not zoom in), its clear button, and the tool's one button: under the
++ field on a phone, beside it from 600 px. Under the box are two or three real **Try** examples; a tap fills the box
++ and runs the tool.
++3. **The answer**, when there is one. It shows labelled rows with the fact that matters marked ("in 10 months"), then
++ the **next step**: watch it for changes, look up the same name's DNS, compress the picture just made. A tool that
++ finds something wrong says what and where (`tone: 'bad'`).
++4. **The family.** The other tools of the same kind. On a phone they are one row that scrolls sideways, with short
++ names; from 1024 px they are a column beside the tool, with full names and the open tool marked.
++5. **The folds**, closed: About, the questions, "Use it from code". Their text stays in the page for search engines.
++6. **People also use**: a short list.
++
++Buttons and links keep the site's own accent, so a tool looks like the site it is on.
++
++| Builder | What it draws |
++| --- | --- |
++| `toolHead({ icon, iconHtml, hue, family, crumb: { href, label }, title, task, level })` | The head. `hue` is the family's tint (0–360); without it, a hue is hashed from `family` or the crumb, so a family always gets the same one |
++| `toolInput({ name, label, placeholder, value, button, icon, type, inputmode, action, method, hidden, tries })` | The box as a GET form (`?q=…` runs the tool without JavaScript); `tries: { items: [{ value, label }] }` adds the examples |
++| `tries({ name, items, target, run })` | Examples for a box the page draws itself (an editor, a box outside a form): `target` is its selector, and `run` is the button to press when there is no form |
++| `answer({ title, badge, actions, rows: [[label, value, note]], body, next, tone })` | The answer. A value can be `{ html }`; a note is a pill (`'1 year ago'`, or `{ text, tone: 'warn' }`); `aria-live` announces it when it arrives |
++| `family({ title, more: { href, label }, items: [{ href, label, short, current }] })` | The family row or column |
++| `tool({ head, input, answer, family, folds, related })` | All of it, in order. `input` is any markup: `toolInput`, `drop`, or a `textarea.ovk-tool-editor` with its button. The answer slot `.ovk-tool-out` is always there (hidden while empty), so a page that draws its answer in the browser fills it |
++
++```js
++const kit = require('openvibe-shared/kit');
++kit.tool({
++ head: kit.toolHead({ icon: 'globe', family: 'network', crumb: { href: '/network', label: 'Network tools' },
++ title: 'Whois Lookup', task: 'Who registered a domain, when it was made and when it runs out.' }),
++ input: kit.toolInput({ name: 'q', label: 'Domain or IP address', placeholder: 'example.com or 1.1.1.1', button: 'Look up',
++ inputmode: 'url', tries: { items: [{ value: 'openvibe.network' }, { value: '1.1.1.1' }] } }),
++ answer: result ? kit.answer({ title: result.domain, badge: { text: 'Registered', tone: 'ok' },
++ rows: [['Registrar', result.registrar], ['Expires', result.expires, { text: result.expiresIn, tone: 'warn' }]],
++ next: [kit.button({ href: watchUrl, label: 'Watch for changes', icon: 'bell', kind: 'quiet', small: true })] }) : '',
++ family: kit.family({ title: 'Network tools', more: { href: '/network', label: 'All 22' }, items }),
++ folds: [kit.fold({ title: 'About Whois Lookup', body }), kit.fold({ title: 'Use it from code', meta: 'API', body: kit.snippet({ code }) })],
++ related: kit.list(rows, { title: 'People also use' }),
++});
++```
++
++The frame is part of Kit 2: its look is in `kit-data.css` and its behaviour (the clear button and the examples) in
++`kit-data-web.js`. A page that draws no tool loads neither, so `kit.css` and `kit-web.js` weigh what they did.
++
## The navbar's links
The 2026-10-28 sweep of all 38 sites' bars found three habits that made them feel crowded and repetitive:
ddiff --git a/kit-data-web.js b/kit-data-web.js
iindex 48daf1b..892dd7b 100644
---- a/kit-data-web.js
++++ b/kit-data-web.js
@@@ -4,10 +4,12 @@
* charts .ovk-chart-plot[data-ovk-chart]: the values under the pointer, a tap, or ← → Home End
* scatter labels a label that would cover another, or cross the right edge, is hidden or moved
* drop zones [data-ovk-drop]: drag and drop, paste, the camera on phones, the file's name and picture
++ * tool frame [data-ovk-clear] shows while its box has text and empties it; a[data-ovk-try] fills the box and runs
++ * the tool (its form's submit, else the [data-ovk-run] button); without this it is a ?name=value link
*
* It runs by itself when it loads, again on a boosted page swap (ov:boost:load), and for markup added later through
* OpenVibeKitData.enhance(container). Idempotent and safe on a page that uses none of it. Load it on a page that draws
-- * kit.chart or kit.drop (kit.steps, table, rank, meter and receipt need no script).
++ * kit.chart, kit.drop or the tool frame (kit.steps, table, rank, meter and receipt need no script).
*/
(function (root) {
'use strict';
@@@ -15,7 +17,8 @@
if (typeof document === 'undefined') return;
const scope = rootEl || document;
const all = (sel) => Array.prototype.slice.call(scope.querySelectorAll ? scope.querySelectorAll(sel) : []);
-- const once = (el, key) => { if (el.dataset[key]) return false; el.dataset[key] = '1'; return true; };
++ // The mark has a name of its own (…Wired), apart from the data-ovk-* attribute that asks for the behaviour (4.55.0).
++ const once = (el, key) => { const k = `${key}Wired`; if (el.dataset[k]) return false; el.dataset[k] = '1'; return true; };
// 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;
@@@ -119,6 +122,31 @@
camInput.addEventListener('change', () => { take(camInput.files); camInput.value = ''; });
}
});
++ // The tool frame: a clear button shows while its box has text; a "Try" example fills the box and runs the tool
++ // (its form's submit, else the run button it names). Without this, the example is a link to ?name=value.
++ all('[data-ovk-clear]').forEach((b) => {
++ const f = document.querySelector(b.getAttribute('data-ovk-clear'));
++ if (!f || !once(b, 'ovkClear')) return;
++ const sync = () => { b.hidden = !f.value; };
++ f.addEventListener('input', sync); sync();
++ b.addEventListener('click', () => { f.value = ''; sync(); f.dispatchEvent(new Event('input', { bubbles: true })); f.focus(); });
++ });
++ all('a[data-ovk-try]').forEach((a) => {
++ if (!once(a, 'ovkTry')) return;
++ a.addEventListener('click', (e) => {
++ const sel = a.getAttribute('data-ovk-for');
++ const form = a.closest('form');
++ const f = (sel && document.querySelector(sel)) || (form && form.elements.namedItem(a.getAttribute('data-ovk-name') || 'q'));
++ if (!f || !('value' in f)) return;
++ e.preventDefault();
++ f.value = a.getAttribute('data-ovk-try');
++ f.dispatchEvent(new Event('input', { bubbles: true }));
++ const run = a.getAttribute('data-ovk-run');
++ const btn = run && document.querySelector(run);
++ if (btn) btn.click();
++ else if (f.form) { if (f.form.requestSubmit) f.form.requestSubmit(); else f.form.submit(); }
++ });
++ });
}
const api = { enhance };
ddiff --git a/kit-data.css b/kit-data.css
iindex 7a01a77..fcde9b2 100644
---- a/kit-data.css
++++ b/kit-data.css
@@@ -1,5 +1,6 @@
/* openvibe-shared/kit-data.css — Kit 2's look: steps, tables, charts, rankings, meters, drop zones and receipts
-- * (kit.steps/table/chart/rank/meter/drop/receipt; docs/kit.md "Kit 2"). Load it after kit.css, whose tokens it uses,
++ * (kit.steps/table/chart/rank/meter/drop/receipt; docs/kit.md "Kit 2"), and the tool frame (kit.tool and its pieces,
++ * 4.55.0; docs/kit.md "The tool frame"). Load it after kit.css, whose tokens it uses,
* on a page that draws any of them; kit-data-web.js adds their behaviour. Split from kit.css in 4.37.0 so a page that
* uses none of them does not carry them (kit.css weighed 25 KB before Kit 2, 44 KB with it). */
@@@ -173,3 +174,100 @@
.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; } }
++
++/* ── The tool frame (4.55.0): kit.tool and its pieces. One order on every tool page: the name and what it does, the
++ box, the answer and its next step, the family, the folds. The family's tint is only on the icon tile; buttons keep
++ the site's accent. From 1024 px the family is a column beside the tool. ── */
++.ovk-page { --ovk-gutter: 16px; } /* the page's side padding: a sideways row may run to the screen edge */
++.ovk-tool { display: grid; grid-template-columns: minmax(0, 1fr); gap: 14px; margin: 0 0 20px; color: var(--ovk-text); }
++.ovk-tool-head { --ovk-tint: color-mix(in srgb, hsl(var(--ovk-tint-h, 210) 80% 56%) 82%, var(--ovk-text)); display: grid; grid-template-columns: 44px minmax(0, 1fr); column-gap: 12px; align-items: center; }
++.ovk-tool-tile { grid-row: span 2; width: 44px; height: 44px; border-radius: 12px; display: grid; place-items: center; color: var(--ovk-tint); background: color-mix(in srgb, var(--ovk-tint) 14%, transparent); }
++.ovk-tool-tile > svg, .ovk-tool-tile > .ovk-i, .ovk-tool-tile > img { width: 22px; height: 22px; }
++.ovk-tool-head--solo .ovk-tool-tile { grid-row: auto; }
++.ovk-tool-crumb { justify-self: start; display: inline-flex; align-items: center; gap: 2px; min-height: 24px; color: var(--ovk-muted); font-size: .8rem; font-weight: 600; text-decoration: none; }
++.ovk-tool-crumb > .ovk-i { width: .95em; height: .95em; }
++a.ovk-tool-crumb:hover { color: var(--ovk-text); }
++.ovk-tool-head .ovk-tool-title.ovk-tool-title { margin: 0; font-family: inherit; font-size: 1.5rem; line-height: 1.15; font-weight: 800; letter-spacing: -.02em; color: var(--ovk-text); text-wrap: balance; }
++.ovk-tool-head .ovk-tool-task.ovk-tool-task { grid-column: 1 / -1; margin: 8px 0 0; color: var(--text-secondary, var(--ovk-muted)); font-size: .95rem; line-height: 1.45; text-wrap: pretty; }
++
++.ovk-tool-card { display: grid; gap: 12px; padding: 14px; border: 1px solid var(--ovk-line); border-radius: 16px; background: var(--ovk-panel); }
++.ovk-tool-card > .ovk-drop, .ovk-tool-card > form > .ovk-drop { margin: 0; }
++.ovk-tool-form { display: grid; gap: 10px; margin: 0; }
++.ovk-tool-label { font-size: .8rem; font-weight: 650; color: var(--text-secondary, var(--ovk-muted)); }
++.ovk-tool-row { display: grid; gap: 10px; }
++.ovk-tool-field { position: relative; display: flex; align-items: center; min-width: 0; }
++.ovk-tool-field > input { width: 100%; min-width: 0; height: 52px; box-sizing: border-box; padding: 0 48px 0 14px; border-radius: 12px; border: 1.5px solid var(--ovk-line); background: var(--ovk-input); color: var(--ovk-text); font: inherit; font-size: 17px; }
++.ovk-tool-field > input::placeholder { color: var(--ovk-muted); opacity: 1; }
++.ovk-tool-field > input:focus { outline: none; border-color: var(--ovk-accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ovk-accent) 22%, transparent); }
++.ovk-tool-clear { position: absolute; right: 4px; top: 50%; transform: translateY(-50%); width: 44px; height: 44px; display: grid; place-items: center; border: 0; border-radius: 10px; background: transparent; color: var(--ovk-muted); cursor: pointer; }
++.ovk-tool-clear:hover { color: var(--ovk-text); }
++.ovk-tool-clear[hidden] { display: none; }
++.ovk-tool-go { width: 100%; min-height: 48px; font-size: 1rem; }
++.ovk-tool-card textarea.ovk-tool-editor { width: 100%; min-height: 160px; box-sizing: border-box; padding: 12px; border-radius: 12px; border: 1.5px solid var(--ovk-line); background: var(--ovk-input); color: var(--ovk-text); font: .86rem/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; resize: vertical; }
++.ovk-tool-card textarea.ovk-tool-editor:focus { outline: none; border-color: var(--ovk-accent); }
++
++.ovk-try { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 0; color: var(--ovk-muted); font-size: .82rem; }
++.ovk-try > span { margin-right: 2px; }
++.ovk-try > a { display: inline-flex; align-items: center; min-height: 32px; box-sizing: border-box; max-width: 100%; padding: 4px 11px; border-radius: 999px; background: var(--ovk-panel-2); color: var(--text-secondary, var(--ovk-text)); font: .8rem ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.ovk-try > a:hover { color: var(--ovk-text); box-shadow: inset 0 0 0 1px var(--ovk-accent); }
++
++.ovk-tool-out:empty { display: none; }
++.ovk-answer { border: 1px solid var(--ovk-line); border-radius: 16px; background: var(--ovk-panel); overflow: hidden; }
++.ovk-answer-head { display: flex; align-items: center; gap: 10px; min-height: 52px; box-sizing: border-box; padding: 6px 6px 6px 14px; border-bottom: 1px solid var(--ovk-line); }
++.ovk-answer-head.ovk-answer-head h2 { flex: 1; min-width: 0; margin: 0; font-family: inherit; font-size: 1rem; font-weight: 750; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ovk-text); }
++.ovk-answer-acts { display: inline-flex; gap: 2px; }
++.ovk-answer--bad { border-color: color-mix(in srgb, var(--ovk-bad) 45%, var(--ovk-line)); }
++.ovk-answer-rows { display: grid; grid-template-columns: minmax(84px, max-content) minmax(0, 1fr); margin: 0; }
++.ovk-answer-rows > div { display: contents; }
++.ovk-answer-rows dt, .ovk-answer-rows dd { margin: 0; padding: 10px 14px; border-bottom: 1px solid var(--ovk-line); font-size: .9rem; line-height: 1.45; }
++.ovk-answer-rows dt { padding-right: 6px; color: var(--ovk-muted); }
++.ovk-answer-rows dd { min-width: 0; overflow-wrap: anywhere; color: var(--ovk-text); }
++.ovk-answer-rows > div:last-child > dt, .ovk-answer-rows > div:last-child > dd { border-bottom: 0; }
++.ovk-answer-rows + .ovk-answer-body, .ovk-answer-rows + .ovk-answer-next { border-top: 1px solid var(--ovk-line); }
++.ovk-answer-body { padding: 12px 14px; }
++.ovk-answer-body > :first-child { margin-top: 0; }
++.ovk-answer-body > :last-child { margin-bottom: 0; }
++.ovk-answer-next { display: flex; flex-wrap: wrap; gap: 8px; padding: 10px 12px; }
++.ovk-pill { display: inline-block; margin-left: 4px; padding: 1px 8px; border-radius: 999px; font-size: .74rem; font-weight: 700; vertical-align: 1px; white-space: nowrap; color: var(--ovk-accent); background: color-mix(in srgb, var(--ovk-accent) 13%, transparent); }
++.ovk-pill--ok { color: var(--ovk-ok); background: color-mix(in srgb, var(--ovk-ok) 14%, transparent); }
++.ovk-pill--warn { color: var(--ovk-warn); background: color-mix(in srgb, var(--ovk-warn) 15%, transparent); }
++.ovk-pill--bad { color: var(--ovk-bad); background: color-mix(in srgb, var(--ovk-bad) 14%, transparent); }
++
++.ovk-family-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin: 0 0 8px; }
++.ovk-family-head.ovk-family-head h2 { margin: 0; font-family: inherit; font-size: .95rem; font-weight: 750; color: var(--ovk-text); }
++.ovk-family-head a { display: inline-flex; align-items: center; min-height: 32px; color: var(--ovk-accent); font-size: .84rem; font-weight: 700; text-decoration: none; }
++.ovk-family-items { display: flex; gap: 8px; margin: 0 calc(-1 * var(--ovk-gutter, 0px)); padding: 2px var(--ovk-gutter, 0px) 4px; overflow-x: auto; scrollbar-width: none; scroll-padding-inline: var(--ovk-gutter, 0px); overscroll-behavior-x: contain; }
++.ovk-family-items::-webkit-scrollbar { display: none; }
++.ovk-family-items > a { flex: none; display: inline-flex; align-items: center; min-height: 40px; box-sizing: border-box; padding: 0 14px; border-radius: 999px; border: 1px solid var(--ovk-line); background: var(--ovk-panel); color: var(--ovk-text); font-size: .86rem; font-weight: 600; text-decoration: none; white-space: nowrap; }
++.ovk-family-items > a:hover { border-color: var(--ovk-accent); }
++.ovk-family-items > a[aria-current="page"] { border-color: var(--ovk-accent); color: var(--ovk-accent); }
++.ovk-family-full { display: none; }
++
++.ovk-tool-folds > .ovk-fold { margin: 0; border-radius: 0; border-top-width: 0; }
++.ovk-tool-folds > .ovk-fold:first-child { border-top-width: 1px; border-radius: 16px 16px 0 0; }
++.ovk-tool-folds > .ovk-fold:last-child { border-radius: 0 0 16px 16px; }
++.ovk-tool-folds > .ovk-fold:only-child { border-radius: 16px; }
++.ovk-tool-folds > .ovk-fold > summary { min-height: 52px; }
++.ovk-tool-related > .ovk-list-head:first-child { margin-top: 4px; }
++.ovk-tool-related > .ovk-list { margin-bottom: 0; }
++
++@media (min-width: 600px) {
++ .ovk-tool-row { grid-template-columns: minmax(0, 1fr) auto; }
++ .ovk-tool-go { width: auto; min-width: 150px; min-height: 52px; }
++ .ovk-tool-head .ovk-tool-title.ovk-tool-title { font-size: 1.85rem; }
++}
++@media (min-width: 1024px) {
++ .ovk-tool { grid-template-columns: minmax(0, 1fr) 300px; grid-template-rows: repeat(4, auto) 1fr; column-gap: 32px; row-gap: 18px; }
++ .ovk-tool > * { grid-column: 1; }
++ .ovk-tool > .ovk-family { grid-column: 2; grid-row: 1 / -1; align-self: start; position: sticky; top: calc(var(--ovk-top) + 16px); }
++ .ovk-tool-head { grid-template-columns: 52px minmax(0, 1fr); }
++ .ovk-tool-tile { width: 52px; height: 52px; border-radius: 14px; }
++ .ovk-tool-head .ovk-tool-task.ovk-tool-task { font-size: 1rem; }
++ .ovk-family-items { flex-direction: column; gap: 0; margin: 0; padding: 0; overflow: visible; border: 1px solid var(--ovk-line); border-radius: 16px; background: var(--ovk-panel); }
++ .ovk-family-items > a { min-height: 42px; padding: 0 14px; border: 0; border-top: 1px solid var(--ovk-line); border-radius: 0; background: none; white-space: normal; }
++ .ovk-family-items > a:first-child { border-top: 0; }
++ .ovk-family-items > a[aria-current="page"] { box-shadow: inset 3px 0 var(--ovk-accent); }
++ .ovk-family-short { display: none; }
++ .ovk-family-full { display: inline; }
++}
++@media (pointer: coarse) { .ovk-try > a { min-height: 36px; } }
ddiff --git a/kit-web.js b/kit-web.js
iindex 36a3c67..d3c003d 100644
---- a/kit-web.js
++++ b/kit-web.js
@@@ -26,7 +26,9 @@
if (typeof document === 'undefined') return;
const scope = rootEl || document;
const all = (sel) => Array.prototype.slice.call(scope.querySelectorAll ? scope.querySelectorAll(sel) : []);
-- const once = (el, key) => { if (el.dataset[key]) return false; el.dataset[key] = '1'; return true; };
++ // Marks an element as wired. The mark has its own name (…Wired): `data-ovk-copy="#id"` is dataset.ovkCopy, so a mark
++ // under that same name was already set by the markup, and copy, sheets, confirms and titled shares never wired.
++ const once = (el, key) => { const k = `${key}Wired`; if (el.dataset[k]) return false; el.dataset[k] = '1'; return true; };
// Copy buttons: shown only where the clipboard works; the icon turns into a check for a moment.
all('[data-ovk-copy]').forEach((b) => {
if (!once(b, 'ovkCopy') || !navigator.clipboard) return;
ddiff --git a/kit.js b/kit.js
iindex a1f85fe..169118e 100644
---- a/kit.js
++++ b/kit.js
@@@ -29,6 +29,11 @@
* kit.button({ href | type, label, icon, kind }) · kit.iconButton(…) · kit.chips(…) · kit.seg(…) · kit.icon(name)
* kit.mark({ name, src, hue, square }) · kit.when(ts) → { iso, text } · kit.esc(text) · kit.ICONS (names)
*
++ * The tool frame, one order for every tool page: kit.tool({ head: kit.toolHead({ icon, hue, crumb, title, task }),
++ * input: kit.toolInput({ name, label, placeholder, button, tries: { items } }) | kit.drop(…) | an editor,
++ * answer: kit.answer({ title, badge, rows: [[label, value, note]], next }), family: kit.family({ title, more, items }),
++ * folds: [kit.fold(…)], related: kit.list(…) })
++ *
* Text arguments are escaped; arguments named body, html, before, after, actions, trail, badge, extra and mark take
* trusted markup (usually another builder's output). URLs are kept only when they are http(s) or same-site paths.
*/
@@@ -652,6 +657,105 @@
+ `${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>`;
}
++ // ── The tool frame (4.55.0, owner ask 11): one order for every tool page on every site ──
++ // What it does, the box you type in, the answer and its next step, the family's other tools, then the rest folded
++ // away. Phone first; from 1024 px the family sits in a column beside the tool. docs/kit.md "The tool frame".
++
++ /** A family's tint as a hue (0–360): a number, or a key hashed the way kit.mark hashes names. */
++ const hueFor = (h, key) => (h != null && h !== '' && Number.isFinite(Number(h)) ? ((Math.round(Number(h)) % 360) + 360) % 360 : hueOf(key || ''));
++
++ /**
++ * toolHead({ icon, iconHtml, hue, family, crumb: { href, label }, title, task, level }) — the tool's name and the one
++ * sentence saying what it does. The icon sits on a tile in the family's tint (hue, else one hashed from family or the
++ * crumb); buttons keep the site's own accent.
++ */
++ function toolHead({ icon: ic = 'grid', iconHtml = null, hue = null, family: fam = '', crumb = null, title = '', task = '', level = 1 } = {}) {
++ const lv = Math.min(3, Math.max(1, Number(level) || 1));
++ const cr = crumb && crumb.label ? (crumb.href ? `<a class="ovk-tool-crumb" href="${esc(url(crumb.href))}">${esc(crumb.label)}${icon('chevron')}</a>` : `<span class="ovk-tool-crumb">${esc(crumb.label)}</span>`) : '';
++ return `<header class="ovk-tool-head${cr ? '' : ' ovk-tool-head--solo'}" style="--ovk-tint-h:${hueFor(hue, fam || (crumb && crumb.label) || title)}">`
++ + `<span class="ovk-tool-tile" aria-hidden="true">${iconHtml != null ? iconHtml : (icon(ic) || icon('grid'))}</span>${cr}`
++ + `<h${lv} class="ovk-tool-title">${esc(title)}</h${lv}>${task ? `<p class="ovk-tool-task">${esc(task)}</p>` : ''}</header>`;
++ }
++
++ /**
++ * tries({ name, target, run, items: [{ label, value, href }] }) — "Try" examples under the box: a tap fills it and
++ * runs the tool (kit-web.js). Without JavaScript each is a link to ?<name>=<value>, so a tool page runs the tool its
++ * query names. target: the box (a selector; default the [name] field in the same form); run: a button to press when
++ * the box is in no form.
++ */
++ function tries({ name = 'q', target = '', run = '', items = [], label = 'Try' } = {}) {
++ const its = (items || []).filter((it) => it && (it.value != null || it.href));
++ if (!its.length) return '';
++ return `<p class="ovk-try"><span>${esc(label)}</span>${its.map((it) => {
++ const v = it.value == null ? '' : String(it.value);
++ const href = it.href ? url(it.href) : `?${encodeURIComponent(name)}=${encodeURIComponent(v)}`;
++ return `<a href="${esc(href)}"${it.value != null ? ` data-ovk-try="${esc(v)}" data-ovk-name="${esc(name)}"` : ''}${target ? ` data-ovk-for="${esc(target)}"` : ''}${run ? ` data-ovk-run="${esc(run)}"` : ''}>${esc(it.label || v)}</a>`;
++ }).join('')}</p>`;
++ }
++
++ /**
++ * toolInput({ action, method, name, label, placeholder, value, button, icon, type, inputmode, id, required, pattern,
++ * maxlength, hidden, tries }) — the one box most tools need: a big field (17 px, so phones do not zoom in), a clear
++ * button, and the tool's button under it on a phone and beside it on a computer.
++ */
++ function toolInput({ action: to = '', method = 'get', name = 'q', label = '', placeholder = '', value = '', button: btn = 'Go', icon: ic = 'search', type = 'text', inputmode = null, id = null, required = true, pattern = null, maxlength = null, hidden = {}, tries: tr = null, autocomplete = 'off' } = {}) {
++ const fid = id || `ovk-tool-${String(name).replace(/[^a-z0-9_-]/gi, '')}`;
++ const t = ['text', 'search', 'url', 'email', 'number', 'tel'].includes(type) ? type : 'text';
++ return `<form class="ovk-tool-form" method="${method === 'post' ? 'post' : 'get'}" action="${esc(url(to || '?'))}">`
++ + Object.keys(hidden || {}).map((k) => `<input type="hidden" name="${esc(k)}" value="${esc(hidden[k])}">`).join('')
++ + `${label ? `<label class="ovk-tool-label" for="${esc(fid)}">${esc(label)}</label>` : ''}`
++ + `<div class="ovk-tool-row"><span class="ovk-tool-field"><input id="${esc(fid)}" name="${esc(name)}" type="${t}" value="${esc(value)}"${label ? '' : ` aria-label="${esc(placeholder || btn)}"`} placeholder="${esc(placeholder)}" autocomplete="${esc(autocomplete)}" spellcheck="false" autocapitalize="off"${inputmode ? ` inputmode="${esc(inputmode)}"` : ''}${required ? ' required' : ''}${pattern ? ` pattern="${esc(pattern)}"` : ''}${maxlength ? ` maxlength="${Number(maxlength)}"` : ''}>`
++ + `<button type="button" class="ovk-tool-clear" data-ovk-clear="#${esc(fid)}" aria-label="Clear" title="Clear"${value ? '' : ' hidden'}>${icon('close')}</button></span>`
++ + `<button type="submit" class="ovk-btn ovk-btn--primary ovk-tool-go">${ic ? icon(ic) : ''}<span>${esc(btn)}</span></button></div>`
++ + (tr ? tries({ name, target: `#${fid}`, ...tr }) : '') + '</form>';
++ }
++
++ /**
++ * answer({ title, badge: { text, tone }, actions: [html], rows: [[label, value, note]], body, next: [html], tone, id })
++ * — a tool's answer: labelled rows with the fact that matters marked (note: text or { text, tone }, a small pill),
++ * then the next step (watch it for changes, look up the same name's DNS, compress the picture just made). A value
++ * can be { html } (trusted markup). tone 'bad' draws an error answer.
++ */
++ function answer({ title = '', badge: bd = null, actions = [], rows = [], body = '', next = [], tone = '', id = null, live = true } = {}) {
++ const rs = (rows || []).filter((r) => r && r[1] != null && r[1] !== '');
++ const acts = (actions || []).filter(Boolean).join('');
++ const nx = (next || []).filter(Boolean).join('');
++ const pill = (n) => { if (n == null || n === '') return ''; const o = typeof n === 'object' ? n : { text: n }; return ` <span class="ovk-pill${o.tone ? ` ovk-pill--${esc(o.tone)}` : ''}">${esc(o.text)}</span>`; };
++ const val = (v) => (v && typeof v === 'object' && v.html != null ? v.html : esc(v));
++ return `<section class="ovk-answer${tone ? ` ovk-answer--${esc(tone)}` : ''}"${id ? ` id="${esc(id)}"` : ''}${live ? ' aria-live="polite"' : ''}${title ? '' : ' aria-label="Answer"'}>`
++ + (title || (bd && bd.text) || acts ? `<div class="ovk-answer-head">${title ? `<h2>${esc(title)}</h2>` : '<span></span>'}${bd && bd.text ? badge(bd.text, bd.tone || '') : ''}${acts ? `<span class="ovk-answer-acts">${acts}</span>` : ''}</div>` : '')
++ + (rs.length ? `<dl class="ovk-answer-rows">${rs.map(([l, v, n]) => `<div><dt>${esc(l)}</dt><dd>${val(v)}${pill(n)}</dd></div>`).join('')}</dl>` : '')
++ + (body ? `<div class="ovk-answer-body">${body}</div>` : '')
++ + (nx ? `<div class="ovk-answer-next">${nx}</div>` : '') + '</section>';
++ }
++
++ /**
++ * family({ title, more: { href, label }, items: [{ href, label, short, current }] }) — the family's other tools: one
++ * row that scrolls sideways on a phone (short names), a list beside the tool on a computer (full names).
++ */
++ function family({ title = '', more: m = null, items = [], label = '' } = {}) {
++ const its = (items || []).filter((it) => it && it.label);
++ if (!its.length) return '';
++ return `<nav class="ovk-family" aria-label="${esc(label || title || 'More tools')}">`
++ + `<div class="ovk-family-head">${title ? `<h2>${esc(title)}</h2>` : '<span></span>'}${m && m.href ? `<a href="${esc(url(m.href))}">${esc(m.label || 'All')}</a>` : ''}</div>`
++ + `<div class="ovk-family-items">${its.map((it) => `<a href="${esc(url(it.href))}"${it.current ? ' aria-current="page"' : ''}>${it.short && it.short !== it.label ? `<span class="ovk-family-short">${esc(it.short)}</span><span class="ovk-family-full">${esc(it.label)}</span>` : esc(it.label)}</a>`).join('')}</div></nav>`;
++ }
++
++ /**
++ * tool({ head, input, answer, family, folds: [html], related, id }) — the whole frame. input: the card's markup
++ * (kit.toolInput, kit.drop, an editor); answer: '' until there is one (the .ovk-tool-out slot stays, so a page that
++ * draws its answer later fills it); folds: kit.fold()s joined into one card (About, Questions, Use it from code);
++ * related: a kit.list of tools people also use. Folded text stays in the page for search engines.
++ */
++ function tool({ head: h = '', input = '', answer: a = '', family: f = '', folds = [], related = '', id = null } = {}) {
++ const fl = (Array.isArray(folds) ? folds : [folds]).filter(Boolean).join('');
++ return `<div class="ovk-tool"${id ? ` id="${esc(id)}"` : ''}>${h}`
++ + (input ? `<div class="ovk-tool-card">${input}</div>` : '')
++ + `<div class="ovk-tool-out">${a || ''}</div>${f}`
++ + (fl ? `<div class="ovk-tool-folds">${fl}</div>` : '')
++ + (related ? `<div class="ovk-tool-related">${related}</div>` : '') + '</div>';
++ }
++
/**
* 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.
@@@ -665,5 +769,6 @@
switchRow, rangeRow, selectRow, seg, chips, saveBar, find, dock, stats, empty, notice, snippet, sheet, sheetButton,
enhance, VERSION: 2,
num, steps, table, chart, rank, meter, drop, receipt, page,
++ toolHead, toolInput, tries, answer, family, tool,
};
});
ddiff --git a/package.json b/package.json
iindex e4176f4..5874962 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.54.0",
++ "version": "4.55.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
ddiff --git a/test/kit-web-chrome.test.js b/test/kit-web-chrome.test.js
nnew file mode 100644
iindex 0000000..997d9a9
---- /dev/null
++++ b/test/kit-web-chrome.test.js
@@@ -0,0 +1,76 @@
++'use strict';
++// kit-web.js and kit-data-web.js in a real headless Chrome (4.55.0). Until 4.55.0 its once() marked an element as wired under the same
++// dataset name as the attribute that asks for the behaviour (data-ovk-copy="#id" is dataset.ovkCopy), so the markup had
++// already "marked" it: copy buttons stayed hidden, sheet buttons opened nothing, and a form with data-ovk-confirm was
++// sent without asking. This checks each of those, and the tool frame's clear button and "Try" examples, in the page.
++// Skipped without Chrome.
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const h = require('../browser-harness');
++const kit = require('../kit');
++
++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {
++ console.log('kit-web-chrome: skipped (no Chrome; set CHROME_BIN)');
++ process.exit(0);
++}
++
++const ROOT = path.join(__dirname, '..');
++const BODY = kit.snippet({ code: 'hello', id: 'sn' })
++ + kit.sheetButton({ sheet: 'sh', label: 'Filters', iconOnly: false }) + kit.sheet({ id: 'sh', title: 'Filters', body: '<p>x</p>' })
++ + kit.action({ action: '/gone', label: 'Delete', confirm: 'Delete it?' })
++ + kit.tool({ head: kit.toolHead({ title: 'Whois Lookup', crumb: { label: 'Network tools' } }),
++ input: kit.toolInput({ name: 'q', label: 'Domain', button: 'Look up', tries: { items: [{ value: 'openvibe.network' }] } }) });
++const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Kit</title><link rel="icon" href="data:,"></head>
++<body>${BODY}<script>
++ window.__asked = []; window.confirm = (m) => { window.__asked.push(m); return false; };
++ window.__sent = [];
++ document.addEventListener('submit', (e) => { if (!e.defaultPrevented) { e.preventDefault(); window.__sent.push([e.target.getAttribute('action'), new FormData(e.target).get('q')]); } });
++</script><script src="/kit-web.js"></script><script src="/kit-data-web.js"></script></body></html>`;
++const server = http.createServer((req, res) => {
++ const u = new URL(req.url, 'http://x');
++ if (u.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(PAGE); }
++ if (u.pathname === '/kit-web.js' || u.pathname === '/kit-data-web.js') { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(ROOT, u.pathname.slice(1)))); }
++ res.writeHead(404); return res.end();
++});
++
++(async () => {
++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-kitweb-'));
++ const browser = await h.launch({ tmpDir });
++ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
++ try {
++ const context = await browser.newContext();
++ const p = await h.openPage(browser, context, { width: 390 });
++ await p.goto(`${base}/`, { maxMs: 5000 });
++ await wait(200);
++ const hasClipboard = await p.evaluate('!!navigator.clipboard');
++ if (hasClipboard) assert.strictEqual(await p.evaluate("document.querySelector('[data-ovk-copy]').hidden"), false, 'the copy button shows where the clipboard works');
++ await p.evaluate("document.querySelector('[data-ovk-sheet]').click()");
++ await wait(100);
++ assert.strictEqual(await p.evaluate("document.getElementById('sh').open"), true, 'the sheet button opens its sheet');
++ await p.evaluate("document.querySelector('#sh [data-ovk-close]').click()");
++ await p.evaluate("document.querySelector('form[data-ovk-confirm] button').click()");
++ await wait(100);
++ assert.deepStrictEqual(await p.evaluate('window.__asked'), ['Delete it?'], 'a form that asks first asks');
++ assert.ok(!(await p.evaluate("window.__sent.some(([a]) => a === '/gone')")), 'and is not sent when the answer is no');
++ // The tool frame: the clear button follows the box; a Try example fills the box and runs the tool.
++ assert.strictEqual(await p.evaluate("document.querySelector('.ovk-tool-clear').hidden"), true, 'an empty box hides its clear button');
++ await p.evaluate("document.querySelector('a[data-ovk-try]').click()");
++ await wait(100);
++ assert.strictEqual(await p.evaluate("document.getElementById('ovk-tool-q').value"), 'openvibe.network', 'the example fills the box');
++ assert.ok(await p.evaluate("window.__sent.some(([, q]) => q === 'openvibe.network')"), 'and runs the tool (its form is sent)');
++ assert.strictEqual(await p.evaluate("document.querySelector('.ovk-tool-clear').hidden"), false, 'a box with text shows its clear button');
++ await p.evaluate("document.querySelector('.ovk-tool-clear').click()");
++ assert.strictEqual(await p.evaluate("document.getElementById('ovk-tool-q').value"), '', 'which empties it');
++ await p.close();
++ console.log(`kit-web-chrome: ${hasClipboard ? 'copy shows, ' : ''}sheets open, confirms ask, the tool frame's clear and Try work ok`);
++ } finally {
++ await browser.close();
++ server.close();
++ fs.rmSync(tmpDir, { recursive: true, force: true });
++ }
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/kit.test.js b/test/kit.test.js
iindex 4f7bb74..cafa532 100644
---- a/test/kit.test.js
++++ b/test/kit.test.js
@@@ -194,4 +194,69 @@ const kit = require('../kit');
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');
++// ── The tool frame (4.55.0): toolHead, toolInput, tries, answer, family, tool ──
++{
++ const h = kit.toolHead({ icon: 'globe', hue: 190, crumb: { href: '/network', label: 'Network <tools>' }, title: 'Whois <b>', task: 'Who "registered" it' });
++ assert.ok(h.startsWith('<header class="ovk-tool-head" style="--ovk-tint-h:190">'), 'the family tint is a hue number');
++ assert.ok(h.includes('<a class="ovk-tool-crumb" href="/network">Network &lt;tools&gt;') && h.includes('<h1 class="ovk-tool-title">Whois &lt;b&gt;</h1>'));
++ assert.ok(h.includes('<p class="ovk-tool-task">Who &quot;registered&quot; it</p>'));
++ const evil = kit.toolHead({ title: 'x', hue: 'red;background:url(x)' });
++ assert.ok(/--ovk-tint-h:\d+"/.test(evil) && !evil.includes('url('), 'a hue that is no number is hashed, never written into the style');
++ assert.ok(kit.toolHead({ title: 'x', hue: -30 }).includes('--ovk-tint-h:330') && kit.toolHead({ title: 'x', hue: 725 }).includes('--ovk-tint-h:5'));
++ assert.ok(kit.toolHead({ title: 'Solo' }).includes('ovk-tool-head--solo'), 'no crumb: the tile sits beside the title alone');
++ assert.ok(kit.toolHead({ title: 'x', crumb: { href: 'javascript:alert(1)', label: 'c' } }).includes('href="#"'), 'a crumb link is kept only when it is http(s) or a path');
++ assert.ok(kit.toolHead({ title: 'x', level: 2 }).includes('<h2 class="ovk-tool-title">'));
++
++ const t = kit.tries({ name: 'q', items: [{ value: 'a b&c' }, { label: 'IP', value: '1.1.1.1' }, { label: 'Docs', href: 'javascript:x' }, null] });
++ assert.ok(t.startsWith('<p class="ovk-try"><span>Try</span>'));
++ assert.ok(t.includes('<a href="?q=a%20b%26c" data-ovk-try="a b&amp;c" data-ovk-name="q">a b&amp;c</a>'), 'without JavaScript an example is a link that runs the tool from the query');
++ assert.ok(t.includes('data-ovk-try="1.1.1.1" data-ovk-name="q">IP</a>'));
++ assert.ok(t.includes('<a href="#">Docs</a>'), 'an example link is sanitised like every other');
++ assert.strictEqual(kit.tries({ items: [] }), '', 'no examples, nothing drawn');
++
++ const i = kit.toolInput({ name: 'q', label: 'Domain or IP', placeholder: 'example.com', button: 'Look up', value: 'x"y', inputmode: 'url', tries: { items: [{ value: 'openvibe.network' }] } });
++ assert.ok(i.startsWith('<form class="ovk-tool-form" method="get" action="?">'));
++ assert.ok(i.includes('<label class="ovk-tool-label" for="ovk-tool-q">Domain or IP</label>') && i.includes('value="x&quot;y"') && i.includes('inputmode="url"'));
++ assert.ok(i.includes('data-ovk-clear="#ovk-tool-q" aria-label="Clear" title="Clear">'), 'a box with text shows its clear button');
++ assert.ok(kit.toolInput({ name: 'q' }).includes('title="Clear" hidden>'), 'an empty box hides it until there is text');
++ assert.ok(i.includes('<button type="submit" class="ovk-btn ovk-btn--primary ovk-tool-go">') && i.includes('<span>Look up</span>'));
++ assert.ok(i.includes('data-ovk-for="#ovk-tool-q"') && i.endsWith('</p></form>'), 'its examples point at its own box');
++ assert.ok(kit.toolInput({ name: 'q', type: 'password' }).includes('type="text"'), 'only plain text types');
++ assert.ok(!/<input id="ovk-tool-q"[^>]*aria-label=/.test(kit.toolInput({ name: 'q', label: 'L' })), 'a labelled box needs no aria-label');
++
++ const a = kit.answer({ title: 'openvibe.network', badge: { text: 'Registered', tone: 'ok' }, actions: ['<button>c</button>'],
++ rows: [['Registrar', 'Cloud <flare>'], ['Expires', '2027', { text: 'in 10 months', tone: 'warn' }], ['Created', '2025', '1 year ago'], ['Empty', ''], ['NS', { html: 'a<br>b' }]],
++ next: ['<a class="n">Watch</a>'], id: 'ans' });
++ assert.ok(a.startsWith('<section class="ovk-answer" id="ans" aria-live="polite">'), 'the answer is announced when it arrives');
++ assert.ok(a.includes('<h2>openvibe.network</h2><span class="ovk-badge ovk-badge--ok">Registered</span><span class="ovk-answer-acts"><button>c</button></span>'));
++ assert.ok(a.includes('<div><dt>Registrar</dt><dd>Cloud &lt;flare&gt;</dd></div>') && !a.includes('Empty'), 'rows: escaped, blanks left out');
++ assert.ok(a.includes('2027 <span class="ovk-pill ovk-pill--warn">in 10 months</span>') && a.includes('2025 <span class="ovk-pill">1 year ago</span>'));
++ assert.ok(a.includes('<dd>a<br>b</dd>') && a.includes('<div class="ovk-answer-next"><a class="n">Watch</a></div>'));
++ const bad = kit.answer({ title: 'Not valid JSON', tone: 'bad', body: '<p>x</p>', live: false });
++ assert.ok(bad.startsWith('<section class="ovk-answer ovk-answer--bad">') && bad.includes('<div class="ovk-answer-body"><p>x</p></div>'));
++ assert.ok(kit.answer({ body: 'B' }).includes('aria-label="Answer"') && !kit.answer({ body: 'B' }).includes('ovk-answer-head'), 'no title, no head');
++
++ const f = kit.family({ title: 'Network tools', more: { href: '/network', label: 'All 22' }, items: [{ href: '/whois', label: 'Whois Lookup', short: 'Whois', current: true }, { href: '/dns', label: 'DNS Lookup' }, { label: '' }] });
++ assert.ok(f.startsWith('<nav class="ovk-family" aria-label="Network tools">') && f.includes('<a href="/network">All 22</a>'));
++ assert.ok(f.includes('<a href="/whois" aria-current="page"><span class="ovk-family-short">Whois</span><span class="ovk-family-full">Whois Lookup</span></a>'), 'short on a phone, full beside the tool');
++ assert.ok(f.includes('<a href="/dns">DNS Lookup</a>') && (f.match(/<a href/g) || []).length === 3, 'an item with no label is left out');
++ assert.strictEqual(kit.family({ items: [] }), '');
++
++ const w = kit.tool({ head: '<header>H</header>', input: '<form>I</form>', family: '<nav>F</nav>', folds: [kit.fold({ title: 'About' }), '', kit.fold({ title: 'Questions' })], related: '<ul>R</ul>', id: 'tool' });
++ assert.ok(w.startsWith('<div class="ovk-tool" id="tool"><header>H</header><div class="ovk-tool-card"><form>I</form></div><div class="ovk-tool-out"></div><nav>F</nav>'), 'the order: head, box, answer slot, family');
++ assert.ok(w.includes('<div class="ovk-tool-folds"><details class="ovk-fold">') && (w.match(/<details/g) || []).length === 2 && w.endsWith('<div class="ovk-tool-related"><ul>R</ul></div></div>'));
++ assert.ok(kit.tool({ answer: '<section>A</section>' }).includes('<div class="ovk-tool-out"><section>A</section></div>'), 'the answer goes in its slot');
++}
++
++// kit-web.js marks what it has wired under a name of its own: data-ovk-copy="#id" is dataset.ovkCopy, so a mark of the
++// same name was set by the markup itself, and copy buttons, sheets and confirms were never wired (fixed in 4.55.0).
++{
++ for (const file of ['kit-web.js', 'kit-data-web.js']) {
++ const src = require('fs').readFileSync(require('path').join(__dirname, '..', file), 'utf8');
++ assert.ok(/const once = \(el, key\) => \{ const k = `\$\{key\}Wired`;/.test(src), `${file}: once() keeps its mark apart from the data-ovk-* attribute it wires`);
++ const keys = [...src.matchAll(/once\(\w+, '(\w+)'\)/g)].map((m) => m[1]);
++ assert.ok(keys.length >= 3 && keys.every((k) => !/Wired$/.test(k)), `${file}: once() adds the suffix itself`);
++ }
++}
++
++console.log('kit: builders, escaping, tabs, rows, settings, find/dock, icons, time and Kit 2 (steps, table, chart, rank, meter, drop, receipt, page) and the tool frame ok');