openvibers/openvibe.live Commits
Home: live content first, compact stat strip, shorter feeds
Changes
@@@ -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; }++}@@@ -88,7 +88,7 @@overlay) and is fetched without blocking the first paint: media="print" makes the browsertreat 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 — 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="fa-solid fa-heart"></i> We love what people build here — come make something"><span class="hb-ico"><i class="fa-solid fa-wand-magic-sparkles"></i></span><span class="hb-label">Build & 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">•</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></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>@@@ -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'.@@@ -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 ─────────────────────────────
@@ -14792,3 +14792,39 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
@@ -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 (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; } }
@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); }
.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; }
+}
@@ -88,7 +88,7 @@
@@ -88,7 +88,7 @@
overlay) and is fetched without blocking the first paint: media="print" makes the browser
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. -->
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="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-fx.css?v=10">
<link rel="stylesheet" href="/css/home-tour.css?v=4">
<link rel="stylesheet" href="/css/home-tour.css?v=4">
<link rel="stylesheet" href="/css/i18n-star.css?v=5">
<link rel="stylesheet" href="/css/i18n-star.css?v=5">
@@ -283,37 +283,6 @@
@@ -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-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>
<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-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>
<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>
<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>
<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 @@
@@ -328,30 +297,11 @@
data-ovtip="Chat, streams, clips, overlays, robots, sound commands, whole games — 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="Chat, streams, clips, overlays, robots, sound commands, whole games — 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="fa-solid fa-heart"></i> We love what people build here — come make something"><span class="hb-ico"><i class="fa-solid fa-wand-magic-sparkles"></i></span><span class="hb-label">Build & Mod <small>API docs</small></span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
data-ovtip-foot="<i class="fa-solid fa-heart"></i> We love what people build here — come make something"><span class="hb-ico"><i class="fa-solid fa-wand-magic-sparkles"></i></span><span class="hb-label">Build & Mod <small>API docs</small></span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
</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>
- <!-- 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>
</div>
<div class="container">
<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 -->
<!-- Live Streams -->
<div class="section-header">
<div class="section-header">
<h2><i class="fa-solid fa-circle live-dot"></i> Live Now</h2>
<h2><i class="fa-solid fa-circle live-dot"></i> Live Now</h2>
@@ -369,6 +319,45 @@
@@ -369,6 +319,45 @@
</div>
</div>
</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 -->
<!-- Community pulse: goals near completion, latest activity, weekly leaders -->
<div class="home-section-lazy" id="home-pulse-section" style="display:none">
<div class="home-section-lazy" id="home-pulse-section" style="display:none">
<div class="section-header">
<div class="section-header">
@@ -741,6 +730,21 @@
@@ -741,6 +730,21 @@
<div class="home-changelog-fade" id="home-changelog-fade"></div>
<div class="home-changelog-fade" id="home-changelog-fade"></div>
</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">•</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>
</div>
</section>
</section>
@@ -5124,7 +5128,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -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="/js/theme-engine.js?v=4"></script>
<script src="/live-notify.js?v=3" defer></script>
<script src="/live-notify.js?v=3" defer></script>
<script src="/js/ov-skeleton.js?v=1" 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-player.js?v=21" defer></script>
<script src="/js/stream-pip.js?v=1" defer></script>
<script src="/js/stream-pip.js?v=1" defer></script>
<script src="/js/chat-shared.js?v=3" 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
@@ -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/tooltip.js?v=1" defer></script>
<script src="/shared/footer.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/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/home-tour.js?v=6" defer></script>
<script src="/js/ov-guide.js?v=1" defer></script>
<script src="/js/ov-guide.js?v=1" defer></script>
<script src="/js/guides-live.js?v=4" defer></script>
<script src="/js/guides-live.js?v=4" defer></script>
@@ -1349,12 +1349,53 @@ function renderHeroStats(stats) {
@@ -1349,12 +1349,53 @@ function renderHeroStats(stats) {
const clickable = !!r.metric;
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>`;
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);
_heroBindInteractions(wrap);
}
}
@@ -2557,10 +2598,19 @@ let _homeRecentOnlinePage = 1;
@@ -2557,10 +2598,19 @@ let _homeRecentOnlinePage = 1;
let _homeRecentVodsPage = 1;
let _homeRecentVodsPage = 1;
let _homeClipsPage = 1;
let _homeClipsPage = 1;
let _homePastesPage = 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
// Small Newest/Oldest segmented control (shared markup). `setter` is a global fn name
// taking 'newest'|'oldest'.
// taking 'newest'|'oldest'.
@@ -88,22 +88,39 @@
@@ -88,22 +88,39 @@
}
}
// ── Scroll reveal ──────────────────────────────────────────
// ── 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() {
function attachReveal() {
if (!('IntersectionObserver' in window)) return;
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 });
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';
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; 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 ─────────────────────────────
// ── Count-up on the hero stats ─────────────────────────────