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)
Changes
@@@ -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@@@ -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 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.@@@ -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 everypiece, the sizes, the colours, and how to use it in an OpenVibe site or anyone's project;--`node scripts/kit-gallery.js --shots <dir>` shows them all at 390, 768 and 1280 px.++`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).```jsconst kit = require('openvibe-shared/kit');@@@ -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)",@@@ -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), andthe 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):**@@@ -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]);nnew file mode 100644@@@ -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);@@@ -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(); },@@@ -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",nnew file mode 100644@@@ -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);@@@ -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',@@@ -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 ──────────────────────────{nnew file mode 100644@@@ -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); });@@@ -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');
@@ -6,6 +6,30 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
@@ -6,6 +6,30 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
## 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
## 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
- **The kit's behaviour is its own small file, `kit-web.js`** (about 7 KB, 2 KB compressed): copy buttons, menus that
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
-**Status:** alpha, v4.20.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
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.
@@ -622,7 +622,8 @@ cards that fold, settings rows, a search row, a composer pinned at the bottom, a
@@ -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
(`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
(about 7 KB), all a server-rendered page loads. [docs/kit.md](docs/kit.md) has every
piece, the sizes, the colours, and how to use it in an OpenVibe site or anyone's project;
piece, the sizes, the colours, and how to use it in an OpenVibe site or anyone's project;
-`node scripts/kit-gallery.js --shots <dir>` shows them all at 390, 768 and 1280 px.
+`node scripts/kit-gallery.js --shots <dir>` shows them all at 390, 768 and 1280 px. 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
```js
const kit = require('openvibe-shared/kit');
const kit = require('openvibe-shared/kit');
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.20.1",
+ "version": "4.21.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)",
@@ -11,6 +11,8 @@ theme, and they fit a phone first.
@@ -11,6 +11,8 @@ theme, and they fit a phone first.
loads only this one.
loads only this one.
- **`kit.css`**: the look, built on the site's theme tokens.
- **`kit.css`**: the look, built on the site's theme tokens.
- **`appbar.js`**: the phone tab bar. `navbar.js` loads it only for a site that sets `appBar`.
- **`appbar.js`**: the phone tab bar. `navbar.js` loads it only for a site that sets `appBar`.
+- **`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
The package is MIT. Every site serves its own pinned copy at `/shared/kit.css`, `/shared/kit.js`, `/shared/kit-web.js` and
`/shared/appbar.js` (`openvibe-shared/serve`), so a page keeps working when another site is down.
`/shared/appbar.js` (`openvibe-shared/serve`), so a page keeps working when another site is down.
@@ -213,6 +215,53 @@ OpenVibeNavbar.setAppBar(spec | false)
@@ -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
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.
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
## Using it
**An OpenVibe site (Node, server-rendered):**
**An OpenVibe site (Node, server-rendered):**
@@ -41,6 +41,8 @@ const BROWSER = Object.freeze([
@@ -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
'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.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
'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
'openvibe-sw.js', // Web Push service worker
]);
]);
new file mode 100644
new file mode 100644
@@ -0,0 +1,190 @@
@@ -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);
@@ -242,6 +242,8 @@
@@ -242,6 +242,8 @@
.openvibe-navbar-spacer { flex: 1; }
.openvibe-navbar-spacer { flex: 1; }
.openvibe-navbar-right { display: flex; align-items: center; gap: 6px; }
.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 {
.openvibe-navbar-avatar {
width: 32px; height: 32px; border-radius: 50%; cursor: pointer;
width: 32px; height: 32px; border-radius: 50%; cursor: pointer;
@@ -1649,6 +1651,7 @@
@@ -1649,6 +1651,7 @@
</div>
</div>
<div class="openvibe-navbar-spacer"></div>
<div class="openvibe-navbar-spacer"></div>
<div class="openvibe-navbar-right">
<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) : ''}
${u ? chipsHTML(_config.chips) + chipsAllHTML(_config.chips) : ''}
<div id="openvibe-bell-mount"></div>
<div id="openvibe-bell-mount"></div>
${u ? avatarImg(u, 64, 'openvibe-navbar-avatar', 'openvibe-avatar-btn') :
${u ? avatarImg(u, 64, 'openvibe-navbar-avatar', 'openvibe-avatar-btn') :
@@ -1659,6 +1662,7 @@
@@ -1659,6 +1662,7 @@
`;
`;
if (_config.className) String(_config.className).split(/\s+/).filter(Boolean).forEach(c => nav.classList.add(c));
if (_config.className) String(_config.className).split(/\s+/).filter(Boolean).forEach(c => nav.classList.add(c));
bindLinks(nav, links);
bindLinks(nav, links);
+ hookSearch(nav);
bindChips(nav);
bindChips(nav);
bindChipsAll(nav);
bindChipsAll(nav);
queueIcons();
queueIcons();
@@ -1808,6 +1812,27 @@
@@ -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 ──
// ── The phone app bar (appbar.js, loaded only when the site sets appBar): its main places as a bottom tab bar ──
let _appBarLoading = false;
let _appBarLoading = false;
const rawLinks = () => _runtimeLinks || _config.links || SERVICE_LINKS[_config.service] || [];
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() {
function renderAppBar() {
if (typeof document === 'undefined' || !document.body) return;
if (typeof document === 'undefined' || !document.body) return;
const ab = root.OpenVibeAppBar;
const ab = root.OpenVibeAppBar;
@@ -1886,6 +1911,9 @@
@@ -1886,6 +1911,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(); },
+ /** 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. */
/** 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(); },
setAppBar(spec) { _config.appBar = spec; renderAppBar(); },
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"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",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -62,6 +62,8 @@
@@ -62,6 +62,8 @@
"./kit": "./kit.js",
"./kit": "./kit.js",
"./kit-web": "./kit-web.js",
"./kit-web": "./kit-web.js",
"./appbar": "./appbar.js",
"./appbar": "./appbar.js",
+ "./search": "./search.js",
+ "./loading": "./loading.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,259 @@
@@ -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);
@@ -40,7 +40,7 @@
@@ -40,7 +40,7 @@
live: 'Live', network: 'Network', tools: 'Tools', media: 'Media', community: 'Community', chat: 'Chat', games: 'Games',
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',
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',
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',
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',
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',
website: 'Website', fund: 'Fund', 'media-hub': 'Media Hub', extensions: 'Extensions', contracts: 'Contracts', sdk: 'SDK',
@@ -13,7 +13,7 @@ const { brotli } = require('../scripts/size-report');
@@ -13,7 +13,7 @@ const { brotli } = require('../scripts/size-report');
const ROOT = path.join(__dirname, '..');
const ROOT = path.join(__dirname, '..');
const NAVBAR_PATH = path.join(ROOT, 'navbar.js');
const NAVBAR_PATH = path.join(ROOT, 'navbar.js');
const ICONS = require('../nav-icons');
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 ──────────────────────────
// ── size budget and generated block ──────────────────────────
{
{
new file mode 100644
new file mode 100644
@@ -0,0 +1,131 @@
@@ -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); });
@@ -27,7 +27,7 @@ const data = {
@@ -27,7 +27,7 @@ const data = {
assert.ok(items[0].textContent.includes('<img src=x'));
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');
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").
// 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(shipped.siteName(id), name, id);
}
}
assert.strictEqual(items[1].querySelector('a'), null, 'a non-https link is not a link');
assert.strictEqual(items[1].querySelector('a'), null, 'a non-https link is not a link');