openvibers/openvibe.shared Commits
4.151.0: the menu marks the page you are on; on a phone it opens without the keyboard (#341)
Changes
@@@ -1,5 +1,15 @@# Changelog++## 4.151.0 — 2026-11-08++++- **The menu marks the page you are on** (the owner, on openvibe.chat/messages: "highlight based on page"). In++ "On <site>", the link whose path is this page's, or the longest one this page sits under (/messages/abc under++ Messages), wears the accent and `aria-current="page"`; "/" only on the home itself; another site's link never.++- **On a phone the menu opens without the keyboard.** The orb opens a menu, not a search, so the field is no longer++ focused when it opens there (the keyboard covered half the menu); focus goes to the close orb until the person taps++ the field. On a computer, and when the menu opens with "/", the field is focused as before.++- Test: `test/menu-here-current-chrome.test.js`.++## 4.150.0 — 2026-11-08- **The menu's button turns into its close, on top of the menu** (the owner: "make it render on top … a cool effect as@@@ -2,7 +2,7 @@> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.--**Status:** alpha, v4.150.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.151.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.150.0",++ "version": "4.151.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)",@@@ -539,7 +539,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': 'fa4ded2928f4', 'ov-share.css': 'cbd18e6ce991', 'search.js': 'cff638dd174c', 'dock.js': 'ef5c198edf47', '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' };++ 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': 'fa4ded2928f4', 'ov-share.css': 'cbd18e6ce991', 'search.js': 'c036db4ac93a', 'dock.js': 'ef5c198edf47', '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' };// 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">.@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.150.0",++ "version": "4.151.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -251,6 +251,8 @@.ovs-link { display: flex; align-items: center; gap: 10px; min-width: 0; min-height: 44px; padding: 0 10px; border-radius: 12px; color: inherit; text-decoration: none; font-size: 14px; font-weight: 600; }.ovs-link svg { width: 16px; height: 16px; flex: none; color: var(--text-muted, #93a0b4); }.ovs-link span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.ovs-link.is-here { background: color-mix(in srgb, var(--accent, #3b82f6) 14%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent, #3b82f6) 42%, transparent); color: var(--text-primary, #e6edf3); }++.ovs-link.is-here svg { color: var(--accent, #3b82f6); }.ovs-livebar { display: flex; gap: 8px; overflow-x: auto; scrollbar-width: none; padding: 8px 6px 2px; }.ovs-livebar::-webkit-scrollbar { display: none; }.ovs-live { flex: none; display: inline-flex; align-items: center; gap: 8px; height: 44px; padding: 0 16px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.12)); color: inherit; text-decoration: none; font-size: 13.5px; font-weight: 650; }@@@ -941,6 +943,17 @@// This site's own cards (a drop zone on Download, go-live status on Live, a new task on Codes…).for (const c of cards) groups.push({ title: c.title || '', as: 'card', card: c, section: `card:${c.id}`, items: [] });const pages = (ctx.links || []).filter((l) => l && l.label && typeof l.href === 'string' && !l.hidden).map((l) => ({ title: l.label, url: safeUrl(l.href), icon: 'go', drawn: l.icon && typeof ctx.navIcon === 'function' ? ctx.navIcon(l.icon) : '', kind: 'page' })).filter((x) => x.url);++ // The page the person is on wears the highlight (owner 11-08): the link whose path is this page's, or the longest one++ // this page sits under (/messages/abc under Messages); "/" only on the home itself.++ let best = -1, bestLen = -1;++ pages.forEach((x, i) => {++ let u; try { u = new URL(x.url, root.location.href); } catch { return; }++ if (u.origin !== root.location.origin) return;++ const p = u.pathname.replace(/\/+$/, '') || '/', at = root.location.pathname.replace(/\/+$/, '') || '/';++ const len = p === at ? 10000 + p.length : (p !== '/' && at.startsWith(`${p}/`) ? p.length : -1);++ if (len > bestLen) { best = i; bestLen = len; }++ });++ if (best >= 0) pages[best].here = true;if (pages.length) groups.push({ title: `On ${here()}`, as: 'links', items: pages, section: 'here' });const fgroups = f && f.groups && f.groups.length ? f.groups.slice() : [{ id: '', name: 'OpenVibe' }];if (mineSite) fgroups.sort((a, b) => (b.id === mineSite.group) - (a.id === mineSite.group));@@@ -1240,7 +1253,9 @@input.value = opts.q || '';paintGhost();render(input.value);-- setTimeout(() => input.focus(), 0);++ // On a phone the orb opens a menu, not a search: focusing the field would raise the keyboard over the menu (owner++ // 11-08), so focus stays on the close orb until the person taps the field. A query, or a computer, still types.++ setTimeout(() => { if (phoneSheet() && !opts.q && !opts.search && orbEl && orbEl.isConnected) orbEl.focus({ preventScroll: true }); else input.focus(); }, 0);// What makes it the person's own arrives after the first paint: their history, and the tools catalog.mine = null;loadHistory().then((h) => { if (h && el.classList.contains('is-open')) { mine = h; paintGhost(); render(input.value); } });nnew file mode 100644@@@ -0,0 +1,86 @@++'use strict';++// The menu marks the page you are on (owner, 11-08, on openvibe.chat/messages: "highlight based on page"): in "On <site>",++// the link whose path is this page's, or the longest one this page sits under, wears is-here and aria-current; "/" only++// on the home itself. And on a phone the orb opens a menu, not a search: the field is not focused, so the keyboard does++// not come up over the menu (it still is on a computer, and when the menu opens with "/"). A real headless Chrome;++// 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-here-current-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: 'Vibe', href: '/' }, { label: 'Global chat', href: '/global' }, { label: 'Messages', href: '/messages' }, { label: 'Message settings', href: '/messages/settings' }, { label: 'Elsewhere', href: 'https://example.test/messages/abc' }];++const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Here</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));++</script>++<script src="/shared/navbar.js" defer></script></head><body><div id="navbar-mount"></div>++<script>document.addEventListener('DOMContentLoaded', function () { OpenVibeNavbar.init({ service: 'site', user: null, links: ${JSON.stringify(LINKS)}, countViews: false, notifications: false }); });</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])));++ }++ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(PAGE);++});++const HERE = `[...document.querySelectorAll('.ovs-links .ovs-link')].filter((a) => a.classList.contains('is-here') || a.getAttribute('aria-current')).map((a) => a.textContent + ':' + a.getAttribute('aria-current'))`;++++(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-here-'));++ 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); } };++ const openWith = async (p, how) => {++ if (how === 'button') await p.evaluate(`(document.getElementById('openvibe-burger') || document.getElementById('openvibe-search-btn')).click(), true`);++ else await p.evaluate(`document.dispatchEvent(new KeyboardEvent('keydown', { key: '/', bubbles: true })), true`);++ assert.ok(await until(p, `!!document.querySelector('.ovs.is-open .ovs-links')`), 'the menu opened with this site\'s links');++ await wait(120);++ };++ const visit = async (p, where) => {++ await p.goto(`${base}${where}`, { maxMs: 5000 });++ assert.ok(await until(p, `!!document.getElementById('openvibe-burger')`), 'the phone menu button is there');++ await openWith(p, 'button');++ return p.evaluate(HERE);++ };++ try {++ const context = await browser.newContext();++ const blocked = ['*openvibe.tools*', '*openvibe.media*', '*jsdelivr*'];++ // A page under Messages, on a phone, opened from the orb.++ const p = await h.openPage(browser, context, { width: 390, height: 844, block: blocked });++ assert.deepStrictEqual(await visit(p, '/messages/abc'), ['Messages:page'], 'the longest link this page sits under; not Vibe ("/"), not another site');++ assert.notStrictEqual(await p.evaluate(`!!(document.activeElement && document.activeElement.classList.contains('ovs-input'))`), true, 'on a phone the field is not focused (no keyboard over the menu)');++ assert.strictEqual(await p.evaluate(`!!(document.activeElement && document.activeElement.closest('.ovs'))`), true, 'focus is inside the menu');++ // An exact match wins over a shorter one; the home marks "/" only on itself; a page with no link marks none.++ assert.deepStrictEqual(await visit(p, '/messages/settings'), ['Message settings:page'], 'the exact page');++ assert.deepStrictEqual(await visit(p, '/'), ['Vibe:page'], 'the home');++ assert.deepStrictEqual(await visit(p, '/nowhere'), [], 'a page with no link of its own marks none');++++ // On a computer the menu is a search: the field is focused.++ const d = await h.openPage(browser, context, { width: 1280, height: 900, block: blocked });++ await d.goto(`${base}/global`, { maxMs: 5000 });++ assert.ok(await until(d, `!!document.querySelector('.openvibe-navbar')`), 'the navbar is up');++ await openWith(d, 'key');++ assert.deepStrictEqual(await d.evaluate(HERE), ['Global chat:page'], 'the same highlight on a computer');++ assert.ok(await until(d, `!!(document.activeElement && document.activeElement.classList.contains('ovs-input'))`, 1500), 'on a computer the field is focused');++ console.log('menu-here-current-chrome: ok');++ } finally {++ await browser.close();++ server.close();++ fs.rmSync(tmpDir, { recursive: true, force: true });++ }++})().catch((err) => { console.error(err); process.exit(1); });
@@ -1,5 +1,15 @@
@@ -1,5 +1,15 @@
# Changelog
# Changelog
+## 4.151.0 — 2026-11-08
+
+- **The menu marks the page you are on** (the owner, on openvibe.chat/messages: "highlight based on page"). In
+ "On <site>", the link whose path is this page's, or the longest one this page sits under (/messages/abc under
+ Messages), wears the accent and `aria-current="page"`; "/" only on the home itself; another site's link never.
+- **On a phone the menu opens without the keyboard.** The orb opens a menu, not a search, so the field is no longer
+ focused when it opens there (the keyboard covered half the menu); focus goes to the close orb until the person taps
+ the field. On a computer, and when the menu opens with "/", the field is focused as before.
+- Test: `test/menu-here-current-chrome.test.js`.
+
## 4.150.0 — 2026-11-08
## 4.150.0 — 2026-11-08
- **The menu's button turns into its close, on top of the menu** (the owner: "make it render on top … a cool effect as
- **The menu's button turns into its close, on top of the menu** (the owner: "make it render on top … a cool effect as
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
-**Status:** alpha, v4.150.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.151.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.150.0",
+ "version": "4.151.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)",
@@ -539,7 +539,7 @@
@@ -539,7 +539,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': 'fa4ded2928f4', 'ov-share.css': 'cbd18e6ce991', 'search.js': 'cff638dd174c', 'dock.js': 'ef5c198edf47', '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' };
+ 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': 'fa4ded2928f4', 'ov-share.css': 'cbd18e6ce991', 'search.js': 'c036db4ac93a', 'dock.js': 'ef5c198edf47', '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' };
// 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">.
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.150.0",
+ "version": "4.151.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": {
@@ -251,6 +251,8 @@
@@ -251,6 +251,8 @@
.ovs-link { display: flex; align-items: center; gap: 10px; min-width: 0; min-height: 44px; padding: 0 10px; border-radius: 12px; color: inherit; text-decoration: none; font-size: 14px; font-weight: 600; }
.ovs-link { display: flex; align-items: center; gap: 10px; min-width: 0; min-height: 44px; padding: 0 10px; border-radius: 12px; color: inherit; text-decoration: none; font-size: 14px; font-weight: 600; }
.ovs-link svg { width: 16px; height: 16px; flex: none; color: var(--text-muted, #93a0b4); }
.ovs-link svg { width: 16px; height: 16px; flex: none; color: var(--text-muted, #93a0b4); }
.ovs-link span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ovs-link span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ovs-link.is-here { background: color-mix(in srgb, var(--accent, #3b82f6) 14%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent, #3b82f6) 42%, transparent); color: var(--text-primary, #e6edf3); }
+.ovs-link.is-here svg { color: var(--accent, #3b82f6); }
.ovs-livebar { display: flex; gap: 8px; overflow-x: auto; scrollbar-width: none; padding: 8px 6px 2px; }
.ovs-livebar { display: flex; gap: 8px; overflow-x: auto; scrollbar-width: none; padding: 8px 6px 2px; }
.ovs-livebar::-webkit-scrollbar { display: none; }
.ovs-livebar::-webkit-scrollbar { display: none; }
.ovs-live { flex: none; display: inline-flex; align-items: center; gap: 8px; height: 44px; padding: 0 16px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.12)); color: inherit; text-decoration: none; font-size: 13.5px; font-weight: 650; }
.ovs-live { flex: none; display: inline-flex; align-items: center; gap: 8px; height: 44px; padding: 0 16px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.12)); color: inherit; text-decoration: none; font-size: 13.5px; font-weight: 650; }
@@ -941,6 +943,17 @@
@@ -941,6 +943,17 @@
// This site's own cards (a drop zone on Download, go-live status on Live, a new task on Codes…).
// This site's own cards (a drop zone on Download, go-live status on Live, a new task on Codes…).
for (const c of cards) groups.push({ title: c.title || '', as: 'card', card: c, section: `card:${c.id}`, items: [] });
for (const c of cards) groups.push({ title: c.title || '', as: 'card', card: c, section: `card:${c.id}`, items: [] });
const pages = (ctx.links || []).filter((l) => l && l.label && typeof l.href === 'string' && !l.hidden).map((l) => ({ title: l.label, url: safeUrl(l.href), icon: 'go', drawn: l.icon && typeof ctx.navIcon === 'function' ? ctx.navIcon(l.icon) : '', kind: 'page' })).filter((x) => x.url);
const pages = (ctx.links || []).filter((l) => l && l.label && typeof l.href === 'string' && !l.hidden).map((l) => ({ title: l.label, url: safeUrl(l.href), icon: 'go', drawn: l.icon && typeof ctx.navIcon === 'function' ? ctx.navIcon(l.icon) : '', kind: 'page' })).filter((x) => x.url);
+ // The page the person is on wears the highlight (owner 11-08): the link whose path is this page's, or the longest one
+ // this page sits under (/messages/abc under Messages); "/" only on the home itself.
+ let best = -1, bestLen = -1;
+ pages.forEach((x, i) => {
+ let u; try { u = new URL(x.url, root.location.href); } catch { return; }
+ if (u.origin !== root.location.origin) return;
+ const p = u.pathname.replace(/\/+$/, '') || '/', at = root.location.pathname.replace(/\/+$/, '') || '/';
+ const len = p === at ? 10000 + p.length : (p !== '/' && at.startsWith(`${p}/`) ? p.length : -1);
+ if (len > bestLen) { best = i; bestLen = len; }
+ });
+ if (best >= 0) pages[best].here = true;
if (pages.length) groups.push({ title: `On ${here()}`, as: 'links', items: pages, section: 'here' });
if (pages.length) groups.push({ title: `On ${here()}`, as: 'links', items: pages, section: 'here' });
const fgroups = f && f.groups && f.groups.length ? f.groups.slice() : [{ id: '', name: 'OpenVibe' }];
const fgroups = f && f.groups && f.groups.length ? f.groups.slice() : [{ id: '', name: 'OpenVibe' }];
if (mineSite) fgroups.sort((a, b) => (b.id === mineSite.group) - (a.id === mineSite.group));
if (mineSite) fgroups.sort((a, b) => (b.id === mineSite.group) - (a.id === mineSite.group));
@@ -1240,7 +1253,9 @@
@@ -1240,7 +1253,9 @@
input.value = opts.q || '';
input.value = opts.q || '';
paintGhost();
paintGhost();
render(input.value);
render(input.value);
- setTimeout(() => input.focus(), 0);
+ // On a phone the orb opens a menu, not a search: focusing the field would raise the keyboard over the menu (owner
+ // 11-08), so focus stays on the close orb until the person taps the field. A query, or a computer, still types.
+ setTimeout(() => { if (phoneSheet() && !opts.q && !opts.search && orbEl && orbEl.isConnected) orbEl.focus({ preventScroll: true }); else input.focus(); }, 0);
// What makes it the person's own arrives after the first paint: their history, and the tools catalog.
// What makes it the person's own arrives after the first paint: their history, and the tools catalog.
mine = null;
mine = null;
loadHistory().then((h) => { if (h && el.classList.contains('is-open')) { mine = h; paintGhost(); render(input.value); } });
loadHistory().then((h) => { if (h && el.classList.contains('is-open')) { mine = h; paintGhost(); render(input.value); } });
new file mode 100644
new file mode 100644
@@ -0,0 +1,86 @@
@@ -0,0 +1,86 @@
+'use strict';
+// The menu marks the page you are on (owner, 11-08, on openvibe.chat/messages: "highlight based on page"): in "On <site>",
+// the link whose path is this page's, or the longest one this page sits under, wears is-here and aria-current; "/" only
+// on the home itself. And on a phone the orb opens a menu, not a search: the field is not focused, so the keyboard does
+// not come up over the menu (it still is on a computer, and when the menu opens with "/"). A real headless Chrome;
+// 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-here-current-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: 'Vibe', href: '/' }, { label: 'Global chat', href: '/global' }, { label: 'Messages', href: '/messages' }, { label: 'Message settings', href: '/messages/settings' }, { label: 'Elsewhere', href: 'https://example.test/messages/abc' }];
+const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Here</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));
+</script>
+<script src="/shared/navbar.js" defer></script></head><body><div id="navbar-mount"></div>
+<script>document.addEventListener('DOMContentLoaded', function () { OpenVibeNavbar.init({ service: 'site', user: null, links: ${JSON.stringify(LINKS)}, countViews: false, notifications: false }); });</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])));
+ }
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(PAGE);
+});
+const HERE = `[...document.querySelectorAll('.ovs-links .ovs-link')].filter((a) => a.classList.contains('is-here') || a.getAttribute('aria-current')).map((a) => a.textContent + ':' + a.getAttribute('aria-current'))`;
+
+(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-here-'));
+ 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); } };
+ const openWith = async (p, how) => {
+ if (how === 'button') await p.evaluate(`(document.getElementById('openvibe-burger') || document.getElementById('openvibe-search-btn')).click(), true`);
+ else await p.evaluate(`document.dispatchEvent(new KeyboardEvent('keydown', { key: '/', bubbles: true })), true`);
+ assert.ok(await until(p, `!!document.querySelector('.ovs.is-open .ovs-links')`), 'the menu opened with this site\'s links');
+ await wait(120);
+ };
+ const visit = async (p, where) => {
+ await p.goto(`${base}${where}`, { maxMs: 5000 });
+ assert.ok(await until(p, `!!document.getElementById('openvibe-burger')`), 'the phone menu button is there');
+ await openWith(p, 'button');
+ return p.evaluate(HERE);
+ };
+ try {
+ const context = await browser.newContext();
+ const blocked = ['*openvibe.tools*', '*openvibe.media*', '*jsdelivr*'];
+ // A page under Messages, on a phone, opened from the orb.
+ const p = await h.openPage(browser, context, { width: 390, height: 844, block: blocked });
+ assert.deepStrictEqual(await visit(p, '/messages/abc'), ['Messages:page'], 'the longest link this page sits under; not Vibe ("/"), not another site');
+ assert.notStrictEqual(await p.evaluate(`!!(document.activeElement && document.activeElement.classList.contains('ovs-input'))`), true, 'on a phone the field is not focused (no keyboard over the menu)');
+ assert.strictEqual(await p.evaluate(`!!(document.activeElement && document.activeElement.closest('.ovs'))`), true, 'focus is inside the menu');
+ // An exact match wins over a shorter one; the home marks "/" only on itself; a page with no link marks none.
+ assert.deepStrictEqual(await visit(p, '/messages/settings'), ['Message settings:page'], 'the exact page');
+ assert.deepStrictEqual(await visit(p, '/'), ['Vibe:page'], 'the home');
+ assert.deepStrictEqual(await visit(p, '/nowhere'), [], 'a page with no link of its own marks none');
+
+ // On a computer the menu is a search: the field is focused.
+ const d = await h.openPage(browser, context, { width: 1280, height: 900, block: blocked });
+ await d.goto(`${base}/global`, { maxMs: 5000 });
+ assert.ok(await until(d, `!!document.querySelector('.openvibe-navbar')`), 'the navbar is up');
+ await openWith(d, 'key');
+ assert.deepStrictEqual(await d.evaluate(HERE), ['Global chat:page'], 'the same highlight on a computer');
+ assert.ok(await until(d, `!!(document.activeElement && document.activeElement.classList.contains('ovs-input'))`, 1500), 'on a computer the field is focused');
+ console.log('menu-here-current-chrome: ok');
+ } finally {
+ await browser.close();
+ server.close();
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ }
+})().catch((err) => { console.error(err); process.exit(1); });