openvibers/openvibe.shared Commits
kit-layout.css: the fluid scale, the layout primitives and a classless base (4.78.0) (#231)
Changes
@@@ -4,6 +4,19 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semvebreaking 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@@@ -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 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.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)",@@@ -655,6 +655,37 @@ OpenVibeLoading.skeleton(listEl, { rows: 3 }) // shimmering rows whilThe element it mounts into should be positioned. Motion stops under `prefers-reduced-motion`, and the status isannounced 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):**@@@ -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)nnew file mode 100644@@@ -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; }@@@ -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": {nnew file mode 100644@@@ -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); });
@@ -4,6 +4,19 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -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.
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).
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
## 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
- **Tabs below the fold no longer scroll the page to themselves.** kit-web.js kept the open tab in view with
@@ -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.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
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.77.1",
+ "version": "4.78.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)",
@@ -655,6 +655,37 @@ OpenVibeLoading.skeleton(listEl, { rows: 3 }) // shimmering rows whil
@@ -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
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).
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
## Using it
**An OpenVibe site (Node, server-rendered):**
**An OpenVibe site (Node, server-rendered):**
@@ -68,6 +68,7 @@ const STYLES = Object.freeze([
@@ -68,6 +68,7 @@ const STYLES = Object.freeze([
'player.css', // player.js's controls
'player.css', // player.js's controls
'kit.css', // the OpenVibe Kit's look (kit.js / openvibe-shared/kit build the markup; docs/kit.md)
'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)
'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)
'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)
'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)
'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)
new file mode 100644
new file mode 100644
@@ -0,0 +1,105 @@
@@ -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; }
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"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",
"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,98 @@
@@ -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); });