openvibers/openvibe.live Commits

Live updates: stop the page flashing, stop the scroll yanks, hide the strip when the board opens

OP OpenVibers committed 16 Sept 2026 · 3afca764e5 · 7 files, +74 −19

The strip never actually hid. Setting .hidden on it did nothing because an author `display: grid`
declaration outranks the browser's own [hidden] { display: none } rule, so the expanded board
showed both copies of the same nine numbers. Restated explicitly.

The home page faded itself out and back in once a minute. The 60-second section refresh replaces
five grids with new nodes, the effects layer stamps every new card with the reveal animation, and
that animation starts at opacity 0 — so ~25 cards dropped out and slid back in under the reader's
cursor, collapsing any AI overview they had expanded and destroying text selection. Cards that
are already on screen when they're rebuilt now just appear. The first page load still animates.

Three MutationObservers ran a full-document querySelectorAll on every single DOM mutation —
every chat message, every grid tick, every activity row. All three coalesce to one pass per
frame now.

Scroll yanks, all four fixed the same way: decide whether the reader was at the live edge BEFORE
touching the DOM, because scrollHeight counts the new content the instant it lands.
  - Live captions jumped to the bottom every six seconds even if you had scrolled back to re-read
    a line, and trimming old lines off the top moved the text under you as well.
  - The messenger thread threw you to the bottom every three seconds while reading back.
  - Redemption messages in chat scrolled the container regardless of where you were.
  - The channel tab strip smooth-scrolled itself on every poll tick; it now only moves when the
    active tab actually changed.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMxJm1dB8rdsaMSgNjVa82

Changes

