openvibers/openvibe.live Commits
Hero numbers: seven live stats, three per row, rolling digits, updated in place
Changes
@@@ -14799,11 +14799,14 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srgsingle biggest source of layout shift on the page. Now a fixed-height strip carries the fivenumbers worth glancing at, the rest is one tap away, and the reserved height means filling thestrip 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; }@@@ -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=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>@@@ -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) {@@@ -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;nnew file mode 100644@@@ -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,++ };++})();@@@ -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');
@@ -14799,11 +14799,14 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
@@ -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
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
numbers worth glancing at, the rest is one tap away, and the reserved height means filling the
strip in moves nothing below it. */
strip in moves nothing below it. */
-.hero-stats { min-height: 104px; }
+.hero-stats { min-height: 168px; }
.hero-stat-strip {
.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;
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-strip .hero-stat { background: color-mix(in srgb, var(--accent) 7%, transparent); }
.hero-stat-more {
.hero-stat-more {
display: flex; align-items: center; justify-content: center; gap: 8px; width: 100%;
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
@@ -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; } }
@keyframes heroStatsOpen { from { opacity: 0; transform: translateY(-6px); } 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: 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) {
@media (prefers-reduced-motion: reduce) {
.hero-stat-full:not([hidden]) { animation: none; }
.hero-stat-full:not([hidden]) { animation: none; }
.hero-stat-more i { transition: 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; }
@@ -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=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-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">
@@ -5128,7 +5128,8 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -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="/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=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-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>
@@ -5211,7 +5212,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -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/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=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/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>
@@ -1268,10 +1268,10 @@ function renderHeroStats(stats) {
@@ -1268,10 +1268,10 @@ function renderHeroStats(stats) {
// ── Right now ────────────────────────────────────────────────
// ── Right now ────────────────────────────────────────────────
const 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.
// 24h viewer sparkline (5-minute samples) — trends read better than a snapshot.
const trend = Array.isArray(stats.viewerTrend) ? stats.viewerTrend : [];
const trend = Array.isArray(stats.viewerTrend) ? stats.viewerTrend : [];
@@ -1292,10 +1292,10 @@ function renderHeroStats(stats) {
@@ -1292,10 +1292,10 @@ function renderHeroStats(stats) {
groups.push({
groups.push({
kicker: 'Community', icon: 'fa-people-group', rows: [
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-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-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' },
{ 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) {
@@ -1347,7 +1347,7 @@ function renderHeroStats(stats) {
metric: r.metric || null,
metric: r.metric || null,
};
};
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' : ''}" ${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
// 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
// stream on the site below the fold, and because it only arrives once the stats request lands
@@ -1356,12 +1356,17 @@ function renderHeroStats(stats) {
@@ -1356,12 +1356,17 @@ function renderHeroStats(stats) {
//
//
// So: a fixed-height headline strip by default, the full board one tap away. The strip's
// 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.
// 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 = [
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; } })();
const open = (() => { try { return localStorage.getItem('ov_stats_open') === '1'; } catch { return false; } })();
wrap.innerHTML = `
wrap.innerHTML = `
@@ -1379,26 +1384,79 @@ function renderHeroStats(stats) {
@@ -1379,26 +1384,79 @@ function renderHeroStats(stats) {
const btn = wrap.querySelector('#hero-stat-more');
const btn = wrap.querySelector('#hero-stat-more');
const full = wrap.querySelector('#hero-stat-full');
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 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', () => {
btn.addEventListener('click', () => {
full.hidden = !full.hidden;
full.hidden = !full.hidden;
btn.setAttribute('aria-expanded', String(!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 { /* */ }
try { localStorage.setItem('ov_stats_open', full.hidden ? '0' : '1'); } catch { /* */ }
// 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);
});
});
});
});
- 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); });
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);
}
}
+/**
+ * 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 ───────────────────
// ── Hero stat tooltips + click-through charts ───────────────────
let _heroTipEl = null;
let _heroTipEl = null;
function _heroTipShow(chip) {
function _heroTipShow(chip) {
@@ -125,6 +125,10 @@
@@ -125,6 +125,10 @@
// ── Count-up on the hero stats ─────────────────────────────
// ── Count-up on the hero stats ─────────────────────────────
function countUp(el) {
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 raw = String(el.textContent || '').trim();
const m = raw.match(/^([\d,]+)(\.\d+)?([kKmM]?)$/);
const m = raw.match(/^([\d,]+)(\.\d+)?([kKmM]?)$/);
if (!m || el.dataset.hfxCounted) return;
if (!m || el.dataset.hfxCounted) return;
new file mode 100644
new file mode 100644
@@ -0,0 +1,110 @@
@@ -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,
+ };
+})();
@@ -205,6 +205,28 @@ async function heroStats() {
@@ -205,6 +205,28 @@ async function heroStats() {
return stats;
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) => {
router.get('/hero', async (req, res) => {
try {
try {
res.set('Cache-Control', 'public, max-age=20');
res.set('Cache-Control', 'public, max-age=20');