openvibers/openvibe.shared Commits

kit-layout.css: the fluid scale, the layout primitives and a classless base (4.78.0) (#231)

OP OpenVibers committed 12 days ago · 8255742cb2 · 8 files, +251 −3

Plan L54 K1/K2, opt-in after kit.css (2.5 KB brotli):
- the fluid scale: --step--2 … --step-5 and --space-3xs … --space-3xl by clamp(), 320 to 1240 px;
- eight primitives that lay out by their own width: ov-stack, ov-cluster, ov-sidebar, ov-switcher,
  ov-grid, ov-rail, ov-cover, ov-frame;
- .ov-base: plain HTML looks finished on the site's theme, every selector inside :where().

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex b16cceb..5831bd4 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,19 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
breaking change to any exported module, browser global or served file name is a new major.
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
++## 4.78.0 — 2026-11-01
++
++- **`kit-layout.css`: the fluid scale, the layout primitives and a classless base** (plan L54 K1/K2). A file of its
++ own, after `kit.css` (2.5 KB brotli); a page that uses none of it loads neither.
++ - The fluid scale: `--step--2` … `--step-5` for type and `--space-3xs` … `--space-3xl` for spacing, by `clamp()`
++ between 320 and 1240 px (16 → 18 px base, ratio 1.2 → 1.25). No breakpoints.
++ - Eight primitives that lay out by their own width: `.ov-stack`, `.ov-cluster`, `.ov-sidebar` (`--end`),
++ `.ov-switcher` (all side by side or all stacked, never two and one), `.ov-grid`, `.ov-rail`, `.ov-cover`,
++ `.ov-frame`. Set by `--gap`, `--min`, `--side`, `--item`, `--ratio`.
++ - `.ov-base`: plain HTML (headings, text, links, lists, quotes, code, tables, details, forms) looks finished on the
++ site's theme. Every selector is inside `:where()`, so a site's own rule always wins.
++ - Documented in docs/kit.md; `test/kit-layout-chrome.test.js` measures each in Chrome at 320, 1280 and 1920 px.
++
## 4.77.1 — 2026-11-01
- **Tabs below the fold no longer scroll the page to themselves.** kit-web.js kept the open tab in view with
ddiff --git a/README.md b/README.md
iindex c2beac5..7cf61e6 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.77.1 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.78.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 9b785b0..4d62cda 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.77.1",
++ "version": "4.78.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 1305c92..bb7da29 100644
---- a/docs/kit.md
++++ b/docs/kit.md
@@@ -655,6 +655,37 @@ OpenVibeLoading.skeleton(listEl, { rows: 3 }) // shimmering rows whil
The element it mounts into should be positioned. Motion stops under `prefers-reduced-motion`, and the status is
announced to screen readers (`role=status`, or `alert` when it fails).
++## Layout: the fluid scale, the primitives and the classless base (4.78.0)
++
++`kit-layout.css` is a file of its own (load it after `kit.css`, about 2.5 KB brotli); a page that uses none of it loads
++neither. It brings three things, on the same theme tokens as the rest of the kit.
++
++**The fluid scale.** Type steps and spaces grow with the screen between 320 and 1240 px by `clamp()`, with no
++breakpoints: the base is 16 px on a phone and 18 px on a wide screen, the ratio 1.2 then 1.25.
++
++| Tokens | What for |
++| --- | --- |
++| `--step--2` … `--step-5` | Font sizes: `--step-0` is body text, `--step-5` a page title |
++| `--space-3xs` … `--space-3xl` | Gaps and padding: ¼, ½, ¾, 1, 1½, 2, 3, 4 and 6 times the base |
++
++**The layout primitives.** Each is a few lines, set by custom properties, and lays out by its own width rather than
++the screen's, so it behaves the same in a sidebar, a card or a whole page.
++
++```html
++<div class="ov-stack" style="--gap: var(--space-m)">…</div> <!-- one under another -->
++<div class="ov-cluster">…tags or buttons…</div> <!-- a wrapping row -->
++<div class="ov-sidebar" style="--side: 16rem"><nav>…</nav><main>…</main></div> <!-- the side goes under when narrow -->
++<div class="ov-switcher" style="--min: 36rem">…three plans…</div> <!-- all side by side, or all stacked -->
++<div class="ov-grid" style="--min: 14rem">…cards…</div> <!-- as many columns as fit -->
++<div class="ov-rail" style="--item: 16rem">…cards…</div> <!-- one row that scrolls sideways -->
++<section class="ov-cover"><p>…</p><h1 class="is-centre">…</h1></section> <!-- a tall box, one thing centred -->
++<div class="ov-frame" style="--ratio: 4 / 3"><img …></div> <!-- a picture cropped to a ratio -->
++```
++
++**The classless base.** Put `.ov-base` on `<body>` or `<main>` and plain HTML looks finished: headings on the fluid
++steps, text kept to a readable measure, links, lists, quotes, code, tables that scroll in their own box, details, and
++form fields a finger tall. Every selector is inside `:where()`, so a site's own rule always wins.
++
## Using it
**An OpenVibe site (Node, server-rendered):**
ddiff --git a/files.js b/files.js
iindex b4dadce..8f6b922 100644
---- a/files.js
++++ b/files.js
@@@ -68,6 +68,7 @@ const STYLES = Object.freeze([
'player.css', // player.js's controls
'kit.css', // the OpenVibe Kit's look (kit.js / openvibe-shared/kit build the markup; docs/kit.md)
'room.css', // kit.card, kit.rail and a room's accent (4.29.1; load after kit.css where cards, rails or sc.room() are)
++ 'kit-layout.css', // the fluid scale, the layout primitives (.ov-stack, .ov-switcher…) and the opt-in .ov-base (4.78.0; after kit.css)
'kit-data.css', // Kit 2's look: steps, tables, charts, rankings, meters, drop zones, receipts (4.37.0; after kit.css)
'agent-session.css', // agent-session.js's look (4.32.0; the script links it from beside itself unless the page does)
'ov-glyphs-font.css', // the glyphs as a font: <i class="ovi ovi-eye"> (4.62.0; built with the font, scripts/build-glyph-fonts.py)
ddiff --git a/kit-layout.css b/kit-layout.css
nnew file mode 100644
iindex 0000000..4774b1a
---- /dev/null
++++ b/kit-layout.css
@@@ -0,0 +1,105 @@
++/* openvibe-shared/kit-layout.css: the Kit's fluid scale, its layout primitives and an opt-in classless base
++ * (plan L54 K1/K2). Load it after kit.css; a page that uses none of it loads neither this nor its bytes.
++ *
++ * The fluid scale (Utopia's idea): type steps and spaces that grow with the screen between 320 and 1240 px, by clamp()
++ * and with no breakpoints. Base 16 px at 320 and 18 px at 1240; ratio 1.2 then 1.25.
++ * --step--2 … --step-5 font sizes
++ * --space-3xs … --space-3xl gaps and padding (¼, ½, ¾, 1, 1½, 2, 3, 4 and 6 times the base)
++ *
++ * The primitives (Every Layout's idea): each a few lines, set by custom properties, and aware of their own width
++ * rather than the screen's, so they behave the same in a sidebar, a card or a whole page.
++ * .ov-stack children one under another, --gap apart
++ * .ov-cluster children in a wrapping row (tags, buttons, a toolbar); --gap, --justify, --align
++ * .ov-sidebar a side part --side wide beside the rest; it goes under once the rest would be under --min
++ * (the side part is the first child; .ov-sidebar--end when it is the last)
++ * .ov-switcher side by side until the box is narrower than --min, then all stacked: never two and one
++ * .ov-grid as many columns of at least --min as fit
++ * .ov-rail one row that scrolls sideways, items --item wide, snapping
++ * .ov-cover a box at least --min tall with one child (.is-centre) centred in it
++ * .ov-frame a box of --ratio that crops a picture or a video to fill it
++ *
++ * The classless base (Pico's idea): put .ov-base on <body> or <main> and plain HTML (headings, text, links, lists,
++ * quotes, code, tables, forms) looks finished on the site's theme, light or dark. Every selector is inside :where(),
++ * so it weighs nothing against a site's own rules.
++ */
++:root {
++ --step--2: clamp(0.6944rem, 0.6856rem + 0.0444vw, 0.72rem);
++ --step--1: clamp(0.8333rem, 0.8101rem + 0.1159vw, 0.9rem);
++ --step-0: clamp(1rem, 0.9565rem + 0.2174vw, 1.125rem);
++ --step-1: clamp(1.2rem, 1.1283rem + 0.3587vw, 1.4063rem);
++ --step-2: clamp(1.44rem, 1.3295rem + 0.5527vw, 1.7578rem);
++ --step-3: clamp(1.728rem, 1.5648rem + 0.8161vw, 2.1973rem);
++ --step-4: clamp(2.0736rem, 1.8395rem + 1.1704vw, 2.7466rem);
++ --step-5: clamp(2.4883rem, 2.1597rem + 1.6433vw, 3.4332rem);
++ --space-3xs: clamp(0.25rem, 0.2391rem + 0.0543vw, 0.2813rem);
++ --space-2xs: clamp(0.5rem, 0.4783rem + 0.1087vw, 0.5625rem);
++ --space-xs: clamp(0.75rem, 0.7174rem + 0.163vw, 0.8438rem);
++ --space-s: clamp(1rem, 0.9565rem + 0.2174vw, 1.125rem);
++ --space-m: clamp(1.5rem, 1.4348rem + 0.3261vw, 1.6875rem);
++ --space-l: clamp(2rem, 1.913rem + 0.4348vw, 2.25rem);
++ --space-xl: clamp(3rem, 2.8696rem + 0.6522vw, 3.375rem);
++ --space-2xl: clamp(4rem, 3.8261rem + 0.8696vw, 4.5rem);
++ --space-3xl: clamp(6rem, 5.7391rem + 1.3043vw, 6.75rem);
++}
++
++/* ── Primitives ── */
++.ov-stack { display: flex; flex-direction: column; justify-content: flex-start; gap: var(--gap, var(--space-s)); }
++.ov-stack > * { margin-block: 0; }
++.ov-cluster { display: flex; flex-wrap: wrap; gap: var(--gap, var(--space-xs)); justify-content: var(--justify, flex-start); align-items: var(--align, center); }
++.ov-sidebar { display: flex; flex-wrap: wrap; gap: var(--gap, var(--space-m)); align-items: var(--align, flex-start); }
++.ov-sidebar > :first-child, .ov-sidebar--end > :last-child { flex-basis: var(--side, 18rem); flex-grow: 1; }
++.ov-sidebar > :last-child, .ov-sidebar--end > :first-child { flex-basis: 0; flex-grow: 999; min-inline-size: var(--min, 55%); }
++.ov-sidebar--end > :first-child { order: 0; }
++.ov-switcher { display: flex; flex-wrap: wrap; gap: var(--gap, var(--space-s)); }
++.ov-switcher > * { flex-grow: 1; flex-basis: calc((var(--min, 32rem) - 100%) * 999); min-inline-size: 0; }
++.ov-grid { display: grid; gap: var(--gap, var(--space-s)); grid-template-columns: repeat(auto-fill, minmax(min(100%, var(--min, 16rem)), 1fr)); }
++.ov-rail { display: grid; grid-auto-flow: column; grid-auto-columns: var(--item, min(80%, 18rem)); gap: var(--gap, var(--space-s)); overflow-x: auto; overscroll-behavior-x: contain;
++ scroll-snap-type: x mandatory; scroll-padding-inline: var(--pad, 0px); padding-inline: var(--pad, 0px); padding-block-end: var(--space-2xs); scrollbar-width: thin; }
++.ov-rail > * { scroll-snap-align: start; min-inline-size: 0; }
++.ov-cover { display: flex; flex-direction: column; min-block-size: var(--min, 100svh); padding: var(--pad, var(--space-m)); box-sizing: border-box; }
++.ov-cover > * { margin-block: var(--gap, var(--space-s)); }
++.ov-cover > :first-child:not(.is-centre) { margin-block-start: 0; }
++.ov-cover > :last-child:not(.is-centre) { margin-block-end: 0; }
++.ov-cover > .is-centre { margin-block: auto; }
++.ov-frame { aspect-ratio: var(--ratio, 16 / 9); overflow: hidden; display: flex; justify-content: center; align-items: center; max-inline-size: 100%; }
++.ov-frame > :is(img, video, iframe, canvas, svg) { inline-size: 100%; block-size: 100%; object-fit: cover; }
++
++/* ── The classless base, under .ov-base ── */
++:where(.ov-base) { color: var(--ovk-text); font-family: var(--ovk-font); font-size: var(--step-0); line-height: 1.6; -webkit-text-size-adjust: 100%; }
++:where(.ov-base) :where(h1, h2, h3, h4, h5, h6) { margin: 1.4em 0 .5em; line-height: 1.15; letter-spacing: -.015em; text-wrap: balance; color: var(--ovk-text); }
++:where(.ov-base) :where(h1) { font-size: var(--step-5); letter-spacing: -.03em; margin-block-start: 0; }
++:where(.ov-base) :where(h2) { font-size: var(--step-3); }
++:where(.ov-base) :where(h3) { font-size: var(--step-2); }
++:where(.ov-base) :where(h4) { font-size: var(--step-1); }
++:where(.ov-base) :where(h5, h6) { font-size: var(--step-0); }
++:where(.ov-base) :where(p, ul, ol, dl, blockquote, pre, table, figure, details) { margin: 0 0 1em; }
++:where(.ov-base) :where(p, li, dd, blockquote) { max-inline-size: 68ch; }
++:where(.ov-base) :where(small) { font-size: var(--step--1); color: var(--ovk-muted); }
++:where(.ov-base) :where(a) { color: var(--ovk-accent); text-decoration-thickness: 1px; text-underline-offset: .18em; }
++:where(.ov-base) :where(a:hover) { text-decoration-thickness: 2px; }
++:where(.ov-base) :where(a, button, input, select, textarea, summary):focus-visible { outline: 2px solid var(--ovk-accent); outline-offset: 2px; }
++:where(.ov-base) :where(ul, ol) { padding-inline-start: 1.3em; }
++:where(.ov-base) :where(li + li) { margin-block-start: .3em; }
++:where(.ov-base) :where(hr) { border: 0; border-block-start: 1px solid var(--ovk-line); margin: var(--space-l) 0; }
++:where(.ov-base) :where(blockquote) { padding: .2em 0 .2em 1em; border-inline-start: 3px solid var(--ovk-accent); color: var(--ovk-muted); }
++:where(.ov-base) :where(code, kbd, samp, pre) { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .88em; }
++:where(.ov-base) :where(:not(pre) > code, kbd) { padding: .1em .35em; border-radius: 6px; background: var(--ovk-panel-2); }
++:where(.ov-base) :where(pre) { padding: var(--space-xs) var(--space-s); overflow-x: auto; border: 1px solid var(--ovk-line); border-radius: var(--ovk-radius-sm); background: var(--ovk-panel); line-height: 1.5; }
++:where(.ov-base) :where(img, video, svg, canvas, iframe) { max-inline-size: 100%; }
++:where(.ov-base) :where(img, video) { block-size: auto; border-radius: var(--ovk-radius-sm); }
++:where(.ov-base) :where(table) { inline-size: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; display: block; overflow-x: auto; }
++:where(.ov-base) :where(th, td) { padding: .55em .7em; border-block-end: 1px solid var(--ovk-line); text-align: start; vertical-align: top; }
++:where(.ov-base) :where(th) { font-size: var(--step--1); font-weight: 700; letter-spacing: .03em; color: var(--ovk-muted); }
++:where(.ov-base) :where(details) { padding: .6em .9em; border: 1px solid var(--ovk-line); border-radius: var(--ovk-radius-sm); background: var(--ovk-panel); }
++:where(.ov-base) :where(summary) { cursor: pointer; font-weight: 700; }
++:where(.ov-base) :where(label) { display: block; margin-block-end: .3em; font-weight: 600; }
++:where(.ov-base) :where(input:not([type=checkbox], [type=radio], [type=range], [type=color]), select, textarea) {
++ box-sizing: border-box; inline-size: 100%; min-block-size: var(--ovk-tap); padding: .5em .8em; border: 1px solid var(--ovk-line); border-radius: var(--ovk-radius-sm);
++ background: var(--ovk-input); color: var(--ovk-text); font: inherit; }
++:where(.ov-base) :where(textarea) { min-block-size: 6em; resize: vertical; }
++:where(.ov-base) :where(input[type=checkbox], input[type=radio], input[type=range]) { accent-color: var(--ovk-fill); }
++:where(.ov-base) :where(button, input[type=submit], input[type=button]) { min-block-size: var(--ovk-tap); padding: .5em 1.1em; border: 0; border-radius: 999px; cursor: pointer;
++ background: var(--ovk-fill); color: var(--ovk-on-fill); font: inherit; font-weight: 700; }
++:where(.ov-base) :where(button:disabled, input:disabled, select:disabled, textarea:disabled) { opacity: .55; cursor: not-allowed; }
++:where(.ov-base) :where(fieldset) { margin: 0 0 1em; padding: var(--space-xs) var(--space-s); border: 1px solid var(--ovk-line); border-radius: var(--ovk-radius-sm); }
++:where(.ov-base) :where(mark) { padding: 0 .2em; border-radius: 4px; background: color-mix(in srgb, var(--ovk-warn) 35%, transparent); color: inherit; }
ddiff --git a/package.json b/package.json
iindex 9118c32..b644640 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.77.1",
++ "version": "4.78.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-layout-chrome.test.js b/test/kit-layout-chrome.test.js
nnew file mode 100644
iindex 0000000..d37a962
---- /dev/null
++++ b/test/kit-layout-chrome.test.js
@@@ -0,0 +1,98 @@
++'use strict';
++// kit-layout.css in a real headless Chrome (4.78.0, plan L54 K1/K2): the fluid scale grows with the screen and stops at
++// its ends; each primitive lays out by its own width (a switcher is all side by side or all stacked, never two and one;
++// a sidebar's side goes under when the rest would be too narrow; a grid fits columns of --min; a rail scrolls); and the
++// classless base finishes plain HTML under .ov-base while a site's own rule still wins. 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');
++
++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {
++ console.log('kit-layout-chrome: skipped (no Chrome; set CHROME_BIN)');
++ process.exit(0);
++}
++
++const ROOT = path.join(__dirname, '..');
++const box = (n, w = '') => Array.from({ length: n }, (_, i) => `<div class="b" style="${w}">${i + 1}</div>`).join('');
++const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Layout</title><link rel="icon" href="data:,">
++<link rel="stylesheet" href="/kit.css"><link rel="stylesheet" href="/kit-layout.css">
++<style>body{margin:0}.b{min-height:20px;background:#345}.site-h2{color:rgb(255,0,0)}</style></head>
++<body>
++<div id="sw" class="ov-switcher" style="--min:600px">${box(3)}</div>
++<div id="sb" class="ov-sidebar" style="--side:200px;--min:60%"><aside class="b">side</aside><div class="b">rest</div></div>
++<div id="gr" class="ov-grid" style="--min:180px;--gap:0px">${box(8)}</div>
++<div id="ra" class="ov-rail" style="--item:200px">${box(8)}</div>
++<div id="co" class="ov-cover" style="--min:400px"><p>top</p><h1 class="is-centre">centred</h1><p>bottom</p></div>
++<div id="fr" class="ov-frame" style="--ratio:2/1;width:300px"><svg viewBox="0 0 10 10"></svg></div>
++<main class="ov-base"><h1 id="h1">A heading</h1><h2 id="h2" class="site-h2">A site's own colour</h2><p id="p">Text <a id="a" href="#">a link</a></p>
++<table id="t"><tr><th>A</th><td>1</td></tr></table><input id="in"><button id="bt">Go</button></main>
++</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.css' || u.pathname === '/kit-layout.css') { res.writeHead(200, { 'content-type': 'text/css' }); return res.end(fs.readFileSync(path.join(ROOT, u.pathname.slice(1)))); }
++ res.writeHead(404); return res.end();
++});
++
++const MEASURE = `(() => {
++ const r = (s) => document.querySelector(s).getBoundingClientRect();
++ const tops = (s) => [...document.querySelectorAll(s + ' > *')].map((e) => Math.round(e.getBoundingClientRect().top));
++ const cs = (s) => getComputedStyle(document.querySelector(s));
++ const co = r('#co'), mid = r('#co .is-centre');
++ return {
++ step0: parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--step-0')) || parseFloat(cs('#p').fontSize),
++ p: parseFloat(cs('#p').fontSize), h1: parseFloat(cs('#h1').fontSize),
++ sw: new Set(tops('#sw')).size,
++ sb: new Set(tops('#sb')).size,
++ cols: new Set([...document.querySelectorAll('#gr > *')].map((e) => Math.round(e.getBoundingClientRect().left))).size,
++ rail: (() => { const e = document.querySelector('#ra'); return e.scrollWidth > e.clientWidth; })(),
++ centred: Math.abs((mid.top + mid.height / 2) - (co.top + co.height / 2)) < 30,
++ frame: Math.round(r('#fr').height),
++ h2: cs('#h2').color, link: cs('#a').textDecorationLine, table: cs('#t').display,
++ input: Math.round(r('#in').height), btnRadius: cs('#bt').borderRadius,
++ over: document.documentElement.scrollWidth - innerWidth,
++ };
++})()`;
++
++(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-kitlayout-'));
++ const browser = await h.launch({ tmpDir });
++ try {
++ const context = await browser.newContext();
++ const at = {};
++ for (const w of [320, 1280, 1920]) {
++ const p = await h.openPage(browser, context, { width: w });
++ await p.goto(`${base}/`, { maxMs: 5000 });
++ at[w] = await p.evaluate(MEASURE);
++ await p.close();
++ }
++ const [phone, wide, wider] = [at[320], at[1280], at[1920]];
++ assert.strictEqual(phone.p, 16, 'the base step is 16 px on a 320 px screen');
++ assert.strictEqual(wider.p, 18, 'and stops at 18 px');
++ assert.ok(wide.h1 > phone.h1 && wide.h1 <= wider.h1, `headings grow with the screen (${phone.h1} → ${wide.h1})`);
++ assert.strictEqual(phone.sw, 3, 'a switcher narrower than --min stacks every child');
++ assert.strictEqual(wide.sw, 1, 'and puts them all side by side when it fits: never two and one');
++ assert.strictEqual(phone.sb, 2, 'a sidebar goes under when the rest would be too narrow');
++ assert.strictEqual(wide.sb, 1, 'and sits beside it when there is room');
++ assert.strictEqual(phone.cols, 1, 'a grid of 180 px columns: one on a phone');
++ assert.strictEqual(wide.cols, 7, 'seven across 1280 px');
++ assert.ok(phone.rail && wide.rail, 'a rail of eight 200 px items scrolls sideways');
++ assert.ok(phone.centred && wide.centred, 'a cover centres its .is-centre child');
++ assert.strictEqual(wide.frame, 150, 'a frame keeps its ratio');
++ assert.strictEqual(wide.h2, 'rgb(255, 0, 0)', 'the base weighs nothing: a site\'s own class wins');
++ assert.strictEqual(wide.link, 'underline');
++ assert.strictEqual(wide.table, 'block', 'a wide table scrolls in its own box');
++ assert.ok(wide.input >= 44, 'fields are a finger tall');
++ assert.notStrictEqual(wide.btnRadius, '0px', 'buttons are finished');
++ assert.strictEqual(phone.over, 0, 'nothing pushes a 320 px page sideways');
++ } finally {
++ await browser.close();
++ server.close();
++ }
++ console.log('kit-layout-chrome: all checks passed');
++})().catch((err) => { console.error(err); process.exit(1); });