openvibers/openvibe.shared Commits

A tool states its terms: kit.toolHead({ facts }) (4.56.0) (#204)

OP OpenVibers committed 13 days ago · ecc5637788 · 8 files, +38 −10

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 4b379f3..d3ea755 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -6,6 +6,18 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
++## 4.56.0 — 2026-10-30
++
++- **A tool states its terms.** `kit.toolHead({ facts })` adds one short line under the tool's sentence:
++ - what it costs;
++ - where the work happens ("Runs in your browser: nothing is uploaded", or "Your file goes to our server and is
++ deleted after 1 hour");
++ - its limits.
++
++ This is the first outcome of the market research for tool sites, where people's top complaint is limits they are
++ never told. Facts are escaped text, or `{ text, icon }`; blanks are left out. docs/kit.md says to state them from
++ the tool's real configuration, never from a guess.
++
## 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`,
ddiff --git a/README.md b/README.md
iindex f603a46..0afa6d2 100644
---- a/README.md
++++ b/README.md
@@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
--**Status:** alpha, v4.55.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.56.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex 4a7fcb7..7f1d429 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.55.0",
++ "version": "4.56.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
ddiff --git a/docs/kit.md b/docs/kit.md
iindex d84e303..6a8b84e 100644
---- a/docs/kit.md
++++ b/docs/kit.md
@@@ -323,7 +323,7 @@ Buttons and links keep the site's own accent, so a tool looks like the site it i
| 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 |
++| `toolHead({ icon, iconHtml, hue, family, crumb: { href, label }, title, task, facts, 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. `facts` (4.56.0) is the tool's terms in one short line: what it costs, where the work happens, its limits and how long a file is kept (`['Free', 'Runs in your browser: nothing is uploaded', 'Files up to 50 MB']`). Say them from the tool's real configuration, never from a guess |
| `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 |
ddiff --git a/kit-data.css b/kit-data.css
iindex fcde9b2..a11f8ba 100644
---- a/kit-data.css
++++ b/kit-data.css
@@@ -189,6 +189,10 @@
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-facts { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 4px 0; margin: 8px 0 0; padding: 0; list-style: none; color: var(--ovk-muted); font-size: .8rem; line-height: 1.4; }
++.ovk-tool-facts > li { display: inline-flex; align-items: center; gap: 5px; }
++.ovk-tool-facts > li + li::before { content: '·'; margin: 0 8px; opacity: .7; }
++.ovk-tool-facts > li > .ovk-i { width: 1em; height: 1em; }
.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; }
ddiff --git a/kit.js b/kit.js
iindex 169118e..39254c6 100644
---- a/kit.js
++++ b/kit.js
@@@ -29,7 +29,7 @@
* 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 }),
++ * The tool frame, one order for every tool page: kit.tool({ head: kit.toolHead({ icon, hue, crumb, title, task, facts }),
* 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(…) })
@@@ -661,20 +661,29 @@
// 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".
++ /** The tool's terms as a short list: text, or { text, icon }; blanks are left out. */
++ function factsList(facts) {
++ const fs = (Array.isArray(facts) ? facts : [facts]).map((f) => (f && typeof f === 'object' ? f : { text: f })).filter((f) => f.text != null && String(f.text).trim());
++ return fs.length ? `<ul class="ovk-tool-facts">${fs.map((f) => `<li>${f.icon ? icon(f.icon) : ''}${esc(f.text)}</li>`).join('')}</ul>` : '';
++ }
/** 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.
++ * toolHead({ icon, iconHtml, hue, family, crumb: { href, label }, title, task, facts, 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. facts: the plain terms, in a short line under the sentence ('Free',
++ * 'Runs in your browser: nothing is uploaded', 'Files up to 50 MB', 'Deleted after 1 hour'); the market research's
++ * first ask of a tool page is to say its limits, where the work happens and what it costs (4.56.0). A fact is text,
++ * or { text, icon } with a kit icon name.
*/
-- function toolHead({ icon: ic = 'grid', iconHtml = null, hue = null, family: fam = '', crumb = null, title = '', task = '', level = 1 } = {}) {
++ function toolHead({ icon: ic = 'grid', iconHtml = null, hue = null, family: fam = '', crumb = null, title = '', task = '', facts = [], 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>`;
++ + `<h${lv} class="ovk-tool-title">${esc(title)}</h${lv}>${task ? `<p class="ovk-tool-task">${esc(task)}</p>` : ''}`
++ + factsList(facts) + '</header>';
}
/**
ddiff --git a/package.json b/package.json
iindex 5874962..00854cb 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.55.0",
++ "version": "4.56.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
ddiff --git a/test/kit.test.js b/test/kit.test.js
iindex cafa532..31bd8f7 100644
---- a/test/kit.test.js
++++ b/test/kit.test.js
@@@ -206,6 +206,9 @@ const kit = require('../kit');
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 fh = kit.toolHead({ title: 'PNG Converter', facts: ['Free', { text: 'Runs in your browser: nothing is uploaded', icon: 'lock' }, '', null, 'Files up to <50> MB'] });
++ assert.ok(fh.includes('<ul class="ovk-tool-facts"><li>Free</li><li><svg class="ovk-i"') && fh.includes('Runs in your browser: nothing is uploaded</li><li>Files up to &lt;50&gt; MB</li></ul></header>'), 'the terms: escaped, blanks left out, an icon when asked');
++ assert.ok(!kit.toolHead({ title: 'x' }).includes('ovk-tool-facts'), 'no terms, no list');
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>'));