openvibers/openvibe.live Commits

Hero numbers: seven live stats, three per row, rolling digits, updated in place

OP OpenVibers committed 16 Sept 2026 · 11ecf32882 · 6 files, +244 −26

The strip now carries the numbers that actually move — live streams, viewers, active this week,
users, visitors, anons and messages — three to a row, with messages taking the full last row so
seven chips don't leave an orphan sitting in a third of a line. Hours watched and coins earned
moved into the full board; they are all-time totals that change slowly and were spending prime
space saying nothing new.

The numbers update themselves every ten seconds and roll to their new value a digit at a time,
so 1,299 to 1,300 rolls three digits and leaves the leading one alone. A counter that changes by
being replaced is a counter nobody notices. Nothing is re-rendered to do it: the poller finds
each chip by its stat key and touches only the digits, so the expand state, the tooltips, the
hovered chip and any selected text all survive. It skips the request entirely when the tab is in
the background or the home page isn't the current route, backs off when a request fails, and
refreshes immediately when you come back to the tab.

Opening the full board now hides the strip, since the board repeats those same seven numbers and
showing both wasted a screen.

Fixes found while building it: the effects layer's count-up animation was rewriting the text of
any number it found in the hero, which flattened the odometer's digit markup into the literal
string 0123456789; the digit clipping window was one em square, which shaves the tops off
numerals, and is now a full line box; and the rotating tagline was an unbreakable line that ran
off both edges of a phone screen. The hero's tail padding is tighter too, so on a phone the live
grid now starts 200px higher.

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 a8bf1da1..ddd16565 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -14799,11 +14799,14 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
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-stats { min-height: 168px; }
.hero-stat-strip {
-- display: grid; grid-template-columns: repeat(auto-fit, minmax(128px, 1fr)); gap: 8px;
++ display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px;
padding: 10px 12px;
}
++/* Seven chips over three columns leaves one on its own; let it take the whole last row rather
++ than sitting in a third of it looking like a mistake. */
++.hero-stat-strip .hero-stat:nth-child(7):last-child { grid-column: 1 / -1; }
.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%;
@@@ -14821,10 +14824,30 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
@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; }
++ .hero-stats { min-height: 232px; }
++ .hero-stat-strip { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 5px; padding: 7px; }
++ .hero-stat-strip .hero-stat { padding: 7px 8px; gap: 6px; }
++ .hero-stat-strip .hero-stat > i { font-size: 0.85rem; }
++ .hero-stat-strip .hero-stat-label { font-size: 0.55rem; letter-spacing: 0.06em; }
}
@media (prefers-reduced-motion: reduce) {
.hero-stat-full:not([hidden]) { animation: none; }
.hero-stat-more i { transition: none; }
}
++
++
++/* ── Hero polish ─────────────────────────────────────────────────────────────
++ The rotating tagline is a single unbreakable line, so on a narrow screen a long phrase ran off
++ both edges of the page. It wraps now, and the hero's tail padding is tighter so the live grid
++ starts closer to the buttons instead of after a screen of empty space. */
++.hero-rotate-wrap { display: inline; }
++.hero-tagline { text-wrap: balance; overflow-wrap: break-word; }
++@media (max-width: 760px) {
++ .hero-tagline { font-size: clamp(1.15rem, 6vw, 1.7rem); line-height: 1.25; }
++ .hero-rotate { display: inline; white-space: normal; }
++}
++.hero-stats { margin-bottom: 8px; }
++#page-home .hero { padding-bottom: 14px; }
++#page-home .container > .section-header:first-child { margin-top: 18px; }
++/* An empty live grid was a screen and a half of nothing under two short lines of text. */
++#stream-grid-live .empty-state { padding: 26px 16px; }
ddiff --git a/public/index.html b/public/index.html
iindex 904cdeae..025c1d35 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=167">
++ <link rel="stylesheet" href="/css/style.css?v=169">
<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">
@@@ -5128,7 +5128,8 @@ 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=145" defer></script>
++<script src="/js/ov-odometer.js?v=2" defer></script>
++<script src="/js/app.js?v=146" 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>
@@@ -5211,7 +5212,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=7" defer></script>
++<script src="/js/home-fx.js?v=8" 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 6be87963..7fb40119 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -1268,10 +1268,10 @@ function renderHeroStats(stats) {
// ── Right now ────────────────────────────────────────────────
const now = [
-- { 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' },
-- { cls: stats.viewersNow > 0 ? 'hero-stat--live' : '', icon: 'fa-eye', 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', desc: 'Distinct chatters in the last 7 days — signed-in users, anonymous chatters and relayed (Twitch/Kick/YouTube) chatters, each counted once.', metric: 'active' },
-- { icon: 'fa-user-plus', num: stats.weeklyVisitors, label: 'Visitors · 7d', 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' },
++ { key: 'liveNow', 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', 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 · 7d', 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 · 7d', 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' },
];
// 24h viewer sparkline (5-minute samples) — trends read better than a snapshot.
const trend = Array.isArray(stats.viewerTrend) ? stats.viewerTrend : [];
@@@ -1292,10 +1292,10 @@ function renderHeroStats(stats) {
groups.push({
kicker: 'Community', icon: 'fa-people-group', rows: [
{ icon: 'fa-satellite-dish', num: stats.streamers, label: 'Streamers', metric: 'streamers', title: 'People who have gone live' },
-- { icon: 'fa-users', num: stats.users, label: 'Users', metric: 'users', title: 'Registered users', recent: R.users },
-- { icon: 'fa-user-secret', num: stats.anons, label: 'Anons', metric: 'anons', title: 'Anonymous chatters ever seen', recent: R.anons },
++ { key: 'users', icon: 'fa-users', num: stats.users, label: 'Users', metric: 'users', title: 'Registered users', recent: R.users },
++ { key: 'anons', icon: 'fa-user-secret', num: stats.anons, label: 'Anons', metric: 'anons', title: 'Anonymous chatters ever seen', recent: R.anons },
{ icon: 'fa-heart', num: stats.follows, label: 'Follows', metric: 'follows', title: 'Channel follows', recent: R.follows },
-- { icon: 'fa-comments', num: stats.chatMessages, label: 'Messages', metric: 'messages', title: 'Chat messages sent', recent: R.messages },
++ { key: 'chatMessages', icon: 'fa-comments', num: stats.chatMessages, label: 'Messages', metric: 'messages', title: 'Chat messages sent', recent: R.messages },
{ icon: 'fa-couch', num: stats.hoursWatched, label: 'Hrs Watched', metric: 'hoursWatched', title: 'Hours the community has spent watching streams', unit: 'h' },
],
});
@@@ -1347,7 +1347,7 @@ function renderHeroStats(stats) {
metric: r.metric || null,
};
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' : ''}" ${r.key ? `data-stat="${r.key}"` : ''} 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>`;
};
// 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
@@@ -1356,12 +1356,17 @@ function renderHeroStats(stats) {
//
// 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.
++ // Seven live numbers, three to a row. These are the ones that move — every other number on
++ // the board is an all-time total that changes once an hour at best, so it lives under the
++ // toggle. Each is keyed so the poller below can roll it to a new value without a re-render.
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' }] : []),
++ { 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' },
++ { key: 'viewersNow', icon: 'fa-eye', cls: stats.viewersNow > 0 ? 'hero-stat--live' : '', num: stats.viewersNow, label: 'Watching', title: 'Viewers watching right now' },
++ { key: 'weeklyActive', icon: 'fa-fire', num: stats.weeklyActive, label: 'Active · 7d', title: 'People who chatted in the last 7 days' },
++ { key: 'users', icon: 'fa-user-group', num: stats.users, label: 'Users', title: 'Accounts on OpenVibe.Live' },
++ { key: 'weeklyVisitors', icon: 'fa-user-plus', num: stats.weeklyVisitors, label: 'Visitors · 7d', title: 'First-time visitors in the last 7 days' },
++ { key: 'anons', icon: 'fa-user-secret', num: stats.anons, label: 'Anons', title: 'Anonymous chatters who have been given a name' },
++ { key: 'chatMessages', icon: 'fa-comments', num: stats.chatMessages, label: 'Messages', title: 'Chat messages sent, all time' },
];
const open = (() => { try { return localStorage.getItem('ov_stats_open') === '1'; } catch { return false; } })();
wrap.innerHTML = `
@@@ -1379,26 +1384,79 @@ function renderHeroStats(stats) {
const btn = wrap.querySelector('#hero-stat-more');
const full = wrap.querySelector('#hero-stat-full');
++ const strip = wrap.querySelector('.hero-stat-strip');
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);
++ 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));
-- btn.classList.toggle('is-open', !full.hidden);
-- label();
++ sync();
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); });
++ wrap.querySelectorAll('.hero-stat-strip .hero-stat-num').forEach(el => {
++ el.setAttribute('data-counted', '1');
++ if (window.OVNum) OVNum.mount(el, parseInt(el.dataset.n, 10) || 0);
++ else _heroCountUp(el, parseInt(el.dataset.n, 10) || 0);
++ });
++ _startHeroStatsLive();
if (open) full.querySelectorAll('.hero-stat-num').forEach(el => { el.setAttribute('data-counted', '1'); _heroCountUp(el, parseInt(el.dataset.n, 10) || 0); });
_heroBindInteractions(wrap);
}
++/**
++ * Keep the hero numbers current without re-rendering anything.
++ *
++ * A number that changes by being replaced is a number nobody notices, and re-rendering the board
++ * on a timer would throw away the expand state, the tooltips and any text the reader had
++ * selected. So this touches only the digits: it asks for the seven live values, finds each chip
++ * by its data-stat key and rolls it to the new value. Nothing is destroyed, nothing reflows, and
++ * the element the reader is hovering stays exactly where it was.
++ *
++ * It only runs while the home page is the visible route and the tab is in the foreground, and it
++ * backs off when a request fails so a server having a bad minute does not get hammered.
++ */
++let _heroStatsTimer = null, _heroStatsBackoff = 0;
++function _startHeroStatsLive() {
++ if (_heroStatsTimer) return;
++ const BASE = 10000;
++ const tick = async () => {
++ const wrap = document.getElementById('hero-stats');
++ const home = document.getElementById('page-home');
++ // Nothing to update, or nobody looking at it: skip the request entirely.
++ if (!wrap || !home || !home.classList.contains('active') || document.hidden) return schedule(BASE);
++ try {
++ const d = await api('/home/stats-live');
++ _heroStatsBackoff = 0;
++ for (const [key, value] of Object.entries(d || {})) {
++ wrap.querySelectorAll(`[data-stat="${key}"] .hero-stat-num`).forEach(el => {
++ if (Number(el.dataset.n) === Number(value)) return;
++ if (window.OVNum) OVNum.set(el, value); else el.textContent = _heroStatFmt(value);
++ el.dataset.n = String(value);
++ });
++ }
++ // The "Live" chip glows when anyone is on air.
++ wrap.querySelectorAll('[data-stat="liveNow"], [data-stat="viewersNow"]').forEach(chip => {
++ const n = Number(chip.querySelector('.hero-stat-num')?.dataset.n || 0);
++ chip.classList.toggle('hero-stat--live', n > 0);
++ });
++ schedule(BASE);
++ } catch {
++ _heroStatsBackoff = Math.min(6, _heroStatsBackoff + 1);
++ schedule(BASE * (1 + _heroStatsBackoff));
++ }
++ };
++ const schedule = (ms) => { clearTimeout(_heroStatsTimer); _heroStatsTimer = setTimeout(tick, ms); };
++ schedule(BASE);
++ // Coming back to the tab should show current numbers straight away, not in ten seconds.
++ document.addEventListener('visibilitychange', () => { if (!document.hidden) schedule(400); });
++}
++
// ── Hero stat tooltips + click-through charts ───────────────────
let _heroTipEl = null;
function _heroTipShow(chip) {
ddiff --git a/public/js/home-fx.js b/public/js/home-fx.js
iindex c9ff69de..662574eb 100644
---- a/public/js/home-fx.js
++++ b/public/js/home-fx.js
@@@ -125,6 +125,10 @@
// ── Count-up on the hero stats ─────────────────────────────
function countUp(el) {
++ // Numbers owned by the odometer render as a stack of digit strips; rewriting textContent
++ // here would flatten that into the literal string "0123456789" and break every later
++ // update. The odometer does its own entrance and its own rolling.
++ if (el.classList.contains('ovnum') || el.closest('.ovnum')) return;
const raw = String(el.textContent || '').trim();
const m = raw.match(/^([\d,]+)(\.\d+)?([kKmM]?)$/);
if (!m || el.dataset.hfxCounted) return;
ddiff --git a/public/js/ov-odometer.js b/public/js/ov-odometer.js
nnew file mode 100644
iindex 00000000..0acb14b7
---- /dev/null
++++ b/public/js/ov-odometer.js
@@@ -0,0 +1,110 @@
++/**
++ * ov-odometer.js — numbers that roll to their new value instead of snapping to it.
++ *
++ * Live counters that change under you are easy to miss and easy to distrust: a number that was
++ * 41 and is now 42 looks identical to a number nobody updated. Rolling the digit makes the change
++ * impossible to miss and tells you which way it moved, without a flash or a colour change that
++ * pulls the eye away from whatever you were reading.
++ *
++ * Each digit is a vertical strip of 0-9 translated to show one of them, so only the digits that
++ * actually changed move — 1,299 → 1,300 rolls three digits and leaves the leading 1 alone. Commas
++ * and suffixes are plain cells that never animate.
++ *
++ * OVNum.mount(el, value) — render (no animation the first time)
++ * OVNum.set(el, value) — roll to a new value
++ *
++ * Layout-stable by construction: the element's width only changes when the digit COUNT changes,
++ * and a rolling digit never reflows because the strip is transformed, not re-laid-out.
++ */
++(function () {
++ 'use strict';
++ if (typeof window === 'undefined' || window.OVNum) return;
++
++ const CSS = `
++/* --ovnum-h is the height of one digit cell. It has to clear the font's ascenders and
++ descenders, or the clipping window shaves the tops off the numerals — 1em is the em square,
++ not the line box, and digits overflow it. */
++.ovnum{--ovnum-h:1.25em;display:inline-flex;align-items:center;line-height:var(--ovnum-h);font-variant-numeric:tabular-nums}
++.ovnum-cell{display:inline-block;line-height:var(--ovnum-h)}
++.ovnum-digit{display:inline-block;overflow:hidden;height:var(--ovnum-h);width:.62ch;position:relative}
++.ovnum-track{display:block;will-change:transform;transition:transform var(--ovnum-dur,.62s) cubic-bezier(.22,1,.28,1)}
++.ovnum-track span{display:block;height:var(--ovnum-h);line-height:var(--ovnum-h);text-align:center}
++.ovnum.is-up .ovnum-digit.changed{animation:ovnumUp .62s ease-out}
++.ovnum.is-down .ovnum-digit.changed{animation:ovnumDown .62s ease-out}
++@keyframes ovnumUp{0%{filter:none}35%{filter:brightness(1.55)}100%{filter:none}}
++@keyframes ovnumDown{0%{filter:none}35%{filter:brightness(.72)}100%{filter:none}}
++@media (prefers-reduced-motion:reduce){.ovnum-track{transition:none}.ovnum .ovnum-digit.changed{animation:none}}`;
++
++ function ensure() {
++ if (document.getElementById('ovnum-css')) return;
++ const st = document.createElement('style'); st.id = 'ovnum-css'; st.textContent = CSS;
++ document.head.appendChild(st);
++ }
++
++ /** 12345 → "12,345"; big numbers keep the compact form the rest of the site uses. */
++ function format(n) {
++ n = Number(n) || 0;
++ if (n >= 1_000_000) return (n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1).replace(/\.0$/, '') + 'M';
++ if (n >= 100_000) return Math.round(n / 1000) + 'k';
++ return n.toLocaleString('en-US');
++ }
++
++ const digitTrack = () => {
++ const t = document.createElement('span'); t.className = 'ovnum-track';
++ for (let i = 0; i <= 9; i++) { const d = document.createElement('span'); d.textContent = String(i); t.appendChild(d); }
++ return t;
++ };
++
++ function render(el, text, animate) {
++ const chars = [...text];
++ // Rebuild only when the shape changes; otherwise reuse the cells so digits can roll.
++ if (el.dataset.shape !== chars.map(c => (/\d/.test(c) ? '#' : c)).join('')) {
++ el.textContent = '';
++ for (const ch of chars) {
++ if (/\d/.test(ch)) {
++ const d = document.createElement('span'); d.className = 'ovnum-digit';
++ d.appendChild(digitTrack()); el.appendChild(d);
++ } else {
++ const c = document.createElement('span'); c.className = 'ovnum-cell'; c.textContent = ch; el.appendChild(c);
++ }
++ }
++ el.dataset.shape = chars.map(c => (/\d/.test(c) ? '#' : c)).join('');
++ animate = false; // a freshly built shape has nothing to roll from
++ }
++ const digits = el.querySelectorAll('.ovnum-digit');
++ let di = 0;
++ for (const ch of chars) {
++ if (!/\d/.test(ch)) continue;
++ const d = digits[di++]; if (!d) break;
++ const track = d.firstElementChild;
++ const want = Number(ch);
++ const had = d.dataset.v === undefined ? null : Number(d.dataset.v);
++ d.classList.toggle('changed', animate && had !== null && had !== want);
++ if (!animate) track.style.transition = 'none';
++ track.style.transform = `translateY(calc(${-want} * var(--ovnum-h)))`;
++ if (!animate) { void track.offsetHeight; track.style.transition = ''; }
++ d.dataset.v = String(want);
++ }
++ }
++
++ function apply(el, value, animate) {
++ if (!el) return;
++ ensure();
++ el.classList.add('ovnum');
++ const prev = el.dataset.n === undefined ? null : Number(el.dataset.n);
++ const next = Number(value) || 0;
++ el.dataset.n = String(next);
++ if (animate && prev !== null && prev !== next) {
++ el.classList.toggle('is-up', next > prev);
++ el.classList.toggle('is-down', next < prev);
++ setTimeout(() => el.classList.remove('is-up', 'is-down'), 700);
++ }
++ render(el, format(next), animate);
++ }
++
++ window.OVNum = {
++ mount: (el, v) => apply(el, v, false),
++ set: (el, v) => apply(el, v, true),
++ format,
++ };
++})();
ddiff --git a/server/home/routes.js b/server/home/routes.js
iindex c036c422..51751be5 100644
---- a/server/home/routes.js
++++ b/server/home/routes.js
@@@ -205,6 +205,28 @@ async function heroStats() {
return stats;
}
++// ── GET /api/home/stats-live ────────────────────────────────────────
++// Just the numbers the hero strip shows, so the home page can keep them current without
++// re-fetching the whole hero payload (slogans, collage, moments) every few seconds. The
++// underlying stats are memoised for 30s already; this adds a short cache of its own so a
++// roomful of open tabs costs one computation, not one each.
++let _liveStats = { at: 0, data: null };
++router.get('/stats-live', (req, res) => {
++ try {
++ res.set('Cache-Control', 'public, max-age=5');
++ if (_liveStats.data && Date.now() - _liveStats.at < 5000) return res.json(_liveStats.data);
++ const s = db.getHomeStats();
++ const data = {
++ liveNow: s.liveNow, viewersNow: s.viewersNow, weeklyActive: s.weeklyActive,
++ users: s.users, weeklyVisitors: s.weeklyVisitors, anons: s.anons, chatMessages: s.chatMessages,
++ };
++ _liveStats = { at: Date.now(), data };
++ res.json(data);
++ } catch (err) {
++ res.status(500).json({ error: 'stats unavailable' });
++ }
++});
++
router.get('/hero', async (req, res) => {
try {
res.set('Cache-Control', 'public, max-age=20');