openvibers/openvibe.live Commits

Dropdowns never leave the screen (dvh, safe area, clamped positioning, one panel at a time, closed on rotate/Escape); icon boxes centre their glyph; hero rows on one spacing step; deploy card footer layout, clamped subjects, replay to late joiners

OP OpenVibers committed 18 Sept 2026 · ecfcf55788 · 8 files, +114 −17


Changes

ddiff --git a/public/css/features/home.css b/public/css/features/home.css
iindex d289bce8..ca0b65bc 100644
---- a/public/css/features/home.css
++++ b/public/css/features/home.css
@@@ -1070,3 +1070,12 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
/* Hero rhythm: one consistent vertical beat between title → tagline → buttons → links → receipt → stats. */
.hero-slogan-timer { margin: -4px 0 16px; }
.hero-philosophy-links { margin-top: 18px; }
++
++/* ── Hero rhythm ──────────────────────────────────────────────────────────────
++ The rows stacked under the hero buttons (philosophy + legal links, the "shipped" pill, the stats strip)
++ each carried their own margin from a different era: 8px, 28px and 14px in a row. One step for all
++ three, scaled with the viewport, so the block reads as a single evenly spaced column. */
++#page-home .hero { --hero-step: clamp(12px, 1.6vw, 18px); }
++#page-home .hero .hero-philosophy-links { margin: var(--hero-step) auto 0; row-gap: 6px; }
++#page-home .hero .hero-latest { margin: var(--hero-step) auto 0; }
++#page-home .hero .hero-stats { margin-top: var(--hero-step); }
ddiff --git a/public/css/features/stats.css b/public/css/features/stats.css
iindex 732fe334..a9fb1440 100644
---- a/public/css/features/stats.css
++++ b/public/css/features/stats.css
@@@ -19,10 +19,12 @@
display: inline-flex; align-items: center; gap: 6px; font-size: 0.68rem; font-weight: 800;
letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted);
}
--.sn-kicker i { color: var(--accent); }
++.sn-kicker i { color: var(--accent); flex: none; width: 1.5em; text-align: center; }
.sn-pos { font-weight: 600; letter-spacing: 0; text-transform: none; opacity: 0.8; }
.sn-head h3 { margin: 2px 0 2px; font-size: 1.3rem; display: flex; align-items: center; gap: 10px; }
--.sn-icon { color: var(--accent); font-size: 1rem; width: 30px; height: 30px; display: grid; place-items: center; border-radius: 9px; background: color-mix(in srgb, var(--accent) 16%, transparent); }
++/* Font Awesome sets `display` on the icon class itself, which used to beat this rule and leave the glyph in the
++ box's top-left corner. Its own custom property is the supported way to change that. */
++.sn-icon { --fa-display: inline-grid; flex: none; line-height: 1; color: var(--accent); font-size: 1rem; width: 30px; height: 30px; display: grid; place-items: center; border-radius: 9px; background: color-mix(in srgb, var(--accent) 16%, transparent); }
.sn-desc { margin: 0; color: var(--text-secondary); font-size: 0.86rem; }
.sn-nav, .sn-close {
width: 34px; height: 34px; display: grid; place-items: center; border-radius: 10px; cursor: pointer;
ddiff --git a/public/css/style.css b/public/css/style.css
iindex 1510edc9..6a2b4352 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -1368,7 +1368,7 @@ main#app { position: relative; z-index: 1; }
@keyframes eggShake { 10%,90%{transform:translate3d(-2px,0,0)} 20%,80%{transform:translate3d(4px,0,0)} 30%,50%,70%{transform:translate3d(-8px,0,0)} 40%,60%{transform:translate3d(8px,0,0)} }
@keyframes eggRainbow { 0%{filter:hue-rotate(0)} 100%{filter:hue-rotate(360deg)} }
.network-hub-header > i {
-- font-size: 1.6rem; color: var(--accent); flex-shrink: 0;
++ font-size: 1.6rem; line-height: 1; color: var(--accent); flex-shrink: 0;
background: color-mix(in srgb, var(--accent) 10%, transparent); padding: 12px; border-radius: 12px;
}
.network-hub-header h3 {
@@@ -11847,3 +11847,29 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
.ov-conn-pill { transition: opacity var(--motion-fast) linear; transform: translate3d(-50%, 0, 0); }
.ov-conn-pill .ov-conn-dot { animation: none !important; }
}
++
++/* ── Dropdowns never leave the screen ─────────────────────────────────────────
++ One rule for every navbar panel (user menu, nav dropdowns, mobile nav drawer). 100vh on phones
++ includes the strip hidden behind the browser's address bar, which is why the bottom of a long menu
++ used to fall off-screen and could not be scrolled to: dvh is the visible height. The cap also
++ leaves room for the home indicator, and the panel scrolls inside itself without dragging the page. */
++.user-dropdown, .nav-dropdown-menu, .nav-links.show {
++ max-height: calc(100vh - var(--navbar-h) - 16px);
++ max-height: calc(100dvh - var(--navbar-h) - 16px - env(safe-area-inset-bottom, 0px));
++ overflow-y: auto; overflow-x: hidden;
++ overscroll-behavior: contain; -webkit-overflow-scrolling: touch;
++ scrollbar-width: thin; scrollbar-color: color-mix(in srgb, var(--accent) 40%, transparent) transparent;
++}
++.user-dropdown { max-width: calc(100vw - 16px); }
++/* Below tablet width the user menu is a sheet pinned under the bar, as wide as the phone allows. */
++@media (max-width: 900px) {
++ .user-dropdown { position: fixed; top: calc(var(--navbar-h) + 6px); right: 8px; left: auto; min-width: 0; width: min(340px, calc(100vw - 16px)); }
++ /* The floating chat button sits over the bottom-right corner: let the last row scroll clear of it. */
++ .user-dropdown > :last-child { padding-bottom: calc(10px + env(safe-area-inset-bottom, 0px)); }
++ .user-dropdown::after { content: ''; display: block; height: 64px; }
++}
++/* Short screens (landscape phones, split view): tighter rows so more of the menu is visible at once. */
++@media (max-height: 560px) {
++ .user-dropdown .ud-group a, .nav-links.show .nav-link { padding-top: 7px; padding-bottom: 7px; font-size: 0.86rem; }
++ .user-dropdown .ud-label { padding-top: 8px; padding-bottom: 2px; }
++}
ddiff --git a/public/js/app.js b/public/js/app.js
iindex 663c8be4..27338a21 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -840,7 +840,9 @@ function openChannelRewards() {
}
function toggleUserMenu() {
-- document.getElementById('user-dropdown').classList.toggle('show');
++ 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
}
function closeMobileNav() {
@@@ -853,6 +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; }
}
function isModifiedLinkClick(event) {
@@@ -1423,6 +1426,7 @@ function toggleNavDropdown(id) {
function closeNavDropdowns() {
document.querySelectorAll('.nav-dropdown.open').forEach(d => d.classList.remove('open'));
++ closeMobileNav();
}
// Close nav dropdowns when clicking outside
@@@ -1462,9 +1466,30 @@ function positionNavDropdownMenu(dropdown) {
const menu = dropdown?.querySelector('.nav-dropdown-menu');
const trigger = dropdown?.querySelector('.nav-link');
if (!menu || !trigger) return;
++ if (getComputedStyle(menu).position !== 'fixed') { menu.style.top = menu.style.left = menu.style.maxHeight = ''; return; } // inside the mobile drawer it flows inline
const rect = trigger.getBoundingClientRect();
++ const vw = document.documentElement.clientWidth, vh = window.visualViewport ? window.visualViewport.height : window.innerHeight;
++ const width = menu.offsetWidth || 200, pad = 8;
++ // Keep the whole menu on screen: slide left when it would cross the right edge, cap its height to the space below.
++ menu.style.left = `${Math.max(pad, Math.min(rect.left, vw - width - pad))}px`;
menu.style.top = `${rect.bottom}px`;
-- menu.style.left = `${rect.left}px`;
++ menu.style.maxHeight = `${Math.max(120, vh - rect.bottom - pad)}px`;
++}
++
++/** Close every navbar panel (used when the viewport changes under an open menu). */
++function closeNavPanels() {
++ document.getElementById('user-dropdown')?.classList.remove('show');
++ document.querySelectorAll('.nav-dropdown.open').forEach(d => d.classList.remove('open'));
++}
++// A rotated phone, a resized window or an opened keyboard invalidates a fixed menu's position: reposition
++// what is open, and close panels on orientation change. Escape closes them too.
++{
++ let raf = 0;
++ 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/public/js/chat.js b/public/js/chat.js
iindex 24fe0851..b3559ca2 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -3769,12 +3769,21 @@ function addRichSystemMessage(html, style = 'info') {
function _deployCss() {
if (document.getElementById('chat-deploy-css')) return;
const st = document.createElement('style'); st.id = 'chat-deploy-css';
-- st.textContent = `.chat-msg.system.chat-deploy,.chat-msg.system.chat-deploy *{font-style:normal}.chat-deploy{margin:6px 0;padding:9px 11px;border-radius:10px;border:1px solid color-mix(in srgb,var(--accent,#3b82f6) 30%,transparent);background:color-mix(in srgb,var(--accent,#3b82f6) 7%,transparent);color:var(--text-secondary,#a8b3c4);font-style:normal;font-size:.84rem;line-height:1.45}
--.chat-deploy-h{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 10px;color:var(--text-primary,#e6edf7)}.chat-deploy-h b{font-weight:700}.chat-deploy-t{font-size:.76rem;color:var(--text-muted,#7d8aa0)}
--.chat-deploy-a{margin-left:auto;display:flex;gap:10px;align-items:center}.chat-deploy a,.chat-deploy-more{color:var(--accent-light,var(--accent,#60a5fa));text-decoration:none;font-size:.78rem;cursor:pointer;background:none;border:0;padding:0;font-family:inherit}
--.chat-deploy-reload{border:0;border-radius:6px;background:var(--accent,#3b82f6);color:var(--on-accent,#fff);font:600 .74rem/1 inherit;padding:4px 9px;cursor:pointer}
--.chat-deploy ul{list-style:none;margin:6px 0 0;padding:0;display:grid;gap:2px}.chat-deploy li{display:flex;gap:7px;align-items:baseline;min-width:0}.chat-deploy li.is-hidden{display:none}.chat-deploy.is-open li.is-hidden{display:flex}
--.chat-deploy li span{min-width:0;overflow-wrap:anywhere}.chat-deploy li a{font-family:ui-monospace,Menlo,monospace;font-size:.72rem;flex:none}.chat-deploy li.is-fresh span{color:var(--text-primary,#e6edf7)}`;
++ st.textContent = `.chat-msg.system.chat-deploy,.chat-msg.system.chat-deploy *{font-style:normal}
++.chat-deploy{margin:8px 0;padding:10px 12px;border-radius:12px;border:1px solid color-mix(in srgb,var(--accent,#3b82f6) 28%,transparent);background:color-mix(in srgb,var(--accent,#3b82f6) 6%,transparent);color:var(--text-secondary,#a8b3c4);font-size:.84rem;line-height:1.45;container-type:inline-size}
++.chat-deploy-h{display:flex;flex-wrap:wrap;align-items:baseline;gap:2px 10px;color:var(--text-primary,#e6edf7)}.chat-deploy-h b{font-weight:700}.chat-deploy-t{font-size:.76rem;color:var(--text-muted,#7d8aa0)}
++.chat-deploy ul{list-style:none;margin:8px 0 0;padding:0;display:grid;gap:4px}
++.chat-deploy li{display:grid;grid-template-columns:4.6em minmax(0,1fr);gap:8px;align-items:baseline}.chat-deploy li.is-hidden{display:none}.chat-deploy.is-open li.is-hidden{display:grid}
++.chat-deploy li a{font-family:ui-monospace,Menlo,monospace;font-size:.72rem;color:var(--accent-light,var(--accent,#60a5fa));text-decoration:none}
++.chat-deploy li span{min-width:0;overflow-wrap:anywhere;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.chat-deploy.is-open li span{display:block}
++.chat-deploy li.is-fresh span{color:var(--text-primary,#e6edf7)}
++.chat-deploy-f{display:flex;flex-wrap:wrap;align-items:center;gap:8px 12px;margin-top:9px;padding-top:8px;border-top:1px solid color-mix(in srgb,var(--accent,#3b82f6) 16%,transparent)}
++.chat-deploy-a{margin-left:auto;display:flex;align-items:center;gap:12px}
++.chat-deploy-f a,.chat-deploy-more{color:var(--accent-light,var(--accent,#60a5fa));text-decoration:none;font:600 .78rem/1 inherit;cursor:pointer;background:none;border:0;padding:4px 0;min-height:24px}
++.chat-deploy-more::after{content:' ▾';font-size:.7em}.chat-deploy-more[aria-expanded=true]::after{content:' ▴'}
++.chat-deploy-reload{border:0;border-radius:8px;background:var(--accent,#3b82f6);color:var(--on-accent,#fff);font:700 .76rem/1 inherit;padding:7px 11px;cursor:pointer}
++.chat-deploy-f a:focus-visible,.chat-deploy-more:focus-visible,.chat-deploy-reload:focus-visible{outline:2px solid var(--accent,#3b82f6);outline-offset:2px;border-radius:6px}
++@container (max-width:300px){.chat-deploy li{grid-template-columns:minmax(0,1fr)}.chat-deploy li a{display:none}.chat-deploy-a{margin-left:0;width:100%;justify-content:space-between}}`;
document.head.appendChild(st);
}
function _deployTime(d, forceDate) {
@@@ -3798,12 +3807,13 @@ function renderDeployCard(data, opts = {}) {
if (!el) { el = document.createElement('div'); el.className = 'chat-msg system chat-deploy'; if (id) el.dataset.deployId = id.replace(/[^\w-]/g, ''); }
const range = first.getTime() === last.getTime() || Math.abs(last - first) < 60000 ? _deployTime(last) : _deployTime(first, first.toDateString() !== last.toDateString()) + ' – ' + _deployTime(last, first.toDateString() !== last.toDateString());
const deploys = Number(data.deploys) || 1;
-- el.innerHTML = `<div class="chat-deploy-h"><b>🚀 ${commits.length} update${commits.length === 1 ? '' : 's'} shipped</b><span class="chat-deploy-t" title="${esc(last.toLocaleString())}">${esc(range)}${deploys > 1 ? ' · ' + deploys + ' deploys' : ''}</span>
-- <span class="chat-deploy-a">${commits.length > SHOW ? `<button type="button" class="chat-deploy-more">${wasOpen ? 'Show less' : 'Show all ' + commits.length}</button>` : ''}<a href="/updates" target="_blank" rel="noopener">Patch notes</a>${opts.live ? '<button type="button" class="chat-deploy-reload" title="Load the new version">Reload</button>' : ''}</span></div>
-- <ul>${commits.map((c, i) => `<li class="${i >= SHOW ? 'is-hidden' : ''}${fresh.has(c.hash) ? ' is-fresh' : ''}">${c.hash ? `<a href="https://github.com/OpenVibers/OpenVibe.Live/commit/${esc(c.hash)}" target="_blank" rel="noopener">${esc(c.short || String(c.hash).slice(0, 7))}</a>` : ''}<span>${esc(c.subject)}</span></li>`).join('')}</ul>`;
++ const rest = commits.length - SHOW;
++ el.innerHTML = `<div class="chat-deploy-h"><b>🚀 ${commits.length} update${commits.length === 1 ? '' : 's'} shipped</b><span class="chat-deploy-t" title="${esc(last.toLocaleString())}">${esc(range)}${deploys > 1 ? ' · ' + deploys + ' deploys' : ''}</span></div>
++ <ul>${commits.map((c, i) => `<li class="${i >= SHOW ? 'is-hidden' : ''}${fresh.has(c.hash) ? ' is-fresh' : ''}">${c.hash ? `<a href="https://github.com/OpenVibers/OpenVibe.Live/commit/${esc(c.hash)}" target="_blank" rel="noopener">${esc(c.short || String(c.hash).slice(0, 7))}</a>` : '<i></i>'}<span>${esc(c.subject)}</span></li>`).join('')}</ul>
++ <div class="chat-deploy-f">${rest > 0 ? `<button type="button" class="chat-deploy-more" aria-expanded="${wasOpen ? 'true' : 'false'}">${wasOpen ? 'Show fewer' : `Show ${rest} more`}</button>` : ''}<span class="chat-deploy-a"><a href="/updates" target="_blank" rel="noopener">Patch notes</a>${opts.live ? '<button type="button" class="chat-deploy-reload" title="Load the new version">Reload to update</button>' : ''}</span></div>`;
if (wasOpen) el.classList.add('is-open');
const more = el.querySelector('.chat-deploy-more');
-- if (more) more.addEventListener('click', () => { const open = el.classList.toggle('is-open'); more.textContent = open ? 'Show less' : 'Show all ' + commits.length; });
++ if (more) more.addEventListener('click', () => { const open = el.classList.toggle('is-open'); more.textContent = open ? 'Show fewer' : `Show ${rest} more`; more.setAttribute('aria-expanded', String(open)); });
const reload = el.querySelector('.chat-deploy-reload');
if (reload) reload.addEventListener('click', () => { location.href = location.pathname + '?_=' + Date.now(); });
// A live update moves the card to the end (it IS the newest message); history keeps its position.
ddiff --git a/server/chat/chat-server.js b/server/chat/chat-server.js
iindex bf141489..32c43d6f 100644
---- a/server/chat/chat-server.js
++++ b/server/chat/chat-server.js
@@@ -454,6 +454,8 @@ class ChatServer {
if (oldStream) this.broadcastUserCount(oldStream);
this.broadcastUserCount(client.streamId);
}
++ // A deploy notice from this boot reaches late joiners too (once per socket; see deploy-notice.js).
++ setTimeout(() => { try { require('./deploy-notice').replayTo(ws); } catch { /* optional */ } }, 1200);
// Send identity confirmation so the client knows who it is
const displayName = client.user ? (client.user.display_name || client.user.username) : client.anonId;
const streamSlowSec = client.streamId ? Math.round((this.slowModeByStream.get(client.streamId) || 0) / 1000) : 0;
ddiff --git a/server/chat/deploy-notice.js b/server/chat/deploy-notice.js
iindex a86c4c7b..4b0f91a8 100644
---- a/server/chat/deploy-notice.js
++++ b/server/chat/deploy-notice.js
@@@ -25,6 +25,10 @@ const FOLD_WINDOW_MS = 12 * 60 * 60 * 1000;
const MAX_COMMITS = 40;
const ATTEMPTS_MS = [5000, 20000, 45000]; // clients reconnect with backoff after a restart
++// The notice for THIS boot, kept for a while so clients that reconnect late still get it exactly once.
++const REPLAY_MS = 15 * 60 * 1000;
++let _live = null; // { payload, until, sent: WeakSet }
++
const git = (args, timeout = 5000) => new Promise((resolve) => {
execFile('git', args, { cwd: REPO_DIR, encoding: 'utf8', timeout, maxBuffer: 1024 * 1024 }, (err, out) => resolve(err ? '' : String(out || '')));
});
@@@ -84,6 +88,7 @@ async function announce({ db, chatServer, log = console }) {
const payload = JSON.stringify({ type: 'update', kind: 'deploy', id: saved.id, fresh: commits.map(c => c.hash), url: '/updates', timestamp: saved.meta.updated_at, ...saved.meta });
const sent = new WeakSet();
++ _live = { payload, until: Date.now() + REPLAY_MS, sent };
const push = () => {
let n = 0;
for (const [ws] of chatServer.clients) {
@@@ -96,4 +101,15 @@ async function announce({ db, chatServer, log = console }) {
return { announced: commits.length };
}
--module.exports = { announce, newCommits, persist, plainText, parseLog, SETTING };
++/**
++ * Called when a chat client finishes joining: if this boot shipped something and this socket has not had
++ * it, send it now. Covers clients whose reconnect backoff outlasted the broadcast passes, a chat server
++ * that came up late, and tabs that were asleep. The card is keyed by row id, so a repeat cannot duplicate.
++ */
++function replayTo(ws) {
++ if (!_live || Date.now() > _live.until || _live.sent.has(ws)) return false;
++ try { if (ws.readyState === 1) { ws.send(_live.payload); _live.sent.add(ws); return true; } } catch { /* socket went away */ }
++ return false;
++}
++
++module.exports = { replayTo, announce, newCommits, persist, plainText, parseLog, SETTING };
ddiff --git a/vendor/openvibe-shared/navbar.js b/vendor/openvibe-shared/navbar.js
iindex c1262fad..64466bba 100644
---- a/vendor/openvibe-shared/navbar.js
++++ b/vendor/openvibe-shared/navbar.js
@@@ -83,7 +83,7 @@
.ovnav-launcher a { all: unset; cursor: pointer; box-sizing: border-box; }
.openvibe-navbar .ovnav-sep { width: 1px; height: 18px; background: var(--border, rgba(255,255,255,.12)); margin: 0 6px; flex: none; align-self: center; }
@media (max-width: 1180px) { .openvibe-navbar .ovnav-net, .openvibe-navbar .ovnav-sep { display: none; } }
-- .ovnav-launcher { position: absolute; top: calc(100% + 6px); left: 12px; width: min(440px, calc(100vw - 24px)); max-height: min(560px, calc(100vh - 80px)); overflow: auto; background: var(--bg-elevated, var(--bg-secondary, #111826)); border: 1px solid var(--border, rgba(255,255,255,.12)); border-radius: 16px; box-shadow: 0 24px 60px rgba(0,0,0,.5); padding: 12px; z-index: 1000; opacity: 0; transform: translateY(-6px) scale(.98); transform-origin: top left; pointer-events: none; transition: opacity .16s, transform .2s cubic-bezier(.2,1.2,.3,1); }
++ .ovnav-launcher { position: absolute; top: calc(100% + 6px); left: 12px; width: min(440px, calc(100vw - 24px)); background: var(--bg-elevated, var(--bg-secondary, #111826)); border: 1px solid var(--border, rgba(255,255,255,.12)); border-radius: 16px; box-shadow: 0 24px 60px rgba(0,0,0,.5); padding: 12px; z-index: 1000; opacity: 0; transform: translateY(-6px) scale(.98); transform-origin: top left; pointer-events: none; transition: opacity .16s, transform .2s cubic-bezier(.2,1.2,.3,1); }
.ovnav-launcher.open { opacity: 1; transform: none; pointer-events: auto; }
.ovnav-launcher .ovl-q { width: 100%; box-sizing: border-box; padding: 9px 12px; border-radius: 10px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: var(--bg-primary, #0a0f18); color: var(--text-primary, #e6edf7); font: 500 14px/1.2 inherit; outline: none; }
.ovnav-launcher .ovl-q:focus { border-color: var(--accent, #3b82f6); }
@@@ -188,6 +188,13 @@
animation: openvibe-slide-down .2s ease;
}
.openvibe-navbar-dropdown.open { display: flex; }
++ /* Panels never leave the screen: dvh is the VISIBLE height on phones (100vh reaches behind the address
++ bar), the panel scrolls inside itself, and it is never wider than the viewport. */
++ .openvibe-navbar-dropdown, .ovnav-launcher {
++ max-width: calc(100vw - 16px);
++ max-height: calc(100vh - 64px); max-height: calc(100dvh - 64px - env(safe-area-inset-bottom, 0px));
++ overflow-y: auto; overflow-x: hidden; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; scrollbar-width: thin;
++ }
@keyframes openvibe-slide-down { from { transform: translateY(-8px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
.openvibe-navbar-dropdown-header {