openvibers/openvibe.shared Commits

4.20.1: the kit's behaviour is its own small file (kit-web.js, 7 KB), so a server-rendered site loads only that and not every builder and icon (#144)

OP OpenVibers committed 16 days ago · f4310a8dc0 · 10 files, +156 −104

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 1dd8c06..f38af65 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -6,6 +6,15 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
++## 4.20.1 — 2026-10-28
++
++- **The kit's behaviour is its own small file, `kit-web.js`** (about 7 KB, 2 KB compressed): copy buttons, menus that
++ close, Enter to send, confirms, sliders and sheets.
++ - A server-rendered site loads only `kit-web.js`. Its markup comes from `kit.js`'s builders on the server.
++ - A page that draws itself loads `kit.js` too; `OpenVibeKit.enhance()` now calls `OpenVibeKitWeb.enhance()`.
++ - Loading all of `kit.js` (33 KB) on every page pushed a site's home over its JavaScript budget (Coupons: 328 KB raw
++ against 292).
++
## 4.20.0 — 2026-10-28
- **The OpenVibe Kit** (`kit.js`, `kit.css`; `require('openvibe-shared/kit')`, `window.OpenVibeKit`). One set of app
ddiff --git a/README.md b/README.md
iindex acb9bb7..b66ae56 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.20.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.20.1 (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.
@@@ -619,7 +619,8 @@ openvibe-shared/shell".
The pieces an app page is made of, one look on every site, phone first: a sticky head with its tabs, lists of rows,
cards that fold, settings rows, a search row, a composer pinned at the bottom, and a bottom tab bar on phones
(`appBar` on the navbar). The same builders run on the server (`require('openvibe-shared/kit')`) and in the page
--(`window.OpenVibeKit`); text is escaped, and every piece works without JavaScript. [docs/kit.md](docs/kit.md) has every
++(`window.OpenVibeKit`); text is escaped, and every piece works without JavaScript. `kit-web.js` is the behaviour alone
++(about 7 KB), all a server-rendered page loads. [docs/kit.md](docs/kit.md) has every
piece, the sizes, the colours, and how to use it in an OpenVibe site or anyone's project;
`node scripts/kit-gallery.js --shots <dir>` shows them all at 390, 768 and 1280 px.
ddiff --git a/STATUS.json b/STATUS.json
iindex 709acbf..4831e93 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.20.0",
++ "version": "4.20.1",
"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 d17de02..bcfeecd 100644
---- a/docs/kit.md
++++ b/docs/kit.md
@@@ -5,12 +5,14 @@ cards that fold, settings rows, a search row, a composer pinned at the bottom, a
pieces are built on the server or in the page with the same functions. They look the same on every site, in every
theme, and they fit a phone first.
--- **`kit.js`**: the builders (`require('openvibe-shared/kit')` on the server, `window.OpenVibeKit` in the page) and
-- the behaviour (`OpenVibeKit.enhance()`, which runs by itself in the page).
++- **`kit.js`**: the builders (`require('openvibe-shared/kit')` on the server, `window.OpenVibeKit` in the page).
++- **`kit-web.js`**: the behaviour in the page (copy buttons, menus that close, Enter to send, confirms, sheets), about
++ 7 KB. It runs by itself, and again on new markup through `OpenVibeKit.enhance(container)`. A server-rendered site
++ loads only this one.
- **`kit.css`**: the look, built on the site's theme tokens.
- **`appbar.js`**: the phone tab bar. `navbar.js` loads it only for a site that sets `appBar`.
--The package is MIT. Every site serves its own pinned copy at `/shared/kit.css`, `/shared/kit.js` and
++The package is MIT. Every site serves its own pinned copy at `/shared/kit.css`, `/shared/kit.js`, `/shared/kit-web.js` and
`/shared/appbar.js` (`openvibe-shared/serve`), so a page keeps working when another site is down.
## Why
@@@ -218,20 +220,21 @@ the site's main verb (go live, post, ask). Settings and the rest live in More.
```js
const kit = require('openvibe-shared/kit');
const serve = require('openvibe-shared/serve');
--// <link rel="stylesheet" href="${serve.url('kit.css')}"> <script src="${serve.url('kit.js')}" defer></script>
++// <link rel="stylesheet" href="${serve.url('kit.css')}"> <script src="${serve.url('kit-web.js')}" defer></script>
res.send(layout({ body: kit.head({ title: 'Rooms', actions: [kit.iconButton({ href: '/rooms?new=1', icon: 'plus', label: 'Start a room' })] })
+ kit.list(rooms.map((r) => kit.row({ href: `/r/${r.slug}`, mark: kit.mark({ name: r.name }), title: r.name, sub: r.topic }))) }));
```
--**A site that draws its own pages (Live's single-page app):** load `/shared/kit.js` and call the same builders on
--`window.OpenVibeKit`. After inserting markup, call `OpenVibeKit.enhance(container)`.
++**A site that draws its own pages (Live's single-page app):** load `/shared/kit.js` and `/shared/kit-web.js`, and call
++the same builders on `window.OpenVibeKit`. After inserting markup, call `OpenVibeKit.enhance(container)`.
**An app built on the OpenVibe SDK:** the Frame (`openvibe-sdk/frame`) loads the kit with the navbar and footer:
`mountFrame({ …, kit: true, appBar: true })` in the page, or `frameTags({ …, kit: true })` for a server-rendered
page. The files come from openvibe.network's copy of this release.
**Anyone else's project:** install the release (`"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.20.0"`)
--and serve `kit.css` and `kit.js` from it, or copy the two files: they need nothing else. Without OpenVibe's theme,
++and serve `kit.css`, `kit-web.js` (and `kit.js` to build in the page) from it, or copy the files: they need nothing
++else. Without OpenVibe's theme,
set the `--bg-*`, `--text-*` and `--accent` variables yourself, or keep the fallbacks.
## Seeing it
ddiff --git a/files.js b/files.js
iindex 845ee8b..d65376c 100644
---- a/files.js
++++ b/files.js
@@@ -39,7 +39,8 @@ const BROWSER = Object.freeze([
'release-update.js', // plans a new release and applies style/content in place; release-watch loads it on demand
'web-runtime.js', // feature loader and route lifecycle (OVWebRuntime), from Live's ov-loader
'appbar.js', // the phone app bar; navbar.js loads it only for a site that sets appBar
-- 'kit.js', // the OpenVibe Kit: app pieces (head, tabs, rows, folds, dock…) built in the page, and their behaviour
++ 'kit.js', // the OpenVibe Kit's builders in the page (for pages that draw themselves; with kit-web.js)
++ 'kit-web.js', // the OpenVibe Kit's behaviour (copy, menus, Enter to send, confirms, sheets): all a server-rendered site loads
'openvibe-sw.js', // Web Push service worker
]);
ddiff --git a/kit-web.js b/kit-web.js
nnew file mode 100644
iindex 0000000..ad5f489
---- /dev/null
++++ b/kit-web.js
@@@ -0,0 +1,112 @@
++/**
++ * openvibe-shared/kit-web.js — the OpenVibe Kit's behaviour in the page, on its own: what a server-rendered site needs
++ * (kit.js's builders already made its markup on the server). About 5 KB; it runs by itself when it loads and again for
++ * markup added later through OpenVibeKitWeb.enhance(container) (kit.js's OpenVibeKit.enhance() calls it too).
++ *
++ * copy buttons [data-ovk-copy="#id"] are shown where the clipboard works and copy the target's text
++ * menus details.ovk-more: one open at a time; a click elsewhere or Escape closes them
++ * the head html.ovk-scrolled once the page scrolls (the sticky head's shadow)
++ * the composer textarea[data-ovk-enter]: Enter sends (Shift+Enter, or a phone's own Enter, is a new line); it grows
++ * confirms form[data-ovk-confirm]: asked first (OpenVibeUI.confirm when the page has it)
++ * sliders input[type=range][data-ovk-unit] say their value
++ * sheets [data-ovk-sheet="#id"] opens dialog.ovk-sheet; the backdrop, Close and Escape close it
++ * tabs a sideways-scrolling tab strip keeps the open tab in view
++ *
++ * Idempotent and safe on a page that uses none of it. docs/kit.md describes the pieces.
++ */
++(function (root) {
++ 'use strict';
++ const CHECK = '<svg class="ovk-i" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>';
++
++ function enhance(rootEl) {
++ if (typeof document === 'undefined') return;
++ const scope = rootEl || document;
++ const all = (sel) => Array.prototype.slice.call(scope.querySelectorAll ? scope.querySelectorAll(sel) : []);
++ const once = (el, key) => { if (el.dataset[key]) return false; el.dataset[key] = '1'; return true; };
++ // Copy buttons: shown only where the clipboard works; the icon turns into a check for a moment.
++ all('[data-ovk-copy]').forEach((b) => {
++ if (!once(b, 'ovkCopy') || !navigator.clipboard) return;
++ b.hidden = false;
++ b.addEventListener('click', () => {
++ const t = document.querySelector(b.getAttribute('data-ovk-copy'));
++ if (!t) return;
++ navigator.clipboard.writeText(t.value != null && t.tagName !== 'PRE' ? t.value : t.textContent).then(() => {
++ const was = b.innerHTML; b.innerHTML = CHECK; b.classList.add('is-done');
++ setTimeout(() => { b.innerHTML = was; b.classList.remove('is-done'); }, 1400);
++ }, () => {});
++ });
++ });
++ // Menus: one open at a time; a click elsewhere or Escape closes them.
++ all('details.ovk-more').forEach((d) => {
++ if (!once(d, 'ovkMore')) return;
++ d.addEventListener('toggle', () => { if (d.open) all('details.ovk-more[open]').forEach((o) => { if (o !== d) o.open = false; }); });
++ });
++ if (!document.documentElement.dataset.ovkDoc) {
++ document.documentElement.dataset.ovkDoc = '1';
++ document.addEventListener('click', (e) => {
++ Array.prototype.forEach.call(document.querySelectorAll('details.ovk-more[open]'), (d) => { if (!d.contains(e.target)) d.open = false; });
++ });
++ document.addEventListener('keydown', (e) => {
++ if (e.key !== 'Escape') return;
++ Array.prototype.forEach.call(document.querySelectorAll('details.ovk-more[open]'), (d) => { d.open = false; const s = d.querySelector('summary'); if (s) s.focus(); });
++ });
++ // The head casts a shadow once the page has scrolled under it.
++ let ticking = false;
++ const mark = () => { ticking = false; document.documentElement.classList.toggle('ovk-scrolled', (window.scrollY || 0) > 4); };
++ window.addEventListener('scroll', () => { if (!ticking) { ticking = true; requestAnimationFrame(mark); } }, { passive: true });
++ mark();
++ }
++ // The composer: Enter sends (Shift+Enter, or a phone's own Enter key, is a new line); the box grows with the text.
++ const coarse = typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches;
++ all('textarea[data-ovk-enter]').forEach((ta) => {
++ if (!once(ta, 'ovkEnter')) return;
++ const grow = () => { if (CSS && CSS.supports && CSS.supports('field-sizing', 'content')) return; ta.style.height = 'auto'; ta.style.height = `${Math.min(ta.scrollHeight, Math.round(innerHeight * 0.4))}px`; };
++ ta.addEventListener('input', grow);
++ ta.addEventListener('keydown', (e) => {
++ if (e.key !== 'Enter' || e.shiftKey || e.isComposing || coarse) return;
++ e.preventDefault();
++ const f = ta.form; if (f) { if (f.requestSubmit) f.requestSubmit(); else f.submit(); }
++ });
++ });
++ // A form that asks first (delete, leave): the network's confirm dialog when the page has it.
++ all('form[data-ovk-confirm]').forEach((f) => {
++ if (!once(f, 'ovkConfirm')) return;
++ f.addEventListener('submit', (e) => {
++ if (f.dataset.ovkConfirmed) { delete f.dataset.ovkConfirmed; return; }
++ e.preventDefault();
++ const msg = f.getAttribute('data-ovk-confirm');
++ const ask = window.OpenVibeUI && window.OpenVibeUI.confirm ? window.OpenVibeUI.confirm({ title: msg, danger: true }) : Promise.resolve(window.confirm(msg));
++ ask.then((ok) => { if (ok) { f.dataset.ovkConfirmed = '1'; if (f.requestSubmit) f.requestSubmit(e.submitter || undefined); else f.submit(); } });
++ });
++ });
++ // Sliders say their value.
++ all('input[type=range][data-ovk-unit]').forEach((r) => {
++ if (!once(r, 'ovkRange')) return;
++ const u = r.getAttribute('data-ovk-unit');
++ r.addEventListener('input', () => r.setAttribute('aria-valuetext', `${r.value}${u === '%' ? ' percent' : ` ${u}`}`));
++ });
++ // Sheets: a button opens its dialog; the backdrop, Close and Escape close it.
++ all('[data-ovk-sheet]').forEach((b) => {
++ if (!once(b, 'ovkSheet')) return;
++ b.addEventListener('click', () => {
++ const d = document.querySelector(b.getAttribute('data-ovk-sheet'));
++ if (d && d.showModal && !d.open) d.showModal();
++ });
++ });
++ all('dialog.ovk-sheet').forEach((d) => {
++ if (!once(d, 'ovkDialog')) return;
++ d.addEventListener('click', (e) => { if (e.target === d || (e.target.closest && e.target.closest('[data-ovk-close]'))) d.close(); });
++ });
++ // Tabs that scroll sideways keep the open one in view.
++ all('.ovk-tabs [aria-current="page"]').forEach((a) => { if (once(a, 'ovkTab') && a.scrollIntoView) a.scrollIntoView({ block: 'nearest', inline: 'center' }); });
++ }
++
++
++ const api = { enhance };
++ root.OpenVibeKitWeb = api;
++ if (typeof module === 'object' && module.exports) { module.exports = api; return; }
++ if (typeof document !== 'undefined') {
++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => enhance(document));
++ else enhance(document);
++ }
++})(typeof globalThis !== 'undefined' ? globalThis : this);
ddiff --git a/kit.js b/kit.js
iindex b6fdec6..1996f9f 100644
---- a/kit.js
++++ b/kit.js
@@@ -10,8 +10,9 @@
*
* The same file runs on the server (require('openvibe-shared/kit'): builders return HTML strings, every text escaped,
* so pages are complete without JavaScript) and in the browser (window.OpenVibeKit: the same builders for pages that
-- * draw themselves, plus enhance(), which adds copy buttons, menus that close, Enter to send, sheets and confirms to
-- * whatever markup is already there; it runs by itself when the script loads).
++ * draw themselves). The behaviour (copy buttons, menus that close, Enter to send, sheets, confirms) is kit-web.js, a
++ * small file of its own: a server-rendered site loads only that; a page that draws itself loads both and calls
++ * OpenVibeKit.enhance(container) after adding markup.
*
* const kit = require('openvibe-shared/kit');
* kit.head({ title: 'Settings', sub: 'Saved to your account', back: { href: '/', label: 'Home' },
@@@ -32,14 +33,10 @@
* trusted markup (usually another builder's output). URLs are kept only when they are http(s) or same-site paths.
*/
(function (root, factory) {
-- const api = factory();
++ const api = factory(root);
if (typeof module === 'object' && module.exports) { module.exports = api; return; }
root.OpenVibeKit = api;
-- if (typeof document !== 'undefined') {
-- if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => api.enhance(document));
-- else api.enhance(document);
-- }
--})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
++})(typeof globalThis !== 'undefined' ? globalThis : this, function (root) {
'use strict';
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
@@@ -305,89 +302,9 @@
? `<button type="button" class="ovk-icon-btn" data-ovk-sheet="#${esc(sid)}" aria-label="${esc(label)}" title="${esc(label)}">${icon(ic)}</button>`
: `<button type="button" class="ovk-btn ovk-btn--quiet ovk-btn--small" data-ovk-sheet="#${esc(sid)}">${icon(ic)}<span>${esc(label)}</span></button>`);
-- // ── In the browser: behaviour for the markup above (idempotent; safe on pages that use none of it) ──
-- function enhance(rootEl) {
-- if (typeof document === 'undefined') return;
-- const scope = rootEl || document;
-- const all = (sel) => Array.prototype.slice.call(scope.querySelectorAll ? scope.querySelectorAll(sel) : []);
-- const once = (el, key) => { if (el.dataset[key]) return false; el.dataset[key] = '1'; return true; };
-- // Copy buttons: shown only where the clipboard works; the icon turns into a check for a moment.
-- all('[data-ovk-copy]').forEach((b) => {
-- if (!once(b, 'ovkCopy') || !navigator.clipboard) return;
-- b.hidden = false;
-- b.addEventListener('click', () => {
-- const t = document.querySelector(b.getAttribute('data-ovk-copy'));
-- if (!t) return;
-- navigator.clipboard.writeText(t.value != null && t.tagName !== 'PRE' ? t.value : t.textContent).then(() => {
-- const was = b.innerHTML; b.innerHTML = icon('check'); b.classList.add('is-done');
-- setTimeout(() => { b.innerHTML = was; b.classList.remove('is-done'); }, 1400);
-- }, () => {});
-- });
-- });
-- // Menus: one open at a time; a click elsewhere or Escape closes them.
-- all('details.ovk-more').forEach((d) => {
-- if (!once(d, 'ovkMore')) return;
-- d.addEventListener('toggle', () => { if (d.open) all('details.ovk-more[open]').forEach((o) => { if (o !== d) o.open = false; }); });
-- });
-- if (!document.documentElement.dataset.ovkDoc) {
-- document.documentElement.dataset.ovkDoc = '1';
-- document.addEventListener('click', (e) => {
-- Array.prototype.forEach.call(document.querySelectorAll('details.ovk-more[open]'), (d) => { if (!d.contains(e.target)) d.open = false; });
-- });
-- document.addEventListener('keydown', (e) => {
-- if (e.key !== 'Escape') return;
-- Array.prototype.forEach.call(document.querySelectorAll('details.ovk-more[open]'), (d) => { d.open = false; const s = d.querySelector('summary'); if (s) s.focus(); });
-- });
-- // The head casts a shadow once the page has scrolled under it.
-- let ticking = false;
-- const mark = () => { ticking = false; document.documentElement.classList.toggle('ovk-scrolled', (window.scrollY || 0) > 4); };
-- window.addEventListener('scroll', () => { if (!ticking) { ticking = true; requestAnimationFrame(mark); } }, { passive: true });
-- mark();
-- }
-- // The composer: Enter sends (Shift+Enter, or a phone's own Enter key, is a new line); the box grows with the text.
-- const coarse = typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches;
-- all('textarea[data-ovk-enter]').forEach((ta) => {
-- if (!once(ta, 'ovkEnter')) return;
-- const grow = () => { if (CSS && CSS.supports && CSS.supports('field-sizing', 'content')) return; ta.style.height = 'auto'; ta.style.height = `${Math.min(ta.scrollHeight, Math.round(innerHeight * 0.4))}px`; };
-- ta.addEventListener('input', grow);
-- ta.addEventListener('keydown', (e) => {
-- if (e.key !== 'Enter' || e.shiftKey || e.isComposing || coarse) return;
-- e.preventDefault();
-- const f = ta.form; if (f) { if (f.requestSubmit) f.requestSubmit(); else f.submit(); }
-- });
-- });
-- // A form that asks first (delete, leave): the network's confirm dialog when the page has it.
-- all('form[data-ovk-confirm]').forEach((f) => {
-- if (!once(f, 'ovkConfirm')) return;
-- f.addEventListener('submit', (e) => {
-- if (f.dataset.ovkConfirmed) { delete f.dataset.ovkConfirmed; return; }
-- e.preventDefault();
-- const msg = f.getAttribute('data-ovk-confirm');
-- const ask = window.OpenVibeUI && window.OpenVibeUI.confirm ? window.OpenVibeUI.confirm({ title: msg, danger: true }) : Promise.resolve(window.confirm(msg));
-- ask.then((ok) => { if (ok) { f.dataset.ovkConfirmed = '1'; if (f.requestSubmit) f.requestSubmit(e.submitter || undefined); else f.submit(); } });
-- });
-- });
-- // Sliders say their value.
-- all('input[type=range][data-ovk-unit]').forEach((r) => {
-- if (!once(r, 'ovkRange')) return;
-- const u = r.getAttribute('data-ovk-unit');
-- r.addEventListener('input', () => r.setAttribute('aria-valuetext', `${r.value}${u === '%' ? ' percent' : ` ${u}`}`));
-- });
-- // Sheets: a button opens its dialog; the backdrop, Close and Escape close it.
-- all('[data-ovk-sheet]').forEach((b) => {
-- if (!once(b, 'ovkSheet')) return;
-- b.addEventListener('click', () => {
-- const d = document.querySelector(b.getAttribute('data-ovk-sheet'));
-- if (d && d.showModal && !d.open) d.showModal();
-- });
-- });
-- all('dialog.ovk-sheet').forEach((d) => {
-- if (!once(d, 'ovkDialog')) return;
-- d.addEventListener('click', (e) => { if (e.target === d || (e.target.closest && e.target.closest('[data-ovk-close]'))) d.close(); });
-- });
-- // Tabs that scroll sideways keep the open one in view.
-- all('.ovk-tabs [aria-current="page"]').forEach((a) => { if (once(a, 'ovkTab') && a.scrollIntoView) a.scrollIntoView({ block: 'nearest', inline: 'center' }); });
-- }
++ // ── In the browser: the behaviour lives in kit-web.js (a server-rendered site loads only that); a page that draws
++ // markup with these builders loads both and calls enhance() on what it added. A no-op on the server.
++ const enhance = (rootEl) => (root.OpenVibeKitWeb ? root.OpenVibeKitWeb.enhance(rootEl) : undefined);
return {
esc, url, icon, ICONS, when, hueOf, mark, button, iconButton, action, badge, dot,
ddiff --git a/package.json b/package.json
iindex f9029e6..9ebbd63 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.20.0",
++ "version": "4.20.1",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
@@@ -60,6 +60,7 @@
"./shell": "./shell.js",
"./showcase": "./showcase.js",
"./kit": "./kit.js",
++ "./kit-web": "./kit-web.js",
"./appbar": "./appbar.js",
"./social": "./social.js",
"./shipped": "./shipped.js",
ddiff --git a/scripts/kit-gallery.js b/scripts/kit-gallery.js
iindex 4c9484f..6718816 100644
---- a/scripts/kit-gallery.js
++++ b/scripts/kit-gallery.js
@@@ -52,7 +52,7 @@ ${k.dock({ action: '#send', placeholder: 'Message #general' })}`;
<div id="navbar-mount"></div>
<main class="ovk-page">${body}</main>
<script src="navbar.js"></script>
--<script src="kit.js"></script>
++<script src="kit-web.js"></script>
<script>OpenVibeNavbar.init({ service: 'chat', user: null, links: [
{ label: 'Vibe', href: '/vibe', icon: 'fa-wand-magic-sparkles' }, { label: 'Chat', href: '/', icon: 'fa-comments' },
{ label: 'Rooms', href: '/rooms', icon: 'fa-people-group' }, { label: 'Settings', href: '/kit', icon: 'fa-gear' },
ddiff --git a/test/kit.test.js b/test/kit.test.js
iindex 5b45dee..afb24bc 100644
---- a/test/kit.test.js
++++ b/test/kit.test.js
@@@ -99,6 +99,14 @@ const kit = require('../kit');
assert.strictEqual(kit.when(now, now).text, '15:00', 'a number');
assert.deepStrictEqual(kit.when('not a time', now), { iso: '', text: '' });
assert.doesNotThrow(() => kit.enhance(), 'enhance is a no-op without a document');
++ const web = require('../kit-web');
++ assert.strictEqual(typeof web.enhance, 'function', 'kit-web.js carries the behaviour');
++ assert.doesNotThrow(() => web.enhance(), 'and does nothing without a document');
++ let seen = null;
++ globalThis.OpenVibeKitWeb = { enhance: (el) => { seen = el; } };
++ kit.enhance('container');
++ assert.strictEqual(seen, 'container', 'kit.enhance() hands the page\'s markup to kit-web.js');
++ delete globalThis.OpenVibeKitWeb;
}
console.log('kit: builders, escaping, tabs, rows, settings, find/dock, icons and time ok');