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)
Changes
@@@ -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@@@ -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 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.@@@ -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 everypiece, 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.@@@ -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)",@@@ -5,12 +5,14 @@ cards that fold, settings rows, a search row, a composer pinned at the bottom, apieces are built on the server or in the page with the same functions. They look the same on every site, in everytheme, 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.```jsconst 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-renderedpage. 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@@@ -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]);nnew file mode 100644@@@ -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);@@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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,@@@ -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",@@@ -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' },@@@ -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');
@@ -6,6 +6,15 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
@@ -6,6 +6,15 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
## 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
## 4.20.0 — 2026-10-28
- **The OpenVibe Kit** (`kit.js`, `kit.css`; `require('openvibe-shared/kit')`, `window.OpenVibeKit`). One set of app
- **The OpenVibe Kit** (`kit.js`, `kit.css`; `require('openvibe-shared/kit')`, `window.OpenVibeKit`). One set of app
@@ -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.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
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.
@@ -619,7 +619,8 @@ openvibe-shared/shell".
@@ -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,
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
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
(`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;
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.
`node scripts/kit-gallery.js --shots <dir>` shows them all at 390, 768 and 1280 px.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.20.0",
+ "version": "4.20.1",
"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)",
@@ -5,12 +5,14 @@ cards that fold, settings rows, a search row, a composer pinned at the bottom, a
@@ -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
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.
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.
- **`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`.
- **`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.
`/shared/appbar.js` (`openvibe-shared/serve`), so a page keeps working when another site is down.
## Why
## Why
@@ -218,20 +220,21 @@ the site's main verb (go live, post, ask). Settings and the rest live in More.
@@ -218,20 +220,21 @@ the site's main verb (go live, post, ask). Settings and the rest live in More.
```js
```js
const kit = require('openvibe-shared/kit');
const kit = require('openvibe-shared/kit');
const serve = require('openvibe-shared/serve');
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' })] })
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 }))) }));
+ 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:
**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
`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.
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"`)
**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.
set the `--bg-*`, `--text-*` and `--accent` variables yourself, or keep the fallbacks.
## Seeing it
## Seeing it
@@ -39,7 +39,8 @@ const BROWSER = Object.freeze([
@@ -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
'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
'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
'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
'openvibe-sw.js', // Web Push service worker
]);
]);
new file mode 100644
new file mode 100644
@@ -0,0 +1,112 @@
@@ -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);
@@ -10,8 +10,9 @@
@@ -10,8 +10,9 @@
*
*
* The same file runs on the server (require('openvibe-shared/kit'): builders return HTML strings, every text escaped,
* 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
* 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');
* const kit = require('openvibe-shared/kit');
* kit.head({ title: 'Settings', sub: 'Saved to your account', back: { href: '/', label: 'Home' },
* kit.head({ title: 'Settings', sub: 'Saved to your account', back: { href: '/', label: 'Home' },
@@ -32,14 +33,10 @@
@@ -32,14 +33,10 @@
* trusted markup (usually another builder's output). URLs are kept only when they are http(s) or same-site paths.
* trusted markup (usually another builder's output). URLs are kept only when they are http(s) or same-site paths.
*/
*/
(function (root, factory) {
(function (root, factory) {
- const api = factory();
+ const api = factory(root);
if (typeof module === 'object' && module.exports) { module.exports = api; return; }
if (typeof module === 'object' && module.exports) { module.exports = api; return; }
root.OpenVibeKit = api;
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';
'use strict';
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
@@ -305,89 +302,9 @@
@@ -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-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>`);
: `<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 {
return {
esc, url, icon, ICONS, when, hueOf, mark, button, iconButton, action, badge, dot,
esc, url, icon, ICONS, when, hueOf, mark, button, iconButton, action, badge, dot,
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"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",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -60,6 +60,7 @@
@@ -60,6 +60,7 @@
"./shell": "./shell.js",
"./shell": "./shell.js",
"./showcase": "./showcase.js",
"./showcase": "./showcase.js",
"./kit": "./kit.js",
"./kit": "./kit.js",
+ "./kit-web": "./kit-web.js",
"./appbar": "./appbar.js",
"./appbar": "./appbar.js",
"./social": "./social.js",
"./social": "./social.js",
"./shipped": "./shipped.js",
"./shipped": "./shipped.js",
@@ -52,7 +52,7 @@ ${k.dock({ action: '#send', placeholder: 'Message #general' })}`;
@@ -52,7 +52,7 @@ ${k.dock({ action: '#send', placeholder: 'Message #general' })}`;
<div id="navbar-mount"></div>
<div id="navbar-mount"></div>
<main class="ovk-page">${body}</main>
<main class="ovk-page">${body}</main>
<script src="navbar.js"></script>
<script src="navbar.js"></script>
-<script src="kit.js"></script>
+<script src="kit-web.js"></script>
<script>OpenVibeNavbar.init({ service: 'chat', user: null, links: [
<script>OpenVibeNavbar.init({ service: 'chat', user: null, links: [
{ label: 'Vibe', href: '/vibe', icon: 'fa-wand-magic-sparkles' }, { label: 'Chat', href: '/', icon: 'fa-comments' },
{ 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' },
{ label: 'Rooms', href: '/rooms', icon: 'fa-people-group' }, { label: 'Settings', href: '/kit', icon: 'fa-gear' },
@@ -99,6 +99,14 @@ const kit = require('../kit');
@@ -99,6 +99,14 @@ const kit = require('../kit');
assert.strictEqual(kit.when(now, now).text, '15:00', 'a number');
assert.strictEqual(kit.when(now, now).text, '15:00', 'a number');
assert.deepStrictEqual(kit.when('not a time', now), { iso: '', text: '' });
assert.deepStrictEqual(kit.when('not a time', now), { iso: '', text: '' });
assert.doesNotThrow(() => kit.enhance(), 'enhance is a no-op without a document');
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');
console.log('kit: builders, escaping, tabs, rows, settings, find/dock, icons and time ok');