openvibers/openvibe.shared Commits
A tool states its terms: kit.toolHead({ facts }) (4.56.0) (#204)
Changes
@@@ -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`,@@@ -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 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.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)",@@@ -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 |@@@ -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; }@@@ -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>';}/**@@@ -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": {@@@ -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 <50> 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>'));
@@ -6,6 +6,18 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
@@ -6,6 +6,18 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
## Unreleased
+## 4.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
## 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`,
- **The tool frame: one order for every tool page** (owner ask 11). It has six new kit builders: `kit.toolHead`,
@@ -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.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
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.55.0",
+ "version": "4.56.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)",
@@ -323,7 +323,7 @@ Buttons and links keep the site's own accent, so a tool looks like the site it i
@@ -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 |
| 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 |
| `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 |
| `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 |
| `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 |
@@ -189,6 +189,10 @@
@@ -189,6 +189,10 @@
a.ovk-tool-crumb:hover { color: var(--ovk-text); }
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-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-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 { 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-card > .ovk-drop, .ovk-tool-card > form > .ovk-drop { margin: 0; }
@@ -29,7 +29,7 @@
@@ -29,7 +29,7 @@
* 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 }),
+ * 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,
* 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 }),
* answer: kit.answer({ title, badge, rows: [[label, value, note]], next }), family: kit.family({ title, more, items }),
* folds: [kit.fold(…)], related: kit.list(…) })
* folds: [kit.fold(…)], related: kit.list(…) })
@@ -661,20 +661,29 @@
@@ -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
// 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".
// 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. */
/** 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 || ''));
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 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>`) : '';
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)}">`
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}`
+ `<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>';
}
}
/**
/**
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"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",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -206,6 +206,9 @@ const kit = require('../kit');
@@ -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: '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', 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">'));
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 <50> 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] });
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.startsWith('<p class="ovk-try"><span>Try</span>'));