openvibers/openvibe.live Commits
Hero stats: animate the swap, and always start on the highlights
Changes
@@@ -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);++}@@@ -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=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>@@@ -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);
@@ -14845,14 +14845,17 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
@@ -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 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-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 { 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; }
.hero-stat-full .hero-stat-group:first-child { border-top: 0; }
@media (max-width: 640px) {
@media (max-width: 640px) {
.hero-stats { min-height: 182px; }
.hero-stats { min-height: 182px; }
}
}
@media (prefers-reduced-motion: reduce) {
@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; }
.hero-stat-more i { transition: none; }
}
}
@@ -15051,3 +15054,13 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
@@ -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 { font-size: 0.55rem; padding: 1px 5px 1px 3px; }
.hero-stat-delta em { display: none; }
.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);
+}
@@ -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=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-fx.css?v=11">
<link rel="stylesheet" href="/css/home-tour.css?v=7">
<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=5">
@@ -5130,7 +5130,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -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="/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/ov-odometer.js?v=3" 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-player.js?v=22" 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>
@@ -1469,7 +1469,10 @@ function renderHeroStats(stats) {
@@ -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: '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 },
{ 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 = `
wrap.innerHTML = `
<div class="hero-stat-strip">${HEADLINE.map(chip).join('')}</div>
<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">
<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) {
@@ -1490,11 +1493,48 @@ function renderHeroStats(stats) {
const label = () => { btn.querySelector('span').textContent = full.hidden ? `All ${total} numbers` : 'Just the highlights'; };
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(); };
const sync = () => { strip.hidden = !full.hidden; btn.classList.toggle('is-open', !full.hidden); label(); };
sync();
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();
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.
// 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 => {
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);
el.setAttribute('data-counted', '1'); _heroCountUp(el, parseInt(el.dataset.n, 10) || 0);