openvibers/openvibe.shared Commits
4.4.0: the launcher lists every site of the network in the Frame's groups (it kept the first 12), compact tiles four across (two on phones), the page's own site marked, search pinned on top (#87)
Changes
@@@ -4,6 +4,18 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semvebreaking change to any exported module, browser global or served file name is a new major.A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).++## 4.4.0 — 2026-10-17++++- **Every site in the launcher, in groups.** The menu beside the brand showed the first 12 of the network's sites (the++ owner: "missing tons of sites … needs organization"). It now lists every site the Frame sends (up to 60), under the++ Frame's `groups` in order (Watch & stream, Create & play, Talk & learn, Shop & everyday, Build & automate, Your++ OpenVibe), most used first within each, as compact tiles: four across on a computer, two on a phone, with the++ tagline under each name and the page's own site marked (`aria-current`). The search box stays on top while the menu++ scrolls. A site in a group the navbar does not know goes in the last group; a Frame without groups (older Network)++ shows one "Sites" section. Products on their own domain (Pics, Download, Homes) come with Network's frame.++- The Frame cache key is `ov_frame_v2` (navbar.js and footer.js alike), so a cached 12-site answer is not reused.++- navbar.js: 31.4 KB brotli (budget 32 KB).++## 4.3.0 — 2026-10-17- **Vibes in the account menu on every site.** Next to the OpenCoins chip, the menu header shows the person's Vibes@@@ -36,15 +36,15 @@// minutes in localStorage and refreshed in the background, so pages paint from cache and the// order never jumps while someone is looking at it. Defined once, shared by navbar + footer.const OVFrame = root.OpenVibeFrame || root.OpenVibeChrome || (root.OpenVibeFrame = root.OpenVibeChrome = (function () {-- const KEY = 'ov_frame_v1', TTL = 30 * 60000;++ const KEY = 'ov_frame_v2', TTL = 30 * 60000; // v2: every site, with its launcher grouplet inflight = null;const host = () => (typeof location !== 'undefined' ? location.hostname : '');const https = (u) => { try { return new URL(u).protocol === 'https:'; } catch { return false; } };function clean(d) {if (!d || !Array.isArray(d.nav)) return null;-- const link = (l) => (l && typeof l.name === 'string' && https(l.url) ? { id: String(l.id || ''), name: l.name.slice(0, 60), url: l.url, icon: String(l.icon || ''), tagline: String(l.tagline || '').slice(0, 80) } : null);++ const link = (l) => (l && typeof l.name === 'string' && https(l.url) ? { id: String(l.id || ''), name: l.name.slice(0, 60), url: l.url, icon: String(l.icon || ''), tagline: String(l.tagline || '').slice(0, 80), group: String(l.group || '').slice(0, 20) } : null);const f = d.footer || {}, lg = f.legal || {};-- return { nav: d.nav.map(link).filter(Boolean).slice(0, 12), soon: (d.soon || []).map(link).filter(Boolean).slice(0, 24),++ return { nav: d.nav.map(link).filter(Boolean).slice(0, 60), groups: (Array.isArray(d.groups) ? d.groups : []).filter(g => g && typeof g.id === 'string' && typeof g.name === 'string').slice(0, 10).map(g => ({ id: g.id.slice(0, 20), name: g.name.slice(0, 40) })), soon: (d.soon || []).map(link).filter(Boolean).slice(0, 24),footer: { blurb: String(f.blurb || '').slice(0, 200), discover: (f.discover || []).map(link).filter(Boolean).slice(0, 6), popular: (f.popular || []).map(link).filter(Boolean).slice(0, 10),legal: https(lg.terms) && https(lg.privacy) && https(lg.dmca) ? { terms: lg.terms, privacy: lg.privacy, dmca: lg.dmca } : null } };}@@@ -101,17 +101,21 @@.ovnav-drawer-net a.ovnav-net { display: flex !important; }@media (max-width: 860px) { .openvibe-navbar .ovnav-burger { display: inline-flex; } .openvibe-navbar .openvibe-navbar-links { display: none; } .openvibe-navbar .ovnav-chip .ovnav-ic + .ovnav-chip-v:empty { display: none; } }@media (max-width: 420px) { .openvibe-navbar .ovnav-chip { padding: 0 8px; height: 30px; font-size: 12px; } }-- .ovnav-launcher { position: absolute; top: calc(100% + 6px); left: 12px; width: min(440px, calc(100vw - 24px)); background: var(--bg-elevated, var(--bg-secondary, #111826)); border: 1px solid var(--border, rgba(255,255,255,.12)); border-radius: 16px; box-shadow: 0 24px 60px rgba(0,0,0,.5); padding: 12px; z-index: 1000; opacity: 0; transform: translateY(-6px) scale(.98); transform-origin: top left; pointer-events: none; transition: opacity .16s, transform .2s cubic-bezier(.2,1.2,.3,1); }++ .ovnav-launcher { position: absolute; top: calc(100% + 6px); left: 12px; width: min(760px, calc(100vw - 24px)); background: var(--bg-elevated, var(--bg-secondary, #111826)); border: 1px solid var(--border, rgba(255,255,255,.12)); border-radius: 16px; box-shadow: 0 24px 60px rgba(0,0,0,.5); padding: 0 12px 12px; z-index: 1000; opacity: 0; transform: translateY(-6px) scale(.98); transform-origin: top left; pointer-events: none; transition: opacity .16s, transform .2s cubic-bezier(.2,1.2,.3,1); }.ovnav-launcher.open { opacity: 1; transform: none; pointer-events: auto; }++ .ovnav-launcher .ovl-top { position: sticky; top: 0; z-index: 1; margin: 0 -12px; padding: 12px 12px 2px; background: inherit; }.ovnav-launcher .ovl-q { width: 100%; box-sizing: border-box; padding: 9px 12px; border-radius: 10px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: var(--bg-primary, #0a0f18); color: var(--text-primary, #e6edf7); font: 500 14px/1.2 inherit; outline: none; }.ovnav-launcher .ovl-q:focus { border-color: var(--accent, #3b82f6); }.ovnav-launcher .ovl-h { display: flex; justify-content: space-between; font-size: 11px; font-weight: 700; letter-spacing: .7px; text-transform: uppercase; color: var(--text-muted, #7d8aa0); margin: 12px 4px 6px; }.ovnav-launcher .ovl-h a { color: var(--accent-light, var(--accent, #60a5fa)); text-decoration: none; text-transform: none; letter-spacing: 0; }-- .ovnav-launcher .ovl-sites { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }-- .ovnav-launcher .ovl-fams { display: grid; grid-template-columns: repeat(2, 1fr); gap: 2px; }-- .ovnav-launcher .ovl-site, .ovnav-launcher .ovl-fam { display: flex; align-items: center; gap: 9px; padding: 8px; border-radius: 10px; color: var(--text-primary, #e6edf7); text-decoration: none; min-width: 0; }-- .ovnav-launcher .ovl-site { flex-direction: column; text-align: center; gap: 6px; padding: 12px 6px; }-- .ovnav-launcher .ovl-site:hover, .ovnav-launcher .ovl-fam:hover, .ovnav-launcher a:focus-visible { background: var(--accent-glow, rgba(59,130,246,.14)); outline: none; }++ .ovnav-launcher .ovl-apps { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 2px 4px; }++ .ovnav-launcher .ovl-fams { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 2px 4px; }++ .ovnav-launcher .ovl-app, .ovnav-launcher .ovl-fam { display: flex; align-items: center; gap: 9px; padding: 8px; border-radius: 10px; color: var(--text-primary, #e6edf7); text-decoration: none; min-width: 0; }++ .ovnav-launcher .ovl-app { padding: 7px 8px; border: 1px solid transparent; }++ .ovnav-launcher :is(.ovl-app, .ovl-fam) > span:last-child { min-width: 0; }++ .ovnav-launcher .ovl-app small { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; line-height: 1.3; }++ .ovnav-launcher .ovl-app.is-here { border-color: color-mix(in srgb, var(--accent, #3b82f6) 55%, transparent); background: var(--accent-glow, rgba(59,130,246,.14)); }++ .ovnav-launcher .ovl-app:hover, .ovnav-launcher .ovl-fam:hover, .ovnav-launcher a:focus-visible { background: var(--accent-glow, rgba(59,130,246,.14)); outline: none; }.ovnav-launcher b { display: block; font-size: 13px; font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.ovnav-launcher small { display: block; font-size: 11px; color: var(--text-muted, #7d8aa0); margin-top: 1px; }.ovnav-launcher .ovl-soon { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 4px 4px; }@@@ -130,7 +134,7 @@.ovnav-launcher .ovl-all { grid-column: 1 / -1; color: var(--accent-light, var(--accent, #60a5fa)); }.ovnav-launcher .ovl-h span { text-transform: none; letter-spacing: 0; font-weight: 500; }.ovnav-launcher .ovl-empty { padding: 18px 8px; color: var(--text-secondary, #a8b3c4); font-size: 13px; }-- @media (max-width: 480px) { .ovnav-launcher { left: 8px; } .ovnav-launcher .ovl-fams { grid-template-columns: 1fr; } }++ @media (max-width: 480px) { .ovnav-launcher { left: 8px; } .ovnav-launcher :is(.ovl-apps, .ovl-fams) { grid-template-columns: repeat(2, minmax(0, 1fr)); } }@media (prefers-reduced-motion: reduce) { .ovnav-launcher { transition: 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 .name { display: inline-flex; align-items: baseline; font-size: 15px; font-weight: 700; letter-spacing: -.3px; white-space: nowrap; line-height: 1; }@@@ -547,15 +551,15 @@// minutes in localStorage and refreshed in the background, so pages paint from cache and the// order never jumps while someone is looking at it. Defined once, shared by navbar + footer.const OVFrame = root.OpenVibeFrame || root.OpenVibeChrome || (root.OpenVibeFrame = root.OpenVibeChrome = (function () {-- const KEY = 'ov_frame_v1', TTL = 30 * 60000;++ const KEY = 'ov_frame_v2', TTL = 30 * 60000; // v2: every site, with its launcher grouplet inflight = null;const host = () => (typeof location !== 'undefined' ? location.hostname : '');const https = (u) => { try { return new URL(u).protocol === 'https:'; } catch { return false; } };function clean(d) {if (!d || !Array.isArray(d.nav)) return null;-- const link = (l) => (l && typeof l.name === 'string' && https(l.url) ? { id: String(l.id || ''), name: l.name.slice(0, 60), url: l.url, icon: String(l.icon || ''), tagline: String(l.tagline || '').slice(0, 80) } : null);++ const link = (l) => (l && typeof l.name === 'string' && https(l.url) ? { id: String(l.id || ''), name: l.name.slice(0, 60), url: l.url, icon: String(l.icon || ''), tagline: String(l.tagline || '').slice(0, 80), group: String(l.group || '').slice(0, 20) } : null);const f = d.footer || {}, lg = f.legal || {};-- return { nav: d.nav.map(link).filter(Boolean).slice(0, 12), soon: (d.soon || []).map(link).filter(Boolean).slice(0, 24),++ return { nav: d.nav.map(link).filter(Boolean).slice(0, 60), groups: (Array.isArray(d.groups) ? d.groups : []).filter(g => g && typeof g.id === 'string' && typeof g.name === 'string').slice(0, 10).map(g => ({ id: g.id.slice(0, 20), name: g.name.slice(0, 40) })), soon: (d.soon || []).map(link).filter(Boolean).slice(0, 24),footer: { blurb: String(f.blurb || '').slice(0, 200), discover: (f.discover || []).map(link).filter(Boolean).slice(0, 6), popular: (f.popular || []).map(link).filter(Boolean).slice(0, 10),legal: https(lg.terms) && https(lg.privacy) && https(lg.dmca) ? { terms: lg.terms, privacy: lg.privacy, dmca: lg.dmca } : null } };}@@@ -619,7 +623,10 @@function bindLauncher(nav) {const btn = nav.querySelector('#openvibe-launcher-btn'); if (!btn) return;let panel = null;-- const tile = (it, cls) => `<a class="${cls}" href="${escapeAttr(it.go || it.url)}"><span class="ov-icon" data-icon="${escapeAttr(it.icon)}" data-size="${cls === 'ovl-site' ? 34 : 24}" data-fx="none"></span><span><b>${escapeAttr(it.name)}</b>${it.desc ? `<small>${escapeAttr(it.desc)}</small>` : ''}</span></a>`;++ const tile = (it, cls, here) => `<a class="${cls}${here ? ' is-here' : ''}" href="${escapeAttr(it.go || it.url)}"${here ? ' aria-current="page"' : ''}${it.desc ? ` title="${escapeAttr(it.desc)}"` : ''}><span class="ov-icon" data-icon="${escapeAttr(it.icon)}" data-size="${cls === 'ovl-app' ? 28 : 24}" data-fx="none"></span><span><b>${escapeAttr(it.name)}</b>${it.desc ? `<small>${escapeAttr(it.desc)}</small>` : ''}</span></a>`;++ const siteItems = (frame) => (frame && frame.nav.length ? frame.nav.map(n => ({ name: n.name, desc: n.tagline.replace(/\.\s*$/, ''), icon: n.icon || n.id, url: n.url, group: n.group })) : LAUNCHER_SITES);++ // This page's site: the longest matching host (play.openvibe.network is Play, not Network).++ const hereOf = (list) => { const h = currentHost().toLowerCase(); let best = null, len = 0; for (const x of list) { let sh = ''; try { sh = new URL(x.url).hostname; } catch { continue; } if ((h === sh || h.endsWith('.' + sh)) && sh.length > len) { best = x; len = sh.length; } } return best; };const close = () => { if (panel) panel.classList.remove('open'); btn.setAttribute('aria-expanded', 'false'); };function paint(cat, q) {const fams = (cat && cat.families.length ? cat.families : LAUNCHER_FAMILIES);@@@ -628,24 +635,28 @@// Sites and families always filter locally; tools need the catalog. Without it (offline, blocked),// the last row hands the query to the Tools search page, so typing never does nothing.const hit = (x) => (x.name + ' ' + (x.desc || '')).toLowerCase().includes(q);-- const siteList = (OVFrame.get() && OVFrame.get().nav.length ? OVFrame.get().nav.map(n => ({ name: n.name, desc: n.tagline, icon: n.icon || n.id, url: n.url })) : LAUNCHER_SITES).filter(hit);++ const siteList = siteItems(OVFrame.get()).filter(hit);const famList = fams.filter(hit);const tools = cat ? cat.tools.filter(t => t.k.includes(q)).slice(0, 12) : [];const more = `<a class="ovl-fam ovl-all" href="${escapeAttr(TOOLS_SEARCH + encodeURIComponent(q))}"><span class="ov-icon" data-icon="search" data-size="24" data-fx="none"></span><span><b>Search all tools for “${escapeAttr(q)}”</b></span></a>`;-- body.innerHTML = (siteList.length ? `<div class="ovl-h">Sites</div><div class="ovl-fams">${siteList.map(x => tile(x, 'ovl-fam')).join('')}</div>` : '')++ body.innerHTML = (siteList.length ? `<div class="ovl-h">Sites</div><div class="ovl-apps">${siteList.map(x => tile(x, 'ovl-app')).join('')}</div>` : '')+ (famList.length ? `<div class="ovl-h">Tool families</div><div class="ovl-fams">${famList.map(x => tile(x, 'ovl-fam')).join('')}</div>` : '')+ `<div class="ovl-h">Tools${cat ? '' : ' <span>loading…</span>'}</div><div class="ovl-fams">${tools.map(t => tile(t, 'ovl-fam')).join('')}${more}</div>`;return;}const frame = OVFrame.get();-- const sites = frame && frame.nav.length ? frame.nav.map(n => ({ name: n.name, desc: n.tagline, icon: n.icon || n.id, url: n.url })) : LAUNCHER_SITES;++ const sites = siteItems(frame), hereSite = hereOf(sites);++ // The Frame's groups in order; a site in no known group goes in the last one.++ const groups = frame && frame.groups && frame.groups.length ? frame.groups : [{ id: '', name: 'Sites' }];++ const inGroup = (s, g, i) => !g.id || s.group === g.id || (i === groups.length - 1 && !groups.some(x => x.id === s.group));++ const siteHtml = groups.map((g, i) => { const list = sites.filter(s => inGroup(s, g, i)); return list.length ? `<div class="ovl-h">${escapeAttr(g.name)}</div><div class="ovl-apps">${list.map(x => tile(x, 'ovl-app', x === hereSite)).join('')}</div>` : ''; }).join('');const soon = frame ? frame.soon : [];// On a tool: every address it answers to (short, search-friendly, mirrors, custom domains), so people// can pick the one they will remember.const cur = currentHost().toLowerCase();const here = cat ? cat.tools.find(t => t.hosts.indexOf(cur) >= 0) : null;const addresses = here && here.hosts.length > 1 ? `<div class="ovl-h">${escapeAttr(here.name)} lives at <a href="https://openvibe.tools/tool/${escapeAttr(here.id)}">About</a></div><div class="ovl-soon ovl-addr">${[...new Set(here.hosts)].filter(h => /^[a-z0-9.-]+$/.test(h)).map(h => `<a href="https://${h}/"${h === cur ? ' class="is-here" aria-current="page"' : ''}>${h}</a>`).join('')}</div>` : '';-- body.innerHTML = addresses + `<div class="ovl-h">Sites</div><div class="ovl-sites">${sites.map(x => tile(x, 'ovl-site')).join('')}</div>++ body.innerHTML = addresses + siteHtml + `<div class="ovl-h">Tools <a href="https://openvibe.tools/">See all</a></div><div class="ovl-fams">${fams.map(x => tile(x, 'ovl-fam')).join('')}</div>${soon.length ? `<div class="ovl-h">Opening soon</div><div class="ovl-soon">${soon.map(x => `<a href="${escapeAttr(x.url)}">${escapeAttr(x.name)}</a>`).join('')}</div>` : ''}`;}@@@ -654,7 +665,7 @@if (panel && panel.classList.contains('open')) return close();if (!panel) {panel = document.createElement('div'); panel.className = 'ovnav-launcher'; panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-label', 'OpenVibe sites and tools');-- panel.innerHTML = '<input type="search" class="ovl-q" placeholder="Find a tool or site" aria-label="Find a tool or site"><div class="ovl-body"></div><div class="ovl-display" hidden></div>';++ panel.innerHTML = '<div class="ovl-top"><input type="search" class="ovl-q" placeholder="Find a site or tool" aria-label="Find a site or tool"></div><div class="ovl-body"></div><div class="ovl-display" hidden></div>';bindDisplayControls(panel.querySelector('.ovl-display'));nav.appendChild(panel);regPanel(panel, 'launcher', close);@@@ -1,12 +1,12 @@{"name": "openvibe-shared",-- "version": "4.3.0",++ "version": "4.4.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "4.3.0",++ "version": "4.4.0","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0"@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.3.0",++ "version": "4.4.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -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 = 31 * 1024; // measured 27.6 KB on 2026-09-22 (39.0 KB with every glyph inline); 29.4 KB with the top-of-page fade (2.15.0); 30.6 KB with Ask Actor and its glyph (4.2.0)++const NAVBAR_BROTLI_BUDGET = 32 * 1024; // measured 27.6 KB on 2026-09-22 (39.0 KB with every glyph inline); 29.4 KB with the top-of-page fade (2.15.0); 30.6 KB with Ask Actor and its glyph (4.2.0); 31.4 KB with every site in groups in the launcher (4.4.0)// ── size budget and generated block ──────────────────────────{nnew file mode 100644@@@ -0,0 +1,78 @@++'use strict';++// The launcher (the grid button beside the brand) lists every open site of the network, in the Frame's groups, most used++// first within each: Network's /api/frame sends 40 entries (the navbar used to keep 12, so 28 sites were missing from the++// menu, owner 2026-10-17). Runs in a real headless Chrome (skipped without Chrome or with OV_SKIP_BROWSER=1), with the++// Frame answer served through a stubbed fetch so the navbar's own cleaning of it is what is tested.++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('navbar-launcher: skipped (no Chrome; set CHROME_BIN)');++ process.exit(0);++}++++const GROUPS = [{ id: 'watch', name: 'Watch & stream' }, { id: 'talk', name: 'Talk & learn' }, { id: 'you', name: 'Your 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('chat', 'Chat', 'talk'), site('here', 'Here', 'talk', 'https://127.0.0.1/'),++ site('network', 'Network', 'you'), site('mystery', 'Mystery', 'brand-new-group')];++for (let i = 0; i < 15; i++) nav.push(site(`w${i}`, `Watch ${i}`, 'watch')); // 20 entries: more than the old cut of 12++nav.push({ id: 'pics', name: 'Pics', url: 'https://openvibe.pics/', icon: 'pics', tagline: 'Share pictures', group: 'watch', of: 'media-hub' });++const FRAME = { nav, groups: GROUPS, soon: [], footer: {} };++++const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Launcher</title><link rel="icon" href="data:,">++<script>++ const realFetch = window.fetch;++ window.fetch = (u, o) => (String(u).startsWith('https://openvibe.network/api/frame') ? Promise.resolve({ ok: true, json: async () => (${JSON.stringify(FRAME)}) }) : 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: 'site', user: null, links: [], 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-launcher-'));++ 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 });++ // The navbar asks for the Frame at render; the launcher paints from what it kept.++ await p.evaluate(`new Promise((r) => { const t = setInterval(() => { if (localStorage.getItem('ov_frame_v2')) { clearInterval(t); r(true); } }, 20); })`);++ await p.evaluate(`document.getElementById('openvibe-launcher-btn').click(), true`);++ const read = () => p.evaluate(`(() => { const b = document.querySelector('.ovnav-launcher .ovl-body'); return {++ heads: [...b.querySelectorAll('.ovl-h')].map((e) => e.firstChild.textContent.trim()),++ groups: [...b.querySelectorAll('.ovl-apps')].map((g) => [...g.querySelectorAll('b')].map((x) => x.textContent)),++ here: [...b.querySelectorAll('.ovl-app.is-here')].map((a) => a.querySelector('b').textContent + '|' + a.getAttribute('aria-current')),++ small: (b.querySelector('.ovl-app small') || {}).textContent } })()`);++ const r = await read();++ assert.deepStrictEqual(r.heads.slice(0, 3), ['Watch & stream', 'Talk & learn', 'Your OpenVibe'], 'the Frame\'s groups, in its order');++ assert.strictEqual(r.groups.flat().length, 21, 'every site of the Frame (21 here; the navbar used to keep 12)');++ assert.deepStrictEqual(r.groups[0].slice(0, 2), ['Live', 'Watch 0'], 'most used first within a group');++ assert.ok(r.groups[0].includes('Pics'), 'a product on its own domain sits in its group');++ assert.deepStrictEqual(r.groups[2], ['Network', 'Mystery'], 'a site in a group the navbar does not know goes in the last one');++ assert.deepStrictEqual(r.here, ['Here|page'], 'the page\'s own site is marked');++ assert.strictEqual(r.small, 'Live things', 'taglines without a closing period');++ // Typing filters the sites (and the search box stays on top of the scrolling panel).++ await p.evaluate(`(() => { const q = document.querySelector('.ovnav-launcher .ovl-q'); q.value = 'pic'; q.dispatchEvent(new Event('input')); return true; })()`);++ const f = await read();++ assert.deepStrictEqual(f.groups[0], ['Pics'], JSON.stringify(f));++ assert.strictEqual(await p.evaluate(`getComputedStyle(document.querySelector('.ovnav-launcher .ovl-top')).position`), 'sticky');++ console.log('navbar-launcher: all checks passed');++ } finally {++ await browser.close();++ server.close();++ fs.rmSync(tmpDir, { recursive: true, force: true });++ }++})().catch((e) => { console.error(e); process.exit(1); });
@@ -4,6 +4,18 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -4,6 +4,18 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
breaking change to any exported module, browser global or served file name is a new major.
breaking change to any exported module, browser global or served file name is a new major.
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
+## 4.4.0 — 2026-10-17
+
+- **Every site in the launcher, in groups.** The menu beside the brand showed the first 12 of the network's sites (the
+ owner: "missing tons of sites … needs organization"). It now lists every site the Frame sends (up to 60), under the
+ Frame's `groups` in order (Watch & stream, Create & play, Talk & learn, Shop & everyday, Build & automate, Your
+ OpenVibe), most used first within each, as compact tiles: four across on a computer, two on a phone, with the
+ tagline under each name and the page's own site marked (`aria-current`). The search box stays on top while the menu
+ scrolls. A site in a group the navbar does not know goes in the last group; a Frame without groups (older Network)
+ shows one "Sites" section. Products on their own domain (Pics, Download, Homes) come with Network's frame.
+- The Frame cache key is `ov_frame_v2` (navbar.js and footer.js alike), so a cached 12-site answer is not reused.
+- navbar.js: 31.4 KB brotli (budget 32 KB).
+
## 4.3.0 — 2026-10-17
## 4.3.0 — 2026-10-17
- **Vibes in the account menu on every site.** Next to the OpenCoins chip, the menu header shows the person's Vibes
- **Vibes in the account menu on every site.** Next to the OpenCoins chip, the menu header shows the person's Vibes
@@ -36,15 +36,15 @@
@@ -36,15 +36,15 @@
// minutes in localStorage and refreshed in the background, so pages paint from cache and the
// minutes in localStorage and refreshed in the background, so pages paint from cache and the
// order never jumps while someone is looking at it. Defined once, shared by navbar + footer.
// order never jumps while someone is looking at it. Defined once, shared by navbar + footer.
const OVFrame = root.OpenVibeFrame || root.OpenVibeChrome || (root.OpenVibeFrame = root.OpenVibeChrome = (function () {
const OVFrame = root.OpenVibeFrame || root.OpenVibeChrome || (root.OpenVibeFrame = root.OpenVibeChrome = (function () {
- const KEY = 'ov_frame_v1', TTL = 30 * 60000;
+ const KEY = 'ov_frame_v2', TTL = 30 * 60000; // v2: every site, with its launcher group
let inflight = null;
let inflight = null;
const host = () => (typeof location !== 'undefined' ? location.hostname : '');
const host = () => (typeof location !== 'undefined' ? location.hostname : '');
const https = (u) => { try { return new URL(u).protocol === 'https:'; } catch { return false; } };
const https = (u) => { try { return new URL(u).protocol === 'https:'; } catch { return false; } };
function clean(d) {
function clean(d) {
if (!d || !Array.isArray(d.nav)) return null;
if (!d || !Array.isArray(d.nav)) return null;
- const link = (l) => (l && typeof l.name === 'string' && https(l.url) ? { id: String(l.id || ''), name: l.name.slice(0, 60), url: l.url, icon: String(l.icon || ''), tagline: String(l.tagline || '').slice(0, 80) } : null);
+ const link = (l) => (l && typeof l.name === 'string' && https(l.url) ? { id: String(l.id || ''), name: l.name.slice(0, 60), url: l.url, icon: String(l.icon || ''), tagline: String(l.tagline || '').slice(0, 80), group: String(l.group || '').slice(0, 20) } : null);
const f = d.footer || {}, lg = f.legal || {};
const f = d.footer || {}, lg = f.legal || {};
- return { nav: d.nav.map(link).filter(Boolean).slice(0, 12), soon: (d.soon || []).map(link).filter(Boolean).slice(0, 24),
+ return { nav: d.nav.map(link).filter(Boolean).slice(0, 60), groups: (Array.isArray(d.groups) ? d.groups : []).filter(g => g && typeof g.id === 'string' && typeof g.name === 'string').slice(0, 10).map(g => ({ id: g.id.slice(0, 20), name: g.name.slice(0, 40) })), soon: (d.soon || []).map(link).filter(Boolean).slice(0, 24),
footer: { blurb: String(f.blurb || '').slice(0, 200), discover: (f.discover || []).map(link).filter(Boolean).slice(0, 6), popular: (f.popular || []).map(link).filter(Boolean).slice(0, 10),
footer: { blurb: String(f.blurb || '').slice(0, 200), discover: (f.discover || []).map(link).filter(Boolean).slice(0, 6), popular: (f.popular || []).map(link).filter(Boolean).slice(0, 10),
legal: https(lg.terms) && https(lg.privacy) && https(lg.dmca) ? { terms: lg.terms, privacy: lg.privacy, dmca: lg.dmca } : null } };
legal: https(lg.terms) && https(lg.privacy) && https(lg.dmca) ? { terms: lg.terms, privacy: lg.privacy, dmca: lg.dmca } : null } };
}
}
@@ -101,17 +101,21 @@
@@ -101,17 +101,21 @@
.ovnav-drawer-net a.ovnav-net { display: flex !important; }
.ovnav-drawer-net a.ovnav-net { display: flex !important; }
@media (max-width: 860px) { .openvibe-navbar .ovnav-burger { display: inline-flex; } .openvibe-navbar .openvibe-navbar-links { display: none; } .openvibe-navbar .ovnav-chip .ovnav-ic + .ovnav-chip-v:empty { display: none; } }
@media (max-width: 860px) { .openvibe-navbar .ovnav-burger { display: inline-flex; } .openvibe-navbar .openvibe-navbar-links { display: none; } .openvibe-navbar .ovnav-chip .ovnav-ic + .ovnav-chip-v:empty { display: none; } }
@media (max-width: 420px) { .openvibe-navbar .ovnav-chip { padding: 0 8px; height: 30px; font-size: 12px; } }
@media (max-width: 420px) { .openvibe-navbar .ovnav-chip { padding: 0 8px; height: 30px; font-size: 12px; } }
- .ovnav-launcher { position: absolute; top: calc(100% + 6px); left: 12px; width: min(440px, calc(100vw - 24px)); background: var(--bg-elevated, var(--bg-secondary, #111826)); border: 1px solid var(--border, rgba(255,255,255,.12)); border-radius: 16px; box-shadow: 0 24px 60px rgba(0,0,0,.5); padding: 12px; z-index: 1000; opacity: 0; transform: translateY(-6px) scale(.98); transform-origin: top left; pointer-events: none; transition: opacity .16s, transform .2s cubic-bezier(.2,1.2,.3,1); }
+ .ovnav-launcher { position: absolute; top: calc(100% + 6px); left: 12px; width: min(760px, calc(100vw - 24px)); background: var(--bg-elevated, var(--bg-secondary, #111826)); border: 1px solid var(--border, rgba(255,255,255,.12)); border-radius: 16px; box-shadow: 0 24px 60px rgba(0,0,0,.5); padding: 0 12px 12px; z-index: 1000; opacity: 0; transform: translateY(-6px) scale(.98); transform-origin: top left; pointer-events: none; transition: opacity .16s, transform .2s cubic-bezier(.2,1.2,.3,1); }
.ovnav-launcher.open { opacity: 1; transform: none; pointer-events: auto; }
.ovnav-launcher.open { opacity: 1; transform: none; pointer-events: auto; }
+ .ovnav-launcher .ovl-top { position: sticky; top: 0; z-index: 1; margin: 0 -12px; padding: 12px 12px 2px; background: inherit; }
.ovnav-launcher .ovl-q { width: 100%; box-sizing: border-box; padding: 9px 12px; border-radius: 10px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: var(--bg-primary, #0a0f18); color: var(--text-primary, #e6edf7); font: 500 14px/1.2 inherit; outline: none; }
.ovnav-launcher .ovl-q { width: 100%; box-sizing: border-box; padding: 9px 12px; border-radius: 10px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: var(--bg-primary, #0a0f18); color: var(--text-primary, #e6edf7); font: 500 14px/1.2 inherit; outline: none; }
.ovnav-launcher .ovl-q:focus { border-color: var(--accent, #3b82f6); }
.ovnav-launcher .ovl-q:focus { border-color: var(--accent, #3b82f6); }
.ovnav-launcher .ovl-h { display: flex; justify-content: space-between; font-size: 11px; font-weight: 700; letter-spacing: .7px; text-transform: uppercase; color: var(--text-muted, #7d8aa0); margin: 12px 4px 6px; }
.ovnav-launcher .ovl-h { display: flex; justify-content: space-between; font-size: 11px; font-weight: 700; letter-spacing: .7px; text-transform: uppercase; color: var(--text-muted, #7d8aa0); margin: 12px 4px 6px; }
.ovnav-launcher .ovl-h a { color: var(--accent-light, var(--accent, #60a5fa)); text-decoration: none; text-transform: none; letter-spacing: 0; }
.ovnav-launcher .ovl-h a { color: var(--accent-light, var(--accent, #60a5fa)); text-decoration: none; text-transform: none; letter-spacing: 0; }
- .ovnav-launcher .ovl-sites { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }
- .ovnav-launcher .ovl-fams { display: grid; grid-template-columns: repeat(2, 1fr); gap: 2px; }
- .ovnav-launcher .ovl-site, .ovnav-launcher .ovl-fam { display: flex; align-items: center; gap: 9px; padding: 8px; border-radius: 10px; color: var(--text-primary, #e6edf7); text-decoration: none; min-width: 0; }
- .ovnav-launcher .ovl-site { flex-direction: column; text-align: center; gap: 6px; padding: 12px 6px; }
- .ovnav-launcher .ovl-site:hover, .ovnav-launcher .ovl-fam:hover, .ovnav-launcher a:focus-visible { background: var(--accent-glow, rgba(59,130,246,.14)); outline: none; }
+ .ovnav-launcher .ovl-apps { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 2px 4px; }
+ .ovnav-launcher .ovl-fams { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 2px 4px; }
+ .ovnav-launcher .ovl-app, .ovnav-launcher .ovl-fam { display: flex; align-items: center; gap: 9px; padding: 8px; border-radius: 10px; color: var(--text-primary, #e6edf7); text-decoration: none; min-width: 0; }
+ .ovnav-launcher .ovl-app { padding: 7px 8px; border: 1px solid transparent; }
+ .ovnav-launcher :is(.ovl-app, .ovl-fam) > span:last-child { min-width: 0; }
+ .ovnav-launcher .ovl-app small { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; line-height: 1.3; }
+ .ovnav-launcher .ovl-app.is-here { border-color: color-mix(in srgb, var(--accent, #3b82f6) 55%, transparent); background: var(--accent-glow, rgba(59,130,246,.14)); }
+ .ovnav-launcher .ovl-app:hover, .ovnav-launcher .ovl-fam:hover, .ovnav-launcher a:focus-visible { background: var(--accent-glow, rgba(59,130,246,.14)); outline: none; }
.ovnav-launcher b { display: block; font-size: 13px; font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ovnav-launcher b { display: block; font-size: 13px; font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ovnav-launcher small { display: block; font-size: 11px; color: var(--text-muted, #7d8aa0); margin-top: 1px; }
.ovnav-launcher small { display: block; font-size: 11px; color: var(--text-muted, #7d8aa0); margin-top: 1px; }
.ovnav-launcher .ovl-soon { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 4px 4px; }
.ovnav-launcher .ovl-soon { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 4px 4px; }
@@ -130,7 +134,7 @@
@@ -130,7 +134,7 @@
.ovnav-launcher .ovl-all { grid-column: 1 / -1; color: var(--accent-light, var(--accent, #60a5fa)); }
.ovnav-launcher .ovl-all { grid-column: 1 / -1; color: var(--accent-light, var(--accent, #60a5fa)); }
.ovnav-launcher .ovl-h span { text-transform: none; letter-spacing: 0; font-weight: 500; }
.ovnav-launcher .ovl-h span { text-transform: none; letter-spacing: 0; font-weight: 500; }
.ovnav-launcher .ovl-empty { padding: 18px 8px; color: var(--text-secondary, #a8b3c4); font-size: 13px; }
.ovnav-launcher .ovl-empty { padding: 18px 8px; color: var(--text-secondary, #a8b3c4); font-size: 13px; }
- @media (max-width: 480px) { .ovnav-launcher { left: 8px; } .ovnav-launcher .ovl-fams { grid-template-columns: 1fr; } }
+ @media (max-width: 480px) { .ovnav-launcher { left: 8px; } .ovnav-launcher :is(.ovl-apps, .ovl-fams) { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (prefers-reduced-motion: reduce) { .ovnav-launcher { transition: none; } }
@media (prefers-reduced-motion: reduce) { .ovnav-launcher { transition: 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; }
.openvibe-navbar-brand .name { display: inline-flex; align-items: baseline; font-size: 15px; font-weight: 700; letter-spacing: -.3px; white-space: nowrap; line-height: 1; }
.openvibe-navbar-brand .name { display: inline-flex; align-items: baseline; font-size: 15px; font-weight: 700; letter-spacing: -.3px; white-space: nowrap; line-height: 1; }
@@ -547,15 +551,15 @@
@@ -547,15 +551,15 @@
// minutes in localStorage and refreshed in the background, so pages paint from cache and the
// minutes in localStorage and refreshed in the background, so pages paint from cache and the
// order never jumps while someone is looking at it. Defined once, shared by navbar + footer.
// order never jumps while someone is looking at it. Defined once, shared by navbar + footer.
const OVFrame = root.OpenVibeFrame || root.OpenVibeChrome || (root.OpenVibeFrame = root.OpenVibeChrome = (function () {
const OVFrame = root.OpenVibeFrame || root.OpenVibeChrome || (root.OpenVibeFrame = root.OpenVibeChrome = (function () {
- const KEY = 'ov_frame_v1', TTL = 30 * 60000;
+ const KEY = 'ov_frame_v2', TTL = 30 * 60000; // v2: every site, with its launcher group
let inflight = null;
let inflight = null;
const host = () => (typeof location !== 'undefined' ? location.hostname : '');
const host = () => (typeof location !== 'undefined' ? location.hostname : '');
const https = (u) => { try { return new URL(u).protocol === 'https:'; } catch { return false; } };
const https = (u) => { try { return new URL(u).protocol === 'https:'; } catch { return false; } };
function clean(d) {
function clean(d) {
if (!d || !Array.isArray(d.nav)) return null;
if (!d || !Array.isArray(d.nav)) return null;
- const link = (l) => (l && typeof l.name === 'string' && https(l.url) ? { id: String(l.id || ''), name: l.name.slice(0, 60), url: l.url, icon: String(l.icon || ''), tagline: String(l.tagline || '').slice(0, 80) } : null);
+ const link = (l) => (l && typeof l.name === 'string' && https(l.url) ? { id: String(l.id || ''), name: l.name.slice(0, 60), url: l.url, icon: String(l.icon || ''), tagline: String(l.tagline || '').slice(0, 80), group: String(l.group || '').slice(0, 20) } : null);
const f = d.footer || {}, lg = f.legal || {};
const f = d.footer || {}, lg = f.legal || {};
- return { nav: d.nav.map(link).filter(Boolean).slice(0, 12), soon: (d.soon || []).map(link).filter(Boolean).slice(0, 24),
+ return { nav: d.nav.map(link).filter(Boolean).slice(0, 60), groups: (Array.isArray(d.groups) ? d.groups : []).filter(g => g && typeof g.id === 'string' && typeof g.name === 'string').slice(0, 10).map(g => ({ id: g.id.slice(0, 20), name: g.name.slice(0, 40) })), soon: (d.soon || []).map(link).filter(Boolean).slice(0, 24),
footer: { blurb: String(f.blurb || '').slice(0, 200), discover: (f.discover || []).map(link).filter(Boolean).slice(0, 6), popular: (f.popular || []).map(link).filter(Boolean).slice(0, 10),
footer: { blurb: String(f.blurb || '').slice(0, 200), discover: (f.discover || []).map(link).filter(Boolean).slice(0, 6), popular: (f.popular || []).map(link).filter(Boolean).slice(0, 10),
legal: https(lg.terms) && https(lg.privacy) && https(lg.dmca) ? { terms: lg.terms, privacy: lg.privacy, dmca: lg.dmca } : null } };
legal: https(lg.terms) && https(lg.privacy) && https(lg.dmca) ? { terms: lg.terms, privacy: lg.privacy, dmca: lg.dmca } : null } };
}
}
@@ -619,7 +623,10 @@
@@ -619,7 +623,10 @@
function bindLauncher(nav) {
function bindLauncher(nav) {
const btn = nav.querySelector('#openvibe-launcher-btn'); if (!btn) return;
const btn = nav.querySelector('#openvibe-launcher-btn'); if (!btn) return;
let panel = null;
let panel = null;
- const tile = (it, cls) => `<a class="${cls}" href="${escapeAttr(it.go || it.url)}"><span class="ov-icon" data-icon="${escapeAttr(it.icon)}" data-size="${cls === 'ovl-site' ? 34 : 24}" data-fx="none"></span><span><b>${escapeAttr(it.name)}</b>${it.desc ? `<small>${escapeAttr(it.desc)}</small>` : ''}</span></a>`;
+ const tile = (it, cls, here) => `<a class="${cls}${here ? ' is-here' : ''}" href="${escapeAttr(it.go || it.url)}"${here ? ' aria-current="page"' : ''}${it.desc ? ` title="${escapeAttr(it.desc)}"` : ''}><span class="ov-icon" data-icon="${escapeAttr(it.icon)}" data-size="${cls === 'ovl-app' ? 28 : 24}" data-fx="none"></span><span><b>${escapeAttr(it.name)}</b>${it.desc ? `<small>${escapeAttr(it.desc)}</small>` : ''}</span></a>`;
+ const siteItems = (frame) => (frame && frame.nav.length ? frame.nav.map(n => ({ name: n.name, desc: n.tagline.replace(/\.\s*$/, ''), icon: n.icon || n.id, url: n.url, group: n.group })) : LAUNCHER_SITES);
+ // This page's site: the longest matching host (play.openvibe.network is Play, not Network).
+ const hereOf = (list) => { const h = currentHost().toLowerCase(); let best = null, len = 0; for (const x of list) { let sh = ''; try { sh = new URL(x.url).hostname; } catch { continue; } if ((h === sh || h.endsWith('.' + sh)) && sh.length > len) { best = x; len = sh.length; } } return best; };
const close = () => { if (panel) panel.classList.remove('open'); btn.setAttribute('aria-expanded', 'false'); };
const close = () => { if (panel) panel.classList.remove('open'); btn.setAttribute('aria-expanded', 'false'); };
function paint(cat, q) {
function paint(cat, q) {
const fams = (cat && cat.families.length ? cat.families : LAUNCHER_FAMILIES);
const fams = (cat && cat.families.length ? cat.families : LAUNCHER_FAMILIES);
@@ -628,24 +635,28 @@
@@ -628,24 +635,28 @@
// Sites and families always filter locally; tools need the catalog. Without it (offline, blocked),
// Sites and families always filter locally; tools need the catalog. Without it (offline, blocked),
// the last row hands the query to the Tools search page, so typing never does nothing.
// the last row hands the query to the Tools search page, so typing never does nothing.
const hit = (x) => (x.name + ' ' + (x.desc || '')).toLowerCase().includes(q);
const hit = (x) => (x.name + ' ' + (x.desc || '')).toLowerCase().includes(q);
- const siteList = (OVFrame.get() && OVFrame.get().nav.length ? OVFrame.get().nav.map(n => ({ name: n.name, desc: n.tagline, icon: n.icon || n.id, url: n.url })) : LAUNCHER_SITES).filter(hit);
+ const siteList = siteItems(OVFrame.get()).filter(hit);
const famList = fams.filter(hit);
const famList = fams.filter(hit);
const tools = cat ? cat.tools.filter(t => t.k.includes(q)).slice(0, 12) : [];
const tools = cat ? cat.tools.filter(t => t.k.includes(q)).slice(0, 12) : [];
const more = `<a class="ovl-fam ovl-all" href="${escapeAttr(TOOLS_SEARCH + encodeURIComponent(q))}"><span class="ov-icon" data-icon="search" data-size="24" data-fx="none"></span><span><b>Search all tools for “${escapeAttr(q)}”</b></span></a>`;
const more = `<a class="ovl-fam ovl-all" href="${escapeAttr(TOOLS_SEARCH + encodeURIComponent(q))}"><span class="ov-icon" data-icon="search" data-size="24" data-fx="none"></span><span><b>Search all tools for “${escapeAttr(q)}”</b></span></a>`;
- body.innerHTML = (siteList.length ? `<div class="ovl-h">Sites</div><div class="ovl-fams">${siteList.map(x => tile(x, 'ovl-fam')).join('')}</div>` : '')
+ body.innerHTML = (siteList.length ? `<div class="ovl-h">Sites</div><div class="ovl-apps">${siteList.map(x => tile(x, 'ovl-app')).join('')}</div>` : '')
+ (famList.length ? `<div class="ovl-h">Tool families</div><div class="ovl-fams">${famList.map(x => tile(x, 'ovl-fam')).join('')}</div>` : '')
+ (famList.length ? `<div class="ovl-h">Tool families</div><div class="ovl-fams">${famList.map(x => tile(x, 'ovl-fam')).join('')}</div>` : '')
+ `<div class="ovl-h">Tools${cat ? '' : ' <span>loading…</span>'}</div><div class="ovl-fams">${tools.map(t => tile(t, 'ovl-fam')).join('')}${more}</div>`;
+ `<div class="ovl-h">Tools${cat ? '' : ' <span>loading…</span>'}</div><div class="ovl-fams">${tools.map(t => tile(t, 'ovl-fam')).join('')}${more}</div>`;
return;
return;
}
}
const frame = OVFrame.get();
const frame = OVFrame.get();
- const sites = frame && frame.nav.length ? frame.nav.map(n => ({ name: n.name, desc: n.tagline, icon: n.icon || n.id, url: n.url })) : LAUNCHER_SITES;
+ const sites = siteItems(frame), hereSite = hereOf(sites);
+ // The Frame's groups in order; a site in no known group goes in the last one.
+ const groups = frame && frame.groups && frame.groups.length ? frame.groups : [{ id: '', name: 'Sites' }];
+ const inGroup = (s, g, i) => !g.id || s.group === g.id || (i === groups.length - 1 && !groups.some(x => x.id === s.group));
+ const siteHtml = groups.map((g, i) => { const list = sites.filter(s => inGroup(s, g, i)); return list.length ? `<div class="ovl-h">${escapeAttr(g.name)}</div><div class="ovl-apps">${list.map(x => tile(x, 'ovl-app', x === hereSite)).join('')}</div>` : ''; }).join('');
const soon = frame ? frame.soon : [];
const soon = frame ? frame.soon : [];
// On a tool: every address it answers to (short, search-friendly, mirrors, custom domains), so people
// On a tool: every address it answers to (short, search-friendly, mirrors, custom domains), so people
// can pick the one they will remember.
// can pick the one they will remember.
const cur = currentHost().toLowerCase();
const cur = currentHost().toLowerCase();
const here = cat ? cat.tools.find(t => t.hosts.indexOf(cur) >= 0) : null;
const here = cat ? cat.tools.find(t => t.hosts.indexOf(cur) >= 0) : null;
const addresses = here && here.hosts.length > 1 ? `<div class="ovl-h">${escapeAttr(here.name)} lives at <a href="https://openvibe.tools/tool/${escapeAttr(here.id)}">About</a></div><div class="ovl-soon ovl-addr">${[...new Set(here.hosts)].filter(h => /^[a-z0-9.-]+$/.test(h)).map(h => `<a href="https://${h}/"${h === cur ? ' class="is-here" aria-current="page"' : ''}>${h}</a>`).join('')}</div>` : '';
const addresses = here && here.hosts.length > 1 ? `<div class="ovl-h">${escapeAttr(here.name)} lives at <a href="https://openvibe.tools/tool/${escapeAttr(here.id)}">About</a></div><div class="ovl-soon ovl-addr">${[...new Set(here.hosts)].filter(h => /^[a-z0-9.-]+$/.test(h)).map(h => `<a href="https://${h}/"${h === cur ? ' class="is-here" aria-current="page"' : ''}>${h}</a>`).join('')}</div>` : '';
- body.innerHTML = addresses + `<div class="ovl-h">Sites</div><div class="ovl-sites">${sites.map(x => tile(x, 'ovl-site')).join('')}</div>
+ body.innerHTML = addresses + siteHtml + `
<div class="ovl-h">Tools <a href="https://openvibe.tools/">See all</a></div><div class="ovl-fams">${fams.map(x => tile(x, 'ovl-fam')).join('')}</div>
<div class="ovl-h">Tools <a href="https://openvibe.tools/">See all</a></div><div class="ovl-fams">${fams.map(x => tile(x, 'ovl-fam')).join('')}</div>
${soon.length ? `<div class="ovl-h">Opening soon</div><div class="ovl-soon">${soon.map(x => `<a href="${escapeAttr(x.url)}">${escapeAttr(x.name)}</a>`).join('')}</div>` : ''}`;
${soon.length ? `<div class="ovl-h">Opening soon</div><div class="ovl-soon">${soon.map(x => `<a href="${escapeAttr(x.url)}">${escapeAttr(x.name)}</a>`).join('')}</div>` : ''}`;
}
}
@@ -654,7 +665,7 @@
@@ -654,7 +665,7 @@
if (panel && panel.classList.contains('open')) return close();
if (panel && panel.classList.contains('open')) return close();
if (!panel) {
if (!panel) {
panel = document.createElement('div'); panel.className = 'ovnav-launcher'; panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-label', 'OpenVibe sites and tools');
panel = document.createElement('div'); panel.className = 'ovnav-launcher'; panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-label', 'OpenVibe sites and tools');
- panel.innerHTML = '<input type="search" class="ovl-q" placeholder="Find a tool or site" aria-label="Find a tool or site"><div class="ovl-body"></div><div class="ovl-display" hidden></div>';
+ panel.innerHTML = '<div class="ovl-top"><input type="search" class="ovl-q" placeholder="Find a site or tool" aria-label="Find a site or tool"></div><div class="ovl-body"></div><div class="ovl-display" hidden></div>';
bindDisplayControls(panel.querySelector('.ovl-display'));
bindDisplayControls(panel.querySelector('.ovl-display'));
nav.appendChild(panel);
nav.appendChild(panel);
regPanel(panel, 'launcher', close);
regPanel(panel, 'launcher', close);
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.3.0",
+ "version": "4.4.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.3.0",
+ "version": "4.4.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.3.0",
+ "version": "4.4.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": {
@@ -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 = 31 * 1024; // measured 27.6 KB on 2026-09-22 (39.0 KB with every glyph inline); 29.4 KB with the top-of-page fade (2.15.0); 30.6 KB with Ask Actor and its glyph (4.2.0)
+const NAVBAR_BROTLI_BUDGET = 32 * 1024; // measured 27.6 KB on 2026-09-22 (39.0 KB with every glyph inline); 29.4 KB with the top-of-page fade (2.15.0); 30.6 KB with Ask Actor and its glyph (4.2.0); 31.4 KB with every site in groups in the launcher (4.4.0)
// ── size budget and generated block ──────────────────────────
// ── size budget and generated block ──────────────────────────
{
{
new file mode 100644
new file mode 100644
@@ -0,0 +1,78 @@
@@ -0,0 +1,78 @@
+'use strict';
+// The launcher (the grid button beside the brand) lists every open site of the network, in the Frame's groups, most used
+// first within each: Network's /api/frame sends 40 entries (the navbar used to keep 12, so 28 sites were missing from the
+// menu, owner 2026-10-17). Runs in a real headless Chrome (skipped without Chrome or with OV_SKIP_BROWSER=1), with the
+// Frame answer served through a stubbed fetch so the navbar's own cleaning of it is what is tested.
+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('navbar-launcher: skipped (no Chrome; set CHROME_BIN)');
+ process.exit(0);
+}
+
+const GROUPS = [{ id: 'watch', name: 'Watch & stream' }, { id: 'talk', name: 'Talk & learn' }, { id: 'you', name: 'Your 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('chat', 'Chat', 'talk'), site('here', 'Here', 'talk', 'https://127.0.0.1/'),
+ site('network', 'Network', 'you'), site('mystery', 'Mystery', 'brand-new-group')];
+for (let i = 0; i < 15; i++) nav.push(site(`w${i}`, `Watch ${i}`, 'watch')); // 20 entries: more than the old cut of 12
+nav.push({ id: 'pics', name: 'Pics', url: 'https://openvibe.pics/', icon: 'pics', tagline: 'Share pictures', group: 'watch', of: 'media-hub' });
+const FRAME = { nav, groups: GROUPS, soon: [], footer: {} };
+
+const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Launcher</title><link rel="icon" href="data:,">
+<script>
+ const realFetch = window.fetch;
+ window.fetch = (u, o) => (String(u).startsWith('https://openvibe.network/api/frame') ? Promise.resolve({ ok: true, json: async () => (${JSON.stringify(FRAME)}) }) : 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: 'site', user: null, links: [], 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-launcher-'));
+ 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 });
+ // The navbar asks for the Frame at render; the launcher paints from what it kept.
+ await p.evaluate(`new Promise((r) => { const t = setInterval(() => { if (localStorage.getItem('ov_frame_v2')) { clearInterval(t); r(true); } }, 20); })`);
+ await p.evaluate(`document.getElementById('openvibe-launcher-btn').click(), true`);
+ const read = () => p.evaluate(`(() => { const b = document.querySelector('.ovnav-launcher .ovl-body'); return {
+ heads: [...b.querySelectorAll('.ovl-h')].map((e) => e.firstChild.textContent.trim()),
+ groups: [...b.querySelectorAll('.ovl-apps')].map((g) => [...g.querySelectorAll('b')].map((x) => x.textContent)),
+ here: [...b.querySelectorAll('.ovl-app.is-here')].map((a) => a.querySelector('b').textContent + '|' + a.getAttribute('aria-current')),
+ small: (b.querySelector('.ovl-app small') || {}).textContent } })()`);
+ const r = await read();
+ assert.deepStrictEqual(r.heads.slice(0, 3), ['Watch & stream', 'Talk & learn', 'Your OpenVibe'], 'the Frame\'s groups, in its order');
+ assert.strictEqual(r.groups.flat().length, 21, 'every site of the Frame (21 here; the navbar used to keep 12)');
+ assert.deepStrictEqual(r.groups[0].slice(0, 2), ['Live', 'Watch 0'], 'most used first within a group');
+ assert.ok(r.groups[0].includes('Pics'), 'a product on its own domain sits in its group');
+ assert.deepStrictEqual(r.groups[2], ['Network', 'Mystery'], 'a site in a group the navbar does not know goes in the last one');
+ assert.deepStrictEqual(r.here, ['Here|page'], 'the page\'s own site is marked');
+ assert.strictEqual(r.small, 'Live things', 'taglines without a closing period');
+ // Typing filters the sites (and the search box stays on top of the scrolling panel).
+ await p.evaluate(`(() => { const q = document.querySelector('.ovnav-launcher .ovl-q'); q.value = 'pic'; q.dispatchEvent(new Event('input')); return true; })()`);
+ const f = await read();
+ assert.deepStrictEqual(f.groups[0], ['Pics'], JSON.stringify(f));
+ assert.strictEqual(await p.evaluate(`getComputedStyle(document.querySelector('.ovnav-launcher .ovl-top')).position`), 'sticky');
+ console.log('navbar-launcher: all checks passed');
+ } finally {
+ await browser.close();
+ server.close();
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ }
+})().catch((e) => { console.error(e); process.exit(1); });