openvibers/openvibe.shared Commits

4.129.0: one menu (#317)

OP OpenVibers committed 6 days ago · 1f0a59dea1 · 8 files, +57 −18

The burger opens the OpenVibe menu, grown from the burger: this site's pages first, Display at the foot (the same
controls the drawer had). The orb opens the same menu with the places first. The old drawer stays only for sites that
turn the menu off or ask for it (drawer: 'legacy').

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 2a05497..9f97d7e 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,16 @@
# Changelog
++## 4.129.0 — 2026-11-07
++
++- **One menu** (the owner, 11-07, on the right-hand drawer: "sloppy … maybe combining this menu and the other menu and just
++ putting our main button on the right-hand side"). The burger now opens the OpenVibe menu, grown from the burger:
++ - this site's pages lead, all of them (the drawer's job);
++ - then the live bar, your places and workspace, the network, What's new, tools and Jump back in;
++ - **Display** (Light/Dark/Auto, text size, Motion/Calm) sits at the foot, drawn by the same controls the drawer had.
++
++ The orb by the logo opens the same menu with the places first. The old drawer remains only for a site that turns the
++ menu off (`search: false`) or asks for it (`drawer: 'legacy'`). The burger's `aria-expanded` follows the menu.
++
## 4.128.0 — 2026-11-07
- **The menu, rethought around real use** (the owner, 11-07: Jump back in came first, people opened Sites every time just to
ddiff --git a/README.md b/README.md
iindex af775b7..a66913c 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.128.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.129.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 8f542dc..0921ca7 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.128.0",
++ "version": "4.129.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)",
@@@ -21,6 +21,7 @@
"share cards for every site and for its big pages (openvibe-shared/social)",
"the shared video player (player.js, player.css): one look for every <video>",
"the player's AD control: a descriptions track is remembered per person, toggled with D and spoken through speech synthesis (4.126.0)",
++ "one menu: the burger opens the OpenVibe menu, this site's pages first and Display at its foot; the old drawer only for sites that ask (4.129.0)",
"the menu rethought around real use: who is live, your places dock, this site's pages, next from here, two places new to you, you and your agents, tools, every site behind a count, jump back in; a click beacon when Network asks (4.128.0)",
"items.js: what people wear from OpenVibe.Inventory (name effects, hats, particles, community badges), drawn the same on every site",
"shipped pill, recent list and /updates log",
ddiff --git a/navbar.js b/navbar.js
iindex cb49f31..888478e 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -1052,7 +1052,14 @@
nav.addEventListener('pointerover', place); nav.addEventListener('focusin', place);
if (burger && drawer) {
if (!_config.user) { bindDisplayControls(drawer.querySelector('.ovnav-display')); bindLook(drawer); } // guests: members have them in the account menu
-- burger.addEventListener('click', (e) => { e.stopPropagation(); const open = drawer.classList.toggle('open'); burger.setAttribute('aria-expanded', String(open)); if (open) loadOvIcons(drawer); });
++ // One menu (the owner, 11-07: "combining this menu and the other menu … the main button on the right-hand side"):
++ // the burger opens the OpenVibe menu, from the burger, leading with this site's pages. The old drawer stays for a
++ // site that turns the menu off (search: false) or asks for it (drawer: 'legacy').
++ burger.addEventListener('click', (e) => {
++ e.stopPropagation();
++ if (_config.search !== false && _config.drawer !== 'legacy') { openSearch('', 'here', burger); return; }
++ const open = drawer.classList.toggle('open'); burger.setAttribute('aria-expanded', String(open)); if (open) loadOvIcons(drawer);
++ });
drawer.addEventListener('click', (e) => {
const all = e.target.closest && e.target.closest('[data-open-launcher]'); if (!all) return;
e.preventDefault(); e.stopPropagation(); closeDrawer();
@@@ -2037,12 +2044,13 @@
// Search (search.js, loaded on first use). _config.search: false, or { owner, label, page }.
let _searchLoading = false;
/** The OpenVibe menu (search.js, loaded on first use): search, every site and every tool. tab: 'you' | 'sites' | 'tools'. */
-- function openSearch(q, tab) {
++ function openSearch(q, tab, fromEl) {
const orb = _navEl && _navEl.querySelector('#openvibe-search-btn');
-- // The menu drops from the orb, under the bar (the bar stays, and the orb's × closes it).
++ // The menu drops from the button that opened it (the orb, or the burger), under the bar.
++ const from = fromEl || orb;
const anchor = () => {
-- if (!_navEl || !orb || !orb.getBoundingClientRect) return null;
-- const bar = _navEl.getBoundingClientRect(), at = orb.getBoundingClientRect();
++ if (!_navEl || !from || !from.getBoundingClientRect) return null;
++ const bar = _navEl.getBoundingClientRect(), at = from.getBoundingClientRect();
return bar.bottom > 0 && bar.bottom < 200 ? { top: bar.bottom, left: at.left - 6, cx: at.left + at.width / 2, cy: at.top + at.height / 2 } : null;
};
const go = () => {
@@@ -2051,8 +2059,10 @@
if (_navEl) _navEl.classList.remove('ovnav-hide');
root.OpenVibeSearch.open({ service: _config.service, scope: Object.assign({ owner: _config.service }, _config.search || {}), links: flatLinks(rawLinks()), q, tab,
frame: OVFrame.get(), token: _config.token, apiBase: _config.apiBase, user: _config.user, icons: (scope) => loadOvIcons(scope), anchor: anchor(), navIcon,
-- onClose: () => { const b = _navEl && _navEl.querySelector('#openvibe-search-btn'); if (b) b.setAttribute('aria-expanded', 'false'); } });
-- const b = _navEl && _navEl.querySelector('#openvibe-search-btn'); if (b) b.setAttribute('aria-expanded', 'true');
++ // Display (theme, text size, motion) at the menu's foot, drawn by the same controls the drawer had.
++ display: (el) => { const box = document.createElement('div'); box.className = 'ovnav-display ovs-display'; el.appendChild(box); bindDisplayControls(box); },
++ onClose: () => { const b = _navEl && _navEl.querySelector('#openvibe-search-btn'); if (b) b.setAttribute('aria-expanded', 'false'); const g = _navEl && _navEl.querySelector('#openvibe-burger'); if (g) g.setAttribute('aria-expanded', 'false'); } });
++ const b = fromEl || (_navEl && _navEl.querySelector('#openvibe-search-btn')); if (b) b.setAttribute('aria-expanded', 'true');
};
if (root.OpenVibeSearch) return go();
if (_searchLoading) return;
ddiff --git a/package.json b/package.json
iindex 87b049a..e75c0e4 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.128.0",
++ "version": "4.129.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 55756c9..b7b6a8a 100644
---- a/search.js
++++ b/search.js
@@@ -62,7 +62,7 @@
{ name: 'Developer Tools', icon: 'code', url: 'https://dev.openvibe.tools/' },
{ name: 'Network Tools', icon: 'dns', url: 'https://net.openvibe.tools/' },
];
-- const TABS = [['you', 'Start'], ['sites', 'Sites'], ['tools', 'Tools']];
++ const TABS = [['you', 'Start'], ['sites', 'Sites'], ['tools', 'Tools'], ['here', 'Here']];
let el = null, input = null, ghost = null, listEl = null, tabsEl = null, ctx = {}, items = [], active = -1, inflight = null, timer = null, lastFocus = null;
let tab = 'you', mine = null, catalog = null, completion = '';
// The results view (4.29.0): Enter shows the Search Fabric's answer here, by scope, instead of leaving the page.
@@@ -238,6 +238,8 @@
.ovs-tag { margin-left: 7px; padding: 1px 7px; border-radius: 999px; font-style: normal; font-size: 10.5px; font-weight: 800; letter-spacing: .03em; vertical-align: 1px; color: color-mix(in srgb, var(--accent, #3b82f6) 72%, var(--text-primary, #e8ecf3)); background: color-mix(in srgb, var(--accent, #3b82f6) 15%, transparent); }
.ovs-card { padding: 2px 6px 6px; }
.ovs-cardbody { border-radius: 16px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: color-mix(in srgb, var(--text-primary, #e8ecf3) 4%, transparent); padding: 12px 14px; }
++.ovs-displayrow { padding: 4px 8px 10px; }
++.ovs-displayrow .ovnav-display { margin-top: 4px; }
.ovs-host { display: block; margin-top: 2px; font-size: 11px; font-weight: 650; letter-spacing: .01em; color: color-mix(in srgb, var(--accent, #3b82f6) 60%, var(--text-muted, #93a0b4)); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ovs-group .ovs-more { margin-left: auto; color: var(--accent-light, #8ab4ff); font-size: 11.5px; font-weight: 650; text-transform: none; letter-spacing: 0; text-decoration: none; }
@media (hover: hover) { .ovs-app:hover, .ovs-link:hover, .ovs-live:hover, .ovs-fold:hover { background: var(--bg-hover, rgba(255,255,255,.07)); color: inherit; } .ovs-app:hover .ovs-appic { transform: translateY(-2px); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent, #3b82f6) 45%, transparent); } }
@@@ -424,12 +426,18 @@
function paint(groups, note) {
items = [];
listEl.textContent = '';
-- groups.filter((g) => (g.items && g.items.length) || ['note', 'loading', 'route', 'fold', 'card'].includes(g.as)).forEach((g) => {
++ groups.filter((g) => (g.items && g.items.length) || ['note', 'loading', 'route', 'fold', 'card', 'display'].includes(g.as)).forEach((g) => {
if (g.as === 'route') {
const r = document.createElement('li'); r.className = 'ovs-route'; r.setAttribute('role', 'presentation'); r.textContent = g.text; listEl.appendChild(r);
return;
}
if (g.as === 'fold') { listEl.appendChild(foldButton(g.fold, g.section)); return; }
++ if (g.as === 'display') {
++ const li = document.createElement('li'); li.className = 'ovs-displayrow'; li.setAttribute('role', 'presentation'); li.setAttribute('data-sec', 'display');
++ try { ctx.display(li); } catch { return; }
++ listEl.appendChild(li);
++ return;
++ }
if (g.as === 'card') {
const body = document.createElement('div'); body.className = 'ovs-cardbody';
try {
@@@ -627,7 +635,7 @@
live: 'Streams and calls', 'media-hub': 'Recordings and clips', community: 'Forum and pastes', chat: 'Rooms and messages',
tools: 'Free tools', bot: 'Robots and machines', vip: 'Back a streamer', pics: 'Pictures', download: 'Files and drive',
};
-- let allOpen = false, hereOpen = false, discoverPick = null, pulseData = null;
++ let allOpen = false, hereOpen = false, leadHere = false, discoverPick = null, pulseData = null;
/** Network's live line (GET /api/frame/pulse, kept 30 s): how many are live now, and who when the Frame sends it. */
function loadPulse() {
try { const c = JSON.parse(sessionStorage.getItem('ov_pulse') || 'null'); if (c && Date.now() - c.at < 30000) { pulseData = c.data; return Promise.resolve(c.data); } } catch { /* */ }
@@@ -753,7 +761,8 @@
const allPages = (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);
if (allPages.length) {
const head = hereOpen ? allPages : allPages.slice(0, 6);
-- groups.push({ title: `On ${here()}`, as: 'links', items: head, section: 'here', fold: allPages.length > 6 ? { open: hereOpen, label: hereOpen ? 'Fewer' : `More on ${here()} (${allPages.length - 6})`, key: 'here' } : null });
++ const hereGroup = { title: `On ${here()}`, as: 'links', items: head, section: 'here', fold: allPages.length > 6 ? { open: hereOpen, label: hereOpen ? 'Fewer' : `More on ${here()} (${allPages.length - 6})`, key: 'here' } : null };
++ if (leadHere) groups.unshift(hereGroup); else groups.push(hereGroup);
}
// 4. Where people go next from here: the Frame's list, else the moves Network saw (11-07) and this site's group.
const f = ctx.frame;
@@@ -811,6 +820,8 @@
const back = mine ? mine.items.filter((h) => { if (seen.has(h.url)) return false; seen.add(h.url); return true; }).slice(0, 5)
.map((h) => ({ title: h.title, meta: [h.site, ago(h.at)].filter(Boolean).join(' · '), url: h.url, icon: 'back', kind: 'history' })) : [];
groups.push({ title: 'Jump back in', items: back, section: 'back' });
++ // Display: theme, text size and motion, at the foot (the drawer's row, now in the one menu).
++ if (typeof ctx.display === 'function') groups.push({ title: '', as: 'display', items: [], section: 'display' });
paint(groups);
}
@@@ -975,7 +986,8 @@
lastFocus = document.activeElement;
tab = TABS.some(([id]) => id === opts.tab) ? opts.tab : 'you';
// The old tabs, kept as a way in: "sites" opens the list of every site, "tools" scrolls to the tools.
-- allOpen = tab === 'sites'; hereOpen = false; discoverPick = null;
++ // From the burger ('here'): this site's pages lead, all of them, the way the drawer had them.
++ allOpen = tab === 'sites'; hereOpen = tab === 'here'; leadHere = tab === 'here'; discoverPick = null;
drainCards();
el.classList.add('is-open');
// Back closes the menu first (the owner, 11-07: pressing Back on a phone left the page instead): opening adds one
ddiff --git a/test/navbar-drawer-overflow.test.js b/test/navbar-drawer-overflow.test.js
iindex 9210639..dacd61a 100644
---- a/test/navbar-drawer-overflow.test.js
++++ b/test/navbar-drawer-overflow.test.js
@@@ -20,7 +20,7 @@ if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {
const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Wide</title><link rel="icon" href="data:,">
<script src="/shared/navbar.js" defer></script></head>
<body style="margin:0"><div id="navbar-mount"></div><main><p id="wide" style="white-space:nowrap">https://www.example.com/a-very-long-link-that-nobody-wrapped-so-it-runs-far-past-the-right-edge-of-any-phone-screen?and=more&amp;still=more</p></main>
--<script>document.addEventListener('DOMContentLoaded', function () { OpenVibeNavbar.init({ service: 'site', user: { id: 7, username: 'probe', display_name: 'Probe' }, links: [{ label: 'Rooms', href: '/rooms' }, { label: 'Messages', href: '/messages' }, { label: 'Settings', href: '/settings' }], networkLinks: false, countViews: false, notifications: false, recent: false }); });</script>
++<script>document.addEventListener('DOMContentLoaded', function () { OpenVibeNavbar.init({ service: 'site', user: { id: 7, username: 'probe', display_name: 'Probe' }, links: [{ label: 'Rooms', href: '/rooms' }, { label: 'Messages', href: '/messages' }, { label: 'Settings', href: '/settings' }], networkLinks: false, countViews: false, notifications: false, recent: false, drawer: 'legacy' }); });</script>
</body></html>`;
const server = http.createServer((req, res) => {
const u = new URL(req.url, 'http://x');
ddiff --git a/test/navbar-launcher.test.js b/test/navbar-launcher.test.js
iindex b7672cd..eba06f5 100644
---- a/test/navbar-launcher.test.js
++++ b/test/navbar-launcher.test.js
@@@ -52,9 +52,14 @@ const server = http.createServer((req, res) => {
// The navbar asks for the Frame at render; the launcher paints from what it kept.
await p.evaluate(`new Promise((r) => { const t = setInterval(() => { if (localStorage.getItem('ov_frame_v2')) { clearInterval(t); r(true); } }, 20); })`);
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
-- // The drawer's "All sites" opens the one menu with the list of every site open (4.128.0: one scroll, no tabs).
++ // One menu (4.129.0, the owner 11-07): the burger opens the OpenVibe menu, this site's pages first, Display at its foot.
await p.evaluate(`document.getElementById('openvibe-burger').click(), true`);
-- await wait(150);
++ await p.evaluate(`new Promise((r) => { const t = setInterval(() => { if (document.querySelector('.ovs.is-open .ovs-group')) { clearInterval(t); r(true); } }, 20); })`);
++ const first = await p.evaluate(`({ head: document.querySelector('.ovs-list .ovs-group').firstChild.textContent, sec: document.querySelector('.ovs-list .ovs-group').getAttribute('data-sec'), display: !!document.querySelector('.ovs-displayrow .ovnav-display'), expanded: document.getElementById('openvibe-burger').getAttribute('aria-expanded') })`);
++ assert.strictEqual(first.sec, 'here', 'from the burger, this site\'s pages lead: ' + first.head);
++ assert.strictEqual(first.display, true, 'Display (theme, text size, motion) at the menu\'s foot');
++ assert.strictEqual(first.expanded, 'true', 'the burger says the menu is open');
++ // The drawer's "All sites" (kept for sites that ask for the legacy drawer) opens the list of every site.
await p.evaluate(`document.querySelector('#openvibe-drawer [data-open-launcher]').click(), true`);
await p.evaluate(`new Promise((r) => { const t = setInterval(() => { if (document.querySelector('.ovs.is-open .ovs-grid.is-sites[data-sec="all"]')) { clearInterval(t); r(true); } }, 20); })`);
const read = () => p.evaluate(`(() => { const l = document.querySelector('.ovs-list'); return {