openvibers/openvibe.network Commits

Every page of each Network host sends that host's own page list (#861)

OP OpenVibers committed 1 days ago · 3d0baee13e · 20 files, +216 −28

The shared navbar's fallback (Home, My Account, Themes) was Network's list on Link and My pages, and Network's own pages disagreed with its home. server/frame/page-links.js now holds one list per host: Network, Link and My Account; Link's machines hub; My's sections. Home, Link, stats, updates, sites, bot, wallet, terms, privacy, dmca, profile, verify-email, admin and the 404 page pass it, and the test checks every page and every route.

Changes

ddiff --git a/public/index.html b/public/index.html
iindex e545b82b..1493a317 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -290,7 +290,7 @@ footer{border-top:1px solid var(--border);background:var(--bg-secondary)}
function initNavbar(user) {
if (typeof OpenVibeNavbar !== 'undefined') {
-- OpenVibeNavbar.init({ service: 'network', notificationsRealtime: true, token: token || null, user: user || null, menu: { before: [{ id: 'ov-wallet', label: 'Wallet', short: 'Wallet', icon: 'fa-wallet', href: 'https://openvibe.network/wallet' }] }, links: [{ label: 'Link', href: 'https://openvibe.link', icon: 'fa-desktop', featured: true }, { label: 'My Account', href: '/my' }, { label: 'Wallet', href: '/wallet' }, { label: 'Themes', href: '/themes' }], networkLinks: 5 });
++ OpenVibeNavbar.init({ service: 'network', notificationsRealtime: true, token: token || null, user: user || null, menu: { before: [{ id: 'ov-wallet', label: 'Wallet', short: 'Wallet', icon: 'fa-wallet', href: 'https://openvibe.network/wallet' }] }, links: [{"label":"Network","href":"/"},{"label":"Link","href":"https://openvibe.link/","icon":"fa-desktop","featured":true},{"label":"My Account","href":"https://openvibe.my/"},{"label":"Wallet","href":"/wallet"},{"label":"Themes","href":"/themes"}], networkLinks: 5 });
}
if (user) {
document.getElementById('hero-guest-ctas').style.display = 'none';
ddiff --git a/public/js/admin/boot.js b/public/js/admin/boot.js
iindex 96c1fcc7..f9af24b7 100644
---- a/public/js/admin/boot.js
++++ b/public/js/admin/boot.js
@@@ -4,6 +4,8 @@
admin/core.js's helpers (api, esc, toast, API, token) and on the
tabLoaders map in admin/boot.js (loaded last). No ES modules.
═══════════════════════════════════════════════════════════════ */
++// Network's own page list: the shared navbar's fallback (Home, My Account, Themes) is not this host's list.
++const OV_PAGE_LINKS = [{"label":"Network","href":"/"},{"label":"Link","href":"https://openvibe.link/","icon":"fa-desktop","featured":true},{"label":"My Account","href":"https://openvibe.my/"},{"label":"Wallet","href":"/wallet"},{"label":"Themes","href":"/themes"}];
const tabLoaders = {
setup: loadSetup,
glossary: loadGlossary,
@@@ -72,7 +74,7 @@ async function _bootAdmin() {
}
// Init navbar
if (window.OpenVibeNavbar) {
-- OpenVibeNavbar.init({ service: 'network', token, user: currentUser });
++ OpenVibeNavbar.init({ service: 'network', token, user: currentUser, links: OV_PAGE_LINKS });
const bellMount = OpenVibeNavbar.getBellMount();
if (bellMount && window.OpenVibeNotifications) {
OpenVibeNotifications.init({ mountEl: bellMount, apiBase: API, token });
ddiff --git a/public/js/my/profile.js b/public/js/my/profile.js
iindex 7b813d18..e08b00c1 100644
---- a/public/js/my/profile.js
++++ b/public/js/my/profile.js
@@@ -15,6 +15,15 @@ const OV_NAV_AUTH = MY_HOST ? {
logoutUrl: '/auth/logout?next={path}',
} : {};
++// The page list this host's shared navbar shows. openvibe.my's own sections, openvibe.link's machines hub (this file
++// also renders /machines there), and Network's own pages everywhere else. Never the shared fallback: that is
++// Home, My Account, Themes on every host (the owner, 2026-11-12).
++const OV_PAGE_LINKS = MY_HOST
++ ? [{"label":"My Account","href":"/"},{"label":"Profile","href":"/profile"},{"label":"Themes","href":"/themes"},{"label":"Security","href":"/security"}]
++ : location.hostname.replace(/^www\./, '') === 'openvibe.link'
++ ? [{"label":"Link","href":"/","icon":"fa-desktop","featured":true},{"label":"Machines","href":"/machines","icon":"fa-laptop"},{"label":"Network","href":"https://openvibe.network/"},{"label":"My Account","href":"https://openvibe.my/"}]
++ : [{"label":"Network","href":"/"},{"label":"Link","href":"https://openvibe.link/","icon":"fa-desktop","featured":true},{"label":"My Account","href":"https://openvibe.my/"},{"label":"Wallet","href":"/wallet"},{"label":"Themes","href":"/themes"}];
++
// /my#delete is the return target of the re-authentication path (and a deep link): it opens the accounts
// section and puts the cursor in the delete confirmation field. Read before core.js rewrites the hash.
const OV_DELETE_DEEP_LINK = window.location.hash === '#delete';
@@@ -85,7 +94,7 @@ function showSignedOutHub() {
card.append(heading, text, signIn, list, still);
page.replaceChildren(card);
try {
-- if (window.OpenVibeNavbar) OpenVibeNavbar.init({ service: 'network', token: null, user: null, menu: OV_WALLET_MENU, ...OV_NAV_AUTH });
++ if (window.OpenVibeNavbar) OpenVibeNavbar.init({ service: 'network', token: null, user: null, menu: OV_WALLET_MENU, links: OV_PAGE_LINKS, ...OV_NAV_AUTH });
} catch { /* the page still offers the buttons */ }
}
@@@ -120,7 +129,7 @@ async function loadUser() {
applyAnonModeUI(currentUser);
loadAccounts();
loadAnonInfo();
-- OpenVibeNavbar.init({ service: 'network', notificationsRealtime: true, token: null, user: currentUser, menu: OV_WALLET_MENU, ...OV_NAV_AUTH });
++ OpenVibeNavbar.init({ service: 'network', notificationsRealtime: true, token: null, user: currentUser, menu: OV_WALLET_MENU, links: OV_PAGE_LINKS, ...OV_NAV_AUTH });
} else {
const data = await apiFetch('/api/auth/me');
currentUser = data.user;
@@@ -145,7 +154,7 @@ async function loadUser() {
loadRecentTools();
// Init navbar
-- OpenVibeNavbar.init({ service: 'network', notificationsRealtime: true, token: authToken, user: currentUser, menu: OV_WALLET_MENU, ...OV_NAV_AUTH });
++ OpenVibeNavbar.init({ service: 'network', notificationsRealtime: true, token: authToken, user: currentUser, menu: OV_WALLET_MENU, links: OV_PAGE_LINKS, ...OV_NAV_AUTH });
const bellMount = OpenVibeNavbar.getBellMount();
if (bellMount && window.OpenVibeNotifications && authToken) {
OpenVibeNotifications.init({ token: authToken, apiBase: API, swPath: '/openvibe-sw.js' });
ddiff --git a/public/js/wallet/common.js b/public/js/wallet/common.js
iindex a64df2fc..015f4383 100644
---- a/public/js/wallet/common.js
++++ b/public/js/wallet/common.js
@@@ -96,7 +96,7 @@
root.OpenVibeNavbar.init({
service: 'network', notificationsRealtime: true, token: token(), user,
menu: WALLET_MENU,
-- links: [{ label: 'Link', href: 'https://openvibe.link', icon: 'fa-desktop', featured: true }, { label: 'Wallet', href: '/wallet' }, { label: 'My Account', href: 'https://openvibe.my/' }],
++ links: [{"label":"Network","href":"/"},{"label":"Link","href":"https://openvibe.link/","icon":"fa-desktop","featured":true},{"label":"My Account","href":"https://openvibe.my/"},{"label":"Wallet","href":"/wallet"},{"label":"Themes","href":"/themes"}],
});
} catch { /* the page works without it */ }
}
ddiff --git a/public/verify-email.html b/public/verify-email.html
iindex eb1fbbe4..ddcb5816 100644
---- a/public/verify-email.html
++++ b/public/verify-email.html
@@@ -96,7 +96,7 @@ p{color:var(--text-secondary);font-size:14px;line-height:1.6}
<script src="/js/account-safety.js" defer></script>
<script>
(function () {
-- if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: 'network', apiBase: location.origin }); } catch (e) { /* */ } }
++ if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: 'network', apiBase: location.origin, links: [{"label":"Network","href":"/"},{"label":"Link","href":"https://openvibe.link/","icon":"fa-desktop","featured":true},{"label":"My Account","href":"https://openvibe.my/"},{"label":"Wallet","href":"/wallet"},{"label":"Themes","href":"/themes"}] }); } catch (e) { /* */ } }
const card = document.getElementById('card'), ring = document.getElementById('ring'), ico = document.getElementById('ico');
const title = document.getElementById('title'), msg = document.getElementById('msg'), actions = document.getElementById('actions'), hint = document.getElementById('hint');
const burst = document.getElementById('burst');
ddiff --git a/server/bot/page.js b/server/bot/page.js
iindex 266ddb4a..c152cf0a 100644
---- a/server/bot/page.js
++++ b/server/bot/page.js
@@@ -1,4 +1,5 @@
'use strict';
++const pageLinks = require('../frame/page-links');
// OpenVibeBot (ADR 0007 in OpenVibe.Sources): the page its user agent links to — what it is, what it reads, what it obeys,
// and how a site blocks, slows or opts in. /bot.json says the same for machines. Static: no database, no outbound calls.
@@@ -125,7 +126,7 @@ ${require('openvibe-shared/footer').ssr({ service: 'network', variant: 'compact'
<script src="/shared/navbar.js" defer></script>
<script src="/js/account-safety.js" defer></script>
${require('../boost').SCRIPT}
--<script>document.addEventListener('DOMContentLoaded',function(){try{if(window.OpenVibeNavbar)OpenVibeNavbar.init({service:'network',apiBase:location.origin});}catch(e){}
++<script>document.addEventListener('DOMContentLoaded',function(){try{if(window.OpenVibeNavbar)OpenVibeNavbar.init({service:'network',apiBase:location.origin,links:${JSON.stringify(pageLinks.NETWORK)}});}catch(e){}
document.querySelectorAll('.bp-copy').forEach(function(b){b.addEventListener('click',function(){var el=document.getElementById(b.getAttribute('data-copy'));if(!el||!navigator.clipboard)return;navigator.clipboard.writeText(el.textContent).then(function(){b.textContent='Copied';b.classList.add('is-done');setTimeout(function(){b.textContent='Copy';b.classList.remove('is-done');},1600);},function(){});});});});</script>
</body>
</html>`;
ddiff --git a/server/frame/page-links.js b/server/frame/page-links.js
nnew file mode 100644
iindex 00000000..ccfe1293
---- /dev/null
++++ b/server/frame/page-links.js
@@@ -0,0 +1,52 @@
++'use strict';
++// The page list each host gives the shared navbar's "Pages on <site>" menu. Every page of a host sends the same
++// list, and each row is a page of that host or a door named for where it goes (the owner, 2026-11-12:
++// "Pages on OpenVibe.Link: Home, My Account, Themes" was Network's list on Link's menu, not Link's).
++//
++// Network serves four hosts. openvibe.network, openvibe.link and openvibe.my read these lists here; Computer has
++// its own server. A page that passes no links gets the shared navbar's fallback (Home, My Account, Themes), which
++// is why every Network page passes the host's list explicitly.
++const NETWORK = Object.freeze([
++ { label: 'Network', href: '/' },
++ { label: 'Link', href: 'https://openvibe.link/', icon: 'fa-desktop', featured: true },
++ { label: 'My Account', href: 'https://openvibe.my/' },
++ { label: 'Wallet', href: '/wallet' },
++ { label: 'Themes', href: '/themes' },
++]);
++
++const LINK = Object.freeze([
++ { label: 'Link', href: '/', icon: 'fa-desktop', featured: true },
++ { label: 'Machines', href: '/machines', icon: 'fa-laptop' },
++ { label: 'Network', href: 'https://openvibe.network/' },
++ { label: 'My Account', href: 'https://openvibe.my/' },
++]);
++
++const MY = Object.freeze([
++ { label: 'My Account', href: '/' },
++ { label: 'Profile', href: '/profile' },
++ { label: 'Themes', href: '/themes' },
++ { label: 'Security', href: '/security' },
++]);
++
++const HOSTS = new Set(['openvibe.network', 'www.openvibe.network', 'openvibe.link', 'www.openvibe.link', 'openvibe.my', 'www.openvibe.my']);
++
++/** This request's host, lowercased, no port, no www. */
++function hostOf(req) {
++ return String((req && (req.hostname || (req.headers && req.headers.host))) || '').split(':')[0].toLowerCase().replace(/^www\./, '');
++}
++
++/** The page list of the host Network is answering: Link and My have their own; everything else is Network's. */
++function forHost(host) {
++ const h = String(host || '').split(':')[0].toLowerCase().replace(/^www\./, '');
++ if (h === 'openvibe.link') return LINK;
++ if (h === 'openvibe.my') return MY;
++ return NETWORK;
++}
++
++/** A JS expression for a page that is cached across hosts: the same choice, made in the browser. */
++function browserList() {
++ const host = "location.hostname.replace(/^www\\./, '')";
++ return `(${host} === 'openvibe.link' ? ${JSON.stringify(LINK)} : ${host} === 'openvibe.my' ? ${JSON.stringify(MY)} : ${JSON.stringify(NETWORK)})`;
++}
++
++module.exports = { NETWORK, LINK, MY, HOSTS, hostOf, forHost, browserList };
ddiff --git a/server/home/render.js b/server/home/render.js
iindex 3ddd7887..7d3154b6 100644
---- a/server/home/render.js
++++ b/server/home/render.js
@@@ -27,6 +27,7 @@ const icon = (name, size) => `<span class="ov-icon" data-icon="${esc(name)}" dat
// public domain serves the service itself (Network's exposure overlay), so the home page, the navbar and
// "Opening next" always agree with what is actually running.
const { LISTED, GROUPS, groupOf, fullName } = require('../frame/sites');
++const pageLinks = require('../frame/page-links');
const contracts = require('openvibe-contracts');
const social = require('openvibe-shared/social');
// OpenRestream is its own name, never "OpenVibe.OpenRestream" (the owner, 2026-10-28).
@@@ -540,7 +541,7 @@ function render({ releaseId = '' } = {}) {
})) + seo.jsonLdTag(seo.jsonLd.itemList(sitesHeading(), OPEN.map((site) => ({ name: siteName(site), url: `https://${site.host}/`, description: site.what })))) + seo.jsonLdTag(seo.jsonLd.faq(faqPairs()));
const card = social.url('network', 'https://openvibe.network/shared') || 'https://openvibe.network/assets/logo-512.png';
const summary = summaryOf(catalog);
-- const html = withSameAs(lookBoot.apply(fs.readFileSync(SHELL, 'utf8'))).replace('<div id="navbar-mount"></div>', require('openvibe-shared/frame').serverNav({ name: 'OpenVibe.Network', service: 'network', home: '/', links: [{ label: 'Link', href: 'https://openvibe.link/' }, { label: 'My Account', href: '/my' }, { label: 'Wallet', href: '/wallet' }, { label: 'Themes', href: '/themes' }] }) + summary.body).replace('<h1>Go live, chat, play<br><span class="grad">and get things done.</span></h1>', () => kit.pageHead({ title: 'Go live, chat, play and get things done.', icon: 'home', lede: 'Your places, people and projects across OpenVibe.' })).replace('<!--OV:HOME-->', body(catalog, act)).replace('<!--OV:INTENTS-->', intents(catalog, act.popularity)).replace('<!--OV:CONSTELLATION-->', () => orbit() + numbers(catalog, act)).replaceAll('__OV_CARD__', card).replace('<!--OV:SITES-->', String(SITE_COUNT)).replace('<!--OV:COUNT-->', catalog.tools.length ? String(catalog.tools.length) : 'free').replace('<div id="ov-footer"></div>', require('openvibe-shared/footer').ssr({ service: 'network', variant: 'full' })).replace('</head>', () => `${ld}\n<link rel="stylesheet" href="${require('openvibe-shared/serve').url('kit.css')}"><script src="${require('openvibe-shared/serve').url('kit-web.js')}" defer></script>\n${summary.head}\n${boost.meta(releaseId)}\n</head>`);
++ const html = withSameAs(lookBoot.apply(fs.readFileSync(SHELL, 'utf8'))).replace('<div id="navbar-mount"></div>', require('openvibe-shared/frame').serverNav({ name: 'OpenVibe.Network', service: 'network', home: '/', links: pageLinks.NETWORK }) + summary.body).replace('<h1>Go live, chat, play<br><span class="grad">and get things done.</span></h1>', () => kit.pageHead({ title: 'Go live, chat, play and get things done.', icon: 'home', lede: 'Your places, people and projects across OpenVibe.' })).replace('<!--OV:HOME-->', body(catalog, act)).replace('<!--OV:INTENTS-->', intents(catalog, act.popularity)).replace('<!--OV:CONSTELLATION-->', () => orbit() + numbers(catalog, act)).replaceAll('__OV_CARD__', card).replace('<!--OV:SITES-->', String(SITE_COUNT)).replace('<!--OV:COUNT-->', catalog.tools.length ? String(catalog.tools.length) : 'free').replace('<div id="ov-footer"></div>', require('openvibe-shared/footer').ssr({ service: 'network', variant: 'full' })).replace('</head>', () => `${ld}\n<link rel="stylesheet" href="${require('openvibe-shared/serve').url('kit.css')}"><script src="${require('openvibe-shared/serve').url('kit-web.js')}" defer></script>\n${summary.head}\n${boost.meta(releaseId)}\n</head>`);
pageCache = { key, html, etag: '"' + crypto.createHash('sha1').update(html).digest('base64url').slice(0, 20) + '"' };
return pageCache;
}
ddiff --git a/server/index.js b/server/index.js
iindex e9199212..4f915d28 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -985,12 +985,17 @@ function requireAdmin(req, res, next) {
// is added to the head here, and the small Twitter card they declare becomes the large one that image needs.
{
const legal = require('openvibe-shared/legal');
++ const pageLinks = require('./frame/page-links');
const card = require('openvibe-shared/social').url('network', 'https://openvibe.network/shared');
const tags = `<meta property="og:image" content="${card}"><meta property="og:image:alt" content="OpenVibe.Network: one account for every OpenVibe site"><meta name="twitter:image" content="${card}">`;
++ // Shared's legal template draws the navbar without links, so its fallback (Home, My Account, Themes) was the
++ // Network list on /terms, /privacy and /dmca. Add this host's own list to the init call (Shared stays untouched).
++ const navLinks = JSON.stringify(pageLinks.NETWORK);
app.get(legal.PATHS, (req, res, next) => {
const send = res.send.bind(res);
res.send = (body) => send(String(body)
.replace('<meta name="twitter:card" content="summary">', '<meta name="twitter:card" content="summary_large_image">')
++ .replace('try{OpenVibeNavbar.init({service:', `try{OpenVibeNavbar.init({links:${navLinks},service:`)
.replace('</head>', `${tags}</head>`));
next();
}, legal.handler({ id: 'network', service: 'network', host: 'openvibe.network', name: 'OpenVibe.Network', profile: 'account' }));
ddiff --git a/server/link/page.js b/server/link/page.js
iindex 8e5a68ff..ab5ee55e 100644
---- a/server/link/page.js
++++ b/server/link/page.js
@@@ -1,4 +1,5 @@
'use strict';
++const pageLinks = require('../frame/page-links');
// openvibe.link: OpenVibe Link's own front door (the machine agent). What Link is (your machines, connected), what it
// does today, how to install it on each OS, and what it never does. The chrome is Network's own; the page is Link's.
// The version, the release date and the checksums come from Link's latest GitHub release (server/link/release.js).
@@@ -242,7 +243,7 @@ ${require('../boost').SCRIPT}
<script src="/shared/kit-web.js" defer></script>
<script type="module" src="/send.js"></script>
<script src="/egress-settings.js" defer></script>
--<script>document.addEventListener('DOMContentLoaded',function(){try{if(window.OpenVibeNavbar)OpenVibeNavbar.init({service:'network',apiBase:'https://openvibe.network',sessionUrl:'/auth/me',loginUrl:'/auth/login?next={path}',logoutUrl:'/auth/logout?next={url}',silentLogin:'/auth/login?silent=1&next={url}',links:[{label:'Link',href:'/',icon:'fa-desktop',featured:true,active:true},{label:'Machines',href:'/machines',icon:'fa-laptop'},{label:'Network',href:'https://openvibe.network/'},{label:'My Account',href:'https://openvibe.network/my'}]});}catch(e){}});</script>
++<script>document.addEventListener('DOMContentLoaded',function(){try{if(window.OpenVibeNavbar)OpenVibeNavbar.init({service:'network',apiBase:'https://openvibe.network',sessionUrl:'/auth/me',loginUrl:'/auth/login?next={path}',logoutUrl:'/auth/logout?next={url}',silentLogin:'/auth/login?silent=1&next={url}',links:${JSON.stringify(pageLinks.LINK)}});}catch(e){}});</script>
</body>
</html>`;
}
ddiff --git a/server/not-found.js b/server/not-found.js
iindex ea66e28c..0559abe4 100644
---- a/server/not-found.js
++++ b/server/not-found.js
@@@ -1,4 +1,5 @@
'use strict';
++const pageLinks = require('./frame/page-links');
// ═══════════════════════════════════════════════════════════════
// Unknown paths answer 404 (roadmap §2.5 crawlability, D44).
//
@@@ -16,16 +17,17 @@ const ACCOUNT_HUB_PATHS = ['/my', '/my.html', '/themes', '/notifications', '/lin
const JSON_PREFIXES = ['/api/', '/internal/', '/oauth/'];
--let cached = null;
--function page() {
-- if (cached) return cached;
++const cached = new Map();
++function page(host = 'openvibe.network') {
++ const key = pageLinks.hostOf({ hostname: host });
++ if (cached.has(key)) return cached.get(key);
let footer = '';
let icons = '';
let nav = '';
try { footer = require('openvibe-shared/footer').ssr({ service: 'network', variant: 'compact' }); } catch { /* optional */ }
try { icons = require('openvibe-shared/app-icon').headTags({ site: 'network', iconBase: '/assets' }); } catch { /* optional */ }
try { nav = require('openvibe-shared/frame').noscriptNav({ name: 'OpenVibe.Network', links: [{ label: 'Sign in', href: '/login' }] }); } catch { /* optional */ }
-- cached = `<!DOCTYPE html>
++ const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
@@@ -64,10 +66,11 @@ ${nav}
${footer}
<script src="/shared/navbar.js" defer></script>
<script src="/js/account-safety.js" defer></script>
--<script>document.addEventListener('DOMContentLoaded',function(){try{if(window.OpenVibeNavbar)OpenVibeNavbar.init({service:'network',apiBase:location.origin});}catch(e){}});</script>
++<script>document.addEventListener('DOMContentLoaded',function(){try{if(window.OpenVibeNavbar)OpenVibeNavbar.init({service:'network',apiBase:location.origin,links:${JSON.stringify(pageLinks.forHost(key))}});}catch(e){}});</script>
</body>
</html>`;
-- return cached;
++ cached.set(key, html);
++ return html;
}
/** Last handler of the app: every method, every path nothing else answered. */
@@@ -76,7 +79,7 @@ function notFound(req, res) {
res.set('Cache-Control', 'no-cache');
if (JSON_PREFIXES.some(p => req.path.startsWith(p))) return res.json({ error: 'Not found' });
res.set('X-Robots-Tag', 'noindex');
-- return res.type('html').send(page());
++ return res.type('html').send(page(pageLinks.hostOf(req)));
}
module.exports = { notFound, page, ACCOUNT_HUB_PATHS };
ddiff --git a/server/profile/public.js b/server/profile/public.js
iindex d09a450e..9017c195 100644
---- a/server/profile/public.js
++++ b/server/profile/public.js
@@@ -1,4 +1,5 @@
'use strict';
++const pageLinks = require('../frame/page-links');
// ═══════════════════════════════════════════════════════════════
// Public profiles (plan T21 step 3): one page per person on the Network, and the same data as JSON for every site.
//
@@@ -613,7 +614,7 @@ ${isPublic ? showcaseHtml(inv, name) + badgesHtml(inv) + gamesHtml(inv) + itemsH
${require('openvibe-shared/footer').ssr({ service: 'network', variant: 'compact' })}
<script src="/shared/navbar.js" defer></script>
<script src="/js/account-safety.js" defer></script>
--<script>document.addEventListener('DOMContentLoaded',function(){try{if(window.OpenVibeNavbar)OpenVibeNavbar.init({service:'network',apiBase:location.origin});}catch(e){}});</script>
++<script>document.addEventListener('DOMContentLoaded',function(){try{if(window.OpenVibeNavbar)OpenVibeNavbar.init({service:'network',apiBase:location.origin,links:${JSON.stringify(pageLinks.NETWORK)}});}catch(e){}});</script>
</body>
</html>`;
}
ddiff --git a/server/sites/page.js b/server/sites/page.js
iindex 94ad8122..d63b55c7 100644
---- a/server/sites/page.js
++++ b/server/sites/page.js
@@@ -1,4 +1,5 @@
'use strict';
++const pageLinks = require('../frame/page-links');
// /sites: the front door to the whole network, for people and for crawlers (SEO-9). Every open entry is listed under
// the same eight groups the home page and the menu use (server/frame/sites.js GROUPS), with its own tagline, and
// each site is anchored at its registry id (/sites#live) so it can be linked directly. One source: the registry the
@@@ -82,7 +83,7 @@ ${require('openvibe-shared/footer').ssr({ service: 'network', variant: 'compact'
<script src="/shared/navbar.js" defer></script>
<script src="/js/account-safety.js" defer></script>
${require('../boost').SCRIPT}
--<script>document.addEventListener('DOMContentLoaded',function(){try{if(window.OpenVibeNavbar)OpenVibeNavbar.init({service:'network',apiBase:location.origin});}catch(e){}});</script>
++<script>document.addEventListener('DOMContentLoaded',function(){try{if(window.OpenVibeNavbar)OpenVibeNavbar.init({service:'network',apiBase:location.origin,links:${JSON.stringify(pageLinks.NETWORK)}});}catch(e){}});</script>
</body>
</html>`;
}
ddiff --git a/server/stats/open.js b/server/stats/open.js
iindex 46d6fcf9..f513b7ef 100644
---- a/server/stats/open.js
++++ b/server/stats/open.js
@@@ -1,4 +1,5 @@
'use strict';
++const pageLinks = require('../frame/page-links');
/**
* Open stats (owner, 2026-10-27: "on the network site, we should publish the stats for all of the different platforms
* and publish all the full analytics … make it all public for everyone to see"): every OpenVibe site's numbers on one
@@@ -479,7 +480,7 @@ ${require('openvibe-shared/footer').ssr({ service: 'network', variant: 'compact'
<script src="/shared/navbar.js" defer></script>
<script src="/js/account-safety.js" defer></script>
${require('../boost').SCRIPT}
--<script>document.addEventListener('DOMContentLoaded',function(){try{if(window.OpenVibeNavbar)OpenVibeNavbar.init({service:'network',apiBase:location.origin});}catch(e){}});</script>
++<script>document.addEventListener('DOMContentLoaded',function(){try{if(window.OpenVibeNavbar)OpenVibeNavbar.init({service:'network',apiBase:location.origin,links:${JSON.stringify(pageLinks.NETWORK)}});}catch(e){}});</script>
</body>
</html>`;
}
ddiff --git a/server/status/routes.js b/server/status/routes.js
iindex 461be5b7..15850b8e 100644
---- a/server/status/routes.js
++++ b/server/status/routes.js
@@@ -1,4 +1,5 @@
'use strict';
++const pageLinks = require('../frame/page-links');
// The network's share card (SEO-21): a status link pasted in chat shows it.
const SHARE_CARD = require('openvibe-shared/social').url('network', 'https://openvibe.network/shared');
@@@ -240,7 +241,7 @@ ${require('openvibe-shared/footer').ssr({ service: 'network', variant: 'compact'
<script src="/shared/navbar.js" defer></script>
<script src="/js/account-safety.js" defer></script>
${boost.SCRIPT}
--<script>document.addEventListener('DOMContentLoaded',function(){try{if(window.OpenVibeNavbar)OpenVibeNavbar.init({service:'network',apiBase:location.origin});}catch(e){}});</script>
++<script>document.addEventListener('DOMContentLoaded',function(){try{if(window.OpenVibeNavbar)OpenVibeNavbar.init({service:'network',apiBase:location.origin,links:${JSON.stringify(pageLinks.NETWORK)}});}catch(e){}});</script>
</body>
</html>`;
}
ddiff --git a/server/updates/routes.js b/server/updates/routes.js
iindex fc66891b..495f98de 100644
---- a/server/updates/routes.js
++++ b/server/updates/routes.js
@@@ -1,4 +1,5 @@
'use strict';
++const pageLinks = require('../frame/page-links');
// The network's share card (SEO-21): link previews of this page show it, as the home and legal pages do.
const SHARE_CARD = require('openvibe-shared/social').url('network', 'https://openvibe.network/shared');
@@@ -217,7 +218,7 @@ ${require('openvibe-shared/footer').ssr({ service: 'network', variant: 'compact'
<script src="/shared/shipped.js" defer></script>
${boost.SCRIPT}
<script>document.addEventListener('ov:boost:load',function(){if(window.OpenVibeShipped)OpenVibeShipped.scan(document.getElementById('main'));});</script>
--<script>document.addEventListener('DOMContentLoaded',function(){try{if(window.OpenVibeNavbar)OpenVibeNavbar.init({service:'network',apiBase:location.origin});}catch(e){}});</script>
++<script>document.addEventListener('DOMContentLoaded',function(){try{if(window.OpenVibeNavbar)OpenVibeNavbar.init({service:'network',apiBase:location.origin,links:${JSON.stringify(pageLinks.NETWORK)}});}catch(e){}});</script>
</body>
</html>`;
}
ddiff --git a/test/home-front-door.test.js b/test/home-front-door.test.js
iindex c70cf3b0..b6d77249 100644
---- a/test/home-front-door.test.js
++++ b/test/home-front-door.test.js
@@@ -82,7 +82,7 @@ let { html } = home.render();
// The first HTML carries the site's navigation for a crawler; the browser navbar replaces it on boot.
const serverNav = /<div id="navbar-mount"><nav class="openvibe-navbar"[^>]*data-ov-server-nav="true"[\s\S]*?<\/nav><\/div>/.exec(html);
assert.ok(serverNav, 'the first HTML has the server navigation');
--for (const [label, href] of [['My Account', '/my'], ['Wallet', '/wallet'], ['Themes', '/themes']]) {
++for (const [label, href] of [['Network', '/'], ['Link', 'https://openvibe.link/'], ['My Account', 'https://openvibe.my/'], ['Wallet', '/wallet'], ['Themes', '/themes']]) {
assert.ok(serverNav[0].includes(`href="${href}"`) && serverNav[0].includes(`>${label}</a>`), `the server nav links ${label}`);
}
assert.ok(/<form class="intent-bar" action="https:\/\/openvibe.tools\/search" method="get"/.test(html), 'the search works without JavaScript (a tool search)');
ddiff --git a/test/link-home.test.js b/test/link-home.test.js
iindex e1d80102..3d3699c6 100644
---- a/test/link-home.test.js
++++ b/test/link-home.test.js
@@@ -151,7 +151,7 @@ const SHAPED = {
assert.ok(html.includes('docs/install.md'), 'the install guide still links the Link docs');
assert.ok(html.includes('github.com/OpenVibers/OpenVibe.Link'), 'the GitHub source keeps its URL');
assert.ok(html.includes('/shared/navbar.js'), 'the shared navbar is on the page');
-- assert.ok(html.includes("label:'Link'"), 'the navbar calls it Link');
++ assert.ok(html.includes('"label":"Link"'), 'the navbar calls it Link');
assert.ok(html.includes('<link rel="canonical" href="https://openvibe.link/">'), 'the canonical is openvibe.link');
assert.ok(html.includes('<meta property="og:url" content="https://openvibe.link/">'), 'og:url is openvibe.link');
assert.ok(html.includes('"name":"OpenVibe Link"'), 'the JSON-LD names OpenVibe Link');
@@@ -276,10 +276,10 @@ const SHAPED = {
// ── The navbar and the home card point at the new name ──
const index = fs.readFileSync(path.join(__dirname, '..', 'public', 'index.html'), 'utf8');
-- assert.ok(index.includes("label: 'Link', href: 'https://openvibe.link', icon: 'fa-desktop', featured: true"), 'the home page\u2019s navbar leads with Link');
-- assert.ok(index.indexOf("label: 'Link'") < index.indexOf("label: 'My Account'"), 'and Link comes first');
++ assert.ok(index.includes('"label":"Link","href":"https://openvibe.link/"'), 'the home page\u2019s navbar names Link');
++ assert.ok(index.indexOf('"label":"Link"') < index.indexOf('"label":"My Account"'), 'and Link comes before My Account');
const wallet = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'wallet', 'common.js'), 'utf8');
-- assert.ok(wallet.includes("label: 'Link', href: 'https://openvibe.link'"), 'the wallet pages\u2019 navbar does too');
++ assert.ok(wallet.includes('"label":"Link","href":"https://openvibe.link/"'), 'the wallet pages\u2019 navbar does too');
const home = fs.readFileSync(path.join(__dirname, '..', 'server', 'home', 'render.js'), 'utf8');
assert.ok(home.includes("'OpenVibe Link: your machines, connected', url: 'https://openvibe.link/'"), 'Network\u2019s home card names Link and links it');
assert.ok(home.includes("'Install Link', url: 'https://openvibe.link/install'"), 'and its install card does too');
ddiff --git a/test/page-links.test.js b/test/page-links.test.js
nnew file mode 100644
iindex 00000000..af79b4a0
---- /dev/null
++++ b/test/page-links.test.js
@@@ -0,0 +1,109 @@
++'use strict';
++// Every host Network serves gives the shared navbar one list, on every page (the owner, 2026-11-12: the phone
++// screenshot of openvibe.link showed "Pages on OpenVibe.Link: Home, My Account, Themes" — Network's fallback list,
++// not Link's pages). The home row is the site's own name; a door to another site is labelled with that site's name.
++// node test/page-links.test.js
++const assert = require('assert');
++const fs = require('fs');
++const http = require('http');
++const path = require('path');
++const { bootServer } = require('./helpers/boot-server');
++const pageLinks = require('../server/frame/page-links');
++const { ACCOUNT_HUB_PATHS } = require('../server/not-found');
++
++const ROOT = path.join(__dirname, '..');
++const read = (f) => fs.readFileSync(path.join(ROOT, f), 'utf8');
++
++/** The links array in a page's OpenVibeNavbar.init(...) call, or null when the init lives in a client script. */
++function initLinks(html) {
++ const at = html.indexOf('OpenVibeNavbar.init');
++ if (at < 0) return null;
++ const brace = html.indexOf('{', at);
++ if (brace < 0) return null;
++ let depth = 0, q = null, esc = false, end = -1;
++ for (let i = brace; i < html.length && end < 0; i += 1) {
++ const c = html[i];
++ if (q) { if (esc) esc = false; else if (c === '\\') esc = true; else if (c === q) q = null; continue; }
++ if (c === '"' || c === "'" || c === '`') { q = c; continue; }
++ if (c === '{') depth += 1; else if (c === '}') { depth -= 1; if (!depth) end = i; }
++ }
++ if (end < 0) return null;
++ const args = html.slice(brace, end + 1);
++ const m = /(['"]?)links\1\s*:\s*(\[)/.exec(args);
++ if (!m) return null;
++ const start = brace + m.index + m[0].lastIndexOf('[');
++ depth = 0; q = null; esc = false;
++ for (let i = start; i < html.length; i += 1) {
++ const c = html[i];
++ if (q) { if (esc) esc = false; else if (c === '\\') esc = true; else if (c === q) q = null; continue; }
++ if (c === '"' || c === "'" || c === '`') { q = c; continue; }
++ if (c === '[') depth += 1; else if (c === ']') { depth -= 1; if (!depth) return JSON.parse(html.slice(start, i + 1)); }
++ }
++ return null;
++}
++
++(async () => {
++ // ── The lists themselves: the home row names the site, and each row is unique. ──
++ for (const [host, list, home] of [['openvibe.network', pageLinks.NETWORK, 'Network'], ['openvibe.link', pageLinks.LINK, 'Link'], ['openvibe.my', pageLinks.MY, 'My Account']]) {
++ assert.strictEqual(pageLinks.forHost(host), list, `${host} has its own list`);
++ assert.ok(list.length >= 3, `${host} lists its pages`);
++ assert.strictEqual(list[0].label, home, `${host}'s home row is the site's name, never "Home"`);
++ assert.strictEqual(list[0].href, '/', `${host}'s home row is its front door`);
++ assert.strictEqual(new Set(list.map((l) => l.label)).size, list.length, `${host} has no repeated label`);
++ assert.ok(!list.some((l) => l.label === 'Home'), `${host} never calls its own home "Home"`);
++ }
++ assert.strictEqual(pageLinks.forHost('www.openvibe.link'), pageLinks.LINK, 'www uses the same list');
++ assert.strictEqual(pageLinks.forHost('openvibe.network'), pageLinks.NETWORK, 'every other host is Network\'s');
++
++ // The hub's client-routed sections are the router's paths, not links it would 404.
++ for (const p of pageLinks.MY.filter((l) => l.href.startsWith('/')).map((l) => l.href)) {
++ assert.ok(p === '/' || ACCOUNT_HUB_PATHS.includes(p), `openvibe.my knows ${p}`);
++ }
++
++ // Pages whose navbar is started from a client script carry the same list, byte for byte.
++ for (const [file, list] of [
++ ['public/index.html', pageLinks.NETWORK], ['public/verify-email.html', pageLinks.NETWORK],
++ ['public/js/admin/boot.js', pageLinks.NETWORK], ['public/js/wallet/common.js', pageLinks.NETWORK],
++ ['public/js/my/profile.js', pageLinks.NETWORK], ['public/js/my/profile.js', pageLinks.LINK], ['public/js/my/profile.js', pageLinks.MY],
++ ]) assert.ok(read(file).includes(JSON.stringify(list)), `${file} sends the ${list[0].label} list`);
++
++ const srv = await bootServer();
++ const port = new URL(srv.base).port;
++ const get = (p, host) => new Promise((resolve, reject) => {
++ const req = http.request({ host: '127.0.0.1', port, path: p, method: 'GET', headers: { host } }, (res) => {
++ let body = '';
++ res.on('data', (c) => { body += c; });
++ res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body }));
++ });
++ req.on('error', reject);
++ req.end();
++ });
++ try {
++ // Every server-rendered page of a host sends that host's list, and every same-host row answers (not 404).
++ const pages = {
++ 'openvibe.network': ['/', '/stats', '/updates', '/sites', '/bot', '/terms', '/privacy', '/dmca', '/no-such-page'],
++ 'openvibe.link': ['/', '/no-such-page'],
++ 'openvibe.my': ['/no-such-page'],
++ };
++ for (const [host, paths] of Object.entries(pages)) {
++ for (const p of paths) {
++ const r = await get(p, host);
++ assert.ok([200, 404].includes(r.status), `${host}${p} answered ${r.status}`);
++ const links = initLinks(r.body);
++ assert.deepStrictEqual(links, pageLinks.forHost(host), `${host}${p} sends ${pageLinks.forHost(host)[0].label}'s list`);
++ }
++ for (const l of pageLinks.forHost(host)) {
++ if (!l.href.startsWith('/')) continue;
++ const r = await get(l.href, host);
++ assert.notStrictEqual(r.status, 404, `${host}${l.href} is a route of this host`);
++ assert.ok(r.status < 500, `${host}${l.href} does not fail (${r.status})`);
++ }
++ }
++ console.log('page links: Network, Link and My send their own list on every page');
++ } catch (err) {
++ console.error(srv.logs());
++ throw err;
++ } finally {
++ await srv.stop();
++ }
++})().then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex c2f8135f..c15b7b70 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -12,7 +12,7 @@ const { spawn } = require('child_process');
const { measure, check, format } = require('openvibe-shared/perf-budget');
const BUDGETS = {
-- htmlRawKB: 218, // 218: measured 217 KB (2026-11-11: Directory joins the network list on contracts 0.406.0, and the popular block draws its five site marks as inline SVG, this page having no icon font); 210.5: measured 209.9 KB with the popular block designed (2026-11-11: ranks, marks, numbers and its CSS, +0.9 KB); 209.5: measured 209 KB with openvibe-shared 4.190.0 (2026-11-10: the site name's dropdown, the footer's online strip, the balances pill); 206: measured 205.8 KB with openvibe-shared 4.185.0 (the navbar's links in the first HTML since 4.184.0), 2026-11-10; 205.4: measured 205.4 KB with the front door's five most popular destinations (plan L43, 2026-11-09: Report's ranked list adds about 1.2 KB of plain list markup); 204.2: measured 204.2 KB with openvibe-contracts 0.373.0 (the Git service in the network list) on openvibe-shared 4.173.0, 2026-11-09; 204: measured 203.5 KB with the shared page head's kit CSS on the home (openvibe-shared 4.163.0 + page head 1369721), 2026-11-09; 198.8 with openvibe-contracts 0.348.0 (2026-11-08: Studio joins the menu; Studio and Locker get menu lines); 197.4 with Email open (contracts 0.330.0, 2026-11-07: the mail site in the network list); 196 with the Node's own item in the navbar (openvibe-shared 4.117.0, 2026-11-06: the Node link and the llms.txt section); 194.9 with the Organization naming every open site in sameAs (SEO-27, 2026-11-03); 192.4 with Tours and Shoes open (contracts 0.265.0, 2026-10-31); 189.9 with Casino open (contracts 0.264.0, 2026-10-31); 189 with the look-boot script inline above the stylesheets on every page (L42, 2026-10-31: +1 KB); 188 with the agent layer's rel=alternate/describedby link tags on every public page (SEO-1/SEO-2, 2026-10-29: +0.2 KB); 187 with Bench open (contracts 0.238.0, 2026-10-28); 186 with Cam open (contracts 0.237.0, 2026-10-28); 184.4 with Fun open (contracts 0.236.0, 2026-10-28); 182.7 with Vision and Exchange open (contracts 0.235.0, 2026-10-28); 179 with Meme open (contracts 0.234.0, 2026-10-28); 174.1 with Domains, Report and Design open (contracts 0.226-0.228, 2026-10-28); 168.1 the hero's orbit and rich cards for live streams, recordings and screenshots (2026-10-28: the owner asked for animated visuals and YouTube-style cards); 152.4 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 147.9 with the home's FAQ (six questions and their FAQPage JSON-LD), the numbers panel and the service map's group headings (2026-10-22); 141.2 with Tips and VIP open (contracts 0.200.0, 37 sites; the Opening next section is gone); 134 with Play (contracts 0.172.1); 128.3 with Events, AI and Search added (31 sites, contracts 0.135.0); 122.3 (shared 2.21.0: a real glyph for every product card, where ten drew the 'V'); was 110.6 with 33 live sites (contracts 0.119.0). The home page lists every site, so it grows with the network
++ htmlRawKB: 218.5, // 218.5: measured 218.1 KB with Network's own page list in the navbar (2026-11-12: the Network home row, and Link / My Account named as doors); 218: measured 217 KB (2026-11-11: Directory joins the network list on contracts 0.406.0, and the popular block draws its five site marks as inline SVG, this page having no icon font); 210.5: measured 209.9 KB with the popular block designed (2026-11-11: ranks, marks, numbers and its CSS, +0.9 KB); 209.5: measured 209 KB with openvibe-shared 4.190.0 (2026-11-10: the site name's dropdown, the footer's online strip, the balances pill); 206: measured 205.8 KB with openvibe-shared 4.185.0 (the navbar's links in the first HTML since 4.184.0), 2026-11-10; 205.4: measured 205.4 KB with the front door's five most popular destinations (plan L43, 2026-11-09: Report's ranked list adds about 1.2 KB of plain list markup); 204.2: measured 204.2 KB with openvibe-contracts 0.373.0 (the Git service in the network list) on openvibe-shared 4.173.0, 2026-11-09; 204: measured 203.5 KB with the shared page head's kit CSS on the home (openvibe-shared 4.163.0 + page head 1369721), 2026-11-09; 198.8 with openvibe-contracts 0.348.0 (2026-11-08: Studio joins the menu; Studio and Locker get menu lines); 197.4 with Email open (contracts 0.330.0, 2026-11-07: the mail site in the network list); 196 with the Node's own item in the navbar (openvibe-shared 4.117.0, 2026-11-06: the Node link and the llms.txt section); 194.9 with the Organization naming every open site in sameAs (SEO-27, 2026-11-03); 192.4 with Tours and Shoes open (contracts 0.265.0, 2026-10-31); 189.9 with Casino open (contracts 0.264.0, 2026-10-31); 189 with the look-boot script inline above the stylesheets on every page (L42, 2026-10-31: +1 KB); 188 with the agent layer's rel=alternate/describedby link tags on every public page (SEO-1/SEO-2, 2026-10-29: +0.2 KB); 187 with Bench open (contracts 0.238.0, 2026-10-28); 186 with Cam open (contracts 0.237.0, 2026-10-28); 184.4 with Fun open (contracts 0.236.0, 2026-10-28); 182.7 with Vision and Exchange open (contracts 0.235.0, 2026-10-28); 179 with Meme open (contracts 0.234.0, 2026-10-28); 174.1 with Domains, Report and Design open (contracts 0.226-0.228, 2026-10-28); 168.1 the hero's orbit and rich cards for live streams, recordings and screenshots (2026-10-28: the owner asked for animated visuals and YouTube-style cards); 152.4 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 147.9 with the home's FAQ (six questions and their FAQPage JSON-LD), the numbers panel and the service map's group headings (2026-10-22); 141.2 with Tips and VIP open (contracts 0.200.0, 37 sites; the Opening next section is gone); 134 with Play (contracts 0.172.1); 128.3 with Events, AI and Search added (31 sites, contracts 0.135.0); 122.3 (shared 2.21.0: a real glyph for every product card, where ten drew the 'V'); was 110.6 with 33 live sites (contracts 0.119.0). The home page lists every site, so it grows with the network
htmlBrotliKB: 39.5, // 39.5: measured 39.3 KB brotli with openvibe-shared 4.190.0 (2026-11-10: the site name's dropdown, the footer's online strip, the balances pill); 39: measured 38.6 KB brotli with the shared page head's kit CSS on the home (openvibe-shared 4.163.0 + page head 1369721), 2026-11-09; 38.1 with openvibe-contracts 0.348.0 (2026-11-08: Studio joins the menu; Studio and Locker get menu lines); 37.9 with openvibe-shared 4.129.0 (2026-11-07: the one menu (the burger opens the OpenVibe menu)); 37.8 with openvibe-shared 4.129.0 (2026-11-07: the one menu (the burger opens the OpenVibe menu)); 36.1 with the Node's own item in the navbar (openvibe-shared 4.117.0, 2026-11-06); 35.5 with Tours and Shoes open (2026-10-31); 35.1 with the look-boot script inline above the stylesheets on every page (L42, 2026-10-31: +0.6 KB); 34.2 with the lower half as one block of chips on phones (2026-10-30); 33.1 with Games granted its Bench match capabilities (BENCH-7, 2026-10-29: the home lists what each service may do); 32.0 with Fun, Vision and Exchange open (contracts 0.236.0, 2026-10-28); 31.2 with Domains, Report and Design open (2026-10-28); 30.1 with the Organization saying who OpenVibe is (description, other names, what it is not, 15 sites; 2026-10-28); 28.5 with the FAQ, numbers and groups (2026-10-22); 26.1 with Play; 25.1 (31 sites, contracts 0.135.0); 24.1 (shared 2.21.0, the same glyphs); 20.5 (contracts 0.116.0)
jsFiles: 8, // 7 with boost.js, was 6 (2026-10-13, plan T11 step 3: one more script on every boosted page; it loads route-transition.js on the first move only)
jsRawKB: 450.5, // 450.5 openvibe-shared v4.214.0 (2026-11-12: measured 450.2, raised by pin-bump from 449.5); 449.5 openvibe-shared v4.211.0 (2026-11-11: measured 449.4, raised by pin-bump from 448.5); 448.5 openvibe-shared v4.205.0 (2026-11-11: measured 448.4, raised by pin-bump from 445); 445 openvibe-shared v4.202.0 (2026-11-11: measured 444.6, raised by pin-bump from 444); 444 openvibe-shared v4.201.0 (2026-11-11: measured 443.7, raised by pin-bump from 440.5); 440.5 openvibe-shared v4.200.0 (2026-11-10: measured 440.2, raised by pin-bump from 440.0); 440.0: measured 439.6 KB raw / 106.7 KB brotli with openvibe-shared 4.197.0 (2026-11-10: the menu's row of places, the You tab's row, the Sign in mark, the tooltip); 438.5: measured 438 KB raw / 106.5 KB brotli with openvibe-shared 4.190.0 (2026-11-10: the site name's dropdown, the footer's online strip, the balances pill); 421: measured 420.9 KB raw / 102.2 KB brotli with openvibe-shared 4.186.0 (the mark's Working and Thinking states, the Dock and the menu as one sheet, bare controls), 2026-11-10; 420.5: measured 419.9 KB raw / 101.9 KB brotli with openvibe-shared 4.185.0 (Calm reduces, the solids and site marks, the You tab, the Dock out of the orb), 2026-11-10; 417: measured 416.4 KB raw / 100.9 KB brotli with openvibe-shared 4.179.0, 2026-11-09; 410: measured 409.2 KB raw / 99.4 KB brotli with openvibe-shared 4.173.0 (the wallet's cookie mode), 2026-11-09; 409: measured 408.1 KB raw / 99.0 KB brotli after merging main's Frame feed work (openvibe-shared 4.167.0), 2026-11-09; 406: measured 405.7 KB raw / 98.4 KB brotli with the shared page head's kit script on the home (openvibe-shared 4.163.0 + page head 1369721), 2026-11-09; 381.8: measured 375.8 KB raw / 91.1 KB brotli with openvibe-shared 4.159.0, plus room for 4.160.0–4.161.0 (the navbar's slots loader, the keyboard watcher, the pop motion), 2026-11-09; 373 with openvibe-shared 4.153.0 (2026-11-08: signed in to the network, signed in here, on phones too; a page can turn away a carried card); 372.3 with openvibe-shared 4.152.0 (2026-11-08: the menu marks the page you are on and opens without the keyboard on a phone, floating cards follow you across OpenVibe sites (overlay.js), and the navbar preconnects to the next OpenVibe site on intent); 369.6 with openvibe-shared 4.150.0 (2026-11-08: the bell opens the Dock on Activity, the Dock's phone header is in two rows, and the menu's orb is the close button with an open/close animation); 368.6 with openvibe-shared 4.149.0 (2026-11-08: the menu has tabs at the top; the phone's menu button is the orb of 4.148.0); 366.5 with openvibe-shared 4.118.0 (2026-11-06: the Locker re-auth link, the 44 px footer and TLD targets; main's 4.117.0 navbar item and the account-safety helper included); about 363.8 with both (2026-11-06: openvibe-shared 4.117.0's featured navbar item, +1.7 KB, and the account-safety helper, +8.5 KB; merged, to be confirmed by CI's measure); 353.6 with the account-safety helper on every page (2026-11-06: the one re-authentication path and the scheduled-deletion banner, +8.5 KB); 345.1 with openvibe-shared 4.114.0 (2026-11-06: the one Share sheet in the navbar (ov-share.js and ov-qr.js load on the first press); navbar.js gains its click handler); 343.9 with openvibe-shared 4.112.1 (2026-11-06: theme-loader.js (4.111.1) and the footer/appbar phone CSS (4.112.1)); 342.3 with openvibe-shared 4.109.2 (2026-11-03: OpenVibe's own select, the navbar loads ov-forms); 339.6 with openvibe-shared 4.107.0 (2026-11-03: open.<tld> sites are named for their domain; the brand knows its front door); 337.8 with openvibe-shared 4.103.0 (2026-11-02: the puzzle glyph, two-line sidebar rows, boost lets parsed pages go, eight solids) (2026-11-02); 334.1 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 333.2 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 330.1 with openvibe-shared 4.52.0 (from 4.47.2): the seasonal fall, rich embeds, the Dock and its Chats tab (2026-10-30); 323.2 with openvibe-shared 4.47.2 (the comfort settings in theme-loader 4.45, touch targets 4.46, the grouped updates log 4.47.2); 313.7 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 306.4 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 298.9 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 293.1 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 279.1 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 269.3 with shared 4.11.0's seasonal navbar (Pumpkin Night and the season badge, +2.6); 249.8 with boost.js (13.1); 228.3