openvibers/openvibe.shared Commits

Changing pages: the menu closes, the page is marked, a glowing top bar (#357)

OP OpenVibers committed 4 days ago · bd49589eb7 · 6 files, +147 −7

* Changing pages: the menu closes, the page is marked, a glowing top bar

The owner, 11-09 (menu open on openvibe.cam, Home chosen, Camera still lit): a page chosen in the menu closes it
(folding into the orb on a phone) without spending Back; a menu still open when a page is swapped in place marks the
new page; a link that leaves the page draws the top bar at once. The bar (navbar and Boost alike) is 3 px of the
OpenVibe gradient from the theme's accents with a soft glow and a bright running head; reduced motion keeps it still.

Test: menu-navigate-chrome (3/3 repeated).

* Re-apply the navbar's top bar lost in the rebase; a deterministic leave test

The rebase onto #354/#355 kept main's navbar.js and dropped this branch's own change (the window click listener and the
bar's CSS). Re-applied; the test stops the plain link's navigation with a listener registered after the navbar's, so the
bar is read on the page instead of racing a 204.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 26c2954..5058b64 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -37,6 +37,11 @@
- **No pill above the headline.** `sc.hero()` no longer draws an eyebrow (the owner, 11-09: the pill above every home's
headline "looks really cheesy… generic"); the headline and its lede say what the site is. `eyebrow` is ignored.
++- **Changing pages feels like one app** (owner, 11-09). A page chosen in the menu closes it (folding into the orb on a
++ phone) without spending Back; a menu still open when a page is swapped in place marks the new page; a link that leaves
++ the page draws the top bar at once. The bar (navbar and Boost alike) is 3 px of the OpenVibe gradient from the theme's
++ accents, with a soft glow and a bright running head; reduced motion keeps it still.
++
## 4.158.0 — 2026-11-09
- **The menu's Home.** A site whose links never name its home (Exchange: Models, Chat, Arena…) now gets Home first in
ddiff --git a/boost.js b/boost.js
iindex 8095b97..9446b1d 100644
---- a/boost.js
++++ b/boost.js
@@@ -113,7 +113,7 @@
}
// route-transition.js's content hash (scripts/sibling-revs.js writes it), so a release gives it a new URL (4.138.0).
-- const BOOST_REV = { 'route-transition.js': '776674802ce8' };
++ const BOOST_REV = { 'route-transition.js': '7e15e1169d14' };
function fx(mainEl) {
if (o.fx === false) return Promise.resolve(null);
if (root.OVRouteFx) return Promise.resolve(root.OVRouteFx);
ddiff --git a/navbar.js b/navbar.js
iindex eef37f6..4beef99 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -36,6 +36,14 @@
improved"). Never while one of its menus is open. A site turns it off with autoHide: false. */
.openvibe-navbar.ovnav-hide { transform: translateY(calc(-100% - 10px)); box-shadow: none; }
#navbar-mount:has(> .ovnav-hide) { pointer-events: none; }
++ /* The top bar while a page changes (4.159.0): the OpenVibe gradient with a soft glow and a bright head that runs. */
++ .ovrt-bar { position: fixed; left: 0; top: 0; height: 3px; width: 100%; z-index: 2147483000; pointer-events: none; transform-origin: 0 50%; transform: scaleX(0); opacity: 0;
++ background: linear-gradient(90deg, var(--accent, #3b82f6), var(--accent-light, #60a5fa) 60%, var(--accent-2, #a78bfa));
++ box-shadow: 0 0 10px color-mix(in srgb, var(--accent, #3b82f6) 70%, transparent), 0 0 3px var(--accent-light, #60a5fa); border-radius: 0 3px 3px 0; }
++ .ovrt-bar::after { content: ''; position: absolute; right: 0; top: -2px; width: 90px; height: 7px; border-radius: 50%; background: var(--accent-light, #93c5fd); filter: blur(4px); opacity: .9; }
++ .ovrt-bar.is-run { opacity: 1; transform: scaleX(.88); transition: transform 3s cubic-bezier(.08, .7, .2, 1), opacity .1s; }
++ .ovrt-bar.is-done { opacity: 0; transform: scaleX(1); transition: transform .2s ease, opacity .35s .15s; }
++ @media (prefers-reduced-motion: reduce) { .ovrt-bar.is-run, .ovrt-bar.is-done { transition: none; } .ovrt-bar::after { display: none; } }
.openvibe-navbar::before { content: ""; position: absolute; inset: 0 0 -28px; pointer-events: none; z-index: -1; opacity: 0; transition: opacity .45s cubic-bezier(.2,.7,.2,1); background: linear-gradient(180deg, rgba(4,7,14,.62), rgba(4,7,14,.28) 55%, rgba(4,7,14,0)); }
.openvibe-navbar:not(.ovnav-top) { background-color: color-mix(in srgb, var(--bg-secondary, #252530) 84%, transparent); -webkit-backdrop-filter: saturate(1.5) blur(16px); backdrop-filter: saturate(1.5) blur(16px); box-shadow: 0 12px 32px -14px rgba(0,0,0,.6); }
.openvibe-navbar.ovnav-top { background-color: transparent; border-bottom-color: transparent; box-shadow: none; -webkit-backdrop-filter: none; backdrop-filter: none; }
@@@ -539,7 +547,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': '1a0d8ce6909f', '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': '8e55bfe93238', 'ov-share.css': 'cbd18e6ce991', 'search.js': 'b113a2fd8e93', 'dock.js': 'd1354be66c41', 'presence.js': '1b675c37d888', 'panels.js': '7d8267ca8035', 'notification-ui.js': 'a2d37237ba0b', 'history.js': '1e6598f78ba4', 'site-switcher.js': '292bea111aa8', 'appbar.js': '1edf7532f228', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'ba3981246bc0', 'sso-client.js': '52439265b980', 'overlay.js': '25960cf0c075', 'ov-home.js': '267bf753169d' };
++ const SIBLING_REV = { 'ov-mark.js': '1e91c9c0f347', 'ov-glyphs.js': '1a0d8ce6909f', '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': '8e55bfe93238', 'ov-share.css': 'cbd18e6ce991', 'search.js': '04ac8238e9ed', 'dock.js': 'd1354be66c41', 'presence.js': '1b675c37d888', 'panels.js': '7d8267ca8035', 'notification-ui.js': 'a2d37237ba0b', 'history.js': '1e6598f78ba4', 'site-switcher.js': '292bea111aa8', 'appbar.js': '1edf7532f228', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'ba3981246bc0', 'sso-client.js': '52439265b980', 'overlay.js': '25960cf0c075', 'ov-home.js': '267bf753169d' };
// 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">.
@@@ -1328,6 +1336,23 @@
document.addEventListener('pointerover', warm, { passive: true, capture: true });
document.addEventListener('touchstart', warm, { passive: true, capture: true });
document.addEventListener('focusin', warm, { passive: true, capture: true });
++ // A link that leaves this page shows the top bar at once (the owner, 11-09: "a progress indicator at the very
++ // top… a glow… OpenVibe looking"); Boost's own moves draw the same bar (route-transition.js). Listening on the
++ // window, after the page's own handlers: a click a page or Boost took (defaultPrevented) draws nothing here.
++ if (typeof root.addEventListener !== 'function') return;
++ root.addEventListener('click', (e) => {
++ if (e.defaultPrevented || e.button || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
++ const a = e.target && e.target.closest && e.target.closest('a[href]');
++ if (!a || (a.target && a.target !== '_self') || a.hasAttribute('download')) return;
++ let u; try { u = new URL(a.href, root.location.href); } catch { return; }
++ const here = root.location;
++ if (!/^https?:$/.test(u.protocol) || (u.origin === here.origin && u.pathname === here.pathname && u.search === here.search)) return;
++ let b = document.getElementById('ovnav-leave');
++ if (!b) { b = document.createElement('div'); b.id = 'ovnav-leave'; b.className = 'ovrt-bar'; b.setAttribute('aria-hidden', 'true'); document.body.appendChild(b); }
++ b.classList.remove('is-done'); void b.offsetWidth; b.classList.add('is-run');
++ });
++ // Back from the next page (the browser's page cache): the bar is gone.
++ root.addEventListener('pageshow', () => { const b = document.getElementById('ovnav-leave'); if (b) b.classList.remove('is-run'); });
}
// 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 || {})); }); }
ddiff --git a/route-transition.js b/route-transition.js
iindex db84f65..76ad8ec 100644
---- a/route-transition.js
++++ b/route-transition.js
@@@ -16,14 +16,15 @@
.ovrt-wait i{display:block;width:min(200px,50vw);height:3px;border-radius:3px;overflow:hidden;position:relative;background:color-mix(in srgb,var(--accent,#3b82f6) 18%,transparent)}
.ovrt-wait i::after{content:'';position:absolute;inset:0;width:38%;border-radius:3px;background:var(--accent,#3b82f6);animation:ovrtSlide 1.05s cubic-bezier(.45,0,.2,1) infinite}
.ovrt-wait small{font-weight:500;font-size:.76rem;opacity:.85;min-height:1.2em;transition:opacity .2s}
--.ovrt-bar{position:fixed;left:0;top:0;height:2px;width:100%;z-index:2147483000;pointer-events:none;transform-origin:0 50%;transform:scaleX(0);opacity:0;background:linear-gradient(90deg,var(--accent,#3b82f6),var(--accent-light,#60a5fa));box-shadow:0 0 8px color-mix(in srgb,var(--accent,#3b82f6) 55%,transparent)}
--.ovrt-bar.is-run{opacity:1;transform:scaleX(.85);transition:transform 2.6s cubic-bezier(.1,.7,.2,1)}
--.ovrt-bar.is-done{opacity:0;transform:scaleX(1);transition:transform .18s ease,opacity .35s .12s}
++.ovrt-bar{position:fixed;left:0;top:0;height:3px;width:100%;z-index:2147483000;pointer-events:none;transform-origin:0 50%;transform:scaleX(0);opacity:0;background:linear-gradient(90deg,var(--accent,#3b82f6),var(--accent-light,#60a5fa) 60%,var(--accent-2,#a78bfa));box-shadow:0 0 10px color-mix(in srgb,var(--accent,#3b82f6) 70%,transparent),0 0 3px var(--accent-light,#60a5fa);border-radius:0 3px 3px 0}
++.ovrt-bar::after{content:'';position:absolute;right:0;top:-2px;width:90px;height:7px;border-radius:50%;background:var(--accent-light,#93c5fd);filter:blur(4px);opacity:.9}
++.ovrt-bar.is-run{opacity:1;transform:scaleX(.88);transition:transform 3s cubic-bezier(.08,.7,.2,1),opacity .1s}
++.ovrt-bar.is-done{opacity:0;transform:scaleX(1);transition:transform .2s ease,opacity .35s .15s}
.ovrt-enter{animation:ovrtEnter .28s cubic-bezier(.22,1,.36,1) both}
@keyframes ovrtEnter{from{opacity:0}to{opacity:1}}
@keyframes ovrtIn{from{opacity:0}to{opacity:1}}
@keyframes ovrtSlide{from{transform:translateX(-100%)}to{transform:translateX(265%)}}
--@media (prefers-reduced-motion:reduce){.ovrt-enter,.ovrt-wait,.ovrt-wait i::after{animation:none}.ovrt-bar.is-run,.ovrt-bar.is-done{transition:none}}`;
++@media (prefers-reduced-motion:reduce){.ovrt-enter,.ovrt-wait,.ovrt-wait i::after{animation:none}.ovrt-bar.is-run,.ovrt-bar.is-done{transition:none}.ovrt-bar::after{display:none}}`;
const st = document.createElement('style');
st.id = 'ovrt-css';
st.textContent = css;
ddiff --git a/search.js b/search.js
iindex 568f858..6477022 100644
---- a/search.js
++++ b/search.js
@@@ -451,6 +451,11 @@
if (it && it.kind === 'results') { e.preventDefault(); showResults(input.value.trim()); input.focus(); return; }
if (it && it.kind === 'recent') { e.preventDefault(); input.value = it.title; paintGhost(); showResults(it.title); input.focus(); return; }
if (it) remember(it);
++ // A page chosen in the menu (owner, 11-09: "it doesn't update the navigation that's highlighted… the menu
++ // should animate closed when you change pages"): the menu closes as the page changes, whether Boost swaps it in
++ // place or the browser loads the next one. Its Back entry stays, so Back is not spent on it during the move.
++ const link = e.target.closest && e.target.closest('a[href]');
++ if (link && !e.defaultPrevented && !e.button && !e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey && (!link.target || link.target === '_self') && !/^#/.test(link.getAttribute('href'))) close({ navigating: true });
});
document.body.appendChild(el);
}
@@@ -1365,9 +1370,10 @@
}
let pushed = false, popping = false;
if (typeof window !== 'undefined') window.addEventListener('popstate', () => { if (el && el.classList.contains('is-open')) { popping = true; pushed = false; close(); popping = false; } });
-- function close() {
++ function close({ navigating = false } = {}) {
if (view === 'results' && rtabsEl) leaveResults();
if (!el || closing) return;
++ if (navigating) pushed = false;
// Closed by the person (Close, Escape, a tap outside): take back the entry Back would have used.
if (pushed && !popping) { pushed = false; try { if (history.state && history.state.ovsMenu) history.back(); } catch { /* */ } }
// A phone's menu folds back into the orb (its x turns back into dots) before it goes; elsewhere it closes at once.
@@@ -1386,6 +1392,8 @@
if (lastFocus && lastFocus.focus) lastFocus.focus();
}
++ document.addEventListener('ov:boost:load', () => { if (el && el.classList.contains('is-open') && !input.value) render(''); });
++
const api = { open, close, suggest, siteOf, registerCard };
root.OpenVibeSearch = api;
if (typeof module === 'object' && module.exports) module.exports = api;
ddiff --git a/test/menu-navigate-chrome.test.js b/test/menu-navigate-chrome.test.js
nnew file mode 100644
iindex 0000000..18d03ca
---- /dev/null
++++ b/test/menu-navigate-chrome.test.js
@@@ -0,0 +1,101 @@
++'use strict';
++// Changing pages from the menu (owner, 11-09: "when you switch pages… it doesn't update the navigation that's highlighted…
++// the menu should animate closed when you change pages… a progress indicator at the very top… a glow"), in a real
++// headless Chrome:
++// • a page chosen in the menu closes it, and Back is not spent on the menu during the move (a site's in-place move,
++// like Boost's, keeps its own history entry);
++// • a menu still open when a page is swapped in place marks the new page;
++// • a link that leaves the page shows the top bar at once, while the next page loads; a click a page handled itself
++// (defaultPrevented) draws nothing.
++// Skipped without Chrome.
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++
++const ROOT = path.join(__dirname, '..');
++const h = require('../browser-harness');
++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {
++ console.log('menu-navigate-chrome: skipped (no Chrome; set CHROME_BIN)');
++ process.exit(0);
++}
++
++const FRAME = { nav: [{ id: 'chat', name: 'Chat', url: 'https://openvibe.chat/', icon: 'chat', group: 'talk' }], groups: [{ id: 'talk', name: 'Talk' }], soon: [], footer: {} };
++const LINKS = [{ label: 'Home', href: '/' }, { label: 'Camera', href: '/camera' }, { label: 'Elsewhere', href: '/stay' }];
++const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Nav</title><link rel="icon" href="data:,">
++<script>
++ const realFetch = window.fetch;
++ window.fetch = (u, o) => (String(u).startsWith('https://openvibe.network/api/frame?') ? Promise.resolve({ ok: true, json: async () => (${JSON.stringify(FRAME)}) }) : String(u).startsWith('https://') ? Promise.resolve({ ok: false, status: 404, json: async () => ({}) }) : realFetch(u, o));
++ // A stand-in for Boost: same-site links to /camera are swapped in place.
++ document.addEventListener('click', (e) => {
++ const a = e.target.closest && e.target.closest('a[href]');
++ if (!a || new URL(a.href).pathname !== '/camera') return;
++ e.preventDefault();
++ history.pushState({}, '', '/camera');
++ document.dispatchEvent(new CustomEvent('ov:boost:load'));
++ });
++</script>
++<script src="/shared/navbar.js" defer></script></head><body><div id="navbar-mount"></div><main><a id="plain" href="/stay">A plain link</a> <a id="handled" href="/elsewhere">Handled</a></main>
++<script>document.getElementById('handled').addEventListener('click', (e) => e.preventDefault());
++document.addEventListener('DOMContentLoaded', function () { OpenVibeNavbar.init({ service: 'site', user: null, links: ${JSON.stringify(LINKS)}, countViews: false, notifications: false });
++ setTimeout(function () { window.addEventListener('click', function (e) { if (e.target && e.target.id === 'plain') { window.__left = true; e.preventDefault(); } }); }, 0); });</script>
++</body></html>`;
++const server = http.createServer((req, res) => {
++ const u = new URL(req.url, 'http://x');
++ const m = /^\/shared\/([a-z0-9-]+\.js)$/.exec(u.pathname);
++ if (m) {
++ if (!fs.existsSync(path.join(ROOT, m[1]))) { res.writeHead(404); return res.end(); }
++ res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(ROOT, m[1])));
++ }
++ if (u.pathname === '/stay') { res.writeHead(204); return res.end(); } // no content: the browser stays on the page
++ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(PAGE);
++});
++
++(async () => {
++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-nav-'));
++ const browser = await h.launch({ tmpDir });
++ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
++ const until = async (p, expr, ms = 5000) => { const end = Date.now() + ms; for (;;) { const v = await p.evaluate(expr); if (v || Date.now() > end) return v; await wait(50); } };
++ try {
++ const context = await browser.newContext();
++ const blocked = ['*openvibe.tools*', '*openvibe.media*', '*jsdelivr*'];
++ const p = await h.openPage(browser, context, { width: 390, height: 844, block: blocked });
++ await p.goto(`${base}/`, { maxMs: 5000 });
++ assert.ok(await until(p, `!!document.getElementById('openvibe-burger')`), 'the navbar is up');
++
++ // Choose Camera in the menu: the menu closes, the page moves in place, Back still leads to the first page.
++ await p.evaluate(`document.getElementById('openvibe-burger').click(), true`);
++ assert.ok(await until(p, `!!document.querySelector('.ovs.is-open .ovs-links')`), 'the menu opened');
++ const before = await p.evaluate('history.length');
++ await p.evaluate(`[...document.querySelectorAll('.ovs-links .ovs-link')].find((a) => a.textContent === 'Camera').click(), true`);
++ assert.ok(await until(p, `!document.querySelector('.ovs.is-open')`, 1500), 'the menu closed when a page was chosen');
++ await wait(300);
++ assert.strictEqual(await p.evaluate('location.pathname'), '/camera', 'the page moved, and the menu did not step Back over it');
++ assert.ok(await p.evaluate('history.length') >= before, 'no history entry was taken back');
++
++ // Open again on the new page: Camera is marked; swap back to / in place while it is open: Home is marked.
++ await p.evaluate(`document.getElementById('openvibe-burger').click(), true`);
++ assert.ok(await until(p, `!!document.querySelector('.ovs.is-open .ovs-links')`), 'the menu opened again');
++ assert.ok(await until(p, `[...document.querySelectorAll('.ovs-links .is-here')].map((a) => a.textContent).join() === 'Camera'`), 'Camera is marked on /camera');
++ await p.evaluate(`history.pushState({}, '', '/'); document.dispatchEvent(new CustomEvent('ov:boost:load')); true`);
++ assert.ok(await until(p, `[...document.querySelectorAll('.ovs-links .is-here')].map((a) => a.textContent).join() === 'Home'`, 2000), 'a page swapped under the open menu is marked');
++ await p.evaluate(`document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })), true`);
++ await wait(400);
++
++ // A click the page handled draws no bar; a link that leaves draws it at once, while the next page loads.
++ await p.evaluate(`document.getElementById('handled').click(), true`);
++ await wait(100);
++ assert.strictEqual(await p.evaluate(`!!document.querySelector('#ovnav-leave.is-run')`), false, 'a handled click draws nothing');
++ await p.evaluate(`document.getElementById('plain').click(), true`);
++ assert.ok(await until(p, `window.__left === true`, 1500), 'the plain link was clicked');
++ assert.ok(await until(p, `(() => { const b = document.getElementById('ovnav-leave'); return !!b && b.classList.contains('is-run') && getComputedStyle(b).position === 'fixed' && parseFloat(getComputedStyle(b).height) === 3; })()`, 1500), 'the top bar runs as the page is left');
++ console.log('menu-navigate-chrome: a chosen page closes the menu without spending Back, a swapped page is marked, the top bar runs on leaving ok');
++ } finally {
++ await browser.close();
++ server.close();
++ fs.rmSync(tmpDir, { recursive: true, force: true });
++ }
++})().catch((e) => { console.error(e); process.exit(1); });