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)

OP OpenVibers committed 5 days ago · fafec81fb3 · 10 files, +146 −16

The owner: the button should render on top of the menu and turn into its close with a nicer effect; the bell did
nothing (its panel opened under the Dock's sheet); the Dock's tabs were cut off on a phone.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex fd9ec51..6c2221d 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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
ddiff --git a/README.md b/README.md
iindex 0e017a8..7b0f326 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.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
([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 11c584f..16f404b 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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)",
ddiff --git a/dock.js b/dock.js
iindex add539f..2ff58e3 100644
---- a/dock.js
++++ b/dock.js
@@@ -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; }
ddiff --git a/navbar.js b/navbar.js
iindex e4b31a6..e314f46 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -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()));
ddiff --git a/notification-ui.js b/notification-ui.js
iindex 5b670e8..41bd3b4 100644
---- a/notification-ui.js
++++ b/notification-ui.js
@@@ -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();
ddiff --git a/package.json b/package.json
iindex d8aa938..e441dfa 100644
---- a/package.json
++++ b/package.json
@@@ -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": {
ddiff --git a/search.js b/search.js
iindex 43dcf70..06b8993 100644
---- a/search.js
++++ b/search.js
@@@ -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 { /* */ } }
ddiff --git a/test/dock-size.test.js b/test/dock-size.test.js
iindex 07d680b..adf2142 100644
---- a/test/dock-size.test.js
++++ b/test/dock-size.test.js
@@@ -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 ──────────────────────────────────────────────
ddiff --git a/test/menu-close-chrome.test.js b/test/menu-close-chrome.test.js
iindex 1e841aa..2359d6a 100644
---- a/test/menu-close-chrome.test.js
++++ b/test/menu-close-chrome.test.js
@@@ -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 orb
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 });