openvibers/openvibe.shared Commits

4.137.0: every sibling script by its content hash (releases reach phones at once); one menu button on a phone; the menu warms ahead (#325)

OP OpenVibers committed 6 days ago · 1c9fab722d · 16 files, +115 −34

* 4.136.0: every sibling script by its content hash (releases reach phones at once, not a day later); one menu button on a phone

* 4.136.0: the menu warms at idle and on intent; tests follow one menu button and hashed sibling URLs

* Merge main (4.136.0 is the one Computer in the Dock): the hashed siblings and one menu button become 4.137.0

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 496fa93..a866456 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,25 @@
# Changelog
++## 4.137.0 — 2026-11-07
++
++- **Releases reach phones at once.** The bar loaded the menu (`search.js`), the Dock, presence, panels, notifications,
++ history, the site switcher, the app bar, the release watcher, the icons and the sign-in client without a version.
++ - They went out as `max-age=300, stale-while-revalidate=86400`, so a phone could keep the old menu for up to a day
++ after a release. The owner still saw the menu without feeds hours after 4.135.0.
++ - Every one now loads by its content hash (`?v=`), which sites serve `immutable`: each release is new URLs, and no
++ edge or browser has an old copy to give.
++ - The Dock's add-ons (Vibe, the Action Center, the timeline, the agent view, the timer) and the notifications'
++ realtime file go the same way.
++ - `scripts/sibling-revs.js` writes all three tables. `test/sibling-revs.test.js` fails on any sibling loaded
++ without its hash.
++- **One menu button on a phone** (the owner, 11-07: "these buttons do the same shit"). Where the burger shows (860 px
++ and narrower, on a bar with page links), the orb that opened the same menu steps aside. A bar without page links
++ keeps its orb, its only way in.
++- **One Close.** On a phone the menu's in-box "Close" steps aside for the burger's ✕.
++- **The menu opens at once.** search.js is prefetched at idle once the page settles (not on Save-Data or 2G), and as
++ soon as a finger or pointer reaches the burger or the orb. By its hashed URL it is cached until the next release.
++- Budgets: navbar.js 44 KB brotli (43.5 with the hashes, which do not compress, and the warm-up), dock.js 16.0 KB.
++
## 4.136.0 — 2026-11-07
- **One Computer in the Dock** (plan 2026-11-07: "Surf, Computer and Actor are one experience"): a timeline step
ddiff --git a/README.md b/README.md
iindex cd4e2d9..d02e44c 100644
---- a/README.md
++++ b/README.md
@@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
--**Status:** alpha, v4.136.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.137.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex 41329dc..6f17603 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,13 +4,14 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.136.0",
++ "version": "4.137.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"contracts": "openvibe-contracts v0.312.0 (devDependency: the release manifest contract)",
"sdk": "openvibe-sdk v0.94.0",
"features": [
++ "every script the bar, the Dock and the notifications load goes by its content hash (?v=), so a release reaches every open page at once instead of up to a day later; one menu button on a phone (4.137.0)",
"the Dock's Agent panel reads the timeline again (the token is resolved when a call is made), and the Dock can open at a step: OpenVibeNavbar.openDock('agent', { task }) marks it and brings it into view (4.134.0)",
"the OpenVibe Frame: navbar, footer, panels, account switcher, notification UI with realtime",
"the launcher beside the brand lists every site of the network, in groups; Ask Actor and the Vibes chip in the account menu",
ddiff --git a/dock.js b/dock.js
iindex f700949..e4ee1a6 100644
---- a/dock.js
++++ b/dock.js
@@@ -98,12 +98,14 @@
} catch { /* offline, or no route yet: the copy stands and the next change tries again */ }
}
++ // Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.
++ const DOCK_REV = { 'dock-vibe.js': 'fa6d48d86c71', 'action-center.js': '125c20e01289', 'dock-timeline.js': 'ad21e7bb4306', 'dock-agent.js': '49938c0551cc', 'dock-timer.js': 'f5d9fee28824' };
function load(src, test) {
return new Promise((ok) => {
if (test()) return ok(true);
const id = `ov-dock-${src.replace(/\W/g, '')}`;
let sc = document.getElementById(id);
-- if (!sc) { sc = document.createElement('script'); sc.id = id; sc.src = sibling(src); sc.async = true; document.head.appendChild(sc); }
++ if (!sc) { sc = document.createElement('script'); sc.id = id; sc.src = sibling(src) + (DOCK_REV[src] ? `?v=${DOCK_REV[src]}` : ''); sc.async = true; document.head.appendChild(sc); }
sc.addEventListener('load', () => ok(test()));
sc.addEventListener('error', () => ok(false));
});
ddiff --git a/navbar.js b/navbar.js
iindex 0b9d55f..9b44acc 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -172,7 +172,7 @@
.openvibe-navbar-right.has-chips-all .ovnav-chip[data-chip-id] { display: none; }
.openvibe-navbar-right.has-chips-all .ovnav-chip--all { display: inline-flex; }
}
-- @media (max-width: 860px) { .openvibe-navbar .ovnav-burger, .openvibe-navbar .ovnav-featured-phone { display: inline-flex; } .openvibe-navbar .openvibe-navbar-links, .openvibe-navbar .openvibe-navbar-spacer { display: none; } .openvibe-navbar .openvibe-navbar-right { margin-left: auto; } .openvibe-navbar .ovnav-chip .ovnav-ic + .ovnav-chip-v:empty { display: none; } }
++ @media (max-width: 860px) { .openvibe-navbar .ovnav-burger, .openvibe-navbar .ovnav-featured-phone { display: inline-flex; } .openvibe-navbar:has(.ovnav-burger) .ovnav-orb { display: none; } .openvibe-navbar .openvibe-navbar-links, .openvibe-navbar .openvibe-navbar-spacer { display: none; } .openvibe-navbar .openvibe-navbar-right { margin-left: auto; } .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; } }
/* A narrow phone never draws the site's name over the bar's buttons (owner, 2026-10-28: on openvibe.games, signed
in, the name ran under the launcher and the magnifier). The name gives way with an ellipsis, and up to 440px
@@@ -513,7 +513,7 @@
const NAV_ICONS_REV = 'd9a4febbbad7'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
// END generated
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
-- const SIBLING_REV = { 'ov-mark.js': '1e91c9c0f347', 'ov-glyphs.js': '715972edd968', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2a6c53a7b176', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '068ce1b1c4d5', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': '7d4affbb5093', 'ov-share.css': 'cbd18e6ce991' };
++ const SIBLING_REV = { 'ov-mark.js': '1e91c9c0f347', 'ov-glyphs.js': '715972edd968', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2a6c53a7b176', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '068ce1b1c4d5', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': '7d4affbb5093', 'ov-share.css': 'cbd18e6ce991', 'search.js': '08f0452e6a79', 'dock.js': 'e13066db0aac', 'presence.js': '1b675c37d888', 'panels.js': '7d8267ca8035', 'notification-ui.js': '3ceb5266e7b8', 'history.js': '1e6598f78ba4', 'site-switcher.js': '292bea111aa8', 'appbar.js': '1edf7532f228', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'ba3981246bc0', 'sso-client.js': '52439265b980' };
// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest
// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
@@@ -742,7 +742,7 @@
const rw = _config.releaseWatch;
if (rw === false || root.OVRelease || document.querySelector('script[src*="release-watch.js"]')) return;
if (rw && typeof rw === 'object' && !root.OVReleaseConfig) root.OVReleaseConfig = rw;
-- const sc = document.createElement('script'); sc.src = sibling('release-watch.js'); sc.async = true;
++ const sc = document.createElement('script'); sc.src = siblingV('release-watch.js'); sc.async = true;
try { sc.fetchPriority = 'low'; } catch { /* */ }
document.head.appendChild(sc);
} catch { /* optional */ }
@@@ -1002,7 +1002,7 @@
function loadOvIcons(scope) {
if (root.OpenVibeIcons) { if (scope && root.OpenVibeIcons.mount) root.OpenVibeIcons.mount(scope); return; }
if (document.getElementById('ov-icons-loader')) return;
-- try { const sc = document.createElement('script'); sc.id = 'ov-icons-loader'; sc.src = sibling('ov-icons.js'); sc.async = true; document.head.appendChild(sc); } catch { /* */ }
++ try { const sc = document.createElement('script'); sc.id = 'ov-icons-loader'; sc.src = siblingV('ov-icons.js'); sc.async = true; document.head.appendChild(sc); } catch { /* */ }
}
function queueIcons() {
if (_iconsQueued || root.OpenVibeIcons) return;
@@@ -1038,7 +1038,11 @@
function bindLinks(nav, links) {
const byId = new Map(flatLinks(links).filter(l => l.id).map(l => [l.id, l]));
const drawer = nav.querySelector('#openvibe-drawer'), burger = nav.querySelector('#openvibe-burger');
++ // The menu's own Close is the burger's ✕ on a phone (search.js hides its in-box Close then).
++ try { document.documentElement.classList.toggle('ovnav-has-burger', !!burger); } catch { /* */ }
const closeDrawer = () => { if (drawer) drawer.classList.remove('open'); if (burger) burger.setAttribute('aria-expanded', 'false'); };
++ // A finger or pointer on its way to the menu fetches it now (warmMenu above).
++ ['pointerenter', 'pointerdown', 'focusin'].forEach((t) => nav.addEventListener(t, (e) => { if (e.target && e.target.closest && e.target.closest('#openvibe-burger, #openvibe-search-btn')) warmMenu(); }, { passive: true, capture: t === 'pointerenter' }));
nav.addEventListener('click', (e) => {
const a = e.target.closest && e.target.closest('a.ovnav-link, .ovnav-net'); if (!a || !nav.contains(a)) return;
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
@@@ -1094,7 +1098,7 @@
if (root.OpenVibePanels) return root.OpenVibePanels.register(opts);
_panelQueue.push(opts);
if (document.getElementById('ov-panels-loader')) return;
-- const sc = document.createElement('script'); sc.id = 'ov-panels-loader'; sc.src = sibling('panels.js'); sc.async = true;
++ const sc = document.createElement('script'); sc.id = 'ov-panels-loader'; sc.src = siblingV('panels.js'); sc.async = true;
sc.onload = () => { while (_panelQueue.length) root.OpenVibePanels && root.OpenVibePanels.register(_panelQueue.shift()); };
document.head.appendChild(sc);
}
@@@ -1206,7 +1210,7 @@
};
if (root.OpenVibeNotifications) return go();
if (document.getElementById('ov-notify-loader')) return;
-- const sc = document.createElement('script'); sc.id = 'ov-notify-loader'; sc.src = sibling('notification-ui.js'); sc.async = true; sc.onload = () => setTimeout(go, 0); document.head.appendChild(sc);
++ const sc = document.createElement('script'); sc.id = 'ov-notify-loader'; sc.src = siblingV('notification-ui.js'); sc.async = true; sc.onload = () => setTimeout(go, 0); document.head.appendChild(sc);
}
// ── The Dock (Dock plan K-D1): your agents and activity at the screen's edge. dock.js draws its pill and the Dock;
@@@ -1218,7 +1222,7 @@
function loadDock(then) {
if (root.OpenVibeDock) return then();
let sc = document.getElementById('ov-dock-loader');
-- if (!sc) { sc = document.createElement('script'); sc.id = 'ov-dock-loader'; sc.src = sibling('dock.js'); sc.async = true; document.head.appendChild(sc); }
++ if (!sc) { sc = document.createElement('script'); sc.id = 'ov-dock-loader'; sc.src = siblingV('dock.js'); sc.async = true; document.head.appendChild(sc); }
sc.addEventListener('load', () => setTimeout(() => { try { then(); } catch { /* */ } }, 0));
}
function mountDock(u) {
@@@ -1233,7 +1237,7 @@
const go = () => root.OpenVibePresence && root.OpenVibePresence.start({ token: () => _config.token, apiBase: dockBase(), user: u });
if (root.OpenVibePresence) return go();
if (document.getElementById('ov-presence-loader')) return;
-- const sc = document.createElement('script'); sc.id = 'ov-presence-loader'; sc.src = sibling('presence.js'); sc.async = true; sc.onload = go; document.head.appendChild(sc);
++ const sc = document.createElement('script'); sc.id = 'ov-presence-loader'; sc.src = siblingV('presence.js'); sc.async = true; sc.onload = go; document.head.appendChild(sc);
}
// The Dock on a tab ('agent' | 'activity'); opts.task marks that step and brings it into view.
function openDock(tab, opts) { loadDock(() => { root.OpenVibeDock.mount(dockOpts()); root.OpenVibeDock.open(Object.assign({ tab }, opts || {})); }); }
@@@ -1627,7 +1631,7 @@
if (root.OpenVibeSSO) return Promise.resolve(root.OpenVibeSSO);
if (_ssoClientLoading) return _ssoClientLoading;
_ssoClientLoading = new Promise((resolve) => {
-- const sc = document.createElement('script'); sc.async = true; sc.src = sibling('sso-client.js');
++ const sc = document.createElement('script'); sc.async = true; sc.src = siblingV('sso-client.js');
sc.onload = () => resolve(root.OpenVibeSSO || null); sc.onerror = () => resolve(null);
document.head.appendChild(sc);
});
@@@ -1706,7 +1710,7 @@
if (H && typeof H.record === 'function') { H.record(rec, { token: _config.token, apiBase: _config.apiBase }); return; }
if (!document.getElementById('ov-history-loader')) {
const sc = document.createElement('script'); sc.id = 'ov-history-loader'; sc.async = true;
-- sc.src = sibling('history.js');
++ sc.src = siblingV('history.js');
sc.onload = () => { try { root.OpenVibeHistory.record(rec, { token: _config.token, apiBase: _config.apiBase }); } catch { /* */ } };
document.head.appendChild(sc);
} else {
@@@ -2073,6 +2077,20 @@
// Search (search.js, loaded on first use). _config.search: false, or { owner, label, page }.
let _searchLoading = false;
++ // Warm the menu (4.137.0): search.js by its hashed URL is cached for good, so fetching it early costs one download per
++ // release. At idle after the page settles (not on Save-Data or 2G), and at once when a finger or pointer reaches the
++ // burger or the orb; the first open then has nothing to wait for.
++ let _menuWarm = false;
++ function warmMenu() {
++ if (_menuWarm || root.OpenVibeSearch || typeof document === 'undefined') return;
++ _menuWarm = true;
++ try { const l = document.createElement('link'); l.rel = 'prefetch'; l.as = 'script'; l.href = siblingV('search.js'); document.head.appendChild(l); } catch { /* */ }
++ }
++ function warmMenuLater() {
++ try { const c = root.navigator && root.navigator.connection; if (c && (c.saveData || /(^|-)2g$/.test(c.effectiveType || ''))) return; } catch { /* */ }
++ const go = () => warmMenu();
++ if (typeof root.requestIdleCallback === 'function') root.requestIdleCallback(go, { timeout: 8000 }); else setTimeout(go, 6000);
++ }
/** The OpenVibe menu (search.js, loaded on first use): search, every site and every tool. tab: 'you' | 'sites' | 'tools'. */
function openSearch(q, tab, fromEl) {
const orb = _navEl && _navEl.querySelector('#openvibe-search-btn');
@@@ -2098,7 +2116,7 @@
if (_searchLoading) return;
_searchLoading = true;
if (orb) orb.classList.add('is-busy');
-- try { const sc = document.createElement('script'); sc.src = sibling('search.js'); sc.async = true; sc.onload = go; sc.onerror = () => { _searchLoading = false; if (orb) orb.classList.remove('is-busy'); }; document.head.appendChild(sc); } catch { _searchLoading = false; }
++ try { const sc = document.createElement('script'); sc.src = siblingV('search.js'); sc.async = true; sc.onload = go; sc.onerror = () => { _searchLoading = false; if (orb) orb.classList.remove('is-busy'); }; document.head.appendChild(sc); } catch { _searchLoading = false; }
}
const menuOpen = () => !!(typeof document !== 'undefined' && document.querySelector && document.querySelector('.ovs.is-open'));
@@@ -2125,7 +2143,7 @@
if (root.OpenVibeSiteSwitcher) return go();
if (_switcherLoading) return;
_switcherLoading = true;
-- try { const sc = document.createElement('script'); sc.src = sibling('site-switcher.js'); sc.async = true; sc.onload = go; sc.onerror = () => { _switcherLoading = false; }; document.head.appendChild(sc); } catch { _switcherLoading = false; }
++ try { const sc = document.createElement('script'); sc.src = siblingV('site-switcher.js'); sc.async = true; sc.onload = go; sc.onerror = () => { _switcherLoading = false; }; document.head.appendChild(sc); } catch { _switcherLoading = false; }
}
// A signed-in person's visit moves this site to the front of their Recent, at most once in 30 minutes, and never
// under Global Privacy Control or Do Not Track (as the page-view beacon).
@@@ -2175,7 +2193,7 @@
if (ab) { ab.render({ config: _config, links: rawLinks(), activePage: _activePage, nav: _navEl, navIcon, escapeAttr, escapeHtml, flatLinks }); return; }
if (!_config.appBar || _appBarLoading) return;
_appBarLoading = true;
-- try { const sc = document.createElement('script'); sc.src = sibling('appbar.js'); sc.async = true; sc.onload = () => renderAppBar(); document.head.appendChild(sc); } catch { /* no bar */ }
++ try { const sc = document.createElement('script'); sc.src = siblingV('appbar.js'); sc.async = true; sc.onload = () => renderAppBar(); document.head.appendChild(sc); } catch { /* no bar */ }
}
// The bar's at-the-top state (ovnav-top, see injectStyles), on every render and scroll. One window listener pair for
@@@ -2299,6 +2317,7 @@
const el = render();
upgradeIconsWhenFontsReady();
if (typeof document !== 'undefined') setTimeout(loadReleaseWatch, 4000);
++ if (typeof document !== 'undefined') setTimeout(warmMenuLater, 3000);
// A page that passes a user keeps control; otherwise the user comes from the shared SSO state, then a re-render.
// auth: 'external' — the site signs people in itself (Live) and tells us with setUser().
if (_config.auth === 'external') { if (_config.user) { recordHistory(); enableHandoff(); } }
ddiff --git a/notification-ui.js b/notification-ui.js
iindex 99fdf4f..5b670e8 100644
---- a/notification-ui.js
++++ b/notification-ui.js
@@@ -1190,6 +1190,8 @@
}
function stopPolling() { if (_pollTimer) clearInterval(_pollTimer); _pollTimer = null; }
++ // notification-live.js's content hash (scripts/sibling-revs.js writes it), so a release gives it a new URL.
++ const NOTIFY_REV = { 'notification-live.js': 'cd2d64cc77d0' };
// ── Realtime (notification-live.js) ─────────────────────
function liveOpen() { return !!(_live && _live.state().state === 'open'); }
/** One re-read for a burst of events (a go-live fan-out, a replay after a reconnect); a gap re-reads open lists too. */
@@@ -1207,7 +1209,7 @@
function liveSrc() {
if (_config.liveSrc) return _config.liveSrc;
const m = /^(https?:\/\/[^?#]*\/)notification-ui\.js(?:[?#]|$)/.exec(SELF_SRC);
-- return `${m ? m[1] : 'https://openvibe.network/shared/'}notification-live.js`;
++ return `${m ? m[1] : 'https://openvibe.network/shared/'}notification-live.js${NOTIFY_REV['notification-live.js'] ? `?v=${NOTIFY_REV['notification-live.js']}` : ''}`;
}
let _liveLoading = null;
function loadLive() {
ddiff --git a/package.json b/package.json
iindex 30265f0..2bc0c09 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.136.0",
++ "version": "4.137.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
ddiff --git a/scripts/sibling-revs.js b/scripts/sibling-revs.js
iindex 98d024e..93cd3a4 100644
---- a/scripts/sibling-revs.js
++++ b/scripts/sibling-revs.js
@@@ -9,11 +9,29 @@ const path = require('path');
const { hashAsset } = require('../assets');
const ROOT = path.join(__dirname, '..');
--const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov-fx.js', 'ov-labels.js', 'ov-forms.js', 'ov-forms.css', 'ov-share.js', 'ov-share.css'];
++// 4.137.0: every file the navbar loads from beside itself, not ten of them. search.js, dock.js, presence.js and the rest
++// went out without ?v=, so a release reached phones up to a day late (max-age=300, stale-while-revalidate=86400): the
++// owner still saw the old menu hours after the new one shipped (11-07).
++const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov-fx.js', 'ov-labels.js', 'ov-forms.js', 'ov-forms.css', 'ov-share.js', 'ov-share.css',
++ 'search.js', 'dock.js', 'presence.js', 'panels.js', 'notification-ui.js', 'history.js', 'site-switcher.js', 'appbar.js', 'release-watch.js', 'ov-icons.js', 'sso-client.js'];
++// The files dock.js and notification-ui.js load from beside themselves: their own tables, in their own files.
++const OWN = { 'dock.js': { name: 'DOCK_REV', files: ['dock-vibe.js', 'action-center.js', 'dock-timeline.js', 'dock-agent.js', 'dock-timer.js'] },
++ 'notification-ui.js': { name: 'NOTIFY_REV', files: ['notification-live.js'] } };
++const table = (name, files) => ` const ${name} = { ${files.map((f) => `'${f}': '${hashAsset(fs.readFileSync(path.join(ROOT, f)))}'`).join(', ')} };`;
++function writeOwn() {
++ for (const [file, { name, files }] of Object.entries(OWN)) {
++ const p = path.join(ROOT, file);
++ const src = fs.readFileSync(p, 'utf8');
++ const re = new RegExp(`^ {4}const ${name} = \\{[^\\n]*\\};$`, 'm');
++ if (!re.test(src)) throw new Error(`${file} has no ${name} line to update`);
++ fs.writeFileSync(p, src.replace(re, table(name, files)));
++ }
++}
const revs = () => Object.fromEntries(FILES.map((f) => [f, hashAsset(fs.readFileSync(path.join(ROOT, f)))]));
const line = (r) => ` const SIBLING_REV = { ${FILES.map((f) => `'${f}': '${r[f]}'`).join(', ')} };`;
if (require.main === module) {
++ writeOwn();
const p = path.join(ROOT, 'navbar.js');
const src = fs.readFileSync(p, 'utf8');
const next = src.replace(/^ {4}const SIBLING_REV = \{[^\n]*\};$/m, line(revs()));
@@@ -21,4 +39,4 @@ if (require.main === module) {
fs.writeFileSync(p, next);
console.log(line(revs()).trim());
}
--module.exports = { FILES, revs, line };
++module.exports = { FILES, OWN, revs, line, table };
ddiff --git a/search.js b/search.js
iindex f183c40..6c55e90 100644
---- a/search.js
++++ b/search.js
@@@ -176,6 +176,7 @@
.ovs-ghost { position: absolute; inset: 0; display: flex; align-items: center; white-space: pre; overflow: hidden; pointer-events: none; color: var(--text-muted, #93a0b4); opacity: .75; }
.ovs-ghost b { color: transparent; font-weight: inherit; }
.ovs-x { flex: none; height: 36px; padding: 0 12px; border: 0; border-radius: 10px; background: transparent; color: var(--text-muted, #93a0b4); font: inherit; font-size: 13px; cursor: pointer; }
++@media (max-width: 860px) { html.ovnav-has-burger .ovs-x { display: none; } }
.ovs-tabs { display: flex; gap: 4px; padding: 0 12px 8px; border-bottom: 1px solid var(--border, rgba(255,255,255,.1)); }
.ovs-tabs[hidden] { display: none; }
.ovs-tab { flex: none; height: 32px; padding: 0 14px; border: 0; border-radius: 999px; background: transparent; color: var(--text-secondary, #a8b3c4); font: inherit; font-size: 13.5px; font-weight: 650; cursor: pointer; }
ddiff --git a/test/dock-size.test.js b/test/dock-size.test.js
iindex 9c6bf36..b736680 100644
---- a/test/dock-size.test.js
++++ b/test/dock-size.test.js
@@@ -19,7 +19,7 @@ const DOCK_PATH = path.join(ROOT, 'dock.js');
// (the embed, Curtain now, End, the still) is dock-agent.js, 3.9 KB brotli, loaded the first time the Agents tab opens.
// The timer itself (menu, countdown, chime, focus rounds) is dock-timer.js (3.7 KB brotli), loaded only while a timer
// runs or its menu opens; the header's tab rail and narrow-width rules came with it.
--const DOCK_BROTLI_BUDGET = 16077; // 15.70 KB: 15.55 KB with Ask your agent (4.135.0, 2026-11-07) + headroom; was 15.50 KB (15435 after 4.120.0)
++const DOCK_BROTLI_BUDGET = 16384; // 16.0 KB: 15.74 KB with its siblings' ?v= content hashes (4.137.0, 2026-11-07) + headroom; 15.70 KB: 15.55 KB with Ask your agent (4.135.0, 2026-11-07) + headroom; was 15.50 KB (15435 after 4.120.0)
const SIBLINGS = ['action-center.js', 'notification-ui.js', 'notification-live.js', 'ov-mark.js', 'dock-vibe.js', 'dock-timer.js', 'dock-agent.js'];
// ── the budget ──────────────────────────────────────────────
ddiff --git a/test/nav-icons.test.js b/test/nav-icons.test.js
iindex 37679d4..6f66aa7 100644
---- a/test/nav-icons.test.js
++++ b/test/nav-icons.test.js
@@@ -13,7 +13,7 @@ const { brotli } = require('../scripts/size-report');
const ROOT = path.join(__dirname, '..');
const NAVBAR_PATH = path.join(ROOT, 'navbar.js');
const ICONS = require('../nav-icons');
--const NAVBAR_BROTLI_BUDGET = 43 * 1024; // 42.8 KB keeping the menu's Frame fields (4.132.0, 2026-11-07); 42.6 KB with Messages and the balances on every site (4.130.0, 2026-11-07); 42.1 KB with the featured link's phone button (4.119.0, 2026-11-06); 41.4 KB with the page-move code (4.70.0: busy(), the mark on plain links, prefetch rules on unboosted sites); was 41 KB with the Dock's loader (4.51.0)
++const NAVBAR_BROTLI_BUDGET = 44 * 1024; // 43.5 KB with the menu warmed at idle and on intent (4.137.0); 43.2 KB with every sibling's ?v= content hash (4.137.0, 2026-11-07: the owner's menu was a day stale without them); 42.8 KB keeping the menu's Frame fields (4.132.0, 2026-11-07); 42.6 KB with Messages and the balances on every site (4.130.0, 2026-11-07); 42.1 KB with the featured link's phone button (4.119.0, 2026-11-06); 41.4 KB with the page-move code (4.70.0: busy(), the mark on plain links, prefetch rules on unboosted sites); was 41 KB with the Dock's loader (4.51.0)
// ── size budget and generated block ──────────────────────────
{
ddiff --git a/test/navbar-appbar.test.js b/test/navbar-appbar.test.js
iindex ed84b7f..5c4db90 100644
---- a/test/navbar-appbar.test.js
++++ b/test/navbar-appbar.test.js
@@@ -61,8 +61,9 @@ const LINKS = [
const { navbar, head } = load('/rooms', { preload: false });
navbar.init({ service: 'chat', links: LINKS, appBar: true });
navbar.setActive('rooms');
-- const scripts = head.children.filter((c) => c.tag === 'script' && /appbar\.js$/.test(c.src || ''));
++ const scripts = head.children.filter((c) => c.tag === 'script' && /appbar\.js(\?v=[0-9a-f]{12})?$/.test(c.src || ''));
assert.strictEqual(scripts.length, 1, 'fetched once');
++ assert.match(scripts[0].src, /appbar\.js\?v=[0-9a-f]{12}$/, 'by its content hash (4.137.0), so a release is a new URL');
assert.strictEqual(typeof scripts[0].onload, 'function');
}
{ // appBar: true → the first four links with icons and More (six links); the open page marked, under its path too.
ddiff --git a/test/navbar-phone-fit-chrome.test.js b/test/navbar-phone-fit-chrome.test.js
iindex 1528991..4eb2fc5 100644
---- a/test/navbar-phone-fit-chrome.test.js
++++ b/test/navbar-phone-fit-chrome.test.js
@@@ -72,8 +72,10 @@ const MEASURE = `(() => {
assert.ok(last[1][1] <= width, `${at}: ${last[0]} runs off the screen`);
if (width >= 360) assert.ok(!v.clipped && /Games/.test(v.visibleText), `${at}: the site's word reads whole (${v.visibleText})`);
if (who === 'in') assert.strictEqual(v.minis, 2, `${at}: the folded balances show both, OpenCoins and Vibes (4.25.0)`);
-- const orb = v.parts.find(([k]) => k === 'search');
-- assert.ok(orb && orb[1][0] < width / 2, `${at}: the OpenVibe menu's orb sits by the brand, on the left (4.25.0)`);
++ // One menu button on a phone (4.137.0, the owner: "these buttons do the same shit"): the burger opens the
++ // OpenVibe menu, and the orb that opened the same menu steps aside.
++ assert.ok(v.parts.some(([k]) => k === 'menu'), `${at}: the burger is the menu button`);
++ assert.ok(!v.parts.some(([k]) => k === 'search'), `${at}: no second button for the same menu (4.137.0)`);
assert.ok(!v.parts.some(([k]) => k === 'launcher'), `${at}: no launcher button: the magnifier opens the one menu (4.23.0)`);
await p.close();
}
ddiff --git a/test/notification-live.test.js b/test/notification-live.test.js
iindex d0dbf28..212e594 100644
---- a/test/notification-live.test.js
++++ b/test/notification-live.test.js
@@@ -95,8 +95,8 @@ function page({ url = 'https://openvibe.live/', eventSource = true, random = 0.5
if (src) {
out.scripts.push(src);
setImmediate(() => {
-- if (/action-center\.js$/.test(src)) { vm.runInContext(AC_SRC, ctx, { filename: 'action-center.js' }); if (node.onload) node.onload(new ctx.Event('load')); return; }
-- if (!/notification-live\.js$/.test(src)) { if (node.onerror) node.onerror(new ctx.Event('error')); return; }
++ if (/action-center\.js(\?|$)/.test(src)) { vm.runInContext(AC_SRC, ctx, { filename: 'action-center.js' }); if (node.onload) node.onload(new ctx.Event('load')); return; }
++ if (!/notification-live\.js(\?|$)/.test(src)) { if (node.onerror) node.onerror(new ctx.Event('error')); return; }
vm.runInContext(LIVE_SRC, ctx, { filename: 'notification-live.js' });
if (node.onload) node.onload(new ctx.Event('load'));
});
@@@ -262,7 +262,8 @@ const created = (subject, over = {}) => ({
N.init({ token: 'jwt-alice', apiBase: API, realtime: true });
const bell = N.createBell(p.document.body);
await p.settle();
-- assert.deepStrictEqual(p.out.scripts, ['https://openvibe.live/shared/notification-live.js'], 'loaded from beside notification-ui.js');
++ assert.strictEqual(p.out.scripts.length, 1, 'one script');
++ assert.match(p.out.scripts[0], /^https:\/\/openvibe\.live\/shared\/notification-live\.js\?v=[0-9a-f]{12}$/, 'loaded from beside notification-ui.js, by its content hash (4.137.0)');
assert.strictEqual(p.tickets().length, 1);
assert.strictEqual(p.tickets()[0].headers.Authorization, 'Bearer jwt-alice');
assert.strictEqual(p.tickets()[0].credentials, 'omit', 'cross-site: the token, never cookies');
@@@ -360,7 +361,8 @@ const created = (subject, over = {}) => ({
p.run(UI_SRC, 'notification-ui.js', 'https://openvibe.network/shared/notification-ui.js');
p.ctx.OpenVibeNotifications.init({ apiBase: API, realtime: true });
await p.settle();
-- assert.deepStrictEqual(p.out.scripts, ['https://openvibe.network/shared/notification-live.js']);
++ assert.strictEqual(p.out.scripts.length, 1);
++ assert.match(p.out.scripts[0], /^https:\/\/openvibe\.network\/shared\/notification-live\.js\?v=[0-9a-f]{12}$/, 'by its content hash (4.137.0)');
assert.strictEqual(p.tickets()[0].credentials, 'include', 'on openvibe.network the session cookie asks for the ticket');
assert.strictEqual(p.tickets()[0].headers.Authorization, undefined);
}
@@@ -485,7 +487,8 @@ const created = (subject, over = {}) => ({
// bell's rich list). It loads only after sign-in, when the bell mounts, never on first paint; measured 19.16 KB.
// 20 → 24 KB on 2026-10-28 (4.30.0, the bell center: tabs with counts, the Unread filter, thumbnails, swipe to read or
// clear, the phone sheet; quiet hours); measured 22.56 KB.
-- assert.ok(size('notification-ui.js') <= 24 * 1024, `notification-ui.js is ${(size('notification-ui.js') / 1024).toFixed(2)} KB brotli, budget 24 KB`);
++ // 24 → 24.5 KB on 2026-11-07 (4.137.0): notification-live.js by its content hash (24.04 KB measured).
++ assert.ok(size('notification-ui.js') <= 24.5 * 1024, `notification-ui.js is ${(size('notification-ui.js') / 1024).toFixed(2)} KB brotli, budget 24.5 KB`);
console.log('notification live: all checks passed');
})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/sibling-revs.test.js b/test/sibling-revs.test.js
iindex 1558e80..cb6a496 100644
---- a/test/sibling-revs.test.js
++++ b/test/sibling-revs.test.js
@@@ -5,7 +5,7 @@
const assert = require('assert');
const fs = require('fs');
const path = require('path');
--const { revs, line } = require('../scripts/sibling-revs');
++const { revs, line, OWN, table } = require('../scripts/sibling-revs');
const src = fs.readFileSync(path.join(__dirname, '..', 'navbar.js'), 'utf8');
assert.ok(src.includes(line(revs())), 'SIBLING_REV is current: run node scripts/sibling-revs.js');
@@@ -14,4 +14,13 @@ assert.ok(src.includes("sc.src = siblingV('ov-mark.js')") && src.includes("url($
assert.ok(/GLYPH_ICONS = 'i\[class\*="fa-"\]/.test(src), 'the glyphs load where Font Awesome is used, unless a site says glyphs: false');
assert.ok(/glyphWatch = new MutationObserver[\s\S]{0,300}glyphWatch\.disconnect\(\)/.test(src), 'and when the page adds its first icon later, then the watch ends');
assert.ok(/_config\.glyphs !== false\) watchGlyphs\(\)/.test(src), 'the render only asks; the one watch lives outside it (a Boost page move re-renders the bar)');
--console.log('sibling revs: the navbar loads the mark, the glyphs and the brand face by content hash');
++// 4.137.0 (2026-11-07): every script the navbar, the Dock and the notifications load from beside themselves goes by its
++// content hash. Without it a release reached phones up to a day late (max-age=300, stale-while-revalidate=86400).
++assert.strictEqual((src.match(/[^V]sibling\('[a-z0-9-]+\.(js|css)'\)/g) || []).filter((m) => !/nav-icons\.js/.test(m)).length, 0, 'no sibling script loads without its hash (use siblingV)');
++for (const [file, { name, files }] of Object.entries(OWN)) {
++ const own = fs.readFileSync(path.join(__dirname, '..', file), 'utf8');
++ assert.ok(own.includes(table(name, files)), `${file}'s ${name} is current: run node scripts/sibling-revs.js`);
++}
++const dock = fs.readFileSync(path.join(__dirname, '..', 'dock.js'), 'utf8');
++assert.ok(dock.includes("sc.src = sibling(src) + (DOCK_REV[src] ? `?v=${DOCK_REV[src]}` : '');"), 'the Dock loads its siblings by hash');
++console.log('sibling revs: every script the navbar, the Dock and the notifications load goes by its content hash');
ddiff --git a/test/tap-areas-chrome.test.js b/test/tap-areas-chrome.test.js
iindex f5b3168..5965cc8 100644
---- a/test/tap-areas-chrome.test.js
++++ b/test/tap-areas-chrome.test.js
@@@ -62,7 +62,10 @@ const MEASURE = `(() => {
const firstRight = right && [...right.querySelectorAll('a, button')].find((el) => el.getBoundingClientRect().width > 0);
const fr = firstRight && firstRight.getBoundingClientRect();
const rightKeepsItsStart = !firstRight || on(firstRight, fr.left + 2, fr.top + fr.height / 2);
-- const out = { signIn: size(bar.querySelector('#openvibe-login-btn')), orb: size(bar.querySelector('.ovnav-orb')), nameKeepsItsEnd, rightKeepsItsStart };
++ // The menu's way in on a phone is the burger when the bar has page links (4.137.0: the orb steps aside), else the orb.
++ const orbEl = bar.querySelector('.ovnav-orb');
++ const menuEl = orbEl && getComputedStyle(orbEl).display !== 'none' ? orbEl : bar.querySelector('#openvibe-burger');
++ const out = { signIn: size(bar.querySelector('#openvibe-login-btn')), orb: size(menuEl), nameKeepsItsEnd, rightKeepsItsStart };
out.flame = size(bar.querySelector('.flame'));
out.tld = size(bar.querySelector('a.b-tld'));
out.tldBox = bar.querySelector('a.b-tld').getBoundingClientRect().width;
@@@ -88,7 +91,7 @@ const MEASURE = `(() => {
const v = await p.evaluate(MEASURE);
const at = `${width}px signed ${who}`;
if (who === 'out') assert.ok(v.signIn && v.signIn.h >= 44 && v.signIn.w >= 44, `${at}: Sign In ${JSON.stringify(v.signIn)}`);
-- assert.ok(v.orb && v.orb.w >= 44 && v.orb.h >= 44, `${at}: the orb ${JSON.stringify(v.orb)}`);
++ assert.ok(v.orb && v.orb.w >= 44 && v.orb.h >= 44, `${at}: the menu button ${JSON.stringify(v.orb)}`);
assert.ok(v.flame.w >= 44 && v.flame.h >= 44, `${at}: site switcher ${JSON.stringify(v.flame)}`);
assert.ok(v.nameKeepsItsEnd, `${at}: the site's name keeps the taps on its last letters`);
assert.ok(v.rightKeepsItsStart, `${at}: the right-hand group keeps its own first taps`);