openvibers/openvibe.shared Commits

4.20.0: the OpenVibe Kit (kit.js builders on the server and in the page, kit.css on the theme tokens, docs/kit.md, a gallery) and the phone app bar (appbar.js, navbar appBar), so every site's pages fit a phone and work like one app (#143)

OP OpenVibers committed 17 days ago · 0275492a06 · 13 files, +1382 −4

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 223fb47..1dd8c06 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -6,6 +6,34 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
++## 4.20.0 — 2026-10-28
++
++- **The OpenVibe Kit** (`kit.js`, `kit.css`; `require('openvibe-shared/kit')`, `window.OpenVibeKit`). One set of app
++ pieces for every site and anyone's, phone first.
++ - The owner, 2026-10-28: "the pages are so dang long… use tabs or collapsible areas… on a phone it should act more
++ like an app… one app ecosystem across all our systems… part of the SDK and the API… explained in Markdown and on
++ the wiki… reusable for other people's projects."
++ - **The pieces:** a sticky head (back, title, one line, icon actions) with link tabs (`tabs`, `tabOf`), rows and
++ lists, sections, folds and a "more" menu; switch, range, select, segmented and chip rows with a save bar; a find
++ row and a dock (the composer pinned at the bottom); a numbers strip; empty states, notices, copyable snippets,
++ bottom sheets; buttons, icon buttons, one-button forms with a confirm; 78 line icons; `when()` and `mark()`.
++ - **How it runs:** the same builders on the server and in the page, every text escaped and unsafe URLs dropped.
++ `enhance()` adds copy buttons, menus that close, Enter to send, confirms, sliders that speak and sheets, and runs
++ by itself in the page.
++ - **The look:** `kit.css` on the site's theme tokens (`--ovk-*` from `--bg-*`, `--text-*`, `--accent-strong`…).
++ 44 px touch targets, a sticky head and dock, layouts for phones (< 600 px), tablets and computers (≥ 1024 px,
++ `.ovk-split`).
++ - **Docs and gallery:** docs/kit.md (the why, with the 38-site phone sweep; the shape of a page; every builder;
++ adoption) and `scripts/kit-gallery.js` (one page with every piece, screenshots at 390/768/1280 and a sideways-scroll
++ check).
++- **The phone app bar** (`appbar.js`; navbar `appBar`, `setAppBar()`).
++ - A site's main places as a bottom tab bar under 760 px: `appBar: true` takes the links that carry an icon (four and
++ More, which opens the drawer), or a list of five.
++ - The open page is marked, a link's dot shows on its tab, and clicks go through `onClick` and `onNavigate`.
++ - It steps aside for the keyboard and open panels, and keeps the page's last line above it (`html.ov-has-appbar`,
++ `--ovnav-appbar-h`).
++ - `navbar.js` loads it only for a site that sets `appBar`, so the navbar's own size is unchanged.
++
## 4.19.0 — 2026-10-28
- **Footer `variant: 'auto'`** (`footer.js`). The owner, 2026-10-28: "the full-size footer on the chat pages might be a
ddiff --git a/README.md b/README.md
iindex dbf6a3d..acb9bb7 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.19.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.20.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
@@@ -614,6 +614,23 @@ hand-written layouts produce the same document. `templates/site/server/app.js` (
new site starts from) already calls it. See `docs/migration-plan.md`, "T11: layout.js onto
openvibe-shared/shell".
++### The OpenVibe Kit (`kit.js`, `kit.css`, `appbar.js`)
++
++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
++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.
++
++```js
++const kit = require('openvibe-shared/kit');
++kit.head({ title: 'Settings', back: { href: '/', label: 'Home' }, tabs: kit.tabs({ base: '/settings', items, active }) })
++ + kit.list(rooms.map((r) => kit.row({ href: `/r/${r.slug}`, mark: kit.mark({ name: r.name }), title: r.name, sub: r.topic })))
++ + kit.dock({ action: '/r/x', placeholder: 'Message #general' });
++OpenVibeNavbar.init({ …, appBar: true }); // phones: the links with icons as a bottom tab bar
++```
++
### The navbar (`navbar.js`)
```text
ddiff --git a/STATUS.json b/STATUS.json
iindex 2ec2e94..709acbf 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.19.0",
++ "version": "4.20.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
ddiff --git a/appbar.js b/appbar.js
nnew file mode 100644
iindex 0000000..489b39c
---- /dev/null
++++ b/appbar.js
@@@ -0,0 +1,104 @@
++/**
++ * openvibe-shared/appbar.js — the phone app bar: a site's main places as a tab bar fixed at the bottom of the screen,
++ * as in an app (the owner, 2026-10-28: "on a phone it should act like an app… one app ecosystem across all our
++ * systems"). navbar.js loads this file only for a site that asks for it, and draws it again on every render, page move
++ * and setActive(); it needs nothing else.
++ *
++ * OpenVibeNavbar.init({ …, appBar: true }) the site's own links that carry an icon: the first four, and More
++ * for the rest (it opens the navbar's drawer); five fit without More
++ * OpenVibeNavbar.init({ …, appBar: [{ label, href, icon, id, page, active, dot }] }) five at most
++ * OpenVibeNavbar.setAppBar(spec | false) change it later (a single-page app's sections)
++ *
++ * It shows under 760 px only. html.ov-has-appbar and --ovnav-appbar-h keep the page's last lines above it (kit.css's
++ * dock and save bar sit on it), and it steps aside while the keyboard is up or a panel is open. A link's icon is a
++ * navbar icon name (fa-…), its dot a small red mark, the open page (its path, a path under it, or setActive's page)
++ * a filled pill. Clicks go through the link's onClick and the site's onNavigate, as the navbar's own links do.
++ */
++(function (root) {
++ 'use strict';
++ let barEl = null;
++
++ function spec(ctx) {
++ const cfg = ctx.config.appBar;
++ if (!cfg) return null;
++ const path = (typeof location !== 'undefined' && location.pathname) || '/';
++ const here = (l) => (l.active !== undefined ? !!l.active : ctx.activePage && l.page ? l.page === ctx.activePage
++ : l.href === path || (l.href.length > 1 && l.href.startsWith('/') && path.startsWith(`${l.href.replace(/\/$/, '')}/`)));
++ const usable = (l) => l && typeof l.href === 'string' && l.label && !l.hidden && !l.external;
++ if (Array.isArray(cfg)) return { items: cfg.filter(usable).slice(0, 5).map((l) => Object.assign({}, l, { active: here(l) })), more: false };
++ const all = ctx.links.filter(usable);
++ const withIcon = all.filter((l) => l.icon);
++ const items = withIcon.slice(0, withIcon.length > 5 || all.length > withIcon.length ? 4 : 5);
++ return { items: items.map((l) => Object.assign({}, l, { active: here(l) })), more: all.length > items.length };
++ }
++
++ function styles() {
++ if (document.getElementById('openvibe-appbar-styles')) return;
++ const st = document.createElement('style');
++ st.id = 'openvibe-appbar-styles';
++ st.textContent = `
++ .ovnav-appbar { display: none; }
++ @media (max-width: 760px) {
++ html.ov-has-appbar { --ovnav-appbar-h: calc(58px + env(safe-area-inset-bottom, 0px)); }
++ html.ov-has-appbar body { padding-bottom: var(--ovnav-appbar-h); }
++ .ovnav-appbar { position: fixed; left: 0; right: 0; bottom: 0; z-index: 9990; display: flex; align-items: stretch; height: var(--ovnav-appbar-h); box-sizing: border-box; padding: 0 4px env(safe-area-inset-bottom, 0px);
++ background: color-mix(in srgb, var(--bg-secondary, #121826) 90%, transparent); -webkit-backdrop-filter: saturate(1.5) blur(16px); backdrop-filter: saturate(1.5) blur(16px);
++ border-top: 1px solid var(--border, rgba(255,255,255,.1)); font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; transition: transform .2s ease; }
++ .ovnav-appbar .ovnav-ab { all: unset; box-sizing: border-box; flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; padding: 6px 2px; cursor: pointer;
++ color: var(--text-muted, #93a0b4); font-size: 11px; font-weight: 650; line-height: 1.1; text-align: center; -webkit-tap-highlight-color: transparent; }
++ .ovnav-appbar .ovnav-ab:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -4px; border-radius: 12px; }
++ .ovnav-appbar .ovnav-ab.is-here { color: var(--text-primary, #e8ecf3); }
++ .ovnav-appbar .ovnav-ab-ic { position: relative; display: grid; place-items: center; width: 52px; height: 28px; border-radius: 999px; font-size: 18px; transition: background .15s; }
++ .ovnav-appbar .ovnav-ab.is-here .ovnav-ab-ic { background: color-mix(in srgb, var(--accent, #3b82f6) 22%, transparent); color: var(--accent-light, var(--accent, #60a5fa)); }
++ .ovnav-appbar .ovnav-ab-ic svg { width: 1em; height: 1em; }
++ .ovnav-appbar .ovnav-ab-l { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++ .ovnav-appbar .ovnav-ab-dot { position: absolute; top: 2px; right: 12px; width: 8px; height: 8px; border-radius: 50%; background: var(--danger, #ef4444); box-shadow: 0 0 0 2px var(--bg-secondary, #121826); }
++ html.ov-has-appbar:has(:is(input:not([type=checkbox]):not([type=radio]):not([type=range]), textarea, select, [contenteditable="true"]):focus) { --ovnav-appbar-h: 0px; }
++ html.ov-has-appbar:has(:is(input:not([type=checkbox]):not([type=radio]):not([type=range]), textarea, select, [contenteditable="true"]):focus) .ovnav-appbar,
++ body.ov-panel-open .ovnav-appbar { transform: translateY(110%); }
++ }
++ @media (prefers-reduced-motion: reduce) { .ovnav-appbar, .ovnav-appbar .ovnav-ab-ic { transition: none; } }
++ @media print { .ovnav-appbar { display: none !important; } }`;
++ document.head.appendChild(st);
++ }
++
++ /** Draw (or take away) the bar for the navbar's current state: { config, links, activePage, nav, navIcon, escapeAttr, escapeHtml, flatLinks }. */
++ function render(ctx) {
++ if (typeof document === 'undefined' || !document.body) return;
++ if (barEl) { barEl.remove(); barEl = null; }
++ const s = spec(ctx);
++ const de = document.documentElement;
++ if (!s || s.items.length < 2) { if (de && de.classList) de.classList.remove('ov-has-appbar'); return; }
++ styles();
++ const { navIcon, escapeAttr, escapeHtml } = ctx;
++ const bar = document.createElement('nav');
++ bar.className = 'ovnav-appbar';
++ bar.setAttribute('aria-label', 'App');
++ bar.innerHTML = s.items.map((l) => `<a href="${escapeAttr(l.href)}" class="ovnav-ab${l.active ? ' is-here' : ''}"${l.active ? ' aria-current="page"' : ''}${l.id ? ` data-link-id="${escapeAttr(l.id)}"` : ''}${l.page ? ` data-page="${escapeAttr(l.page)}"` : ''}><span class="ovnav-ab-ic">${navIcon(l.icon || 'fa-circle')}${l.dot ? '<span class="ovnav-ab-dot" aria-label="new"></span>' : ''}</span><span class="ovnav-ab-l">${escapeHtml(l.label)}</span></a>`).join('')
++ + (s.more ? `<button type="button" class="ovnav-ab" data-ab-more aria-haspopup="true"><span class="ovnav-ab-ic">${navIcon('fa-bars')}</span><span class="ovnav-ab-l">More</span></button>` : '');
++ bar.addEventListener('click', (e) => {
++ if (e.target.closest && e.target.closest('[data-ab-more]')) {
++ e.preventDefault(); e.stopPropagation();
++ const b = ctx.nav && ctx.nav.querySelector('#openvibe-burger'); if (b) b.click();
++ return;
++ }
++ const a = e.target.closest && e.target.closest('a.ovnav-ab');
++ if (!a || e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
++ const id = a.getAttribute('data-link-id');
++ const item = id ? ctx.flatLinks(ctx.links).concat(Array.isArray(ctx.config.appBar) ? ctx.config.appBar : []).find((l) => l && l.id === id) : null;
++ if (item && typeof item.onClick === 'function' && item.onClick(e) === false) e.preventDefault();
++ if (e.defaultPrevented) return;
++ if (typeof ctx.config.onNavigate === 'function') {
++ let same = false; try { same = new URL(a.href, location.href).origin === location.origin; } catch { /* */ }
++ if (same && ctx.config.onNavigate(a.getAttribute('href'), e) === false) e.preventDefault();
++ }
++ });
++ document.body.appendChild(bar);
++ barEl = bar;
++ if (de && de.classList) de.classList.add('ov-has-appbar');
++ }
++
++ const api = { render, spec };
++ if (typeof module === 'object' && module.exports) module.exports = api;
++ root.OpenVibeAppBar = api;
++})(typeof globalThis !== 'undefined' ? globalThis : this);
ddiff --git a/docs/kit.md b/docs/kit.md
nnew file mode 100644
iindex 0000000..d17de02
---- /dev/null
++++ b/docs/kit.md
@@@ -0,0 +1,241 @@
++# The OpenVibe Kit
++
++One set of app pieces for every OpenVibe site, and for anyone else's: 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. The
++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.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
++`/shared/appbar.js` (`openvibe-shared/serve`), so a page keeps working when another site is down.
++
++## Why
++
++The owner, 2026-10-28:
++
++> "The pages are so dang long still… use tabs or collapsible areas… consider which areas are going to be more useful
++> than others and make it more simple and straightforward while still being powerful."
++>
++> "On a phone they want it to act more like an app, not a huge long web page… a whole app ecosystem across all our
++> systems in harmony… optimized across all devices, tablet, mobile phone, computer."
++
++The same day, a sweep measured the front page of all 38 live sites at 390 px wide, signed out
++(`scripts/kit-gallery.js` has the gallery; the sweep script is in the plan state):
++
++| What | At 390 px |
++|---|---|
++| Length | 6 to 22 screens, most 10 to 15 (Media 22.5, Network 19.9, Quest 17.3, Actor 17.2, Services 16.4) |
++| Before the real content | the hero (badge, title, a 5-7 line paragraph, 2-3 stacked full-width buttons) takes about 1.5 screens |
++| Numbers | two-column cards, about one more screen |
++| Footer | 530-925 px, about a whole screen (now only on a site's front page: footer `variant: 'auto'`, 4.19) |
++| Tap targets under 32 px | up to 101 a page (Media 101, Chat 81, Live 68, Quest 68, Search 53) |
++| Text under 12 px | up to 244 elements (Media 244, Live 130, Community 46, Help 43) |
++| Fixed on screen | only the navbar; no site had a bottom bar |
++| Sideways scroll | none (the 2026-10-17 sweep held) |
++
++## The shape of a page
++
++```
++┌──────────────────────────────┐
++│ navbar (sticky) │ OpenVibe Frame: brand, launcher, bell, account
++├──────────────────────────────┤
++│ ‹ Title ⓘ ••• │ kit.head: back, title, one muted line, icon actions ┐ sticky under
++│ ( Look ) Privacy Sound … │ kit.tabs: the page's parts, one at a time ┘ the navbar
++├──────────────────────────────┤
++│ content │ rows, sections, folds; one or two screens, never ten
++│ │
++├──────────────────────────────┤
++│ ( Message #general ) (↑) │ kit.dock: the one thing you type, pinned (sticky)
++├──────────────────────────────┤
++│ ✦ 💬 👥 ⚙ ☰ │ app bar (phones, < 760 px): the site's main places
++└──────────────────────────────┘
++```
++
++Rules every page follows:
++
++1. **One or two screens.** A part people use every visit is open. A part used once (setup, embed snippets, hooks, a
++ danger zone) folds. A page with distinct parts gets tabs (`?tab=`), and the server builds only the open tab, so
++ each request reads less.
++2. **The content first.** Signed in, a site opens on its app (the conversation, the list, the search), not on its
++ introduction. The hero and the long explanations are for new visitors and search engines.
++3. **Icons for actions, words for content.** Header actions are icon buttons with a label for screen readers and a
++ tooltip. A row is the whole tap target. Primary actions say what they do ("Start a room", not "Submit").
++4. **44 px to the touch.** Rows are at least 56 px, buttons at least 44 px (`--ovk-tap`), icon buttons 44 px on touch
++ screens.
++5. **Fixed where it helps.** The head and tabs stick under the navbar. The dock and a form's save bar stick above the
++ app bar. Nothing else floats.
++6. **No sideways scroll** except strips meant to scroll: tabs, numbers, chips.
++7. **Works without JavaScript.** Every piece is plain HTML (links, forms, `<details>`). `enhance()` only adds copy
++ buttons, menus that close, Enter to send, confirms and sheets.
++
++## Sizes
++
++| Width | What changes |
++|---|---|
++| < 600 px (phone) | numbers scroll sideways, sheets come up from the bottom, the dock spans the screen |
++| < 760 px | the app bar shows (when the site sets `appBar`) |
++| ≥ 600 px (tablet) | numbers sit in a grid, the save button is its natural width |
++| ≥ 1024 px (computer) | `.ovk-split` puts a sticky side panel (320 px) beside the content |
++
++## Colours
++
++The kit uses the site's theme (`theme-loader.js`), so it wears every theme in light and dark:
++
++| Kit token | From the theme | Fallback |
++|---|---|---|
++| `--ovk-bg` | `--bg-primary` | `#0b0f17` |
++| `--ovk-panel` | `--bg-card`, `--bg-secondary` | `#121826` |
++| `--ovk-panel-2` | `--bg-hover`, `--bg-tertiary` | `#1a2233` |
++| `--ovk-input` | `--bg-input` | `#0f1522` |
++| `--ovk-line` | `--border` | `rgba(255,255,255,.1)` |
++| `--ovk-text`, `--ovk-muted` | `--text-primary`, `--text-muted` | |
++| `--ovk-accent` | `--accent` | `#3b82f6` |
++| `--ovk-fill`, `--ovk-on-fill` | `--accent-strong`, `--on-accent-strong` (readable filled controls) | |
++| `--ovk-ok`, `--ovk-warn`, `--ovk-bad`, `--ovk-live` | `--success`, `--warning`, `--danger`, `--live-red` | |
++| `--ovk-top` | `--ovnav-real-h` (navbar.js sets it) | `52px` |
++| `--ovk-bottom` | `--ovnav-appbar-h` (appbar.js sets it on phones) | `0px` |
++
++Every class starts with `ovk-`, and nothing in `kit.css` styles a bare element, so the kit can sit beside a site's own
++CSS.
++
++## The pieces
++
++Text arguments are escaped. Arguments named `body`, `html`, `before`, `after`, `actions`, `trail`, `badge`, `extra`
++and `mark` take markup, usually another builder's output. A URL is kept only when it is `http(s)://`, a same-site
++path, a query or a fragment; anything else becomes `#`.
++
++### Head and tabs
++
++```js
++kit.head({ title, sub, back: { href, label }, actions: [kit.iconButton(…), kit.more(…)], tabs: kit.tabs(…) })
++kit.tabs({ base: '/settings', items: [{ key: 'look', label: 'Look', count, icon }], active, param: 'tab' })
++kit.tabOf(req.query, ['look', 'sound']) // the open tab: a known key, else the first
++```
++
++The head sticks under the navbar and casts a shadow once the page scrolls (`html.ovk-scrolled`). Tabs are links, so
++they work without JavaScript. On the server, build only the open tab's data. A form posts a hidden `tab`, redirects
++back to `?tab=…&saved=1`, and changes only its own tab's fields: an unchecked switch on another tab must not be
++reset.
++
++### Lists and rows
++
++```js
++kit.list(rows, { title: 'Your rooms', more: { href: '/rooms', label: 'All' }, flush, empty: 'No rooms yet.' })
++kit.row({ href, mark: kit.mark({ name, src, square }), title, badge: kit.badge('live', 'live'),
++ meta: kit.when(ts).text, metaIso: kit.when(ts).iso, sub, unread, trail })
++```
++
++A row has a mark (a picture, or up to two letters on a colour from the name; `square` for places), the title with a
++badge and the time on one line, and one muted line under it. With `href` the whole row is a link. Without it the row is
++static, and `trail` can hold its own forms (a role select with a check button). `kit.when()` gives 14:05 today,
++Yesterday, a weekday this week, `Oct 20` this year, else the date.
++
++### Sections, folds, menus
++
++```js
++kit.section({ title: 'Members', count: 12, note: 'Who can do what', body, id })
++kit.fold({ id, title: 'Put this room on your site', meta: 'two ways', open, body, tone: 'danger' })
++kit.more({ label: 'More', items: [{ href, label, icon }, { action: '/x/delete', label, icon, danger, confirm }] })
++```
++
++### Settings rows
++
++```js
++kit.switchRow({ name, label, hint, on })
++kit.rangeRow({ name, label, value, min, max, step, unit: '%' })
++kit.selectRow({ name, label, hint, options: [[value, label]], value })
++kit.seg({ name, label, options: [[value, label]], value })
++kit.chips({ name, label, options: [[value, label, checked]], single })
++kit.saveBar({ label: 'Save settings' })
++```
++
++Inside `<form class="ovk-form">`, each row keeps its label on the left and its control on the right. The save bar
++stays in reach above the app bar.
++
++### Search, add, compose
++
++```js
++kit.find({ action, method: 'get' | 'post', name, placeholder, icon: 'search' | 'plus', extra, hidden, inset })
++kit.dock({ action, name: 'message', placeholder: 'Message #general', before: attachButton })
++```
++
++`find` is a pill with an icon, the input, optional controls (a select) and a round button: search with `get`, "add
++by name" with `post`. `dock` is the composer pinned at the bottom: one line that grows, Enter sends (Shift+Enter, or a
++phone's own Enter, makes a new line), and a round send button.
++
++### Numbers, empty states, notices, code, sheets
++
++```js
++kit.stats([[1234, 'messages this week'], [48, 'people chatting']]) // zero and unknown values are left out
++kit.empty({ icon: 'chat', title, text, action: { href, label, icon } })
++kit.notice({ kind: 'ok' | 'error' | 'warn' | 'info', text })
++kit.snippet({ code, id }) // a copy button in the corner
++kit.sheet({ id, title, body }) + kit.sheetButton({ sheet: id, label, icon: 'filter' })
++```
++
++A sheet comes up from the bottom on phones and is a centred dialog on computers.
++
++### Controls and icons
++
++```js
++kit.button({ href | type, label, icon, kind: 'primary' | 'quiet' | 'ghost' | 'danger', small })
++kit.iconButton({ href | type, icon, label, name, value })
++kit.action({ action: '/s/x/leave', icon: 'leave', label: 'Leave', confirm: 'Leave the server?' })
++kit.badge(text, 'accent' | 'ok' | 'warn' | 'bad' | 'live') · kit.dot(n) · kit.icon(name) · kit.ICONS
++```
++
++`kit.ICONS` lists the icon names: 24-unit line drawings in the text's colour, among them `back`, `plus`, `send`,
++`search`, `gear`, `members`, `invite`, `chat`, `bell`, `home`, `spark`, `live`, `video`, `image`, `tag`, `coin`,
++`trophy`, `map`, `code`, `robot` and `game`.
++
++## The app bar
++
++```js
++OpenVibeNavbar.init({ …, links: [{ label: 'Rooms', href: '/rooms', icon: 'fa-people-group' }, …], appBar: true })
++OpenVibeNavbar.init({ …, appBar: [{ label, href, icon, id, page, dot }] }) // five at most
++OpenVibeNavbar.setAppBar(spec | false)
++```
++
++- **`appBar: true`** builds the bar from the site's own links that carry an icon: the first four, then **More**, which
++ opens the navbar's drawer with the rest. Five fit without More.
++- **What's open:** the open page is the link whose path matches, or a path under it, or the page given to
++ `setActive()`. A link's `dot` shows on its tab.
++- **Clicks** go through the link's `onClick` and the site's `onNavigate`, so single-page sites keep their routing.
++- **Keeping out of the way:** the bar shows under 760 px only. It steps aside while the keyboard is up or a panel is
++ open. `html.ov-has-appbar` and `--ovnav-appbar-h` keep the page's last line above it.
++
++Pick the places people go every visit: the home or feed, search, what is theirs (messages, saved, their page), and
++the site's main verb (go live, post, ask). Settings and the rest live in More.
++
++## Using it
++
++**An OpenVibe site (Node, server-rendered):**
++
++```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>
++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)`.
++
++**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,
++set the `--bg-*`, `--text-*` and `--accent` variables yourself, or keep the fallbacks.
++
++## Seeing it
++
++`node scripts/kit-gallery.js > gallery.html` writes one page with every piece and the app bar on.
++`node scripts/kit-gallery.js --shots <dir>` serves the package folder and screenshots it at 390, 768 and 1280 px. It
++prints any sideways scroll, so the gallery doubles as the visual test.
ddiff --git a/files.js b/files.js
iindex fd2a03c..845ee8b 100644
---- a/files.js
++++ b/files.js
@@@ -38,6 +38,8 @@ const BROWSER = Object.freeze([
'release-watch.js', // keeps open tabs on a supported release (ADR-016), loaded after first paint
'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
'openvibe-sw.js', // Web Push service worker
]);
@@@ -47,6 +49,7 @@ const STYLES = Object.freeze([
'items.css', // Live's name effects for items.js (live.name_effect.css@1)
'profile-panel.css', // the panel profile-panel.js draws
'player.css', // player.js's controls
++ 'kit.css', // the OpenVibe Kit's look (kit.js / openvibe-shared/kit build the markup; docs/kit.md)
]);
const served = (name) => BROWSER.includes(String(name)) || STYLES.includes(String(name));
ddiff --git a/kit.css b/kit.css
nnew file mode 100644
iindex 0000000..f24d256
---- /dev/null
++++ b/kit.css
@@@ -0,0 +1,254 @@
++/* openvibe-shared/kit.css — the OpenVibe Kit's look (kit.js builds the markup; docs/kit.md describes every piece).
++ *
++ * Phone first: a page fits the screen it is on. The head and its tabs stick under the navbar, the dock (a conversation's
++ * composer, a form's save) sticks above the navbar's app bar, rows are 44 px or more to the touch, nothing scrolls
++ * sideways but a strip that is meant to (tabs, numbers, chips). From 600 px (a tablet) lists breathe and numbers sit in
++ * a row; from 1024 px (a computer) .ovk-split puts a side panel beside the content.
++ *
++ * Colours come from the site's theme (theme-loader.js: --bg-*, --text-*, --accent, --accent-strong, --border, --success,
++ * --danger…), so the kit wears every site's theme and every theme's light and dark; the fallbacks are the network's
++ * default dark. Every class starts with ovk- and nothing here styles bare elements, so the kit can sit beside a site's
++ * own CSS. */
++
++:root {
++ --ovk-bg: var(--bg-primary, #0b0f17);
++ --ovk-panel: var(--bg-card, var(--bg-secondary, #121826));
++ --ovk-panel-2: var(--bg-hover, var(--bg-tertiary, #1a2233));
++ --ovk-input: var(--bg-input, var(--bg-tertiary, #0f1522));
++ --ovk-line: var(--border, rgba(255, 255, 255, .1));
++ --ovk-text: var(--text-primary, #e8ecf3);
++ --ovk-muted: var(--text-muted, var(--text-secondary, #93a0b4));
++ --ovk-accent: var(--accent, #3b82f6);
++ --ovk-fill: var(--accent-strong, var(--accent, #3b82f6));
++ --ovk-on-fill: var(--on-accent-strong, var(--on-accent, #fff));
++ --ovk-ok: var(--success, #22c55e);
++ --ovk-warn: var(--warning, #f59e0b);
++ --ovk-bad: var(--danger, var(--error, #ef4444));
++ --ovk-live: var(--live-red, #ef4444);
++ --ovk-radius: 14px;
++ --ovk-radius-sm: 10px;
++ --ovk-tap: 44px;
++ --ovk-top: var(--ovnav-real-h, 52px); /* the navbar's height: sticky things stop under it */
++ --ovk-bottom: var(--ovnav-appbar-h, 0px); /* the app bar's height on phones (navbar.js sets it) */
++ --ovk-font: var(--ov-font, 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif);
++}
++
++/* ── Basics ── */
++.ovk-i { width: 1.15em; height: 1.15em; flex: none; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; vertical-align: -.2em; }
++.ovk-sr { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
++.ovk-muted { color: var(--ovk-muted); }
++.ovk-inline { display: inline; margin: 0; }
++.ovk-page { box-sizing: border-box; width: 100%; max-width: 960px; margin: 0 auto; padding: 0 16px 24px; color: var(--ovk-text); font-family: var(--ovk-font); }
++.ovk-page--wide { max-width: 1280px; }
++
++/* ── Buttons ── */
++.ovk-btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; box-sizing: border-box; min-height: var(--ovk-tap); padding: 10px 18px; border-radius: 12px; border: 1px solid transparent; font: inherit; font-weight: 700; font-size: .95rem; line-height: 1.2; text-decoration: none; cursor: pointer; transition: filter .12s, border-color .12s, background .12s; }
++.ovk-btn--primary { background: var(--ovk-fill); color: var(--ovk-on-fill); }
++.ovk-btn--primary:hover { filter: brightness(1.08); }
++.ovk-btn--quiet { background: var(--ovk-panel); color: var(--ovk-text); border-color: var(--ovk-line); }
++.ovk-btn--quiet:hover { border-color: var(--ovk-accent); }
++.ovk-btn--ghost { background: transparent; color: var(--ovk-accent); padding-left: 8px; padding-right: 8px; }
++.ovk-btn--danger { background: transparent; color: var(--ovk-bad); border-color: color-mix(in srgb, var(--ovk-bad) 45%, transparent); }
++.ovk-btn--danger:hover { background: color-mix(in srgb, var(--ovk-bad) 12%, transparent); }
++.ovk-btn--small { min-height: 36px; padding: 6px 13px; border-radius: 999px; font-size: .88rem; }
++.ovk-btn:focus-visible, .ovk-icon-btn:focus-visible, .ovk-go:focus-visible { outline: 2px solid var(--ovk-accent); outline-offset: 2px; }
++.ovk-btn[disabled] { opacity: .55; cursor: default; filter: none; }
++.ovk-icon-btn { display: inline-grid; place-items: center; box-sizing: border-box; width: 40px; height: 40px; flex: none; padding: 0; border-radius: 12px; border: 1px solid transparent; background: transparent; color: var(--ovk-muted); font: inherit; cursor: pointer; text-decoration: none; list-style: none; }
++.ovk-icon-btn::-webkit-details-marker { display: none; }
++.ovk-icon-btn:hover { color: var(--ovk-text); background: var(--ovk-panel-2); border-color: var(--ovk-line); }
++.ovk-icon-btn[hidden] { display: none; }
++.ovk-icon-btn.is-done { color: var(--ovk-ok); }
++.ovk-icon-btn--danger:hover { color: var(--ovk-bad); }
++.ovk-icon-btn .ovk-i { width: 20px; height: 20px; }
++@media (pointer: coarse) { .ovk-icon-btn { width: var(--ovk-tap); height: var(--ovk-tap); } }
++.ovk-badge { display: inline-flex; align-items: center; flex: none; padding: 1px 7px; border-radius: 999px; border: 1px solid var(--ovk-line); font-size: .7rem; font-weight: 800; letter-spacing: .03em; text-transform: uppercase; color: var(--ovk-muted); white-space: nowrap; }
++.ovk-badge--accent { color: var(--ovk-accent); border-color: color-mix(in srgb, var(--ovk-accent) 50%, transparent); }
++.ovk-badge--ok { color: var(--ovk-ok); border-color: color-mix(in srgb, var(--ovk-ok) 50%, transparent); }
++.ovk-badge--warn { color: var(--ovk-warn); border-color: color-mix(in srgb, var(--ovk-warn) 50%, transparent); }
++.ovk-badge--bad { color: var(--ovk-bad); border-color: color-mix(in srgb, var(--ovk-bad) 50%, transparent); }
++.ovk-badge--live { color: #fff; background: var(--ovk-live); border-color: transparent; }
++.ovk-dot { display: inline-grid; place-items: center; flex: none; min-width: 20px; height: 20px; padding: 0 6px; box-sizing: border-box; border-radius: 999px; background: var(--ovk-fill); color: var(--ovk-on-fill); font-size: .72rem; font-weight: 800; }
++
++/* ── The head: back, title and one line, icon actions; its tabs under it; sticky under the navbar ── */
++.ovk-head { position: sticky; top: var(--ovk-top); z-index: 40; margin: 0 -16px 12px; padding: 6px 16px 0; background: color-mix(in srgb, var(--ovk-bg) 88%, transparent); -webkit-backdrop-filter: saturate(1.4) blur(14px); backdrop-filter: saturate(1.4) blur(14px); border-bottom: 1px solid transparent; transition: border-color .2s, box-shadow .2s; }
++.ovk-scrolled .ovk-head { border-bottom-color: var(--ovk-line); box-shadow: 0 10px 24px -18px rgba(0, 0, 0, .7); }
++.ovk-head--static { position: static; background: transparent; -webkit-backdrop-filter: none; backdrop-filter: none; }
++.ovk-head-row { display: flex; align-items: center; gap: 6px; min-height: 48px; padding-bottom: 6px; }
++.ovk-head-row > .ovk-back { margin-left: -8px; }
++.ovk-head-id { flex: 1; min-width: 0; }
++.ovk-head-id h1, .ovk-head-id h2 { margin: 0; color: var(--ovk-text); font-family: var(--ovk-font); font-size: clamp(1.15rem, 4.2vw, 1.5rem); font-weight: 800; letter-spacing: -.01em; line-height: 1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.ovk-sub { margin: 2px 0 0; color: var(--ovk-muted); font-size: .86rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.ovk-head-tools { display: flex; align-items: center; gap: 2px; margin-right: -8px; }
++.ovk-head-tools form { margin: 0; }
++.ovk-tabs { display: flex; gap: 4px; margin: 0 -16px; padding: 0 16px 8px; overflow-x: auto; scrollbar-width: none; -webkit-overflow-scrolling: touch; }
++.ovk-tabs::-webkit-scrollbar { display: none; }
++.ovk-tabs a { display: inline-flex; align-items: center; gap: 6px; flex: none; min-height: 36px; padding: 6px 14px; border-radius: 999px; color: var(--ovk-muted); font-size: .9rem; font-weight: 650; text-decoration: none; white-space: nowrap; }
++.ovk-tabs a:hover { color: var(--ovk-text); background: var(--ovk-panel-2); }
++.ovk-tabs a[aria-current="page"] { color: var(--ovk-text); background: var(--ovk-panel-2); box-shadow: inset 0 0 0 1px var(--ovk-line); }
++.ovk-tabs .ovk-i { width: 16px; height: 16px; }
++.ovk-tab-n { min-width: 18px; padding: 0 5px; border-radius: 999px; background: var(--ovk-panel); color: var(--ovk-muted); font-size: .72rem; font-weight: 800; text-align: center; }
++
++/* ── Lists: rows that read like a phone's (a mark, the name and time on one line, one line under it) ── */
++.ovk-list-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 18px 0 8px; }
++.ovk-list-head h2 { margin: 0; color: var(--ovk-muted); font-family: inherit; font-size: .76rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; }
++.ovk-list-head a { color: var(--ovk-accent); font-size: .84rem; font-weight: 700; text-decoration: none; }
++.ovk-list { list-style: none; display: grid; grid-template-columns: minmax(0, 1fr); margin: 0 0 14px; padding: 0; border: 1px solid var(--ovk-line); border-radius: var(--ovk-radius); background: var(--ovk-panel); overflow: hidden; }
++.ovk-list--flush { margin: 0; border: 0; border-radius: 0; border-top: 1px solid var(--ovk-line); background: transparent; }
++.ovk-row { margin: 0; color: var(--ovk-text); }
++.ovk-row + .ovk-row { border-top: 1px solid var(--ovk-line); }
++.ovk-row > a, .ovk-row--static { display: flex; align-items: center; gap: 12px; min-height: 56px; box-sizing: border-box; padding: 8px 14px; color: inherit; text-decoration: none; min-width: 0; }
++.ovk-row > a:hover { background: var(--ovk-panel-2); }
++.ovk-row > a:focus-visible { outline: 2px solid var(--ovk-accent); outline-offset: -2px; }
++.ovk-row--static { padding-right: 8px; }
++.ovk-row-main { flex: 1; min-width: 0; display: grid; gap: 1px; }
++.ovk-row-top { display: flex; align-items: center; gap: 8px; min-width: 0; }
++.ovk-row-top strong { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 650; }
++.ovk-row-top time, .ovk-row-meta { flex: none; margin-left: auto; color: var(--ovk-muted); font-size: .78rem; font-variant-numeric: tabular-nums; }
++.ovk-row-sub { display: flex; align-items: center; gap: 8px; min-width: 0; color: var(--ovk-muted); font-size: .9rem; }
++.ovk-row-sub > span:first-child { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.ovk-row--unread .ovk-row-top strong { font-weight: 800; }
++.ovk-row--unread .ovk-row-sub { color: var(--ovk-text); }
++.ovk-row-trail { display: flex; align-items: center; gap: 2px; flex: none; }
++.ovk-row-trail form { display: flex; align-items: center; gap: 2px; margin: 0; }
++.ovk-row-trail select { max-width: 9.5em; padding: 5px 6px; border-radius: 8px; border: 1px solid var(--ovk-line); background: var(--ovk-input); color: var(--ovk-text); font: inherit; font-size: .86rem; }
++.ovk-mark { --ovk-mark: 40px; flex: none; display: inline-grid; place-items: center; width: var(--ovk-mark); height: var(--ovk-mark); box-sizing: border-box; border-radius: 50%; object-fit: cover; background: hsl(var(--ovk-hue, 215) 45% 38%); color: #fff; font-size: calc(var(--ovk-mark) * .34); font-weight: 800; letter-spacing: .02em; line-height: 1; }
++.ovk-mark--square { border-radius: 28%; }
++.ovk-empty-line { margin: 0 0 14px; padding: 14px; border: 1px dashed var(--ovk-line); border-radius: var(--ovk-radius); color: var(--ovk-muted); }
++
++/* ── Sections (settings groups, a page's parts) and folds ── */
++.ovk-section { margin: 0 0 12px; border: 1px solid var(--ovk-line); border-radius: var(--ovk-radius); background: var(--ovk-panel); overflow: hidden; color: var(--ovk-text); }
++.ovk-section > h2 { display: flex; align-items: center; gap: 6px; margin: 0; padding: 12px 14px 6px; color: var(--ovk-muted); font-family: inherit; font-size: .74rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; }
++.ovk-count { color: var(--ovk-text); font-weight: 800; letter-spacing: 0; }
++.ovk-note { margin: 0; padding: 0 14px 10px; color: var(--ovk-muted); font-size: .86rem; }
++.ovk-section > .ovk-form, .ovk-section > form:not(.ovk-find):not(.ovk-inline) { padding: 4px 14px 14px; }
++.ovk-fold { margin: 0 0 10px; border: 1px solid var(--ovk-line); border-radius: var(--ovk-radius); background: var(--ovk-panel); color: var(--ovk-text); }
++.ovk-fold > summary { display: flex; align-items: center; gap: 10px; min-height: 48px; box-sizing: border-box; padding: 10px 14px; cursor: pointer; list-style: none; font-weight: 650; }
++.ovk-fold > summary::-webkit-details-marker { display: none; }
++.ovk-fold > summary > span { flex: 1; min-width: 0; }
++.ovk-fold > summary > .ovk-i { color: var(--ovk-muted); transition: transform .15s; }
++.ovk-fold[open] > summary > .ovk-i { transform: rotate(90deg); }
++.ovk-fold[open] > summary { border-bottom: 1px solid var(--ovk-line); }
++.ovk-fold > summary:focus-visible { outline: 2px solid var(--ovk-accent); outline-offset: -2px; border-radius: var(--ovk-radius); }
++.ovk-fold-body { padding: 12px 14px 14px; }
++.ovk-fold-body > :first-child { margin-top: 0; }
++.ovk-fold-body > :last-child { margin-bottom: 0; }
++.ovk-fold--danger > summary { color: var(--ovk-bad); }
++.ovk-more { position: relative; display: inline-block; }
++.ovk-more > summary { list-style: none; }
++.ovk-pop { position: absolute; right: 0; top: calc(100% + 4px); z-index: 60; min-width: 210px; padding: 6px; box-sizing: border-box; border-radius: 12px; border: 1px solid var(--ovk-line); background: var(--ovk-panel); box-shadow: 0 14px 32px rgba(0, 0, 0, .4); }
++.ovk-pop form { margin: 0; }
++.ovk-pop-item { display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 8px 10px; border: 0; border-radius: 8px; background: transparent; color: var(--ovk-text); font: inherit; font-weight: 600; text-align: left; text-decoration: none; cursor: pointer; }
++.ovk-pop-item:hover, .ovk-pop-item:focus-visible { background: var(--ovk-panel-2); outline: none; }
++.ovk-pop-item--danger { color: var(--ovk-bad); }
++
++/* ── Settings rows: what it does on the left, its control on the right ── */
++.ovk-set-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 52px; box-sizing: border-box; padding: 8px 14px; border-top: 1px solid var(--ovk-line); color: var(--ovk-text); cursor: pointer; }
++.ovk-section > h2 + .ovk-set-row, .ovk-section > .ovk-note + .ovk-set-row { border-top: 0; }
++div.ovk-set-row { cursor: default; }
++.ovk-set-text { flex: 1; min-width: 0; display: grid; gap: 2px; }
++.ovk-set-text small { color: var(--ovk-muted); font-size: .8rem; }
++.ovk-set-row select { flex: none; max-width: 52%; margin: 0; padding: 7px 8px; border-radius: 10px; border: 1px solid var(--ovk-line); background: var(--ovk-input); color: var(--ovk-text); font: inherit; font-size: .9rem; }
++.ovk-set-range input[type=range] { flex: 0 1 45%; min-width: 120px; margin: 0; accent-color: var(--ovk-fill); }
++.ovk-set-chips { flex-wrap: wrap; }
++.ovk-switch { appearance: none; -webkit-appearance: none; flex: none; position: relative; width: 44px; height: 26px; margin: 0; padding: 0; border-radius: 999px; border: 0; background: var(--ovk-line); cursor: pointer; transition: background .15s; }
++.ovk-switch::after { content: ''; position: absolute; top: 3px; left: 3px; width: 20px; height: 20px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(0, 0, 0, .35); transition: transform .15s; }
++.ovk-switch:checked { background: var(--ovk-fill); }
++.ovk-switch:checked::after { transform: translateX(18px); }
++.ovk-switch:focus-visible { outline: 2px solid var(--ovk-accent); outline-offset: 2px; }
++.ovk-seg { display: inline-flex; flex: none; gap: 2px; padding: 3px; border-radius: 10px; background: var(--ovk-panel-2); }
++.ovk-seg input, .ovk-chip input { position: absolute; opacity: 0; pointer-events: none; }
++.ovk-seg span { display: block; padding: 6px 12px; border-radius: 8px; color: var(--ovk-muted); font-size: .86rem; font-weight: 650; cursor: pointer; }
++.ovk-seg input:checked + span { background: var(--ovk-fill); color: var(--ovk-on-fill); }
++.ovk-seg input:focus-visible + span, .ovk-chip input:focus-visible + span { outline: 2px solid var(--ovk-accent); outline-offset: 1px; }
++.ovk-chips { display: flex; flex-wrap: wrap; gap: 6px; }
++.ovk-chip span { display: inline-flex; align-items: center; min-height: 32px; box-sizing: border-box; padding: 5px 12px; border-radius: 999px; border: 1px solid var(--ovk-line); color: var(--ovk-muted); font-size: .86rem; font-weight: 650; cursor: pointer; }
++.ovk-chip input:checked + span { border-color: color-mix(in srgb, var(--ovk-accent) 55%, transparent); background: color-mix(in srgb, var(--ovk-accent) 14%, transparent); color: var(--ovk-text); }
++.ovk-save { position: sticky; bottom: var(--ovk-bottom); z-index: 30; margin: 0; padding: 10px 0 calc(10px + env(safe-area-inset-bottom, 0px)); background: linear-gradient(transparent, var(--ovk-bg) 35%); }
++.ovk-save .ovk-btn { width: 100%; }
++.ovk-form { display: grid; gap: 12px; margin: 0; }
++.ovk-form label { display: grid; gap: 5px; color: var(--ovk-muted); font-size: .9rem; }
++.ovk-form label.ovk-check { display: flex; align-items: flex-start; gap: 10px; color: var(--ovk-text); font-size: .95rem; }
++.ovk-form input:not([type=checkbox]):not([type=radio]):not([type=range]), .ovk-form select, .ovk-form textarea { width: 100%; box-sizing: border-box; min-height: var(--ovk-tap); padding: 10px 12px; border-radius: 10px; border: 1px solid var(--ovk-line); background: var(--ovk-input); color: var(--ovk-text); font: inherit; font-size: 1rem; }
++.ovk-form textarea { min-height: 96px; resize: vertical; }
++.ovk-form input:focus-visible, .ovk-form select:focus-visible, .ovk-form textarea:focus-visible { outline: 2px solid var(--ovk-accent); outline-offset: 0; border-color: transparent; }
++.ovk-form > p { margin: 0; }
++/* A settings row inside a form stays a row: the label on the left, its control (not stretched) on the right. */
++.ovk-form label.ovk-set-row { display: flex; color: var(--ovk-text); font-size: 1rem; }
++.ovk-form .ovk-set-row select { width: auto; min-height: 0; padding: 7px 8px; font-size: .9rem; }
++.ovk-form .ovk-section { margin: 0; }
++
++/* ── Find (search, add by name) and the dock (a conversation's composer) ── */
++.ovk-find { display: flex; align-items: center; gap: 8px; box-sizing: border-box; margin: 0 0 12px; padding: 4px 4px 4px 14px; border: 1px solid var(--ovk-line); border-radius: 999px; background: var(--ovk-panel); color: var(--ovk-muted); }
++.ovk-find:focus-within { border-color: var(--ovk-accent); }
++.ovk-find--inset { margin: 0 14px 12px; }
++.ovk-find input { flex: 1; min-width: 0; min-height: 38px; margin: 0; padding: 8px 0; border: 0; background: transparent; color: var(--ovk-text); font: inherit; font-size: 1rem; outline: none; -webkit-appearance: none; appearance: none; }
++.ovk-find input::-webkit-search-cancel-button { -webkit-appearance: none; }
++.ovk-find select { flex: none; max-width: 38%; padding: 6px 8px; border: 0; border-radius: 999px; background: var(--ovk-panel-2); color: var(--ovk-text); font: inherit; font-size: .86rem; }
++.ovk-go { display: inline-grid; place-items: center; flex: none; width: 38px; height: 38px; padding: 0; border: 0; border-radius: 50%; background: var(--ovk-fill); color: var(--ovk-on-fill); cursor: pointer; }
++.ovk-go .ovk-i { width: 18px; height: 18px; stroke-width: 2.4; }
++.ovk-go--send { width: var(--ovk-tap); height: var(--ovk-tap); }
++.ovk-dock { position: sticky; bottom: var(--ovk-bottom); z-index: 30; display: flex; align-items: flex-end; gap: 6px; margin: 8px -16px 0; padding: 8px 16px calc(8px + env(safe-area-inset-bottom, 0px)); background: color-mix(in srgb, var(--ovk-bg) 92%, transparent); -webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px); }
++.ovk-compose { flex: 1; min-width: 0; display: flex; align-items: flex-end; gap: 8px; margin: 0; }
++.ovk-compose textarea { flex: 1; min-width: 0; min-height: var(--ovk-tap); max-height: 40vh; box-sizing: border-box; margin: 0; padding: 10px 16px; border-radius: 22px; border: 1px solid var(--ovk-line); background: var(--ovk-input); color: var(--ovk-text); font: inherit; font-size: 1rem; line-height: 1.4; resize: none; field-sizing: content; }
++.ovk-compose textarea:focus-visible { outline: none; border-color: var(--ovk-accent); }
++
++/* ── App pages: the conversation fills the screen between the head and the dock, and only it scrolls ── */
++.ovk-app { display: flex; flex-direction: column; min-height: calc(100dvh - var(--ovk-top) - var(--ovk-bottom)); }
++.ovk-app > .ovk-feed { flex: 1 1 auto; }
++.ovk-feed { min-height: 240px; }
++@supports (height: 100dvh) {
++ .ovk-app--fixed { height: calc(100dvh - var(--ovk-top) - var(--ovk-bottom)); min-height: 0; }
++ .ovk-app--fixed > .ovk-feed { min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
++ .ovk-app--fixed > .ovk-head { position: static; }
++ .ovk-app--fixed > .ovk-dock { position: static; }
++}
++
++/* ── Numbers, empty states, notices, code, sheets ── */
++.ovk-stats { display: flex; gap: 8px; margin: 12px -16px; padding: 0 16px; overflow-x: auto; scrollbar-width: none; }
++.ovk-stats::-webkit-scrollbar { display: none; }
++.ovk-stats > div { display: flex; flex-direction: column-reverse; flex: 0 0 auto; min-width: 104px; box-sizing: border-box; padding: 8px 14px; border: 1px solid var(--ovk-line); border-radius: 12px; background: var(--ovk-panel); }
++.ovk-stats dt { margin: 0; color: var(--ovk-muted); font-size: .76rem; line-height: 1.25; }
++.ovk-stats dt a { color: inherit; }
++.ovk-stats dd { margin: 0; color: var(--ovk-text); font-size: 1.2rem; font-weight: 800; font-variant-numeric: tabular-nums; line-height: 1.2; }
++.ovk-empty { display: grid; justify-items: center; gap: 8px; margin: 0 0 14px; padding: 24px 16px; border: 1px dashed var(--ovk-line); border-radius: var(--ovk-radius); color: var(--ovk-muted); text-align: center; }
++.ovk-empty > .ovk-i { width: 28px; height: 28px; }
++.ovk-empty p { margin: 0; max-width: 46ch; }
++.ovk-empty .ovk-empty-title { color: var(--ovk-text); font-weight: 750; }
++.ovk-notice { display: flex; align-items: flex-start; gap: 10px; margin: 0 0 12px; padding: 10px 14px; border-radius: 12px; border: 1px solid var(--ovk-line); background: var(--ovk-panel); color: var(--ovk-text); font-size: .92rem; }
++.ovk-notice > .ovk-i { margin-top: 2px; }
++.ovk-notice--ok { border-color: color-mix(in srgb, var(--ovk-ok) 45%, transparent); }
++.ovk-notice--ok > .ovk-i { color: var(--ovk-ok); }
++.ovk-notice--error { border-color: color-mix(in srgb, var(--ovk-bad) 50%, transparent); }
++.ovk-notice--error > .ovk-i { color: var(--ovk-bad); }
++.ovk-notice--warn > .ovk-i { color: var(--ovk-warn); }
++.ovk-notice--info > .ovk-i { color: var(--ovk-accent); }
++.ovk-snip { position: relative; }
++.ovk-snip > .ovk-icon-btn { position: absolute; top: 6px; right: 6px; z-index: 1; width: 34px; height: 34px; background: var(--ovk-panel); border-color: var(--ovk-line); }
++.ovk-snip > pre { max-height: 50vh; margin: 0 0 10px; padding: 12px 48px 12px 12px; overflow: auto; border-radius: 10px; border: 1px solid var(--ovk-line); background: var(--ovk-input); color: var(--ovk-text); font-size: .84rem; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; }
++.ovk-sheet { width: min(560px, 100vw); max-width: 100vw; max-height: 85dvh; margin: auto; padding: 0; box-sizing: border-box; border: 1px solid var(--ovk-line); border-radius: 18px; background: var(--ovk-panel); color: var(--ovk-text); overflow: hidden; }
++.ovk-sheet::backdrop { background: rgba(0, 0, 0, .55); }
++.ovk-sheet[open] { display: flex; flex-direction: column; }
++.ovk-sheet-head { display: flex; align-items: center; gap: 8px; padding: 8px 8px 8px 16px; border-bottom: 1px solid var(--ovk-line); }
++.ovk-sheet-head h2 { flex: 1; margin: 0; font-size: 1.05rem; }
++.ovk-sheet-body { padding: 14px 16px calc(16px + env(safe-area-inset-bottom, 0px)); overflow-y: auto; }
++
++/* ── Phones (< 600 px): bottom sheets, full-width forms ── */
++@media (max-width: 599px) {
++ .ovk-sheet { margin: auto 0 0; width: 100vw; border-radius: 18px 18px 0 0; border-bottom: 0; }
++ .ovk-row > a, .ovk-row--static { padding-left: 12px; }
++ .ovk-head-tools { gap: 0; }
++}
++/* ── Tablets and up (≥ 600 px): numbers in a row, roomier lists ── */
++@media (min-width: 600px) {
++ .ovk-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); margin-left: 0; margin-right: 0; padding: 0; overflow: visible; }
++ .ovk-save .ovk-btn { width: auto; min-width: 200px; }
++ .ovk-dock { margin-left: 0; margin-right: 0; padding-left: 0; padding-right: 0; }
++}
++/* ── Computers (≥ 1024 px): a side panel beside the content ── */
++.ovk-split { display: grid; gap: 16px; grid-template-columns: minmax(0, 1fr); }
++@media (min-width: 1024px) {
++ .ovk-split { grid-template-columns: minmax(0, 1fr) 320px; align-items: start; }
++ .ovk-split > .ovk-side { position: sticky; top: calc(var(--ovk-top) + 12px); }
++}
++@media (prefers-reduced-motion: reduce) { .ovk-switch, .ovk-switch::after, .ovk-fold > summary > .ovk-i, .ovk-head, .ovk-btn { transition: none; } }
++@media print { .ovk-head, .ovk-dock, .ovk-save { position: static; background: none; -webkit-backdrop-filter: none; backdrop-filter: none; } }
ddiff --git a/kit.js b/kit.js
nnew file mode 100644
iindex 0000000..b6fdec6
---- /dev/null
++++ b/kit.js
@@@ -0,0 +1,398 @@
++/**
++ * openvibe-shared/kit.js — the OpenVibe Kit: the pieces an app page is made of, one look on every OpenVibe site (and on
++ * anyone's: the package is MIT), phone first. Draw with /shared/kit.css. docs/kit.md describes every piece and how it
++ * behaves on a phone, a tablet and a computer.
++ *
++ * The owner, 2026-10-28: "the pages are so dang long… use tabs or collapsible areas… fixed position elements… on a
++ * phone it should act like an app, not a huge long web page… one app ecosystem across all our systems." So a page is:
++ * a sticky head (back, title, one muted line, icon actions) with its tabs, content in lists of rows and cards that fold,
++ * a dock pinned at the bottom for the one thing you type, and the navbar's app bar under it on phones.
++ *
++ * 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).
++ *
++ * const kit = require('openvibe-shared/kit');
++ * kit.head({ title: 'Settings', sub: 'Saved to your account', back: { href: '/', label: 'Home' },
++ * actions: [kit.iconButton({ href: '/help', icon: 'help', label: 'Help' })],
++ * tabs: kit.tabs({ base: '/settings', items: [{ key: 'look', label: 'Look' }, …], active }) })
++ * kit.tabOf(req.query, ['look', 'sound']) → the open tab (the first when none or unknown)
++ * kit.list([kit.row({ href, mark: kit.mark({ name }), title, meta: kit.when(ts).text, sub, unread: 2 }), …])
++ * kit.section({ title: 'Messages look', body: kit.switchRow({ name, label, hint, on }) + kit.rangeRow(…) + … })
++ * kit.fold({ id, title, meta, open, body }) a card that opens: setup, extras, danger
++ * kit.find({ action, name, placeholder, icon: 'search' }) a search or "add by name" row
++ * kit.dock({ action, name, placeholder, label }) the composer pinned at the bottom
++ * kit.stats([[1234, 'messages this week'], …]) a strip of numbers (scrolls sideways on phones)
++ * kit.empty({ title, text, action: { href, label } }) · kit.notice({ kind, text }) · kit.more({ label, items })
++ * kit.button({ href | type, label, icon, kind }) · kit.iconButton(…) · kit.chips(…) · kit.seg(…) · kit.icon(name)
++ * kit.mark({ name, src, hue, square }) · kit.when(ts) → { iso, text } · kit.esc(text) · kit.ICONS (names)
++ *
++ * Text arguments are escaped; arguments named body, html, before, after, actions, trail, badge, extra and mark take
++ * 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();
++ 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 () {
++ 'use strict';
++
++ const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
++ /** A URL kept only when it is http(s), a same-site path, a query or a fragment; anything else becomes '#'. */
++ const url = (u) => { const s = String(u == null ? '' : u).trim(); return /^(https?:\/\/|\/(?!\/)|\?|#)/i.test(s) ? s : '#'; };
++ const attrs = (o) => Object.keys(o || {}).filter((k) => o[k] != null && o[k] !== false && /^[a-z][a-z0-9-]*$/i.test(k)).map((k) => (o[k] === true ? ` ${k}` : ` ${k}="${esc(o[k])}"`)).join('');
++
++ // ── Icons: 24-unit line drawings in the text's colour (stroke 2, round), one set for every site ──
++ const P = {
++ back: '<path d="M15 5l-7 7 7 7"/>', forward: '<path d="m9 5 7 7-7 7"/>', chevron: '<path d="m9 6 6 6-6 6"/>',
++ up: '<path d="m6 15 6-6 6 6"/>', down: '<path d="m6 9 6 6 6-6"/>', close: '<path d="M6 6l12 12M18 6 6 18"/>',
++ plus: '<path d="M12 5v14M5 12h14"/>', minus: '<path d="M5 12h14"/>', check: '<path d="m5 12.5 4.5 4.5L19 7.5"/>',
++ send: '<path d="M12 19V5M5 12l7-7 7 7"/>', search: '<circle cx="11" cy="11" r="6.5"/><path d="m20 20-4.2-4.2"/>',
++ more: '<circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/>',
++ menu: '<path d="M4 7h16M4 12h16M4 17h16"/>', filter: '<path d="M4 6h16M7 12h10M10 18h4"/>',
++ home: '<path d="M4 11 12 4l8 7v8a1 1 0 0 1-1 1h-4v-6h-6v6H5a1 1 0 0 1-1-1Z"/>',
++ chat: '<path d="M4 5h16v11H8l-4 4Z"/>', mail: '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="m4 7 8 6 8-6"/>',
++ bell: '<path d="M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15ZM10 20a2 2 0 0 0 4 0"/>',
++ user: '<circle cx="12" cy="8" r="3.5"/><path d="M5 20c0-3.6 3.1-6 7-6s7 2.4 7 6"/>',
++ members: '<circle cx="9" cy="8" r="3.2"/><path d="M3 20c0-3.3 2.7-5.5 6-5.5s6 2.2 6 5.5M16 4.8a3.2 3.2 0 0 1 0 6.4M18 14.8c1.9.6 3 2.4 3 5.2"/>',
++ invite: '<circle cx="9" cy="8" r="3.2"/><path d="M3 20c0-3.3 2.7-5.5 6-5.5s6 2.2 6 5.5M19 8v6M16 11h6"/>',
++ gear: '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z"/>',
++ pencil: '<path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/>', trash: '<path d="M4 7h16M10 11v6M14 11v6M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12M9 7V4h6v3"/>',
++ copy: '<rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/>',
++ open: '<path d="M14 4h6v6M20 4l-9 9M18 14v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4"/>',
++ link: '<path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/>',
++ download: '<path d="M12 4v11M7 10l5 5 5-5M5 20h14"/>', upload: '<path d="M12 20V9M7 14l5-5 5 5M5 4h14"/>',
++ lock: '<rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/>',
++ key: '<circle cx="8" cy="15" r="4"/><path d="m10.8 12.2 8.7-8.7M16 7l3 3M14 9l2 2"/>',
++ ban: '<circle cx="12" cy="12" r="8.5"/><path d="m6 6 12 12"/>', help: '<circle cx="12" cy="12" r="8.5"/><path d="M9.6 9.3a2.5 2.5 0 0 1 4.8.9c0 1.7-2.4 2.2-2.4 3.8M12 17h.01"/>',
++ info: '<circle cx="12" cy="12" r="8.5"/><path d="M12 11v5M12 8h.01"/>', alert: '<path d="M12 4 2.8 19.5h18.4Z"/><path d="M12 10v4M12 17h.01"/>',
++ clock: '<circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/>', calendar: '<rect x="4" y="5" width="16" height="15" rx="2"/><path d="M4 10h16M9 3v4M15 3v4"/>',
++ spark: '<path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8ZM19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8Z"/>',
++ star: '<path d="m12 4 2.4 5 5.4.6-4 3.7 1.1 5.4L12 16l-4.9 2.7 1.1-5.4-4-3.7 5.4-.6Z"/>', heart: '<path d="M12 20s-7-4.4-7-10a4 4 0 0 1 7-2.6A4 4 0 0 1 19 10c0 5.6-7 10-7 10Z"/>',
++ play: '<path d="M8 5v14l11-7Z"/>', pause: '<path d="M8 5v14M16 5v14"/>', stop: '<circle cx="12" cy="12" r="8.5"/><path d="m8.5 8.5 7 7"/>',
++ live: '<circle cx="12" cy="12" r="2.5"/><path d="M7.8 7.8a6 6 0 0 0 0 8.4M16.2 7.8a6 6 0 0 1 0 8.4M5 5a10 10 0 0 0 0 14M19 5a10 10 0 0 1 0 14"/>',
++ video: '<rect x="3" y="6" width="13" height="12" rx="2"/><path d="m16 10 5-3v10l-5-3"/>', mic: '<rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0 0 14 0M12 18v3"/>',
++ image: '<rect x="3" y="5" width="18" height="14" rx="3"/><circle cx="9" cy="10" r="1.6"/><path d="m21 16-5-5-8 8"/>',
++ file: '<path d="M6 3h8l4 4v14H6Z"/><path d="M14 3v4h4"/>', folder: '<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/>',
++ code: '<path d="m8 8-4 4 4 4M16 8l4 4-4 4M14 5l-4 14"/>', terminal: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="m7 9 3 3-3 3M13 15h4"/>',
++ computer: '<rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8M12 16v4"/>', phone: '<path d="M5 4h4l2 5-2.5 1.5a11 11 0 0 0 5 5L15 13l5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 3 6a2 2 0 0 1 2-2"/>',
++ globe: '<circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18"/>',
++ tag: '<path d="M3 12V4h8l10 10-8 8Z"/><circle cx="7.5" cy="8.5" r="1.3"/>', cart: '<path d="M3 4h2l2.4 11h11L21 7H6.2"/><circle cx="9" cy="19.5" r="1.3"/><circle cx="17" cy="19.5" r="1.3"/>',
++ coin: '<circle cx="12" cy="12" r="8.5"/><path d="M14.5 9.2c-.5-.8-1.4-1.2-2.5-1.2-1.5 0-2.5.8-2.5 2s1 1.6 2.5 2 2.5.8 2.5 2-1 2-2.5 2c-1.1 0-2-.4-2.5-1.2M12 6.5v11"/>',
++ gift: '<rect x="4" y="9" width="16" height="11" rx="1"/><path d="M3 9h18M12 9v11M12 9c-1.5-3-5-4-5-1.5S12 9 12 9s5-1 5-3.5S13.5 6 12 9"/>',
++ trophy: '<path d="M8 4h8v5a4 4 0 0 1-8 0ZM8 6H5a3 3 0 0 0 3 4M16 6h3a3 3 0 0 1-3 4M12 13v4M8 20h8"/>',
++ flag: '<path d="M5 21V4M5 4h11l-2 4 2 4H5"/>', eye: '<path d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Z"/><circle cx="12" cy="12" r="3"/>',
++ map: '<path d="M9 4 3 6v14l6-2 6 2 6-2V4l-6 2Z"/><path d="M9 4v14M15 6v14"/>', book: '<path d="M4 5a2 2 0 0 1 2-2h13v16H6a2 2 0 0 0-2 2Z"/><path d="M4 19V5"/>',
++ game: '<rect x="2.5" y="7" width="19" height="11" rx="5"/><path d="M7 11v3M5.5 12.5h3M15.5 12h.01M18 13.5h.01"/>',
++ leave: '<path d="M14 4h4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-4M10 16l-4-4 4-4M6 12h10"/>',
++ retry: '<path d="M4 12a8 8 0 0 1 13.7-5.7L20 8.5M20 4v4.5h-4.5M20 12a8 8 0 0 1-13.7 5.7L4 15.5M4 20v-4.5h4.5"/>',
++ task: '<rect x="4" y="4" width="16" height="16" rx="3"/><path d="m8 12 3 3 5-6"/>', steps: '<path d="M9 6h11M9 12h11M9 18h11"/><path d="m3 6 1.5 1.5L7 5M3 12l1.5 1.5L7 11M3 18l1.5 1.5L7 17"/>',
++ hand: '<path d="M8 13V5.5a1.5 1.5 0 0 1 3 0V11M11 10V4.5a1.5 1.5 0 0 1 3 0V11M14 10.5V6a1.5 1.5 0 0 1 3 0v7c0 4-2.5 7-6.5 7-2.6 0-4.2-1.3-5.7-3.6L3 13.6a1.5 1.5 0 0 1 2.4-1.8L8 15"/>',
++ shield: '<path d="M12 3 5 6v6c0 4.4 3 7.6 7 9 4-1.4 7-4.6 7-9V6Z"/>', zap: '<path d="M13 3 5 13h6l-1 8 8-10h-6Z"/>',
++ cloud: '<path d="M7 18h10a4 4 0 0 0 .5-8A6 6 0 0 0 6 9.5 4.3 4.3 0 0 0 7 18Z"/>', server: '<rect x="4" y="4" width="16" height="7" rx="2"/><rect x="4" y="13" width="16" height="7" rx="2"/><path d="M8 7.5h.01M8 16.5h.01"/>',
++ sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>',
++ moon: '<path d="M20 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5Z"/>', grid: '<rect x="4" y="4" width="7" height="7" rx="1.5"/><rect x="13" y="4" width="7" height="7" rx="1.5"/><rect x="4" y="13" width="7" height="7" rx="1.5"/><rect x="13" y="13" width="7" height="7" rx="1.5"/>',
++ list: '<path d="M9 6h11M9 12h11M9 18h11M4.5 6h.01M4.5 12h.01M4.5 18h.01"/>', robot: '<rect x="5" y="8" width="14" height="11" rx="3"/><path d="M12 4v4M9 13h.01M15 13h.01M9.5 16.5h5M3 13v2M21 13v2"/>',
++ };
++ const ICONS = Object.freeze(Object.keys(P));
++ /** One icon as inline SVG (decorative: aria-hidden); '' for a name the set does not have. */
++ function icon(name, { size = null, cls = '' } = {}) {
++ const d = P[name];
++ if (!d) return '';
++ return `<svg class="ovk-i${cls ? ` ${esc(cls)}` : ''}" viewBox="0 0 24 24"${size ? ` width="${Number(size) || 20}" height="${Number(size) || 20}"` : ''} aria-hidden="true" focusable="false">${d}</svg>`;
++ }
++
++ // ── Time and marks ──
++ const DAY = 86400000;
++ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
++ const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
++ /** A time people read at a glance: 14:05 today, Yesterday, Mon this week, Oct 20 this year, else 2025-10-20 (UTC). */
++ function when(ts, now = Date.now()) {
++ const s = ts instanceof Date ? ts.toISOString() : typeof ts === 'number' ? new Date(ts).toISOString() : String(ts || '');
++ const t = Date.parse(/[TZ]/.test(s) || !s.includes(' ') ? s : `${s.replace(' ', 'T')}Z`);
++ if (!Number.isFinite(t)) return { iso: '', text: '' };
++ const d = new Date(t), n = new Date(now);
++ const today = Date.UTC(n.getUTCFullYear(), n.getUTCMonth(), n.getUTCDate());
++ let text;
++ if (t >= today) text = d.toISOString().slice(11, 16);
++ else if (t >= today - DAY) text = 'Yesterday';
++ else if (t >= today - 6 * DAY) text = WEEKDAYS[d.getUTCDay()];
++ else if (d.getUTCFullYear() === n.getUTCFullYear()) text = `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}`;
++ else text = d.toISOString().slice(0, 10);
++ return { iso: d.toISOString(), text };
++ }
++ /** A steady hue (0-359) for a name or id, so the same thing has the same colour everywhere. */
++ function hueOf(key) { let h = 0; for (const ch of String(key || '')) h = (h * 31 + ch.codePointAt(0)) % 360; return h; }
++ /** A person's or a place's mark: their picture, or up to two letters on a colour from the name (square: a place). */
++ function mark({ name = '?', src = '', hue = null, square = false, size = null } = {}) {
++ const cls = `ovk-mark${square ? ' ovk-mark--square' : ''}`;
++ const st = size ? `--ovk-mark:${Number(size) || 40}px;` : '';
++ if (src && url(src) !== '#') return `<img class="${cls}" src="${esc(url(src))}" alt="" loading="lazy" decoding="async"${st ? ` style="${st}"` : ''}>`;
++ const words = String(name || '?').replace(/[^\p{L}\p{N} ]/gu, ' ').trim().split(/\s+/).filter(Boolean);
++ const letters = (words.length > 1 ? [...words[0]][0] + [...words[1]][0] : [...(words[0] || '?')].slice(0, 2).join('')).toUpperCase();
++ return `<span class="${cls}" style="${st}--ovk-hue:${hue == null ? hueOf(name) : Number(hue) || 0}" aria-hidden="true">${esc(letters)}</span>`;
++ }
++
++ // ── Controls ──
++ /** A button or a link that looks like one. kind: 'primary' (default), 'quiet', 'danger', 'ghost'; small: a pill. */
++ function button({ href = null, type = 'submit', label = '', icon: ic = '', kind = 'primary', small = false, name = null, value = null, attrs: extra = null } = {}) {
++ const cls = `ovk-btn ovk-btn--${esc(kind)}${small ? ' ovk-btn--small' : ''}`;
++ const inner = `${ic ? icon(ic) : ''}<span>${esc(label)}</span>`;
++ if (href != null) return `<a class="${cls}" href="${esc(url(href))}"${attrs(extra)}>${inner}</a>`;
++ return `<button class="${cls}" type="${type === 'button' ? 'button' : type === 'reset' ? 'reset' : 'submit'}"${name ? ` name="${esc(name)}"` : ''}${value != null ? ` value="${esc(value)}"` : ''}${attrs(extra)}>${inner}</button>`;
++ }
++ /** An icon-only control: a link (href), or a button (type) — it always carries its label for screen readers and a tooltip. */
++ function iconButton({ href = null, type = 'submit', icon: ic = 'more', label = '', name = null, value = null, kind = '', attrs: extra = null } = {}) {
++ const cls = `ovk-icon-btn${kind ? ` ovk-icon-btn--${esc(kind)}` : ''}`;
++ const a = ` aria-label="${esc(label)}" title="${esc(label)}"${attrs(extra)}`;
++ if (href != null) return `<a class="${cls}" href="${esc(url(href))}"${a}>${icon(ic)}</a>`;
++ return `<button class="${cls}" type="${type === 'button' ? 'button' : 'submit'}"${name ? ` name="${esc(name)}"` : ''}${value != null ? ` value="${esc(value)}"` : ''}${a}>${icon(ic)}</button>`;
++ }
++ /** A one-button form (a POST action as an icon or a button), e.g. leave, delete, revoke. confirm: asked first (kit.enhance). */
++ function action({ action: to, method = 'post', hidden = {}, confirm = '', ...btn }) {
++ const fields = Object.keys(hidden || {}).map((k) => `<input type="hidden" name="${esc(k)}" value="${esc(hidden[k])}">`).join('');
++ const control = btn.label && !btn.iconOnly ? button(btn) : iconButton(btn);
++ return `<form class="ovk-inline" method="${method === 'get' ? 'get' : 'post'}" action="${esc(url(to))}"${confirm ? ` data-ovk-confirm="${esc(confirm)}"` : ''}>${fields}${control}</form>`;
++ }
++ /** A small badge beside a name: tone 'accent' | 'ok' | 'warn' | 'bad' | 'live' | '' (quiet). */
++ const badge = (text, tone = '') => `<span class="ovk-badge${tone ? ` ovk-badge--${esc(tone)}` : ''}">${esc(text)}</span>`;
++ /** An unread count: 1-9, then 9+. */
++ const dot = (n) => (Number(n) > 0 ? `<span class="ovk-dot" aria-label="${Number(n)} new">${Number(n) > 9 ? '9+' : Number(n)}</span>` : '');
++
++ // ── The page's head and its tabs ──
++ /**
++ * The sticky head every app page starts with: back (an icon), the title and one muted line, icon actions on the right,
++ * and the page's tabs under it (kit.tabs). It stays under the navbar while the page scrolls.
++ */
++ function head({ title = '', titleHtml = null, sub = '', subHtml = null, back = null, actions = [], tabs = '', level = 1, sticky = true } = {}) {
++ const h = level === 2 ? 'h2' : 'h1';
++ return `<header class="ovk-head${sticky ? '' : ' ovk-head--static'}"><div class="ovk-head-row">`
++ + (back ? `<a class="ovk-icon-btn ovk-back" href="${esc(url(back.href))}" aria-label="${esc(back.label || 'Back')}" title="${esc(back.label || 'Back')}">${icon('back')}</a>` : '')
++ + `<div class="ovk-head-id"><${h}>${titleHtml != null ? titleHtml : esc(title)}</${h}>${subHtml != null ? `<p class="ovk-sub">${subHtml}</p>` : sub ? `<p class="ovk-sub">${esc(sub)}</p>` : ''}</div>`
++ + (actions && actions.length ? `<div class="ovk-head-tools">${actions.join('')}</div>` : '')
++ + `</div>${tabs || ''}</header>`;
++ }
++ /**
++ * Tabs as links (?tab=key, or ?<param>=key): the server builds only the open one, so a long page becomes a few short
++ * ones and nothing needs JavaScript. items: [{ key, label, count, icon, href }].
++ */
++ function tabs({ base = '', items = [], active = '', param = 'tab', label = 'Sections' } = {}) {
++ if (!items || items.length < 2) return '';
++ const sep = String(base).includes('?') ? '&' : '?';
++ return `<nav class="ovk-tabs" aria-label="${esc(label)}">${items.map((t) => `<a href="${esc(url(t.href || `${base}${sep}${param}=${encodeURIComponent(t.key)}`))}"${t.key === active ? ' aria-current="page"' : ''}>${t.icon ? icon(t.icon) : ''}<span>${esc(t.label)}</span>${t.count != null && t.count !== '' ? `<span class="ovk-tab-n">${esc(t.count)}</span>` : ''}</a>`).join('')}</nav>`;
++ }
++ /** The open tab from a query object: its value when it is one of keys, else the first key. */
++ function tabOf(query, keys, param = 'tab') {
++ const v = query && query[param] != null ? String(query[param]) : '';
++ return keys.includes(v) ? v : keys[0];
++ }
++
++ // ── Lists and rows ──
++ /** A list of rows (kit.row), in one rounded panel; title: a small heading above it. */
++ function list(rows = [], { title = '', more = null, flush = false, empty = '' } = {}) {
++ const headHtml = title ? `<div class="ovk-list-head"><h2>${esc(title)}</h2>${more ? `<a href="${esc(url(more.href))}">${esc(more.label || 'All')}</a>` : ''}</div>` : '';
++ if (!rows.length) return empty ? `${headHtml}<p class="ovk-empty-line">${esc(empty)}</p>` : '';
++ return `${headHtml}<ul class="ovk-list${flush ? ' ovk-list--flush' : ''}">${rows.join('')}</ul>`;
++ }
++ /**
++ * One row: its mark, the title (with a badge) and the time on one line, one muted line under it, a trailing control.
++ * href makes the whole row a link; without it the row is static and trail can hold its own forms.
++ */
++ function row({ href = null, mark: markHtml = '', title = '', titleHtml = null, badge: badgeHtml = '', meta = '', metaIso = '', sub = '', subHtml = null, trail = '', unread = 0, attrs: extra = null } = {}) {
++ const top = `<span class="ovk-row-top"><strong>${titleHtml != null ? titleHtml : esc(title)}</strong>${badgeHtml || ''}${meta ? (metaIso ? `<time datetime="${esc(metaIso)}">${esc(meta)}</time>` : `<span class="ovk-row-meta">${esc(meta)}</span>`) : ''}</span>`;
++ const subLine = subHtml != null || sub || unread ? `<span class="ovk-row-sub"><span>${subHtml != null ? subHtml : esc(sub)}</span>${dot(unread)}</span>` : '';
++ const main = `${markHtml || ''}<span class="ovk-row-main">${top}${subLine}</span>`;
++ const cls = `ovk-row${unread ? ' ovk-row--unread' : ''}`;
++ if (href != null) return `<li class="${cls}"${attrs(extra)}><a href="${esc(url(href))}">${main}${trail ? `<span class="ovk-row-trail">${trail}</span>` : ''}</a></li>`;
++ return `<li class="${cls} ovk-row--static"${attrs(extra)}>${main}${trail ? `<span class="ovk-row-trail">${trail}</span>` : ''}</li>`;
++ }
++
++ // ── Cards, sections, folds ──
++ /** A card with a small heading (settings groups, a page's parts): body is markup; note: one muted line under the title. */
++ function section({ title = '', count = null, note = '', body = '', id = null, cls = '' } = {}) {
++ const hid = id ? `${id}-h` : null;
++ return `<section class="ovk-section${cls ? ` ${esc(cls)}` : ''}"${id ? ` id="${esc(id)}"` : ''}${hid && title ? ` aria-labelledby="${esc(hid)}"` : ''}>`
++ + (title ? `<h2${hid ? ` id="${esc(hid)}"` : ''}>${esc(title)}${count != null ? `<span class="ovk-count">${esc(count)}</span>` : ''}</h2>` : '')
++ + (note ? `<p class="ovk-note">${esc(note)}</p>` : '') + body + '</section>';
++ }
++ /** A card that opens and closes (setup, extras, the danger zone): closed unless open; meta: a muted word after the title. */
++ function fold({ id = null, title = '', meta = '', open = false, body = '', icon: ic = 'chevron', tone = '' } = {}) {
++ return `<details class="ovk-fold${tone ? ` ovk-fold--${esc(tone)}` : ''}"${id ? ` id="${esc(id)}"` : ''}${open ? ' open' : ''}><summary><span>${esc(title)}${meta ? ` <span class="ovk-muted">· ${esc(meta)}</span>` : ''}</span>${icon(ic)}</summary><div class="ovk-fold-body">${body}</div></details>`;
++ }
++ /** A "more" menu: an icon that opens a small panel of links and one-button forms. items: [{ href | action, label, icon, danger, hidden }]. */
++ function more({ label = 'More', items = [], icon: ic = 'more' } = {}) {
++ const item = (it) => {
++ const inner = `${it.icon ? icon(it.icon) : ''}<span>${esc(it.label)}</span>`;
++ const cls = `ovk-pop-item${it.danger ? ' ovk-pop-item--danger' : ''}`;
++ if (it.action) return `<form method="post" action="${esc(url(it.action))}"${it.confirm ? ` data-ovk-confirm="${esc(it.confirm)}"` : ''}>${Object.keys(it.hidden || {}).map((k) => `<input type="hidden" name="${esc(k)}" value="${esc(it.hidden[k])}">`).join('')}<button type="submit" class="${cls}" role="menuitem">${inner}</button></form>`;
++ return `<a class="${cls}" role="menuitem" href="${esc(url(it.href))}">${inner}</a>`;
++ };
++ return `<details class="ovk-more"><summary class="ovk-icon-btn" aria-label="${esc(label)}" title="${esc(label)}">${icon(ic)}</summary><div class="ovk-pop" role="menu">${items.filter(Boolean).map(item).join('')}</div></details>`;
++ }
++
++ // ── Settings rows ──
++ /** A switch row: what it does (one muted hint line when needed) on the left, the switch on the right. */
++ const switchRow = ({ name, label, hint = '', on = false, value = '1' }) => `<label class="ovk-set-row"><span class="ovk-set-text">${esc(label)}${hint ? `<small>${esc(hint)}</small>` : ''}</span><input type="checkbox" class="ovk-switch" name="${esc(name)}" value="${esc(value)}"${on ? ' checked' : ''}></label>`;
++ /** A slider row (0-100 by 5 unless told otherwise); the value is spoken as a percent when unit is '%'. */
++ function rangeRow({ name, label, value = 0, min = 0, max = 100, step = 5, unit = '%' }) {
++ const v = Number(value);
++ return `<label class="ovk-set-row ovk-set-range"><span class="ovk-set-text">${esc(label)}</span><input type="range" name="${esc(name)}" min="${Number(min)}" max="${Number(max)}" step="${Number(step)}" value="${Number.isFinite(v) ? v : Number(min)}" data-ovk-unit="${esc(unit)}" aria-valuetext="${Number.isFinite(v) ? v : Number(min)}${unit === '%' ? ' percent' : ` ${esc(unit)}`}"></label>`;
++ }
++ /** A choice row: the label on the left, a select on the right. options: [[value, label], …]. */
++ const selectRow = ({ name, label, hint = '', options = [], value = '' }) => `<label class="ovk-set-row"><span class="ovk-set-text">${esc(label)}${hint ? `<small>${esc(hint)}</small>` : ''}</span><select name="${esc(name)}">${options.map(([v, l]) => `<option value="${esc(v)}"${String(v) === String(value) ? ' selected' : ''}>${esc(l)}</option>`).join('')}</select></label>`;
++ /** A segmented choice (2-4 options) as radios that look like one control. */
++ function seg({ name, options = [], value = '', label = '' }) {
++ const id = `ovk-seg-${esc(name)}`;
++ return `<div class="ovk-set-row"><span class="ovk-set-text" id="${id}">${esc(label)}</span><span class="ovk-seg" role="radiogroup" aria-labelledby="${id}">${options.map(([v, l]) => `<label><input type="radio" name="${esc(name)}" value="${esc(v)}"${String(v) === String(value) ? ' checked' : ''}><span>${esc(l)}</span></label>`).join('')}</span></div>`;
++ }
++ /** Chips: checkboxes (or radios: single) that look like small pills, for filters and "which of these". options: [[value, label, checked]]. */
++ function chips({ name, options = [], single = false, label = '' }) {
++ const box = options.map(([v, l, on]) => `<label class="ovk-chip"><input type="${single ? 'radio' : 'checkbox'}" name="${esc(single ? name : `${name}_${v}`)}" value="${esc(single ? v : '1')}"${on ? ' checked' : ''}><span>${esc(l)}</span></label>`).join('');
++ return label ? `<div class="ovk-set-row ovk-set-chips"><span class="ovk-set-text">${esc(label)}</span><span class="ovk-chips">${box}</span></div>` : `<span class="ovk-chips">${box}</span>`;
++ }
++ /** A form's last line that stays in reach (sticky above the dock/app bar on phones). */
++ const saveBar = ({ label = 'Save', name = null, value = null } = {}) => `<p class="ovk-save">${button({ label, name, value })}</p>`;
++
++ // ── Search, add, compose ──
++ /**
++ * A one-line form in a pill: an icon, the input, optional extra controls (a select), and a round icon button. For
++ * search (method get) and for "add by name" (post). hidden: { name: value } fields.
++ */
++ function find({ action: to = '', method = 'get', name = 'q', placeholder = '', value = '', label = '', icon: ic = 'search', type = 'search', required = false, pattern = null, maxlength = null, extra = '', hidden = {}, button: btn = null, inset = false, id = null } = {}) {
++ const fid = id || `ovk-find-${esc(name)}`;
++ const b = btn || { icon: method === 'get' ? 'forward' : 'plus', label: method === 'get' ? 'Search' : 'Add' };
++ return `<form class="ovk-find${inset ? ' ovk-find--inset' : ''}" method="${method === 'post' ? 'post' : 'get'}" action="${esc(url(to || '?'))}"${method === 'get' ? ' role="search"' : ''}>`
++ + Object.keys(hidden || {}).map((k) => `<input type="hidden" name="${esc(k)}" value="${esc(hidden[k])}">`).join('')
++ + `${ic ? icon(ic) : ''}<label for="${fid}" class="ovk-sr">${esc(label || placeholder)}</label><input id="${fid}" name="${esc(name)}" type="${type === 'text' ? 'text' : 'search'}" value="${esc(value)}" placeholder="${esc(placeholder)}" autocomplete="off"${required ? ' required' : ''}${pattern ? ` pattern="${esc(pattern)}"` : ''}${maxlength ? ` maxlength="${Number(maxlength)}"` : ''}>`
++ + `${extra || ''}<button type="submit" class="ovk-go" aria-label="${esc(b.label)}" title="${esc(b.label)}">${icon(b.icon)}</button></form>`;
++ }
++ /**
++ * The composer pinned at the bottom of a conversation (above the app bar on phones): a one-line box that grows, Enter
++ * sends (Shift+Enter: a new line; kit.enhance), a round send button. before: controls left of the box (attach).
++ */
++ function dock({ action: to = '', name = 'message', placeholder = 'Write a message', label = 'Message', maxlength = 2000, id = 'ovk-dock', before = '', hidden = {}, send = 'Send', value = '' } = {}) {
++ return `<div class="ovk-dock">${before || ''}<form class="ovk-compose" method="post" action="${esc(url(to))}" id="${esc(id)}">`
++ + Object.keys(hidden || {}).map((k) => `<input type="hidden" name="${esc(k)}" value="${esc(hidden[k])}">`).join('')
++ + `<label for="${esc(id)}-input" class="ovk-sr">${esc(label)}</label><textarea id="${esc(id)}-input" name="${esc(name)}" rows="1" maxlength="${Number(maxlength) || 2000}" required placeholder="${esc(placeholder)}" data-ovk-enter>${esc(value)}</textarea>`
++ + `<button type="submit" class="ovk-go ovk-go--send" aria-label="${esc(send)}" title="${esc(send)}">${icon('send')}</button></form></div>`;
++ }
++
++ // ── Numbers, empty, notices, code ──
++ /** A strip of numbers: [[value, label], …] (zero and unknown values left out); scrolls sideways on phones. */
++ function stats(items = [], { label = 'In numbers' } = {}) {
++ const fmt = (v) => (typeof v === 'number' ? v.toLocaleString('en-US') : String(v));
++ const shown = items.filter((x) => x && x[0] != null && x[0] !== '' && x[0] !== 0);
++ if (!shown.length) return '';
++ return `<dl class="ovk-stats" aria-label="${esc(label)}">${shown.map(([v, l, href]) => `<div${href ? ' class="ovk-stat--link"' : ''}><dt>${href ? `<a href="${esc(url(href))}">${esc(l)}</a>` : esc(l)}</dt><dd>${esc(fmt(v))}</dd></div>`).join('')}</dl>`;
++ }
++ /** What an empty list says: a title, one line, and the one thing to do next. */
++ const empty = ({ title = '', text = '', action: act = null, icon: ic = '' } = {}) => `<div class="ovk-empty">${ic ? icon(ic) : ''}${title ? `<p class="ovk-empty-title">${esc(title)}</p>` : ''}${text ? `<p>${esc(text)}</p>` : ''}${act ? button({ href: act.href, label: act.label, kind: act.kind || 'primary', icon: act.icon || '' }) : ''}</div>`;
++ /** A notice in the page: kind 'ok' | 'error' | 'info' | 'warn'; errors are announced (role=alert). */
++ const notice = ({ kind = 'info', text = '', html = null } = {}) => `<p class="ovk-notice ovk-notice--${esc(kind)}" role="${kind === 'error' ? 'alert' : 'status'}">${icon(kind === 'ok' ? 'check' : kind === 'error' || kind === 'warn' ? 'alert' : 'info')}<span>${html != null ? html : esc(text)}</span></p>`;
++ /** A block of code people copy: the copy button in its corner (shown by kit.enhance), never taller than half a screen. */
++ function snippet({ code = '', id = null, label = 'Copy' } = {}) {
++ const sid = id || `ovk-snip-${hueOf(code)}-${String(code).length}`;
++ return `<div class="ovk-snip"><button type="button" class="ovk-icon-btn" data-ovk-copy="#${esc(sid)}" aria-label="${esc(label)}" title="${esc(label)}" hidden>${icon('copy')}</button><pre tabindex="0" id="${esc(sid)}"><code>${esc(code)}</code></pre></div>`;
++ }
++ /** A bottom sheet on phones, a dialog on computers: kit.sheetButton opens it (without JavaScript the sheet is a fold). */
++ const sheet = ({ id, title = '', body = '' }) => `<dialog class="ovk-sheet" id="${esc(id)}" aria-labelledby="${esc(id)}-t"><div class="ovk-sheet-head"><h2 id="${esc(id)}-t">${esc(title)}</h2><button type="button" class="ovk-icon-btn" data-ovk-close aria-label="Close" title="Close">${icon('close')}</button></div><div class="ovk-sheet-body">${body}</div></dialog>`;
++ const sheetButton = ({ sheet: sid, label = '', icon: ic = 'filter', iconOnly = true }) => (iconOnly
++ ? `<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' }); });
++ }
++
++ return {
++ esc, url, icon, ICONS, when, hueOf, mark, button, iconButton, action, badge, dot,
++ head, tabs, tabOf, list, row, section, fold, more,
++ switchRow, rangeRow, selectRow, seg, chips, saveBar, find, dock, stats, empty, notice, snippet, sheet, sheetButton,
++ enhance, VERSION: 1,
++ };
++});
ddiff --git a/navbar.js b/navbar.js
iindex 8ec5d12..5eb23f7 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -1801,9 +1801,22 @@
attachAvatarFallbacks(nav);
hookTop();
loadWalletChips();
++ renderAppBar();
return nav;
}
++ // ── The phone app bar (appbar.js, loaded only when the site sets appBar): its main places as a bottom tab bar ──
++ let _appBarLoading = false;
++ const rawLinks = () => _runtimeLinks || _config.links || SERVICE_LINKS[_config.service] || [];
++ function renderAppBar() {
++ if (typeof document === 'undefined' || !document.body) return;
++ const ab = root.OpenVibeAppBar;
++ if (ab) { ab.render({ config: _config, links: rawLinks(), activePage: _activePage, nav: _navEl, navIcon, escapeAttr, escapeHtml, flatLinks }); return; }
++ if (!_config.appBar || _appBarLoading) return;
++ _appBarLoading = true;
++ try { const sc = document.createElement('script'); sc.src = sibling('appbar.js'); sc.async = true; sc.onload = () => renderAppBar(); document.head.appendChild(sc); } catch { /* no bar */ }
++ }
++
// The bar's at-the-top state (ovnav-top, see injectStyles), on every render and scroll. One window listener pair for
// the page's life, a frame at most per update; --ovnav-real-h is the bar's height for overlay pages.
let _topHooked = false;
@@@ -1873,6 +1886,9 @@
/** Replace this site's top links at runtime ([{label, href, icon?, active?, external?}]). */
setLinks(links) { _runtimeLinks = Array.isArray(links) ? links : null; if (_navEl) render(); },
++ /** The phone app bar: true (from the links that have icons), a list of { label, href, icon, id, page, dot }, or false. */
++ setAppBar(spec) { _config.appBar = spec; renderAppBar(); },
++
/** Add a row to the account dropdown: {label, href|onClick, icon, danger, external, position:'before'|'after'}. */
addMenuItem(item) {
if (!item || !item.label) return;
@@@ -1896,7 +1912,7 @@
/** The signed-in user changed (sites with auth: 'external'). Pass null for signed out. */
setUser(user, token) { _config.user = user || null; if (token !== undefined) _config.token = token; _walletLoaded = false; return render(); },
/** Single-page apps: mark the link whose `page` matches as active. */
-- setActive(page) { _activePage = page || null; if (!_navEl) return; _navEl.querySelectorAll('.ovnav-link[data-page]').forEach(a => a.classList.toggle('active', a.getAttribute('data-page') === _activePage)); },
++ setActive(page) { _activePage = page || null; renderAppBar(); if (!_navEl) return; _navEl.querySelectorAll('.ovnav-link[data-page]').forEach(a => a.classList.toggle('active', a.getAttribute('data-page') === _activePage)); },
/** Change a status chip's text (navbar and menu header): setChip('coins', '1,234'). */
setChip(id, value, patch) {
const lists = [_config.chips || [], (_config.menu && _config.menu.headerChips) || []];
ddiff --git a/package.json b/package.json
iindex 2f37ed6..f9029e6 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.19.0",
++ "version": "4.20.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
@@@ -59,6 +59,8 @@
"./serve": "./serve.js",
"./shell": "./shell.js",
"./showcase": "./showcase.js",
++ "./kit": "./kit.js",
++ "./appbar": "./appbar.js",
"./social": "./social.js",
"./shipped": "./shipped.js",
"./sso-client": "./sso-client.js",
ddiff --git a/scripts/kit-gallery.js b/scripts/kit-gallery.js
nnew file mode 100644
iindex 0000000..4c9484f
---- /dev/null
++++ b/scripts/kit-gallery.js
@@@ -0,0 +1,102 @@
++#!/usr/bin/env node
++'use strict';
++/**
++ * The OpenVibe Kit's gallery: one page with every piece (kit.js / kit.css) and the navbar's app bar, built only from
++ * the kit's own builders, so it is both the visual test and the picture docs/kit.md describes.
++ *
++ * node scripts/kit-gallery.js > gallery.html the page (it loads ./navbar.js, ./kit.js, ./kit.css…)
++ * node scripts/kit-gallery.js --shots <dir> [--chrome <path>] serves this folder, screenshots it at 390, 768 and
++ * 1280 px wide, and prints sideways scroll per width
++ */
++const fs = require('fs');
++const path = require('path');
++const http = require('http');
++const kit = require('../kit');
++
++function gallery() {
++ const k = kit;
++ const settingsTabs = k.tabs({ base: '/kit', items: [{ key: 'look', label: 'Look' }, { key: 'privacy', label: 'Privacy' }, { key: 'sound', label: 'Sound', count: 2 }, { key: 'data', label: 'Data and AI' }], active: 'look' });
++ const now = Date.now();
++ const rows = [
++ k.row({ href: '/r/night-owls', mark: k.mark({ name: 'Night Owls' }), title: 'Night Owls', meta: k.when(now - 6e5).text, sub: 'Ann: see you at nine?', unread: 3 }),
++ k.row({ href: '/r/raiders', mark: k.mark({ name: 'Saturday Raiders', square: true }), title: 'Saturday Raiders', badge: k.badge('official', 'accent'), meta: 'Yesterday', sub: 'Raids on Saturday nights' }),
++ k.row({ href: '/@goosely', mark: k.mark({ name: 'Goosely' }), title: 'Goosely', badge: k.badge('live', 'live'), meta: 'Mon', sub: 'Seattle Maybe 🤔 · 41 watching' }),
++ ];
++ const members = [
++ k.row({ mark: k.mark({ name: 'Ann Lee' }), title: 'Ann Lee', sub: '@ann · seen 14:05', trail: '<form class="ovk-inline"><select aria-label="Role"><option>member</option><option>moderator</option></select>' + k.iconButton({ icon: 'check', label: 'Set' }) + '</form>' }),
++ k.row({ mark: k.mark({ name: 'Bo' }), title: 'Bo', sub: '@bo · seen Yesterday', trail: '<span class="ovk-muted">owner</span>' }),
++ ];
++ const body = `
++${k.head({ title: 'Settings', sub: 'Saved to your account, on every OpenVibe site', back: { href: '/', label: 'Home' }, actions: [k.iconButton({ href: '#help', icon: 'help', label: 'Help' }), k.more({ label: 'More', items: [{ href: '#export', label: 'Export', icon: 'download' }, { action: '#reset', label: 'Reset everything', icon: 'trash', danger: true, confirm: 'Reset everything?' }] })], tabs: settingsTabs })}
++${k.notice({ kind: 'ok', text: 'Saved. These settings follow you to every device.' })}
++${k.stats([[12345, 'messages this week'], [48, 'people chatting'], [7, 'rooms live'], [1200000, 'messages in all']])}
++${k.find({ action: '/search', placeholder: 'Search rooms and people' })}
++${k.list(rows, { title: 'Your rooms', more: { href: '/rooms', label: 'All' } })}
++<form class="ovk-form" onsubmit="return false">
++${k.section({ title: 'Messages look', body: k.switchRow({ name: 'timestamps', label: 'Times next to messages', on: true }) + k.switchRow({ name: 'compact', label: 'Compact messages', hint: 'Less space between lines' }) + k.seg({ name: 'size', label: 'Text size', options: [['s', 'Small'], ['m', 'Default'], ['l', 'Large']], value: 'm' }) + k.rangeRow({ name: 'vol', label: 'Speech volume', value: 70 }) + k.selectRow({ name: 'who', label: 'Who can message you', options: [['all', 'Everyone'], ['none', 'Nobody']], value: 'all' }) + k.chips({ name: 'src', label: 'Read messages from', options: [['ov', 'OpenVibe', true], ['tw', 'Twitch', true], ['yt', 'YouTube', false]] })})}
++${k.saveBar({ label: 'Save settings' })}
++</form>
++${k.section({ title: 'Members', count: 2, note: 'Members write; moderators delete messages and manage members.', body: k.find({ action: '#add', method: 'post', name: 'username', placeholder: 'Add by username', icon: 'plus', inset: true }) + k.list(members, { flush: true }) })}
++${k.fold({ id: 'embed', title: 'Put this room on your site', meta: 'two ways', body: k.snippet({ code: '<script src="https://openvibe.chat/widget.js" data-room="night-owls" async></script>', id: 'snip-1' }) })}
++${k.fold({ title: 'Delete the room', tone: 'danger', body: '<p class="ovk-muted">Everything in it goes, for everyone.</p>' + k.action({ action: '#delete', label: 'Delete the room', kind: 'danger', confirm: 'Delete the room?' }) })}
++${k.empty({ icon: 'chat', title: 'No public rooms yet', text: 'Start the first one: public, or private to the people you invite.', action: { href: '#new', label: 'Start a room', icon: 'plus' } })}
++<p>${k.button({ href: '#a', label: 'Primary' })} ${k.button({ href: '#b', label: 'Quiet', kind: 'quiet' })} ${k.button({ href: '#c', label: 'Small', kind: 'quiet', small: true, icon: 'plus' })} ${k.button({ type: 'button', label: 'Danger', kind: 'danger' })}</p>
++${k.dock({ action: '#send', placeholder: 'Message #general' })}`;
++ return `<!doctype html>
++<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
++<title>OpenVibe Kit</title>
++<script src="theme-loader.js"></script>
++<link rel="stylesheet" href="kit.css">
++<style>body { margin: 0; background: var(--bg-primary, #0b0f17); color: var(--text-primary, #e8ecf3); font-family: 'Inter', system-ui, sans-serif; }</style>
++</head><body>
++<div id="navbar-mount"></div>
++<main class="ovk-page">${body}</main>
++<script src="navbar.js"></script>
++<script src="kit.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' },
++ { label: 'Messages', href: '/messages', icon: 'fa-comment-dots', dot: true }, { label: 'Servers', href: '/servers', icon: 'fa-server' } ], appBar: true });</script>
++</body></html>`;
++}
++
++async function shots(dir, chromePath) {
++ const root = path.join(__dirname, '..');
++ const page = gallery();
++ const server = http.createServer((req, res) => {
++ const name = decodeURIComponent(new URL(req.url, 'http://x').pathname.slice(1));
++ if (!name || name === 'kit') { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(page); }
++ const file = path.join(root, path.basename(name));
++ if (!fs.existsSync(file)) { res.writeHead(404); return res.end(); }
++ res.writeHead(200, { 'content-type': file.endsWith('.css') ? 'text/css' : 'text/javascript' });
++ fs.createReadStream(file).pipe(res);
++ }).listen(0, '127.0.0.1');
++ await new Promise((r) => server.once('listening', r));
++ const base = `http://127.0.0.1:${server.address().port}/kit`;
++ const { chromium } = require(require.resolve('playwright', { paths: [process.cwd(), '/home/workstation/openvibe/work/OpenVibe.Games'] }));
++ const browser = await chromium.launch(chromePath ? { executablePath: chromePath } : {});
++ fs.mkdirSync(dir, { recursive: true });
++ for (const [w, h, mobile] of [[390, 844, true], [768, 1024, true], [1280, 900, false]]) {
++ const ctx = await browser.newContext({ viewport: { width: w, height: h }, isMobile: mobile, hasTouch: mobile, colorScheme: 'dark' });
++ const p = await ctx.newPage();
++ await p.goto(base, { waitUntil: 'networkidle' });
++ await p.waitForTimeout(400);
++ await p.screenshot({ path: path.join(dir, `kit-${w}.png`), fullPage: true });
++ await p.evaluate(() => window.scrollTo(0, 900));
++ await p.waitForTimeout(250);
++ await p.screenshot({ path: path.join(dir, `kit-${w}-scrolled.png`) });
++ const m = await p.evaluate(() => ({ overflow: document.documentElement.scrollWidth - innerWidth, height: document.documentElement.scrollHeight, appbar: !!document.querySelector('.ovnav-appbar') && getComputedStyle(document.querySelector('.ovnav-appbar')).display !== 'none' }));
++ console.log(`${w}px: sideways ${m.overflow}, height ${m.height}, app bar ${m.appbar ? 'shown' : 'hidden'}`);
++ await ctx.close();
++ }
++ await browser.close();
++ server.close();
++}
++
++if (require.main === module) {
++ const a = process.argv.slice(2);
++ const i = a.indexOf('--shots');
++ if (i >= 0) shots(a[i + 1] || 'kit-shots', a.includes('--chrome') ? a[a.indexOf('--chrome') + 1] : null).catch((e) => { console.error(e); process.exit(1); });
++ else process.stdout.write(gallery());
++}
++module.exports = { gallery };
ddiff --git a/test/kit.test.js b/test/kit.test.js
nnew file mode 100644
iindex 0000000..5b45dee
---- /dev/null
++++ b/test/kit.test.js
@@@ -0,0 +1,104 @@
++'use strict';
++// kit.js — the OpenVibe Kit's builders: complete, escaped markup a page can send as is (docs/kit.md).
++const assert = require('assert');
++const kit = require('../kit');
++
++// ── Escaping and URLs: text is always escaped, a URL that is not http(s) or same-site becomes '#' ──
++{
++ const h = kit.head({ title: '<b>Hi</b>', sub: '"quoted" & more', back: { href: 'javascript:alert(1)', label: 'Back <x>' } });
++ assert.ok(h.includes('&lt;b&gt;Hi&lt;/b&gt;') && !h.includes('<b>Hi'), 'the title is escaped');
++ assert.ok(h.includes('&quot;quoted&quot; &amp; more'), 'the sub line is escaped');
++ assert.ok(h.includes('href="#"') && !h.includes('javascript:'), 'a script URL never lands in an href');
++ assert.ok(h.includes('aria-label="Back &lt;x&gt;"'), 'labels are escaped');
++ for (const ok of ['https://openvibe.chat/x', '/rooms?x=1', '?tab=a', '#top']) assert.strictEqual(kit.url(ok), ok, ok);
++ for (const bad of ['javascript:x', '//evil.example/', 'data:text/html,x', ' vbscript:x']) assert.strictEqual(kit.url(bad), '#', bad);
++ assert.ok(kit.row({ href: '//evil.example', title: 'x' }).includes('href="#"'), 'protocol-relative links are refused');
++ assert.ok(kit.mark({ name: 'A', src: 'javascript:x' }).startsWith('<span'), 'a bad picture URL falls back to letters');
++}
++
++// ── The head and its tabs ──
++{
++ const tabs = kit.tabs({ base: '/settings', items: [{ key: 'look', label: 'Look' }, { key: 'sound', label: 'Sound', count: 2 }], active: 'sound' });
++ assert.ok(tabs.includes('href="/settings?tab=look"') && tabs.includes('href="/settings?tab=sound" aria-current="page"'));
++ assert.ok(tabs.includes('<span class="ovk-tab-n">2</span>'));
++ assert.ok(kit.tabs({ base: '/x?a=1', items: [{ key: 'a', label: 'A' }, { key: 'b', label: 'B' }] }).includes('href="/x?a=1&amp;tab=a"'), 'a base with a query gets &');
++ assert.strictEqual(kit.tabs({ base: '/x', items: [{ key: 'only', label: 'Only' }] }), '', 'one tab is no tabs');
++ assert.strictEqual(kit.tabOf({ tab: 'sound' }, ['look', 'sound']), 'sound');
++ assert.strictEqual(kit.tabOf({ tab: 'nope' }, ['look', 'sound']), 'look', 'unknown → the first');
++ assert.strictEqual(kit.tabOf({}, ['look', 'sound']), 'look');
++ assert.strictEqual(kit.tabOf({ view: 'b' }, ['a', 'b'], 'view'), 'b', 'another parameter name');
++ const h = kit.head({ title: 'Settings', tabs, actions: [kit.iconButton({ href: '/help', icon: 'help', label: 'Help' })] });
++ assert.ok(/^<header class="ovk-head">/.test(h) && h.indexOf('ovk-tabs') > h.indexOf('ovk-head-row'), 'the tabs sit inside the sticky head');
++ assert.ok(h.includes('<h1>Settings</h1>') && h.includes('aria-label="Help" title="Help"'));
++ assert.ok(kit.head({ title: 'x', sticky: false }).includes('ovk-head--static'));
++}
++
++// ── Rows and lists ──
++{
++ const r = kit.row({ href: '/r/a', mark: kit.mark({ name: 'Night Owls' }), title: 'Night Owls', meta: '14:05', metaIso: '2026-10-28T14:05:00Z', sub: 'Late chats', unread: 12 });
++ assert.ok(r.startsWith('<li class="ovk-row ovk-row--unread">') && r.includes('<a href="/r/a">'));
++ assert.ok(r.includes('<time datetime="2026-10-28T14:05:00Z">14:05</time>'));
++ assert.ok(r.includes('aria-label="12 new">9+<'), 'more than nine reads 9+');
++ assert.ok(r.includes('>NO</span>'), 'two initials');
++ const s = kit.row({ title: 'ann', trail: '<form>…</form>' });
++ assert.ok(s.includes('ovk-row--static') && !s.includes('<a '), 'a row without href is static with its own controls');
++ assert.strictEqual(kit.list([]), '', 'an empty list without an empty line is nothing');
++ assert.ok(kit.list([], { title: 'Rooms', empty: 'None yet' }).includes('<p class="ovk-empty-line">None yet</p>'));
++ assert.ok(kit.list([r], { title: 'Rooms', more: { href: '/rooms', label: 'All' } }).includes('<a href="/rooms">All</a>'));
++ assert.strictEqual(kit.mark({ name: 'Ann' }), kit.mark({ name: 'Ann' }), 'the same name, the same colour');
++ assert.ok(kit.mark({ name: 'x', square: true }).includes('ovk-mark--square'));
++ assert.ok(kit.mark({ name: 'émile zola' }).includes('>ÉZ<'), 'letters beyond ASCII');
++}
++
++// ── Settings rows, folds, menus ──
++{
++ assert.ok(kit.switchRow({ name: 'compact', label: 'Compact', hint: 'Less space', on: true }).includes('class="ovk-switch" name="compact" value="1" checked'));
++ assert.ok(kit.rangeRow({ name: 'v', label: 'Volume', value: 40 }).includes('value="40"') && kit.rangeRow({ name: 'v', label: 'V', value: 40 }).includes('aria-valuetext="40 percent"'));
++ assert.ok(kit.selectRow({ name: 'who', label: 'Who', options: [['all', 'Everyone'], ['none', 'Nobody']], value: 'none' }).includes('<option value="none" selected>Nobody</option>'));
++ const seg = kit.seg({ name: 'size', label: 'Text size', options: [['s', 'Small'], ['m', 'Default']], value: 'm' });
++ assert.ok(seg.includes('role="radiogroup"') && seg.includes('value="m" checked'));
++ assert.ok(kit.chips({ name: 'src', options: [['kick', 'Kick', true], ['yt', 'YouTube', false]] }).includes('name="src_kick" value="1" checked'));
++ const f = kit.fold({ id: 'oc-ai', title: 'AI members', meta: '2', body: '<p>x</p>' });
++ assert.ok(f.startsWith('<details class="ovk-fold" id="oc-ai">') && !f.includes(' open'));
++ assert.ok(kit.fold({ title: 'x', open: true, tone: 'danger' }).includes('ovk-fold--danger" open'));
++ const m = kit.more({ label: 'More', items: [{ href: '/a', label: 'Open' }, { action: '/b/delete', label: 'Delete', danger: true, confirm: 'Delete it?' }, null] });
++ assert.ok(m.includes('role="menu"') && m.includes('data-ovk-confirm="Delete it?"') && m.includes('ovk-pop-item--danger'));
++ assert.ok(kit.section({ title: 'Members', count: 3, note: 'Who', body: '<ul></ul>', id: 'mem' }).includes('aria-labelledby="mem-h"'));
++}
++
++// ── Find, dock, numbers, notices ──
++{
++ const f = kit.find({ action: '/search', placeholder: 'Search rooms' });
++ assert.ok(f.includes('role="search"') && f.includes('method="get"') && f.includes('aria-label="Search"'));
++ const add = kit.find({ action: '/r/a/members', method: 'post', name: 'username', placeholder: 'Add by username', icon: 'plus', hidden: { role: 'member' }, required: true, pattern: '[A-Za-z0-9_]{3,24}' });
++ assert.ok(add.includes('method="post"') && add.includes('<input type="hidden" name="role" value="member">') && add.includes('required pattern="[A-Za-z0-9_]{3,24}"'));
++ const d = kit.dock({ action: '/r/a', placeholder: 'Message #general' });
++ assert.ok(d.includes('class="ovk-dock"') && d.includes('data-ovk-enter') && d.includes('rows="1"') && d.includes('aria-label="Send"'));
++ const st = kit.stats([[1234, 'messages'], [0, 'hidden'], [null, 'unknown'], ['12k', 'views']]);
++ assert.ok(st.includes('<dd>1,234</dd>') && st.includes('<dd>12k</dd>') && !st.includes('hidden') && !st.includes('unknown'));
++ assert.strictEqual(kit.stats([[0, 'none']]), '', 'nothing to show, no strip');
++ assert.ok(kit.notice({ kind: 'error', text: 'No' }).includes('role="alert"'));
++ assert.ok(kit.notice({ kind: 'ok', text: 'Saved' }).includes('role="status"'));
++ assert.ok(kit.empty({ title: 'No rooms', text: 'Start one', action: { href: '/rooms?new=1', label: 'Start a room' } }).includes('href="/rooms?new=1"'));
++ const sn = kit.snippet({ code: '<script src="x"></script>', id: 'snip' });
++ assert.ok(sn.includes('data-ovk-copy="#snip"') && sn.includes('hidden') && sn.includes('&lt;script'));
++ assert.ok(kit.action({ action: '/s/x/leave', icon: 'leave', label: 'Leave', confirm: 'Leave it?' }).includes('data-ovk-confirm="Leave it?"'));
++}
++
++// ── Icons, time ──
++{
++ assert.ok(kit.ICONS.length >= 60 && kit.ICONS.includes('send') && kit.ICONS.includes('gear'));
++ for (const n of kit.ICONS) assert.ok(kit.icon(n).startsWith('<svg class="ovk-i" viewBox="0 0 24 24"') && kit.icon(n).includes('aria-hidden="true"'), n);
++ assert.strictEqual(kit.icon('no-such-icon'), '');
++ const now = Date.parse('2026-10-28T15:00:00Z');
++ assert.strictEqual(kit.when('2026-10-28 14:05:00', now).text, '14:05');
++ assert.strictEqual(kit.when('2026-10-27T23:00:00Z', now).text, 'Yesterday');
++ assert.strictEqual(kit.when('2026-10-24T10:00:00Z', now).text, 'Sat');
++ assert.strictEqual(kit.when('2026-03-02T10:00:00Z', now).text, 'Mar 2');
++ assert.strictEqual(kit.when('2025-03-02T10:00:00Z', now).text, '2025-03-02');
++ 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');
++}
++
++console.log('kit: builders, escaping, tabs, rows, settings, find/dock, icons and time ok');
ddiff --git a/test/navbar-appbar.test.js b/test/navbar-appbar.test.js
nnew file mode 100644
iindex 0000000..ed84b7f
---- /dev/null
++++ b/test/navbar-appbar.test.js
@@@ -0,0 +1,109 @@
++'use strict';
++// navbar.js — the phone app bar (appBar): a site's main places as a bottom tab bar, built from its own links or a list,
++// with More for the rest, the open page marked, and nothing at all unless the site asks for it.
++const assert = require('assert');
++const path = require('path');
++const NAVBAR_PATH = path.join(__dirname, '..', 'navbar.js');
++const APPBAR_PATH = path.join(__dirname, '..', 'appbar.js');
++
++function stubElement(tag) {
++ const el = {
++ tag, style: { setProperty() {} }, textContent: '', className: '', id: '', innerHTML: '', dataset: {}, attrs: {}, children: [], removed: false,
++ classes: new Set(),
++ setAttribute(k, v) { this.attrs[k] = String(v); }, getAttribute(k) { return this.attrs[k] ?? null; },
++ appendChild(c) { this.children.push(c); return c; }, prepend(c) { this.children.unshift(c); }, remove() { this.removed = true; }, addEventListener() {},
++ querySelector() { return null; }, querySelectorAll() { return []; },
++ };
++ el.classList = { add: (c) => el.classes.add(c), remove: (c) => el.classes.delete(c), toggle() {}, contains: (c) => el.classes.has(c) };
++ return el;
++}
++
++function load(pathname = '/', { preload = true } = {}) {
++ global.location = { hostname: 'openvibe.chat', href: `https://openvibe.chat${pathname}`, pathname, protocol: 'https:', origin: 'https://openvibe.chat' };
++ global.window = { location: global.location };
++ try { Object.defineProperty(global, 'navigator', { value: { userAgent: 'test' }, configurable: true }); } catch { /* */ }
++ const body = stubElement('body');
++ const html = stubElement('html');
++ global.document = {
++ cookie: '', head: stubElement('head'), body, documentElement: html, title: 'T',
++ getElementById: () => null, createElement: (tag) => stubElement(tag),
++ addEventListener() {}, removeEventListener() {}, dispatchEvent() {},
++ };
++ global.CustomEvent = class { constructor(t, i) { this.type = t; this.detail = i && i.detail; } };
++ const store = {};
++ global.localStorage = { getItem: (k) => (k in store ? store[k] : null), setItem: (k, v) => { store[k] = String(v); }, removeItem: (k) => { delete store[k]; } };
++ global.sessionStorage = { getItem: () => null, setItem() {}, removeItem() {} };
++ global.fetch = async () => ({ ok: false, status: 404, json: async () => ({}) });
++ delete globalThis.OpenVibeAppBar;
++ delete require.cache[require.resolve(APPBAR_PATH)];
++ if (preload) require(APPBAR_PATH); // what the page has once navbar.js has fetched appbar.js
++ delete require.cache[require.resolve(NAVBAR_PATH)];
++ return { navbar: require(NAVBAR_PATH), body, html, head: global.document.head };
++}
++const barOf = (body) => body.children.filter((c) => c.className === 'ovnav-appbar' && !c.removed).pop() || null;
++const LINKS = [
++ { label: 'Vibe', href: '/vibe', icon: 'fa-wand-magic-sparkles' },
++ { label: 'Global chat', href: '/', icon: 'fa-comments' },
++ { label: 'Rooms', href: '/rooms', icon: 'fa-people-group' },
++ { label: 'Servers', href: '/servers', icon: 'fa-server' },
++ { label: 'Messages', href: '/messages', icon: 'fa-comment-dots', dot: true },
++ { label: 'Settings', href: '/settings', icon: 'fa-gear' },
++];
++
++{ // Not asked for: no bar, no class, and appbar.js is never fetched.
++ const { navbar, body, html, head } = load('/rooms', { preload: false });
++ navbar.init({ service: 'chat', links: LINKS });
++ assert.strictEqual(barOf(body), null);
++ assert.ok(!html.classList.contains('ov-has-appbar'));
++ assert.ok(!head.children.some((c) => c.tag === 'script' && /appbar\.js/.test(c.src || '')), 'nothing fetched');
++}
++{ // Asked for: navbar.js fetches appbar.js (once) and draws the bar when it arrives.
++ const { navbar, head } = load('/rooms', { preload: false });
++ navbar.init({ service: 'chat', links: LINKS, appBar: true });
++ navbar.setActive('rooms');
++ const scripts = head.children.filter((c) => c.tag === 'script' && /appbar\.js$/.test(c.src || ''));
++ assert.strictEqual(scripts.length, 1, 'fetched once');
++ assert.strictEqual(typeof scripts[0].onload, 'function');
++}
++{ // appBar: true → the first four links with icons and More (six links); the open page marked, under its path too.
++ const { navbar, body, html } = load('/rooms/night-owls');
++ navbar.init({ service: 'chat', links: LINKS, appBar: true });
++ const bar = barOf(body);
++ assert.ok(bar, 'the bar is in the page');
++ assert.strictEqual(bar.attrs['aria-label'], 'App');
++ assert.ok(html.classList.contains('ov-has-appbar'), 'pages keep their bottom');
++ const items = bar.innerHTML.match(/<a href="[^"]*"/g);
++ assert.deepStrictEqual(items, ['<a href="/vibe"', '<a href="/"', '<a href="/rooms"', '<a href="/servers"']);
++ assert.ok(bar.innerHTML.includes('data-ab-more') && bar.innerHTML.includes('>More<'), 'More for the rest');
++ assert.ok(/<a href="\/rooms" class="ovnav-ab is-here" aria-current="page">/.test(bar.innerHTML), 'a page under /rooms marks Rooms');
++ assert.ok(!/<a href="\/" class="ovnav-ab is-here"/.test(bar.innerHTML), 'the home link is not open on /rooms/…');
++}
++{ // On the front page, home is the open one; five links with icons fit without More.
++ const { navbar, body } = load('/');
++ navbar.init({ service: 'chat', links: LINKS.slice(0, 5), appBar: true });
++ const bar = barOf(body);
++ assert.ok(/<a href="\/" class="ovnav-ab is-here" aria-current="page">/.test(bar.innerHTML));
++ assert.ok(!bar.innerHTML.includes('data-ab-more'), 'five fit');
++ assert.ok(bar.innerHTML.includes('ovnav-ab-dot'), 'a link\'s dot shows on its tab');
++}
++{ // A list of its own, labels escaped, at most five; setAppBar(false) takes it away.
++ const { navbar, body, html } = load('/deals');
++ navbar.init({ service: 'deals', links: [], appBar: [
++ { label: 'Home', href: '/', icon: 'fa-house' }, { label: 'Deals <b>', href: '/deals', icon: 'fa-tag' },
++ { label: 'Search', href: '/search', icon: 'fa-magnifying-glass' }, { label: 'Saved', href: '/saved', icon: 'fa-heart' },
++ { label: 'You', href: '/me', icon: 'fa-user' }, { label: 'Sixth', href: '/six', icon: 'fa-star' },
++ ] });
++ const bar = barOf(body);
++ assert.strictEqual(bar.innerHTML.match(/<a href=/g).length, 5, 'five at most');
++ assert.ok(bar.innerHTML.includes('Deals &lt;b&gt;') && !bar.innerHTML.includes('<b>'), 'labels are escaped');
++ assert.ok(/<a href="\/deals" class="ovnav-ab is-here"/.test(bar.innerHTML));
++ navbar.setAppBar(false);
++ assert.strictEqual(barOf(body), null, 'gone');
++ assert.ok(!html.classList.contains('ov-has-appbar'));
++}
++{ // Fewer than two places is no bar; links without icons do not make tabs (More holds them).
++ const { navbar, body } = load('/');
++ navbar.init({ service: 'x', links: [{ label: 'A', href: '/a' }, { label: 'B', href: '/b', icon: 'fa-star' }], appBar: true });
++ assert.strictEqual(barOf(body), null, 'one tab is no tab bar');
++}
++console.log('navbar app bar: opt-in, from links or a list, More, the open page, escaping, removal ok');