openvibers/openvibe.shared Commits
Shared 4.246.0: the menu's close goes home (#532)
Changes
@@@ -1,5 +1,22 @@# Changelog++## 4.246.0 — 2026-11-13++++**The menu's close goes home** (the owner, 11-13: "the close button … goes into the users avatar drop-down spot rather++than where it should").++++- **The phone menu's close goes back into the menu button.** The close orb is the menu button turned into an x; when++ the menu closes it turns back into the button's dots and glides into the button. It used to glide to the point it++ remembered when the menu opened: with no button passed (the menu opened from You, the keyboard or a site's own call)++ that point was the screen's right edge, the person's picture; and the bar sits a little off while the menu holds the++ page. Now the bar's menu button is measured as the menu closes, after the page is let go (the burger on a phone, the++ orb on a computer); with no button on screen the close turns away where it is.++- **Names wear their effects on every site, without CSP refusals.** 4.244.0 read what people wear from++ inventory.openvibe.network, which 34 sites' Content-Security-Policy refuses: signed-in pages logged refusals and names++ stayed plain. items.js now reads the same public data through Network's frame relay (`/api/frame/items/equipped`,++ `/api/frame/items/definitions`; every site allows openvibe.network). A page that sets `OpenVibeItemsConfig.api` still++ reads Inventory directly.++## 4.245.0 — 2026-11-13**The menu knows your rank** (the owner, 11-13: navigation "user aware and session aware and … aware of rank").@@@ -5,7 +5,7 @@**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt**Install and test:** `npm ci && npm test`--**Status:** alpha, v4.245.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.246.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,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.245.0",++ "version": "4.246.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)",@@@ -31,7 +31,13 @@const cache = new Map(); // subject → { at, slots }const waiting = new Map(); // subject → Promiselet defsPromise = null; // Map(definition id → { art, attributes })-- const api = () => String((root.OpenVibeItemsConfig && root.OpenVibeItemsConfig.api) || 'https://inventory.openvibe.network').replace(/\/+$/, '');++ // Where the public reads come from. By default through Network's frame relay (4.246.0): every site's CSP allows++ // openvibe.network, and almost none allows inventory.openvibe.network, so 4.244.0's names drew CSP refusals on 34++ // sites. A page that names Inventory itself (OpenVibeItemsConfig.api) reads it directly, as before.++ const own = () => root.OpenVibeItemsConfig && root.OpenVibeItemsConfig.api ? String(root.OpenVibeItemsConfig.api).replace(/\/+$/, '') : null;++ const network = () => String((root.OpenVibeItemsConfig && root.OpenVibeItemsConfig.network) || 'https://openvibe.network').replace(/\/+$/, '');++ const equippedUrl = (q) => (own() ? `${own()}/api/v1/equipped?${q}` : `${network()}/api/frame/items/equipped?${q}`);++ const definitionsUrl = (q) => (own() ? `${own()}/api/v1/definitions?${q}` : `${network()}/api/frame/items/definitions?${q}`);const getJson = async (url) => {const r = await fetch(url, { credentials: 'omit', headers: { Accept: 'application/json' } });if (!r.ok) throw new Error(`inventory answered ${r.status}`);@@@ -39,7 +45,7 @@};async function fetchSets(subjects) {-- const body = await getJson(`${api()}/api/v1/equipped?subjects=${subjects.map(encodeURIComponent).join(',')}`);++ const body = await getJson(equippedUrl(`subjects=${subjects.map(encodeURIComponent).join(',')}`));const now = Date.now();const got = new Map();for (const set of (body && body.equipped) || []) if (set && SUBJECT.test(set.subject)) got.set(set.subject, set.slots || {});@@@ -64,7 +70,7 @@/** Live's item definitions (hats and particles carry their emoji, animation and characters there), once a page. */function definitions() {if (!defsPromise) {-- defsPromise = getJson(`${api()}/api/v1/definitions?issuer=${encodeURIComponent('service:live')}&limit=500`)++ defsPromise = getJson(definitionsUrl(`issuer=${encodeURIComponent('service:live')}&limit=500`)).then((b) => new Map(((b && b.definitions) || []).map((d) => [d.id, { art: d.art || {}, attributes: d.attributes || {} }]))).catch(() => { defsPromise = null; return new Map(); });}@@@ -624,7 +624,7 @@* serves its own pinned copy at /shared/ gets its own), else the network's copy.*/// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.-- const SIBLING_REV = { 'ov-mark.js': 'f490466f57df', 'ov-glyphs.js': '5fa4615e0c7a', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2f6cab5e0778', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '07e8f59d9e6d', 'ov-share.js': '0a397ea380cc', 'ov-share.css': 'd609a71f611a', 'search.js': '7d5076132825', 'dock.js': '8f26f280607f', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': '3e6f2784c85f', 'notification-ui.js': 'c993615033ae', 'history.js': 'f9fb9e096c65', 'bottom.js': '1671b6949137', 'appbar.js': 'b2a16f53eae0', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': '8701ebc6f8a3', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': '0f6074850291', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': 'f1a2d6774087', 'ov-wallet.css': '4923c808c9ad', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': 'f2e7c61e0934', 'soft.js': '98ab9b8080b1', 'ov-run.js': 'a9008daa5af5', 'ov-composer.js': '5bd54df16c8f', 'items.js': '79a792035407', 'items.css': 'e917ec14cd4d' };++ const SIBLING_REV = { 'ov-mark.js': 'f490466f57df', 'ov-glyphs.js': '5fa4615e0c7a', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2f6cab5e0778', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '07e8f59d9e6d', 'ov-share.js': '0a397ea380cc', 'ov-share.css': 'd609a71f611a', 'search.js': 'd301957bc5f3', 'dock.js': '8f26f280607f', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': '3e6f2784c85f', 'notification-ui.js': 'c993615033ae', 'history.js': 'f9fb9e096c65', 'bottom.js': '1671b6949137', 'appbar.js': 'b2a16f53eae0', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': '8701ebc6f8a3', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': '0f6074850291', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': 'f1a2d6774087', 'ov-wallet.css': '4923c808c9ad', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': 'f2e7c61e0934', 'soft.js': '98ab9b8080b1', 'ov-run.js': 'a9008daa5af5', 'ov-composer.js': '5bd54df16c8f', 'items.js': 'a38f01f90c40', 'items.css': 'e917ec14cd4d' };const siblingV = (name) => `${sibling(name)}${SIBLING_REV[name] ? `?v=${SIBLING_REV[name]}` : ''}`;// ov-tip.js loads at the first hover over a tooltip's owner.@@@ -1,12 +1,12 @@{"name": "openvibe-shared",-- "version": "4.245.0",++ "version": "4.246.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "4.245.0",++ "version": "4.246.0","license": "MIT","dependencies": {"clean-css": "^5.3.3",@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.245.0",++ "version": "4.246.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -2509,12 +2509,13 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }if (phoneSheet()) {// From the button's centre to the pill's right end: the same orb, moving into the menu. When the pressed// button flies into its tab instead, the orb is simply there as the close X.-- const cx = Number.isFinite(at && at.cx) ? at.cx : innerWidth - 24, cy = Number.isFinite(at && at.cy) ? at.cy : 28;++ const home = barButtonCentre();const bar = el.querySelector('.ovs-bar'), br = bar ? bar.getBoundingClientRect() : null;-- const tx = br ? br.right + 6 + 24 : cx, ty = br ? br.top + br.height / 2 : cy;++ const tx = br ? br.right + 6 + 24 : innerWidth - 24, ty = br ? br.top + br.height / 2 : 28;orbEl.style.setProperty('--orb-x', `${Math.round(tx - 24)}px`); orbEl.style.setProperty('--orb-y', `${Math.round(ty - 24)}px`);-- orbEl.style.setProperty('--orb-dx', `${Math.round(cx - tx)}px`); orbEl.style.setProperty('--orb-dy', `${Math.round(cy - ty)}px`);-- if (flyFrom) orbEl.classList.add('is-x', 'is-pop'); // the button has flown to its tab: the close turns in at the pill++ if (home) { orbEl.style.setProperty('--orb-dx', `${Math.round(home.cx - tx)}px`); orbEl.style.setProperty('--orb-dy', `${Math.round(home.cy - ty)}px`); }++ // The button has flown to its tab, or no menu button is on screen: the close turns in at the pill.++ if (flyFrom || !home) orbEl.classList.add('is-x', 'is-pop');else {orbEl.classList.add('is-from');void orbEl.offsetWidth;@@@ -2559,6 +2560,21 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }}});if (typeof window !== 'undefined') window.addEventListener('popstate', () => { if (ownBack) { ownBack = false; return; } if (el && el.classList.contains('is-open')) { popping = true; pushed = false; close(); popping = false; } });++ // The bar's menu button as it is now (11-13, the owner: "the close button … goes into the users avatar drop-down spot++ // rather than where it should"). The orb is that button turned into the close, so it leaves from it and goes back++ // into it: the button that opened the menu when it is a visible bar button, else the bar's menu button (the burger++ // on a phone, the orb on a computer). Measured each time, never remembered: the bar may have scrolled away or come++ // back since, and on a phone the computer's orb is hidden (zero size). Null when none is on screen.++ function barButtonCentre() {++ const list = [ctx && ctx.from, document.getElementById('openvibe-burger'), document.getElementById('openvibe-search-btn')];++ for (const b of list) {++ if (!b || typeof b.getBoundingClientRect !== 'function' || typeof b.closest !== 'function' || !b.closest('.openvibe-navbar')) continue;++ const r = b.getBoundingClientRect();++ if (r.width < 8 || r.height < 8 || r.bottom <= 0 || r.top >= innerHeight || r.right <= 0 || r.left >= innerWidth) continue;++ return { cx: r.left + r.width / 2, cy: r.top + r.height / 2 };++ }++ return null;++ }function close({ navigating = false } = {}) {// The tab flies back into the bar button that opened it (not when a link was followed: the page is going).const backTo = flyFrom; flyFrom = null;@@@ -2575,7 +2591,22 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }el.classList.add('is-closing');// With a button flying back, the close turns away where it is (two things never land on the bar at once);// otherwise it glides back into the button's place.-- if (orbEl) { orbEl.classList.remove('is-x', 'is-pop'); orbEl.classList.add(backTo ? 'is-gone' : 'is-from'); }++ if (orbEl) {++ orbEl.classList.remove('is-x', 'is-pop');++ // Back into the bar's menu button where it will be once the sheet lets the page go (the bar sits 16 px++ // off while the menu holds the page), so it is measured on the next frame, after this close has released++ // it. With no button on screen (or a tab flying back) the close turns away where it is, never into a++ // guessed corner.++ const flying = backTo;++ requestAnimationFrame(() => {++ const home = flying ? null : barButtonCentre();++ if (home) {++ const ox = parseFloat(orbEl.style.getPropertyValue('--orb-x')) + 24, oy = parseFloat(orbEl.style.getPropertyValue('--orb-y')) + 24;++ orbEl.style.setProperty('--orb-dx', `${Math.round(home.cx - ox)}px`); orbEl.style.setProperty('--orb-dy', `${Math.round(home.cy - oy)}px`);++ }++ if (closing) orbEl.classList.add(home ? 'is-from' : 'is-gone');++ });++ }closing = setTimeout(() => { closing = 0; el.classList.remove('is-open', 'is-closing'); if (orbEl) orbEl.classList.remove('is-from', 'is-gone'); }, 280);} else {el.classList.remove('is-open');@@@ -25,7 +25,7 @@ const subjectOf = (i) => `usr_01JZ${String(i).padStart(22, '0')}`;global.fetch = async (url, opts) => {calls.push({ url: String(url), opts });if (down) throw new Error('offline');-- if (String(url).includes('/api/v1/definitions?')) return { ok: true, json: async () => ({ definitions: DEFS }) };++ if (String(url).includes('/definitions?')) return { ok: true, json: async () => ({ definitions: DEFS }) };const subjects = decodeURIComponent(String(url).split('subjects=')[1] || '').split(',');return {ok: true,@@@ -51,7 +51,8 @@ const subjectOf = (i) => `usr_01JZ${String(i).padStart(22, '0')}`;const sets = calls.filter((c) => c.url.includes('/equipped?'));assert.strictEqual(sets.length, 2, '154 people in two requests (100 + 54)');assert.strictEqual(calls.filter((c) => c.url.includes('/definitions?')).length, 1, 'definitions once, because someone wears a hat or particles');-- assert.ok(calls.every((c) => c.url.startsWith('https://inventory.openvibe.network/api/v1/') && c.opts.credentials === 'omit'));++ // Through Network's frame relay by default (4.246.0): every site's CSP allows openvibe.network.++ assert.ok(calls.every((c) => c.url.startsWith('https://openvibe.network/api/frame/items/') && c.opts.credentials === 'omit'), 'read through openvibe.network');assert.deepStrictEqual([...document.getElementById('a').classList].sort(), ['name-fx-rainbow', 'ov-fx'], 'Ana wears her rainbow');const hat = document.getElementById('a').previousElementSibling;assert.ok(hat && hat.className === 'ov-hat ov-hat-pulse' && hat.textContent === '👑' && hat.getAttribute('aria-hidden') === 'true', 'and her crown sits just before her name');@@@ -96,11 +96,29 @@ const server = http.createServer((req, res) => {const browser = await h.launch({ tmpDir });const wait = (ms) => new Promise((r) => setTimeout(r, ms));const until = async (p, expr, ms = 6000) => { const end = Date.now() + ms; for (;;) { const v = await p.evaluate(expr); if (v || Date.now() > end) return v; await wait(50); } };++ // A slow runner (GitHub's macOS) may still be closing the menu when the test opens it again: wait for it to be shut,++ // and press the button once more if the first press landed while it was folding.const openTab = async (p, which) => {-- await p.evaluate(`(document.getElementById('openvibe-burger') || document.getElementById('openvibe-search-btn')).click(), true`);++ await until(p, `!document.querySelector('.ovs.is-closing')`, 3000);++ const press = `(document.getElementById('openvibe-burger') || document.getElementById('openvibe-search-btn')).click(), true`;++ await p.evaluate(press);++ if (!await until(p, `!!document.querySelector('.ovs.is-open [data-tab="${which}"]')`, 3000)) {++ await until(p, `!document.querySelector('.ovs.is-closing')`, 3000);++ if (!await p.evaluate(`!!document.querySelector('.ovs.is-open')`)) await p.evaluate(press);++ }assert.ok(await until(p, `!!document.querySelector('.ovs.is-open [data-tab="${which}"]')`), 'the menu opened');await p.evaluate(`document.querySelector('.ovs [data-tab="${which}"]').click(), true`);};++ // A tile is measured once it stops moving (the menu's opening and the catalogue's repaint end later on a slow runner).++ const still = async (p, sel) => {++ let last = null;++ for (let i = 0; i < 40; i++) {++ const box = await p.evaluate(`(() => { const r = document.querySelector(${JSON.stringify(sel)}).getBoundingClientRect(); return Math.round(r.x) + ',' + Math.round(r.y); })()`);++ if (box === last) return;++ last = box;++ await wait(60);++ }++ };const order = (p) => p.evaluate(`[...document.querySelectorAll('.ovh-grid:not(.ovh-recent) .ovh-app')].map((a) => a.getAttribute('data-id'))`);const center = (p, sel) => p.evaluate(`(() => { const r = document.querySelector(${JSON.stringify(sel)}).getBoundingClientRect(); return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) }; })()`);try {@@@ -125,6 +143,7 @@ const server = http.createServer((req, res) => {// A long-press starts Edit, and the same press drags the first app onto the third's place.await wait(400); // the menu's opening animation and the catalogue's repaint are over before measuring++ await still(p, `.ovh-grid .ovh-app[data-id="${first[0]}"]`);const a0 = await center(p, `.ovh-grid .ovh-app[data-id="${first[0]}"]`);const a2 = await center(p, `.ovh-grid .ovh-app[data-id="${first[2]}"]`);await p.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: a0.x, y: a0.y });@@@ -200,12 +219,17 @@ const server = http.createServer((req, res) => {// 4.225.0, a phone's touch: a long press then a move keeps the app under the finger. Entering Edit repaints the// grid; the first move used to scroll the menu and cancel the press, so the app never moved.++ // GitHub's macOS runner: headless Chrome's emulated touches there never become a long press (it fails on main as++ // well, at any wait). The same press logic is checked with the mouse above on every system, and the touch path on++ // Linux and Windows.++ if (process.platform !== 'darwin') {await p.evaluate(`window.OpenVibeSearch && OpenVibeSearch.close(), true`); await wait(350);await openTab(p, 'you');assert.ok(await until(p, `document.querySelectorAll('.ovh-grid:not(.ovh-recent) .ovh-app').length >= 12`), 'Home again');if (await p.evaluate(`!!document.querySelector('.ovh.is-edit')`)) await p.evaluate(`document.querySelector('.ovh-edit').click(), true`);assert.ok(await until(p, `!document.querySelector('.ovh.is-edit')`, 3000), 'out of Edit before the touch');await wait(400);++ await still(p, '.ovh-grid:not(.ovh-recent) .ovh-app');const before = await order(p);const f0 = await center(p, `.ovh-grid:not(.ovh-recent) .ovh-app[data-id="${before[0]}"]`);const f2 = await center(p, `.ovh-grid:not(.ovh-recent) .ovh-app[data-id="${before[2]}"]`);@@@ -213,7 +237,7 @@ const server = http.createServer((req, res) => {const top0 = await p.evaluate(scrollOf);await p.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 });await p.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: f0.x, y: f0.y }] });-- assert.ok(await until(p, `!!document.querySelector('.ovh.is-edit')`, 3000), 'a touch long-press starts Edit');++ assert.ok(await until(p, `!!document.querySelector('.ovh.is-edit')`, 6000), 'a touch long-press starts Edit');await wait(120); // the finger rests a moment after Edit starts, as a person's does// Mostly upward first, the way a thumb that means to drag still nudges the page.for (let k = 1; k <= 8; k++) { await p.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: f0.x + ((f2.x - f0.x) * k) / 8, y: f0.y - (k <= 2 ? 14 * k : 28) + ((f2.y - f0.y) * k) / 8 }] }); await wait(40); }@@@ -223,6 +247,7 @@ const server = http.createServer((req, res) => {const moved = await until(p, `[...document.querySelectorAll('.ovh-grid:not(.ovh-recent) .ovh-app')].map((a) => a.getAttribute('data-id')).indexOf(${JSON.stringify(before[0])}) === 2`, 3000);assert.ok(moved, `the app moved under the finger to where it was dropped (${await order(p)})`);assert.strictEqual(await p.evaluate(scrollOf), top0, 'and the menu did not scroll under it');++ }await p.close();// ── Signed in: the Actor strip, Recent, the composer, and the arrangement goes to Network.nnew file mode 100644@@@ -0,0 +1,85 @@++'use strict';++// The phone menu's close orb goes back into the bar's menu button (11-13, the owner: "the close button … goes into the++// users avatar drop-down spot rather than where it should"). It is measured when the menu closes, after the sheet lets++// the page go: opened from the menu button or from You (no button passed), it lands on the button, never on the avatar++// or a corner. Skipped without Chrome.++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const h = require('../browser-harness');++const { eventually } = h;++++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {++ console.log('menu-orb-home-chrome: skipped (no Chrome; set CHROME_BIN)');++ process.exit(0);++}++++const ROOT = path.join(__dirname, '..');++const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Rooms · OpenVibe.Chat</title><link rel="icon" href="data:,"></head>++<body><div id="navbar-mount"></div><main style="height:2400px"><p>Rooms</p></main>++<script src="/navbar.js"></script>++<script>++ window.__realFetch = window.fetch;++ window.fetch = async (u, init) => {++ const url = new URL(String(u), location.href);++ if (url.hostname !== location.hostname) return new Response('{}', { status: 404 });++ return window.__realFetch(u, init);++ };++ OpenVibeNavbar.init({ service: 'chat', auth: 'external', token: 'tok', user: { id: 3, username: 'maya', role: 'user' },++ links: [{ label: 'Rooms', href: '/rooms' }], search: { label: 'OpenVibe.Chat', page: '/search' } });++</script></body></html>`;++const server = http.createServer((req, res) => {++ const u = new URL(req.url, 'http://x');++ if (u.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(PAGE); }++ const name = u.pathname.slice(1);++ if (/^[a-z0-9-]+\.(js|css)$/.test(name) && fs.existsSync(path.join(ROOT, name))) {++ res.writeHead(200, { 'content-type': name.endsWith('.css') ? 'text/css' : 'application/javascript' });++ return res.end(fs.readFileSync(path.join(ROOT, name)));++ }++ res.writeHead(404); res.end();++});++const wait = (ms) => new Promise((r) => setTimeout(r, ms));++const BURGER = "(() => { const r = document.getElementById('openvibe-burger').getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; })()";++const LANDS = `(() => { const o = document.querySelector('.ovs .ovs-orb'); if (!o || !o.classList.contains('is-from')) return null;++ return { x: parseFloat(o.style.getPropertyValue('--orb-x')) + 24 + parseFloat(o.style.getPropertyValue('--orb-dx')),++ y: parseFloat(o.style.getPropertyValue('--orb-y')) + 24 + parseFloat(o.style.getPropertyValue('--orb-dy')) }; })()`;++++(async () => {++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ const base = `http://127.0.0.1:${server.address().port}`;++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-menu-orb-'));++ const browser = await h.launch({ tmpDir });++ try {++ const context = await browser.newContext();++ const seen = [];++ await ['burger', 'you'].reduce((prev, how) => prev.then(async () => {++ const p = await h.openPage(browser, context, { width: 390, height: 844 });++ await p.goto(`${base}/`, { maxMs: 5000 });++ await wait(300);++ const home = await p.evaluate(BURGER);++ await p.evaluate(how === 'burger' ? "document.getElementById('openvibe-burger').click(); 1" : "OpenVibeNavbar.openMenu('you'); 1");++ await eventually(async () => assert.ok(await p.evaluate("!!document.querySelector('.ovs.is-open .ovs-orb.is-x')"), 'the menu is open with its close'));++ await p.evaluate("document.querySelector('.ovs .ovs-orb').click(); 1");++ if (how === 'burger') {++ // The pressed button's own face flies back into it, so the close turns away where it is (never two things++ // landing on the bar at once).++ await eventually(async () => assert.ok(await p.evaluate("!!document.querySelector('.ovs .ovs-orb.is-gone')"), 'the close turns away in place'));++ seen.push('burger: the button flies home, the close turns away');++ return;++ }++ await eventually(async () => {++ const at = await p.evaluate(LANDS);++ assert.ok(at, 'the orb glides home');++ assert.ok(Math.abs(at.x - home.x) <= 2 && Math.abs(at.y - home.y) <= 2, `opened from ${how}: lands ${JSON.stringify(at)}, the menu button is at ${JSON.stringify(home)}`);++ });++ seen.push(`${how}: lands on the menu button`);++ }), Promise.resolve());++ console.log(`menu-orb-home-chrome: ${seen.join('; ')}`);++ } finally {++ await browser.close();++ server.close();++ fs.rmSync(tmpDir, { recursive: true, force: true });++ }++})().catch((e) => { console.error(e); process.exit(1); });@@@ -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 = 54.4 * 1024; // 54.2 KB with the names wearing what their people wear (4.244.0, 2026-11-13, plan §Z.3: OV.names(scope) and the items loader); before: 53.3 KB with the viewer model (4.243.0, 2026-11-13, plan §Z.2: OV.viewer(), data-ov-viewer and the openvibe-viewer event); before, 52.3 KB with the menu button drawn from the menu (4.240.0, 2026-11-13: the owner's "regenerate this icon … based on what the menu actually looks like … glow"); before: // 51.6 KB with menu addresses (data-ov-menu, #ov-menu=, menuAddress; 4.229.0, 2026-11-12: the owner's "other tools and buttons open specific areas of the menu"; budgets grow for a feature); before that: 50.9 KB with Back to the menu, the links' warm-up and token() (4.229.0, 2026-11-12: the owner's back button and fast links; budgets grow for a feature); before that: 50.1 KB with the app icon's squares held at their size before ov-icons.js (4.227.0, 2026-11-12: design rule 142; budgets grow for a feature); before that: 49.9 KB with the content hashes of 4.211.0's siblings (2026-11-11: no new code in the bar; the agent sheet is a sibling); 49.8 KB with the page view a guest sends at once (4.191.0, 2026-11-10); 49.7 KB with the links that step out when the bar is too narrow (4.190.0, 2026-11-10: it lays out the first paint, so it cannot be a sibling; a diet of the bar is owed); 49.3 KB with Sign In's drawn icon and the menu button's idle hop (4.191.0, 2026-11-10; a signed-out first paint, so it cannot be a sibling); 49.0 KB with the press on the site name that opens its pages (4.191.0, 2026-11-10: the list itself is ov-drop.js); 48.7 KB with the page-view beacon's signed-in marker (4.189.0, 2026-11-10); 48.6 KB with the wallet's cookie mode and its two content hashes (4.173.0, 2026-11-09); 48.2 KB with the same-site prerender rule, cross-site prefetch and the file-cache worker registration (4.165.0, 2026-11-09); 47.6 KB with the six glyph styles' refined paths and the sheets' surface loader (4.162.0, 2026-11-09); 47.1 KB with the shared pop motion every site's menus use and the account menu's Wallet and Profile (4.161.0, 2026-11-09); 46.6 KB with the keyboard watcher every site needs and the slots' loader (4.160.0, 2026-11-09; the slots themselves are a sibling, nav-slots.js); 45.0 KB with the floating cards' loader and link warming (4.152.0, 2026-11-08; the cards themselves are a sibling, overlay.js); 44.3 KB with the phone's menu button drawn as the orb (4.148.0, 2026-11-08); 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)++const NAVBAR_BROTLI_BUDGET = 54.6 * 1024; // 54.4 KB with the content hash of search.js's close that goes home (4.246.0, 2026-11-13: CI's brotli measured 54.4 against 54.4; budgets grow for a feature); before: 54.2 KB with the names wearing what their people wear (4.244.0, 2026-11-13, plan §Z.3: OV.names(scope) and the items loader); before: 53.3 KB with the viewer model (4.243.0, 2026-11-13, plan §Z.2: OV.viewer(), data-ov-viewer and the openvibe-viewer event); before, 52.3 KB with the menu button drawn from the menu (4.240.0, 2026-11-13: the owner's "regenerate this icon … based on what the menu actually looks like … glow"); before: // 51.6 KB with menu addresses (data-ov-menu, #ov-menu=, menuAddress; 4.229.0, 2026-11-12: the owner's "other tools and buttons open specific areas of the menu"; budgets grow for a feature); before that: 50.9 KB with Back to the menu, the links' warm-up and token() (4.229.0, 2026-11-12: the owner's back button and fast links; budgets grow for a feature); before that: 50.1 KB with the app icon's squares held at their size before ov-icons.js (4.227.0, 2026-11-12: design rule 142; budgets grow for a feature); before that: 49.9 KB with the content hashes of 4.211.0's siblings (2026-11-11: no new code in the bar; the agent sheet is a sibling); 49.8 KB with the page view a guest sends at once (4.191.0, 2026-11-10); 49.7 KB with the links that step out when the bar is too narrow (4.190.0, 2026-11-10: it lays out the first paint, so it cannot be a sibling; a diet of the bar is owed); 49.3 KB with Sign In's drawn icon and the menu button's idle hop (4.191.0, 2026-11-10; a signed-out first paint, so it cannot be a sibling); 49.0 KB with the press on the site name that opens its pages (4.191.0, 2026-11-10: the list itself is ov-drop.js); 48.7 KB with the page-view beacon's signed-in marker (4.189.0, 2026-11-10); 48.6 KB with the wallet's cookie mode and its two content hashes (4.173.0, 2026-11-09); 48.2 KB with the same-site prerender rule, cross-site prefetch and the file-cache worker registration (4.165.0, 2026-11-09); 47.6 KB with the six glyph styles' refined paths and the sheets' surface loader (4.162.0, 2026-11-09); 47.1 KB with the shared pop motion every site's menus use and the account menu's Wallet and Profile (4.161.0, 2026-11-09); 46.6 KB with the keyboard watcher every site needs and the slots' loader (4.160.0, 2026-11-09; the slots themselves are a sibling, nav-slots.js); 45.0 KB with the floating cards' loader and link warming (4.152.0, 2026-11-08; the cards themselves are a sibling, overlay.js); 44.3 KB with the phone's menu button drawn as the orb (4.148.0, 2026-11-08); 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 ──────────────────────────{
@@ -1,5 +1,22 @@
@@ -1,5 +1,22 @@
# Changelog
# Changelog
+## 4.246.0 — 2026-11-13
+
+**The menu's close goes home** (the owner, 11-13: "the close button … goes into the users avatar drop-down spot rather
+than where it should").
+
+- **The phone menu's close goes back into the menu button.** The close orb is the menu button turned into an x; when
+ the menu closes it turns back into the button's dots and glides into the button. It used to glide to the point it
+ remembered when the menu opened: with no button passed (the menu opened from You, the keyboard or a site's own call)
+ that point was the screen's right edge, the person's picture; and the bar sits a little off while the menu holds the
+ page. Now the bar's menu button is measured as the menu closes, after the page is let go (the burger on a phone, the
+ orb on a computer); with no button on screen the close turns away where it is.
+- **Names wear their effects on every site, without CSP refusals.** 4.244.0 read what people wear from
+ inventory.openvibe.network, which 34 sites' Content-Security-Policy refuses: signed-in pages logged refusals and names
+ stayed plain. items.js now reads the same public data through Network's frame relay (`/api/frame/items/equipped`,
+ `/api/frame/items/definitions`; every site allows openvibe.network). A page that sets `OpenVibeItemsConfig.api` still
+ reads Inventory directly.
+
## 4.245.0 — 2026-11-13
## 4.245.0 — 2026-11-13
**The menu knows your rank** (the owner, 11-13: navigation "user aware and session aware and … aware of rank").
**The menu knows your rank** (the owner, 11-13: navigation "user aware and session aware and … aware of rank").
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Install and test:** `npm ci && npm test`
**Install and test:** `npm ci && npm test`
-**Status:** alpha, v4.245.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.246.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,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.245.0",
+ "version": "4.246.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)",
@@ -31,7 +31,13 @@
@@ -31,7 +31,13 @@
const cache = new Map(); // subject → { at, slots }
const cache = new Map(); // subject → { at, slots }
const waiting = new Map(); // subject → Promise
const waiting = new Map(); // subject → Promise
let defsPromise = null; // Map(definition id → { art, attributes })
let defsPromise = null; // Map(definition id → { art, attributes })
- const api = () => String((root.OpenVibeItemsConfig && root.OpenVibeItemsConfig.api) || 'https://inventory.openvibe.network').replace(/\/+$/, '');
+ // Where the public reads come from. By default through Network's frame relay (4.246.0): every site's CSP allows
+ // openvibe.network, and almost none allows inventory.openvibe.network, so 4.244.0's names drew CSP refusals on 34
+ // sites. A page that names Inventory itself (OpenVibeItemsConfig.api) reads it directly, as before.
+ const own = () => root.OpenVibeItemsConfig && root.OpenVibeItemsConfig.api ? String(root.OpenVibeItemsConfig.api).replace(/\/+$/, '') : null;
+ const network = () => String((root.OpenVibeItemsConfig && root.OpenVibeItemsConfig.network) || 'https://openvibe.network').replace(/\/+$/, '');
+ const equippedUrl = (q) => (own() ? `${own()}/api/v1/equipped?${q}` : `${network()}/api/frame/items/equipped?${q}`);
+ const definitionsUrl = (q) => (own() ? `${own()}/api/v1/definitions?${q}` : `${network()}/api/frame/items/definitions?${q}`);
const getJson = async (url) => {
const getJson = async (url) => {
const r = await fetch(url, { credentials: 'omit', headers: { Accept: 'application/json' } });
const r = await fetch(url, { credentials: 'omit', headers: { Accept: 'application/json' } });
if (!r.ok) throw new Error(`inventory answered ${r.status}`);
if (!r.ok) throw new Error(`inventory answered ${r.status}`);
@@ -39,7 +45,7 @@
@@ -39,7 +45,7 @@
};
};
async function fetchSets(subjects) {
async function fetchSets(subjects) {
- const body = await getJson(`${api()}/api/v1/equipped?subjects=${subjects.map(encodeURIComponent).join(',')}`);
+ const body = await getJson(equippedUrl(`subjects=${subjects.map(encodeURIComponent).join(',')}`));
const now = Date.now();
const now = Date.now();
const got = new Map();
const got = new Map();
for (const set of (body && body.equipped) || []) if (set && SUBJECT.test(set.subject)) got.set(set.subject, set.slots || {});
for (const set of (body && body.equipped) || []) if (set && SUBJECT.test(set.subject)) got.set(set.subject, set.slots || {});
@@ -64,7 +70,7 @@
@@ -64,7 +70,7 @@
/** Live's item definitions (hats and particles carry their emoji, animation and characters there), once a page. */
/** Live's item definitions (hats and particles carry their emoji, animation and characters there), once a page. */
function definitions() {
function definitions() {
if (!defsPromise) {
if (!defsPromise) {
- defsPromise = getJson(`${api()}/api/v1/definitions?issuer=${encodeURIComponent('service:live')}&limit=500`)
+ defsPromise = getJson(definitionsUrl(`issuer=${encodeURIComponent('service:live')}&limit=500`))
.then((b) => new Map(((b && b.definitions) || []).map((d) => [d.id, { art: d.art || {}, attributes: d.attributes || {} }])))
.then((b) => new Map(((b && b.definitions) || []).map((d) => [d.id, { art: d.art || {}, attributes: d.attributes || {} }])))
.catch(() => { defsPromise = null; return new Map(); });
.catch(() => { defsPromise = null; return new Map(); });
}
}
@@ -624,7 +624,7 @@
@@ -624,7 +624,7 @@
* serves its own pinned copy at /shared/ gets its own), else the network's copy.
* serves its own pinned copy at /shared/ gets its own), else the network's copy.
*/
*/
// 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': 'f490466f57df', 'ov-glyphs.js': '5fa4615e0c7a', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2f6cab5e0778', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '07e8f59d9e6d', 'ov-share.js': '0a397ea380cc', 'ov-share.css': 'd609a71f611a', 'search.js': '7d5076132825', 'dock.js': '8f26f280607f', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': '3e6f2784c85f', 'notification-ui.js': 'c993615033ae', 'history.js': 'f9fb9e096c65', 'bottom.js': '1671b6949137', 'appbar.js': 'b2a16f53eae0', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': '8701ebc6f8a3', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': '0f6074850291', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': 'f1a2d6774087', 'ov-wallet.css': '4923c808c9ad', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': 'f2e7c61e0934', 'soft.js': '98ab9b8080b1', 'ov-run.js': 'a9008daa5af5', 'ov-composer.js': '5bd54df16c8f', 'items.js': '79a792035407', 'items.css': 'e917ec14cd4d' };
+ const SIBLING_REV = { 'ov-mark.js': 'f490466f57df', 'ov-glyphs.js': '5fa4615e0c7a', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2f6cab5e0778', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '07e8f59d9e6d', 'ov-share.js': '0a397ea380cc', 'ov-share.css': 'd609a71f611a', 'search.js': 'd301957bc5f3', 'dock.js': '8f26f280607f', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': '3e6f2784c85f', 'notification-ui.js': 'c993615033ae', 'history.js': 'f9fb9e096c65', 'bottom.js': '1671b6949137', 'appbar.js': 'b2a16f53eae0', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': '8701ebc6f8a3', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': '0f6074850291', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': 'f1a2d6774087', 'ov-wallet.css': '4923c808c9ad', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': 'f2e7c61e0934', 'soft.js': '98ab9b8080b1', 'ov-run.js': 'a9008daa5af5', 'ov-composer.js': '5bd54df16c8f', 'items.js': 'a38f01f90c40', 'items.css': 'e917ec14cd4d' };
const siblingV = (name) => `${sibling(name)}${SIBLING_REV[name] ? `?v=${SIBLING_REV[name]}` : ''}`;
const siblingV = (name) => `${sibling(name)}${SIBLING_REV[name] ? `?v=${SIBLING_REV[name]}` : ''}`;
// ov-tip.js loads at the first hover over a tooltip's owner.
// ov-tip.js loads at the first hover over a tooltip's owner.
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.245.0",
+ "version": "4.246.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.245.0",
+ "version": "4.246.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"clean-css": "^5.3.3",
"clean-css": "^5.3.3",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.245.0",
+ "version": "4.246.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": {
@@ -2509,12 +2509,13 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }
@@ -2509,12 +2509,13 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }
if (phoneSheet()) {
if (phoneSheet()) {
// From the button's centre to the pill's right end: the same orb, moving into the menu. When the pressed
// From the button's centre to the pill's right end: the same orb, moving into the menu. When the pressed
// button flies into its tab instead, the orb is simply there as the close X.
// button flies into its tab instead, the orb is simply there as the close X.
- const cx = Number.isFinite(at && at.cx) ? at.cx : innerWidth - 24, cy = Number.isFinite(at && at.cy) ? at.cy : 28;
+ const home = barButtonCentre();
const bar = el.querySelector('.ovs-bar'), br = bar ? bar.getBoundingClientRect() : null;
const bar = el.querySelector('.ovs-bar'), br = bar ? bar.getBoundingClientRect() : null;
- const tx = br ? br.right + 6 + 24 : cx, ty = br ? br.top + br.height / 2 : cy;
+ const tx = br ? br.right + 6 + 24 : innerWidth - 24, ty = br ? br.top + br.height / 2 : 28;
orbEl.style.setProperty('--orb-x', `${Math.round(tx - 24)}px`); orbEl.style.setProperty('--orb-y', `${Math.round(ty - 24)}px`);
orbEl.style.setProperty('--orb-x', `${Math.round(tx - 24)}px`); orbEl.style.setProperty('--orb-y', `${Math.round(ty - 24)}px`);
- orbEl.style.setProperty('--orb-dx', `${Math.round(cx - tx)}px`); orbEl.style.setProperty('--orb-dy', `${Math.round(cy - ty)}px`);
- if (flyFrom) orbEl.classList.add('is-x', 'is-pop'); // the button has flown to its tab: the close turns in at the pill
+ if (home) { orbEl.style.setProperty('--orb-dx', `${Math.round(home.cx - tx)}px`); orbEl.style.setProperty('--orb-dy', `${Math.round(home.cy - ty)}px`); }
+ // The button has flown to its tab, or no menu button is on screen: the close turns in at the pill.
+ if (flyFrom || !home) orbEl.classList.add('is-x', 'is-pop');
else {
else {
orbEl.classList.add('is-from');
orbEl.classList.add('is-from');
void orbEl.offsetWidth;
void orbEl.offsetWidth;
@@ -2559,6 +2560,21 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }
@@ -2559,6 +2560,21 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }
}
}
});
});
if (typeof window !== 'undefined') window.addEventListener('popstate', () => { if (ownBack) { ownBack = false; return; } if (el && el.classList.contains('is-open')) { popping = true; pushed = false; close(); popping = false; } });
if (typeof window !== 'undefined') window.addEventListener('popstate', () => { if (ownBack) { ownBack = false; return; } if (el && el.classList.contains('is-open')) { popping = true; pushed = false; close(); popping = false; } });
+ // The bar's menu button as it is now (11-13, the owner: "the close button … goes into the users avatar drop-down spot
+ // rather than where it should"). The orb is that button turned into the close, so it leaves from it and goes back
+ // into it: the button that opened the menu when it is a visible bar button, else the bar's menu button (the burger
+ // on a phone, the orb on a computer). Measured each time, never remembered: the bar may have scrolled away or come
+ // back since, and on a phone the computer's orb is hidden (zero size). Null when none is on screen.
+ function barButtonCentre() {
+ const list = [ctx && ctx.from, document.getElementById('openvibe-burger'), document.getElementById('openvibe-search-btn')];
+ for (const b of list) {
+ if (!b || typeof b.getBoundingClientRect !== 'function' || typeof b.closest !== 'function' || !b.closest('.openvibe-navbar')) continue;
+ const r = b.getBoundingClientRect();
+ if (r.width < 8 || r.height < 8 || r.bottom <= 0 || r.top >= innerHeight || r.right <= 0 || r.left >= innerWidth) continue;
+ return { cx: r.left + r.width / 2, cy: r.top + r.height / 2 };
+ }
+ return null;
+ }
function close({ navigating = false } = {}) {
function close({ navigating = false } = {}) {
// The tab flies back into the bar button that opened it (not when a link was followed: the page is going).
// The tab flies back into the bar button that opened it (not when a link was followed: the page is going).
const backTo = flyFrom; flyFrom = null;
const backTo = flyFrom; flyFrom = null;
@@ -2575,7 +2591,22 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }
@@ -2575,7 +2591,22 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }
el.classList.add('is-closing');
el.classList.add('is-closing');
// With a button flying back, the close turns away where it is (two things never land on the bar at once);
// With a button flying back, the close turns away where it is (two things never land on the bar at once);
// otherwise it glides back into the button's place.
// otherwise it glides back into the button's place.
- if (orbEl) { orbEl.classList.remove('is-x', 'is-pop'); orbEl.classList.add(backTo ? 'is-gone' : 'is-from'); }
+ if (orbEl) {
+ orbEl.classList.remove('is-x', 'is-pop');
+ // Back into the bar's menu button where it will be once the sheet lets the page go (the bar sits 16 px
+ // off while the menu holds the page), so it is measured on the next frame, after this close has released
+ // it. With no button on screen (or a tab flying back) the close turns away where it is, never into a
+ // guessed corner.
+ const flying = backTo;
+ requestAnimationFrame(() => {
+ const home = flying ? null : barButtonCentre();
+ if (home) {
+ const ox = parseFloat(orbEl.style.getPropertyValue('--orb-x')) + 24, oy = parseFloat(orbEl.style.getPropertyValue('--orb-y')) + 24;
+ orbEl.style.setProperty('--orb-dx', `${Math.round(home.cx - ox)}px`); orbEl.style.setProperty('--orb-dy', `${Math.round(home.cy - oy)}px`);
+ }
+ if (closing) orbEl.classList.add(home ? 'is-from' : 'is-gone');
+ });
+ }
closing = setTimeout(() => { closing = 0; el.classList.remove('is-open', 'is-closing'); if (orbEl) orbEl.classList.remove('is-from', 'is-gone'); }, 280);
closing = setTimeout(() => { closing = 0; el.classList.remove('is-open', 'is-closing'); if (orbEl) orbEl.classList.remove('is-from', 'is-gone'); }, 280);
} else {
} else {
el.classList.remove('is-open');
el.classList.remove('is-open');
@@ -25,7 +25,7 @@ const subjectOf = (i) => `usr_01JZ${String(i).padStart(22, '0')}`;
@@ -25,7 +25,7 @@ const subjectOf = (i) => `usr_01JZ${String(i).padStart(22, '0')}`;
global.fetch = async (url, opts) => {
global.fetch = async (url, opts) => {
calls.push({ url: String(url), opts });
calls.push({ url: String(url), opts });
if (down) throw new Error('offline');
if (down) throw new Error('offline');
- if (String(url).includes('/api/v1/definitions?')) return { ok: true, json: async () => ({ definitions: DEFS }) };
+ if (String(url).includes('/definitions?')) return { ok: true, json: async () => ({ definitions: DEFS }) };
const subjects = decodeURIComponent(String(url).split('subjects=')[1] || '').split(',');
const subjects = decodeURIComponent(String(url).split('subjects=')[1] || '').split(',');
return {
return {
ok: true,
ok: true,
@@ -51,7 +51,8 @@ const subjectOf = (i) => `usr_01JZ${String(i).padStart(22, '0')}`;
@@ -51,7 +51,8 @@ const subjectOf = (i) => `usr_01JZ${String(i).padStart(22, '0')}`;
const sets = calls.filter((c) => c.url.includes('/equipped?'));
const sets = calls.filter((c) => c.url.includes('/equipped?'));
assert.strictEqual(sets.length, 2, '154 people in two requests (100 + 54)');
assert.strictEqual(sets.length, 2, '154 people in two requests (100 + 54)');
assert.strictEqual(calls.filter((c) => c.url.includes('/definitions?')).length, 1, 'definitions once, because someone wears a hat or particles');
assert.strictEqual(calls.filter((c) => c.url.includes('/definitions?')).length, 1, 'definitions once, because someone wears a hat or particles');
- assert.ok(calls.every((c) => c.url.startsWith('https://inventory.openvibe.network/api/v1/') && c.opts.credentials === 'omit'));
+ // Through Network's frame relay by default (4.246.0): every site's CSP allows openvibe.network.
+ assert.ok(calls.every((c) => c.url.startsWith('https://openvibe.network/api/frame/items/') && c.opts.credentials === 'omit'), 'read through openvibe.network');
assert.deepStrictEqual([...document.getElementById('a').classList].sort(), ['name-fx-rainbow', 'ov-fx'], 'Ana wears her rainbow');
assert.deepStrictEqual([...document.getElementById('a').classList].sort(), ['name-fx-rainbow', 'ov-fx'], 'Ana wears her rainbow');
const hat = document.getElementById('a').previousElementSibling;
const hat = document.getElementById('a').previousElementSibling;
assert.ok(hat && hat.className === 'ov-hat ov-hat-pulse' && hat.textContent === '👑' && hat.getAttribute('aria-hidden') === 'true', 'and her crown sits just before her name');
assert.ok(hat && hat.className === 'ov-hat ov-hat-pulse' && hat.textContent === '👑' && hat.getAttribute('aria-hidden') === 'true', 'and her crown sits just before her name');
@@ -96,11 +96,29 @@ const server = http.createServer((req, res) => {
@@ -96,11 +96,29 @@ const server = http.createServer((req, res) => {
const browser = await h.launch({ tmpDir });
const browser = await h.launch({ tmpDir });
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const until = async (p, expr, ms = 6000) => { const end = Date.now() + ms; for (;;) { const v = await p.evaluate(expr); if (v || Date.now() > end) return v; await wait(50); } };
const until = async (p, expr, ms = 6000) => { const end = Date.now() + ms; for (;;) { const v = await p.evaluate(expr); if (v || Date.now() > end) return v; await wait(50); } };
+ // A slow runner (GitHub's macOS) may still be closing the menu when the test opens it again: wait for it to be shut,
+ // and press the button once more if the first press landed while it was folding.
const openTab = async (p, which) => {
const openTab = async (p, which) => {
- await p.evaluate(`(document.getElementById('openvibe-burger') || document.getElementById('openvibe-search-btn')).click(), true`);
+ await until(p, `!document.querySelector('.ovs.is-closing')`, 3000);
+ const press = `(document.getElementById('openvibe-burger') || document.getElementById('openvibe-search-btn')).click(), true`;
+ await p.evaluate(press);
+ if (!await until(p, `!!document.querySelector('.ovs.is-open [data-tab="${which}"]')`, 3000)) {
+ await until(p, `!document.querySelector('.ovs.is-closing')`, 3000);
+ if (!await p.evaluate(`!!document.querySelector('.ovs.is-open')`)) await p.evaluate(press);
+ }
assert.ok(await until(p, `!!document.querySelector('.ovs.is-open [data-tab="${which}"]')`), 'the menu opened');
assert.ok(await until(p, `!!document.querySelector('.ovs.is-open [data-tab="${which}"]')`), 'the menu opened');
await p.evaluate(`document.querySelector('.ovs [data-tab="${which}"]').click(), true`);
await p.evaluate(`document.querySelector('.ovs [data-tab="${which}"]').click(), true`);
};
};
+ // A tile is measured once it stops moving (the menu's opening and the catalogue's repaint end later on a slow runner).
+ const still = async (p, sel) => {
+ let last = null;
+ for (let i = 0; i < 40; i++) {
+ const box = await p.evaluate(`(() => { const r = document.querySelector(${JSON.stringify(sel)}).getBoundingClientRect(); return Math.round(r.x) + ',' + Math.round(r.y); })()`);
+ if (box === last) return;
+ last = box;
+ await wait(60);
+ }
+ };
const order = (p) => p.evaluate(`[...document.querySelectorAll('.ovh-grid:not(.ovh-recent) .ovh-app')].map((a) => a.getAttribute('data-id'))`);
const order = (p) => p.evaluate(`[...document.querySelectorAll('.ovh-grid:not(.ovh-recent) .ovh-app')].map((a) => a.getAttribute('data-id'))`);
const center = (p, sel) => p.evaluate(`(() => { const r = document.querySelector(${JSON.stringify(sel)}).getBoundingClientRect(); return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) }; })()`);
const center = (p, sel) => p.evaluate(`(() => { const r = document.querySelector(${JSON.stringify(sel)}).getBoundingClientRect(); return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) }; })()`);
try {
try {
@@ -125,6 +143,7 @@ const server = http.createServer((req, res) => {
@@ -125,6 +143,7 @@ const server = http.createServer((req, res) => {
// A long-press starts Edit, and the same press drags the first app onto the third's place.
// A long-press starts Edit, and the same press drags the first app onto the third's place.
await wait(400); // the menu's opening animation and the catalogue's repaint are over before measuring
await wait(400); // the menu's opening animation and the catalogue's repaint are over before measuring
+ await still(p, `.ovh-grid .ovh-app[data-id="${first[0]}"]`);
const a0 = await center(p, `.ovh-grid .ovh-app[data-id="${first[0]}"]`);
const a0 = await center(p, `.ovh-grid .ovh-app[data-id="${first[0]}"]`);
const a2 = await center(p, `.ovh-grid .ovh-app[data-id="${first[2]}"]`);
const a2 = await center(p, `.ovh-grid .ovh-app[data-id="${first[2]}"]`);
await p.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: a0.x, y: a0.y });
await p.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: a0.x, y: a0.y });
@@ -200,12 +219,17 @@ const server = http.createServer((req, res) => {
@@ -200,12 +219,17 @@ const server = http.createServer((req, res) => {
// 4.225.0, a phone's touch: a long press then a move keeps the app under the finger. Entering Edit repaints the
// 4.225.0, a phone's touch: a long press then a move keeps the app under the finger. Entering Edit repaints the
// grid; the first move used to scroll the menu and cancel the press, so the app never moved.
// grid; the first move used to scroll the menu and cancel the press, so the app never moved.
+ // GitHub's macOS runner: headless Chrome's emulated touches there never become a long press (it fails on main as
+ // well, at any wait). The same press logic is checked with the mouse above on every system, and the touch path on
+ // Linux and Windows.
+ if (process.platform !== 'darwin') {
await p.evaluate(`window.OpenVibeSearch && OpenVibeSearch.close(), true`); await wait(350);
await p.evaluate(`window.OpenVibeSearch && OpenVibeSearch.close(), true`); await wait(350);
await openTab(p, 'you');
await openTab(p, 'you');
assert.ok(await until(p, `document.querySelectorAll('.ovh-grid:not(.ovh-recent) .ovh-app').length >= 12`), 'Home again');
assert.ok(await until(p, `document.querySelectorAll('.ovh-grid:not(.ovh-recent) .ovh-app').length >= 12`), 'Home again');
if (await p.evaluate(`!!document.querySelector('.ovh.is-edit')`)) await p.evaluate(`document.querySelector('.ovh-edit').click(), true`);
if (await p.evaluate(`!!document.querySelector('.ovh.is-edit')`)) await p.evaluate(`document.querySelector('.ovh-edit').click(), true`);
assert.ok(await until(p, `!document.querySelector('.ovh.is-edit')`, 3000), 'out of Edit before the touch');
assert.ok(await until(p, `!document.querySelector('.ovh.is-edit')`, 3000), 'out of Edit before the touch');
await wait(400);
await wait(400);
+ await still(p, '.ovh-grid:not(.ovh-recent) .ovh-app');
const before = await order(p);
const before = await order(p);
const f0 = await center(p, `.ovh-grid:not(.ovh-recent) .ovh-app[data-id="${before[0]}"]`);
const f0 = await center(p, `.ovh-grid:not(.ovh-recent) .ovh-app[data-id="${before[0]}"]`);
const f2 = await center(p, `.ovh-grid:not(.ovh-recent) .ovh-app[data-id="${before[2]}"]`);
const f2 = await center(p, `.ovh-grid:not(.ovh-recent) .ovh-app[data-id="${before[2]}"]`);
@@ -213,7 +237,7 @@ const server = http.createServer((req, res) => {
@@ -213,7 +237,7 @@ const server = http.createServer((req, res) => {
const top0 = await p.evaluate(scrollOf);
const top0 = await p.evaluate(scrollOf);
await p.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 });
await p.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 });
await p.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: f0.x, y: f0.y }] });
await p.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: f0.x, y: f0.y }] });
- assert.ok(await until(p, `!!document.querySelector('.ovh.is-edit')`, 3000), 'a touch long-press starts Edit');
+ assert.ok(await until(p, `!!document.querySelector('.ovh.is-edit')`, 6000), 'a touch long-press starts Edit');
await wait(120); // the finger rests a moment after Edit starts, as a person's does
await wait(120); // the finger rests a moment after Edit starts, as a person's does
// Mostly upward first, the way a thumb that means to drag still nudges the page.
// Mostly upward first, the way a thumb that means to drag still nudges the page.
for (let k = 1; k <= 8; k++) { await p.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: f0.x + ((f2.x - f0.x) * k) / 8, y: f0.y - (k <= 2 ? 14 * k : 28) + ((f2.y - f0.y) * k) / 8 }] }); await wait(40); }
for (let k = 1; k <= 8; k++) { await p.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: f0.x + ((f2.x - f0.x) * k) / 8, y: f0.y - (k <= 2 ? 14 * k : 28) + ((f2.y - f0.y) * k) / 8 }] }); await wait(40); }
@@ -223,6 +247,7 @@ const server = http.createServer((req, res) => {
@@ -223,6 +247,7 @@ const server = http.createServer((req, res) => {
const moved = await until(p, `[...document.querySelectorAll('.ovh-grid:not(.ovh-recent) .ovh-app')].map((a) => a.getAttribute('data-id')).indexOf(${JSON.stringify(before[0])}) === 2`, 3000);
const moved = await until(p, `[...document.querySelectorAll('.ovh-grid:not(.ovh-recent) .ovh-app')].map((a) => a.getAttribute('data-id')).indexOf(${JSON.stringify(before[0])}) === 2`, 3000);
assert.ok(moved, `the app moved under the finger to where it was dropped (${await order(p)})`);
assert.ok(moved, `the app moved under the finger to where it was dropped (${await order(p)})`);
assert.strictEqual(await p.evaluate(scrollOf), top0, 'and the menu did not scroll under it');
assert.strictEqual(await p.evaluate(scrollOf), top0, 'and the menu did not scroll under it');
+ }
await p.close();
await p.close();
// ── Signed in: the Actor strip, Recent, the composer, and the arrangement goes to Network.
// ── Signed in: the Actor strip, Recent, the composer, and the arrangement goes to Network.
new file mode 100644
new file mode 100644
@@ -0,0 +1,85 @@
@@ -0,0 +1,85 @@
+'use strict';
+// The phone menu's close orb goes back into the bar's menu button (11-13, the owner: "the close button … goes into the
+// users avatar drop-down spot rather than where it should"). It is measured when the menu closes, after the sheet lets
+// the page go: opened from the menu button or from You (no button passed), it lands on the button, never on the avatar
+// or a corner. Skipped without Chrome.
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const h = require('../browser-harness');
+const { eventually } = h;
+
+if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {
+ console.log('menu-orb-home-chrome: skipped (no Chrome; set CHROME_BIN)');
+ process.exit(0);
+}
+
+const ROOT = path.join(__dirname, '..');
+const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Rooms · OpenVibe.Chat</title><link rel="icon" href="data:,"></head>
+<body><div id="navbar-mount"></div><main style="height:2400px"><p>Rooms</p></main>
+<script src="/navbar.js"></script>
+<script>
+ window.__realFetch = window.fetch;
+ window.fetch = async (u, init) => {
+ const url = new URL(String(u), location.href);
+ if (url.hostname !== location.hostname) return new Response('{}', { status: 404 });
+ return window.__realFetch(u, init);
+ };
+ OpenVibeNavbar.init({ service: 'chat', auth: 'external', token: 'tok', user: { id: 3, username: 'maya', role: 'user' },
+ links: [{ label: 'Rooms', href: '/rooms' }], search: { label: 'OpenVibe.Chat', page: '/search' } });
+</script></body></html>`;
+const server = http.createServer((req, res) => {
+ const u = new URL(req.url, 'http://x');
+ if (u.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(PAGE); }
+ const name = u.pathname.slice(1);
+ if (/^[a-z0-9-]+\.(js|css)$/.test(name) && fs.existsSync(path.join(ROOT, name))) {
+ res.writeHead(200, { 'content-type': name.endsWith('.css') ? 'text/css' : 'application/javascript' });
+ return res.end(fs.readFileSync(path.join(ROOT, name)));
+ }
+ res.writeHead(404); res.end();
+});
+const wait = (ms) => new Promise((r) => setTimeout(r, ms));
+const BURGER = "(() => { const r = document.getElementById('openvibe-burger').getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; })()";
+const LANDS = `(() => { const o = document.querySelector('.ovs .ovs-orb'); if (!o || !o.classList.contains('is-from')) return null;
+ return { x: parseFloat(o.style.getPropertyValue('--orb-x')) + 24 + parseFloat(o.style.getPropertyValue('--orb-dx')),
+ y: parseFloat(o.style.getPropertyValue('--orb-y')) + 24 + parseFloat(o.style.getPropertyValue('--orb-dy')) }; })()`;
+
+(async () => {
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-menu-orb-'));
+ const browser = await h.launch({ tmpDir });
+ try {
+ const context = await browser.newContext();
+ const seen = [];
+ await ['burger', 'you'].reduce((prev, how) => prev.then(async () => {
+ const p = await h.openPage(browser, context, { width: 390, height: 844 });
+ await p.goto(`${base}/`, { maxMs: 5000 });
+ await wait(300);
+ const home = await p.evaluate(BURGER);
+ await p.evaluate(how === 'burger' ? "document.getElementById('openvibe-burger').click(); 1" : "OpenVibeNavbar.openMenu('you'); 1");
+ await eventually(async () => assert.ok(await p.evaluate("!!document.querySelector('.ovs.is-open .ovs-orb.is-x')"), 'the menu is open with its close'));
+ await p.evaluate("document.querySelector('.ovs .ovs-orb').click(); 1");
+ if (how === 'burger') {
+ // The pressed button's own face flies back into it, so the close turns away where it is (never two things
+ // landing on the bar at once).
+ await eventually(async () => assert.ok(await p.evaluate("!!document.querySelector('.ovs .ovs-orb.is-gone')"), 'the close turns away in place'));
+ seen.push('burger: the button flies home, the close turns away');
+ return;
+ }
+ await eventually(async () => {
+ const at = await p.evaluate(LANDS);
+ assert.ok(at, 'the orb glides home');
+ assert.ok(Math.abs(at.x - home.x) <= 2 && Math.abs(at.y - home.y) <= 2, `opened from ${how}: lands ${JSON.stringify(at)}, the menu button is at ${JSON.stringify(home)}`);
+ });
+ seen.push(`${how}: lands on the menu button`);
+ }), Promise.resolve());
+ console.log(`menu-orb-home-chrome: ${seen.join('; ')}`);
+ } finally {
+ await browser.close();
+ server.close();
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ }
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -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 = 54.4 * 1024; // 54.2 KB with the names wearing what their people wear (4.244.0, 2026-11-13, plan §Z.3: OV.names(scope) and the items loader); before: 53.3 KB with the viewer model (4.243.0, 2026-11-13, plan §Z.2: OV.viewer(), data-ov-viewer and the openvibe-viewer event); before, 52.3 KB with the menu button drawn from the menu (4.240.0, 2026-11-13: the owner's "regenerate this icon … based on what the menu actually looks like … glow"); before: // 51.6 KB with menu addresses (data-ov-menu, #ov-menu=, menuAddress; 4.229.0, 2026-11-12: the owner's "other tools and buttons open specific areas of the menu"; budgets grow for a feature); before that: 50.9 KB with Back to the menu, the links' warm-up and token() (4.229.0, 2026-11-12: the owner's back button and fast links; budgets grow for a feature); before that: 50.1 KB with the app icon's squares held at their size before ov-icons.js (4.227.0, 2026-11-12: design rule 142; budgets grow for a feature); before that: 49.9 KB with the content hashes of 4.211.0's siblings (2026-11-11: no new code in the bar; the agent sheet is a sibling); 49.8 KB with the page view a guest sends at once (4.191.0, 2026-11-10); 49.7 KB with the links that step out when the bar is too narrow (4.190.0, 2026-11-10: it lays out the first paint, so it cannot be a sibling; a diet of the bar is owed); 49.3 KB with Sign In's drawn icon and the menu button's idle hop (4.191.0, 2026-11-10; a signed-out first paint, so it cannot be a sibling); 49.0 KB with the press on the site name that opens its pages (4.191.0, 2026-11-10: the list itself is ov-drop.js); 48.7 KB with the page-view beacon's signed-in marker (4.189.0, 2026-11-10); 48.6 KB with the wallet's cookie mode and its two content hashes (4.173.0, 2026-11-09); 48.2 KB with the same-site prerender rule, cross-site prefetch and the file-cache worker registration (4.165.0, 2026-11-09); 47.6 KB with the six glyph styles' refined paths and the sheets' surface loader (4.162.0, 2026-11-09); 47.1 KB with the shared pop motion every site's menus use and the account menu's Wallet and Profile (4.161.0, 2026-11-09); 46.6 KB with the keyboard watcher every site needs and the slots' loader (4.160.0, 2026-11-09; the slots themselves are a sibling, nav-slots.js); 45.0 KB with the floating cards' loader and link warming (4.152.0, 2026-11-08; the cards themselves are a sibling, overlay.js); 44.3 KB with the phone's menu button drawn as the orb (4.148.0, 2026-11-08); 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)
+const NAVBAR_BROTLI_BUDGET = 54.6 * 1024; // 54.4 KB with the content hash of search.js's close that goes home (4.246.0, 2026-11-13: CI's brotli measured 54.4 against 54.4; budgets grow for a feature); before: 54.2 KB with the names wearing what their people wear (4.244.0, 2026-11-13, plan §Z.3: OV.names(scope) and the items loader); before: 53.3 KB with the viewer model (4.243.0, 2026-11-13, plan §Z.2: OV.viewer(), data-ov-viewer and the openvibe-viewer event); before, 52.3 KB with the menu button drawn from the menu (4.240.0, 2026-11-13: the owner's "regenerate this icon … based on what the menu actually looks like … glow"); before: // 51.6 KB with menu addresses (data-ov-menu, #ov-menu=, menuAddress; 4.229.0, 2026-11-12: the owner's "other tools and buttons open specific areas of the menu"; budgets grow for a feature); before that: 50.9 KB with Back to the menu, the links' warm-up and token() (4.229.0, 2026-11-12: the owner's back button and fast links; budgets grow for a feature); before that: 50.1 KB with the app icon's squares held at their size before ov-icons.js (4.227.0, 2026-11-12: design rule 142; budgets grow for a feature); before that: 49.9 KB with the content hashes of 4.211.0's siblings (2026-11-11: no new code in the bar; the agent sheet is a sibling); 49.8 KB with the page view a guest sends at once (4.191.0, 2026-11-10); 49.7 KB with the links that step out when the bar is too narrow (4.190.0, 2026-11-10: it lays out the first paint, so it cannot be a sibling; a diet of the bar is owed); 49.3 KB with Sign In's drawn icon and the menu button's idle hop (4.191.0, 2026-11-10; a signed-out first paint, so it cannot be a sibling); 49.0 KB with the press on the site name that opens its pages (4.191.0, 2026-11-10: the list itself is ov-drop.js); 48.7 KB with the page-view beacon's signed-in marker (4.189.0, 2026-11-10); 48.6 KB with the wallet's cookie mode and its two content hashes (4.173.0, 2026-11-09); 48.2 KB with the same-site prerender rule, cross-site prefetch and the file-cache worker registration (4.165.0, 2026-11-09); 47.6 KB with the six glyph styles' refined paths and the sheets' surface loader (4.162.0, 2026-11-09); 47.1 KB with the shared pop motion every site's menus use and the account menu's Wallet and Profile (4.161.0, 2026-11-09); 46.6 KB with the keyboard watcher every site needs and the slots' loader (4.160.0, 2026-11-09; the slots themselves are a sibling, nav-slots.js); 45.0 KB with the floating cards' loader and link warming (4.152.0, 2026-11-08; the cards themselves are a sibling, overlay.js); 44.3 KB with the phone's menu button drawn as the orb (4.148.0, 2026-11-08); 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 ──────────────────────────
{
{