openvibers/openvibe.live Commits

Hero strip: nine numbers in three rows, responsive at every width; louder disclosure toggle

OP OpenVibers committed 16 Sept 2026 · 63ebb78fda · 6 files, +86 −24

Added streamers and hours watched, so the strip is three clean rows of three rather than two
rows and an orphan. Both are keyed like the rest, so the ten-second poller rolls them too.

The strip is now fluid rather than breakpoint-switched: gaps, padding, icon size, number size
and label size all scale with clamp(), and the strip caps at 940px and centres itself so chips
don't stretch to 400px on a wide monitor. Verified at 320, 412, 768 and 1280 wide plus a 900x420
landscape phone: three rows of three every time, no horizontal scroll, no truncated labels.
Below 380px the labels wrap instead of ellipsing, because "Hours watched" cannot fit on one line
in a third of a 320px screen. Reserved height is now within a few pixels of the real rendered
height at each width, instead of over-reserving 70px on a phone and leaving a gap.

Fixed the odometer's digit cells: they were 0.62ch wide, which clipped the right edge off every
numeral. With tabular figures one digit is exactly 1ch.

The join panel's "what each of these means" was plain underlined text that nobody would press.
It is a pill now, with a lit edge, the chevron in its own gradient disc, a light sweep every few
seconds and a slow downward nudge — and all of that stops permanently once the reader opens it,
because an invitation that keeps waving after you accept it is just noise.

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

Changes

