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)
Changes
@@@ -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@@@ -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 deployedconsumer installs a tagged release and none keeps a vendored copy([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` namesthe tag it pins.@@@ -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 ontoopenvibe-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@@@ -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)",nnew file mode 100644@@@ -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);nnew file mode 100644@@@ -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.@@@ -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));nnew file mode 100644@@@ -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; } }nnew file mode 100644@@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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,++ };++});@@@ -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) || []];@@@ -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",nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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('<b>Hi</b>') && !h.includes('<b>Hi'), 'the title is escaped');++ assert.ok(h.includes('"quoted" & 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 <x>"'), '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&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('<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');nnew file mode 100644@@@ -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 <b>') && !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');
@@ -6,6 +6,34 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
@@ -6,6 +6,34 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
## 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
## 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
- **Footer `variant: 'auto'`** (`footer.js`). The owner, 2026-10-28: "the full-size footer on the chat pages might be a
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
-**Status:** alpha, v4.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
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -614,6 +614,23 @@ hand-written layouts produce the same document. `templates/site/server/app.js` (
@@ -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
new site starts from) already calls it. See `docs/migration-plan.md`, "T11: layout.js onto
openvibe-shared/shell".
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`)
### The navbar (`navbar.js`)
```text
```text
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.19.0",
+ "version": "4.20.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
new file mode 100644
new file mode 100644
@@ -0,0 +1,104 @@
@@ -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);
new file mode 100644
new file mode 100644
@@ -0,0 +1,241 @@
@@ -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.
@@ -38,6 +38,8 @@ const BROWSER = Object.freeze([
@@ -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-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
'release-update.js', // plans a new release and applies style/content in place; release-watch loads it on demand
'web-runtime.js', // feature loader and route lifecycle (OVWebRuntime), from Live's ov-loader
'web-runtime.js', // feature loader and route lifecycle (OVWebRuntime), from Live's ov-loader
+ 'appbar.js', // the phone app bar; navbar.js loads it only for a site that sets appBar
+ '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
'openvibe-sw.js', // Web Push service worker
]);
]);
@@ -47,6 +49,7 @@ const STYLES = Object.freeze([
@@ -47,6 +49,7 @@ const STYLES = Object.freeze([
'items.css', // Live's name effects for items.js (live.name_effect.css@1)
'items.css', // Live's name effects for items.js (live.name_effect.css@1)
'profile-panel.css', // the panel profile-panel.js draws
'profile-panel.css', // the panel profile-panel.js draws
'player.css', // player.js's controls
'player.css', // player.js's controls
+ 'kit.css', // the OpenVibe Kit's look (kit.js / openvibe-shared/kit build the markup; docs/kit.md)
]);
]);
const served = (name) => BROWSER.includes(String(name)) || STYLES.includes(String(name));
const served = (name) => BROWSER.includes(String(name)) || STYLES.includes(String(name));
new file mode 100644
new file mode 100644
@@ -0,0 +1,254 @@
@@ -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; } }
new file mode 100644
new file mode 100644
@@ -0,0 +1,398 @@
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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,
+ };
+});
@@ -1801,9 +1801,22 @@
@@ -1801,9 +1801,22 @@
attachAvatarFallbacks(nav);
attachAvatarFallbacks(nav);
hookTop();
hookTop();
loadWalletChips();
loadWalletChips();
+ renderAppBar();
return nav;
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 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.
// the page's life, a frame at most per update; --ovnav-real-h is the bar's height for overlay pages.
let _topHooked = false;
let _topHooked = false;
@@ -1873,6 +1886,9 @@
@@ -1873,6 +1886,9 @@
/** Replace this site's top links at runtime ([{label, href, icon?, active?, external?}]). */
/** Replace this site's top links at runtime ([{label, href, icon?, active?, external?}]). */
setLinks(links) { _runtimeLinks = Array.isArray(links) ? links : null; if (_navEl) render(); },
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'}. */
/** Add a row to the account dropdown: {label, href|onClick, icon, danger, external, position:'before'|'after'}. */
addMenuItem(item) {
addMenuItem(item) {
if (!item || !item.label) return;
if (!item || !item.label) return;
@@ -1896,7 +1912,7 @@
@@ -1896,7 +1912,7 @@
/** The signed-in user changed (sites with auth: 'external'). Pass null for signed out. */
/** 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(); },
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. */
/** 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'). */
/** Change a status chip's text (navbar and menu header): setChip('coins', '1,234'). */
setChip(id, value, patch) {
setChip(id, value, patch) {
const lists = [_config.chips || [], (_config.menu && _config.menu.headerChips) || []];
const lists = [_config.chips || [], (_config.menu && _config.menu.headerChips) || []];
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"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",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -59,6 +59,8 @@
@@ -59,6 +59,8 @@
"./serve": "./serve.js",
"./serve": "./serve.js",
"./shell": "./shell.js",
"./shell": "./shell.js",
"./showcase": "./showcase.js",
"./showcase": "./showcase.js",
+ "./kit": "./kit.js",
+ "./appbar": "./appbar.js",
"./social": "./social.js",
"./social": "./social.js",
"./shipped": "./shipped.js",
"./shipped": "./shipped.js",
"./sso-client": "./sso-client.js",
"./sso-client": "./sso-client.js",
new file mode 100644
new file mode 100644
@@ -0,0 +1,102 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,104 @@
@@ -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('<b>Hi</b>') && !h.includes('<b>Hi'), 'the title is escaped');
+ assert.ok(h.includes('"quoted" & 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 <x>"'), '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&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('<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');
new file mode 100644
new file mode 100644
@@ -0,0 +1,109 @@
@@ -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 <b>') && !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');