openvibers/openvibe.shared Commits
Changing pages: the menu closes, the page is marked, a glowing top bar (#357)
Changes
@@@ -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'sheadline "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@@@ -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);@@@ -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 || {})); }); }@@@ -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;@@@ -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;nnew file mode 100644@@@ -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); });
@@ -37,6 +37,11 @@
@@ -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
- **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.
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
## 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
- **The menu's Home.** A site whose links never name its home (Exchange: Models, Chat, Arena…) now gets Home first in
@@ -113,7 +113,7 @@
@@ -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).
// 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) {
function fx(mainEl) {
if (o.fx === false) return Promise.resolve(null);
if (o.fx === false) return Promise.resolve(null);
if (root.OVRouteFx) return Promise.resolve(root.OVRouteFx);
if (root.OVRouteFx) return Promise.resolve(root.OVRouteFx);
@@ -36,6 +36,14 @@
@@ -36,6 +36,14 @@
improved"). Never while one of its menus is open. A site turns it off with autoHide: false. */
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; }
.openvibe-navbar.ovnav-hide { transform: translateY(calc(-100% - 10px)); box-shadow: none; }
#navbar-mount:has(> .ovnav-hide) { pointer-events: 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::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: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; }
.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 @@
@@ -539,7 +547,7 @@
const NAV_ICONS_REV = 'd9a4febbbad7'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
const NAV_ICONS_REV = 'd9a4febbbad7'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
// END generated
// END generated
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
- const SIBLING_REV = { 'ov-mark.js': '1e91c9c0f347', 'ov-glyphs.js': '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
// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest
// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their
// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
@@ -1328,6 +1336,23 @@
@@ -1328,6 +1336,23 @@
document.addEventListener('pointerover', warm, { passive: true, capture: true });
document.addEventListener('pointerover', warm, { passive: true, capture: true });
document.addEventListener('touchstart', warm, { passive: true, capture: true });
document.addEventListener('touchstart', warm, { passive: true, capture: true });
document.addEventListener('focusin', 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.
// The Dock on a tab ('agent' | 'activity'); opts.task marks that step and brings it into view.
function openDock(tab, opts) { loadDock(() => { root.OpenVibeDock.mount(dockOpts()); root.OpenVibeDock.open(Object.assign({ tab }, opts || {})); }); }
function openDock(tab, opts) { loadDock(() => { root.OpenVibeDock.mount(dockOpts()); root.OpenVibeDock.open(Object.assign({ tab }, opts || {})); }); }
@@ -16,14 +16,15 @@
@@ -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{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 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-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}
.ovrt-enter{animation:ovrtEnter .28s cubic-bezier(.22,1,.36,1) both}
@keyframes ovrtEnter{from{opacity:0}to{opacity:1}}
@keyframes ovrtEnter{from{opacity:0}to{opacity:1}}
@keyframes ovrtIn{from{opacity:0}to{opacity:1}}
@keyframes ovrtIn{from{opacity:0}to{opacity:1}}
@keyframes ovrtSlide{from{transform:translateX(-100%)}to{transform:translateX(265%)}}
@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');
const st = document.createElement('style');
st.id = 'ovrt-css';
st.id = 'ovrt-css';
st.textContent = css;
st.textContent = css;
@@ -451,6 +451,11 @@
@@ -451,6 +451,11 @@
if (it && it.kind === 'results') { e.preventDefault(); showResults(input.value.trim()); input.focus(); return; }
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 && it.kind === 'recent') { e.preventDefault(); input.value = it.title; paintGhost(); showResults(it.title); input.focus(); return; }
if (it) remember(it);
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);
document.body.appendChild(el);
}
}
@@ -1365,9 +1370,10 @@
@@ -1365,9 +1370,10 @@
}
}
let pushed = false, popping = false;
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; } });
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 (view === 'results' && rtabsEl) leaveResults();
if (!el || closing) return;
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.
// 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 { /* */ } }
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.
// 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 @@
@@ -1386,6 +1392,8 @@
if (lastFocus && lastFocus.focus) lastFocus.focus();
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 };
const api = { open, close, suggest, siteOf, registerCard };
root.OpenVibeSearch = api;
root.OpenVibeSearch = api;
if (typeof module === 'object' && module.exports) module.exports = api;
if (typeof module === 'object' && module.exports) module.exports = api;
new file mode 100644
new file mode 100644
@@ -0,0 +1,101 @@
@@ -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); });