openvibers/openvibe.shared Commits

4.48.0: the site switcher, and a cleaner OpenVibe menu (#193)

OP OpenVibers committed 14 days ago · 02544982c2 · 10 files, +496 −23

* The OpenVibe mark opens a site switcher (site-switcher.js)

The mark becomes a button that opens a compact grid of every OpenVibe
site: Recent and Pinned (following the account through Network's
/api/themes/me/sites when signed in), then the frame's groups, most used
first. Type to filter, arrows to move, Enter to go, Esc to close; hold a
site or right-click it to pin it. The site's name stays the link home;
switcher: false keeps the old link. The navbar records one visit per site
per 30 minutes for a signed-in person, never under GPC or DNT.

* 4.48.0: the site switcher, and a cleaner OpenVibe menu

The menu's search field is a rounded field with a soft focus ring that a
site's own focus style can no longer draw over; the placeholder and the
headings use the site's short name; 'For you' is Start, with everyday
Quick tools; the groups rise in on opening. The navbar's visit for the
switcher waits for an idle moment. navbar.js brotli budget 40 KB (39.8).

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 07b8b40..7807ce5 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -6,6 +6,30 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
++## 4.48.0 — 2026-10-30
++
++- **The OpenVibe mark opens a site switcher.** The owner: "clicking the icon … its own drop-down … all the sites, so
++ you can hop between them fast". `site-switcher.js` loads on the first tap of the mark, as search.js does for the
++ menu.
++ - What it shows: a compact grid of every open site, with Recent, then Pinned, then the frame's groups, most used
++ first. The page's own site is marked.
++ - How to use it: type to filter, use the arrow keys to move, Enter to go and Esc to close. Hold a site, or
++ right-click it, to pin it.
++ - On phones it is a sheet under the bar that keeps its own scroll.
++ - Recent and pinned follow the account through Network `/api/themes/me/sites`. Signed out, they stay on this site.
++ The navbar records one visit per site per 30 minutes for a signed-in person, never under GPC or DNT.
++ - The site's name in the bar stays its link home. `switcher: false` keeps the mark as a home link.
++ - `test/site-switcher.test.js` covers it in a real Chrome.
++- **The OpenVibe menu, cleaner** (the owner: "the search input is styled all weird … For you kind of sucks … just get
++ to basic tools"):
++ - the search field is a rounded field with a soft focus ring; a site's own focus style can no longer draw a box
++ on it;
++ - the placeholder and the headings use the site's short name ("Search Download, or ask Vibe", "On Download"),
++ not the page title, which was cut off;
++ - "For you" is now **Start**: this site's places, **Quick tools** (QR codes, images, PDFs, word counts first,
++ then the most used), recent searches, then popular sites;
++ - opening, the groups rise in one after another.
++
## 4.47.3 — 2026-10-30
- **A tap on the logo no longer opens the menu.** On a phone, the menu button's invisible touch area (4.46.0) covered
ddiff --git a/README.md b/README.md
iindex 70f49f3..62e37bc 100644
---- a/README.md
++++ b/README.md
@@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
--**Status:** alpha, v4.47.3 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.48.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
@@@ -704,6 +704,11 @@ Sign-in and history: history: { type: 'tool'|'stream'|'paste'|'page'|…,
chip the first time and re-authenticates silently afterwards, 'silent' only
re-authenticates, false opts out; fedcmLogin is the POST target for the
assertion (default: this site's /auth/fedcm).
++Site switcher (4.48): the OpenVibe mark is a button that opens
++ site-switcher.js, a compact grid of every OpenVibe site with Recent, Pinned,
++ then the frame's groups. Recent and pinned follow the account (Network
++ /api/themes/me/sites) when signed in. The site's name stays the link home;
++ switcher: false keeps the mark as a home link.
Balances: chips: [{ id, icon, value, label, tone, title, onClick, hidden }]
are status chips beside the bell (Live's OpenCoins and Vibes); setChip(id,
value) updates one. Two or more fold into one chip at 560 px and narrower.
ddiff --git a/STATUS.json b/STATUS.json
iindex 8625f2b..6693050 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.47.3",
++ "version": "4.48.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
ddiff --git a/navbar.js b/navbar.js
iindex 8fd027a..c16a272 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -49,9 +49,12 @@
/* Brand group: mark + linked wordmark + launcher read as one control. Every property a host
page might set on bare "nav a" / "a" is reset here, so the brand looks the same everywhere. */
.openvibe-navbar .openvibe-navbar-brand a, .openvibe-navbar .openvibe-navbar-brand a:hover { all: unset; cursor: pointer; color: inherit; font: inherit; }
++ :where(.openvibe-navbar-brand) button.flame { all: unset; cursor: pointer; color: inherit; font: inherit; }
++ .openvibe-navbar .openvibe-navbar-brand .flame[aria-expanded="true"] { background: var(--accent-glow, rgba(59,130,246,.18)); }
++ .openvibe-navbar .openvibe-navbar-brand button.flame:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }
.openvibe-navbar .openvibe-navbar-brand { gap: 6px; padding: 2px; border-radius: 12px; margin-right: 0; }
-- .openvibe-navbar .openvibe-navbar-brand a.flame { display: inline-grid; place-items: center; width: 30px; height: 30px; border-radius: 9px; transition: background .15s; }
-- .openvibe-navbar .openvibe-navbar-brand a.flame:hover { background: var(--accent-glow, rgba(59,130,246,.14)); }
++ .openvibe-navbar .openvibe-navbar-brand .flame { display: inline-grid; place-items: center; width: 30px; height: 30px; border-radius: 9px; transition: background .15s; }
++ .openvibe-navbar .openvibe-navbar-brand .flame:hover { background: var(--accent-glow, rgba(59,130,246,.14)); }
.openvibe-navbar .openvibe-navbar-brand .name { align-items: center; gap: 0; font-size: 15.5px; letter-spacing: -.25px; }
.openvibe-navbar .openvibe-navbar-brand .name a { padding: 3px 1px; border-radius: 6px; transition: color .15s, background .15s; }
.openvibe-navbar .openvibe-navbar-brand .name a.b-sub { color: var(--text-primary, #e6edf7); font-weight: 750; }
@@@ -67,8 +70,8 @@
gets a clear ring, without moving anything (they were 22px and 30px tall). */
@media (pointer: coarse) {
.openvibe-navbar .openvibe-navbar-brand .name a { padding-top: 14px; padding-bottom: 14px; margin-top: -11px; margin-bottom: -11px; }
-- .openvibe-navbar .openvibe-navbar-brand a.flame { position: relative; }
-- .openvibe-navbar .openvibe-navbar-brand a.flame::after { content: ''; position: absolute; inset: -8px; }
++ .openvibe-navbar .openvibe-navbar-brand .flame { position: relative; }
++ .openvibe-navbar .openvibe-navbar-brand .flame::after { content: ''; position: absolute; inset: -8px; }
/* 4.46.0 (the fleet tap audit): Sign In, the menu and the orb reach 44px to a finger, still drawn as before. */
.openvibe-navbar .openvibe-navbar-login, .openvibe-navbar .ovnav-burger, .openvibe-navbar .ovnav-orb { position: relative; }
.openvibe-navbar .openvibe-navbar-login::after { content: ''; position: absolute; inset: -6px 0; }
@@@ -160,14 +163,14 @@
in, the name ran under the launcher and the magnifier). The name gives way with an ellipsis, and up to 440px
the folded balances show the first one only (a tap lists them all). */
.openvibe-navbar .openvibe-navbar-brand { flex: 0 1 auto; }
-- .openvibe-navbar .openvibe-navbar-brand a.flame { flex: none; }
++ .openvibe-navbar .openvibe-navbar-brand .flame { flex: none; }
.openvibe-navbar .openvibe-navbar-brand .name { display: block; min-width: 0; white-space: nowrap; overflow-x: clip; text-overflow: ellipsis; }
/* Both balances on every phone (the owner, 2026-10-28: "you can only see one… use the full space"): the pill
tightens instead of folding to one. */
@media (max-width: 440px) { .ovnav-chip-mini { padding: 0 5px; gap: 3px; } .openvibe-navbar .ovnav-chip.ovnav-chip--all { font-size: 12px; } .ovnav-chip-mini .ovnav-ic { width: 13px; height: 13px; } }
@media (max-width: 400px) { .openvibe-navbar .openvibe-navbar-right { gap: 1px; } .ovnav-chip-mini { padding: 0 3px; gap: 2px; } .openvibe-navbar .ovnav-chip.ovnav-chip--all { font-size: 11.5px; letter-spacing: -.2px; } .openvibe-navbar .openvibe-navbar-brand { gap: 4px; }
.openvibe-navbar { padding-left: 8px; padding-right: 6px; gap: 2px; } .openvibe-navbar .ovnav-burger { width: 34px; } .openvibe-navbar .openvibe-navbar-avatar { width: 32px; height: 32px; box-sizing: border-box; }
-- .openvibe-navbar .openvibe-navbar-brand .name { font-size: 14px; letter-spacing: -.3px; } .openvibe-navbar .openvibe-navbar-brand a.flame { width: 28px; height: 28px; } }
++ .openvibe-navbar .openvibe-navbar-brand .name { font-size: 14px; letter-spacing: -.3px; } .openvibe-navbar .openvibe-navbar-brand .flame { width: 28px; height: 28px; } }
/* Under 350px the mark alone names the site, so both balances keep their icons (a number alone says nothing). */
@media (max-width: 350px) { .openvibe-navbar .openvibe-navbar-brand .name { display: none; } }
.openvibe-navbar-brand .flame { font-size: 18px; text-decoration: none; color: var(--accent, #3b82f6); display: inline-grid; place-items: center; width: 28px; height: 28px; flex: none; }
@@@ -669,7 +672,9 @@
? `<i class="fa-solid ${escapeAttr(brand.icon)}"></i>`
: `<span class="ov-mark" data-size="28" data-variant="${escapeAttr(brand.variant)}"></span>`;
return `<div class="openvibe-navbar-brand${brand.subText ? ' has-sub' : ''}${brand.joined ? ' joined' : ''}">
-- <a class="flame" href="${escapeAttr(brand.href)}" aria-label="${escapeAttr(brand.name)} home">${mark}</a>
++ ${_config.switcher === false
++ ? `<a class="flame" href="${escapeAttr(brand.href)}" aria-label="${escapeAttr(brand.name)} home">${mark}</a>`
++ : `<button type="button" class="flame" id="openvibe-switch-btn" aria-haspopup="dialog" aria-expanded="false" aria-label="Switch to another OpenVibe site" title="Every OpenVibe site">${mark}</button>`}
<span class="name">${text}${brand.tag ? `<span class="b-tag">${escapeAttr(brand.tag)}</span>` : ''}</span>
${_config.search === false ? '' : orbHTML()}
</div>`;
@@@ -1656,6 +1661,7 @@
bindLinks(nav, links);
bindMessages(nav);
hookSearch(nav);
++ hookSwitcher(nav);
bindChips(nav);
bindChipsAll(nav);
queueIcons();
@@@ -1833,6 +1839,53 @@
try { const sc = document.createElement('script'); sc.src = sibling('search.js'); sc.async = true; sc.onload = go; sc.onerror = () => { _searchLoading = false; if (orb) orb.classList.remove('is-busy'); }; document.head.appendChild(sc); } catch { _searchLoading = false; }
}
const menuOpen = () => !!(typeof document !== 'undefined' && document.querySelector && document.querySelector('.ovs.is-open'));
++
++ // ── The site switcher (site-switcher.js, 4.48.0): the OpenVibe mark opens a compact grid of every site ──
++ // Recent and pinned follow the account (Network /api/themes/me/sites) when signed in; the name stays the home link.
++ let _switcherLoading = false;
++ function openSwitcher() {
++ const btn = _navEl && _navEl.querySelector('#openvibe-switch-btn');
++ const anchor = () => {
++ if (!_navEl || !btn || !btn.getBoundingClientRect) return null;
++ const bar = _navEl.getBoundingClientRect(), at = btn.getBoundingClientRect();
++ return bar.bottom > 0 && bar.bottom < 200 ? { top: bar.bottom, left: at.left - 6 } : null;
++ };
++ const go = () => {
++ const S = root.OpenVibeSiteSwitcher;
++ if (!S) return;
++ if (menuOpen() && root.OpenVibeSearch) root.OpenVibeSearch.close();
++ if (_navEl) _navEl.classList.remove('ovnav-hide');
++ S.open({ service: _config.service, frame: OVFrame.get((d) => S.update(d)), token: _config.token, apiBase: 'https://openvibe.network', user: _config.user,
++ icons: (scope) => loadOvIcons(scope), anchor: anchor(),
++ onClose: () => { const b = _navEl && _navEl.querySelector('#openvibe-switch-btn'); if (b) b.setAttribute('aria-expanded', 'false'); } });
++ const b = _navEl && _navEl.querySelector('#openvibe-switch-btn'); if (b) b.setAttribute('aria-expanded', 'true');
++ };
++ if (root.OpenVibeSiteSwitcher) return go();
++ if (_switcherLoading) return;
++ _switcherLoading = true;
++ try { const sc = document.createElement('script'); sc.src = sibling('site-switcher.js'); sc.async = true; sc.onload = go; sc.onerror = () => { _switcherLoading = false; }; document.head.appendChild(sc); } catch { _switcherLoading = false; }
++ }
++ // A signed-in person's visit moves this site to the front of their Recent, at most once in 30 minutes, and never
++ // under Global Privacy Control or Do Not Track (as the page-view beacon).
++ let _visitSent = false;
++ function recordVisit() {
++ if (_visitSent || !_config.token || !_config.service || typeof fetch === 'undefined' || typeof navigator === 'undefined') return;
++ if (navigator.globalPrivacyControl || navigator.doNotTrack === '1') return;
++ _visitSent = true;
++ const key = 'ov_sw_visit_' + _config.service;
++ try { if (Date.now() - Number(localStorage.getItem(key) || 0) < 30 * 60000) return; localStorage.setItem(key, String(Date.now())); } catch { /* storage off: still record */ }
++ fetch('https://openvibe.network/api/themes/me/sites/visit', { method: 'POST', headers: { Authorization: `Bearer ${_config.token}`, 'Content-Type': 'application/json' }, credentials: 'include', keepalive: true, body: JSON.stringify({ site: _config.service }) }).catch(() => {});
++ }
++ function hookSwitcher(nav) {
++ const b = nav.querySelector('#openvibe-switch-btn');
++ if (!b) return;
++ b.addEventListener('click', () => { if (root.OpenVibeSiteSwitcher && root.OpenVibeSiteSwitcher.isOpen()) root.OpenVibeSiteSwitcher.close(); else openSwitcher(); });
++ for (const ev of ['pointerenter', 'touchstart', 'focus']) b.addEventListener(ev, () => { loadOvIcons(); OVFrame.get(); }, { once: true, passive: true });
++ if (root.OpenVibeSiteSwitcher && root.OpenVibeSiteSwitcher.isOpen()) b.setAttribute('aria-expanded', 'true');
++ // Once the page is idle: the visit never competes with the first paint or the sign-in check.
++ if (typeof root.requestIdleCallback === 'function') root.requestIdleCallback(() => recordVisit(), { timeout: 8000 });
++ else setTimeout(recordVisit, 3000);
++ }
let _orbIntro = false;
let _searchKeys = false;
function hookSearch(nav) {
@@@ -1947,6 +2000,7 @@
/** Open the search panel (search.js), with an optional query already in it. */
openSearch(q, tab) { openSearch(q, tab); },
openMenu(tab) { openSearch('', tab || 'you'); },
++ openSwitcher() { openSwitcher(); },
/** The phone app bar: true (from the links that have icons), a list of { label, href, icon, id, page, dot }, or false. */
setAppBar(spec) { _config.appBar = spec; renderAppBar(); },
ddiff --git a/package.json b/package.json
iindex 1e1733c..013d40f 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.47.3",
++ "version": "4.48.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
@@@ -66,6 +66,7 @@
"./tokens": "./tokens.js",
"./components": "./components.js",
"./appbar": "./appbar.js",
++ "./site-switcher": "./site-switcher.js",
"./search": "./search.js",
"./agent-session": "./agent-session.js",
"./loading": "./loading.js",
ddiff --git a/search.js b/search.js
iindex b221507..81c38e1 100644
---- a/search.js
++++ b/search.js
@@@ -62,7 +62,7 @@
{ name: 'Developer Tools', icon: 'code', url: 'https://dev.openvibe.tools/' },
{ name: 'Network Tools', icon: 'dns', url: 'https://net.openvibe.tools/' },
];
-- const TABS = [['you', 'For you'], ['sites', 'Sites'], ['tools', 'Tools']];
++ const TABS = [['you', 'Start'], ['sites', 'Sites'], ['tools', 'Tools']];
let el = null, input = null, ghost = null, listEl = null, tabsEl = null, ctx = {}, items = [], active = -1, inflight = null, timer = null, lastFocus = null;
let tab = 'you', mine = null, catalog = null, completion = '';
// The results view (4.29.0): Enter shows the Search Fabric's answer here, by scope, instead of leaving the page.
@@@ -164,7 +164,10 @@
.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: 8vh; width: min(720px, calc(100vw - 32px)); max-height: 80vh; 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; }
++.ovs-bar { display: flex; align-items: center; gap: 10px; margin: 10px 10px 6px; padding: 0 6px 0 14px; border-radius: 999px; background: var(--bg-primary, rgba(0,0,0,.25)); box-shadow: inset 0 0 0 1px var(--border, rgba(255,255,255,.12)); transition: box-shadow .2s, background .2s; }
++.ovs-bar:focus-within { box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--accent, #3b82f6) 70%, transparent), 0 0 0 4px color-mix(in srgb, var(--accent, #3b82f6) 15%, transparent); }
++/* A site's own focus ring (the orange box on openvibe.download, 10-29) never draws on the field: the pill shows focus. */
++.ovs .ovs-input, .ovs .ovs-input:focus, .ovs .ovs-input:focus-visible { outline: none !important; box-shadow: none !important; border: 0 !important; background: transparent !important; }
.ovs-bar > svg { width: 18px; height: 18px; flex: none; color: var(--text-muted, #93a0b4); }
.ovs-field { position: relative; flex: 1; min-width: 0; display: flex; }
.ovs-input, .ovs-ghost { height: 48px; font: inherit; font-size: 17px; letter-spacing: normal; }
@@@ -178,7 +181,12 @@
.ovs-tab { flex: none; height: 32px; padding: 0 14px; border: 0; border-radius: 999px; background: transparent; color: var(--text-secondary, #a8b3c4); font: inherit; font-size: 13.5px; font-weight: 650; cursor: pointer; }
.ovs-tab[aria-selected="true"] { background: color-mix(in srgb, var(--accent, #3b82f6) 18%, transparent); color: var(--accent-light, var(--accent, #8ab4ff)); }
.ovs-tab:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }
--.ovs-typing .ovs-bar { border-bottom: 1px solid var(--border, rgba(255,255,255,.1)); }
++.ovs-typing .ovs-bar { border-bottom: 0; }
++/* Opening, the groups rise in one after another (not while typing: every key redraws the list). */
++.ovs:not(.ovs-typing) .ovs-list > li { animation: ovs-rise .32s cubic-bezier(.2, 1.2, .3, 1) both; }
++.ovs-list > li:nth-child(2) { animation-delay: 25ms; } .ovs-list > li:nth-child(3) { animation-delay: 50ms; } .ovs-list > li:nth-child(4) { animation-delay: 75ms; }
++.ovs-list > li:nth-child(5) { animation-delay: 100ms; } .ovs-list > li:nth-child(6) { animation-delay: 125ms; } .ovs-list > li:nth-child(n+7) { animation-delay: 150ms; }
++@keyframes ovs-rise { from { opacity: 0; transform: translateY(6px); } }
.ovs-list { list-style: none; margin: 0; padding: 6px; overflow-y: auto; overscroll-behavior: contain; }
.ovs-group { display: flex; align-items: center; gap: 8px; padding: 12px 10px 5px; font-size: 11px; font-weight: 800; letter-spacing: .07em; text-transform: uppercase; color: var(--text-muted, #93a0b4); }
.ovs-group button { margin-left: auto; border: 0; background: none; color: var(--accent-light, #8ab4ff); font: inherit; font-size: 11.5px; font-weight: 650; text-transform: none; letter-spacing: 0; cursor: pointer; }
@@@ -233,7 +241,7 @@
.ovs.ovs-anchored .ovs-panel { top: 8px; left: clamp(8px, var(--ovs-left, 12px), calc(100vw - min(720px, calc(100vw - 16px)) - 8px)); transform: none; transform-origin: 24px 0; max-height: calc(100dvh - var(--ovs-top, 0px) - 24px); animation: ovs-drop .22s cubic-bezier(.2,1.15,.3,1); }
@keyframes ovs-drop { from { opacity: 0; transform: translateY(-8px) scale(.97); } to { opacity: 1; transform: none; } }
@media (max-width: 640px) { .ovs.ovs-anchored .ovs-panel { top: 0; left: 0; width: 100vw; height: auto; max-height: calc(100dvh - var(--ovs-top, 0px) - 12px); border-radius: 0 0 22px 22px; border-top: 0; } }
--@media (prefers-reduced-motion: reduce) { .ovs.is-open, .ovs-busy .ovs-bar, .ovs.ovs-anchored .ovs-panel, .ovs-skel { animation: none; } }`;
++@media (prefers-reduced-motion: reduce) { .ovs.is-open, .ovs-busy .ovs-bar, .ovs.ovs-anchored .ovs-panel, .ovs-skel, .ovs-list > li { animation: none; } }`;
document.head.appendChild(st);
}
@@@ -423,7 +431,15 @@
if (typeof ctx.icons === 'function') { try { ctx.icons(listEl); } catch { /* the icons are optional */ } }
}
-- const here = () => (ctx.scope && ctx.scope.label) || document.title.split(/[·|–-]/).pop().trim() || location.hostname;
++ // This site's short name for the field and the headings: the frame's name for this address ("Download"), not the page
++ // title ("OpenVibe.Download: a free 25 GB drive with share links" was cut off in the field and ran over two lines).
++ const shortName = (t) => String(t || '').split(/\s[·|–—-]\s|:\s/)[0].replace(/^OpenVibe\./, '').trim().slice(0, 28);
++ const here = () => {
++ if (ctx.scope && ctx.scope.label) return shortName(ctx.scope.label);
++ const f = ctx.frame, list = f && Array.isArray(f.nav) ? f.nav.map((n) => ({ name: n.name, url: safeUrl(n.url) })) : [];
++ const s = list.length ? hereSite(list) : null;
++ return (s && s.name) || shortName(document.title.split(/[·|–-]/).pop()) || location.hostname;
++ };
const siteItem = (s, all) => ({ title: s.name, meta: s.desc, url: s.url, site: s.icon || s.id, kind: 'site', here: all && s === hereSite(all) });
function pagesOf(q) {
const n = q.toLowerCase();
@@@ -442,6 +458,14 @@
}
// How well a name answers what was typed: its start (3), inside it (2), or only the words behind it (1).
const scoreOf = (n, name, words) => { const nm = name.toLowerCase(); return nm.startsWith(n) ? 3 : nm.includes(n) ? 2 : words && words.includes(n) ? 1 : 0; };
++ // Quick tools on Start: the everyday ones first (the owner, 10-29: "I'm trying to just get to basic tools"), then the
++ // network's most used.
++ const EVERYDAY = ['QR Code Generator', 'Image Compressor', 'Merge PDF', 'Image Converter', 'Word Counter', 'YouTube Downloader'];
++ function quickTools() {
++ const picked = catalog ? EVERYDAY.map((n) => catalog.tools.find((t) => t.name === n)).filter(Boolean).map(toolRow) : [];
++ const seen = new Set(picked.map((r) => r.url));
++ return [...picked, ...popularTools().filter((r) => !seen.has(r.url))].slice(0, 6);
++ }
const toolRow = (t) => ({ title: t.name, meta: t.desc ? `Tool · ${t.desc}` : 'Tool', url: t.go || t.url, site: t.icon, kind: 'tool' });
/** How often this person opened a tool (their history's visits to any of its addresses). */
function toolUse(t) {
@@@ -514,6 +538,7 @@
paint([
{ title: 'Jump back in', items: back },
{ title: `On ${here()}`, as: 'chips', items: pagesOf('') },
++ { title: 'Quick tools', as: 'chips', more: { label: 'All tools', tab: 'tools' }, items: quickTools() },
{ title: 'Recent searches', as: 'chips', clear: true, items: r.map((x) => ({ title: x, url: `${API}/?q=${encodeURIComponent(x)}`, icon: 'clock', kind: 'recent' })) },
{ title: yours ? 'Your sites' : 'Popular on OpenVibe', as: 'tiles', more: { label: 'All sites', tab: 'sites' }, items: ranked },
], back.length || r.length ? '' : 'Search streams, recordings, jobs, posts, deals, wikis and more, on every OpenVibe site.');
@@@ -675,7 +700,7 @@
if (at) { el.style.setProperty('--ovs-top', `${Math.max(0, Math.round(at.top))}px`); el.style.setProperty('--ovs-left', `${Math.max(8, Math.round(at.left))}px`); }
try { document.dispatchEvent(new CustomEvent('openvibe-menu', { detail: { open: true } })); } catch { /* */ }
document.documentElement.style.overflow = 'hidden';
-- input.placeholder = `Search ${here()}, or jump to any site`;
++ input.placeholder = `Search ${here()}, or ask Vibe`;
input.value = opts.q || '';
paintGhost();
render(input.value);
ddiff --git a/site-switcher.js b/site-switcher.js
nnew file mode 100644
iindex 0000000..b12cb13
---- /dev/null
++++ b/site-switcher.js
@@@ -0,0 +1,251 @@
++/**
++ * site-switcher.js — the site switcher behind the OpenVibe icon (4.48.0).
++ *
++ * The owner, 2026-10-30: "clicking the icon … should have its own drop-down that lets you very quickly … a more compact
++ * view of all the OpenVibe domains … tools and services … all the sites, so you can hop between them fast and easy.
++ * And make it ordered based on whatever makes sense." The navbar loads this on the first tap of its mark (as it loads
++ * search.js for the menu) and calls OpenVibeSiteSwitcher.open().
++ *
++ * Order:
++ * - Recent: the sites this person was on last (their account's list when signed in, from
++ * /api/themes/me/sites; otherwise the sites they jumped to from here, kept in this site's storage).
++ * - Pinned: the sites they pinned (hold a site, or right-click it).
++ * - Then every open site, grouped by what people come to do (the frame's launcher groups). Inside a group the frame's
++ * order is kept: most used first.
++ *
++ * Use:
++ * - Type to filter. The arrow keys move, Enter goes, Esc closes.
++ * - On phones it is a sheet under the bar that keeps its own scroll and locks the page behind it.
++ * - The site's name in the bar stays its link home.
++ *
++ * The switcher is DOM nodes only: no text from the frame is parsed as HTML, and links must be https.
++ */
++(function (root) {
++ 'use strict';
++ if (typeof document === 'undefined' || root.OpenVibeSiteSwitcher) return;
++
++ const STYLE_ID = 'ov-switcher-style';
++ const LOCAL = 'ov_sw_sites';
++ const SITE_ID = /^[a-z][a-z0-9-]{1,31}$/;
++ const ACCOUNT = 'https://openvibe.network/my';
++ const CSS = `
++.ovsw { position: fixed; inset: 0; z-index: 10021; display: none; background: rgba(3, 6, 12, .32); font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
++.ovsw.is-open { display: block; }
++.ovsw-panel { position: fixed; top: var(--ovsw-top, 64px); left: var(--ovsw-left, 8px); width: min(560px, calc(100vw - 16px)); max-height: calc(100dvh - var(--ovsw-top, 64px) - 12px); overflow-y: auto; overscroll-behavior: contain; display: grid; grid-template-columns: minmax(0, 1fr); gap: 12px; padding: 12px; box-sizing: border-box; border-radius: 20px; background: var(--bg-secondary, #101828); color: var(--text-primary, #e6edf7); box-shadow: 0 0 0 1px var(--border-light, rgba(255,255,255,.12)), 0 22px 60px rgba(0,0,0,.45); transform-origin: 24px -6px; animation: ovsw-in .34s cubic-bezier(.2, 1.2, .3, 1) both; }
++@media (max-width: 640px) { .ovsw-panel { left: 0; width: 100vw; border-radius: 0 0 22px 22px; } }
++.ovsw-top { display: flex; align-items: center; gap: 8px; height: 44px; padding: 0 8px 0 12px; border-radius: 13px; background: var(--bg-primary, #0a0f1c); box-shadow: inset 0 0 0 1px var(--border, rgba(255,255,255,.1)); }
++.ovsw-top:focus-within { box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--accent, #3b82f6) 70%, transparent), 0 0 0 4px color-mix(in srgb, var(--accent, #3b82f6) 15%, transparent); }
++.ovsw-top svg { width: 17px; height: 17px; flex: none; fill: none; stroke: var(--text-muted, #8192ac); stroke-width: 2.2; stroke-linecap: round; }
++.ovsw-top input { all: unset; flex: 1; min-width: 0; font-size: 15px; color: var(--text-primary, #e6edf7); }
++.ovsw-top input::placeholder { color: var(--text-muted, #8192ac); }
++.ovsw-top input::-webkit-search-cancel-button { display: none; }
++.ovsw-top kbd { font: 600 10.5px/1 inherit; padding: 4px 6px; border-radius: 6px; color: var(--text-muted, #8192ac); box-shadow: inset 0 0 0 1px var(--border-light, rgba(255,255,255,.14)); }
++@media (pointer: coarse) { .ovsw-top kbd { display: none; } }
++.ovsw-h { display: flex; justify-content: space-between; gap: 8px; margin: 0; padding: 0 4px 4px; font-size: 11px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; color: var(--text-muted, #8192ac); }
++.ovsw-h span { font-weight: 500; letter-spacing: 0; text-transform: none; }
++.ovsw-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(68px, 1fr)); gap: 2px; }
++.ovsw-row { position: relative; display: flex; gap: 2px; overflow-x: auto; scrollbar-width: none; }
++.ovsw-row::-webkit-scrollbar { display: none; }
++.ovsw-row .ovsw-t { flex: 0 0 72px; }
++.ovsw-t { all: unset; box-sizing: border-box; position: relative; display: grid; justify-items: center; gap: 5px; padding: 8px 2px 7px; border-radius: 13px; cursor: pointer; font-size: 11.5px; font-weight: 600; line-height: 1.15; text-align: center; color: var(--text-secondary, #a8b3c4); -webkit-touch-callout: none; user-select: none; animation: ovsw-tile .36s cubic-bezier(.2, 1.3, .3, 1) both; animation-delay: calc(var(--k, 0) * 10ms + 50ms); }
++.ovsw-t:hover, .ovsw-t:focus-visible { outline: none; color: var(--text-primary, #e6edf7); background: color-mix(in srgb, var(--text-primary, #e6edf7) 7%, transparent); }
++.ovsw-t:focus-visible { box-shadow: inset 0 0 0 1.5px var(--accent, #3b82f6); }
++.ovsw-t .ov-icon { width: 36px; height: 36px; transition: transform .26s cubic-bezier(.2, 1.4, .3, 1); }
++.ovsw-t:hover .ov-icon { transform: translateY(-2px) scale(1.06); }
++.ovsw-t.is-here .ov-icon { border-radius: 11px; box-shadow: 0 0 0 2px var(--bg-secondary, #101828), 0 0 0 4px var(--accent, #3b82f6); }
++.ovsw-t.is-here::after { content: 'here'; position: absolute; top: 2px; right: 6px; font-size: 9px; font-weight: 700; color: var(--accent-light, var(--accent, #60a5fa)); }
++.ovsw-pin { position: absolute; top: 2px; left: 6px; font-size: 10px; color: #f59e0b; }
++.ovsw-none { margin: 0; padding: 8px 4px; font-size: 14px; color: var(--text-secondary, #a8b3c4); }
++.ovsw-foot { display: flex; justify-content: space-between; gap: 10px; flex-wrap: wrap; padding: 2px 4px 0; font-size: 12.5px; color: var(--text-muted, #8192ac); }
++.ovsw-foot a { color: var(--accent-light, var(--accent, #60a5fa)); font-weight: 650; text-decoration: none; }
++.ovsw-foot a:hover { text-decoration: underline; }
++@keyframes ovsw-in { from { opacity: 0; transform: translateY(-8px) scale(.97); } }
++@keyframes ovsw-tile { from { opacity: 0; transform: translateY(6px) scale(.94); } }
++@media (prefers-reduced-motion: reduce) { .ovsw-panel, .ovsw-t { animation: none; } .ovsw-t .ov-icon { transition: none; } }
++html[data-ov-motion="reduced"] .ovsw-panel, html[data-ov-motion="reduced"] .ovsw-t { animation: none; }
++`;
++
++ let el = null, panel = null, ctx = {}, lastFocus = null, mine = null, query = '';
++ const https = (u) => { try { return new URL(u).protocol === 'https:'; } catch { return false; } };
++ const ids = (a, n) => [...new Set((Array.isArray(a) ? a : []).map(String).filter((x) => SITE_ID.test(x)))].slice(0, n);
++ function localSites() {
++ try { const d = JSON.parse(localStorage.getItem(LOCAL) || 'null') || {}; return { pinned: ids(d.pinned, 12), recent: ids(d.recent, 8) }; } catch { return { pinned: [], recent: [] }; }
++ }
++ function saveLocal(d) { try { localStorage.setItem(LOCAL, JSON.stringify({ pinned: ids(d.pinned, 12), recent: ids(d.recent, 8) })); } catch { /* storage may be off */ } }
++ function headers(json) {
++ const h = { Accept: 'application/json' };
++ if (json) h['Content-Type'] = 'application/json';
++ if (ctx.token) h.Authorization = 'Bearer ' + ctx.token;
++ return h;
++ }
++ const signedIn = () => !!(ctx.user && ctx.apiBase);
++ function loadMine() {
++ if (!signedIn() || typeof fetch === 'undefined') return Promise.resolve(localSites());
++ return fetch(ctx.apiBase + '/api/themes/me/sites', { headers: headers(), credentials: 'include' })
++ .then((r) => (r.ok ? r.json() : null))
++ .then((d) => (d ? { pinned: ids(d.pinned, 12), recent: ids(d.recent, 8) } : localSites()))
++ .catch(() => localSites());
++ }
++ function savePins(pinned) {
++ const cur = mine || localSites();
++ mine = { pinned: ids(pinned, 12), recent: cur.recent };
++ saveLocal(mine);
++ if (signedIn() && typeof fetch !== 'undefined') fetch(ctx.apiBase + '/api/themes/me/sites', { method: 'PUT', headers: headers(true), credentials: 'include', body: JSON.stringify({ pinned: mine.pinned }) }).catch(() => {});
++ }
++ function noteJump(id) {
++ const cur = localSites();
++ saveLocal({ pinned: cur.pinned, recent: [id, ...cur.recent] });
++ }
++
++ function style() {
++ if (document.getElementById(STYLE_ID)) return;
++ const s = document.createElement('style'); s.id = STYLE_ID; s.textContent = CSS; document.head.appendChild(s);
++ }
++ function sites() {
++ const f = ctx.frame || {};
++ return (Array.isArray(f.nav) ? f.nav : []).filter((s) => s && SITE_ID.test(s.id) && https(s.url));
++ }
++ function lens() {
++ const ns = 'http://www.w3.org/2000/svg', svg = document.createElementNS(ns, 'svg');
++ svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('aria-hidden', 'true');
++ const c = document.createElementNS(ns, 'circle'); c.setAttribute('cx', '11'); c.setAttribute('cy', '11'); c.setAttribute('r', '7');
++ const p = document.createElementNS(ns, 'path'); p.setAttribute('d', 'm21 21-4.3-4.3');
++ svg.append(c, p); return svg;
++ }
++ function build() {
++ style();
++ el = document.createElement('div');
++ el.className = 'ovsw';
++ el.addEventListener('pointerdown', (e) => { if (e.target === el) close(); });
++ panel = document.createElement('div');
++ panel.className = 'ovsw-panel';
++ panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-modal', 'true'); panel.setAttribute('aria-label', 'Switch to another OpenVibe site');
++ panel.addEventListener('keydown', onKey);
++ el.appendChild(panel);
++ document.body.appendChild(el);
++ }
++ // The page's own site: by its address (one service can serve several sites: media-hub is Video, Pics and Download).
++ const isHere = (s) => { try { return new URL(s.url).hostname === location.hostname; } catch { return s.id === ctx.service; } };
++ function tile(s, k, opts) {
++ const here = isHere(s);
++ const b = document.createElement('a');
++ b.className = 'ovsw-t' + (here ? ' is-here' : '');
++ b.href = s.url;
++ b.style.setProperty('--k', String(k));
++ b.title = s.tagline ? `${s.name}: ${s.tagline}` : s.name;
++ if (here) b.setAttribute('aria-current', 'page');
++ const ic = document.createElement('span'); ic.className = 'ov-icon'; ic.setAttribute('data-icon', s.id); ic.setAttribute('data-size', '36'); ic.setAttribute('aria-hidden', 'true');
++ b.appendChild(ic);
++ b.appendChild(document.createTextNode(s.name));
++ if (opts && opts.pinned) { const p = document.createElement('span'); p.className = 'ovsw-pin'; p.textContent = '★'; p.setAttribute('aria-label', 'pinned'); b.appendChild(p); }
++ // Hold (touch) or right-click: pin or unpin.
++ let hold = 0, held = false;
++ const toggle = () => { const cur = (mine || localSites()).pinned; savePins(cur.includes(s.id) ? cur.filter((x) => x !== s.id) : [s.id, ...cur]); render(); };
++ b.addEventListener('pointerdown', (e) => { held = false; if (e.pointerType !== 'mouse') hold = setTimeout(() => { held = true; toggle(); }, 480); });
++ b.addEventListener('pointerup', () => clearTimeout(hold));
++ b.addEventListener('pointerleave', () => clearTimeout(hold));
++ b.addEventListener('contextmenu', (e) => { e.preventDefault(); toggle(); });
++ b.addEventListener('click', (e) => { if (held) { e.preventDefault(); held = false; return; } if (here) { e.preventDefault(); close(); return; } noteJump(s.id); });
++ return b;
++ }
++ function section(title, hint, list, row, pinnedSet) {
++ const wrap = document.createElement('section');
++ const h = document.createElement('p'); h.className = 'ovsw-h'; h.textContent = title;
++ if (hint) { const sp = document.createElement('span'); sp.textContent = hint; h.appendChild(sp); }
++ const grid = document.createElement('div'); grid.className = row ? 'ovsw-row' : 'ovsw-grid';
++ list.forEach((s, k) => grid.appendChild(tile(s, k, { pinned: pinnedSet && pinnedSet.has(s.id) })));
++ wrap.append(h, grid);
++ return wrap;
++ }
++ function render() {
++ if (!panel) return;
++ const keep = panel.querySelector('input');
++ const hadFocus = keep && document.activeElement === keep;
++ const pos = keep ? keep.selectionStart : null;
++ panel.textContent = '';
++ const top = document.createElement('label'); top.className = 'ovsw-top';
++ const input = document.createElement('input');
++ input.type = 'search'; input.placeholder = 'Jump to a site'; input.autocomplete = 'off'; input.setAttribute('aria-label', 'Filter the sites'); input.value = query;
++ input.addEventListener('input', () => { query = input.value; render(); });
++ const kbd = document.createElement('kbd'); kbd.textContent = 'Esc';
++ top.append(lens(), input, kbd);
++ panel.appendChild(top);
++ const all = sites(), byId = new Map(all.map((s) => [s.id, s]));
++ const m = mine || localSites(), pinnedSet = new Set(m.pinned);
++ const q = query.trim().toLowerCase();
++ const groups = (ctx.frame && Array.isArray(ctx.frame.groups) && ctx.frame.groups.length) ? ctx.frame.groups : [{ id: '', name: 'OpenVibe' }];
++ if (!all.length) {
++ const p = document.createElement('p'); p.className = 'ovsw-none'; p.textContent = 'Loading the sites…'; panel.appendChild(p);
++ } else if (q) {
++ const hits = all.filter((s) => s.name.toLowerCase().includes(q) || s.id.includes(q) || String(s.tagline || '').toLowerCase().includes(q));
++ if (hits.length) panel.appendChild(section(`${hits.length} ${hits.length === 1 ? 'site' : 'sites'}`, 'Enter opens the first', hits, false, pinnedSet));
++ else { const p = document.createElement('p'); p.className = 'ovsw-none'; p.textContent = `No site called "${query.trim()}".`; panel.appendChild(p); }
++ } else {
++ const recent = m.recent.map((id) => byId.get(id)).filter((s) => s && !isHere(s));
++ if (recent.length) panel.appendChild(section('Recent', signedIn() ? 'where you were last' : 'where you went from here', recent, true));
++ const pinned = m.pinned.map((id) => byId.get(id)).filter(Boolean);
++ panel.appendChild(pinned.length ? section('Pinned', 'hold a site to unpin it', pinned, false, pinnedSet) : (() => { const p = document.createElement('p'); p.className = 'ovsw-h'; p.textContent = 'Pinned'; const sp = document.createElement('span'); sp.textContent = 'hold a site (or right-click) to pin it'; p.appendChild(sp); return p; })());
++ // Each group in the frame's order, most used first inside it; a site in a group this release does not know
++ // joins the last group (as the menu's Sites tab does).
++ const known = new Set(groups.map((g) => g.id));
++ groups.forEach((g, gi) => {
++ const list = all.filter((s) => (g.id ? s.group === g.id : true) || (gi === groups.length - 1 && !known.has(s.group)));
++ if (list.length) panel.appendChild(section(g.name, '', list, false, pinnedSet));
++ });
++ }
++ const foot = document.createElement('div'); foot.className = 'ovsw-foot';
++ const count = document.createElement('span'); count.textContent = `${all.length || ''}${all.length ? ' sites · ' : ''}one account`;
++ const acct = document.createElement('a'); acct.href = ACCOUNT; acct.textContent = 'Your account →';
++ foot.append(count, acct);
++ panel.appendChild(foot);
++ if (typeof ctx.icons === 'function') { try { ctx.icons(panel); } catch { /* icons are optional */ } }
++ input.addEventListener('keydown', (e) => {
++ if (e.key === 'Enter') { const first = panel.querySelector('.ovsw-t:not(.is-here)'); if (first) { e.preventDefault(); first.click(); } }
++ if (e.key === 'ArrowDown') { const first = panel.querySelector('.ovsw-t'); if (first) { e.preventDefault(); first.focus(); } }
++ });
++ if (hadFocus) { input.focus(); try { input.setSelectionRange(pos, pos); } catch { /* */ } }
++ }
++ function onKey(e) {
++ if (e.key === 'Escape') { e.preventDefault(); close(); return; }
++ const tiles = [...panel.querySelectorAll('.ovsw-t')], i = tiles.indexOf(document.activeElement);
++ if (i < 0) return;
++ const row = tiles[i].parentElement, cols = Math.max(1, Math.round(row.clientWidth / Math.max(1, tiles[i].offsetWidth)));
++ const go = (j) => { if (tiles[j]) { e.preventDefault(); tiles[j].focus(); } };
++ if (e.key === 'ArrowRight') go(i + 1);
++ else if (e.key === 'ArrowLeft') go(i - 1);
++ else if (e.key === 'ArrowDown') go(Math.min(tiles.length - 1, i + cols));
++ else if (e.key === 'ArrowUp') { if (i - cols < 0) { e.preventDefault(); const inp = panel.querySelector('input'); if (inp) inp.focus(); } else go(i - cols); }
++ }
++
++ function open(opts) {
++ ctx = opts || {};
++ if (!el) build();
++ lastFocus = document.activeElement;
++ query = '';
++ const at = ctx.anchor;
++ el.style.setProperty('--ovsw-top', `${Math.max(0, Math.round(at && at.top || 64))}px`);
++ el.style.setProperty('--ovsw-left', `${Math.max(8, Math.round(at && at.left || 8))}px`);
++ el.classList.add('is-open');
++ document.documentElement.style.overflow = 'hidden';
++ try { document.dispatchEvent(new CustomEvent('openvibe-menu', { detail: { open: true, switcher: true } })); } catch { /* */ }
++ render();
++ // A pointer that can hover gets the filter focused; a phone keeps its keyboard down until someone taps it.
++ if (typeof matchMedia === 'function' && matchMedia('(pointer: fine)').matches) { const inp = panel.querySelector('input'); if (inp) setTimeout(() => inp.focus(), 0); }
++ else setTimeout(() => { const t = panel.querySelector('.ovsw-t'); if (t && t.focus) t.focus({ preventScroll: true }); }, 0);
++ loadMine().then((d) => { mine = d; if (isOpen()) render(); });
++ }
++ function update(frame) { if (frame) { ctx.frame = frame; if (isOpen()) render(); } }
++ function close() {
++ if (!el || !el.classList.contains('is-open')) return;
++ el.classList.remove('is-open');
++ document.documentElement.style.overflow = '';
++ if (typeof ctx.onClose === 'function') { try { ctx.onClose(); } catch { /* */ } }
++ try { document.dispatchEvent(new CustomEvent('openvibe-menu', { detail: { open: false, switcher: true } })); } catch { /* */ }
++ if (lastFocus && lastFocus.focus) lastFocus.focus();
++ }
++ const isOpen = () => !!(el && el.classList.contains('is-open'));
++
++ const api = { open, close, update, isOpen };
++ root.OpenVibeSiteSwitcher = api;
++ if (typeof module === 'object' && module.exports) module.exports = api;
++})(typeof globalThis !== 'undefined' ? globalThis : this);
ddiff --git a/test/nav-icons.test.js b/test/nav-icons.test.js
iindex 260800a..9f8c10f 100644
---- a/test/nav-icons.test.js
++++ b/test/nav-icons.test.js
@@@ -13,7 +13,7 @@ const { brotli } = require('../scripts/size-report');
const ROOT = path.join(__dirname, '..');
const NAVBAR_PATH = path.join(ROOT, 'navbar.js');
const ICONS = require('../nav-icons');
--const NAVBAR_BROTLI_BUDGET = 39.5 * 1024; // 38.9 KB with the menu's orb beside the brand, messages beside the bell, the account menu as a sheet on phones, the bar stepping away on phones and both balances through the Network (4.25.0); 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)
++const NAVBAR_BROTLI_BUDGET = 40 * 1024; // 39.8 KB with the site switcher's loader and visit (4.48.0); 38.9 KB with the menu's orb beside the brand, messages beside the bell, the account menu as a sheet on phones, the bar stepping away on phones and both balances through the Network (4.25.0); 37.05 KB with the search magnifier, "/" and Ctrl+K, search.js loaded on first use (4.21.0); measured 27.6 KB on 2026-09-22 (39.0 KB with every glyph inline); 29.4 KB with the top-of-page fade (2.15.0); 30.6 KB with Ask Actor and its glyph (4.2.0); 31.4 KB with every site in groups in the launcher (4.4.0); 32.1 KB with the season's badge and switches (4.11.0); 33.0 KB with Work together and the drawer anchored to the bar (4.13.0); 33.6 KB with account switching on every site and finger-sized brand links (4.14.0); 35.2 KB with the folded balance chip, Across OpenVibe with site marks and the icon preload (4.16.0); 36.2 KB with wallet: true, OpenCoins and Vibes in any site's bar (4.17.0)
// ── size budget and generated block ──────────────────────────
{
ddiff --git a/test/search-loading-chrome.test.js b/test/search-loading-chrome.test.js
iindex 71a3aec..91e0528 100644
---- a/test/search-loading-chrome.test.js
++++ b/test/search-loading-chrome.test.js
@@@ -91,10 +91,10 @@ const server = http.createServer((req, res) => {
groups: [...document.querySelectorAll('.ovs-group')].map((g) => g.firstChild.textContent), chips: [...document.querySelectorAll('.ovs-chip span')].map((t) => t.textContent),
sites: [...document.querySelectorAll('.ovs-tile b')].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.tabs, ['For you*', 'Sites', 'Tools'], 'one menu, three tabs (the launcher and search together, 4.23.0)');
-- assert.deepStrictEqual([v.groups, v.chips], [['On OpenVibe.Chat', 'Popular on OpenVibe'], ['Rooms', 'Servers']], 'nothing typed: this site\'s pages, then the sites');
++ assert.deepStrictEqual(v.tabs, ['Start*', 'Sites', 'Tools'], 'one menu, three tabs (the launcher and search together, 4.23.0)');
++ assert.deepStrictEqual([v.groups, v.chips], [['On Chat', 'Popular on OpenVibe'], ['Rooms', 'Servers']], 'nothing typed: this site\'s pages (by its short name, 4.48.0), then the sites');
assert.ok(v.sites.includes('Live') && v.sites.includes('Tools'), 'the main sites even before the Frame answers');
-- assert.strictEqual(v.placeholder, 'Search OpenVibe.Chat, or jump to any site');
++ assert.strictEqual(v.placeholder, 'Search Chat, or ask Vibe', 'the site by its short name (4.48.0)');
// The box completes from this site's pages (and the person's searches, history and sites); Tab takes it.
await p.evaluate(`(() => { const i = document.querySelector('.ovs-input'); i.value = 'Roo'; i.dispatchEvent(new Event('input')); return 1; })()`);
assert.strictEqual(await p.evaluate('document.querySelector(".ovs-ghost").textContent'), 'Rooms', 'the completion shows behind the text');
@@@ -109,12 +109,12 @@ const server = http.createServer((req, res) => {
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 .ovs-title b'), active: document.querySelector('.ovs-opt.is-active .ovs-title').textContent })`);
-- assert.deepStrictEqual(v.groups, ['On OpenVibe.Chat', 'Across OpenVibe', 'Search']);
++ assert.deepStrictEqual(v.groups, ['On 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 Chat' && u.endsWith('/search?q=night')), 'this site\'s own search page');
assert.ok(v.rows.some(([t, u]) => t === 'All results for “night”' && u === 'https://search.openvibe.network/?q=night'), 'every result, in this panel (the link for a new tab)');
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');
@@@ -126,7 +126,7 @@ const server = http.createServer((req, res) => {
groups: [...document.querySelectorAll('.ovs-group')].map((g) => g.firstChild.textContent), loading: !!document.querySelector('.ovs-skel'), open: !!document.querySelector('.ovs.is-open') })`);
assert.ok(v.open && v.rtabs, 'Enter shows the results view in the panel');
assert.deepStrictEqual(v.tabs, ['All*', 'OpenVibe', 'Web', 'Video', 'News', 'Products'], 'scope tabs; Yours only for a signed-in person');
-- assert.deepStrictEqual(v.groups.slice(0, 2), ['On OpenVibe.Chat', 'Across OpenVibe'], 'this site first');
++ assert.deepStrictEqual(v.groups.slice(0, 2), ['On Chat', 'Across OpenVibe'], 'this site first');
assert.ok(v.loading && v.groups.includes('From the web'), 'the web is still coming: skeleton rows, OpenVibe already shown');
await wait(500);
v = await p.evaluate(`({ rows: [...document.querySelectorAll('.ovs-item a')].map((a) => [a.querySelector('.ovs-title').textContent, a.getAttribute('href'), (a.querySelector('.ovs-meta') || {}).textContent || '']),
ddiff --git a/test/site-switcher.test.js b/test/site-switcher.test.js
nnew file mode 100644
iindex 0000000..00bc0d9
---- /dev/null
++++ b/test/site-switcher.test.js
@@@ -0,0 +1,113 @@
++'use strict';
++// The site switcher behind the OpenVibe mark (site-switcher.js, 4.48.0; the owner, 2026-10-30: "clicking the icon …
++// its own drop-down … all the sites, so you can hop between them fast"). In a real headless Chrome (skipped without
++// Chrome or with OV_SKIP_BROWSER=1), with the Frame and the account's sites served through a stubbed fetch.
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++
++const ROOT = path.join(__dirname, '..');
++const h = require('../browser-harness');
++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {
++ console.log('site-switcher: skipped (no Chrome; set CHROME_BIN)');
++ process.exit(0);
++}
++
++const GROUPS = [{ id: 'watch', name: 'Watch and stream' }, { id: 'talk', name: 'Talk and share' }, { id: 'build', name: 'Build on OpenVibe' }];
++const site = (id, name, group, url = `https://openvibe.${id}/`) => ({ id, name, url, icon: id, tagline: `${name} things`, group });
++const nav = [site('live', 'Live', 'watch'), site('video', 'Video', 'watch'), site('chat', 'Chat', 'talk', 'https://127.0.0.1/'),
++ site('community', 'Community', 'talk'), site('design', 'Design', 'build'), site('pics', 'Pics', 'watch'), site('mystery', 'Mystery', 'new-group')];
++const FRAME = { nav, groups: GROUPS, soon: [], footer: {} };
++const MINE = { pinned: ['design'], recent: ['chat', 'video', 'live'] };
++
++const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Switcher</title><link rel="icon" href="data:,">
++<script>
++ window.__calls = [];
++ const realFetch = window.fetch;
++ window.fetch = (u, o) => {
++ const s = String(u);
++ if (s.startsWith('https://openvibe.network/api/frame')) return Promise.resolve({ ok: true, json: async () => (${JSON.stringify(FRAME)}) });
++ if (s.startsWith('https://openvibe.network/api/themes/me/sites')) {
++ window.__calls.push({ url: s, method: (o && o.method) || 'GET', body: o && o.body, auth: o && o.headers && o.headers.Authorization });
++ return Promise.resolve({ ok: true, json: async () => (${JSON.stringify(MINE)}) });
++ }
++ return realFetch(u, o);
++ };
++</script>
++<script src="/shared/navbar.js" defer></script></head><body><div id="navbar-mount"></div>
++<script>document.addEventListener('DOMContentLoaded', function () { OpenVibeNavbar.init({ service: 'chat', user: { id: 7, username: 'viewer' }, token: 'tok_test', links: [{ label: 'Home', href: '/' }], countViews: false, notifications: false }); });</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; charset=utf-8' }); return res.end(PAGE); }
++ const m = /^\/shared\/([a-z0-9-]+\.js)$/.exec(u.pathname);
++ if (m && fs.existsSync(path.join(ROOT, m[1]))) { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(ROOT, m[1]))); }
++ res.writeHead(404); return res.end();
++});
++
++(async () => {
++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-switcher-'));
++ const browser = await h.launch({ tmpDir });
++ try {
++ const context = await browser.newContext();
++ const p = await h.openPage(browser, context, { width: 1280, block: ['*openvibe.tools*', '*openvibe.media*', '*jsdelivr*'] });
++ await p.goto(`http://127.0.0.1:${server.address().port}/`, { maxMs: 5000 });
++ await p.evaluate(`new Promise((r) => { const t = setInterval(() => { if (localStorage.getItem('ov_frame_v2') && document.getElementById('openvibe-switch-btn')) { clearInterval(t); r(true); } }, 20); })`);
++ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
++ const brand = await p.evaluate(`(() => { const b = document.getElementById('openvibe-switch-btn'); return { tag: b.tagName, popup: b.getAttribute('aria-haspopup'), expanded: b.getAttribute('aria-expanded'), home: !!document.querySelector('.openvibe-navbar-brand .name a[href]') }; })()`);
++ assert.deepStrictEqual(brand, { tag: 'BUTTON', popup: 'dialog', expanded: 'false', home: true }, 'the mark opens the switcher; the name stays the link home');
++ // The visit waits for an idle moment: wait for it (up to 5 s).
++ await p.evaluate(`new Promise((r) => { const t0 = Date.now(); const t = setInterval(() => { if (window.__calls.some((c) => c.method === 'POST') || Date.now() - t0 > 5000) { clearInterval(t); r(true); } }, 50); })`);
++ const visits = await p.evaluate(`window.__calls.filter((c) => c.method === 'POST')`);
++ assert.strictEqual(visits.length, 1, 'one visit recorded for a signed-in person');
++ assert.deepStrictEqual([JSON.parse(visits[0].body).site, visits[0].auth], ['chat', 'Bearer tok_test'], 'this site, with the Network token');
++
++ await p.evaluate(`document.getElementById('openvibe-switch-btn').click(), true`);
++ await p.evaluate(`new Promise((r) => { const t = setInterval(() => { const h = [...document.querySelectorAll('.ovsw.is-open .ovsw-h')].map((x) => x.firstChild.textContent); if (h.includes('Recent')) { clearInterval(t); r(true); } }, 20); })`);
++ const read = () => p.evaluate(`(() => { const box = document.querySelector('.ovsw-panel'); return {
++ open: !!document.querySelector('.ovsw.is-open'),
++ expanded: document.getElementById('openvibe-switch-btn').getAttribute('aria-expanded'),
++ heads: [...box.querySelectorAll('.ovsw-h')].map((x) => x.firstChild.textContent),
++ rows: [...box.querySelectorAll('section')].map((s) => [...s.querySelectorAll('.ovsw-t')].map((t) => t.textContent.replace('★', ''))),
++ here: [...box.querySelectorAll('.ovsw-t.is-here')].map((t) => t.textContent + '|' + t.getAttribute('aria-current')),
++ hrefs: [...box.querySelectorAll('.ovsw-t')].every((t) => /^https?:/.test(t.getAttribute('href'))),
++ width: document.documentElement.scrollWidth } })()`);
++ let r = await read();
++ assert.deepStrictEqual([r.open, r.expanded], [true, 'true']);
++ assert.deepStrictEqual(r.heads, ['Recent', 'Pinned', 'Watch and stream', 'Talk and share', 'Build on OpenVibe'], 'recent, pinned, then the frame\'s groups');
++ assert.deepStrictEqual(r.rows[0], ['Video', 'Live'], 'recent from the account, newest first, without the page\'s own site');
++ assert.deepStrictEqual(r.rows[1], ['Design'], 'pinned from the account');
++ assert.deepStrictEqual(r.rows[2], ['Live', 'Video', 'Pics'], 'a group in the frame\'s order');
++ assert.deepStrictEqual(r.rows[4], ['Design', 'Mystery'], 'a site in an unknown group joins the last one');
++ assert.deepStrictEqual(r.here, ['Chat|page'], 'the page\'s own site is marked by its address');
++ assert.ok(r.hrefs, 'every tile is a real link');
++
++ // Typing filters every site by name.
++ await p.evaluate(`(() => { const q = document.querySelector('.ovsw-top input'); q.value = 'pic'; q.dispatchEvent(new Event('input')); return true; })()`);
++ await wait(50);
++ r = await read();
++ assert.deepStrictEqual(r.rows, [['Pics']], JSON.stringify(r));
++
++ // Right-click pins (saved to the account), and Escape closes.
++ await p.evaluate(`(() => { const q = document.querySelector('.ovsw-top input'); q.value = ''; q.dispatchEvent(new Event('input')); return true; })()`);
++ await wait(50);
++ await p.evaluate(`(() => { const t = [...document.querySelectorAll('.ovsw-t')].find((x) => x.textContent === 'Community'); t.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true })); return true; })()`);
++ await wait(50);
++ const puts = await p.evaluate(`window.__calls.filter((c) => c.method === 'PUT').map((c) => JSON.parse(c.body))`);
++ assert.deepStrictEqual(puts, [{ pinned: ['community', 'design'] }], 'a pin goes to the account');
++ r = await read();
++ assert.deepStrictEqual(r.rows[1], ['Community', 'Design'], 'and shows at once');
++ await p.evaluate(`document.querySelector('.ovsw-panel').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })), true`);
++ await wait(50);
++ r = await p.evaluate(`({ open: !!document.querySelector('.ovsw.is-open'), expanded: document.getElementById('openvibe-switch-btn').getAttribute('aria-expanded'), overflow: document.documentElement.style.overflow })`);
++ assert.deepStrictEqual(r, { open: false, expanded: 'false', overflow: '' }, 'Escape closes it and gives the page its scroll back');
++ console.log('site-switcher: all checks passed');
++ } finally {
++ await browser.close();
++ server.close();
++ fs.rmSync(tmpDir, { recursive: true, force: true });
++ }
++})().catch((e) => { console.error(e); process.exit(1); });