openvibers/openvibe.shared Commits

4.142.0: the menu has a close button on a phone (#331)

OP OpenVibers committed 5 days ago · c984c4d358 · 7 files, +113 −6

Since 4.129.0 the menu covers the whole phone screen, the burger included, but search.js still hid Close at 860 px
and below on a burger site, so only Back closed it (the owner, 11-07: "This needs a close button"). A round x
(40 px, 48 px hit area, "Close the menu") now ends the search pill at 860 px and below; wide screens keep Close.
Test: test/menu-close-chrome.test.js (fails on 4.141.0: the button is not shown).

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex caab51f..64b02e6 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,13 @@
# Changelog
++## 4.142.0 — 2026-11-07
++
++- **The menu has a close button on a phone** (the owner, 11-07: "This needs a close button"). Since 4.129.0 the menu
++ covers the whole phone screen, the navbar's burger included, but `search.js` still hid its Close button at 860 px and
++ below on a site with a burger, so only Back closed it. Now a round x (40 px, a 48 px hit area, "Close the menu" for a
++ screen reader) sits at the end of the search pill at 860 px and below; a wide screen keeps the word Close. Test:
++ `test/menu-close-chrome.test.js` (a phone layout: shown, on screen, the hit area, a tap closes; wide: the word).
++
## 4.141.0 — 2026-11-07
- **The forms kit loads when a form control is drawn, not merely present** (the owner, 11-07: "make things faster and
ddiff --git a/README.md b/README.md
iindex 8101f23..b1205dc 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.141.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.142.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 cf2d660..f3616a6 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,13 +4,14 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.141.0",
++ "version": "4.142.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)",
"contracts": "openvibe-contracts v0.312.0 (devDependency: the release manifest contract)",
"sdk": "openvibe-sdk v0.94.0",
"features": [
++ "the menu on a phone has a round close button at the end of the search pill: it covers the whole screen, burger too (4.142.0)",
"the forms kit loads when a form control is drawn, not merely present: a closed dialog's select costs nothing (4.141.0)",
"the Compose export maps Material 3's container roles (cards, the navigation bar and its selected pill, dialogs) to Vibe surfaces, so native apps never show Material's lavender (4.140.0)",
"the menu's feed rows with a picture show the picture, not a site icon beside it; a row without one in that group keeps its icon in a picture-sized tile (4.139.0)",
ddiff --git a/navbar.js b/navbar.js
iindex 76aab0e..25a9c25 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -513,7 +513,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': '7d4affbb5093', 'ov-share.css': 'cbd18e6ce991', 'search.js': 'c9c68464d19e', 'dock.js': 'e13066db0aac', 'presence.js': '1b675c37d888', 'panels.js': '7d8267ca8035', 'notification-ui.js': '3ceb5266e7b8', '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': '7d4affbb5093', 'ov-share.css': 'cbd18e6ce991', 'search.js': '295b2fffb4e1', 'dock.js': 'e13066db0aac', 'presence.js': '1b675c37d888', 'panels.js': '7d8267ca8035', 'notification-ui.js': '3ceb5266e7b8', '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 9a84d2a..6bdb17f 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.141.0",
++ "version": "4.142.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 abf0979..52d5c27 100644
---- a/search.js
++++ b/search.js
@@@ -176,7 +176,17 @@
.ovs-ghost { position: absolute; inset: 0; display: flex; align-items: center; white-space: pre; overflow: hidden; pointer-events: none; color: var(--text-muted, #93a0b4); opacity: .75; }
.ovs-ghost b { color: transparent; font-weight: inherit; }
.ovs-x { flex: none; height: 36px; padding: 0 12px; border: 0; border-radius: 10px; background: transparent; color: var(--text-muted, #93a0b4); font: inherit; font-size: 13px; cursor: pointer; }
--@media (max-width: 860px) { html.ovnav-has-burger .ovs-x { display: none; } }
++.ovs-x svg { display: none; width: 20px; height: 20px; }
++.ovs-x:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
++/* Phones and small windows: the menu covers the screen (the bar under it too), so Close is always there, a round x at
++ the end of the search pill (the owner, 11-07: "This needs a close button"). The hit area is 48 px. */
++@media (max-width: 860px) {
++ .ovs-x { position: relative; display: grid; place-items: center; width: 40px; height: 40px; padding: 0; border-radius: 50%; background: var(--bg-hover, rgba(255,255,255,.07)); color: var(--text-secondary, #a8b3c4); }
++ .ovs-x::after { content: ""; position: absolute; inset: -4px; }
++ .ovs-x svg { display: block; }
++ .ovs-x span { display: none; }
++ .ovs-x:active { background: var(--bg-active, rgba(255,255,255,.14)); color: var(--text-primary, #fff); }
++}
.ovs-tabs { display: flex; gap: 4px; padding: 0 12px 8px; border-bottom: 1px solid var(--border, rgba(255,255,255,.1)); }
.ovs-tabs[hidden] { display: none; }
.ovs-tab { flex: none; height: 32px; padding: 0 14px; border: 0; border-radius: 999px; background: transparent; color: var(--text-secondary, #a8b3c4); font: inherit; font-size: 13.5px; font-weight: 650; cursor: pointer; }
@@@ -299,6 +309,7 @@
const ICON = {
search: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="6.5"/><path d="m20 20-4.2-4.2"/></svg>',
++ close: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><path d="M6.5 6.5l11 11M17.5 6.5l-11 11"/></svg>',
clock: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/></svg>',
go: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 5 7 7-7 7"/></svg>',
spark: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8Z"/></svg>',
@@@ -313,7 +324,7 @@
el.setAttribute('role', 'dialog');
el.setAttribute('aria-modal', 'true');
el.setAttribute('aria-label', 'Search and every OpenVibe site');
-- el.innerHTML = `<div class="ovs-panel"><div class="ovs-bar">${ICON.search}<div class="ovs-field"><span class="ovs-ghost" aria-hidden="true"></span><input class="ovs-input" type="search" role="combobox" aria-autocomplete="both" aria-expanded="true" aria-controls="ovs-list" autocomplete="off" spellcheck="false" enterkeyhint="search" maxlength="100"></div><button type="button" class="ovs-x">Close</button></div>`
++ el.innerHTML = `<div class="ovs-panel"><div class="ovs-bar">${ICON.search}<div class="ovs-field"><span class="ovs-ghost" aria-hidden="true"></span><input class="ovs-input" type="search" role="combobox" aria-autocomplete="both" aria-expanded="true" aria-controls="ovs-list" autocomplete="off" spellcheck="false" enterkeyhint="search" maxlength="100"></div><button type="button" class="ovs-x" aria-label="Close the menu" title="Close (Esc)">${ICON.close}<span>Close</span></button></div>`
+ '<div class="ovs-tabs ovs-starttabs" hidden></div>'
+ `<div class="ovs-tabs ovs-rtabs" role="tablist" aria-label="Results from" hidden>${RTABS.map(([id, label]) => `<button type="button" class="ovs-tab" role="tab" data-rtab="${id}" aria-selected="false">${label}</button>`).join('')}</div>`
+ '<ul class="ovs-list" id="ovs-list" role="listbox"></ul><div class="ovs-foot" aria-hidden="true"><span><kbd>↑</kbd><kbd>↓</kbd> move</span><span><kbd>Enter</kbd> open</span><span><kbd>Tab</kbd> complete</span><span><kbd>Esc</kbd> close</span></div></div>';
ddiff --git a/test/menu-close-chrome.test.js b/test/menu-close-chrome.test.js
nnew file mode 100644
iindex 0000000..1e841aa
---- /dev/null
++++ b/test/menu-close-chrome.test.js
@@@ -0,0 +1,87 @@
++'use strict';
++// The menu on a phone has a close button (the owner, 11-07: "This needs a close button"). The menu covers the whole
++// screen, the navbar's burger too, so a round x sits at the end of the search pill: a 40 px button with a 48 px hit
++// area, named for a screen reader, and a tap closes the menu. On a wide screen the button keeps its word, Close.
++// Skipped without Chrome.
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const h = require('../browser-harness');
++
++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {
++ console.log('menu-close-chrome: skipped (no Chrome; set CHROME_BIN)');
++ process.exit(0);
++}
++
++const ROOT = path.join(__dirname, '..');
++const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Rooms · OpenVibe.Chat</title><link rel="icon" href="data:,"></head>
++<body><div id="navbar-mount"></div><main><p>Rooms</p></main>
++<script src="/navbar.js"></script>
++<script>
++ window.__realFetch = window.fetch;
++ window.fetch = async (u, init) => {
++ const url = new URL(String(u), location.href);
++ if (url.hostname !== location.hostname) return new Response('{}', { status: 404 });
++ return window.__realFetch(u, init);
++ };
++ OpenVibeNavbar.init({ service: 'chat', user: null, links: [{ label: 'Rooms', href: '/rooms', icon: 'fa-people-group' }, { label: 'Servers', href: '/servers', icon: 'fa-server' }],
++ search: { label: 'OpenVibe.Chat', page: '/search' } });
++</script></body></html>`;
++const server = http.createServer((req, res) => {
++ const u = new URL(req.url, 'http://x');
++ if (u.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(PAGE); }
++ const name = u.pathname.slice(1);
++ if (/^[a-z-]+\.js$/.test(name) && fs.existsSync(path.join(ROOT, name))) { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(ROOT, name))); }
++ res.writeHead(404); res.end();
++});
++
++const wait = (ms) => new Promise((r) => setTimeout(r, ms));
++const BUTTON = `(() => { const b = document.querySelector('.ovs.is-open .ovs-x'); if (!b) return null; const r = b.getBoundingClientRect(); const cs = getComputedStyle(b);
++ const svg = b.querySelector('svg'); const word = b.querySelector('span');
++ return { shown: cs.display !== 'none' && cs.visibility !== 'hidden' && r.width > 0, w: Math.round(r.width), h: Math.round(r.height), right: r.right, top: r.top,
++ vw: document.documentElement.clientWidth, label: b.getAttribute('aria-label'), icon: !!svg && getComputedStyle(svg).display !== 'none',
++ word: !!word && getComputedStyle(word).display !== 'none' }; })()`;
++
++async function openMenu(p) {
++ await p.evaluate('document.dispatchEvent(new KeyboardEvent("keydown", { key: "/", bubbles: true })); 1');
++ for (let i = 0; i < 50 && !(await p.evaluate('!!document.querySelector(".ovs.is-open .ovs-input")')); i++) await wait(100);
++ await wait(450); // the phone's reveal animation
++}
++
++(async () => {
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-menu-close-'));
++ const browser = await h.launch({ tmpDir });
++ try {
++ const context = await browser.newContext();
++ const p = await h.openPage(browser, context, { width: 390, height: 844 });
++ await p.goto(`${base}/`, { maxMs: 5000 });
++ await wait(200);
++ await openMenu(p);
++ const x = await p.evaluate(BUTTON);
++ assert.ok(x && x.shown, 'the close button shows on a phone');
++ assert.strictEqual(x.vw, 390, 'a phone layout');
++ assert.ok(x.w >= 40 && x.h >= 40 && x.right <= x.vw && x.top >= 0, `a 40 px target on screen: ${JSON.stringify(x)}`);
++ assert.deepStrictEqual([x.label, x.icon, x.word], ['Close the menu', true, false], 'a round x, named for a screen reader');
++ const hit = await p.evaluate(`(() => { const b = document.querySelector('.ovs.is-open .ovs-x').getBoundingClientRect(); const e = document.elementFromPoint(b.left - 2, b.top + b.height / 2); return !!e && !!e.closest('.ovs-x'); })()`);
++ assert.ok(hit, 'the hit area reaches past the circle');
++ await p.evaluate('document.querySelector(".ovs.is-open .ovs-x").click(); 1');
++ await wait(150);
++ assert.strictEqual(await p.evaluate('!!document.querySelector(".ovs.is-open")'), false, 'a tap closes the menu');
++
++ const wide = await h.openPage(browser, context, { width: 1280, height: 900 });
++ await wide.goto(`${base}/`, { maxMs: 5000 });
++ await wait(200);
++ await openMenu(wide);
++ const d = await wide.evaluate(BUTTON);
++ assert.ok(d && d.shown && d.word && !d.icon, `a wide screen keeps the word Close: ${JSON.stringify(d)}`);
++ console.log('menu-close-chrome: a phone has a round close button (40 px, 48 px hit area) that closes the menu; wide screens keep Close');
++ } finally {
++ await browser.close();
++ server.close();
++ fs.rmSync(tmpDir, { recursive: true, force: true });
++ }
++})().catch((e) => { console.error(e); process.exit(1); });