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
Changes
@@@ -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); }@@@ -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;@@@ -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; }++}@@@ -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 inlineconst 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@@@ -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.@@@ -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 isconst 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;@@@ -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 };@@@ -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 {
@@ -1070,3 +1070,12 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
@@ -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 rhythm: one consistent vertical beat between title → tagline → buttons → links → receipt → stats. */
.hero-slogan-timer { margin: -4px 0 16px; }
.hero-slogan-timer { margin: -4px 0 16px; }
.hero-philosophy-links { margin-top: 18px; }
.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); }
@@ -19,10 +19,12 @@
@@ -19,10 +19,12 @@
display: inline-flex; align-items: center; gap: 6px; font-size: 0.68rem; font-weight: 800;
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);
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-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-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-desc { margin: 0; color: var(--text-secondary); font-size: 0.86rem; }
.sn-nav, .sn-close {
.sn-nav, .sn-close {
width: 34px; height: 34px; display: grid; place-items: center; border-radius: 10px; cursor: pointer;
width: 34px; height: 34px; display: grid; place-items: center; border-radius: 10px; cursor: pointer;
@@ -1368,7 +1368,7 @@ main#app { position: relative; z-index: 1; }
@@ -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 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)} }
@keyframes eggRainbow { 0%{filter:hue-rotate(0)} 100%{filter:hue-rotate(360deg)} }
.network-hub-header > i {
.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;
background: color-mix(in srgb, var(--accent) 10%, transparent); padding: 12px; border-radius: 12px;
}
}
.network-hub-header h3 {
.network-hub-header h3 {
@@ -11847,3 +11847,29 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
@@ -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 { transition: opacity var(--motion-fast) linear; transform: translate3d(-50%, 0, 0); }
.ov-conn-pill .ov-conn-dot { animation: none !important; }
.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; }
+}
@@ -840,7 +840,9 @@ function openChannelRewards() {
@@ -840,7 +840,9 @@ function openChannelRewards() {
}
}
function toggleUserMenu() {
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() {
function closeMobileNav() {
@@ -853,6 +855,7 @@ function toggleMobileNav() {
@@ -853,6 +855,7 @@ function toggleMobileNav() {
const hamburger = document.querySelector('.nav-hamburger');
const hamburger = document.querySelector('.nav-hamburger');
navLinks.classList.toggle('show');
navLinks.classList.toggle('show');
hamburger?.classList.toggle('open', navLinks.classList.contains('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) {
function isModifiedLinkClick(event) {
@@ -1423,6 +1426,7 @@ function toggleNavDropdown(id) {
@@ -1423,6 +1426,7 @@ function toggleNavDropdown(id) {
function closeNavDropdowns() {
function closeNavDropdowns() {
document.querySelectorAll('.nav-dropdown.open').forEach(d => d.classList.remove('open'));
document.querySelectorAll('.nav-dropdown.open').forEach(d => d.classList.remove('open'));
+ closeMobileNav();
}
}
// Close nav dropdowns when clicking outside
// Close nav dropdowns when clicking outside
@@ -1462,9 +1466,30 @@ function positionNavDropdownMenu(dropdown) {
@@ -1462,9 +1466,30 @@ function positionNavDropdownMenu(dropdown) {
const menu = dropdown?.querySelector('.nav-dropdown-menu');
const menu = dropdown?.querySelector('.nav-dropdown-menu');
const trigger = dropdown?.querySelector('.nav-link');
const trigger = dropdown?.querySelector('.nav-link');
if (!menu || !trigger) return;
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 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.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
// Observe hover/open to position dropdown menus
@@ -3769,12 +3769,21 @@ function addRichSystemMessage(html, style = 'info') {
@@ -3769,12 +3769,21 @@ function addRichSystemMessage(html, style = 'info') {
function _deployCss() {
function _deployCss() {
if (document.getElementById('chat-deploy-css')) return;
if (document.getElementById('chat-deploy-css')) return;
const st = document.createElement('style'); st.id = 'chat-deploy-css';
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);
document.head.appendChild(st);
}
}
function _deployTime(d, forceDate) {
function _deployTime(d, forceDate) {
@@ -3798,12 +3807,13 @@ function renderDeployCard(data, opts = {}) {
@@ -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, ''); }
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 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;
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');
if (wasOpen) el.classList.add('is-open');
const more = el.querySelector('.chat-deploy-more');
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');
const reload = el.querySelector('.chat-deploy-reload');
if (reload) reload.addEventListener('click', () => { location.href = location.pathname + '?_=' + Date.now(); });
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.
// A live update moves the card to the end (it IS the newest message); history keeps its position.
@@ -454,6 +454,8 @@ class ChatServer {
@@ -454,6 +454,8 @@ class ChatServer {
if (oldStream) this.broadcastUserCount(oldStream);
if (oldStream) this.broadcastUserCount(oldStream);
this.broadcastUserCount(client.streamId);
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
// Send identity confirmation so the client knows who it is
const displayName = client.user ? (client.user.display_name || client.user.username) : client.anonId;
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;
const streamSlowSec = client.streamId ? Math.round((this.slowModeByStream.get(client.streamId) || 0) / 1000) : 0;
@@ -25,6 +25,10 @@ const FOLD_WINDOW_MS = 12 * 60 * 60 * 1000;
@@ -25,6 +25,10 @@ const FOLD_WINDOW_MS = 12 * 60 * 60 * 1000;
const MAX_COMMITS = 40;
const MAX_COMMITS = 40;
const ATTEMPTS_MS = [5000, 20000, 45000]; // clients reconnect with backoff after a restart
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) => {
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 || '')));
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 }) {
@@ -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 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();
const sent = new WeakSet();
+ _live = { payload, until: Date.now() + REPLAY_MS, sent };
const push = () => {
const push = () => {
let n = 0;
let n = 0;
for (const [ws] of chatServer.clients) {
for (const [ws] of chatServer.clients) {
@@ -96,4 +101,15 @@ async function announce({ db, chatServer, log = console }) {
@@ -96,4 +101,15 @@ async function announce({ db, chatServer, log = console }) {
return { announced: commits.length };
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 };
@@ -83,7 +83,7 @@
@@ -83,7 +83,7 @@
.ovnav-launcher a { all: unset; cursor: pointer; box-sizing: border-box; }
.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; }
.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; } }
@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.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 { 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); }
.ovnav-launcher .ovl-q:focus { border-color: var(--accent, #3b82f6); }
@@ -188,6 +188,13 @@
@@ -188,6 +188,13 @@
animation: openvibe-slide-down .2s ease;
animation: openvibe-slide-down .2s ease;
}
}
.openvibe-navbar-dropdown.open { display: flex; }
.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; } }
@keyframes openvibe-slide-down { from { transform: translateY(-8px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
.openvibe-navbar-dropdown-header {
.openvibe-navbar-dropdown-header {