openvibers/openvibe.live Commits

Home: live content first, compact stat strip, shorter feeds

OP OpenVibers committed 16 Sept 2026 · 5608916aa2 · 4 files, +182 −75

Reordered the page around what people come for. The hero is now the title, the six buttons and
one strip of headline numbers; Live Now and the Lately on OpenVibe digest follow immediately.
The tour card, the daily secret, the newest-commit line and the philosophy links all moved below
the content they used to sit in front of.

The hero stat board was four groups and 24 chips. It pushed every live stream on the site below
the fold, and since it only rendered once the stats request landed it was 72% of the page's
entire layout shift — measured, not guessed: one 0.57 shift at 7.5 seconds, out of 0.79 total.
It is now five headline numbers in a fixed-height strip with the full board one tap away, and
the strip's height is reserved in CSS so filling it in moves nothing underneath. The choice is
remembered, and the full board's numbers only count up once someone actually opens it.

The four list sections opened with 46 cards between them, which is most of why the page felt
endless — and every visitor downloaded four pages of thumbnails to scroll past. One row each
now, sized to the viewport: four on a phone, six on a desktop. The pagination underneath still
reaches the rest, and the dedicated VOD, clip and paste pages are where browsing belongs. The
crawler snapshot lists thirty of each regardless, so none of this costs discoverability.

