openvibers/openvibe.live Commits

Fix: hero stat board rendered nothing (temporal dead zone on baselineDelta)

OP OpenVibers committed 16 Sept 2026 · 2e0e202f5e · 4 files, +173 −13

`const baselineDelta` was declared below the array that used it, so building the stat rows threw
a ReferenceError and renderHeroStats produced an empty board. Shipped in the previous commit and
live. _baselineDeltaHTML is already a hoisted function declaration at module scope, so the alias
was pointless — every call site uses it directly now and nothing can reintroduce the ordering
problem.

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

Changes

ddiff --git a/public/css/i18n-star.css b/public/css/i18n-star.css
iindex b1d228df..0a56a4ca 100644
---- a/public/css/i18n-star.css
++++ b/public/css/i18n-star.css
@@@ -227,3 +227,64 @@
.star-why-body { font-size: 0.92rem; line-height: 1.45; color: var(--text-secondary); }
.star-why-meta { margin-top: 8px; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: #fbbf24; }
.star-why-meta i { margin-right: 4px; }
++
++/* ── Compact Star card ───────────────────────────────────────────────────────
++ The section opened as a full-width feature panel: big avatar, kicker, name, chips, a reason,
++ a bio in two languages, an AI overview, a last-live note and — when they were on — a whole
++ stream card. That is a lot of front page for one streamer. Collapsed it is now just who they
++ are and the button to go watch them; everything else is one tap away, and the card animates
++ between the two heights rather than jumping. */
++.star-card.is-compact { padding: 14px 16px 16px; gap: 14px; align-items: center; }
++.star-card.is-compact .star-avatar-wrap { width: 68px; height: 68px; }
++.star-card.is-compact .star-avatar { width: 68px; height: 68px; font-size: 1.5rem; }
++.star-card.is-compact .star-kicker { font-size: 0.6rem; margin-bottom: 2px; }
++.star-card.is-compact .star-name { font-size: clamp(1.05rem, 3.4vw, 1.35rem); margin: 0 0 6px; }
++.star-card.is-compact .star-chips { margin-bottom: 10px; }
++.star-card.is-compact .star-petals { display: none; }
++.star-card.is-compact .star-actions { margin-top: 0; }
++.star-card.is-compact .star-btn-watch { font-size: 0.86rem; padding: 8px 15px; }
++
++/* The disclosure. Fades up as the card grows — see animateHeightChange(). */
++.star-more { display: block; }
++.star-more.is-entering { animation: starMoreIn 0.42s cubic-bezier(.2, 1, .3, 1); }
++@keyframes starMoreIn { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: none; } }
++.star-card.is-swapping { overflow: hidden; transition: height 0.42s cubic-bezier(.2, 1, .3, 1); }
++
++.star-expand {
++ display: inline-flex; align-items: center; gap: 8px;
++ padding: 7px 14px 7px 8px; border-radius: 999px; cursor: pointer; font: inherit;
++ font-size: 0.78rem; font-weight: 750;
++ color: color-mix(in srgb, #fbbf24 80%, var(--text-primary));
++ background: color-mix(in srgb, #fbbf24 10%, transparent);
++ border: 1px solid color-mix(in srgb, #fbbf24 30%, transparent);
++ transition: transform 0.2s cubic-bezier(.2, 1, .3, 1), background 0.2s, border-color 0.2s, box-shadow 0.22s;
++}
++.star-expand:hover {
++ transform: translateY(-1px);
++ background: color-mix(in srgb, #fbbf24 18%, transparent);
++ border-color: color-mix(in srgb, #fbbf24 55%, transparent);
++ box-shadow: 0 6px 18px color-mix(in srgb, #fbbf24 22%, transparent);
++}
++.star-expand:active { transform: translateY(0) scale(0.985); }
++.star-expand:focus-visible { outline: 2px solid #fbbf24; outline-offset: 3px; }
++.star-expand i {
++ width: 20px; height: 20px; flex: none; border-radius: 50%; display: grid; place-items: center;
++ font-size: 0.58rem; color: #1a1506; line-height: 1;
++ background: linear-gradient(140deg, #fbbf24, #f59e0b);
++ transition: transform 0.3s cubic-bezier(.2, 1, .3, 1);
++}
++.star-expand.is-open i { transform: rotate(180deg); }
++.star-expand span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 44vw; }
++
++@media (max-width: 640px) {
++ .star-card.is-compact { padding: 12px 13px 14px; gap: 11px; grid-template-columns: auto 1fr; }
++ .star-card.is-compact .star-avatar-wrap, .star-card.is-compact .star-avatar { width: 54px; height: 54px; }
++ .star-card.is-compact .star-avatar { font-size: 1.2rem; }
++ .star-card.is-compact .star-actions { flex-wrap: wrap; gap: 7px; }
++ .star-expand { font-size: 0.72rem; padding: 6px 11px 6px 6px; }
++}
++@media (prefers-reduced-motion: reduce) {
++ .star-more.is-entering { animation: none; }
++ .star-card.is-swapping { transition: none; }
++ .star-expand, .star-expand i { transition: none; }
++}
ddiff --git a/public/css/style.css b/public/css/style.css
iindex f7f4cbf3..aca726f6 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -15279,3 +15279,9 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
.home-cta-features { grid-template-columns: 1fr; gap: 9px; }
.home-cta-feature { padding: 13px 14px; }
}
++
++/* Once the two explainer sections have been demoted to the bottom of the page they are reference
++ material rather than a pitch, so they get a quieter top margin and a rule separating them from
++ the content above. */
++.home-intro-demoted { margin-top: 26px; }
++.home-intro-demoted:first-of-type { border-top: 1px solid var(--border); padding-top: 26px; }
ddiff --git a/public/index.html b/public/index.html
iindex 2a3f17b4..fe09a01f 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -88,10 +88,10 @@
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=193">
++ <link rel="stylesheet" href="/css/style.css?v=194">
<link rel="stylesheet" href="/css/home-fx.css?v=13">
<link rel="stylesheet" href="/css/home-tour.css?v=7">
-- <link rel="stylesheet" href="/css/i18n-star.css?v=5">
++ <link rel="stylesheet" href="/css/i18n-star.css?v=6">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="/css/chat-shared.css?v=1" media="print" onload="this.media='all';this.onload=null">
<link rel="stylesheet" href="/css/broadcast.css?v=11" media="print" onload="this.media='all';this.onload=null">
@@@ -5131,7 +5131,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=162" defer></script>
++<script src="/js/app.js?v=164" defer></script>
<script src="/js/stream-player.js?v=22" defer></script>
<script src="/js/stream-pip.js?v=1" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
ddiff --git a/public/js/app.js b/public/js/app.js
iindex 3dafab26..e78861f5 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -1325,6 +1325,37 @@ function startHeroQuips(quips) {
}
// ── Hero stats bar (animated count-up) ──────────────────────────
++/**
++ * Run a DOM change and animate the container from its old height to its new one.
++ *
++ * Expanding or collapsing a panel by toggling `hidden` makes everything below it jump by however
++ * tall the panel is. Measuring before and after, pinning the old height and transitioning to the
++ * new one turns that into a movement the eye can follow. The height is pinned only for the length
++ * of the animation, so the panel can still grow freely afterwards.
++ *
++ * `entering` is the block being revealed; it fades up while the height changes, so the swap reads
++ * as one movement rather than a resize plus a pop.
++ */
++function animateHeightChange(wrap, mutate, opts = {}) {
++ const { duration = 420, entering = null } = opts;
++ const reduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
++ if (!wrap || reduced) { try { mutate(); } catch { /* */ } return; }
++ const h0 = wrap.offsetHeight;
++ try { mutate(); } catch { /* */ }
++ const h1 = wrap.offsetHeight;
++ if (h0 === h1) return;
++ clearTimeout(wrap._ovHeightTimer);
++ wrap.classList.add('is-swapping');
++ wrap.style.height = `${h0}px`;
++ if (entering) { entering.classList.remove('is-entering'); void entering.offsetWidth; entering.classList.add('is-entering'); }
++ requestAnimationFrame(() => { wrap.style.height = `${h1}px`; });
++ wrap._ovHeightTimer = setTimeout(() => {
++ wrap.style.height = '';
++ wrap.classList.remove('is-swapping');
++ if (entering) entering.classList.remove('is-entering');
++ }, duration + 40);
++}
++
function _baselineDeltaHTML(now, avg, peak, window = '24h') {
const n = Number(now) || 0;
const a = Number(avg);
@@@ -1369,8 +1400,8 @@ function renderHeroStats(stats) {
// ── Right now ────────────────────────────────────────────────
const now = [
-- { key: 'liveNow', deltaHTML: baselineDelta(stats.liveNow, CC.liveAvg24h, CC.livePeak24h), cls: stats.liveNow > 0 ? 'hero-stat--live' : '', icon: stats.liveNow > 0 ? 'fa-circle' : 'fa-circle-dot', num: stats.liveNow, label: 'Live', title: stats.liveNow > 0 ? 'Streams live right now' : 'Nobody is live right now — check Recently Online below' },
-- { key: 'viewersNow', deltaHTML: baselineDelta(stats.viewersNow, CC.viewersAvg24h, CC.viewersPeak24h), cls: stats.viewersNow > 0 ? 'hero-stat--live' : '', icon: 'fa-eye', num: stats.viewersNow, label: 'Watching', title: 'Viewers watching right now' },
++ { key: 'liveNow', deltaHTML: _baselineDeltaHTML(stats.liveNow, CC.liveAvg24h, CC.livePeak24h), cls: stats.liveNow > 0 ? 'hero-stat--live' : '', icon: stats.liveNow > 0 ? 'fa-circle' : 'fa-circle-dot', num: stats.liveNow, label: 'Live', title: stats.liveNow > 0 ? 'Streams live right now' : 'Nobody is live right now — check Recently Online below' },
++ { key: 'viewersNow', deltaHTML: _baselineDeltaHTML(stats.viewersNow, CC.viewersAvg24h, CC.viewersPeak24h), cls: stats.viewersNow > 0 ? 'hero-stat--live' : '', icon: 'fa-eye', num: stats.viewersNow, label: 'Watching', title: 'Viewers watching right now' },
{ key: 'weeklyActive', icon: 'fa-fire', num: stats.weeklyActive, label: 'Active', title: 'People who chatted in the last 7 days', desc: 'Distinct chatters in the last 7 days — signed-in users, anonymous chatters and relayed (Twitch/Kick/YouTube) chatters, each counted once.', metric: 'active' },
{ key: 'weeklyVisitors', icon: 'fa-user-plus', num: stats.weeklyVisitors, label: 'Visitors', title: 'First-time visitors in the last 7 days', desc: 'Browsers seen on the site for the first time in the last 7 days (a privacy-safe fingerprint, no account needed). A proxy for new people showing up, not just chatting.', metric: 'visitors' },
];
@@@ -1465,7 +1496,6 @@ function renderHeroStats(stats) {
* five-minute sampler has a week of history to compare against. Averages ignore samples where
* nothing was happening, so an empty night doesn't make every afternoon look like a record.
*/
-- const baselineDelta = (now, avg, peak, window = '24h') => _baselineDeltaHTML(now, avg, peak, window);
const recDelta = (rec, u = '') => {
if (!rec) return '';
const w = Number(rec.w), pw = Number(rec.pw);
@@@ -1502,8 +1532,8 @@ function renderHeroStats(stats) {
// toggle. Each is keyed so the poller below can roll it to a new value without a re-render.
const CC = stats.concurrency || {};
const HEADLINE = [
-- { key: 'liveNow', 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', deltaHTML: baselineDelta(stats.liveNow, CC.liveAvg24h, CC.livePeak24h) },
-- { key: 'viewersNow', icon: 'fa-eye', cls: stats.viewersNow > 0 ? 'hero-stat--live' : '', num: stats.viewersNow, label: 'Watching', title: 'Viewers watching right now', deltaHTML: baselineDelta(stats.viewersNow, CC.viewersAvg24h, CC.viewersPeak24h) },
++ { key: 'liveNow', 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', deltaHTML: _baselineDeltaHTML(stats.liveNow, CC.liveAvg24h, CC.livePeak24h) },
++ { key: 'viewersNow', icon: 'fa-eye', cls: stats.viewersNow > 0 ? 'hero-stat--live' : '', num: stats.viewersNow, label: 'Watching', title: 'Viewers watching right now', deltaHTML: _baselineDeltaHTML(stats.viewersNow, CC.viewersAvg24h, CC.viewersPeak24h) },
{ key: 'weeklyActive', icon: 'fa-fire', num: stats.weeklyActive, label: 'Active · 7d', title: 'People who chatted in the last 7 days', recent: { w: stats.weeklyActive, pw: stats.prevWeeklyActive } },
{ key: 'users', icon: 'fa-user-group', num: stats.users, label: 'Users', title: 'Accounts on OpenVibe.Live', recent: R.users },
{ key: 'weeklyVisitors', icon: 'fa-user-plus', num: stats.weeklyVisitors, label: 'Visitors · 7d', title: 'First-time visitors in the last 7 days', recent: { w: stats.weeklyVisitors, pw: stats.prevWeeklyVisitors } },
@@@ -2187,7 +2217,44 @@ function _celebrateEgg(egg) {
} catch { /* */ }
}
++/**
++ * Demote the two explainer sections once someone has read them enough times.
++ *
++ * "One stream. Everywhere." and the about panel are the pitch: they exist to tell a first-time
++ * visitor what this place is. By the twentieth visit they are furniture between the reader and
++ * the streams, and the reader has long since stopped looking at them — but they still shouldn't
++ * disappear, because someone may want to send the link to a friend or read the money section.
++ *
++ * So after 18 home page views they move to the bottom of the page instead, ahead of the
++ * changelog and the legal row. Same content, out of the way. The count lives per-browser, and a
++ * cleared cache simply means someone sees the pitch again, which is harmless.
++ */
++const HOME_INTRO_VIEWS_BEFORE_DEMOTING = 18;
++function _homeIntroViews(bump) {
++ try {
++ const n = parseInt(localStorage.getItem('ov_home_views') || '0', 10) || 0;
++ if (bump) localStorage.setItem('ov_home_views', String(Math.min(n + 1, 9999)));
++ return n;
++ } catch { return 0; }
++}
++function demoteHomeIntroSections() {
++ const views = _homeIntroViews(true);
++ if (views < HOME_INTRO_VIEWS_BEFORE_DEMOTING) return;
++ const container = document.querySelector('#page-home .container');
++ const anchorEl = document.getElementById('home-changelog-wrapper');
++ if (!container || !anchorEl) return;
++ for (const id of ['home-tour-mount', 'home-cta-banner']) {
++ const el = document.getElementById(id);
++ // Already moved (a re-render, or a second visit in the same session)? Leave it be.
++ if (!el || el.dataset.ovDemoted) continue;
++ el.dataset.ovDemoted = '1';
++ el.classList.add('home-intro-demoted');
++ try { container.insertBefore(el, anchorEl); } catch { /* */ }
++ }
++}
++
async function loadHome() {
++ demoteHomeIntroSections();
void loadHomeChangelog();
_initHomeAbout();
updateNavHeroTransparency(); // transparent nav over the hero at the top
@@@ -9563,17 +9630,43 @@ async function loadHomeStar() {
<div class="star-kicker"><i class="fa-solid fa-star"></i> ${s.rotates ? 'Star of the day' : 'Featured streamer'}</div>
<h3 class="star-name"><a href="${esc(path)}" onclick="return handleLinkClick(event, '${esc(path)}')">${name}</a>${foreign && lang.code === 'ja' ? '<span class="star-jp">OpenVibeの看板配信者 — ようこそ!</span>' : ''}</h3>
<div class="star-chips">${chips}</div>
-- ${why}
-- ${bioHtml}
-- ${overview}
-- ${offline}
++ <div class="star-more" id="star-more" hidden>
++ ${why}
++ ${bioHtml}
++ ${overview}
++ ${offline}
++ </div>
<div class="star-actions">
<a class="btn btn-lg star-btn-watch" href="${esc(path)}" onclick="return handleLinkClick(event, '${esc(path)}')"><i class="fa-solid ${isLive ? 'fa-play' : 'fa-user'}"></i> ${isLive ? 'Watch now' : 'Visit channel'}</a>
<a class="btn btn-outline btn-lg" href="${esc(path)}#about" onclick="return handleLinkClick(event, '${esc(path)}#about')"><i class="fa-solid fa-comments"></i> Say hi${foreign ? ' — any language works' : ''}</a>
++ <button type="button" class="star-expand" id="star-expand" aria-expanded="false" aria-controls="star-more">
++ <i class="fa-solid fa-chevron-down"></i><span>More about ${name}</span>
++ </button>
</div>
</div>
-- ${liveCard}
</div>
</div>`;
++ // The live stream card is the tallest piece of the section — it goes inside the disclosure.
++ const more = sec.querySelector('#star-more');
++ if (more && liveCard) more.insertAdjacentHTML('beforeend', liveCard);
++
++ const card = sec.querySelector('.star-card');
++ const btn = sec.querySelector('#star-expand');
++ if (card && btn && more) {
++ card.classList.add('is-compact');
++ const label = () => {
++ btn.querySelector('span').textContent = more.hidden ? `More about ${s.display_name || s.username}` : 'Less';
++ btn.classList.toggle('is-open', !more.hidden);
++ btn.setAttribute('aria-expanded', String(!more.hidden));
++ };
++ label();
++ btn.addEventListener('click', () => {
++ animateHeightChange(card, () => {
++ more.hidden = !more.hidden;
++ card.classList.toggle('is-compact', more.hidden);
++ label();
++ }, { entering: more.hidden ? null : more });
++ });
++ }
sec.style.display = '';
}