openvibers/openvibe.shared Commits
The tool frame, and kit-web wires copy, sheets and confirms again (4.55.0) (#203)
Changes
@@@ -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@@@ -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 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.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)",@@@ -299,6 +299,56 @@ lines and areas (under the pointer, a tap, or ← → Home End) and hides a scatshown, 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 linksThe 2026-10-28 sweep of all 38 sites' bars found three habits that made them feel crowded and repetitive:@@@ -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 };@@@ -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; } }@@@ -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;@@@ -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,};});@@@ -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": {nnew file mode 100644@@@ -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); });@@@ -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 <tools>') && h.includes('<h1 class="ovk-tool-title">Whois <b></h1>'));++ assert.ok(h.includes('<p class="ovk-tool-task">Who "registered" 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&c" data-ovk-name="q">a b&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"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 <flare></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');
@@ -6,9 +6,36 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
@@ -6,9 +6,36 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
## 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.
`network.dock-state@1` contract has no such key.
## 4.54.0 — 2026-10-30
## 4.54.0 — 2026-10-30
@@ -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.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
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.54.0",
+ "version": "4.55.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)",
@@ -299,6 +299,56 @@ lines and areas (under the pointer, a tap, or ← → Home End) and hides a scat
@@ -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,
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.
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 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:
@@ -4,10 +4,12 @@
@@ -4,10 +4,12 @@
* charts .ovk-chart-plot[data-ovk-chart]: the values under the pointer, a tap, or ← → Home End
* 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
* 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
* 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
* 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
* 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) {
(function (root) {
'use strict';
'use strict';
@@ -15,7 +17,8 @@
@@ -15,7 +17,8 @@
if (typeof document === 'undefined') return;
if (typeof document === 'undefined') return;
const scope = rootEl || document;
const scope = rootEl || document;
const all = (sel) => Array.prototype.slice.call(scope.querySelectorAll ? scope.querySelectorAll(sel) : []);
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.
// 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) => {
all('.ovk-chart-plot[data-ovk-chart]').forEach((plot) => {
if (!once(plot, 'ovkChart')) return;
if (!once(plot, 'ovkChart')) return;
@@ -119,6 +122,31 @@
@@ -119,6 +122,31 @@
camInput.addEventListener('change', () => { take(camInput.files); camInput.value = ''; });
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 };
const api = { enhance };
@@ -1,5 +1,6 @@
@@ -1,5 +1,6 @@
/* openvibe-shared/kit-data.css — Kit 2's look: steps, tables, charts, rankings, meters, drop zones and receipts
/* 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
* 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). */
* uses none of them does not carry them (kit.css weighed 25 KB before Kit 2, 44 KB with it). */
@@ -173,3 +174,100 @@
@@ -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 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; }
.ovk-receipt-facts .ovk-meter { width: 56px; height: 6px; }
@media (prefers-reduced-motion: reduce) { .ovk-drop-zone { transition: none; } }
@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; } }
@@ -26,7 +26,9 @@
@@ -26,7 +26,9 @@
if (typeof document === 'undefined') return;
if (typeof document === 'undefined') return;
const scope = rootEl || document;
const scope = rootEl || document;
const all = (sel) => Array.prototype.slice.call(scope.querySelectorAll ? scope.querySelectorAll(sel) : []);
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.
// Copy buttons: shown only where the clipboard works; the icon turns into a check for a moment.
all('[data-ovk-copy]').forEach((b) => {
all('[data-ovk-copy]').forEach((b) => {
if (!once(b, 'ovkCopy') || !navigator.clipboard) return;
if (!once(b, 'ovkCopy') || !navigator.clipboard) return;
@@ -29,6 +29,11 @@
@@ -29,6 +29,11 @@
* kit.button({ href | type, label, icon, kind }) · kit.iconButton(…) · kit.chips(…) · kit.seg(…) · kit.icon(name)
* 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)
* 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
* 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.
* trusted markup (usually another builder's output). URLs are kept only when they are http(s) or same-site paths.
*/
*/
@@ -652,6 +657,105 @@
@@ -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>`;
+ `${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),
* 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.
* the content, a side panel beside it on a computer (under it on a phone), and the dock.
@@ -665,5 +769,6 @@
@@ -665,5 +769,6 @@
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: 2,
enhance, VERSION: 2,
num, steps, table, chart, rank, meter, drop, receipt, page,
num, steps, table, chart, rank, meter, drop, receipt, page,
+ toolHead, toolInput, tries, answer, family, tool,
};
};
});
});
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"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",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
new file mode 100644
new file mode 100644
@@ -0,0 +1,76 @@
@@ -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); });
@@ -194,4 +194,69 @@ const kit = require('../kit');
@@ -194,4 +194,69 @@ const kit = require('../kit');
assert.strictEqual(kit.VERSION, 2);
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 <tools>') && h.includes('<h1 class="ovk-tool-title">Whois <b></h1>'));
+ assert.ok(h.includes('<p class="ovk-tool-task">Who "registered" 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&c" data-ovk-name="q">a b&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"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 <flare></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');