openvibers/openvibe.shared Commits
4.150.0: the menu's button turns into its close, on top; the bell opens your notifications; the Dock's phone header whole (#340)
Changes
@@@ -1,5 +1,26 @@# Changelog++## 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++ it goes in and out of the menu … turns it into the close button when it's open"). On a phone the menu covers the bar,++ so it draws the orb in the button's own place, on top: as the menu grows from it, the orb glides into the search++ field's end and its nine dots turn into an x (the edges fold away, the corners and the centre stay). That orb is the++ close; the separate round x is gone. Closing plays it backwards: the orb glides back, its x turns back into dots, and++ the menu folds into it. Reduced motion: no movement.++- **The bell opens your notifications.** With the Dock on the page, its Activity tab is the bell's list, and the bell's++ own panel opened under the Dock's sheet, so a tap seemed to do nothing (the owner). The bell now opens the Dock on++ Activity, and a second tap closes it; without the Dock it opens its panel as before++ (`OpenVibeNotifications.setBellAction`).++- **The Dock on a phone shows every tab whole.** The open tab's name was clipped ("Ac") because four tools took the row.++ The header is two rows on a phone: the tools beside the grab handle, then the four tabs sharing the width, each icon++ over its name. The pop-out (a separate window) is not offered on a phone. The notification filters scroll sideways,++ and a fade now says so.++- **Press feedback** on the bar's orb, messages, bell and picture: a small spring on a tap.++- Budgets: dock.js 16.80 KB brotli (16.55 measured).++ Tests: menu-close-chrome (the orb is the phone's close, its dots an x, one close, it folds and closes),++ dock-size.++## 4.149.0 — 2026-11-08- **The menu has tabs at the top** (the owner, 11-08: "we should have tabs at the top … a feed tab, which allows the@@@ -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.149.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.150.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.149.0",++ "version": "4.150.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)",@@@ -258,6 +258,17 @@/* A thumb, not a cursor: every control on the sheet is 44 px (design 2026-11-07 §1, Phone). */.ovdk-tabs button { height: 44px; }.ovdk-acts button { width: 44px; height: 44px; }++ /* Two rows (4.150.0, the owner: the strip was "cut off", "Activity" read "Ac"): the tools sit beside the grab++ handle; the four tabs share the full width below, each icon over its name, every one whole. */++ .ovdk-head { flex-wrap: wrap; row-gap: 6px; padding: 8px 8px 8px 10px; }++ .ovdk-head::before { top: 10px; }++ .ovdk-acts { order: -1; width: 100%; justify-content: flex-end; gap: 2px; min-height: 44px; }++ .ovdk-acts button[data-act="pop"] { display: none !important; }++ .ovdk-tabs { order: 0; flex: 1 1 100%; padding: 4px; border-radius: 16px; }++ .ovdk-tabs button, .ovdk-tabs button:not([aria-selected="true"]) { flex: 1 1 0; min-width: 0; height: 54px; padding: 4px 2px; flex-direction: column; justify-content: center; gap: 3px; border-radius: 12px; font-size: 11.5px; font-weight: 650; }++ .ovdk-tabs button span, .ovdk-tabs button:not([aria-selected="true"]) span { position: static; width: auto; height: auto; overflow: hidden; clip: auto; max-width: 100%; text-overflow: ellipsis; white-space: nowrap; }++ .ovdk-tabs button svg { width: 19px; height: 19px; }++ .ovdk-tabs .n { position: absolute; top: 4px; left: calc(50% + 6px); min-width: 17px; padding: 1px 5px; font-size: 10.5px; }.ovdk-foot a, .ovdk-foot button { min-height: 44px; }}html.ovdk-sheet-open, html.ovdk-sheet-open body { overflow: hidden; }@@@ -288,6 +288,8 @@.openvibe-navbar .ovnav-orb[aria-expanded="true"] { color: var(--text-primary, #e6edf7); }.ovnav-orb[aria-expanded="true"] .ovo-g { transform: rotate(180deg) scale(.86); }.ovnav-orb[aria-expanded="true"] .ovo-d { animation: none; }++ .openvibe-navbar :is(.ovnav-orb, .ovnav-msgs, .openvibe-bell, .openvibe-navbar-avatar) { transition: transform .18s cubic-bezier(.2, 1.4, .3, 1), background-color .15s, color .15s; }++ .openvibe-navbar :is(.ovnav-orb, .ovnav-msgs, .openvibe-bell, .openvibe-navbar-avatar):active { transform: scale(.9); }.ovnav-orb[aria-expanded="true"] .ovo-r1 { opacity: 0; transform: scale(0); }.ovnav-orb[aria-expanded="true"] :is(.ovo-r0, .ovo-r2) { fill: var(--ovo-a); opacity: 1; }@keyframes ovo-ripple { 0%, 100% { transform: scale(1); } 45% { transform: scale(1.55); } }@@@ -537,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': '6da35435c118', 'dock.js': 'b563663a49d6', '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': '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' };// 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">.@@@ -1250,7 +1252,7 @@const mount = nav.querySelector('#openvibe-bell-mount'); if (!mount || mount.childElementCount) return;const go = () => {const N = root.OpenVibeNotifications; if (!N || mount.childElementCount) return;-- try { if (!N.__ovNavInit) { N.init({ token: _config.token, apiBase: 'https://openvibe.network', realtime: _config.notificationsRealtime === true }); N.__ovNavInit = true; } N.createBell(mount); } catch { /* */ }++ try { if (!N.__ovNavInit) { N.init({ token: _config.token, apiBase: 'https://openvibe.network', realtime: _config.notificationsRealtime === true }); N.__ovNavInit = true; } N.createBell(mount); wireBell(); } catch { /* */ }};if (root.OpenVibeNotifications) return go();if (document.getElementById('ov-notify-loader')) return;@@@ -1269,8 +1271,20 @@if (!sc) { sc = document.createElement('script'); sc.id = 'ov-dock-loader'; sc.src = siblingV('dock.js'); sc.async = true; document.head.appendChild(sc); }sc.addEventListener('load', () => setTimeout(() => { try { then(); } catch { /* */ } }, 0));}++ // The bell opens the Dock on Activity (the same list), and closes it from there; without the Dock, its own panel.++ let _dockOn = false;++ function wireBell() {++ const N = root.OpenVibeNotifications; if (!N || typeof N.setBellAction !== 'function') return;++ N.setBellAction(_dockOn ? () => {++ const D = root.OpenVibeDock; if (!D || typeof D.open !== 'function') return false;++ const st = typeof D._state === 'function' ? D._state() : {};++ if (D.isOpen() && st.tab === 'activity') D.close(); else D.open({ tab: 'activity' });++ return true;++ } : null);++ }function mountDock(u) {const on = _config.dock !== false && !!u && !u.is_anon && !!_config.token;++ _dockOn = on; wireBell();if (root.OpenVibeDock) return root.OpenVibeDock.mount(on ? dockOpts() : null);try { const k = JSON.parse(localStorage.getItem('ov-dock') || 'null'); if (!on || (k && k.off > Date.now())) return; } catch { if (!on) return; }loadDock(() => root.OpenVibeDock.mount(dockOpts()));@@@ -231,6 +231,7 @@.openvibe-nv-unread svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }.openvibe-nv-tabs { display: flex; overflow-x: auto; scrollbar-width: none; margin: 0 -12px; padding: 0 6px; }.openvibe-nv-tabs::-webkit-scrollbar { display: none; }++ .openvibe-nv-tabs.is-more { -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 40px), transparent); mask-image: linear-gradient(to right, #000 calc(100% - 40px), transparent); }.openvibe-nv-tabs button { flex: none; position: relative; display: inline-flex; align-items: center; gap: 6px; min-height: 40px; padding: 0 10px; border: 0; background: transparent; color: var(--text-secondary, #b6b6c0); font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; white-space: nowrap; }.openvibe-nv-tabs button:hover, .openvibe-nv-tabs button[aria-selected="true"] { color: var(--text-primary, #ececf1); }.openvibe-nv-tabs button[aria-selected="true"]::after { content: ''; position: absolute; left: 10px; right: 10px; bottom: 0; height: 2.5px; border-radius: 2px 2px 0 0; background: var(--accent, #3b82f6); }@@@ -784,13 +785,18 @@}// ── Bell / badge ────────────────────────────────────────++ let _bellAction = null;function createBellEl() {const bell = document.createElement('div');bell.className = 'openvibe-bell';bell.setAttribute('role', 'button'); bell.setAttribute('aria-label', 'Notifications'); bell.tabIndex = 0;bell.innerHTML = `<svg class="ovnav-ovg has-s" viewBox="0 0 24 24" aria-hidden="true">${GLYPH.bell}<g class="ovg-s">${GLYPH.bellSolid}</g></svg><span class="badge hidden">0</span>`;-- bell.addEventListener('click', () => togglePanel());-- bell.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); togglePanel(); } });++ // With the Dock on the page, its Activity tab is this list: the bell opens it there (4.150.0, the owner: the bell++ // "doesn't actually open the notifications … you have to open the chat button and click this manually"); the++ // panel opened under the Dock's sheet, so the tap seemed to do nothing.++ const press = () => { if (_bellAction) { try { if (_bellAction() === true) return; } catch { /* the panel then */ } } togglePanel(); };++ bell.addEventListener('click', press);++ bell.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); press(); } });_bellEl = bell;return bell;}@@@ -872,6 +878,14 @@};tabs.forEach((btn) => btn.addEventListener('click', () => selectTab(btn)));const tabRow = root.querySelector('.openvibe-nv-tabs');++ // More filters past the edge: a fade says the row scrolls (4.150.0; "System" was cut off with no sign of more).++ if (tabRow && !tabRow.__more) {++ tabRow.__more = true;++ const more = () => tabRow.classList.toggle('is-more', tabRow.scrollWidth - tabRow.clientWidth - tabRow.scrollLeft > 4);++ tabRow.addEventListener('scroll', more, { passive: true });++ if (typeof ResizeObserver === 'function') new ResizeObserver(more).observe(tabRow);++ setTimeout(more, 0);++ }const fade = () => { tabRow.classList.toggle('is-more', tabRow.scrollLeft + tabRow.clientWidth < tabRow.scrollWidth - 4); };tabRow.addEventListener('scroll', fade, { passive: true });v.fade = fade;@@@ -1388,6 +1402,8 @@_toastContainer?.remove(); _panelEl?.remove();_toastContainer = null; if (_panelEl) _views.delete(_panelEl); _panelEl = null; _queue.length = 0;},++ /** What a tap on the bell does instead of its own panel: fn() returns true when it handled it (navbar.js: the Dock). */++ setBellAction(fn) { _bellAction = typeof fn === 'function' ? fn : null; },/** Create and inject the bell icon. Returns the DOM element. */createBell(container) {injectStyles();@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.149.0",++ "version": "4.150.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -318,7 +318,35 @@.ovs.ovs-anchored .ovs-panel { top: 0; left: 0; width: 100vw; height: 100dvh; max-height: none; border-radius: 0; border: 0; padding-top: env(safe-area-inset-top, 0px); animation: ovs-reveal .38s cubic-bezier(.2,.9,.25,1) both; }}@keyframes ovs-reveal { from { clip-path: circle(28px at var(--ovs-cx, 40px) var(--ovs-cy, 28px)); } to { clip-path: circle(150% at var(--ovs-cx, 40px) var(--ovs-cy, 28px)); } }--@media (prefers-reduced-motion: reduce) { .ovs.is-open, .ovs-busy .ovs-bar, .ovs.ovs-anchored .ovs-panel, .ovs-skel, .ovs-list > li { animation: none; } }`;++@keyframes ovs-fold { from { clip-path: circle(150% at var(--ovs-cx, 40px) var(--ovs-cy, 28px)); } to { clip-path: circle(20px at var(--ovs-cx, 40px) var(--ovs-cy, 28px)); } }++@keyframes ovs-fade { to { opacity: 0; } }++/* The orb, on top of the phone's menu, in the button's place (4.150.0): it glides into the search pill as the menu grows++ from it, its nine dots turning into an x (the corners and the centre stay, the edges fold away); closing, it glides++ back and the menu folds into it. */++.ovs-orb { display: none; }++@media (max-width: 640px) {++ .ovs.ovs-anchored .ovs-x { display: none; }++ .ovs.ovs-anchored .ovs-bar { padding-right: 52px; }++ .ovs.ovs-anchored .ovs-orb { position: fixed; z-index: 3; left: var(--orb-x, calc(100vw - 52px)); top: var(--orb-y, 12px); width: 40px; height: 40px; display: grid; place-items: center; padding: 0; border: 0; border-radius: 50%; cursor: pointer;++ background: color-mix(in srgb, var(--accent, #3b82f6) 16%, var(--bg-primary, #0b0f19)); color: var(--text-primary, #e6edf7); -webkit-tap-highlight-color: transparent;++ box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent-light, #60a5fa) 35%, transparent), 0 6px 18px -6px color-mix(in srgb, var(--accent, #3b82f6) 70%, transparent);++ --ovo-a: var(--accent-light, var(--accent, #60a5fa)); --ovo-b: color-mix(in oklab, var(--accent, #3b82f6) 45%, #e879f9);++ transform: none; transition: transform .42s cubic-bezier(.2, 1.1, .3, 1), background-color .3s, box-shadow .3s; }++ .ovs.ovs-anchored .ovs-orb.is-from { transform: translate(var(--orb-dx, 0px), var(--orb-dy, 0px)) scale(.9); background: transparent; box-shadow: none; }++ .ovs.ovs-anchored .ovs-orb::after { content: ""; position: absolute; inset: -4px; }++ .ovs.ovs-anchored .ovs-orb:active { transform: scale(.9); }++ .ovs.ovs-anchored .ovs-orb svg { display: block; overflow: visible; }++ .ovs.is-closing .ovs-panel { animation: ovs-fold .28s cubic-bezier(.5, 0, .75, .2) both; }++ .ovs.is-closing { animation: ovs-fade .28s ease-in both; }++}++.ovs-orb .ovo-g { transform-origin: 12px 12px; transform: rotate(45deg) scale(.8); transition: transform .5s cubic-bezier(.2, 1.35, .3, 1); }++.ovs-orb .ovo-d { fill: currentColor; transform-box: fill-box; transform-origin: center; transition: fill .3s, opacity .25s, transform .32s cubic-bezier(.2, 1.4, .3, 1); }++.ovs-orb .ovo-r0 { fill: var(--ovo-a); }++.ovs-orb .ovo-r2 { opacity: .6; }++.ovs-orb.is-x .ovo-g { transform: rotate(180deg) scale(.95); }++.ovs-orb.is-x .ovo-r1 { opacity: 0; transform: scale(0); }++.ovs-orb.is-x :is(.ovo-r0, .ovo-r2) { fill: var(--ovo-a); opacity: 1; transform: scale(1.12); }++@media (prefers-reduced-motion: reduce) { .ovs.is-open, .ovs-busy .ovs-bar, .ovs.ovs-anchored .ovs-panel, .ovs-skel, .ovs-list > li { animation: none; } .ovs-orb, .ovs-orb .ovo-g, .ovs-orb .ovo-d { transition: none !important; } }`;document.head.appendChild(st);}@@@ -332,6 +360,15 @@grid: '<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="5" r="2"/><circle cx="12" cy="5" r="2"/><circle cx="19" cy="5" r="2"/><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/><circle cx="5" cy="19" r="2"/><circle cx="12" cy="19" r="2"/><circle cx="19" cy="19" r="2"/></svg>',};++ // The orb (navbar.js draws the same nine dots): on a phone the menu covers the bar, so the menu draws the orb in the++ // button's own place, on top, and it turns into the menu's close (4.150.0, the owner: "it renders on top … turns into++ // the close button when it's open … a cool effect as it goes in and out").++ const ORB_DOTS = [[12, 12, 0], [12, 5, 1], [19, 12, 1], [12, 19, 1], [5, 12, 1], [5, 5, 2], [19, 5, 2], [19, 19, 2], [5, 19, 2]];++ const orbSvg = () => `<svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true"><g class="ovo-g">${ORB_DOTS.map(([x, y, r]) => `<circle class="ovo-d ovo-r${r}" cx="${x}" cy="${y}" r="${r ? 2.15 : 2.5}"/>`).join('')}</g></svg>`;++ let orbEl = null, closing = 0;++ const phoneSheet = () => { try { return !!(el && el.classList.contains('ovs-anchored') && root.matchMedia('(max-width: 640px)').matches); } catch { return false; } };++ const calm = () => { try { return root.matchMedia('(prefers-reduced-motion: reduce)').matches || document.documentElement.getAttribute('data-ov-motion') === 'reduced'; } catch { return false; } };++function build() {styles();el = document.createElement('div');@@@ -351,6 +388,11 @@rtabsEl.addEventListener('click', (e) => { const b = e.target.closest && e.target.closest('[data-rtab]'); if (b) { showResults(rq, b.getAttribute('data-rtab')); input.focus(); } });el.addEventListener('mousedown', (e) => { if (e.target === el) close(); });el.querySelector('.ovs-x').addEventListener('click', close);++ orbEl = document.createElement('button');++ orbEl.type = 'button'; orbEl.className = 'ovs-orb'; orbEl.setAttribute('aria-label', 'Close the menu'); orbEl.title = 'Close (Esc)';++ orbEl.innerHTML = orbSvg();++ orbEl.addEventListener('click', close);++ el.appendChild(orbEl);tabsEl.addEventListener('click', (e) => {const b = e.target.closest && e.target.closest('[data-tab]'); if (!b) return;tab = b.getAttribute('data-tab'); saveTab(tab); render(''); listEl.scrollTop = 0;@@@ -1178,6 +1220,21 @@el.style.setProperty('--ovs-cx', `${Math.round(cx)}px`); el.style.setProperty('--ovs-cy', `${Math.round(cy)}px`);}try { document.dispatchEvent(new CustomEvent('openvibe-menu', { detail: { open: true } })); } catch { /* */ }++ if (closing) { clearTimeout(closing); closing = 0; el.classList.remove('is-closing'); }++ if (orbEl) {++ orbEl.classList.remove('is-x');++ if (phoneSheet()) {++ // From the button's centre to the pill's right end: the same orb, moving into the menu.++ const cx = Number.isFinite(at && at.cx) ? at.cx : innerWidth - 24, cy = Number.isFinite(at && at.cy) ? at.cy : 28;++ const bar = el.querySelector('.ovs-bar'), br = bar ? bar.getBoundingClientRect() : null;++ const tx = br ? br.right - 26 : cx, ty = br ? br.top + br.height / 2 : cy;++ orbEl.style.setProperty('--orb-x', `${Math.round(tx - 20)}px`); orbEl.style.setProperty('--orb-y', `${Math.round(ty - 20)}px`);++ orbEl.style.setProperty('--orb-dx', `${Math.round(cx - tx)}px`); orbEl.style.setProperty('--orb-dy', `${Math.round(cy - ty)}px`);++ orbEl.classList.add('is-from');++ void orbEl.offsetWidth;++ requestAnimationFrame(() => { orbEl.classList.remove('is-from'); orbEl.classList.add('is-x'); });++ }++ }document.documentElement.style.overflow = 'hidden';input.placeholder = `Search ${here()}, or ask Vibe`;input.value = opts.q || '';@@@ -1196,10 +1253,18 @@if (typeof window !== 'undefined') window.addEventListener('popstate', () => { if (el && el.classList.contains('is-open')) { popping = true; pushed = false; close(); popping = false; } });function close() {if (view === 'results' && rtabsEl) leaveResults();-- if (!el) return;++ if (!el || closing) return;// 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 { /* */ } }-- el.classList.remove('is-open');++ // A phone's menu folds back into the orb (its x turns back into dots) before it goes; elsewhere it closes at once.++ if (phoneSheet() && !calm() && el.classList.contains('is-open')) {++ el.classList.add('is-closing');++ if (orbEl) { orbEl.classList.remove('is-x'); orbEl.classList.add('is-from'); }++ closing = setTimeout(() => { closing = 0; el.classList.remove('is-open', 'is-closing'); if (orbEl) orbEl.classList.remove('is-from'); }, 280);++ } else {++ el.classList.remove('is-open');++ if (orbEl) orbEl.classList.remove('is-x', 'is-from');++ }document.documentElement.style.overflow = '';if (inflight) { inflight.abort(); inflight = null; }if (ctx && typeof ctx.onClose === 'function') { try { ctx.onClose(); } catch { /* */ } }@@@ -21,7 +21,7 @@ const DOCK_PATH = path.join(ROOT, 'dock.js');// time something is sent, not on every page.// The timer itself (menu, countdown, chime, focus rounds) is dock-timer.js (3.7 KB brotli), loaded only while a timer// runs or its menu opens; the header's tab rail and narrow-width rules came with it.--const DOCK_BROTLI_BUDGET = 16794; // 16.40 KB: 16.30 KB with the link cards' loader (4.147.0, 2026-11-08; the card itself is dock-card.js, loaded the first time one shows) + headroom; 16.25 KB: 16.07 KB with OpenVibe Chat's hooks (share(), canSend(), the draft; 4.144.0, 2026-11-08), Send to… itself is dock-share.js (1.9 KB, loaded on first use); 16.0 KB: 15.74 KB with its siblings' ?v= content hashes (4.137.0, 2026-11-07) + headroom; 15.70 KB: 15.55 KB with Ask your agent (4.135.0, 2026-11-07) + headroom; was 15.50 KB (15435 after 4.120.0)++const DOCK_BROTLI_BUDGET = 17203; // 16.80 KB: 16.55 KB with the phone header's two rows (4.150.0, 2026-11-08: the open tab read "Ac") + headroom; 16.40 KB: 16.30 KB with the link cards' loader (4.147.0, 2026-11-08; the card itself is dock-card.js, loaded the first time one shows) + headroom; 16.25 KB: 16.07 KB with OpenVibe Chat's hooks (share(), canSend(), the draft; 4.144.0, 2026-11-08), Send to… itself is dock-share.js (1.9 KB, loaded on first use); 16.0 KB: 15.74 KB with its siblings' ?v= content hashes (4.137.0, 2026-11-07) + headroom; 15.70 KB: 15.55 KB with Ask your agent (4.135.0, 2026-11-07) + headroom; was 15.50 KB (15435 after 4.120.0)const SIBLINGS = ['action-center.js', 'notification-ui.js', 'notification-live.js', 'ov-mark.js', 'dock-vibe.js', 'dock-timer.js', 'dock-agent.js', 'dock-share.js', 'dock-card.js'];// ── the budget ──────────────────────────────────────────────@@@ -38,7 +38,8 @@ const server = http.createServer((req, res) => {});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);++// A phone: the menu's close is the orb in the button's place (4.150.0); a wide screen: the word Close.++const BUTTON = `(() => { const b = innerWidth <= 640 ? document.querySelector('.ovs.is-open .ovs-orb') : 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',@@@ -65,11 +66,13 @@ async function openMenu(p) {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.deepStrictEqual([x.label, x.icon, x.word], ['Close the menu', true, false], 'the orb, named for a screen reader');++ assert.strictEqual(await p.evaluate(`(() => { const o = document.querySelector('.ovs.is-open .ovs-orb'); return o.classList.contains('is-x') && o.querySelectorAll('.ovo-d').length === 9; })()`), true, 'its nine dots have turned into an x');++ assert.strictEqual(await p.evaluate(`getComputedStyle(document.querySelector('.ovs.is-open .ovs-x')).display`), 'none', 'one close: the round x steps aside for the orb');++ const hit = await p.evaluate(`(() => { const b = document.querySelector('.ovs.is-open .ovs-orb').getBoundingClientRect(); const e = document.elementFromPoint(b.left - 2, b.top + b.height / 2); return !!e && !!e.closest('.ovs-orb'); })()`);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);++ await p.evaluate('document.querySelector(".ovs.is-open .ovs-orb").click(); 1');++ await wait(450); // the menu folds back into the orbassert.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 });
@@ -1,5 +1,26 @@
@@ -1,5 +1,26 @@
# Changelog
# Changelog
+## 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
+ it goes in and out of the menu … turns it into the close button when it's open"). On a phone the menu covers the bar,
+ so it draws the orb in the button's own place, on top: as the menu grows from it, the orb glides into the search
+ field's end and its nine dots turn into an x (the edges fold away, the corners and the centre stay). That orb is the
+ close; the separate round x is gone. Closing plays it backwards: the orb glides back, its x turns back into dots, and
+ the menu folds into it. Reduced motion: no movement.
+- **The bell opens your notifications.** With the Dock on the page, its Activity tab is the bell's list, and the bell's
+ own panel opened under the Dock's sheet, so a tap seemed to do nothing (the owner). The bell now opens the Dock on
+ Activity, and a second tap closes it; without the Dock it opens its panel as before
+ (`OpenVibeNotifications.setBellAction`).
+- **The Dock on a phone shows every tab whole.** The open tab's name was clipped ("Ac") because four tools took the row.
+ The header is two rows on a phone: the tools beside the grab handle, then the four tabs sharing the width, each icon
+ over its name. The pop-out (a separate window) is not offered on a phone. The notification filters scroll sideways,
+ and a fade now says so.
+- **Press feedback** on the bar's orb, messages, bell and picture: a small spring on a tap.
+- Budgets: dock.js 16.80 KB brotli (16.55 measured).
+ Tests: menu-close-chrome (the orb is the phone's close, its dots an x, one close, it folds and closes),
+ dock-size.
+
## 4.149.0 — 2026-11-08
## 4.149.0 — 2026-11-08
- **The menu has tabs at the top** (the owner, 11-08: "we should have tabs at the top … a feed tab, which allows the
- **The menu has tabs at the top** (the owner, 11-08: "we should have tabs at the top … a feed tab, which allows the
@@ -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.149.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.150.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.149.0",
+ "version": "4.150.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)",
@@ -258,6 +258,17 @@
@@ -258,6 +258,17 @@
/* A thumb, not a cursor: every control on the sheet is 44 px (design 2026-11-07 §1, Phone). */
/* A thumb, not a cursor: every control on the sheet is 44 px (design 2026-11-07 §1, Phone). */
.ovdk-tabs button { height: 44px; }
.ovdk-tabs button { height: 44px; }
.ovdk-acts button { width: 44px; height: 44px; }
.ovdk-acts button { width: 44px; height: 44px; }
+ /* Two rows (4.150.0, the owner: the strip was "cut off", "Activity" read "Ac"): the tools sit beside the grab
+ handle; the four tabs share the full width below, each icon over its name, every one whole. */
+ .ovdk-head { flex-wrap: wrap; row-gap: 6px; padding: 8px 8px 8px 10px; }
+ .ovdk-head::before { top: 10px; }
+ .ovdk-acts { order: -1; width: 100%; justify-content: flex-end; gap: 2px; min-height: 44px; }
+ .ovdk-acts button[data-act="pop"] { display: none !important; }
+ .ovdk-tabs { order: 0; flex: 1 1 100%; padding: 4px; border-radius: 16px; }
+ .ovdk-tabs button, .ovdk-tabs button:not([aria-selected="true"]) { flex: 1 1 0; min-width: 0; height: 54px; padding: 4px 2px; flex-direction: column; justify-content: center; gap: 3px; border-radius: 12px; font-size: 11.5px; font-weight: 650; }
+ .ovdk-tabs button span, .ovdk-tabs button:not([aria-selected="true"]) span { position: static; width: auto; height: auto; overflow: hidden; clip: auto; max-width: 100%; text-overflow: ellipsis; white-space: nowrap; }
+ .ovdk-tabs button svg { width: 19px; height: 19px; }
+ .ovdk-tabs .n { position: absolute; top: 4px; left: calc(50% + 6px); min-width: 17px; padding: 1px 5px; font-size: 10.5px; }
.ovdk-foot a, .ovdk-foot button { min-height: 44px; }
.ovdk-foot a, .ovdk-foot button { min-height: 44px; }
}
}
html.ovdk-sheet-open, html.ovdk-sheet-open body { overflow: hidden; }
html.ovdk-sheet-open, html.ovdk-sheet-open body { overflow: hidden; }
@@ -288,6 +288,8 @@
@@ -288,6 +288,8 @@
.openvibe-navbar .ovnav-orb[aria-expanded="true"] { color: var(--text-primary, #e6edf7); }
.openvibe-navbar .ovnav-orb[aria-expanded="true"] { color: var(--text-primary, #e6edf7); }
.ovnav-orb[aria-expanded="true"] .ovo-g { transform: rotate(180deg) scale(.86); }
.ovnav-orb[aria-expanded="true"] .ovo-g { transform: rotate(180deg) scale(.86); }
.ovnav-orb[aria-expanded="true"] .ovo-d { animation: none; }
.ovnav-orb[aria-expanded="true"] .ovo-d { animation: none; }
+ .openvibe-navbar :is(.ovnav-orb, .ovnav-msgs, .openvibe-bell, .openvibe-navbar-avatar) { transition: transform .18s cubic-bezier(.2, 1.4, .3, 1), background-color .15s, color .15s; }
+ .openvibe-navbar :is(.ovnav-orb, .ovnav-msgs, .openvibe-bell, .openvibe-navbar-avatar):active { transform: scale(.9); }
.ovnav-orb[aria-expanded="true"] .ovo-r1 { opacity: 0; transform: scale(0); }
.ovnav-orb[aria-expanded="true"] .ovo-r1 { opacity: 0; transform: scale(0); }
.ovnav-orb[aria-expanded="true"] :is(.ovo-r0, .ovo-r2) { fill: var(--ovo-a); opacity: 1; }
.ovnav-orb[aria-expanded="true"] :is(.ovo-r0, .ovo-r2) { fill: var(--ovo-a); opacity: 1; }
@keyframes ovo-ripple { 0%, 100% { transform: scale(1); } 45% { transform: scale(1.55); } }
@keyframes ovo-ripple { 0%, 100% { transform: scale(1); } 45% { transform: scale(1.55); } }
@@ -537,7 +539,7 @@
@@ -537,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': '6da35435c118', 'dock.js': 'b563663a49d6', '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': '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' };
// 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">.
@@ -1250,7 +1252,7 @@
@@ -1250,7 +1252,7 @@
const mount = nav.querySelector('#openvibe-bell-mount'); if (!mount || mount.childElementCount) return;
const mount = nav.querySelector('#openvibe-bell-mount'); if (!mount || mount.childElementCount) return;
const go = () => {
const go = () => {
const N = root.OpenVibeNotifications; if (!N || mount.childElementCount) return;
const N = root.OpenVibeNotifications; if (!N || mount.childElementCount) return;
- try { if (!N.__ovNavInit) { N.init({ token: _config.token, apiBase: 'https://openvibe.network', realtime: _config.notificationsRealtime === true }); N.__ovNavInit = true; } N.createBell(mount); } catch { /* */ }
+ try { if (!N.__ovNavInit) { N.init({ token: _config.token, apiBase: 'https://openvibe.network', realtime: _config.notificationsRealtime === true }); N.__ovNavInit = true; } N.createBell(mount); wireBell(); } catch { /* */ }
};
};
if (root.OpenVibeNotifications) return go();
if (root.OpenVibeNotifications) return go();
if (document.getElementById('ov-notify-loader')) return;
if (document.getElementById('ov-notify-loader')) return;
@@ -1269,8 +1271,20 @@
@@ -1269,8 +1271,20 @@
if (!sc) { sc = document.createElement('script'); sc.id = 'ov-dock-loader'; sc.src = siblingV('dock.js'); sc.async = true; document.head.appendChild(sc); }
if (!sc) { sc = document.createElement('script'); sc.id = 'ov-dock-loader'; sc.src = siblingV('dock.js'); sc.async = true; document.head.appendChild(sc); }
sc.addEventListener('load', () => setTimeout(() => { try { then(); } catch { /* */ } }, 0));
sc.addEventListener('load', () => setTimeout(() => { try { then(); } catch { /* */ } }, 0));
}
}
+ // The bell opens the Dock on Activity (the same list), and closes it from there; without the Dock, its own panel.
+ let _dockOn = false;
+ function wireBell() {
+ const N = root.OpenVibeNotifications; if (!N || typeof N.setBellAction !== 'function') return;
+ N.setBellAction(_dockOn ? () => {
+ const D = root.OpenVibeDock; if (!D || typeof D.open !== 'function') return false;
+ const st = typeof D._state === 'function' ? D._state() : {};
+ if (D.isOpen() && st.tab === 'activity') D.close(); else D.open({ tab: 'activity' });
+ return true;
+ } : null);
+ }
function mountDock(u) {
function mountDock(u) {
const on = _config.dock !== false && !!u && !u.is_anon && !!_config.token;
const on = _config.dock !== false && !!u && !u.is_anon && !!_config.token;
+ _dockOn = on; wireBell();
if (root.OpenVibeDock) return root.OpenVibeDock.mount(on ? dockOpts() : null);
if (root.OpenVibeDock) return root.OpenVibeDock.mount(on ? dockOpts() : null);
try { const k = JSON.parse(localStorage.getItem('ov-dock') || 'null'); if (!on || (k && k.off > Date.now())) return; } catch { if (!on) return; }
try { const k = JSON.parse(localStorage.getItem('ov-dock') || 'null'); if (!on || (k && k.off > Date.now())) return; } catch { if (!on) return; }
loadDock(() => root.OpenVibeDock.mount(dockOpts()));
loadDock(() => root.OpenVibeDock.mount(dockOpts()));
@@ -231,6 +231,7 @@
@@ -231,6 +231,7 @@
.openvibe-nv-unread svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.openvibe-nv-unread svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.openvibe-nv-tabs { display: flex; overflow-x: auto; scrollbar-width: none; margin: 0 -12px; padding: 0 6px; }
.openvibe-nv-tabs { display: flex; overflow-x: auto; scrollbar-width: none; margin: 0 -12px; padding: 0 6px; }
.openvibe-nv-tabs::-webkit-scrollbar { display: none; }
.openvibe-nv-tabs::-webkit-scrollbar { display: none; }
+ .openvibe-nv-tabs.is-more { -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 40px), transparent); mask-image: linear-gradient(to right, #000 calc(100% - 40px), transparent); }
.openvibe-nv-tabs button { flex: none; position: relative; display: inline-flex; align-items: center; gap: 6px; min-height: 40px; padding: 0 10px; border: 0; background: transparent; color: var(--text-secondary, #b6b6c0); font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; white-space: nowrap; }
.openvibe-nv-tabs button { flex: none; position: relative; display: inline-flex; align-items: center; gap: 6px; min-height: 40px; padding: 0 10px; border: 0; background: transparent; color: var(--text-secondary, #b6b6c0); font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; white-space: nowrap; }
.openvibe-nv-tabs button:hover, .openvibe-nv-tabs button[aria-selected="true"] { color: var(--text-primary, #ececf1); }
.openvibe-nv-tabs button:hover, .openvibe-nv-tabs button[aria-selected="true"] { color: var(--text-primary, #ececf1); }
.openvibe-nv-tabs button[aria-selected="true"]::after { content: ''; position: absolute; left: 10px; right: 10px; bottom: 0; height: 2.5px; border-radius: 2px 2px 0 0; background: var(--accent, #3b82f6); }
.openvibe-nv-tabs button[aria-selected="true"]::after { content: ''; position: absolute; left: 10px; right: 10px; bottom: 0; height: 2.5px; border-radius: 2px 2px 0 0; background: var(--accent, #3b82f6); }
@@ -784,13 +785,18 @@
@@ -784,13 +785,18 @@
}
}
// ── Bell / badge ────────────────────────────────────────
// ── Bell / badge ────────────────────────────────────────
+ let _bellAction = null;
function createBellEl() {
function createBellEl() {
const bell = document.createElement('div');
const bell = document.createElement('div');
bell.className = 'openvibe-bell';
bell.className = 'openvibe-bell';
bell.setAttribute('role', 'button'); bell.setAttribute('aria-label', 'Notifications'); bell.tabIndex = 0;
bell.setAttribute('role', 'button'); bell.setAttribute('aria-label', 'Notifications'); bell.tabIndex = 0;
bell.innerHTML = `<svg class="ovnav-ovg has-s" viewBox="0 0 24 24" aria-hidden="true">${GLYPH.bell}<g class="ovg-s">${GLYPH.bellSolid}</g></svg><span class="badge hidden">0</span>`;
bell.innerHTML = `<svg class="ovnav-ovg has-s" viewBox="0 0 24 24" aria-hidden="true">${GLYPH.bell}<g class="ovg-s">${GLYPH.bellSolid}</g></svg><span class="badge hidden">0</span>`;
- bell.addEventListener('click', () => togglePanel());
- bell.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); togglePanel(); } });
+ // With the Dock on the page, its Activity tab is this list: the bell opens it there (4.150.0, the owner: the bell
+ // "doesn't actually open the notifications … you have to open the chat button and click this manually"); the
+ // panel opened under the Dock's sheet, so the tap seemed to do nothing.
+ const press = () => { if (_bellAction) { try { if (_bellAction() === true) return; } catch { /* the panel then */ } } togglePanel(); };
+ bell.addEventListener('click', press);
+ bell.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); press(); } });
_bellEl = bell;
_bellEl = bell;
return bell;
return bell;
}
}
@@ -872,6 +878,14 @@
@@ -872,6 +878,14 @@
};
};
tabs.forEach((btn) => btn.addEventListener('click', () => selectTab(btn)));
tabs.forEach((btn) => btn.addEventListener('click', () => selectTab(btn)));
const tabRow = root.querySelector('.openvibe-nv-tabs');
const tabRow = root.querySelector('.openvibe-nv-tabs');
+ // More filters past the edge: a fade says the row scrolls (4.150.0; "System" was cut off with no sign of more).
+ if (tabRow && !tabRow.__more) {
+ tabRow.__more = true;
+ const more = () => tabRow.classList.toggle('is-more', tabRow.scrollWidth - tabRow.clientWidth - tabRow.scrollLeft > 4);
+ tabRow.addEventListener('scroll', more, { passive: true });
+ if (typeof ResizeObserver === 'function') new ResizeObserver(more).observe(tabRow);
+ setTimeout(more, 0);
+ }
const fade = () => { tabRow.classList.toggle('is-more', tabRow.scrollLeft + tabRow.clientWidth < tabRow.scrollWidth - 4); };
const fade = () => { tabRow.classList.toggle('is-more', tabRow.scrollLeft + tabRow.clientWidth < tabRow.scrollWidth - 4); };
tabRow.addEventListener('scroll', fade, { passive: true });
tabRow.addEventListener('scroll', fade, { passive: true });
v.fade = fade;
v.fade = fade;
@@ -1388,6 +1402,8 @@
@@ -1388,6 +1402,8 @@
_toastContainer?.remove(); _panelEl?.remove();
_toastContainer?.remove(); _panelEl?.remove();
_toastContainer = null; if (_panelEl) _views.delete(_panelEl); _panelEl = null; _queue.length = 0;
_toastContainer = null; if (_panelEl) _views.delete(_panelEl); _panelEl = null; _queue.length = 0;
},
},
+ /** What a tap on the bell does instead of its own panel: fn() returns true when it handled it (navbar.js: the Dock). */
+ setBellAction(fn) { _bellAction = typeof fn === 'function' ? fn : null; },
/** Create and inject the bell icon. Returns the DOM element. */
/** Create and inject the bell icon. Returns the DOM element. */
createBell(container) {
createBell(container) {
injectStyles();
injectStyles();
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.149.0",
+ "version": "4.150.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": {
@@ -318,7 +318,35 @@
@@ -318,7 +318,35 @@
.ovs.ovs-anchored .ovs-panel { top: 0; left: 0; width: 100vw; height: 100dvh; max-height: none; border-radius: 0; border: 0; padding-top: env(safe-area-inset-top, 0px); animation: ovs-reveal .38s cubic-bezier(.2,.9,.25,1) both; }
.ovs.ovs-anchored .ovs-panel { top: 0; left: 0; width: 100vw; height: 100dvh; max-height: none; border-radius: 0; border: 0; padding-top: env(safe-area-inset-top, 0px); animation: ovs-reveal .38s cubic-bezier(.2,.9,.25,1) both; }
}
}
@keyframes ovs-reveal { from { clip-path: circle(28px at var(--ovs-cx, 40px) var(--ovs-cy, 28px)); } to { clip-path: circle(150% at var(--ovs-cx, 40px) var(--ovs-cy, 28px)); } }
@keyframes ovs-reveal { from { clip-path: circle(28px at var(--ovs-cx, 40px) var(--ovs-cy, 28px)); } to { clip-path: circle(150% at var(--ovs-cx, 40px) var(--ovs-cy, 28px)); } }
-@media (prefers-reduced-motion: reduce) { .ovs.is-open, .ovs-busy .ovs-bar, .ovs.ovs-anchored .ovs-panel, .ovs-skel, .ovs-list > li { animation: none; } }`;
+@keyframes ovs-fold { from { clip-path: circle(150% at var(--ovs-cx, 40px) var(--ovs-cy, 28px)); } to { clip-path: circle(20px at var(--ovs-cx, 40px) var(--ovs-cy, 28px)); } }
+@keyframes ovs-fade { to { opacity: 0; } }
+/* The orb, on top of the phone's menu, in the button's place (4.150.0): it glides into the search pill as the menu grows
+ from it, its nine dots turning into an x (the corners and the centre stay, the edges fold away); closing, it glides
+ back and the menu folds into it. */
+.ovs-orb { display: none; }
+@media (max-width: 640px) {
+ .ovs.ovs-anchored .ovs-x { display: none; }
+ .ovs.ovs-anchored .ovs-bar { padding-right: 52px; }
+ .ovs.ovs-anchored .ovs-orb { position: fixed; z-index: 3; left: var(--orb-x, calc(100vw - 52px)); top: var(--orb-y, 12px); width: 40px; height: 40px; display: grid; place-items: center; padding: 0; border: 0; border-radius: 50%; cursor: pointer;
+ background: color-mix(in srgb, var(--accent, #3b82f6) 16%, var(--bg-primary, #0b0f19)); color: var(--text-primary, #e6edf7); -webkit-tap-highlight-color: transparent;
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent-light, #60a5fa) 35%, transparent), 0 6px 18px -6px color-mix(in srgb, var(--accent, #3b82f6) 70%, transparent);
+ --ovo-a: var(--accent-light, var(--accent, #60a5fa)); --ovo-b: color-mix(in oklab, var(--accent, #3b82f6) 45%, #e879f9);
+ transform: none; transition: transform .42s cubic-bezier(.2, 1.1, .3, 1), background-color .3s, box-shadow .3s; }
+ .ovs.ovs-anchored .ovs-orb.is-from { transform: translate(var(--orb-dx, 0px), var(--orb-dy, 0px)) scale(.9); background: transparent; box-shadow: none; }
+ .ovs.ovs-anchored .ovs-orb::after { content: ""; position: absolute; inset: -4px; }
+ .ovs.ovs-anchored .ovs-orb:active { transform: scale(.9); }
+ .ovs.ovs-anchored .ovs-orb svg { display: block; overflow: visible; }
+ .ovs.is-closing .ovs-panel { animation: ovs-fold .28s cubic-bezier(.5, 0, .75, .2) both; }
+ .ovs.is-closing { animation: ovs-fade .28s ease-in both; }
+}
+.ovs-orb .ovo-g { transform-origin: 12px 12px; transform: rotate(45deg) scale(.8); transition: transform .5s cubic-bezier(.2, 1.35, .3, 1); }
+.ovs-orb .ovo-d { fill: currentColor; transform-box: fill-box; transform-origin: center; transition: fill .3s, opacity .25s, transform .32s cubic-bezier(.2, 1.4, .3, 1); }
+.ovs-orb .ovo-r0 { fill: var(--ovo-a); }
+.ovs-orb .ovo-r2 { opacity: .6; }
+.ovs-orb.is-x .ovo-g { transform: rotate(180deg) scale(.95); }
+.ovs-orb.is-x .ovo-r1 { opacity: 0; transform: scale(0); }
+.ovs-orb.is-x :is(.ovo-r0, .ovo-r2) { fill: var(--ovo-a); opacity: 1; transform: scale(1.12); }
+@media (prefers-reduced-motion: reduce) { .ovs.is-open, .ovs-busy .ovs-bar, .ovs.ovs-anchored .ovs-panel, .ovs-skel, .ovs-list > li { animation: none; } .ovs-orb, .ovs-orb .ovo-g, .ovs-orb .ovo-d { transition: none !important; } }`;
document.head.appendChild(st);
document.head.appendChild(st);
}
}
@@ -332,6 +360,15 @@
@@ -332,6 +360,15 @@
grid: '<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="5" r="2"/><circle cx="12" cy="5" r="2"/><circle cx="19" cy="5" r="2"/><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/><circle cx="5" cy="19" r="2"/><circle cx="12" cy="19" r="2"/><circle cx="19" cy="19" r="2"/></svg>',
grid: '<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="5" r="2"/><circle cx="12" cy="5" r="2"/><circle cx="19" cy="5" r="2"/><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/><circle cx="5" cy="19" r="2"/><circle cx="12" cy="19" r="2"/><circle cx="19" cy="19" r="2"/></svg>',
};
};
+ // The orb (navbar.js draws the same nine dots): on a phone the menu covers the bar, so the menu draws the orb in the
+ // button's own place, on top, and it turns into the menu's close (4.150.0, the owner: "it renders on top … turns into
+ // the close button when it's open … a cool effect as it goes in and out").
+ const ORB_DOTS = [[12, 12, 0], [12, 5, 1], [19, 12, 1], [12, 19, 1], [5, 12, 1], [5, 5, 2], [19, 5, 2], [19, 19, 2], [5, 19, 2]];
+ const orbSvg = () => `<svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true"><g class="ovo-g">${ORB_DOTS.map(([x, y, r]) => `<circle class="ovo-d ovo-r${r}" cx="${x}" cy="${y}" r="${r ? 2.15 : 2.5}"/>`).join('')}</g></svg>`;
+ let orbEl = null, closing = 0;
+ const phoneSheet = () => { try { return !!(el && el.classList.contains('ovs-anchored') && root.matchMedia('(max-width: 640px)').matches); } catch { return false; } };
+ const calm = () => { try { return root.matchMedia('(prefers-reduced-motion: reduce)').matches || document.documentElement.getAttribute('data-ov-motion') === 'reduced'; } catch { return false; } };
+
function build() {
function build() {
styles();
styles();
el = document.createElement('div');
el = document.createElement('div');
@@ -351,6 +388,11 @@
@@ -351,6 +388,11 @@
rtabsEl.addEventListener('click', (e) => { const b = e.target.closest && e.target.closest('[data-rtab]'); if (b) { showResults(rq, b.getAttribute('data-rtab')); input.focus(); } });
rtabsEl.addEventListener('click', (e) => { const b = e.target.closest && e.target.closest('[data-rtab]'); if (b) { showResults(rq, b.getAttribute('data-rtab')); input.focus(); } });
el.addEventListener('mousedown', (e) => { if (e.target === el) close(); });
el.addEventListener('mousedown', (e) => { if (e.target === el) close(); });
el.querySelector('.ovs-x').addEventListener('click', close);
el.querySelector('.ovs-x').addEventListener('click', close);
+ orbEl = document.createElement('button');
+ orbEl.type = 'button'; orbEl.className = 'ovs-orb'; orbEl.setAttribute('aria-label', 'Close the menu'); orbEl.title = 'Close (Esc)';
+ orbEl.innerHTML = orbSvg();
+ orbEl.addEventListener('click', close);
+ el.appendChild(orbEl);
tabsEl.addEventListener('click', (e) => {
tabsEl.addEventListener('click', (e) => {
const b = e.target.closest && e.target.closest('[data-tab]'); if (!b) return;
const b = e.target.closest && e.target.closest('[data-tab]'); if (!b) return;
tab = b.getAttribute('data-tab'); saveTab(tab); render(''); listEl.scrollTop = 0;
tab = b.getAttribute('data-tab'); saveTab(tab); render(''); listEl.scrollTop = 0;
@@ -1178,6 +1220,21 @@
@@ -1178,6 +1220,21 @@
el.style.setProperty('--ovs-cx', `${Math.round(cx)}px`); el.style.setProperty('--ovs-cy', `${Math.round(cy)}px`);
el.style.setProperty('--ovs-cx', `${Math.round(cx)}px`); el.style.setProperty('--ovs-cy', `${Math.round(cy)}px`);
}
}
try { document.dispatchEvent(new CustomEvent('openvibe-menu', { detail: { open: true } })); } catch { /* */ }
try { document.dispatchEvent(new CustomEvent('openvibe-menu', { detail: { open: true } })); } catch { /* */ }
+ if (closing) { clearTimeout(closing); closing = 0; el.classList.remove('is-closing'); }
+ if (orbEl) {
+ orbEl.classList.remove('is-x');
+ if (phoneSheet()) {
+ // From the button's centre to the pill's right end: the same orb, moving into the menu.
+ const cx = Number.isFinite(at && at.cx) ? at.cx : innerWidth - 24, cy = Number.isFinite(at && at.cy) ? at.cy : 28;
+ const bar = el.querySelector('.ovs-bar'), br = bar ? bar.getBoundingClientRect() : null;
+ const tx = br ? br.right - 26 : cx, ty = br ? br.top + br.height / 2 : cy;
+ orbEl.style.setProperty('--orb-x', `${Math.round(tx - 20)}px`); orbEl.style.setProperty('--orb-y', `${Math.round(ty - 20)}px`);
+ orbEl.style.setProperty('--orb-dx', `${Math.round(cx - tx)}px`); orbEl.style.setProperty('--orb-dy', `${Math.round(cy - ty)}px`);
+ orbEl.classList.add('is-from');
+ void orbEl.offsetWidth;
+ requestAnimationFrame(() => { orbEl.classList.remove('is-from'); orbEl.classList.add('is-x'); });
+ }
+ }
document.documentElement.style.overflow = 'hidden';
document.documentElement.style.overflow = 'hidden';
input.placeholder = `Search ${here()}, or ask Vibe`;
input.placeholder = `Search ${here()}, or ask Vibe`;
input.value = opts.q || '';
input.value = opts.q || '';
@@ -1196,10 +1253,18 @@
@@ -1196,10 +1253,18 @@
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() {
if (view === 'results' && rtabsEl) leaveResults();
if (view === 'results' && rtabsEl) leaveResults();
- if (!el) return;
+ if (!el || closing) return;
// 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 { /* */ } }
- el.classList.remove('is-open');
+ // A phone's menu folds back into the orb (its x turns back into dots) before it goes; elsewhere it closes at once.
+ if (phoneSheet() && !calm() && el.classList.contains('is-open')) {
+ el.classList.add('is-closing');
+ if (orbEl) { orbEl.classList.remove('is-x'); orbEl.classList.add('is-from'); }
+ closing = setTimeout(() => { closing = 0; el.classList.remove('is-open', 'is-closing'); if (orbEl) orbEl.classList.remove('is-from'); }, 280);
+ } else {
+ el.classList.remove('is-open');
+ if (orbEl) orbEl.classList.remove('is-x', 'is-from');
+ }
document.documentElement.style.overflow = '';
document.documentElement.style.overflow = '';
if (inflight) { inflight.abort(); inflight = null; }
if (inflight) { inflight.abort(); inflight = null; }
if (ctx && typeof ctx.onClose === 'function') { try { ctx.onClose(); } catch { /* */ } }
if (ctx && typeof ctx.onClose === 'function') { try { ctx.onClose(); } catch { /* */ } }
@@ -21,7 +21,7 @@ const DOCK_PATH = path.join(ROOT, 'dock.js');
@@ -21,7 +21,7 @@ const DOCK_PATH = path.join(ROOT, 'dock.js');
// time something is sent, not on every page.
// time something is sent, not on every page.
// The timer itself (menu, countdown, chime, focus rounds) is dock-timer.js (3.7 KB brotli), loaded only while a timer
// The timer itself (menu, countdown, chime, focus rounds) is dock-timer.js (3.7 KB brotli), loaded only while a timer
// runs or its menu opens; the header's tab rail and narrow-width rules came with it.
// runs or its menu opens; the header's tab rail and narrow-width rules came with it.
-const DOCK_BROTLI_BUDGET = 16794; // 16.40 KB: 16.30 KB with the link cards' loader (4.147.0, 2026-11-08; the card itself is dock-card.js, loaded the first time one shows) + headroom; 16.25 KB: 16.07 KB with OpenVibe Chat's hooks (share(), canSend(), the draft; 4.144.0, 2026-11-08), Send to… itself is dock-share.js (1.9 KB, loaded on first use); 16.0 KB: 15.74 KB with its siblings' ?v= content hashes (4.137.0, 2026-11-07) + headroom; 15.70 KB: 15.55 KB with Ask your agent (4.135.0, 2026-11-07) + headroom; was 15.50 KB (15435 after 4.120.0)
+const DOCK_BROTLI_BUDGET = 17203; // 16.80 KB: 16.55 KB with the phone header's two rows (4.150.0, 2026-11-08: the open tab read "Ac") + headroom; 16.40 KB: 16.30 KB with the link cards' loader (4.147.0, 2026-11-08; the card itself is dock-card.js, loaded the first time one shows) + headroom; 16.25 KB: 16.07 KB with OpenVibe Chat's hooks (share(), canSend(), the draft; 4.144.0, 2026-11-08), Send to… itself is dock-share.js (1.9 KB, loaded on first use); 16.0 KB: 15.74 KB with its siblings' ?v= content hashes (4.137.0, 2026-11-07) + headroom; 15.70 KB: 15.55 KB with Ask your agent (4.135.0, 2026-11-07) + headroom; was 15.50 KB (15435 after 4.120.0)
const SIBLINGS = ['action-center.js', 'notification-ui.js', 'notification-live.js', 'ov-mark.js', 'dock-vibe.js', 'dock-timer.js', 'dock-agent.js', 'dock-share.js', 'dock-card.js'];
const SIBLINGS = ['action-center.js', 'notification-ui.js', 'notification-live.js', 'ov-mark.js', 'dock-vibe.js', 'dock-timer.js', 'dock-agent.js', 'dock-share.js', 'dock-card.js'];
// ── the budget ──────────────────────────────────────────────
// ── the budget ──────────────────────────────────────────────
@@ -38,7 +38,8 @@ const server = http.createServer((req, res) => {
@@ -38,7 +38,8 @@ const server = http.createServer((req, res) => {
});
});
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
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);
+// A phone: the menu's close is the orb in the button's place (4.150.0); a wide screen: the word Close.
+const BUTTON = `(() => { const b = innerWidth <= 640 ? document.querySelector('.ovs.is-open .ovs-orb') : 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');
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,
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',
vw: document.documentElement.clientWidth, label: b.getAttribute('aria-label'), icon: !!svg && getComputedStyle(svg).display !== 'none',
@@ -65,11 +66,13 @@ async function openMenu(p) {
@@ -65,11 +66,13 @@ async function openMenu(p) {
assert.ok(x && x.shown, 'the close button shows on a phone');
assert.ok(x && x.shown, 'the close button shows on a phone');
assert.strictEqual(x.vw, 390, 'a phone layout');
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.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.deepStrictEqual([x.label, x.icon, x.word], ['Close the menu', true, false], 'the orb, named for a screen reader');
+ assert.strictEqual(await p.evaluate(`(() => { const o = document.querySelector('.ovs.is-open .ovs-orb'); return o.classList.contains('is-x') && o.querySelectorAll('.ovo-d').length === 9; })()`), true, 'its nine dots have turned into an x');
+ assert.strictEqual(await p.evaluate(`getComputedStyle(document.querySelector('.ovs.is-open .ovs-x')).display`), 'none', 'one close: the round x steps aside for the orb');
+ const hit = await p.evaluate(`(() => { const b = document.querySelector('.ovs.is-open .ovs-orb').getBoundingClientRect(); const e = document.elementFromPoint(b.left - 2, b.top + b.height / 2); return !!e && !!e.closest('.ovs-orb'); })()`);
assert.ok(hit, 'the hit area reaches past the circle');
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);
+ await p.evaluate('document.querySelector(".ovs.is-open .ovs-orb").click(); 1');
+ await wait(450); // the menu folds back into the orb
assert.strictEqual(await p.evaluate('!!document.querySelector(".ovs.is-open")'), false, 'a tap closes the menu');
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 });
const wide = await h.openPage(browser, context, { width: 1280, height: 900 });