Also fixed a real bug in the scroll-reveal failsafe added earlier today: it cleared and
rescheduled its timer inside a function that runs on every container mutation, and the home page
mutates constantly, so the timer was cancelled forever and never ran once. It is now a
standalone schedule that sweeps five times and cannot be starved.

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 211fffc8..a8bf1da1 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -14792,3 +14792,39 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
@media (prefers-reduced-motion: reduce) { .brand-mark svg, .ov-glow, .ov-comet, .ov-v, .ov-dot, .ov-spark { animation: none !important; } .ov-orbit, .ov-spark { display: none; } .ov-comet { stroke-dasharray: none; opacity: 0.9; } }
@media (max-width: 600px) { .brand-mark { width: 26px; height: 26px; } .brand-mark svg { width: 25px; height: 25px; } }
.ov-mark--ph { opacity: 0.45; color: var(--text-muted); }
++
++/* ── Hero stat board: headline strip + expandable full board ──────────────────
++ The full board is four groups and ~28 chips. Shown by default it pushed every live stream on
++ the site below the fold and, because it only renders once the stats request lands, it was the
++ single biggest source of layout shift on the page. Now a fixed-height strip carries the five
++ numbers worth glancing at, the rest is one tap away, and the reserved height means filling the
++ strip in moves nothing below it. */
++.hero-stats { min-height: 104px; }
++.hero-stat-strip {
++ display: grid; grid-template-columns: repeat(auto-fit, minmax(128px, 1fr)); gap: 8px;
++ padding: 10px 12px;
++}
++.hero-stat-strip .hero-stat { background: color-mix(in srgb, var(--accent) 7%, transparent); }
++.hero-stat-more {
++ display: flex; align-items: center; justify-content: center; gap: 8px; width: 100%;
++ padding: 8px 12px 9px; cursor: pointer; font: inherit;
++ font-size: 0.74rem; font-weight: 700; letter-spacing: 0.02em;
++ color: color-mix(in srgb, var(--accent) 72%, var(--text-muted));
++ background: none; border: 0; border-top: 1px solid color-mix(in srgb, var(--accent) 12%, transparent);
++ transition: color 0.2s, background 0.2s;
++}
++.hero-stat-more:hover { color: var(--accent); background: color-mix(in srgb, var(--accent) 7%, transparent); }
++.hero-stat-more i { font-size: 0.7rem; transition: transform 0.28s cubic-bezier(.2, 1, .3, 1); }
++.hero-stat-more.is-open i { transform: rotate(180deg); }
++.hero-stat-full { border-top: 1px solid color-mix(in srgb, var(--accent) 12%, transparent); }
++.hero-stat-full:not([hidden]) { animation: heroStatsOpen 0.4s cubic-bezier(.2, 1, .3, 1); }
++@keyframes heroStatsOpen { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: none; } }
++.hero-stat-full .hero-stat-group:first-child { border-top: 0; }
++@media (max-width: 640px) {
++ .hero-stats { min-height: 168px; }
++ .hero-stat-strip { grid-template-columns: repeat(2, 1fr); gap: 6px; padding: 8px; }
++}
++@media (prefers-reduced-motion: reduce) {
++ .hero-stat-full:not([hidden]) { animation: none; }
++ .hero-stat-more i { transition: none; }
++}
ddiff --git a/public/index.html b/public/index.html
iindex 67c63267..904cdeae 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=166">
++ <link rel="stylesheet" href="/css/style.css?v=167">
<link rel="stylesheet" href="/css/home-fx.css?v=10">
<link rel="stylesheet" href="/css/home-tour.css?v=4">
<link rel="stylesheet" href="/css/i18n-star.css?v=5">
@@@ -283,37 +283,6 @@
<p class="hero-tagline">Live streaming for <span class="hero-rotate-wrap"><span class="hero-rotate" id="hero-rotate"></span></span></p>
<p class="hero-quip" id="hero-quip" aria-live="polite"></p>
<div class="hero-slogan-timer" id="hero-slogan-timer" hidden></div>
-- <div class="hero-stats" id="hero-stats" aria-label="Site stats"><div class="ovsk ovsk-panel" aria-hidden="true"><div class="ovsk-bar" style="height:14px;width:45%"></div><div class="ovsk-bar" style="height:10px;width:92%"></div><div class="ovsk-bar" style="height:10px;width:68%"></div></div></div>
-- <div class="hero-egg" id="hero-egg" style="display:none">
-- <button class="hero-egg-head" type="button" onclick="toggleHeroEgg()">
-- <span class="hero-egg-icon">🥚</span>
-- <span class="hero-egg-titles"><span class="hero-egg-kicker">Today's Secret</span><span class="hero-egg-title" id="hero-egg-title"></span></span>
-- <span class="hero-egg-status" id="hero-egg-status"></span>
-- <i class="fa-solid fa-chevron-down hero-egg-chev"></i>
-- </button>
-- <div class="hero-egg-body">
-- <div class="hero-egg-inner">
-- <p class="hero-egg-intro">A new secret every day. Each clue is one key, in order. Type the keys anywhere on the site, or tap them below.</p>
-- <div class="hero-egg-slots" id="hero-egg-slots" aria-label="Your key sequence"></div>
-- <ol class="hero-egg-clues" id="hero-egg-clues"></ol>
-- <div class="hero-egg-pad" id="hero-egg-pad">
-- <div class="hero-egg-pad-arrows">
-- <button type="button" class="egg-key" data-tok="left" aria-label="Left">←</button>
-- <button type="button" class="egg-key" data-tok="up" aria-label="Up">↑</button>
-- <button type="button" class="egg-key" data-tok="down" aria-label="Down">↓</button>
-- <button type="button" class="egg-key" data-tok="right" aria-label="Right">→</button>
-- <button type="button" class="egg-key egg-key--ctl" data-act="back" aria-label="Backspace">⌫</button>
-- <button type="button" class="egg-key egg-key--ctl" data-act="clear" aria-label="Clear">✕</button>
-- </div>
-- <div class="hero-egg-pad-letters" id="hero-egg-letters"></div>
-- </div>
-- <div class="hero-egg-foot">
-- <button type="button" class="hero-egg-reveal" id="hero-egg-reveal" onclick="_eggReveal()"><i class="fa-solid fa-lightbulb"></i> Stuck? Reveal a key</button>
-- <div class="hero-egg-meta"><span id="hero-egg-count"></span><span id="hero-egg-first"></span><span id="hero-egg-reset"></span></div>
-- </div>
-- </div>
-- </div>
-- </div>
<div class="hero-cta" data-fx-viewport>
<button style="--i:0" class="hb hb-stream" onclick="currentUser ? navigate('/broadcast') : showModal('register')"><span class="hb-ico"><i class="fa-solid fa-broadcast-tower"></i></span><span class="hb-label">Stream</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></button>
<a href="https://openvibe.network" style="--i:1" class="hb hb-tools"><span class="hb-ico"><i class="fa-solid fa-screwdriver-wrench"></i></span><span class="hb-label">Tools</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
@@@ -328,30 +297,11 @@
data-ovtip="Chat, streams, clips, overlays, robots, sound commands, whole games &mdash; every feature here has an <b>open API</b>, and the entire site is <b>open source</b>. <em>You do not need to know how to code.</em> Point an AI at these docs, describe the thing you want, and it writes it for you."
data-ovtip-foot="<i class=&quot;fa-solid fa-heart&quot;></i> We love what people build here &mdash; come make something"><span class="hb-ico"><i class="fa-solid fa-wand-magic-sparkles"></i></span><span class="hb-label">Build &amp; Mod <small>API docs</small></span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
</div>
-- <!-- One stream, everywhere — restream pipeline + feature tour (public/js/home-tour.js) -->
-- <div class="home-tour-mount home-section-lazy" id="home-tour-mount" data-fx-viewport></div>
-- <!-- Newest shipped commit — built in the open, receipts included. -->
-- <a class="hero-latest" id="hero-latest" href="/updates" onclick="return handleLinkClick(event, '/updates')" style="display:none"></a>
-- <div class="hero-philosophy-links">
-- <a href="#home-cta-banner" class="hero-philosophy-btn" onclick="event.preventDefault();openHomeAbout();">
-- <i class="fa-solid fa-book-open"></i> What Is This? (Philosophy)
-- </a>
-- <a href="/tos" class="hero-philosophy-link" onclick="return handleLinkClick(event, '/tos')">Terms</a>
-- <span aria-hidden="true">•</span>
-- <a href="/privacy" class="hero-philosophy-link" onclick="return handleLinkClick(event, '/privacy')">Privacy</a>
-- <span aria-hidden="true">•</span>
-- <a href="/dmca" class="hero-philosophy-link" onclick="return handleLinkClick(event, '/dmca')">DMCA</a>
-- </div>
++ <div class="hero-stats" id="hero-stats" aria-label="Site stats"><div class="ovsk ovsk-panel" aria-hidden="true"><div class="ovsk-bar" style="height:14px;width:45%"></div><div class="ovsk-bar" style="height:10px;width:92%"></div><div class="ovsk-bar" style="height:10px;width:68%"></div></div></div>
</div>
</div>
<div class="container">
-- <!-- "While you were away" (signed-in, returning) / "Lately on OpenVibe" (everyone) — site activity digest -->
-- <div class="home-digest" id="home-digest"><div class="ovsk ovsk-panel" aria-hidden="true"><div class="ovsk-bar" style="height:14px;width:45%"></div><div class="ovsk-bar" style="height:10px;width:92%"></div><div class="ovsk-bar" style="height:10px;width:68%"></div></div></div>
--
-- <!-- Star of OpenVibe — featured streamer spotlight (star_streamer setting / STAR_STREAMER env) -->
-- <div class="home-star-section" id="home-star-section"><div class="ovsk ovsk-panel" aria-hidden="true"><div class="ovsk-bar" style="height:14px;width:45%"></div><div class="ovsk-bar" style="height:10px;width:92%"></div><div class="ovsk-bar" style="height:10px;width:68%"></div><div class="ovsk-bar" style="height:10px;width:80%"></div></div></div>
--
<!-- Live Streams -->
<div class="section-header">
<h2><i class="fa-solid fa-circle live-dot"></i> Live Now</h2>
@@@ -369,6 +319,45 @@
</div>
</div>
++ <!-- "While you were away" (signed-in, returning) / "Lately on OpenVibe" (everyone) — site activity digest -->
++ <div class="home-digest" id="home-digest"><div class="ovsk ovsk-panel" aria-hidden="true"><div class="ovsk-bar" style="height:14px;width:45%"></div><div class="ovsk-bar" style="height:10px;width:92%"></div><div class="ovsk-bar" style="height:10px;width:68%"></div></div></div>
++
++ <!-- Star of OpenVibe — featured streamer spotlight (star_streamer setting / STAR_STREAMER env) -->
++ <div class="home-star-section" id="home-star-section"><div class="ovsk ovsk-panel" aria-hidden="true"><div class="ovsk-bar" style="height:14px;width:45%"></div><div class="ovsk-bar" style="height:10px;width:92%"></div><div class="ovsk-bar" style="height:10px;width:68%"></div><div class="ovsk-bar" style="height:10px;width:80%"></div></div></div>
++
++ <!-- One stream, everywhere — restream pipeline + feature tour (public/js/home-tour.js) -->
++ <div class="home-tour-mount home-section-lazy" id="home-tour-mount" data-fx-viewport></div>
++ <div class="hero-egg" id="hero-egg" style="display:none">
++ <button class="hero-egg-head" type="button" onclick="toggleHeroEgg()">
++ <span class="hero-egg-icon">🥚</span>
++ <span class="hero-egg-titles"><span class="hero-egg-kicker">Today's Secret</span><span class="hero-egg-title" id="hero-egg-title"></span></span>
++ <span class="hero-egg-status" id="hero-egg-status"></span>
++ <i class="fa-solid fa-chevron-down hero-egg-chev"></i>
++ </button>
++ <div class="hero-egg-body">
++ <div class="hero-egg-inner">
++ <p class="hero-egg-intro">A new secret every day. Each clue is one key, in order. Type the keys anywhere on the site, or tap them below.</p>
++ <div class="hero-egg-slots" id="hero-egg-slots" aria-label="Your key sequence"></div>
++ <ol class="hero-egg-clues" id="hero-egg-clues"></ol>
++ <div class="hero-egg-pad" id="hero-egg-pad">
++ <div class="hero-egg-pad-arrows">
++ <button type="button" class="egg-key" data-tok="left" aria-label="Left">←</button>
++ <button type="button" class="egg-key" data-tok="up" aria-label="Up">↑</button>
++ <button type="button" class="egg-key" data-tok="down" aria-label="Down">↓</button>
++ <button type="button" class="egg-key" data-tok="right" aria-label="Right">→</button>
++ <button type="button" class="egg-key egg-key--ctl" data-act="back" aria-label="Backspace">⌫</button>
++ <button type="button" class="egg-key egg-key--ctl" data-act="clear" aria-label="Clear">✕</button>
++ </div>
++ <div class="hero-egg-pad-letters" id="hero-egg-letters"></div>
++ </div>
++ <div class="hero-egg-foot">
++ <button type="button" class="hero-egg-reveal" id="hero-egg-reveal" onclick="_eggReveal()"><i class="fa-solid fa-lightbulb"></i> Stuck? Reveal a key</button>
++ <div class="hero-egg-meta"><span id="hero-egg-count"></span><span id="hero-egg-first"></span><span id="hero-egg-reset"></span></div>
++ </div>
++ </div>
++ </div>
++ </div>
++
<!-- Community pulse: goals near completion, latest activity, weekly leaders -->
<div class="home-section-lazy" id="home-pulse-section" style="display:none">
<div class="section-header">
@@@ -741,6 +730,21 @@
<div class="home-changelog-fade" id="home-changelog-fade"></div>
</div>
++ <!-- The quiet bits. These used to sit in the hero, above every stream on the site;
++ they belong under the content, not in front of it. -->
++ <!-- Newest shipped commit — built in the open, receipts included. -->
++ <a class="hero-latest" id="hero-latest" href="/updates" onclick="return handleLinkClick(event, '/updates')" style="display:none"></a>
++ <div class="hero-philosophy-links">
++ <a href="#home-cta-banner" class="hero-philosophy-btn" onclick="event.preventDefault();openHomeAbout();">
++ <i class="fa-solid fa-book-open"></i> What Is This? (Philosophy)
++ </a>
++ <a href="/tos" class="hero-philosophy-link" onclick="return handleLinkClick(event, '/tos')">Terms</a>
++ <span aria-hidden="true">&bull;</span>
++ <a href="/privacy" class="hero-philosophy-link" onclick="return handleLinkClick(event, '/privacy')">Privacy</a>
++ <span aria-hidden="true">&bull;</span>
++ <a href="/dmca" class="hero-philosophy-link" onclick="return handleLinkClick(event, '/dmca')">DMCA</a>
++ </div>
++
</div>
</section>
@@@ -5124,7 +5128,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/js/theme-engine.js?v=4"></script>
<script src="/live-notify.js?v=3" defer></script>
<script src="/js/ov-skeleton.js?v=1" defer></script>
--<script src="/js/app.js?v=144" defer></script>
++<script src="/js/app.js?v=145" 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>
@@@ -5207,7 +5211,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<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=6" defer></script>
++<script src="/js/home-fx.js?v=7" 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=4" defer></script>
ddiff --git a/public/js/app.js b/public/js/app.js
iindex e8619dac..6be87963 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -1349,12 +1349,53 @@ function renderHeroStats(stats) {
const clickable = !!r.metric;
return `<div class="hero-stat ${r.cls || ''} ${clickable ? 'hero-stat--clickable' : ''}" data-tip="${esc(JSON.stringify(tip))}" ${clickable ? `data-metric="${r.metric}" role="button" tabindex="0" aria-label="${esc(r.label)} — show over time"` : ''}><i class="fa-solid ${r.icon}"></i><div class="hero-stat-meta"><span class="hero-stat-num" data-n="${r.num || 0}">0</span><span class="hero-stat-label">${r.label}${clickable ? ' <i class="fa-solid fa-chart-line hero-stat-chart-hint"></i>' : ''}</span>${sub ? `<span class="hero-stat-sub">${sub}</span>` : ''}</div></div>`;
};
-- wrap.innerHTML = groups.filter(g => g.rows.length).map(g => `
-- <div class="hero-stat-group">
-- <span class="hero-stat-kicker"><i class="fa-solid ${g.icon}"></i>${g.kicker}</span>
-- <div class="hero-stat-row">${g.rows.map(chip).join('')}</div>
-- </div>`).join('');
-- wrap.querySelectorAll('.hero-stat-num').forEach(el => _heroCountUp(el, parseInt(el.dataset.n, 10) || 0));
++ // The full board is four groups and two dozen chips. Shown by default it pushed every live
++ // stream on the site below the fold, and because it only arrives once the stats request lands
++ // it was also the single biggest source of layout shift on the page — the content underneath
++ // jumped down by the height of the whole board, several seconds in.
++ //
++ // So: a fixed-height headline strip by default, the full board one tap away. The strip's
++ // height is reserved in CSS, so filling it in moves nothing.
++ const HEADLINE = [
++ { icon: stats.liveNow > 0 ? 'fa-circle' : 'fa-circle-dot', cls: stats.liveNow > 0 ? 'hero-stat--live' : '', num: stats.liveNow, label: 'Live', title: 'Streams live right now' },
++ { icon: 'fa-eye', cls: stats.viewersNow > 0 ? 'hero-stat--live' : '', num: stats.viewersNow, label: 'Watching', title: 'Viewers watching right now' },
++ { icon: 'fa-fire', num: stats.weeklyActive, label: 'Active · 7d', title: 'People who chatted in the last 7 days' },
++ { icon: 'fa-clock-rotate-left', num: stats.hoursWatched, label: 'Hours watched', title: 'Community watch time, all time' },
++ ...(stats.coinsEarned != null ? [{ icon: 'fa-circle-dollar-to-slot', num: stats.coinsEarned, label: 'Coins earned', title: 'OpenCoins earned across the whole network' }] : []),
++ ];
++ const open = (() => { try { return localStorage.getItem('ov_stats_open') === '1'; } catch { return false; } })();
++ wrap.innerHTML = `
++ <div class="hero-stat-strip">${HEADLINE.map(chip).join('')}</div>
++ <button type="button" class="hero-stat-more" id="hero-stat-more" aria-expanded="${open}" aria-controls="hero-stat-full">
++ <i class="fa-solid fa-chevron-down"></i><span></span>
++ </button>
++ <div class="hero-stat-full" id="hero-stat-full" ${open ? '' : 'hidden'}>
++ ${groups.filter(g => g.rows.length).map(g => `
++ <div class="hero-stat-group">
++ <span class="hero-stat-kicker"><i class="fa-solid ${g.icon}"></i>${g.kicker}</span>
++ <div class="hero-stat-row">${g.rows.map(chip).join('')}</div>
++ </div>`).join('')}
++ </div>`;
++
++ const btn = wrap.querySelector('#hero-stat-more');
++ const full = wrap.querySelector('#hero-stat-full');
++ const total = groups.reduce((n, g) => n + g.rows.length, 0);
++ const label = () => { btn.querySelector('span').textContent = full.hidden ? `All ${total} numbers` : 'Hide the numbers'; };
++ label();
++ btn.classList.toggle('is-open', !full.hidden);
++ btn.addEventListener('click', () => {
++ full.hidden = !full.hidden;
++ btn.setAttribute('aria-expanded', String(!full.hidden));
++ btn.classList.toggle('is-open', !full.hidden);
++ label();
++ try { localStorage.setItem('ov_stats_open', full.hidden ? '0' : '1'); } catch { /* */ }
++ // Count the numbers up the first time they are actually looked at.
++ if (!full.hidden) full.querySelectorAll('.hero-stat-num:not([data-counted])').forEach(el => {
++ el.setAttribute('data-counted', '1'); _heroCountUp(el, parseInt(el.dataset.n, 10) || 0);
++ });
++ });
++ wrap.querySelectorAll('.hero-stat-strip .hero-stat-num').forEach(el => { el.setAttribute('data-counted', '1'); _heroCountUp(el, parseInt(el.dataset.n, 10) || 0); });
++ if (open) full.querySelectorAll('.hero-stat-num').forEach(el => { el.setAttribute('data-counted', '1'); _heroCountUp(el, parseInt(el.dataset.n, 10) || 0); });
_heroBindInteractions(wrap);
}
@@@ -2557,10 +2598,19 @@ let _homeRecentOnlinePage = 1;
let _homeRecentVodsPage = 1;
let _homeClipsPage = 1;
let _homePastesPage = 1;
--const HOME_RECENT_ONLINE_PAGE_SIZE = 12;
--const HOME_RECENT_VODS_PAGE_SIZE = 12;
--const HOME_CLIPS_PAGE_SIZE = 12;
--const HOME_PASTES_PAGE_SIZE = 10;
++// The home page used to open with 46 cards across four list sections, which made it enormous and
++// meant every visitor downloaded four full pages of thumbnails to scroll past them. One row's
++// worth each is plenty for a front page; the pagination underneath still reaches the rest, and
++// the dedicated /vods, /clips and /pastes pages are where you go to actually browse.
++//
++// Wide screens fit more per row, so the counts follow the viewport rather than being fixed — six
++// cards is one tidy row at 1400px and two rows on a laptop, but four is the right number on a
++// phone where each card is full width.
++const _narrow = typeof window !== 'undefined' && window.innerWidth < 760;
++const HOME_RECENT_ONLINE_PAGE_SIZE = _narrow ? 4 : 6;
++const HOME_RECENT_VODS_PAGE_SIZE = _narrow ? 4 : 6;
++const HOME_CLIPS_PAGE_SIZE = _narrow ? 4 : 6;
++const HOME_PASTES_PAGE_SIZE = _narrow ? 4 : 5;
// Small Newest/Oldest segmented control (shared markup). `setter` is a global fn name
// taking 'newest'|'oldest'.
ddiff --git a/public/js/home-fx.js b/public/js/home-fx.js
iindex ad6fc0d4..c9ff69de 100644
---- a/public/js/home-fx.js
++++ b/public/js/home-fx.js
@@@ -88,22 +88,39 @@
}
// ── Scroll reveal ──────────────────────────────────────────
-- let io = null, revealSafety = 0;
++ let io = null, revealSafety = 0, revealPasses = 0;
++ /**
++ * Failsafe for the reveal animation.
++ *
++ * A revealed element starts at opacity 0, so anything the observer never reports on stays
++ * invisible — which is a broken page, not a missing animation. The first version of this
++ * cleared and rescheduled its timer inside attachReveal(), and attachReveal runs on every
++ * container mutation; the home page mutates constantly (the live grid alone refreshes every
++ * 12 seconds), so the timer was cancelled forever and never ran once. Hence a "Live Now"
++ * heading sitting at opacity 0 with nothing wrong with it.
++ *
++ * It is now a standalone schedule that cannot be starved, and it sweeps a few times to catch
++ * sections that mount late.
++ */
++ function startRevealSafety() {
++ if (revealSafety) return;
++ const sweep = () => {
++ document.querySelectorAll('.hfx-reveal:not(.is-in)').forEach(el => {
++ const r = el.getBoundingClientRect();
++ if (r.height && r.top < window.innerHeight && r.bottom > 0) el.classList.add('is-in');
++ });
++ revealPasses++;
++ revealSafety = revealPasses < 5 ? setTimeout(sweep, 2000) : 0;
++ };
++ revealSafety = setTimeout(sweep, 1200);
++ }
++
function attachReveal() {
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); });
-- // Failsafe. A revealed element starts at opacity 0, so anything the observer never reports
-- // on would stay invisible — a skipped content-visibility subtree or a restored page can do
-- // that. One late pass shows whatever is already on screen regardless.
-- clearTimeout(revealSafety);
-- revealSafety = setTimeout(() => {
-- document.querySelectorAll('.hfx-reveal:not(.is-in)').forEach(el => {
-- const r = el.getBoundingClientRect();
-- if (r.height && r.top < window.innerHeight && r.bottom > 0) el.classList.add('is-in');
-- });
-- }, 2500);
++ startRevealSafety();
}
// ── Count-up on the hero stats ─────────────────────────────