openvibers/openvibe.shared Commits

4.151.0: the menu marks the page you are on; on a phone it opens without the keyboard (#341)

OP OpenVibers committed 5 days ago · 459bf90fc1 · 7 files, +116 −5

* 4.151.0: the menu marks the page you are on; on a phone it opens without the keyboard

The owner, on openvibe.chat/messages: highlight the current page in the menu. And the keyboard came up over the
menu on a phone because the field was focused on open.

* sibling-revs: search.js's new content hash

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 6c2221d..3809f3d 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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
ddiff --git a/README.md b/README.md
iindex 7b0f326..cc7bdf3 100644
---- a/README.md
++++ b/README.md
@@@ -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 deployed
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
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex 16f404b..a98dc43 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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)",
ddiff --git a/navbar.js b/navbar.js
iindex e314f46..86a4ad4 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -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">.
ddiff --git a/package.json b/package.json
iindex e441dfa..7c9a579 100644
---- a/package.json
++++ b/package.json
@@@ -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": {
ddiff --git a/search.js b/search.js
iindex 06b8993..43ebc8b 100644
---- a/search.js
++++ b/search.js
@@@ -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); } });
ddiff --git a/test/menu-here-current-chrome.test.js b/test/menu-here-current-chrome.test.js
nnew file mode 100644
iindex 0000000..653001b
---- /dev/null
++++ b/test/menu-here-current-chrome.test.js
@@@ -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); });