openvibers/openvibe.shared Commits
4.48.0: the site switcher, and a cleaner OpenVibe menu (#193)
Changes
@@@ -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@@@ -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 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.@@@ -704,6 +704,11 @@ Sign-in and history: history: { type: 'tool'|'stream'|'paste'|'page'|…,chip the first time and re-authenticates silently afterwards, 'silent' onlyre-authenticates, false opts out; fedcmLogin is the POST target for theassertion (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.@@@ -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)",@@@ -49,9 +49,12 @@/* Brand group: mark + linked wordmark + launcher read as one control. Every property a hostpage 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 440pxthe 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 pilltightens 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(); },@@@ -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",@@@ -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);nnew file mode 100644@@@ -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);@@@ -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 ──────────────────────────{@@@ -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 || '']),nnew file mode 100644@@@ -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); });
@@ -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.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
## 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
- **A tap on the logo no longer opens the menu.** On a phone, the menu button's invisible touch area (4.46.0) covered
@@ -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.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
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.
@@ -704,6 +704,11 @@ Sign-in and history: history: { type: 'tool'|'stream'|'paste'|'page'|…,
@@ -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
chip the first time and re-authenticates silently afterwards, 'silent' only
re-authenticates, false opts out; fedcmLogin is the POST target for the
re-authenticates, false opts out; fedcmLogin is the POST target for the
assertion (default: this site's /auth/fedcm).
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 }]
Balances: chips: [{ id, icon, value, label, tone, title, onClick, hidden }]
are status chips beside the bell (Live's OpenCoins and Vibes); setChip(id,
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.
value) updates one. Two or more fold into one chip at 560 px and narrower.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.47.3",
+ "version": "4.48.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)",
@@ -49,9 +49,12 @@
@@ -49,9 +49,12 @@
/* Brand group: mark + linked wordmark + launcher read as one control. Every property a host
/* 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. */
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; }
.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 { 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 { 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 { 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; }
.openvibe-navbar .openvibe-navbar-brand .name a.b-sub { color: var(--text-primary, #e6edf7); font-weight: 750; }
@@ -67,8 +70,8 @@
@@ -67,8 +70,8 @@
gets a clear ring, without moving anything (they were 22px and 30px tall). */
gets a clear ring, without moving anything (they were 22px and 30px tall). */
@media (pointer: coarse) {
@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 .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. */
/* 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, .openvibe-navbar .ovnav-burger, .openvibe-navbar .ovnav-orb { position: relative; }
.openvibe-navbar .openvibe-navbar-login::after { content: ''; position: absolute; inset: -6px 0; }
.openvibe-navbar .openvibe-navbar-login::after { content: ''; position: absolute; inset: -6px 0; }
@@ -160,14 +163,14 @@
@@ -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
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). */
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 { 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; }
.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
/* 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. */
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: 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; }
@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 { 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). */
/* 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; } }
@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; }
.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 @@
@@ -669,7 +672,9 @@
? `<i class="fa-solid ${escapeAttr(brand.icon)}"></i>`
? `<i class="fa-solid ${escapeAttr(brand.icon)}"></i>`
: `<span class="ov-mark" data-size="28" data-variant="${escapeAttr(brand.variant)}"></span>`;
: `<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' : ''}">
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>
<span class="name">${text}${brand.tag ? `<span class="b-tag">${escapeAttr(brand.tag)}</span>` : ''}</span>
${_config.search === false ? '' : orbHTML()}
${_config.search === false ? '' : orbHTML()}
</div>`;
</div>`;
@@ -1656,6 +1661,7 @@
@@ -1656,6 +1661,7 @@
bindLinks(nav, links);
bindLinks(nav, links);
bindMessages(nav);
bindMessages(nav);
hookSearch(nav);
hookSearch(nav);
+ hookSwitcher(nav);
bindChips(nav);
bindChips(nav);
bindChipsAll(nav);
bindChipsAll(nav);
queueIcons();
queueIcons();
@@ -1833,6 +1839,53 @@
@@ -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; }
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'));
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 _orbIntro = false;
let _searchKeys = false;
let _searchKeys = false;
function hookSearch(nav) {
function hookSearch(nav) {
@@ -1947,6 +2000,7 @@
@@ -1947,6 +2000,7 @@
/** Open the search panel (search.js), with an optional query already in it. */
/** Open the search panel (search.js), with an optional query already in it. */
openSearch(q, tab) { openSearch(q, tab); },
openSearch(q, tab) { openSearch(q, tab); },
openMenu(tab) { openSearch('', tab || 'you'); },
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. */
/** 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.47.3",
+ "version": "4.48.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": {
@@ -66,6 +66,7 @@
@@ -66,6 +66,7 @@
"./tokens": "./tokens.js",
"./tokens": "./tokens.js",
"./components": "./components.js",
"./components": "./components.js",
"./appbar": "./appbar.js",
"./appbar": "./appbar.js",
+ "./site-switcher": "./site-switcher.js",
"./search": "./search.js",
"./search": "./search.js",
"./agent-session": "./agent-session.js",
"./agent-session": "./agent-session.js",
"./loading": "./loading.js",
"./loading": "./loading.js",
@@ -62,7 +62,7 @@
@@ -62,7 +62,7 @@
{ name: 'Developer Tools', icon: 'code', url: 'https://dev.openvibe.tools/' },
{ name: 'Developer Tools', icon: 'code', url: 'https://dev.openvibe.tools/' },
{ name: 'Network Tools', icon: 'dns', url: 'https://net.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 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 = '';
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.
// 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 @@
@@ -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 { 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.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-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-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-field { position: relative; flex: 1; min-width: 0; display: flex; }
.ovs-input, .ovs-ghost { height: 48px; font: inherit; font-size: 17px; letter-spacing: normal; }
.ovs-input, .ovs-ghost { height: 48px; font: inherit; font-size: 17px; letter-spacing: normal; }
@@ -178,7 +181,12 @@
@@ -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 { 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[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-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-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 { 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; }
.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 @@
@@ -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); }
.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; } }
@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 (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);
document.head.appendChild(st);
}
}
@@ -423,7 +431,15 @@
@@ -423,7 +431,15 @@
if (typeof ctx.icons === 'function') { try { ctx.icons(listEl); } catch { /* the icons are optional */ } }
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) });
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) {
function pagesOf(q) {
const n = q.toLowerCase();
const n = q.toLowerCase();
@@ -442,6 +458,14 @@
@@ -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).
// 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; };
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' });
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). */
/** How often this person opened a tool (their history's visits to any of its addresses). */
function toolUse(t) {
function toolUse(t) {
@@ -514,6 +538,7 @@
@@ -514,6 +538,7 @@
paint([
paint([
{ title: 'Jump back in', items: back },
{ title: 'Jump back in', items: back },
{ title: `On ${here()}`, as: 'chips', items: pagesOf('') },
{ 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: '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 },
{ 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.');
], back.length || r.length ? '' : 'Search streams, recordings, jobs, posts, deals, wikis and more, on every OpenVibe site.');
@@ -675,7 +700,7 @@
@@ -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`); }
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 { /* */ }
try { document.dispatchEvent(new CustomEvent('openvibe-menu', { detail: { open: true } })); } catch { /* */ }
document.documentElement.style.overflow = 'hidden';
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 || '';
input.value = opts.q || '';
paintGhost();
paintGhost();
render(input.value);
render(input.value);
new file mode 100644
new file mode 100644
@@ -0,0 +1,251 @@
@@ -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);
@@ -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 = 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 ──────────────────────────
// ── size budget and generated block ──────────────────────────
{
{
@@ -91,10 +91,10 @@ const server = http.createServer((req, res) => {
@@ -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),
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 })`);
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.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.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.
// 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; })()`);
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');
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) => {
@@ -109,12 +109,12 @@ const server = http.createServer((req, res) => {
await wait(1200);
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')]),
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 })`);
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.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.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.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(([, 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 === '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.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.strictEqual(v.active, 'night room', 'the first row is ready for Enter');
@@ -126,7 +126,7 @@ const server = http.createServer((req, res) => {
@@ -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') })`);
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.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.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');
assert.ok(v.loading && v.groups.includes('From the web'), 'the web is still coming: skeleton rows, OpenVibe already shown');
await wait(500);
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 || '']),
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 || '']),
new file mode 100644
new file mode 100644
@@ -0,0 +1,113 @@
@@ -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); });