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)
Changes
@@@ -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@@@ -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 deployedconsumer installs a tagged release and none keeps a vendored copy([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` namesthe tag it pins.@@@ -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",@@@ -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));});@@@ -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, signedin, 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(); } }@@@ -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() {@@@ -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": {@@@ -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 };@@@ -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; }@@@ -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 ──────────────────────────────────────────────@@@ -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 ──────────────────────────{@@@ -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.@@@ -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();}@@@ -95,8 +95,8 @@ function page({ url = 'https://openvibe.live/', eventSource = true, random = 0.5if (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); });@@@ -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');@@@ -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`);
@@ -1,5 +1,25 @@
@@ -1,5 +1,25 @@
# Changelog
# 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
## 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
- **One Computer in the Dock** (plan 2026-11-07: "Surf, Computer and Actor are one experience"): a timeline step
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
-**Status:** alpha, v4.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
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -4,13 +4,14 @@
@@ -4,13 +4,14 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.136.0",
+ "version": "4.137.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"contracts": "openvibe-contracts v0.312.0 (devDependency: the release manifest contract)",
"contracts": "openvibe-contracts v0.312.0 (devDependency: the release manifest contract)",
"sdk": "openvibe-sdk v0.94.0",
"sdk": "openvibe-sdk v0.94.0",
"features": [
"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 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 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",
"the launcher beside the brand lists every site of the network, in groups; Ask Actor and the Vibes chip in the account menu",
@@ -98,12 +98,14 @@
@@ -98,12 +98,14 @@
} catch { /* offline, or no route yet: the copy stands and the next change tries again */ }
} 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) {
function load(src, test) {
return new Promise((ok) => {
return new Promise((ok) => {
if (test()) return ok(true);
if (test()) return ok(true);
const id = `ov-dock-${src.replace(/\W/g, '')}`;
const id = `ov-dock-${src.replace(/\W/g, '')}`;
let sc = document.getElementById(id);
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('load', () => ok(test()));
sc.addEventListener('error', () => ok(false));
sc.addEventListener('error', () => ok(false));
});
});
@@ -172,7 +172,7 @@
@@ -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[data-chip-id] { display: none; }
.openvibe-navbar-right.has-chips-all .ovnav-chip--all { display: inline-flex; }
.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; } }
@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
/* 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
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 @@
@@ -513,7 +513,7 @@
const NAV_ICONS_REV = 'd9a4febbbad7'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
const NAV_ICONS_REV = 'd9a4febbbad7'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
// END generated
// END generated
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
// 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
// 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
// (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">.
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
@@ -742,7 +742,7 @@
@@ -742,7 +742,7 @@
const rw = _config.releaseWatch;
const rw = _config.releaseWatch;
if (rw === false || root.OVRelease || document.querySelector('script[src*="release-watch.js"]')) return;
if (rw === false || root.OVRelease || document.querySelector('script[src*="release-watch.js"]')) return;
if (rw && typeof rw === 'object' && !root.OVReleaseConfig) root.OVReleaseConfig = rw;
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 { /* */ }
try { sc.fetchPriority = 'low'; } catch { /* */ }
document.head.appendChild(sc);
document.head.appendChild(sc);
} catch { /* optional */ }
} catch { /* optional */ }
@@ -1002,7 +1002,7 @@
@@ -1002,7 +1002,7 @@
function loadOvIcons(scope) {
function loadOvIcons(scope) {
if (root.OpenVibeIcons) { if (scope && root.OpenVibeIcons.mount) root.OpenVibeIcons.mount(scope); return; }
if (root.OpenVibeIcons) { if (scope && root.OpenVibeIcons.mount) root.OpenVibeIcons.mount(scope); return; }
if (document.getElementById('ov-icons-loader')) 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() {
function queueIcons() {
if (_iconsQueued || root.OpenVibeIcons) return;
if (_iconsQueued || root.OpenVibeIcons) return;
@@ -1038,7 +1038,11 @@
@@ -1038,7 +1038,11 @@
function bindLinks(nav, links) {
function bindLinks(nav, links) {
const byId = new Map(flatLinks(links).filter(l => l.id).map(l => [l.id, l]));
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');
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'); };
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) => {
nav.addEventListener('click', (e) => {
const a = e.target.closest && e.target.closest('a.ovnav-link, .ovnav-net'); if (!a || !nav.contains(a)) return;
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;
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
@@ -1094,7 +1098,7 @@
@@ -1094,7 +1098,7 @@
if (root.OpenVibePanels) return root.OpenVibePanels.register(opts);
if (root.OpenVibePanels) return root.OpenVibePanels.register(opts);
_panelQueue.push(opts);
_panelQueue.push(opts);
if (document.getElementById('ov-panels-loader')) return;
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()); };
sc.onload = () => { while (_panelQueue.length) root.OpenVibePanels && root.OpenVibePanels.register(_panelQueue.shift()); };
document.head.appendChild(sc);
document.head.appendChild(sc);
}
}
@@ -1206,7 +1210,7 @@
@@ -1206,7 +1210,7 @@
};
};
if (root.OpenVibeNotifications) return go();
if (root.OpenVibeNotifications) return go();
if (document.getElementById('ov-notify-loader')) return;
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;
// ── 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 @@
@@ -1218,7 +1222,7 @@
function loadDock(then) {
function loadDock(then) {
if (root.OpenVibeDock) return then();
if (root.OpenVibeDock) return then();
let sc = document.getElementById('ov-dock-loader');
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));
sc.addEventListener('load', () => setTimeout(() => { try { then(); } catch { /* */ } }, 0));
}
}
function mountDock(u) {
function mountDock(u) {
@@ -1233,7 +1237,7 @@
@@ -1233,7 +1237,7 @@
const go = () => root.OpenVibePresence && root.OpenVibePresence.start({ token: () => _config.token, apiBase: dockBase(), user: u });
const go = () => root.OpenVibePresence && root.OpenVibePresence.start({ token: () => _config.token, apiBase: dockBase(), user: u });
if (root.OpenVibePresence) return go();
if (root.OpenVibePresence) return go();
if (document.getElementById('ov-presence-loader')) return;
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.
// 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 || {})); }); }
function openDock(tab, opts) { loadDock(() => { root.OpenVibeDock.mount(dockOpts()); root.OpenVibeDock.open(Object.assign({ tab }, opts || {})); }); }
@@ -1627,7 +1631,7 @@
@@ -1627,7 +1631,7 @@
if (root.OpenVibeSSO) return Promise.resolve(root.OpenVibeSSO);
if (root.OpenVibeSSO) return Promise.resolve(root.OpenVibeSSO);
if (_ssoClientLoading) return _ssoClientLoading;
if (_ssoClientLoading) return _ssoClientLoading;
_ssoClientLoading = new Promise((resolve) => {
_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);
sc.onload = () => resolve(root.OpenVibeSSO || null); sc.onerror = () => resolve(null);
document.head.appendChild(sc);
document.head.appendChild(sc);
});
});
@@ -1706,7 +1710,7 @@
@@ -1706,7 +1710,7 @@
if (H && typeof H.record === 'function') { H.record(rec, { token: _config.token, apiBase: _config.apiBase }); return; }
if (H && typeof H.record === 'function') { H.record(rec, { token: _config.token, apiBase: _config.apiBase }); return; }
if (!document.getElementById('ov-history-loader')) {
if (!document.getElementById('ov-history-loader')) {
const sc = document.createElement('script'); sc.id = 'ov-history-loader'; sc.async = true;
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 { /* */ } };
sc.onload = () => { try { root.OpenVibeHistory.record(rec, { token: _config.token, apiBase: _config.apiBase }); } catch { /* */ } };
document.head.appendChild(sc);
document.head.appendChild(sc);
} else {
} else {
@@ -2073,6 +2077,20 @@
@@ -2073,6 +2077,20 @@
// Search (search.js, loaded on first use). _config.search: false, or { owner, label, page }.
// Search (search.js, loaded on first use). _config.search: false, or { owner, label, page }.
let _searchLoading = false;
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'. */
/** The OpenVibe menu (search.js, loaded on first use): search, every site and every tool. tab: 'you' | 'sites' | 'tools'. */
function openSearch(q, tab, fromEl) {
function openSearch(q, tab, fromEl) {
const orb = _navEl && _navEl.querySelector('#openvibe-search-btn');
const orb = _navEl && _navEl.querySelector('#openvibe-search-btn');
@@ -2098,7 +2116,7 @@
@@ -2098,7 +2116,7 @@
if (_searchLoading) return;
if (_searchLoading) return;
_searchLoading = true;
_searchLoading = true;
if (orb) orb.classList.add('is-busy');
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'));
const menuOpen = () => !!(typeof document !== 'undefined' && document.querySelector && document.querySelector('.ovs.is-open'));
@@ -2125,7 +2143,7 @@
@@ -2125,7 +2143,7 @@
if (root.OpenVibeSiteSwitcher) return go();
if (root.OpenVibeSiteSwitcher) return go();
if (_switcherLoading) return;
if (_switcherLoading) return;
_switcherLoading = true;
_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
// 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).
// under Global Privacy Control or Do Not Track (as the page-view beacon).
@@ -2175,7 +2193,7 @@
@@ -2175,7 +2193,7 @@
if (ab) { ab.render({ config: _config, links: rawLinks(), activePage: _activePage, nav: _navEl, navIcon, escapeAttr, escapeHtml, flatLinks }); return; }
if (ab) { ab.render({ config: _config, links: rawLinks(), activePage: _activePage, nav: _navEl, navIcon, escapeAttr, escapeHtml, flatLinks }); return; }
if (!_config.appBar || _appBarLoading) return;
if (!_config.appBar || _appBarLoading) return;
_appBarLoading = true;
_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
// 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 @@
@@ -2299,6 +2317,7 @@
const el = render();
const el = render();
upgradeIconsWhenFontsReady();
upgradeIconsWhenFontsReady();
if (typeof document !== 'undefined') setTimeout(loadReleaseWatch, 4000);
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.
// 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().
// auth: 'external' — the site signs people in itself (Live) and tells us with setUser().
if (_config.auth === 'external') { if (_config.user) { recordHistory(); enableHandoff(); } }
if (_config.auth === 'external') { if (_config.user) { recordHistory(); enableHandoff(); } }
@@ -1190,6 +1190,8 @@
@@ -1190,6 +1190,8 @@
}
}
function stopPolling() { if (_pollTimer) clearInterval(_pollTimer); _pollTimer = null; }
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) ─────────────────────
// ── Realtime (notification-live.js) ─────────────────────
function liveOpen() { return !!(_live && _live.state().state === 'open'); }
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. */
/** 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 @@
@@ -1207,7 +1209,7 @@
function liveSrc() {
function liveSrc() {
if (_config.liveSrc) return _config.liveSrc;
if (_config.liveSrc) return _config.liveSrc;
const m = /^(https?:\/\/[^?#]*\/)notification-ui\.js(?:[?#]|$)/.exec(SELF_SRC);
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;
let _liveLoading = null;
function loadLive() {
function loadLive() {
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"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",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -9,11 +9,29 @@ const path = require('path');
@@ -9,11 +9,29 @@ const path = require('path');
const { hashAsset } = require('../assets');
const { hashAsset } = require('../assets');
const ROOT = path.join(__dirname, '..');
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 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(', ')} };`;
const line = (r) => ` const SIBLING_REV = { ${FILES.map((f) => `'${f}': '${r[f]}'`).join(', ')} };`;
if (require.main === module) {
if (require.main === module) {
+ writeOwn();
const p = path.join(ROOT, 'navbar.js');
const p = path.join(ROOT, 'navbar.js');
const src = fs.readFileSync(p, 'utf8');
const src = fs.readFileSync(p, 'utf8');
const next = src.replace(/^ {4}const SIBLING_REV = \{[^\n]*\};$/m, line(revs()));
const next = src.replace(/^ {4}const SIBLING_REV = \{[^\n]*\};$/m, line(revs()));
@@ -21,4 +39,4 @@ if (require.main === module) {
@@ -21,4 +39,4 @@ if (require.main === module) {
fs.writeFileSync(p, next);
fs.writeFileSync(p, next);
console.log(line(revs()).trim());
console.log(line(revs()).trim());
}
}
-module.exports = { FILES, revs, line };
+module.exports = { FILES, OWN, revs, line, table };
@@ -176,6 +176,7 @@
@@ -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 { 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-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; }
.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 { 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-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; }
.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; }
@@ -19,7 +19,7 @@ const DOCK_PATH = path.join(ROOT, 'dock.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 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
// 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.
// 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'];
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 ──────────────────────────────────────────────
// ── the budget ──────────────────────────────────────────────
@@ -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 = 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 ──────────────────────────
// ── size budget and generated block ──────────────────────────
{
{
@@ -61,8 +61,9 @@ const LINKS = [
@@ -61,8 +61,9 @@ const LINKS = [
const { navbar, head } = load('/rooms', { preload: false });
const { navbar, head } = load('/rooms', { preload: false });
navbar.init({ service: 'chat', links: LINKS, appBar: true });
navbar.init({ service: 'chat', links: LINKS, appBar: true });
navbar.setActive('rooms');
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.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');
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.
{ // appBar: true → the first four links with icons and More (six links); the open page marked, under its path too.
@@ -72,8 +72,10 @@ const MEASURE = `(() => {
@@ -72,8 +72,10 @@ const MEASURE = `(() => {
assert.ok(last[1][1] <= width, `${at}: ${last[0]} runs off the screen`);
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 (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)`);
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)`);
assert.ok(!v.parts.some(([k]) => k === 'launcher'), `${at}: no launcher button: the magnifier opens the one menu (4.23.0)`);
await p.close();
await p.close();
}
}
@@ -95,8 +95,8 @@ function page({ url = 'https://openvibe.live/', eventSource = true, random = 0.5
@@ -95,8 +95,8 @@ function page({ url = 'https://openvibe.live/', eventSource = true, random = 0.5
if (src) {
if (src) {
out.scripts.push(src);
out.scripts.push(src);
setImmediate(() => {
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' });
vm.runInContext(LIVE_SRC, ctx, { filename: 'notification-live.js' });
if (node.onload) node.onload(new ctx.Event('load'));
if (node.onload) node.onload(new ctx.Event('load'));
});
});
@@ -262,7 +262,8 @@ const created = (subject, over = {}) => ({
@@ -262,7 +262,8 @@ const created = (subject, over = {}) => ({
N.init({ token: 'jwt-alice', apiBase: API, realtime: true });
N.init({ token: 'jwt-alice', apiBase: API, realtime: true });
const bell = N.createBell(p.document.body);
const bell = N.createBell(p.document.body);
await p.settle();
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().length, 1);
assert.strictEqual(p.tickets()[0].headers.Authorization, 'Bearer jwt-alice');
assert.strictEqual(p.tickets()[0].headers.Authorization, 'Bearer jwt-alice');
assert.strictEqual(p.tickets()[0].credentials, 'omit', 'cross-site: the token, never cookies');
assert.strictEqual(p.tickets()[0].credentials, 'omit', 'cross-site: the token, never cookies');
@@ -360,7 +361,8 @@ const created = (subject, over = {}) => ({
@@ -360,7 +361,8 @@ const created = (subject, over = {}) => ({
p.run(UI_SRC, 'notification-ui.js', 'https://openvibe.network/shared/notification-ui.js');
p.run(UI_SRC, 'notification-ui.js', 'https://openvibe.network/shared/notification-ui.js');
p.ctx.OpenVibeNotifications.init({ apiBase: API, realtime: true });
p.ctx.OpenVibeNotifications.init({ apiBase: API, realtime: true });
await p.settle();
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].credentials, 'include', 'on openvibe.network the session cookie asks for the ticket');
assert.strictEqual(p.tickets()[0].headers.Authorization, undefined);
assert.strictEqual(p.tickets()[0].headers.Authorization, undefined);
}
}
@@ -485,7 +487,8 @@ const created = (subject, over = {}) => ({
@@ -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.
// 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
// 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.
// 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');
console.log('notification live: all checks passed');
})().catch((err) => { console.error(err); process.exit(1); });
})().catch((err) => { console.error(err); process.exit(1); });
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
const assert = require('assert');
const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
const path = require('path');
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');
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');
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($
@@ -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(/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(/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)');
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');
@@ -62,7 +62,10 @@ const MEASURE = `(() => {
@@ -62,7 +62,10 @@ const MEASURE = `(() => {
const firstRight = right && [...right.querySelectorAll('a, button')].find((el) => el.getBoundingClientRect().width > 0);
const firstRight = right && [...right.querySelectorAll('a, button')].find((el) => el.getBoundingClientRect().width > 0);
const fr = firstRight && firstRight.getBoundingClientRect();
const fr = firstRight && firstRight.getBoundingClientRect();
const rightKeepsItsStart = !firstRight || on(firstRight, fr.left + 2, fr.top + fr.height / 2);
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.flame = size(bar.querySelector('.flame'));
out.tld = size(bar.querySelector('a.b-tld'));
out.tld = size(bar.querySelector('a.b-tld'));
out.tldBox = bar.querySelector('a.b-tld').getBoundingClientRect().width;
out.tldBox = bar.querySelector('a.b-tld').getBoundingClientRect().width;
@@ -88,7 +91,7 @@ const MEASURE = `(() => {
@@ -88,7 +91,7 @@ const MEASURE = `(() => {
const v = await p.evaluate(MEASURE);
const v = await p.evaluate(MEASURE);
const at = `${width}px signed ${who}`;
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)}`);
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.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.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`);
assert.ok(v.rightKeepsItsStart, `${at}: the right-hand group keeps its own first taps`);