ddiff --git a/public/css/style.css b/public/css/style.css
iindex 6dcd242d..02dc1357 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -14804,6 +14804,10 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
would make each chip absurdly long, so the strip caps and centres itself. Everything inside
scales with clamp() rather than switching at breakpoints, so it reads the same on a 320px
phone, a landscape phone, a tablet and a monitor. */
++/* An author `display` declaration beats the browser's own [hidden] { display: none } rule, so
++ setting .hidden on the strip did nothing and the expanded board showed both copies of the same
++ nine numbers. Restate it explicitly. */
++.hero-stat-strip[hidden] { display: none !important; }
.hero-stat-strip {
display: grid; grid-template-columns: repeat(3, minmax(0, 1fr));
gap: clamp(4px, 0.8vw, 9px); padding: clamp(7px, 1vw, 11px);
ddiff --git a/public/index.html b/public/index.html
iindex 1841173f..0091e6e0 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -88,7 +88,7 @@
overlay) and is fetched without blocking the first paint: media="print" makes the browser
treat it as non-critical, and the onload flips it back to all once it has arrived. -->
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
-- <link rel="stylesheet" href="/css/style.css?v=172">
++ <link rel="stylesheet" href="/css/style.css?v=173">
<link rel="stylesheet" href="/css/home-fx.css?v=10">
<link rel="stylesheet" href="/css/home-tour.css?v=7">
<link rel="stylesheet" href="/css/i18n-star.css?v=5">
@@@ -5129,7 +5129,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/live-notify.js?v=3" defer></script>
<script src="/js/ov-skeleton.js?v=1" defer></script>
<script src="/js/ov-odometer.js?v=3" defer></script>
--<script src="/js/app.js?v=147" defer></script>
++<script src="/js/app.js?v=148" defer></script>
<script src="/js/stream-player.js?v=21" defer></script>
<script src="/js/stream-pip.js?v=1" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
@@@ -5186,7 +5186,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
</div>
<script src="/js/vibes.js?v=11" defer></script>
--<script src="/js/opencoins.js?v=6" defer></script>
++<script src="/js/opencoins.js?v=7" defer></script>
<script src="/js/cosmetics-ui.js?v=2" defer></script>
<!-- Broadcast modules — load order matters: state → devices → vods → workspace → core -->
<script src="/js/broadcast-state.js?v=1" defer></script>
@@@ -5207,12 +5207,12 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/js/powerchat.js?v=10" defer></script>
<script src="/js/pastes.js?v=15" defer></script>
<script src="/js/draggable-fab.js?v=2" defer></script>
--<script src="/js/messenger.js?v=2" defer></script>
--<script src="/js/ov-mark.js?v=1" defer></script>
++<script src="/js/messenger.js?v=3" defer></script>
++<script src="/js/ov-mark.js?v=2" defer></script>
<script src="/shared/tooltip.js?v=1" defer></script>
<script src="/shared/footer.js?v=1" defer></script>
<script src="/js/footer-live.js?v=1" defer></script>
--<script src="/js/home-fx.js?v=8" defer></script>
++<script src="/js/home-fx.js?v=9" defer></script>
<script src="/js/home-tour.js?v=6" defer></script>
<script src="/js/ov-guide.js?v=1" defer></script>
<script src="/js/guides-live.js?v=5" defer></script>
ddiff --git a/public/js/app.js b/public/js/app.js
iindex 223040d3..eb4d9369 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -4153,6 +4153,7 @@ function getLastStream(username) {
/**
* Auto-scroll the active tab into view within the tab bar.
*/
++let _lastScrolledTab = '';
function scrollActiveTabIntoView() {
const active = document.querySelector('.live-tab.active');
if (active) active.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
@@@ -4226,8 +4227,11 @@ function loadLiveStreamTabs(currentUsername, activeStreamId, channelStreams = []
<i class="fa-solid fa-eye"></i> <strong>${totalViewers}</strong> total
</span>`;
-- // Auto-scroll active tab into view after render
-- requestAnimationFrame(scrollActiveTabIntoView);
++ // Auto-scroll the active tab into view, but only when it actually changed. This render runs
++ // on the 15-second channel poll (every 2 seconds during a go-live burst), and smooth-scrolling
++ // the strip on every tick moved it under the reader's finger for no reason.
++ const activeNow = document.querySelector('.live-tab.active')?.dataset.username || '';
++ if (activeNow !== _lastScrolledTab) { _lastScrolledTab = activeNow; requestAnimationFrame(scrollActiveTabIntoView); }
// Setup keyboard navigation (arrow keys between tabs)
setupTabKeyboardNav(tabsScroll, currentUsername);
@@@ -5470,6 +5474,8 @@ function _startCaptions(username, displayName, isLive) {
const box = document.getElementById('ch-captions-lines');
if (!box) return;
box.querySelector('.ch-captions-wait')?.remove();
++ // Is the reader following the live edge right now? Decide before we touch the DOM.
++ const wasPinned = box.scrollHeight - box.scrollTop - box.clientHeight < 60;
for (const l of lines) {
_captionsAfter = Math.max(_captionsAfter, Number(l.id) || 0);
const row = document.createElement('div');
@@@ -5479,8 +5485,17 @@ function _startCaptions(username, displayName, isLive) {
: `<div class="ch-caption-en">${esc(l.text)}</div>`;
box.appendChild(row);
}
-- while (box.children.length > 14) box.removeChild(box.firstChild);
-- box.scrollTop = box.scrollHeight;
++ // Measure BEFORE trimming and before deciding to follow: scrollHeight already includes
++ // whatever we just appended, and trimming from the top moves the content up under the
++ // reader. Someone who scrolled back to re-read a caption should stay where they put
++ // themselves instead of being yanked to the bottom every six seconds.
++ const follow = wasPinned;
++ while (box.children.length > 14) {
++ const gone = box.firstChild.getBoundingClientRect().height;
++ box.removeChild(box.firstChild);
++ if (!follow) box.scrollTop = Math.max(0, box.scrollTop - gone);
++ }
++ if (follow) box.scrollTop = box.scrollHeight;
};
void tick();
_captionsTimer = setInterval(tick, 6000);
ddiff --git a/public/js/home-fx.js b/public/js/home-fx.js
iindex 662574eb..2eb8c3d0 100644
---- a/public/js/home-fx.js
++++ b/public/js/home-fx.js
@@@ -88,7 +88,7 @@
}
// ── Scroll reveal ──────────────────────────────────────────
-- let io = null, revealSafety = 0, revealPasses = 0;
++ let io = null, revealSafety = 0, revealPasses = 0, firstRevealPass = true;
/**
* Failsafe for the reveal animation.
*
@@@ -119,7 +119,21 @@
if (!('IntersectionObserver' in window)) return;
if (!io) io = new IntersectionObserver((entries) => { for (const en of entries) if (en.isIntersecting) { en.target.classList.add('is-in'); io.unobserve(en.target); } }, { rootMargin: '0px 0px -8% 0px', threshold: 0.08 });
const sel = '#page-home .section-header, #page-home .stream-card, #page-home .home-cta-banner, #page-home .home-star-section, #page-home .rs-hero-mount, #page-home .pulse-grid > *, #page-home .moments-row > *, #page-home .home-digest';
-- document.querySelectorAll(sel).forEach((el, i) => { if (el.classList.contains('hfx-reveal')) return; el.classList.add('hfx-reveal'); el.style.setProperty('--d', `${(i % 6) * 60}ms`); io.observe(el); });
++ document.querySelectorAll(sel).forEach((el, i) => {
++ if (el.classList.contains('hfx-reveal')) return;
++ // Anything already on screen after the first pass is NOT new to the reader — it is a
++ // card the 60-second section refresh just rebuilt underneath them. Fading it out and
++ // back in made the whole home page flash once a minute, collapsing expanded AI
++ // overviews and destroying text selection on the way. Show it and move on.
++ if (!firstRevealPass) {
++ const r = el.getBoundingClientRect();
++ if (r.height && r.top < window.innerHeight && r.bottom > 0) { el.classList.add('hfx-reveal', 'is-in'); return; }
++ }
++ el.classList.add('hfx-reveal');
++ el.style.setProperty('--d', `${(i % 6) * 60}ms`);
++ io.observe(el);
++ });
++ firstRevealPass = false;
startRevealSafety();
}
@@@ -204,7 +218,11 @@
});
watch();
// Sections mount asynchronously, so pick up anything that arrives later.
-- try { new MutationObserver(watch).observe(document.body, { childList: true, subtree: true }); } catch { /* */ }
++ try {
++ let pending = 0;
++ const queue = () => { if (pending) return; pending = requestAnimationFrame(() => { pending = 0; watch(); }); };
++ new MutationObserver(queue).observe(document.body, { childList: true, subtree: true });
++ } catch { /* */ }
}
function boot() {
@@@ -216,7 +234,14 @@
if (page && 'MutationObserver' in window) new MutationObserver(apply).observe(page, { attributes: true, attributeFilter: ['class'] });
// New cards arrive as sections load — reveal them too.
const container = page && page.querySelector('.container');
-- if (container) new MutationObserver(() => { if (onHome()) attachReveal(); }).observe(container, { childList: true, subtree: true });
++ if (container) {
++ // Coalesce to one pass per frame. Unthrottled, this ran an eight-selector
++ // querySelectorAll over the whole document on every single DOM mutation — every chat
++ // message, every grid tick, every activity row.
++ let pending = 0;
++ const queue = () => { if (pending) return; pending = requestAnimationFrame(() => { pending = 0; if (onHome()) attachReveal(); }); };
++ new MutationObserver(queue).observe(container, { childList: true, subtree: true });
++ }
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot();
})();
ddiff --git a/public/js/messenger.js b/public/js/messenger.js
iindex ccbaabb0..fea11b1b 100644
---- a/public/js/messenger.js
++++ b/public/js/messenger.js
@@@ -431,10 +431,12 @@
</div>
</div>`;
}
++ // Was the reader at the live edge before we replaced the thread? Measured first, because
++ // after the write scrollHeight already includes the new messages. Someone reading back
++ // through a conversation should not be thrown to the bottom every three seconds.
++ const wasPinned = thread.scrollHeight - thread.scrollTop - thread.clientHeight < 80 || thread.scrollTop === 0;
thread.innerHTML = html;
--
-- // Scroll to bottom
-- thread.scrollTop = thread.scrollHeight;
++ if (wasPinned) thread.scrollTop = thread.scrollHeight;
// Wire up message delete buttons
thread.querySelectorAll('.msg-bubble-delete').forEach(btn => {
ddiff --git a/public/js/opencoins.js b/public/js/opencoins.js
iindex 2c5361dc..04b27bfb 100644
---- a/public/js/opencoins.js
++++ b/public/js/opencoins.js
@@@ -237,8 +237,11 @@ function renderRedemption(msg, container) {
const el = document.createElement('div');
el.className = 'chat-msg redemption';
el.innerHTML = `<i class="fa-solid fa-gem" style="color:${esc(msg.reward_color || 'var(--accent)')}"></i> <strong>${esc(msg.username || 'Someone')}</strong> redeemed <strong>${esc(msg.reward_title || 'a reward')}</strong>${msg.user_input ? `: ${esc(msg.user_input)}` : ''} <span class="muted">(${(msg.cost || 0).toLocaleString()} ${esc(channelPointsName())})</span>`;
++ // Follow the live edge only if the reader was already there — measured before the append,
++ // since scrollHeight counts the new row the moment it lands.
++ const wasPinned = container.scrollHeight - container.scrollTop - container.clientHeight < 100;
container.appendChild(el);
-- container.scrollTop = container.scrollHeight;
++ if (wasPinned) container.scrollTop = container.scrollHeight;
}
// ── Add Reward Modal (Dashboard) ─────────────────────────────
ddiff --git a/public/js/ov-mark.js b/public/js/ov-mark.js
iindex 2c018cf5..d3bda4f5 100644
---- a/public/js/ov-mark.js
++++ b/public/js/ov-mark.js
@@@ -52,7 +52,13 @@
function init() {
if (!document.getElementById('ov-mark-css')) { const st = document.createElement('style'); st.id = 'ov-mark-css'; st.textContent = CSS; document.head.appendChild(st); }
mount();
-- try { new MutationObserver(() => mount()).observe(document.body, { childList: true, subtree: true }); } catch { /* */ }
++ try {
++ // One pass per frame, not one per mutation. Unthrottled this ran a full-document
++ // querySelectorAll on every chat message and every feed tick.
++ let pending = 0;
++ const queue = () => { if (pending) return; pending = requestAnimationFrame(() => { pending = 0; mount(); }); };
++ new MutationObserver(queue).observe(document.body, { childList: true, subtree: true });
++ } catch { /* */ }
}
window.ovMarkMount = mount;
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();