openvibers/openvibe.live Commits

Hero stats: animate the swap, and always start on the highlights

OP OpenVibers committed 16 Sept 2026 · 37e8dbd782 · 3 files, +63 −10

Toggling between the strip and the full board made both vanish and appear in the same frame,
snapping everything below by several hundred pixels. The container now measures its height before
and after, pins the old one, and transitions to the new while the incoming block fades up. Only
the arriving block animates, so the one leaving doesn't flash on its way out, and the height is
pinned only for the duration — the board can still grow freely afterwards.

The expanded state no longer persists across reloads. Remembering it meant every page load
rendered and counted up two dozen extra chips before the page had finished loading, for a view
the reader asked for once days ago. The toggle still holds while they're on the page.

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 87d91711..fe4c4d50 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -14845,14 +14845,17 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
.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; } }
++/* The incoming block fades up while the container animates its height — see the swap() helper.
++ Only the element that is arriving animates, so the one leaving doesn't flash on its way out. */
++.hero-stat-full.is-entering, .hero-stat-strip.is-entering { animation: heroStatsOpen 0.42s cubic-bezier(.2, 1, .3, 1); }
++@keyframes heroStatsOpen { from { opacity: 0; transform: translateY(-7px); } to { opacity: 1; transform: none; } }
.hero-stat-full .hero-stat-group:first-child { border-top: 0; }
@media (max-width: 640px) {
.hero-stats { min-height: 182px; }
}
@media (prefers-reduced-motion: reduce) {
-- .hero-stat-full:not([hidden]) { animation: none; }
++ .hero-stat-full.is-entering, .hero-stat-strip.is-entering { animation: none; }
++ .hero-stats.is-swapping { transition: none; }
.hero-stat-more i { transition: none; }
}
@@@ -15051,3 +15054,13 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
.hero-stat-delta { font-size: 0.55rem; padding: 1px 5px 1px 3px; }
.hero-stat-delta em { display: none; }
}
++
++
++/* Height is pinned only while the board is swapping between the strip and the full set, so the
++ container can still grow freely the rest of the time. min-height would fight an explicit
++ height on the way down, so it is dropped for the duration of the animation. */
++.hero-stats.is-swapping {
++ overflow: hidden;
++ min-height: 0 !important;
++ transition: height 0.42s cubic-bezier(.2, 1, .3, 1);
++}
ddiff --git a/public/index.html b/public/index.html
iindex cc4a71d4..ab1e6e3d 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=178">
++ <link rel="stylesheet" href="/css/style.css?v=179">
<link rel="stylesheet" href="/css/home-fx.css?v=11">
<link rel="stylesheet" href="/css/home-tour.css?v=7">
<link rel="stylesheet" href="/css/i18n-star.css?v=5">
@@@ -5130,7 +5130,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=154" defer></script>
++<script src="/js/app.js?v=155" 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 473a995c..edde86ed 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -1469,7 +1469,10 @@ function renderHeroStats(stats) {
{ key: 'streamers', icon: 'fa-satellite-dish', num: stats.streamers, label: 'Streamers', title: 'People who have gone live here' },
{ key: 'hoursWatched', icon: 'fa-couch', num: stats.hoursWatched, label: 'Hours watched', title: 'Hours the community has spent watching', recent: R.hours },
];
-- const open = (() => { try { return localStorage.getItem('ov_stats_open') === '1'; } catch { return false; } })();
++ // Always starts collapsed. Persisting "expanded" meant every reload rendered and counted up
++ // two dozen extra chips before the page had finished loading, for a view the reader asked for
++ // once, days ago. The toggle still holds for as long as they're on the page.
++ const open = 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">
@@@ -1490,11 +1493,48 @@ function renderHeroStats(stats) {
const label = () => { btn.querySelector('span').textContent = full.hidden ? `All ${total} numbers` : 'Just the highlights'; };
const sync = () => { strip.hidden = !full.hidden; btn.classList.toggle('is-open', !full.hidden); label(); };
sync();
-- btn.addEventListener('click', () => {
-- full.hidden = !full.hidden;
-- btn.setAttribute('aria-expanded', String(!full.hidden));
++
++ /**
++ * Swap between the strip and the full board as one movement.
++ *
++ * Toggling `hidden` on both made them vanish and appear in the same frame, with everything
++ * below snapping up or down by several hundred pixels. Instead: measure the height before and
++ * after, pin the container to the old height, then let it transition to the new one while the
++ * incoming block fades up. The container is only height-constrained during the animation, so
++ * nothing is clipped once it settles and the board can still grow if a number gets longer.
++ */
++ let swapping = 0;
++ const reduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
++ const swap = () => {
++ const toOpen = full.hidden;
++ if (reduced) {
++ full.hidden = !toOpen; sync();
++ btn.setAttribute('aria-expanded', String(toOpen));
++ return;
++ }
++ const h0 = wrap.offsetHeight;
++ full.hidden = !toOpen;
sync();
-- try { localStorage.setItem('ov_stats_open', full.hidden ? '0' : '1'); } catch { /* */ }
++ btn.setAttribute('aria-expanded', String(toOpen));
++ const h1 = wrap.offsetHeight;
++
++ clearTimeout(swapping);
++ wrap.classList.add('is-swapping');
++ wrap.style.height = `${h0}px`;
++ const entering = toOpen ? full : strip;
++ entering.classList.remove('is-entering');
++ void entering.offsetWidth;
++ entering.classList.add('is-entering');
++ requestAnimationFrame(() => { wrap.style.height = `${h1}px`; });
++ swapping = setTimeout(() => {
++ wrap.style.height = '';
++ wrap.classList.remove('is-swapping');
++ entering.classList.remove('is-entering');
++ }, 460);
++ };
++
++ btn.addEventListener('click', () => {
++ swap();
// 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);