openvibers/openvibe.live Commits

Menus coordinated by the shared panel controller; avatar and display name reported to the Network; vendor sync

OP OpenVibers committed 18 Sept 2026 · 7030f4e496 · 7 files, +158 −19


Changes

ddiff --git a/public/js/app.js b/public/js/app.js
iindex 95d6dcc1..4a9cdb1e 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -842,7 +842,7 @@ function openChannelRewards() {
function toggleUserMenu() {
const dd = document.getElementById('user-dropdown');
const open = dd.classList.toggle('show');
-- if (open) { closeMobileNav(); dd.scrollTop = 0; } // one panel at a time, always opened from the top
++ if (open && !window.OpenVibePanels) closeMobileNav(); // the shared controller does this once it has loaded
}
function closeMobileNav() {
@@@ -855,7 +855,7 @@ function toggleMobileNav() {
const hamburger = document.querySelector('.nav-hamburger');
navLinks.classList.toggle('show');
hamburger?.classList.toggle('open', navLinks.classList.contains('show'));
-- if (navLinks.classList.contains('show')) { document.getElementById('user-dropdown')?.classList.remove('show'); navLinks.scrollTop = 0; }
++ if (navLinks.classList.contains('show') && !window.OpenVibePanels) document.getElementById('user-dropdown')?.classList.remove('show');
}
function isModifiedLinkClick(event) {
@@@ -1475,13 +1475,21 @@ function positionNavDropdownMenu(dropdown) {
menu.style.maxHeight = `${Math.max(120, vh - rect.bottom - pad)}px`;
}
--// While the user menu or the mobile drawer is open, the floating chat button steps aside (it used to sit on
--// top of the menu's last rows). Driven by the panels' own classes, so every open/close path is covered.
++// Live's menus are coordinated by the network's shared panel controller (vendor/openvibe-shared/panels.js, the same
++// one the shared navbar uses on every other site): one panel open at a time, Escape and rotation close them, and
++// <body class="ov-panel-open"> lets the floating chat button step aside. Live keeps its own markup and toggles.
{
-- const sync = () => document.body.classList.toggle('ov-panel-open',
-- !!document.querySelector('#user-dropdown.show, .nav-links.show'));
-- const watch = () => { for (const el of [document.getElementById('user-dropdown'), document.querySelector('.nav-links')]) if (el) new MutationObserver(sync).observe(el, { attributes: true, attributeFilter: ['class'] }); };
-- if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', watch); else watch();
++ const register = () => {
++ const P = window.OpenVibePanels; if (!P) return;
++ const dd = document.getElementById('user-dropdown'); if (dd) P.register({ el: dd, openClass: 'show', id: 'user-menu' });
++ const nl = document.querySelector('.nav-links'); if (nl) P.register({ el: nl, openClass: 'show', id: 'nav-drawer', close: () => closeMobileNav() });
++ document.querySelectorAll('.nav-dropdown').forEach((d, i) => P.register({ el: d, openClass: 'open', id: 'nav-dropdown-' + i }));
++ };
++ const load = () => {
++ if (window.OpenVibePanels) return register();
++ const sc = document.createElement('script'); sc.src = '/shared/panels.js'; sc.async = true; sc.onload = register; document.head.appendChild(sc);
++ };
++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', load); else load();
}
/** Close every navbar panel (used when the viewport changes under an open menu). */
@@@ -1497,8 +1505,6 @@ function closeNavPanels() {
const reflow = () => { cancelAnimationFrame(raf); raf = requestAnimationFrame(() => document.querySelectorAll('.nav-dropdown.open, .nav-dropdown:hover').forEach(positionNavDropdownMenu)); };
window.addEventListener('resize', reflow, { passive: true });
if (window.visualViewport) window.visualViewport.addEventListener('resize', reflow, { passive: true });
-- window.addEventListener('orientationchange', closeNavPanels, { passive: true });
-- document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeNavPanels(); });
}
// Observe hover/open to position dropdown menus
ddiff --git a/server/auth/routes.js b/server/auth/routes.js
iindex 41959ab3..532d3c89 100644
---- a/server/auth/routes.js
++++ b/server/auth/routes.js
@@@ -233,6 +233,7 @@ router.post('/avatar', requireAuth, avatarUpload.single('avatar'), async (req, r
db.updateUserAvatar(req.user.id, screenshotUrl, paste.id || null);
const updated = db.getUserById(req.user.id);
++ try { require('../utils/notify').reportLinkedAccount(updated); } catch { /* the Network learns the new picture on the next sign-in */ }
res.json({ user: sanitizeUser(updated), avatar_url: screenshotUrl });
} catch (err) {
if (req.file) try { fs.unlinkSync(req.file.path); } catch { }
ddiff --git a/server/index.js b/server/index.js
iindex c7bf5127..05ef1969 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -435,7 +435,7 @@ app.use((req, res, next) => {
// Serve openvibe-shared browser assets at /shared/ — serve directly from the
// resolved source directory to avoid write operations (the public/ directory
// is read-only under systemd ProtectSystem=strict on production).
--const SHARED_BROWSER_FILES = ['theme-loader.js', 'notification-ui.js', 'account-switcher.js', 'openvibe-sw.js', 'footer.js', 'ov-mark.js', 'tooltip.js', 'history.js', 'navbar.js', 'sso-client.js', 'ov-icons.js', 'island.js', 'ui.js'];
++const SHARED_BROWSER_FILES = ['theme-loader.js', 'notification-ui.js', 'account-switcher.js', 'openvibe-sw.js', 'footer.js', 'ov-mark.js', 'tooltip.js', 'history.js', 'navbar.js', 'sso-client.js', 'ov-icons.js', 'island.js', 'ui.js', 'panels.js'];
let sharedServePath = null;
(function resolveSharedAssets() {
ddiff --git a/server/utils/notify.js b/server/utils/notify.js
iindex 10a3a8c6..a88af333 100644
---- a/server/utils/notify.js
++++ b/server/utils/notify.js
@@@ -64,10 +64,16 @@ function pushNotification(payload) {
// Register this user as having a linked OpenVibe.Live account on openvibe.network so it
// appears under their Linked Services. Fire-and-forget + deduped per process.
--const _linkedReported = new Set();
++const _linkedReported = new Map(); // live user id → what was last reported (so a new avatar or name is sent again)
function reportLinkedAccount(user) {
-- if (!user?.id || _linkedReported.has(user.id) || !INTERNAL_API_KEY) return;
-- _linkedReported.add(user.id);
++ if (!user?.id || !INTERNAL_API_KEY) return;
++ // The Network adopts this picture and name when the account has none of its own, which is what makes the
++ // same face appear on every OpenVibe site. Relative upload paths are made absolute against this site.
++ let avatar = user.avatar_url || null;
++ if (avatar && avatar.startsWith('/')) avatar = `${String(config.baseUrl || 'https://openvibe.live').replace(/\/$/, '')}${avatar}`;
++ const sig = `${avatar || ''}|${user.display_name || ''}|${user.username || ''}`;
++ if (_linkedReported.get(user.id) === sig) return;
++ _linkedReported.set(user.id, sig);
const networkId = toNetworkId(user.id);
if (!networkId) { _linkedReported.delete(user.id); return; }
_post('/internal/link-account', {
@@@ -75,6 +81,8 @@ function reportLinkedAccount(user) {
service: 'live',
service_user_id: String(user.id),
service_username: user.username || user.display_name || null,
++ avatar_url: avatar,
++ display_name: user.display_name || null,
}).catch(() => { _linkedReported.delete(user.id); });
}
ddiff --git a/vendor/openvibe-shared/navbar.js b/vendor/openvibe-shared/navbar.js
iindex 9713e6af..ad8129ef 100644
---- a/vendor/openvibe-shared/navbar.js
++++ b/vendor/openvibe-shared/navbar.js
@@@ -151,6 +151,13 @@
.openvibe-navbar-dropdown-recent .item .icon { width: 18px; text-align: center; color: var(--accent-light, #60a5fa); flex: none; }
.openvibe-navbar-dropdown-recent .item .t { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.openvibe-navbar-dropdown-recent .item .s { margin-left: auto; font-size: 10px; color: var(--text-muted, #707080); flex: none; }
++ .openvibe-navbar-dropdown { width: min(300px, calc(100vw - 16px)); }
++ .openvibe-navbar-dropdown-menu .ud-label { padding: 8px 10px 3px; font-size: 10.5px; font-weight: 800; letter-spacing: .9px; text-transform: uppercase; color: var(--text-muted, #7d8aa0); }
++ .openvibe-navbar-dropdown-menu .ud-val { margin-left: auto; font-weight: 600; font-size: 12.5px; color: var(--text-secondary, #a8b3c4); }
++ .openvibe-navbar-dropdown-menu .icon { display: inline-grid; place-items: center; width: 22px; flex: none; }
++ .openvibe-navbar-dropdown-header { background: linear-gradient(180deg, color-mix(in srgb, var(--accent, #3b82f6) 10%, transparent), transparent); }
++ .openvibe-navbar-dropdown-header img { border-radius: 50%; box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent, #3b82f6) 55%, transparent); }
++ .openvibe-navbar-dropdown-header .ud-wallet a { display: inline-flex; align-items: center; gap: 5px; margin-top: 5px; padding: 2px 9px; border-radius: 999px; font-size: 12px; font-weight: 700; text-decoration: none; color: #fbbf24; background: rgba(251,191,36,.12); border: 1px solid rgba(251,191,36,.3); }
.openvibe-navbar-dropdown-menu .sep { height: 1px; background: var(--border, #333340); margin: 4px -8px; }
.openvibe-navbar-links { display: flex; align-items: center; gap: 4px; margin-left: 8px; }
@@@ -270,6 +277,10 @@
'fa-shield-halved': '<path d="M12 3.5l7 2.500v5.500c0 4.3-3 7.3-7 9-4-1.7-7-4.7-7-9V6zM12 3.500v17"/>',
'fa-screwdriver-wrench': '<path d="M14.5 5.5a4 4 0 0 0-5.2 5.2L4.5 15.5a1.8 1.8 0 0 0 2.5 2.5l4.8-4.8a4 4 0 0 0 5.2-5.2l-2.6 2.6-2.1-.5-.5-2.1z"/>',
'fa-right-from-bracket': '<path d="M10 5H6.500A1.5 1.5 0 0 0 5 6.500v11A1.5 1.5 0 0 0 6.5 19H10M14 8l4 4-4 4M18 12H9.5"/>',
++ 'fa-tower-broadcast': '<circle cx="12" cy="12" r="2"/><path d="M7.8 7.8a6 6 0 0 0 0 8.4M16.2 7.8a6 6 0 0 1 0 8.4M5 5a10 10 0 0 0 0 14M19 5a10 10 0 0 1 0 14"/>',
++ 'fa-text-height': '<path d="M4 7V5.5h9V7M8.5 5.5v13M6.5 18.5h4M18 6v12M15.5 8.5 18 6l2.5 2.5M15.5 15.5 18 18l2.5-2.5"/>',
++ 'fa-wand-magic-sparkles': '<path d="M5 19 15 9M13.5 7.5l3 3M17.5 4l.6 1.4 1.4.6-1.4.6-.6 1.4-.6-1.4L15.5 6l1.4-.6zM7 5l.5 1.2 1.2.5-1.2.5L7 8.400l-.5-1.200L5.3 6.700l1.2-.5zM18.5 13l.5 1.2 1.2.5-1.2.5-.5 1.2-.5-1.2-1.2-.5 1.2-.5z"/>',
++ 'fa-coins': '<ellipse cx="12" cy="7.5" rx="6.5" ry="3"/><path d="M5.5 7.5v4.500c0 1.7 2.9 3 6.5 3s6.5-1.3 6.5-3V7.500M5.5 12v4.500c0 1.7 2.9 3 6.5 3s6.5-1.3 6.5-3V12"/>',
'fa-plus': '<path d="M12 5.500v13M5.5 12h13"/>',
'fa-paste': '<rect x="6" y="6" width="12" height="14" rx="1.8"/><path d="M9.5 6V5a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1M9 11h6M9 14h6"/>',
'fa-house': '<path d="M4.5 11.5 12 5l7.5 6.500M6.5 10v8.500h11V10"/>',
@@@ -488,6 +499,7 @@
panel.innerHTML = '<input type="search" class="ovl-q" placeholder="Find a tool or site" aria-label="Find a tool or site"><div class="ovl-body"></div><div class="ovl-display" hidden></div>';
bindDisplayControls(panel.querySelector('.ovl-display'));
nav.appendChild(panel);
++ regPanel(panel, 'launcher', close);
if (!root.OpenVibeIcons && !document.getElementById('ov-icons-loader')) { const sc = document.createElement('script'); sc.id = 'ov-icons-loader'; sc.src = 'https://openvibe.network/shared/ov-icons.js'; sc.async = true; document.head.appendChild(sc); }
let cat = null; paint(null, '');
const input = panel.querySelector('.ovl-q');
@@@ -521,6 +533,51 @@
} catch { /* */ }
}
++ /** Dropdown, launcher and notifications share one coordinator (panels.js): one open at a time, Escape, rotation.
++ * Panels are registered where they are created (no page-wide observers); registrations queue until the script loads. */
++ const _panelQueue = [];
++ function regPanel(el, id, close) {
++ if (!el) return;
++ const opts = { el, openClass: 'open', id, close };
++ if (root.OpenVibePanels) return root.OpenVibePanels.register(opts);
++ _panelQueue.push(opts);
++ if (document.getElementById('ov-panels-loader')) return;
++ const sc = document.createElement('script'); sc.id = 'ov-panels-loader'; sc.src = 'https://openvibe.network/shared/panels.js'; sc.async = true;
++ sc.onload = () => { while (_panelQueue.length) root.OpenVibePanels && root.OpenVibePanels.register(_panelQueue.shift()); };
++ document.head.appendChild(sc);
++ }
++
++ /** Sites in the user menu: most used first (chrome data), never the one we are on. */
++ function acrossHTML() {
++ const chrome = OVChrome.get();
++ const here = currentHost().toLowerCase();
++ const list = (chrome && chrome.nav.length ? chrome.nav.map(n => ({ name: n.name, url: n.url, icon: n.icon || n.id })) : LAUNCHER_SITES)
++ .filter(n => { try { const h = new URL(n.url).hostname; return h !== here && !here.endsWith('.' + h); } catch { return false; } }).slice(0, 5);
++ return list.map(n => `<a href="${escapeAttr(n.url)}"><span class="icon"><span class="ov-icon" data-icon="${escapeAttr(n.icon)}" data-size="20" data-fx="none"></span></span> ${escapeAttr(n.name)}</a>`).join('');
++ }
++
++ /** Display rows (text size, animations) cycle their value; state lives in theme-loader.js. */
++ function bindDisplayRows(dropdown) {
++ const L = root.OpenVibeThemeLoader; const rows = dropdown.querySelectorAll('[data-ov-display]');
++ if (!L || !L.display) { rows.forEach(r => { r.hidden = true; }); return; }
++ const LABEL = { text: { 100: 'Default', 112: 'Large', 125: 'Largest' }, motion: { auto: 'On', reduced: 'Calm' } };
++ const paint = () => { const d = L.display.get(); rows.forEach(r => { const k = r.getAttribute('data-ov-display'); r.querySelector('.ud-val').textContent = LABEL[k][d[k]] || ''; }); };
++ rows.forEach(r => r.addEventListener('click', (e) => { e.stopPropagation(); const k = r.getAttribute('data-ov-display'), o = L.display.options[k], cur = L.display.get()[k]; L.display.set({ [k]: o[(o.indexOf(cur) + 1) % o.length] }); paint(); }));
++ root.addEventListener('ov:display', paint); paint();
++ }
++
++ /** OpenCoins balance in the menu header (one request when the menu first opens). */
++ let _walletLoaded = false;
++ async function loadWallet(dropdown) {
++ if (_walletLoaded || !_config.token) return; _walletLoaded = true;
++ try {
++ const r = await fetch(`${_config.apiBase}/api/coins/me`, { headers: { Authorization: `Bearer ${_config.token}` }, credentials: 'include' });
++ if (!r.ok) return; const j = await r.json(); const bal = Number(j.balance ?? (j.wallet && j.wallet.balance));
++ const el = dropdown.querySelector('#openvibe-wallet'); if (!el || !isFinite(bal)) return;
++ el.innerHTML = `<a href="https://openvibe.network/my#coins" title="OpenCoins">${navIcon('fa-coins')} ${bal.toLocaleString()}</a>`; el.hidden = false;
++ } catch { /* the chip is optional */ }
++ }
++
/** The network's most used sites, after the page's own links (networkLinks: false turns it off). */
function networkLinksHTML(pageLinks) {
if (_config.networkLinks === false) return '';
@@@ -1095,8 +1152,9 @@
<div class="openvibe-navbar-dropdown-header">
${avatarImg(u, 72, '', '')}
<div class="info">
-- <div class="name">${u.display_name || u.username}</div>
-- <div class="email">${u.email || `@${u.username}`}</div>
++ <div class="name">${escapeAttr(u.display_name || u.username)}</div>
++ <div class="email">${isAnon ? '' : '@' + escapeAttr(u.username || '')}</div>
++ <div class="ud-wallet" id="openvibe-wallet" hidden></div>
${isAnon ? `<div class="anon-tag">Anonymous #${u.anon_number || '?'}</div>` : ''}
</div>
</div>
@@@ -1118,13 +1176,22 @@
</div>
<div class="openvibe-navbar-dropdown-recent" id="openvibe-recent" hidden></div>
<div class="openvibe-navbar-dropdown-menu">
-- ${before.map(menuItemHTML).join('')}${before.length ? '<div class="sep"></div>' : ''}
++ ${before.length ? `<div class="ud-label">${escapeAttr((_config.menu && _config.menu.label) || brand.short || 'This site')}</div>${before.map(menuItemHTML).join('')}<div class="sep"></div>` : ''}
++ <div class="ud-label">You</div>
<a href="https://openvibe.network/my"><span class="icon">${navIcon('fa-user')}</span> My Account</a>
-- <a href="https://openvibe.network/my#history"><span class="icon">${navIcon('fa-clock-rotate-left')}</span> History</a>
++ ${!isAnon && u.username ? `<a href="https://openvibe.live/@${escapeAttr(u.username)}"><span class="icon">${navIcon('fa-tower-broadcast')}</span> My Channel</a>` : ''}
<a href="https://openvibe.network/my#notifications"><span class="icon">${navIcon('fa-bell')}</span> Notifications</a>
++ <div class="sep"></div>
++ <div class="ud-label">Display</div>
++ <button type="button" data-ov-display="text"><span class="icon">${navIcon('fa-text-height')}</span> Text size <b class="ud-val"></b></button>
++ <button type="button" data-ov-display="motion"><span class="icon">${navIcon('fa-wand-magic-sparkles')}</span> Animations <b class="ud-val"></b></button>
<a href="https://openvibe.network/themes"><span class="icon">${navIcon('fa-palette')}</span> Themes</a>
++ <div class="sep"></div>
++ <div class="ud-label">Across OpenVibe</div>
++ ${acrossHTML()}
++ <a href="https://openvibe.network/my#history"><span class="icon">${navIcon('fa-clock-rotate-left')}</span> History</a>
<a href="https://openvibe.network/my#linked"><span class="icon">${navIcon('fa-link')}</span> Linked Services</a>
-- ${u.role === 'admin' ? `<a href="https://openvibe.network/admin"><span class="icon">${navIcon('fa-screwdriver-wrench')}</span> Admin Panel</a>` : ''}
++ ${u.role === 'admin' ? `<a href="https://openvibe.network/admin"><span class="icon">${navIcon('fa-shield-halved')}</span> Admin Panel</a>` : ''}
${after.length ? '<div class="sep"></div>' : ''}${after.map(menuItemHTML).join('')}
<div class="sep"></div>
<button id="openvibe-logout-btn" class="danger"><span class="icon">${navIcon('fa-right-from-bracket')}</span> Sign Out</button>
@@@ -1132,11 +1199,14 @@
`;
nav.appendChild(dropdown);
bindMenuItems(dropdown, before.concat(after));
++ bindDisplayRows(dropdown);
++ regPanel(dropdown, 'user-menu');
if (_config.recent !== false) renderRecent(dropdown.querySelector('#openvibe-recent'));
// Avatar click toggles dropdown
nav.querySelector('#openvibe-avatar-btn').addEventListener('click', () => {
dropdown.classList.toggle('open');
++ if (dropdown.classList.contains('open')) { loadWallet(dropdown); dropdown.scrollTop = 0; if (root.OpenVibeIcons) root.OpenVibeIcons.mount(dropdown); else if (!document.getElementById('ov-icons-loader')) { const sc = document.createElement('script'); sc.id = 'ov-icons-loader'; sc.src = 'https://openvibe.network/shared/ov-icons.js'; sc.async = true; document.head.appendChild(sc); } }
});
// Close on outside click
ddiff --git a/vendor/openvibe-shared/notification-ui.js b/vendor/openvibe-shared/notification-ui.js
iindex 5fb45eaf..3d2ab8fa 100644
---- a/vendor/openvibe-shared/notification-ui.js
++++ b/vendor/openvibe-shared/notification-ui.js
@@@ -567,6 +567,7 @@
function togglePanel() {
if (!_panelEl) createPanel();
const opening = !_panelEl.classList.contains('open');
++ if (opening && window.OpenVibePanels && !_panelEl.__registered) { _panelEl.__registered = true; window.OpenVibePanels.register({ el: _panelEl, openClass: 'open', id: 'notifications', close: () => { if (_panelEl.classList.contains('open')) togglePanel(); } }); window.OpenVibePanels.closeAll(_panelEl); }
if (opening) {
// Sit directly under the site's navbar, whatever its height (sticky bars, banners, safe areas).
const bar = _bellEl && _bellEl.closest('nav, header, .navbar');
ddiff --git a/vendor/openvibe-shared/panels.js b/vendor/openvibe-shared/panels.js
nnew file mode 100644
iindex 00000000..6865d97a
---- /dev/null
++++ b/vendor/openvibe-shared/panels.js
@@@ -0,0 +1,53 @@
++/**
++ * openvibe-shared/panels.js — one coordinator for every dropdown, drawer and popover on a page.
++ *
++ * Sites keep their own markup and their own open/close toggles. They register each panel here and get the
++ * behaviour that should be identical everywhere:
++ * • one panel open at a time (opening one closes the others)
++ * • Escape and a device rotation close everything
++ * • <body class="ov-panel-open"> while any panel is open (floating buttons step aside)
++ * • a freshly opened panel starts scrolled to the top
++ *
++ * OpenVibePanels.register({ el, openClass: 'show', close: () => …, id?: 'user-menu' })
++ * OpenVibePanels.closeAll(exceptEl?) · OpenVibePanels.anyOpen()
++ *
++ * It watches the panel's class instead of wrapping the site's click handlers, so it cannot fight them: a
++ * panel is "open" exactly when its element has `openClass`, however that came about.
++ */
++(function (root) {
++ 'use strict';
++ if (root.OpenVibePanels) return;
++ if (typeof document === 'undefined') { const stub = { register() {}, closeAll() {}, anyOpen: () => false }; if (typeof module !== 'undefined') module.exports = stub; return; }
++
++ const panels = []; // { el, openClass, close, id }
++ const isOpen = (p) => p.el.isConnected && p.el.classList.contains(p.openClass);
++ const anyOpen = () => panels.some(isOpen);
++ const syncBody = () => document.body && document.body.classList.toggle('ov-panel-open', anyOpen());
++
++ function closeAll(except) {
++ // A panel nested in another (a submenu inside the mobile drawer) never closes its container, or vice versa.
++ const related = (a, b) => a && b && (a.contains(b) || b.contains(a));
++ for (const p of panels) if (p.el !== except && !related(p.el, except) && isOpen(p)) { try { p.close(); } catch { p.el.classList.remove(p.openClass); } }
++ syncBody();
++ }
++
++ function register(opts) {
++ const el = opts && opts.el; if (!el || panels.some(p => p.el === el)) return;
++ const p = { el, openClass: opts.openClass || 'open', id: opts.id || '', close: typeof opts.close === 'function' ? opts.close : () => el.classList.remove(opts.openClass || 'open') };
++ panels.push(p);
++ let was = isOpen(p);
++ new MutationObserver(() => {
++ const now = isOpen(p); if (now === was) return; was = now;
++ if (now) { closeAll(el); try { el.scrollTop = 0; } catch { /* */ } }
++ syncBody();
++ }).observe(el, { attributes: true, attributeFilter: ['class'] });
++ syncBody();
++ }
++
++ document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && anyOpen()) closeAll(); });
++ root.addEventListener('orientationchange', () => closeAll(), { passive: true });
++
++ const api = { register, closeAll, anyOpen };
++ root.OpenVibePanels = api;
++ if (typeof module !== 'undefined' && module.exports) module.exports = api;
++})(typeof window !== 'undefined' ? window : globalThis);