ddiff --git a/public/css/home-tour.css b/public/css/home-tour.css
iindex 444aa85c..39cabc53 100644
---- a/public/css/home-tour.css
++++ b/public/css/home-tour.css
@@@ -231,13 +231,51 @@
.ovg-join.is-open .ovg-join-ico { width: 28px; height: 28px; border-radius: 9px; font-size: 0.76rem; }
.ovg-join.is-open .ovg-join-perks b { white-space: normal; font-size: 0.86rem; }
--.ovg-join-expand { display: inline-flex; align-items: center; gap: 7px; margin-top: 11px;
-- padding: 5px 2px; background: none; border: 0; cursor: pointer; font: inherit;
-- font-size: 0.76rem; font-weight: 700; color: color-mix(in srgb, var(--accent) 78%, var(--text-muted));
-- transition: color 0.2s; }
--.ovg-join-expand:hover { color: var(--accent); }
--.ovg-join-expand i { font-size: 0.66rem; transition: transform 0.28s cubic-bezier(.2, 1, .3, 1); }
++/* This used to be plain underlined text and nobody would ever have pressed it. It is now an
++ obvious control: a pill with a lit edge, a chevron in its own disc that nudges downward on a
++ slow loop, and a light sweep that crosses it every few seconds. The idle motion only runs while
++ the panel is on screen and stops for good once the reader has opened it — an invitation that
++ keeps waving after you have accepted it is just noise. */
++.ovg-join-expand {
++ position: relative; overflow: hidden;
++ display: inline-flex; align-items: center; gap: 9px; margin-top: 12px;
++ padding: 7px 15px 7px 8px; cursor: pointer; font: inherit;
++ font-size: 0.78rem; font-weight: 750; letter-spacing: 0.005em;
++ color: color-mix(in srgb, var(--accent) 85%, var(--text-primary));
++ background: color-mix(in srgb, var(--accent) 11%, transparent);
++ border: 1px solid color-mix(in srgb, var(--accent) 34%, transparent);
++ border-radius: 999px;
++ transition: transform 0.22s cubic-bezier(.2, 1, .3, 1), background 0.22s, border-color 0.22s, box-shadow 0.25s;
++}
++.ovg-join-expand:hover {
++ transform: translateY(-1px);
++ background: color-mix(in srgb, var(--accent) 20%, transparent);
++ border-color: color-mix(in srgb, var(--accent) 62%, transparent);
++ box-shadow: 0 6px 18px color-mix(in srgb, var(--accent) 26%, transparent);
++}
++.ovg-join-expand:active { transform: translateY(0) scale(0.985); }
++.ovg-join-expand:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
++.ovg-join-expand i {
++ width: 21px; height: 21px; flex: none; border-radius: 50%; display: grid; place-items: center;
++ font-size: 0.6rem; color: #fff;
++ background: linear-gradient(140deg, var(--accent), color-mix(in srgb, var(--accent) 55%, #2dd4bf));
++ box-shadow: 0 3px 9px color-mix(in srgb, var(--accent) 45%, transparent);
++ transition: transform 0.3s cubic-bezier(.2, 1, .3, 1);
++}
.ovg-join.is-open .ovg-join-expand i { transform: rotate(180deg); }
++/* The nudge and the sweep: attention without a colour change that drags the eye off the copy. */
++.ovg-join-expand::after {
++ content: ''; position: absolute; top: 0; bottom: 0; left: -45%; width: 38%; pointer-events: none;
++ background: linear-gradient(100deg, transparent, color-mix(in srgb, var(--accent) 34%, transparent), transparent);
++}
++.tour-quest-slot.fx-live .ovg-join:not(.is-open) .ovg-join-expand::after { animation: joinNudgeSweep 6s ease-in-out infinite; }
++.tour-quest-slot.fx-live .ovg-join:not(.is-open) .ovg-join-expand i { animation: joinNudge 2.6s ease-in-out infinite; }
++.ovg-join-expand:hover i, .ovg-join.is-open .ovg-join-expand i { animation: none !important; }
++@keyframes joinNudgeSweep { 0%, 62% { transform: translateX(0); } 100% { transform: translateX(420%); } }
++@keyframes joinNudge { 0%, 74%, 100% { transform: translateY(0); } 82% { transform: translateY(2.5px); } 90% { transform: translateY(0); } }
++@media (prefers-reduced-motion: reduce) {
++ .ovg-join-expand::after, .ovg-join-expand i { animation: none !important; }
++}
.tour-quest-slot.fx-live .ovg-join-perks li { animation: joinPerk 0.6s cubic-bezier(.2, 1, .3, 1) both;
animation-delay: calc(var(--i) * 85ms + 100ms); }
@keyframes joinPerk { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
ddiff --git a/public/css/style.css b/public/css/style.css
iindex ddd16565..6dcd242d 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -14799,14 +14799,35 @@ 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: 168px; }
++.hero-stats { min-height: 246px; }
++/* Nine numbers, three rows of three, at every width. The board itself can be 1240px wide, which
++ would make each chip absurdly long, so the strip caps and centres itself. Everything inside
++ scales with clamp() rather than switching at breakpoints, so it reads the same on a 320px
++ phone, a landscape phone, a tablet and a monitor. */
.hero-stat-strip {
-- display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px;
-- padding: 10px 12px;
++ display: grid; grid-template-columns: repeat(3, minmax(0, 1fr));
++ gap: clamp(4px, 0.8vw, 9px); padding: clamp(7px, 1vw, 11px);
++ max-width: 940px; margin: 0 auto; width: 100%;
++}
++.hero-stat-strip .hero-stat { min-width: 0; padding: clamp(6px, 1.1vw, 10px) clamp(7px, 1.2vw, 12px); gap: clamp(5px, 0.9vw, 10px); }
++.hero-stat-strip .hero-stat > i { font-size: clamp(0.72rem, 1.6vw, 1.05rem); }
++.hero-stat-strip .hero-stat-num { font-size: clamp(0.82rem, 2.3vw, 1.18rem); }
++.hero-stat-strip .hero-stat-label {
++ font-size: clamp(0.5rem, 1.15vw, 0.62rem); letter-spacing: 0.05em;
++ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
++}
++/* Below ~360px a label like "Hours watched" cannot fit on one line in a third of the screen.
++ Let it wrap rather than truncating it into something unreadable. */
++@media (max-width: 900px) { .hero-stats { min-height: 218px; } }
++@media (max-width: 380px) {
++ .hero-stat-strip .hero-stat-label { white-space: normal; line-height: 1.12; }
++ .hero-stat-strip .hero-stat { flex-direction: column; align-items: flex-start; gap: 2px; }
++}
++/* Landscape phones are short, not narrow — give the strip back its vertical room. */
++@media (max-height: 520px) and (orientation: landscape) {
++ .hero-stat-strip { padding: 5px; gap: 4px; }
++ .hero-stat-strip .hero-stat { padding: 4px 7px; }
}
--/* 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%;
@@@ -14824,11 +14845,7 @@ 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: 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; }
++ .hero-stats { min-height: 182px; }
}
@media (prefers-reduced-motion: reduce) {
.hero-stat-full:not([hidden]) { animation: none; }
@@@ -14851,3 +14868,7 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
#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; }
++
++/* Narrowest screens wrap their labels, so the board is taller there, not shorter. Stated last
++ so it beats the wider max-width rules above. */
++@media (max-width: 380px) { .hero-stats { min-height: 224px; } }
ddiff --git a/public/index.html b/public/index.html
iindex f9b3dad0..1841173f 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -88,9 +88,9 @@
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=169">
++ <link rel="stylesheet" href="/css/style.css?v=172">
<link rel="stylesheet" href="/css/home-fx.css?v=10">
-- <link rel="stylesheet" href="/css/home-tour.css?v=6">
++ <link rel="stylesheet" href="/css/home-tour.css?v=7">
<link rel="stylesheet" href="/css/i18n-star.css?v=5">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="/css/chat-shared.css?v=1" media="print" onload="this.media='all';this.onload=null">
@@@ -5128,8 +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/ov-odometer.js?v=2" defer></script>
--<script src="/js/app.js?v=146" defer></script>
++<script src="/js/ov-odometer.js?v=3" defer></script>
++<script src="/js/app.js?v=147" 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>
ddiff --git a/public/js/app.js b/public/js/app.js
iindex 7fb40119..223040d3 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -1291,12 +1291,12 @@ function renderHeroStats(stats) {
// ── Community ────────────────────────────────────────────────
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' },
++ { key: 'streamers', icon: 'fa-satellite-dish', num: stats.streamers, label: 'Streamers', metric: 'streamers', title: 'People who have gone live' },
{ 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 },
{ 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' },
++ { key: 'hoursWatched', icon: 'fa-couch', num: stats.hoursWatched, label: 'Hrs Watched', metric: 'hoursWatched', title: 'Hours the community has spent watching streams', unit: 'h' },
],
});
@@@ -1367,6 +1367,8 @@ function renderHeroStats(stats) {
{ 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' },
++ { 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' },
];
const open = (() => { try { return localStorage.getItem('ov_stats_open') === '1'; } catch { return false; } })();
wrap.innerHTML = `
ddiff --git a/public/js/ov-odometer.js b/public/js/ov-odometer.js
iindex 0acb14b7..f1bfd881 100644
---- a/public/js/ov-odometer.js
++++ b/public/js/ov-odometer.js
@@@ -26,7 +26,7 @@
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-digit{display:inline-block;overflow:hidden;height:var(--ovnum-h);width:1ch;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}
ddiff --git a/server/home/routes.js b/server/home/routes.js
iindex 51751be5..55edd5ba 100644
---- a/server/home/routes.js
++++ b/server/home/routes.js
@@@ -219,6 +219,7 @@ router.get('/stats-live', (req, res) => {
const data = {
liveNow: s.liveNow, viewersNow: s.viewersNow, weeklyActive: s.weeklyActive,
users: s.users, weeklyVisitors: s.weeklyVisitors, anons: s.anons, chatMessages: s.chatMessages,
++ streamers: s.streamers, hoursWatched: s.hoursWatched,
};
_liveStats = { at: Date.now(), data };
res.json(data);