openvibers/openvibe.shared Commits

4.21.0: search on every site (the navbar's magnifier, / and Ctrl+K, search.js on first use: this site first, then all of OpenVibe, Ask Vibe) and loading redesigned and shared (loading.js: the thing's own picture, a thin ring, one line of status, a two-pixel progress line); Restream among the short names (#145)

OP OpenVibers committed 16 days ago · 15f6782381 · 13 files, +693 −7

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex f38af65..a1f335a 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -6,6 +6,30 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
++## 4.21.0 — 2026-10-28
++
++- **Search on every site** (`search.js`; navbar `search`, `openSearch()`).
++ - The owner, 2026-10-28: "a magnifying glass button that's more compact, shared across the sites… intelligent… lazy
++ loading and autocomplete… reused in the SDK and API".
++ - Every navbar has a magnifier; `/` and Ctrl+K open it too. `search.js` loads on first use.
++ - It shows this site's pages and your recent searches, then OpenVibe.Search's suggestions as you type (the older
++ request cancelled). This site's results come first, then the rest of OpenVibe.
++ - It always offers the site's own search page, "Search all of OpenVibe" and "Ask Vibe".
++ - Keyboard and screen-reader friendly; full screen on phones. `search: false` hides it.
++ - `OpenVibeSearch.suggest()` serves a page's own box.
++ - The navbar's budget is 37.5 KB (37.05 with the button).
++- **Loading, redesigned and shared** (`loading.js`, `OpenVibeLoading`).
++ - The owner, 2026-10-28, of Live's stream loader: "old and outdated… consider the latest frames of the stream…
++ minimalistic… reusable and shared across the sites".
++ - An edge-to-edge overlay with no card: the thing's own picture blurred and drifting, a thin ring, one status line,
++ and a two-pixel progress line.
++ - `update` / `slow` (a hint and actions) / `fail` (Retry) / `done` (fades out); skeleton rows for lists.
++- `shipped.js` labels OpenRestream "Restream" among the short names (the launcher did the same in Network #367).
++- `test/search-loading-chrome.test.js`:
++ - Search: lazy, scoped, cancelled, text-only titles, script links dropped, keyboard.
++ - Loading: steps, slow, fail, done, skeleton.
++ - No sideways scroll at 390 px.
++
## 4.20.1 — 2026-10-28
- **The kit's behaviour is its own small file, `kit-web.js`** (about 7 KB, 2 KB compressed): copy buttons, menus that
ddiff --git a/README.md b/README.md
iindex b66ae56..069379f 100644
---- a/README.md
++++ b/README.md
@@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
--**Status:** alpha, v4.20.1 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.21.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
@@@ -622,7 +622,8 @@ cards that fold, settings rows, a search row, a composer pinned at the bottom, a
(`window.OpenVibeKit`); text is escaped, and every piece works without JavaScript. `kit-web.js` is the behaviour alone
(about 7 KB), all a server-rendered page loads. [docs/kit.md](docs/kit.md) has every
piece, the sizes, the colours, and how to use it in an OpenVibe site or anyone's project;
--`node scripts/kit-gallery.js --shots <dir>` shows them all at 390, 768 and 1280 px.
++`node scripts/kit-gallery.js --shots <dir>` shows them all at 390, 768 and 1280 px. Every navbar also has search across OpenVibe
++(`search.js`, loaded on first use), and `loading.js` is the one loading look (docs/kit.md#loading).
```js
const kit = require('openvibe-shared/kit');
ddiff --git a/STATUS.json b/STATUS.json
iindex 4831e93..5b9db6d 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.20.1",
++ "version": "4.21.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
ddiff --git a/docs/kit.md b/docs/kit.md
iindex bcfeecd..4b86b9e 100644
---- a/docs/kit.md
++++ b/docs/kit.md
@@@ -11,6 +11,8 @@ theme, and they fit a phone first.
loads only this one.
- **`kit.css`**: the look, built on the site's theme tokens.
- **`appbar.js`**: the phone tab bar. `navbar.js` loads it only for a site that sets `appBar`.
++- **`search.js`**: search across OpenVibe from the navbar's magnifier, loaded on first use.
++- **`loading.js`**: the one loading look (an overlay over the thing's own picture) and skeleton rows.
The package is MIT. Every site serves its own pinned copy at `/shared/kit.css`, `/shared/kit.js`, `/shared/kit-web.js` and
`/shared/appbar.js` (`openvibe-shared/serve`), so a page keeps working when another site is down.
@@@ -213,6 +215,53 @@ OpenVibeNavbar.setAppBar(spec | false)
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.
++## Search, on every site
++
++The navbar has a magnifier on every site. `/` or Ctrl+K opens it too. The first time someone asks, the navbar loads
++`search.js` (about 17 KB), so pages that are never searched pay nothing.
++
++What it does:
++- **Before typing:** the site's own pages, and what you searched before on this device.
++- **While typing:** suggestions from OpenVibe.Search for every site (`GET /api/v1/suggest`, anonymous, the older
++ request cancelled when you type on), this site's results first.
++- **Always offered:** the site's own search page (when it has one), "Search all of OpenVibe" and "Ask Vibe".
++- **Keys:** arrows move, Enter opens, Escape closes.
++- **Layout:** a phone gets the whole screen; a computer gets a panel near the top.
++
++```js
++OpenVibeNavbar.init({ …, search: { owner: 'live', label: 'OpenVibe.Live', page: '/search' } }) // or search: false
++OpenVibeNavbar.openSearch('seattle') // open it from anything on the page
++OpenVibeSearch.suggest('sea', { owner: 'live' }) // the same suggestions for a page's own box
++```
++
++Search answers any origin (anonymous, never with credentials). So the same suggestions work from anyone's page, and
++from the SDK: `openvibe-sdk/search`'s `search.suggest(text, { owner, type, limit })` and
++`search.query(text, { owner, type })`.
++
++## Loading
++
++`loading.js` is the network's one loading look, on Live's player and anywhere something takes a moment. It covers
++what is loading edge to edge, with no card:
++- the thing's own picture behind it, blurred and drifting (a stream's latest frame, a video's poster);
++- a thin turning ring around a soft dot;
++- one word of status, and one quiet line under it;
++- a two-pixel line along the bottom that fills step by step, or sweeps while the progress is unknown.
++
++When it's slow, a hint and its actions appear. When it fails, it says what happened and offers Retry. When it's done,
++it fades away.
++
++```js
++const l = OpenVibeLoading.mount(playerEl, { poster: stream.thumbnail_url, label: 'Connecting', sub: 'Getting your viewer ready', steps: 5 })
++l.update({ step: 2, label: 'Opening secure link', sub: 'Almost there' })
++l.slow({ hint: 'This is taking longer than usual.', actions: [{ label: 'Try again', onClick }, { label: 'Copy details', onClick }] })
++l.fail({ title: 'Could not connect', detail: 'The stream stopped answering.', actions: [{ label: 'Retry', onClick }] })
++l.done()
++OpenVibeLoading.skeleton(listEl, { rows: 3 }) // shimmering rows while a list loads → .done()
++```
++
++The element it mounts into should be positioned. Motion stops under `prefers-reduced-motion`, and the status is
++announced to screen readers (`role=status`, or `alert` when it fails).
++
## Using it
**An OpenVibe site (Node, server-rendered):**
ddiff --git a/files.js b/files.js
iindex d65376c..b4db4b7 100644
---- a/files.js
++++ b/files.js
@@@ -41,6 +41,8 @@ const BROWSER = Object.freeze([
'appbar.js', // the phone app bar; navbar.js loads it only for a site that sets appBar
'kit.js', // the OpenVibe Kit's builders in the page (for pages that draw themselves; with kit-web.js)
'kit-web.js', // the OpenVibe Kit's behaviour (copy, menus, Enter to send, confirms, sheets): all a server-rendered site loads
++ 'search.js', // search everything from the navbar's magnifier, / or Ctrl+K; navbar.js loads it on first use
++ 'loading.js', // the one loading look: a poster-backed overlay with status and progress, and skeleton rows
'openvibe-sw.js', // Web Push service worker
]);
ddiff --git a/loading.js b/loading.js
nnew file mode 100644
iindex 0000000..02634ac
---- /dev/null
++++ b/loading.js
@@@ -0,0 +1,190 @@
++/**
++ * openvibe-shared/loading.js — the network's one loading look (window.OpenVibeLoading). The owner, 2026-10-28, of Live's
++ * stream loader: "old and outdated… awkward… consider the latest frames of the stream… more integrated, modern and
++ * sleek… no borders and wasted space and padding… minimalistic… a better animation… reusable and shared across the
++ * sites… a loading system you can use in the SDK and API."
++ *
++ * It covers what is loading edge to edge, with no card: the thing's own picture (a stream's latest frame, a video's
++ * poster) blurred and drifting behind, a thin ring turning around a soft dot, one word of status with one quiet line under
++ * it, and a two-pixel line along the bottom that fills as the steps go by (or sweeps while the progress is unknown).
++ * Only when it is slow does a hint and its actions appear; a failure says what happened and offers Retry. Done, it fades.
++ *
++ * const l = OpenVibeLoading.mount(el, { poster, label: 'Connecting', sub: 'Getting your viewer ready', steps: 5 })
++ * l.update({ label, sub, step, progress }) step: 0-based of steps (or progress 0-1); omit both to keep it
++ * l.slow({ hint, actions: [{ label, onClick }] }) a hint and actions under the status (it keeps loading)
++ * l.fail({ title, detail, actions: [{ label, onClick }] }) stops the ring, says what happened
++ * l.done() fades out and removes itself (the picture underneath shows through as it goes)
++ * l.destroy() removes it at once
++ * OpenVibeLoading.skeleton(el, { rows: 3, mark: true }) shimmering rows while a list loads → { done() }
++ *
++ * The element it mounts into should be positioned (relative, absolute or fixed); it fills it. Text is set with
++ * textContent; the poster must be an https URL, a same-site path or a blob/data image. Motion stops under
++ * prefers-reduced-motion. Works without the rest of the OpenVibe Frame; colours follow the site's theme.
++ */
++(function (root) {
++ 'use strict';
++ const CSS = `
++.ovld { position: absolute; inset: 0; z-index: 5; display: grid; place-items: center; overflow: hidden; background: #05070c; color: #f4f6fb; font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; transition: opacity .38s ease; contain: strict; }
++.ovld.is-done { opacity: 0; pointer-events: none; }
++.ovld-poster { position: absolute; inset: -8%; background-size: cover; background-position: center; filter: blur(22px) saturate(1.25) brightness(.5); transform: scale(1.06); opacity: 0; transition: opacity .6s ease; animation: ovld-drift 16s ease-in-out infinite alternate; }
++.ovld-poster.is-in { opacity: 1; }
++.ovld::after { content: ''; position: absolute; inset: 0; background: radial-gradient(ellipse at 50% 45%, rgba(5,7,12,0) 0%, rgba(5,7,12,.35) 55%, rgba(5,7,12,.75) 100%); pointer-events: none; }
++.ovld-core { position: relative; z-index: 1; display: grid; justify-items: center; gap: 10px; max-width: min(86%, 420px); text-align: center; text-shadow: 0 1px 12px rgba(0,0,0,.45); }
++.ovld-ring { position: relative; width: 46px; height: 46px; border-radius: 50%; }
++.ovld-ring::before { content: ''; position: absolute; inset: 0; border-radius: 50%; background: conic-gradient(from 0deg, rgba(255,255,255,0) 0 62%, var(--accent-light, #9cc0ff) 100%); -webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 2.5px), #000 calc(100% - 2px)); mask: radial-gradient(farthest-side, transparent calc(100% - 2.5px), #000 calc(100% - 2px)); animation: ovld-spin 1.15s linear infinite; }
++.ovld-ring::after { content: ''; position: absolute; inset: 16px; border-radius: 50%; background: var(--accent-light, #9cc0ff); box-shadow: 0 0 18px var(--accent-light, #9cc0ff); animation: ovld-breathe 1.6s ease-in-out infinite; }
++.ovld-label { font-size: 15px; font-weight: 650; letter-spacing: .01em; transition: opacity .2s ease, transform .2s ease; }
++.ovld-sub { font-size: 12.5px; line-height: 1.45; color: rgba(244,246,251,.72); min-height: 1.45em; transition: opacity .2s ease; }
++.ovld-swap .ovld-label, .ovld-swap .ovld-sub { opacity: 0; transform: translateY(3px); }
++.ovld-help { display: none; gap: 10px; justify-items: center; margin-top: 2px; font-size: 12.5px; line-height: 1.45; color: rgba(244,246,251,.8); }
++.ovld.is-slow .ovld-help, .ovld.is-failed .ovld-help { display: grid; }
++.ovld-acts { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; }
++.ovld-acts button { min-height: 36px; padding: 6px 14px; border: 0; border-radius: 999px; background: rgba(255,255,255,.14); color: inherit; font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); }
++.ovld-acts button:hover, .ovld-acts button:focus-visible { background: rgba(255,255,255,.24); outline: none; }
++.ovld-acts button:first-child { background: var(--accent-strong, #2563eb); color: var(--on-accent-strong, #fff); }
++.ovld-line { position: absolute; left: 0; right: 0; bottom: 0; z-index: 1; height: 2px; background: rgba(255,255,255,.08); overflow: hidden; }
++.ovld-line i { position: absolute; left: 0; top: 0; bottom: 0; width: var(--ovld-p, 0%); background: linear-gradient(90deg, var(--accent, #3b82f6), var(--accent-light, #9cc0ff)); box-shadow: 0 0 10px var(--accent-light, #9cc0ff); transition: width .7s cubic-bezier(.2,.7,.2,1); }
++.ovld.is-unknown .ovld-line i { width: 30%; animation: ovld-sweep 1.4s ease-in-out infinite; }
++.ovld.is-failed .ovld-ring::before { animation: none; background: none; }
++.ovld.is-failed .ovld-ring::after { inset: 14px; background: var(--danger, #ef4444); box-shadow: 0 0 18px var(--danger, #ef4444); animation: none; }
++.ovld.is-failed .ovld-line i { background: var(--danger, #ef4444); box-shadow: none; animation: none; width: 100%; }
++.ovld-skel { display: grid; gap: 10px; padding: 4px 0; }
++.ovld-skel-row { display: flex; align-items: center; gap: 12px; }
++.ovld-skel-mark { flex: none; width: 40px; height: 40px; border-radius: 50%; }
++.ovld-skel-lines { flex: 1; display: grid; gap: 7px; }
++.ovld-skel-line { height: 11px; border-radius: 6px; }
++.ovld-skel-line:last-child { width: 62%; }
++.ovld-skel-mark, .ovld-skel-line { background: linear-gradient(90deg, var(--bg-hover, rgba(255,255,255,.06)) 25%, rgba(255,255,255,.12) 50%, var(--bg-hover, rgba(255,255,255,.06)) 75%); background-size: 300% 100%; animation: ovld-shimmer 1.3s ease-in-out infinite; }
++@keyframes ovld-spin { to { transform: rotate(1turn); } }
++@keyframes ovld-breathe { 0%, 100% { transform: scale(.72); opacity: .75; } 50% { transform: scale(1); opacity: 1; } }
++@keyframes ovld-drift { from { transform: scale(1.06) translate3d(-1.5%, -1%, 0); } to { transform: scale(1.14) translate3d(1.5%, 1%, 0); } }
++@keyframes ovld-sweep { from { left: -30%; } to { left: 100%; } }
++@keyframes ovld-shimmer { from { background-position: 100% 0; } to { background-position: 0 0; } }
++@media (prefers-reduced-motion: reduce) { .ovld-poster, .ovld-ring::before, .ovld-ring::after, .ovld-line i, .ovld-skel-mark, .ovld-skel-line { animation: none !important; } .ovld-ring::before { background: conic-gradient(var(--accent-light, #9cc0ff) 0 30%, rgba(255,255,255,.15) 30%); } }`;
++
++ function styles() {
++ if (typeof document === 'undefined' || document.getElementById('openvibe-loading-styles')) return;
++ const st = document.createElement('style');
++ st.id = 'openvibe-loading-styles';
++ st.textContent = CSS;
++ (document.head || document.documentElement).appendChild(st);
++ }
++ const posterUrl = (u) => { const s = String(u || ''); if (/^(blob:|data:image\/)/.test(s)) return s; try { const x = new URL(s, location.href); return x.protocol === 'https:' || x.origin === location.origin ? x.href : null; } catch { return null; } };
++ const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; };
++ const reduced = () => typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
++
++ function mount(host, opts = {}) {
++ styles();
++ const box = el('div', 'ovld is-unknown');
++ box.setAttribute('role', 'status');
++ box.setAttribute('aria-live', 'polite');
++ const poster = el('div', 'ovld-poster');
++ const core = el('div', 'ovld-core');
++ const ring = el('div', 'ovld-ring');
++ ring.setAttribute('aria-hidden', 'true');
++ const label = el('div', 'ovld-label', opts.label || 'Loading');
++ const sub = el('div', 'ovld-sub', opts.sub || '');
++ const help = el('div', 'ovld-help');
++ const hint = el('div', 'ovld-hint');
++ const acts = el('div', 'ovld-acts');
++ help.append(hint, acts);
++ core.append(ring, label, sub, help);
++ const line = el('div', 'ovld-line');
++ const fill = el('i');
++ line.appendChild(fill);
++ box.append(poster, core, line);
++ host.appendChild(box);
++ let steps = Number(opts.steps) || 0;
++ let gone = false;
++
++ function setPoster(u) {
++ const url = posterUrl(u);
++ if (!url) return;
++ const img = new Image();
++ img.decoding = 'async';
++ img.onload = () => { if (gone) return; poster.style.backgroundImage = `url("${url.replace(/"/g, '%22')}")`; poster.classList.add('is-in'); };
++ img.src = url;
++ }
++ function setText(l, s) {
++ const change = (l != null && l !== label.textContent) || (s != null && s !== sub.textContent);
++ if (!change) return;
++ if (reduced()) { if (l != null) label.textContent = l; if (s != null) sub.textContent = s; return; }
++ core.classList.add('ovld-swap');
++ setTimeout(() => { if (l != null) label.textContent = l; if (s != null) sub.textContent = s; core.classList.remove('ovld-swap'); }, 160);
++ }
++ function setActions(list) {
++ acts.textContent = '';
++ (list || []).slice(0, 3).forEach((a) => {
++ if (!a || !a.label) return;
++ const b = el('button', '', a.label);
++ b.type = 'button';
++ if (typeof a.onClick === 'function') b.addEventListener('click', (e) => a.onClick(e));
++ acts.appendChild(b);
++ });
++ }
++ if (opts.poster) setPoster(opts.poster);
++
++ const api = {
++ update({ label: l, sub: s, step, progress, steps: n, poster: p } = {}) {
++ if (gone) return api;
++ if (n != null) steps = Number(n) || 0;
++ let pct = null;
++ if (progress != null && Number.isFinite(Number(progress))) pct = Math.max(0, Math.min(1, Number(progress)));
++ else if (step != null && steps > 0) pct = Math.max(0, Math.min(1, (Number(step) + 1) / (steps + 1)));
++ if (pct != null) { box.classList.remove('is-unknown'); box.style.setProperty('--ovld-p', `${Math.round(pct * 100)}%`); }
++ if (l != null || s != null) setText(l, s);
++ if (p) setPoster(p);
++ box.classList.remove('is-slow');
++ return api;
++ },
++ slow({ hint: h = '', actions = [] } = {}) {
++ if (gone) return api;
++ hint.textContent = h;
++ setActions(actions);
++ box.classList.add('is-slow');
++ return api;
++ },
++ fail({ title = 'Something went wrong', detail = '', actions = [] } = {}) {
++ if (gone) return api;
++ box.classList.remove('is-slow', 'is-unknown');
++ box.classList.add('is-failed');
++ box.setAttribute('role', 'alert');
++ setText(title, detail);
++ hint.textContent = '';
++ setActions(actions);
++ return api;
++ },
++ done() {
++ if (gone) return;
++ gone = true;
++ box.style.setProperty('--ovld-p', '100%');
++ box.classList.add('is-done');
++ setTimeout(() => box.remove(), reduced() ? 0 : 400);
++ },
++ destroy() { gone = true; box.remove(); },
++ get element() { return box; },
++ };
++ return api;
++ }
++
++ function skeleton(host, { rows = 3, mark = true } = {}) {
++ styles();
++ const wrap = el('div', 'ovld-skel');
++ wrap.setAttribute('aria-busy', 'true');
++ wrap.setAttribute('aria-label', 'Loading');
++ for (let i = 0; i < Math.max(1, Math.min(20, rows)); i++) {
++ const r = el('div', 'ovld-skel-row');
++ if (mark) r.appendChild(el('div', 'ovld-skel-mark'));
++ const lines = el('div', 'ovld-skel-lines');
++ lines.append(el('div', 'ovld-skel-line'), el('div', 'ovld-skel-line'));
++ r.appendChild(lines);
++ wrap.appendChild(r);
++ }
++ host.appendChild(wrap);
++ return { done() { wrap.remove(); }, element: wrap };
++ }
++
++ const api = { mount, skeleton, CSS };
++ root.OpenVibeLoading = api;
++ if (typeof module === 'object' && module.exports) module.exports = api;
++})(typeof globalThis !== 'undefined' ? globalThis : this);
ddiff --git a/navbar.js b/navbar.js
iindex 5eb23f7..9487c4f 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -242,6 +242,8 @@
.openvibe-navbar-spacer { flex: 1; }
.openvibe-navbar-right { display: flex; align-items: center; gap: 6px; }
++ .openvibe-navbar .ovnav-search { all: unset; box-sizing: border-box; display: inline-grid; place-items: center; width: 38px; height: 38px; border-radius: 11px; cursor: pointer; flex: none; }
++ .openvibe-navbar .ovnav-search:hover, .openvibe-navbar .ovnav-search:focus-visible { background: var(--accent-glow, rgba(59,130,246,.14)); }
.openvibe-navbar-avatar {
width: 32px; height: 32px; border-radius: 50%; cursor: pointer;
@@@ -1649,6 +1651,7 @@
</div>
<div class="openvibe-navbar-spacer"></div>
<div class="openvibe-navbar-right">
++ ${_config.search === false ? '' : '<button type="button" class="ovnav-search" id="openvibe-search-btn" aria-label="Search (/)" title="Search (/)"><svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><circle cx="11" cy="11" r="6.5"/><path d="m20 20-4.2-4.2"/></svg></button>'}
${u ? chipsHTML(_config.chips) + chipsAllHTML(_config.chips) : ''}
<div id="openvibe-bell-mount"></div>
${u ? avatarImg(u, 64, 'openvibe-navbar-avatar', 'openvibe-avatar-btn') :
@@@ -1659,6 +1662,7 @@
`;
if (_config.className) String(_config.className).split(/\s+/).filter(Boolean).forEach(c => nav.classList.add(c));
bindLinks(nav, links);
++ hookSearch(nav);
bindChips(nav);
bindChipsAll(nav);
queueIcons();
@@@ -1808,6 +1812,27 @@
// ── 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] || [];
++
++ // Search (search.js, loaded on first use). _config.search: false, or { owner, label, page }.
++ let _searchLoading = false;
++ function openSearch(q) {
++ const go = () => root.OpenVibeSearch && root.OpenVibeSearch.open({ service: _config.service, scope: Object.assign({ owner: _config.service }, _config.search || {}), links: flatLinks(rawLinks()), q });
++ if (root.OpenVibeSearch) return go();
++ if (_searchLoading) return;
++ _searchLoading = true;
++ try { const sc = document.createElement('script'); sc.src = sibling('search.js'); sc.async = true; sc.onload = go; sc.onerror = () => { _searchLoading = false; }; document.head.appendChild(sc); } catch { _searchLoading = false; }
++ }
++ let _searchKeys = false;
++ function hookSearch(nav) {
++ const b = nav.querySelector('#openvibe-search-btn');
++ if (b) b.addEventListener('click', () => openSearch());
++ if (_searchKeys || _config.search === false || typeof document === 'undefined') return;
++ _searchKeys = true;
++ document.addEventListener('keydown', (e) => {
++ const t = e.target, typing = t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName));
++ if ((e.key === '/' && !typing && !e.ctrlKey && !e.metaKey && !e.altKey) || ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K'))) { e.preventDefault(); openSearch(); }
++ });
++ }
function renderAppBar() {
if (typeof document === 'undefined' || !document.body) return;
const ab = root.OpenVibeAppBar;
@@@ -1886,6 +1911,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(); },
++ /** Open the search panel (search.js), with an optional query already in it. */
++ openSearch(q) { openSearch(q); },
++
/** 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(); },
ddiff --git a/package.json b/package.json
iindex 9ebbd63..1f8f921 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.20.1",
++ "version": "4.21.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
@@@ -62,6 +62,8 @@
"./kit": "./kit.js",
"./kit-web": "./kit-web.js",
"./appbar": "./appbar.js",
++ "./search": "./search.js",
++ "./loading": "./loading.js",
"./social": "./social.js",
"./shipped": "./shipped.js",
"./sso-client": "./sso-client.js",
ddiff --git a/search.js b/search.js
nnew file mode 100644
iindex 0000000..d00de02
---- /dev/null
++++ b/search.js
@@@ -0,0 +1,259 @@
++/**
++ * openvibe-shared/search.js — search everything on OpenVibe from any site's navbar: the magnifier, "/" or Ctrl+K. The
++ * navbar loads this file the first time someone asks (the owner, 2026-10-28: "a magnifying glass button that's more
++ * compact, shared across the sites… more intelligent… asynchronous loading, lazy loading and autocomplete… reused in
++ * the SDK and API… intelligent and aware").
++ *
++ * What it knows: the site it is on (its pages come first, its own search page when it has one), what you searched
++ * before (on this device), and everything OpenVibe.Search holds across the sites, suggested as you type (the public
++ * GET /api/v1/suggest, anonymous, one request in flight, the older one cancelled). Every row is a link: a result, a
++ * page of this site, "Search all of OpenVibe" and "Ask Vibe". Arrows move, Enter opens, Escape closes. A phone gets
++ * the whole screen; a computer a panel near the top.
++ *
++ * OpenVibeSearch.open({ service, scope: { owner, label, page }, links, q }) navbar.js calls it
++ * OpenVibeSearch.close()
++ * OpenVibeSearch.suggest(q, { owner, limit, signal }) → [{ owner, type, id, title, url, site }] for any page's own box
++ *
++ * Text is always set with textContent; links are https or same-site paths only. Recent searches live in localStorage
++ * (ov_search_recent, eight at most) and can be cleared from the panel.
++ */
++(function (root) {
++ 'use strict';
++ const API = 'https://search.openvibe.network';
++ const RECENT_KEY = 'ov_search_recent';
++ const SITE = { 'openre.stream': 'Restream', 'search.openvibe.network': 'Search', 'ai.openvibe.services': 'AI' };
++ const TYPE = { vod: 'Recording', clip: 'Clip', job: 'Job', story: 'Story', page: 'Page', thread: 'Thread', paste: 'Paste', post: 'Post', stream: 'Live', channel: 'Channel', deal: 'Deal', coupon: 'Code', review: 'Review', entity: 'Reviews', recipe: 'Recipe', game: 'Game', tool: 'Tool', listing: 'Listing', wiki: 'Wiki' };
++ let el = null, input = null, listEl = null, ctx = {}, items = [], active = -1, inflight = null, timer = null, lastFocus = null;
++
++ const safeUrl = (u) => { try { const x = new URL(u, location.href); return x.protocol === 'https:' || x.origin === location.origin ? x.href : null; } catch { return null; } };
++ function siteOf(url) {
++ try {
++ const h = new URL(url).hostname.replace(/^www\./, '');
++ if (SITE[h]) return SITE[h];
++ const m = /^(?:([a-z0-9-]+)\.)?openvibe\.([a-z]+)$/.exec(h);
++ if (m) return m[1] ? m[1][0].toUpperCase() + m[1].slice(1) : m[2][0].toUpperCase() + m[2].slice(1);
++ return h;
++ } catch { return ''; }
++ }
++ const recent = {
++ get() { try { return (JSON.parse(localStorage.getItem(RECENT_KEY) || '[]') || []).filter((x) => typeof x === 'string').slice(0, 8); } catch { return []; } },
++ add(q) { try { const list = [q, ...recent.get().filter((x) => x.toLowerCase() !== q.toLowerCase())].slice(0, 8); localStorage.setItem(RECENT_KEY, JSON.stringify(list)); } catch { /* private mode */ } },
++ clear() { try { localStorage.removeItem(RECENT_KEY); } catch { /* */ } },
++ };
++
++ async function suggest(q, { owner = null, limit = 8, signal } = {}) {
++ const u = new URL('/api/v1/suggest', API);
++ u.searchParams.set('q', String(q).slice(0, 100));
++ if (owner) u.searchParams.set('owner', owner);
++ u.searchParams.set('limit', String(limit));
++ const res = await fetch(u, { signal, credentials: 'omit', headers: { accept: 'application/json' } });
++ if (!res.ok) return [];
++ const d = await res.json();
++ return (d.suggestions || []).map((s) => ({ owner: s.owner, type: s.type, id: s.id, title: String(s.title || '').slice(0, 160), url: safeUrl(s.canonical_url), site: siteOf(s.canonical_url) })).filter((s) => s.url && s.title);
++ }
++
++ function styles() {
++ if (document.getElementById('openvibe-search-styles')) return;
++ const st = document.createElement('style');
++ st.id = 'openvibe-search-styles';
++ st.textContent = `
++.ovs { position: fixed; inset: 0; z-index: 10020; display: none; background: rgba(3, 6, 12, .55); -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; color: var(--text-primary, #e8ecf3); }
++.ovs.is-open { display: block; animation: ovs-in .14s ease-out; }
++.ovs-panel { position: absolute; left: 50%; top: 10vh; width: min(640px, calc(100vw - 32px)); max-height: 76vh; transform: translateX(-50%); display: flex; flex-direction: column; border-radius: 18px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: var(--bg-secondary, #121826); box-shadow: 0 30px 80px rgba(0,0,0,.55); overflow: hidden; }
++.ovs-bar { display: flex; align-items: center; gap: 10px; padding: 6px 8px 6px 16px; border-bottom: 1px solid var(--border, rgba(255,255,255,.1)); }
++.ovs-bar svg { width: 18px; height: 18px; flex: none; color: var(--text-muted, #93a0b4); }
++.ovs-input { flex: 1; min-width: 0; height: 48px; border: 0; outline: 0; background: transparent; color: inherit; font: inherit; font-size: 17px; }
++.ovs-input::-webkit-search-cancel-button { -webkit-appearance: none; }
++.ovs-x { flex: none; height: 36px; padding: 0 12px; border: 0; border-radius: 10px; background: transparent; color: var(--text-muted, #93a0b4); font: inherit; font-size: 13px; cursor: pointer; }
++.ovs-x:hover { background: var(--bg-hover, rgba(255,255,255,.06)); color: inherit; }
++.ovs-list { list-style: none; margin: 0; padding: 6px; overflow-y: auto; overscroll-behavior: contain; }
++.ovs-group { padding: 10px 10px 4px; font-size: 11px; font-weight: 800; letter-spacing: .07em; text-transform: uppercase; color: var(--text-muted, #93a0b4); }
++.ovs-group button { float: right; border: 0; background: none; color: var(--accent-light, #8ab4ff); font: inherit; font-size: 11px; text-transform: none; letter-spacing: 0; cursor: pointer; }
++.ovs-item a { display: flex; align-items: center; gap: 12px; min-height: 48px; padding: 6px 10px; border-radius: 12px; color: inherit; text-decoration: none; }
++.ovs-item.is-active a, .ovs-item a:hover { background: var(--bg-hover, rgba(255,255,255,.07)); }
++.ovs-ic { flex: none; display: grid; place-items: center; width: 32px; height: 32px; border-radius: 10px; background: color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent); color: var(--accent-light, #8ab4ff); font-size: 12px; font-weight: 800; }
++.ovs-ic svg { width: 16px; height: 16px; }
++.ovs-main { flex: 1; min-width: 0; display: grid; gap: 1px; }
++.ovs-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; font-weight: 600; }
++.ovs-meta { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12.5px; color: var(--text-muted, #93a0b4); }
++.ovs-key { flex: none; font-size: 11px; color: var(--text-muted, #93a0b4); }
++.ovs-note { padding: 14px 12px; font-size: 13px; color: var(--text-muted, #93a0b4); }
++.ovs-busy .ovs-bar { background-image: linear-gradient(90deg, transparent, color-mix(in srgb, var(--accent, #3b82f6) 40%, transparent), transparent); background-size: 40% 2px; background-repeat: no-repeat; background-position: -40% 100%; animation: ovs-scan 1s linear infinite; }
++@keyframes ovs-in { from { opacity: 0; } to { opacity: 1; } }
++@keyframes ovs-scan { to { background-position: 140% 100%; } }
++@media (max-width: 640px) { .ovs-panel { top: 0; left: 0; width: 100vw; height: 100dvh; max-height: none; transform: none; border-radius: 0; border: 0; } .ovs-key { display: none; } }
++@media (prefers-reduced-motion: reduce) { .ovs.is-open, .ovs-busy .ovs-bar { animation: none; } }`;
++ document.head.appendChild(st);
++ }
++
++ const ICON = {
++ search: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="6.5"/><path d="m20 20-4.2-4.2"/></svg>',
++ clock: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/></svg>',
++ go: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 5 7 7-7 7"/></svg>',
++ spark: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8Z"/></svg>',
++ };
++
++ function build() {
++ styles();
++ el = document.createElement('div');
++ el.className = 'ovs';
++ el.setAttribute('role', 'dialog');
++ el.setAttribute('aria-modal', 'true');
++ el.setAttribute('aria-label', 'Search OpenVibe');
++ el.innerHTML = `<div class="ovs-panel"><div class="ovs-bar">${ICON.search}<input class="ovs-input" type="search" role="combobox" aria-autocomplete="list" aria-expanded="true" aria-controls="ovs-list" autocomplete="off" spellcheck="false" enterkeyhint="search" maxlength="100"><button type="button" class="ovs-x">Close</button></div><ul class="ovs-list" id="ovs-list" role="listbox"></ul></div>`;
++ input = el.querySelector('.ovs-input');
++ listEl = el.querySelector('.ovs-list');
++ el.addEventListener('mousedown', (e) => { if (e.target === el) close(); });
++ el.querySelector('.ovs-x').addEventListener('click', close);
++ input.addEventListener('input', () => schedule(input.value));
++ input.addEventListener('keydown', onKey);
++ listEl.addEventListener('click', (e) => {
++ const clear = e.target.closest && e.target.closest('[data-ovs-clear]');
++ if (clear) { e.preventDefault(); recent.clear(); render(''); input.focus(); return; }
++ const a = e.target.closest && e.target.closest('a[data-i]');
++ if (a) remember(items[Number(a.getAttribute('data-i'))]);
++ });
++ document.body.appendChild(el);
++ }
++
++ function onKey(e) {
++ if (e.key === 'Escape') { e.preventDefault(); close(); return; }
++ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
++ e.preventDefault();
++ if (!items.length) return;
++ active = (active + (e.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length;
++ paintActive();
++ return;
++ }
++ if (e.key === 'Enter') {
++ e.preventDefault();
++ const it = items[active >= 0 ? active : 0];
++ if (it) { remember(it); location.href = it.url; }
++ }
++ }
++ function remember(it) {
++ const q = input && input.value.trim();
++ if (q && it && it.kind !== 'recent') recent.add(q);
++ }
++ function paintActive() {
++ Array.prototype.forEach.call(listEl.querySelectorAll('.ovs-item'), (li, i) => {
++ const on = i === active;
++ li.classList.toggle('is-active', on);
++ li.setAttribute('aria-selected', on ? 'true' : 'false');
++ if (on) { input.setAttribute('aria-activedescendant', li.id); li.scrollIntoView({ block: 'nearest' }); }
++ });
++ }
++
++ /** The rows: groups of items { title, meta, url, icon, kind }; rendered with textContent only. */
++ function paint(groups, note) {
++ items = [];
++ listEl.textContent = '';
++ groups.filter((g) => g.items.length).forEach((g) => {
++ const h = document.createElement('li');
++ h.className = 'ovs-group';
++ h.setAttribute('role', 'presentation');
++ h.textContent = g.title;
++ if (g.clear) { const b = document.createElement('button'); b.type = 'button'; b.textContent = 'Clear'; b.setAttribute('data-ovs-clear', ''); h.appendChild(b); }
++ listEl.appendChild(h);
++ g.items.forEach((it) => {
++ const i = items.length;
++ items.push(it);
++ const li = document.createElement('li');
++ li.className = 'ovs-item';
++ li.id = `ovs-o${i}`;
++ li.setAttribute('role', 'option');
++ const a = document.createElement('a');
++ a.href = it.url;
++ a.setAttribute('data-i', String(i));
++ a.tabIndex = -1;
++ const ic = document.createElement('span');
++ ic.className = 'ovs-ic';
++ if (ICON[it.icon]) ic.innerHTML = ICON[it.icon]; else ic.textContent = (it.icon || '•').slice(0, 2);
++ const main = document.createElement('span');
++ main.className = 'ovs-main';
++ const t = document.createElement('span'); t.className = 'ovs-title'; t.textContent = it.title;
++ main.appendChild(t);
++ if (it.meta) { const m = document.createElement('span'); m.className = 'ovs-meta'; m.textContent = it.meta; main.appendChild(m); }
++ a.append(ic, main);
++ if (i === 0) { const k = document.createElement('span'); k.className = 'ovs-key'; k.textContent = 'Enter'; a.appendChild(k); }
++ li.appendChild(a);
++ listEl.appendChild(li);
++ });
++ });
++ if (note) { const n = document.createElement('li'); n.className = 'ovs-note'; n.setAttribute('role', 'presentation'); n.textContent = note; listEl.appendChild(n); }
++ active = items.length ? 0 : -1;
++ paintActive();
++ }
++
++ const here = () => (ctx.scope && ctx.scope.label) || document.title.split(/[·|–-]/).pop().trim() || location.hostname;
++ function pagesOf(q) {
++ const n = q.toLowerCase();
++ return (ctx.links || []).filter((l) => l && l.label && typeof l.href === 'string' && !l.hidden && (!n || l.label.toLowerCase().includes(n)))
++ .slice(0, n ? 4 : 6).map((l) => ({ title: l.label, meta: `Go to · ${here()}`, url: safeUrl(l.href), icon: 'go', kind: 'page' })).filter((x) => x.url);
++ }
++ function actions(q) {
++ const out = [];
++ if (ctx.scope && ctx.scope.page) {
++ const u = safeUrl(`${ctx.scope.page}${ctx.scope.page.includes('?') ? '&' : '?'}q=${encodeURIComponent(q)}`);
++ if (u) out.push({ title: `Search “${q}” on ${here()}`, meta: 'This site\'s own search', url: u, icon: 'search', kind: 'action' });
++ }
++ out.push({ title: `Search “${q}” on all of OpenVibe`, meta: 'Every site, in OpenVibe.Search', url: `${API}/?q=${encodeURIComponent(q)}`, icon: 'search', kind: 'action' });
++ out.push({ title: `Ask Vibe: “${q}”`, meta: 'OpenVibe\'s assistant searches the web and OpenVibe', url: `https://openvibe.chat/vibe?ask=${encodeURIComponent(q)}`, icon: 'spark', kind: 'action' });
++ return out;
++ }
++
++ function render(q) {
++ q = String(q || '').trim();
++ if (!q) {
++ const r = recent.get();
++ paint([
++ { title: 'Recent', clear: true, items: r.map((x) => ({ title: x, meta: 'Search again', url: `${API}/?q=${encodeURIComponent(x)}`, icon: 'clock', kind: 'recent' })) },
++ { title: `On ${here()}`, items: pagesOf('') },
++ ], r.length ? '' : 'Search streams, recordings, jobs, posts, deals, wikis and more, on every OpenVibe site.');
++ return;
++ }
++ paint([{ title: `On ${here()}`, items: pagesOf(q) }, { title: 'Search', items: actions(q) }], 'Looking…');
++ if (inflight) inflight.abort();
++ const ac = new AbortController();
++ inflight = ac;
++ el.classList.add('ovs-busy');
++ suggest(q, { limit: 10, signal: ac.signal }).then((found) => {
++ if (ac.signal.aborted) return;
++ const owner = ctx.scope && ctx.scope.owner;
++ const row = (s) => ({ title: s.title, meta: [s.site, TYPE[s.type] || s.type].filter(Boolean).join(' · '), url: s.url, icon: (s.site || '?').slice(0, 2), kind: 'result' });
++ const mine = owner ? found.filter((s) => s.owner === owner) : [];
++ const rest = found.filter((s) => !mine.includes(s));
++ paint([
++ { title: `On ${here()}`, items: [...mine.map(row), ...pagesOf(q)] },
++ { title: 'Across OpenVibe', items: rest.map(row) },
++ { title: 'Search', items: actions(q) },
++ ], found.length ? '' : 'Nothing starts with that yet: search everywhere, or ask Vibe.');
++ }).catch(() => { if (!ac.signal.aborted) paint([{ title: `On ${here()}`, items: pagesOf(q) }, { title: 'Search', items: actions(q) }], ''); })
++ .finally(() => { if (inflight === ac) { inflight = null; if (el) el.classList.remove('ovs-busy'); } });
++ }
++ function schedule(q) { clearTimeout(timer); timer = setTimeout(() => render(q), String(q).trim() ? 140 : 0); }
++
++ function open(opts = {}) {
++ if (typeof document === 'undefined') return;
++ ctx = opts || {};
++ if (!el) build();
++ lastFocus = document.activeElement;
++ el.classList.add('is-open');
++ document.documentElement.style.overflow = 'hidden';
++ input.placeholder = `Search ${here()} and all of OpenVibe`;
++ input.value = opts.q || '';
++ render(input.value);
++ setTimeout(() => input.focus(), 0);
++ }
++ function close() {
++ if (!el) return;
++ el.classList.remove('is-open');
++ document.documentElement.style.overflow = '';
++ if (inflight) { inflight.abort(); inflight = null; }
++ if (lastFocus && lastFocus.focus) lastFocus.focus();
++ }
++
++ const api = { open, close, suggest, siteOf };
++ root.OpenVibeSearch = api;
++ if (typeof module === 'object' && module.exports) module.exports = api;
++})(typeof globalThis !== 'undefined' ? globalThis : this);
ddiff --git a/shipped.js b/shipped.js
iindex 8966cb5..a0e71d5 100644
---- a/shipped.js
++++ b/shipped.js
@@@ -40,7 +40,7 @@
live: 'Live', network: 'Network', tools: 'Tools', media: 'Media', community: 'Community', chat: 'Chat', games: 'Games',
blog: 'Blog', wiki: 'Wiki', news: 'News', reviews: 'Reviews', deals: 'Deals', coupons: 'Coupons', trade: 'Trade',
codes: 'Codes', host: 'Host', ai: 'AI', search: 'Search', sources: 'Sources', events: 'Events', billing: 'Billing',
-- tips: 'Tips', vip: 'VIP', openre: 'OpenRestream', bot: 'Bot', sites: 'Sites', realtime: 'Realtime', space: 'Space',
++ tips: 'Tips', vip: 'VIP', openre: 'Restream', bot: 'Bot', sites: 'Sites', realtime: 'Realtime', space: 'Space',
services: 'Services', actor: 'Actor', run: 'Run', watch: 'Watch', node: 'Node', zone: 'Zone', download: 'Download',
video: 'Video', pics: 'Pics', rent: 'Rent', homes: 'Homes', work: 'Work', food: 'Food', help: 'Help', quest: 'Quest',
website: 'Website', fund: 'Fund', 'media-hub': 'Media Hub', extensions: 'Extensions', contracts: 'Contracts', sdk: 'SDK',
ddiff --git a/test/nav-icons.test.js b/test/nav-icons.test.js
iindex 74007af..8c660e7 100644
---- a/test/nav-icons.test.js
++++ b/test/nav-icons.test.js
@@@ -13,7 +13,7 @@ const { brotli } = require('../scripts/size-report');
const ROOT = path.join(__dirname, '..');
const NAVBAR_PATH = path.join(ROOT, 'navbar.js');
const ICONS = require('../nav-icons');
--const NAVBAR_BROTLI_BUDGET = 37 * 1024; // measured 27.6 KB on 2026-09-22 (39.0 KB with every glyph inline); 29.4 KB with the top-of-page fade (2.15.0); 30.6 KB with Ask Actor and its glyph (4.2.0); 31.4 KB with every site in groups in the launcher (4.4.0); 32.1 KB with the season's badge and switches (4.11.0); 33.0 KB with Work together and the drawer anchored to the bar (4.13.0); 33.6 KB with account switching on every site and finger-sized brand links (4.14.0); 35.2 KB with the folded balance chip, Across OpenVibe with site marks and the icon preload (4.16.0); 36.2 KB with wallet: true, OpenCoins and Vibes in any site's bar (4.17.0)
++const NAVBAR_BROTLI_BUDGET = 37.5 * 1024; // 37.05 KB with the search magnifier, "/" and Ctrl+K, search.js loaded on first use (4.21.0); measured 27.6 KB on 2026-09-22 (39.0 KB with every glyph inline); 29.4 KB with the top-of-page fade (2.15.0); 30.6 KB with Ask Actor and its glyph (4.2.0); 31.4 KB with every site in groups in the launcher (4.4.0); 32.1 KB with the season's badge and switches (4.11.0); 33.0 KB with Work together and the drawer anchored to the bar (4.13.0); 33.6 KB with account switching on every site and finger-sized brand links (4.14.0); 35.2 KB with the folded balance chip, Across OpenVibe with site marks and the icon preload (4.16.0); 36.2 KB with wallet: true, OpenCoins and Vibes in any site's bar (4.17.0)
// ── size budget and generated block ──────────────────────────
{
ddiff --git a/test/search-loading-chrome.test.js b/test/search-loading-chrome.test.js
nnew file mode 100644
iindex 0000000..60c8d70
---- /dev/null
++++ b/test/search-loading-chrome.test.js
@@@ -0,0 +1,131 @@
++'use strict';
++// search.js and loading.js in a real headless Chrome (4.21.0). Search: the navbar's magnifier (and "/") loads search.js
++// on first use and opens the panel; with nothing typed it shows this site's pages; typing asks OpenVibe.Search's
++// suggest (here a local stand-in) with the older request cancelled, puts this site's results first, and always offers
++// "all of OpenVibe" and "Ask Vibe"; arrows and Escape work; titles are text, never markup. Loading: a poster-backed
++// overlay that steps, slows with actions, fails with Retry and fades out; skeleton rows. Skipped without Chrome.
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const h = require('../browser-harness');
++
++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {
++ console.log('search-loading-chrome: skipped (no Chrome; set CHROME_BIN)');
++ process.exit(0);
++}
++
++const ROOT = path.join(__dirname, '..');
++const asked = [];
++const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Rooms · OpenVibe.Chat</title><link rel="icon" href="data:,"></head>
++<body><div id="navbar-mount"></div><main><div id="player" style="position:relative;width:360px;height:202px"></div><div id="list"></div></main>
++<script src="/navbar.js"></script><script src="/loading.js"></script>
++<script>
++ // search.js asks the real Search; this page answers instead (same shape), slowly for "slow".
++ window.__realFetch = window.fetch;
++ window.fetch = async (u, init) => {
++ const url = new URL(String(u), location.href);
++ if (url.hostname === 'search.openvibe.network') return window.__realFetch('/suggest' + url.search, init);
++ return window.__realFetch(u, init);
++ };
++ OpenVibeNavbar.init({ service: 'chat', user: null, links: [{ label: 'Rooms', href: '/rooms', icon: 'fa-people-group' }, { label: 'Servers', href: '/servers', icon: 'fa-server' }],
++ search: { label: 'OpenVibe.Chat', page: '/search' } });
++</script></body></html>`;
++const server = http.createServer((req, res) => {
++ const u = new URL(req.url, 'http://x');
++ if (u.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(PAGE); }
++ if (u.pathname === '/suggest') {
++ const q = u.searchParams.get('q');
++ asked.push(q);
++ const body = JSON.stringify({ suggestions: [
++ { owner: 'live', type: 'vod', id: '1', title: `${q} stream <b>bold</b>`, canonical_url: 'https://openvibe.video/v/1' },
++ { owner: 'chat', type: 'room', id: 'r', title: `${q} room`, canonical_url: 'https://openvibe.chat/r/night-owls' },
++ { owner: 'evil', type: 'x', id: 'e', title: 'bad link', canonical_url: 'javascript:alert(1)' },
++ ] });
++ const send = () => { res.writeHead(200, { 'content-type': 'application/json' }); res.end(body); };
++ return q === 'slow' ? setTimeout(send, 600) : send();
++ }
++ const name = u.pathname.slice(1);
++ if (/^[a-z-]+\.js$/.test(name) && fs.existsSync(path.join(ROOT, name))) { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(ROOT, name))); }
++ res.writeHead(404); return res.end();
++});
++
++(async () => {
++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-search-'));
++ const browser = await h.launch({ tmpDir });
++ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
++ try {
++ const context = await browser.newContext();
++ const p = await h.openPage(browser, context, { width: 390 });
++ await p.goto(`${base}/`, { maxMs: 5000 });
++ await wait(200);
++ // ── Search ──
++ assert.strictEqual(await p.evaluate('!!document.getElementById("openvibe-search-btn")'), true, 'the magnifier is in the bar');
++ assert.strictEqual(await p.evaluate('typeof window.OpenVibeSearch'), 'undefined', 'search.js is not loaded until asked');
++ await p.evaluate('document.dispatchEvent(new KeyboardEvent("keydown", { key: "/", bubbles: true })); 1');
++ await wait(400);
++ let v = await p.evaluate(`({ open: !!document.querySelector('.ovs.is-open'), focused: document.activeElement && document.activeElement.className,
++ groups: [...document.querySelectorAll('.ovs-group')].map((g) => g.firstChild.textContent), rows: [...document.querySelectorAll('.ovs-title')].map((t) => t.textContent),
++ placeholder: document.querySelector('.ovs-input').placeholder })`);
++ assert.ok(v.open && v.focused === 'ovs-input', '"/" opened the panel and focused the box');
++ assert.deepStrictEqual([v.groups, v.rows], [['On OpenVibe.Chat'], ['Rooms', 'Servers']], 'nothing typed: this site\'s pages');
++ assert.strictEqual(v.placeholder, 'Search OpenVibe.Chat and all of OpenVibe');
++ // Typing: one request for the latest text (the earlier one cancelled), this site's results first.
++ await p.evaluate(`(() => { const i = document.querySelector('.ovs-input'); i.value = 'slow'; i.dispatchEvent(new Event('input')); setTimeout(() => { i.value = 'night'; i.dispatchEvent(new Event('input')); }, 250); })(); 1`);
++ await wait(1200);
++ v = await p.evaluate(`({ groups: [...document.querySelectorAll('.ovs-group')].map((g) => g.firstChild.textContent), rows: [...document.querySelectorAll('.ovs-item a')].map((a) => [a.querySelector('.ovs-title').textContent, a.getAttribute('href')]),
++ bold: !!document.querySelector('.ovs-list b'), active: document.querySelector('.ovs-item.is-active .ovs-title').textContent })`);
++ assert.deepStrictEqual(v.groups, ['On OpenVibe.Chat', 'Across OpenVibe', 'Search']);
++ assert.deepStrictEqual(v.rows[0], ['night room', 'https://openvibe.chat/r/night-owls'], 'the room on this site comes first');
++ assert.ok(v.rows.some(([t, u]) => t === 'night stream <b>bold</b>' && u === 'https://openvibe.video/v/1'), 'a title is text, never markup');
++ assert.ok(!v.bold, 'no markup from a title reached the page');
++ assert.ok(!v.rows.some(([, u]) => /^javascript:/.test(u)), 'a script link is dropped');
++ assert.ok(v.rows.some(([t, u]) => t === 'Search “night” on OpenVibe.Chat' && u.endsWith('/search?q=night')), 'this site\'s own search page');
++ assert.ok(v.rows.some(([t, u]) => t === 'Search “night” on all of OpenVibe' && u === 'https://search.openvibe.network/?q=night'));
++ assert.ok(v.rows.some(([t, u]) => t === 'Ask Vibe: “night”' && u === 'https://openvibe.chat/vibe?ask=night'));
++ assert.strictEqual(v.active, 'night room', 'the first row is ready for Enter');
++ assert.ok(asked.includes('night'), 'Search was asked');
++ await p.evaluate('document.querySelector(".ovs-input").dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); 1');
++ assert.notStrictEqual(await p.evaluate('document.querySelector(".ovs-item.is-active .ovs-title").textContent'), 'night room', 'arrows move');
++ await p.evaluate('document.querySelector(".ovs-input").dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); 1');
++ assert.strictEqual(await p.evaluate('!!document.querySelector(".ovs.is-open")'), false, 'Escape closes');
++
++ // ── Loading ──
++ v = await p.evaluate(`new Promise((done) => {
++ const l = OpenVibeLoading.mount(document.getElementById('player'), { label: 'Connecting', sub: 'Getting your viewer ready', steps: 4,
++ poster: 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="16" height="9"><rect width="16" height="9" fill="purple"/></svg>') });
++ const box = document.querySelector('.ovld');
++ const r = { role: box.getAttribute('role'), unknown: box.classList.contains('is-unknown'), label: box.querySelector('.ovld-label').textContent };
++ l.update({ step: 1, label: 'Opening secure link', sub: 'Almost there' });
++ setTimeout(() => {
++ r.p = box.style.getPropertyValue('--ovld-p'); r.unknown2 = box.classList.contains('is-unknown'); r.label2 = box.querySelector('.ovld-label').textContent;
++ r.poster = box.querySelector('.ovld-poster').classList.contains('is-in');
++ let tapped = 0;
++ l.slow({ hint: 'Taking longer than usual.', actions: [{ label: 'Copy details', onClick: () => { tapped++; } }] });
++ r.slow = box.classList.contains('is-slow'); box.querySelector('.ovld-acts button').click(); r.tapped = tapped;
++ l.fail({ title: 'Could not connect', detail: 'The stream stopped answering.', actions: [{ label: 'Retry', onClick: () => {} }] });
++ setTimeout(() => {
++ r.failed = box.classList.contains('is-failed'); r.alert = box.getAttribute('role'); r.title = box.querySelector('.ovld-label').textContent; r.retry = box.querySelector('.ovld-acts button').textContent;
++ l.done(); setTimeout(() => { r.gone = !document.querySelector('.ovld');
++ const s = OpenVibeLoading.skeleton(document.getElementById('list'), { rows: 3 }); r.skel = document.querySelectorAll('.ovld-skel-row').length; s.done(); r.skelGone = !document.querySelector('.ovld-skel'); done(r); }, 600);
++ }, 400);
++ }, 400);
++ })`);
++ assert.deepStrictEqual([v.role, v.unknown, v.label], ['status', true, 'Connecting'], 'it starts as an unknown progress, announced politely');
++ assert.deepStrictEqual([v.p, v.unknown2, v.label2], ['40%', false, 'Opening secure link'], 'step 2 of 4 fills the line');
++ assert.strictEqual(v.poster, true, 'the poster faded in behind');
++ assert.deepStrictEqual([v.slow, v.tapped], [true, 1], 'slow: a hint and its action');
++ assert.deepStrictEqual([v.failed, v.alert, v.title, v.retry], [true, 'alert', 'Could not connect', 'Retry'], 'failed: said, with Retry');
++ assert.strictEqual(v.gone, true, 'done: it fades and leaves');
++ assert.deepStrictEqual([v.skel, v.skelGone], [3, true], 'skeleton rows');
++ const sideways = await p.evaluate('document.documentElement.scrollWidth - innerWidth');
++ assert.strictEqual(sideways, 0, 'nothing scrolls sideways at 390 px');
++ console.log('search-loading-chrome: search (lazy, scoped, cancelled, safe, keyboard) and loading (steps, slow, fail, done, skeleton) ok');
++ } finally {
++ await browser.close();
++ server.close();
++ }
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/shipped.test.js b/test/shipped.test.js
iindex 2edb9ea..e3318bf 100644
---- a/test/shipped.test.js
++++ b/test/shipped.test.js
@@@ -27,7 +27,7 @@ const data = {
assert.ok(items[0].textContent.includes('<img src=x'));
assert.strictEqual(items[0].querySelector('.ov-shipped-site').textContent, 'Live', 'network-wide: each line names its site');
// Every site reads as its name, never as a lowercase id next to capitalised ones (the owner saw "space", "actor").
-- for (const [id, name] of [['space', 'Space'], ['services', 'Services'], ['actor', 'Actor'], ['vip', 'VIP'], ['openre', 'OpenRestream'], ['media-hub', 'Media Hub'], ['brand-new', 'Brand New']]) {
++ for (const [id, name] of [['space', 'Space'], ['services', 'Services'], ['actor', 'Actor'], ['vip', 'VIP'], ['openre', 'Restream'], ['media-hub', 'Media Hub'], ['brand-new', 'Brand New']]) {
assert.strictEqual(shipped.siteName(id), name, id);
}
assert.strictEqual(items[1].querySelector('a'), null, 'a non-https link is not a link');