openvibers/openvibe.live Commits
Hero strip: nine numbers in three rows, responsive at every width; louder disclosure toggle
Changes
@@@ -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; } }@@@ -14799,14 +14799,35 @@ 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: 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; } }@@@ -88,9 +88,9 @@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=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>@@@ -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 = `@@@ -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}@@@ -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);
@@ -231,13 +231,51 @@
@@ -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-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.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); }
.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;
.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); }
animation-delay: calc(var(--i) * 85ms + 100ms); }
@keyframes joinPerk { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
@keyframes joinPerk { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
@@ -14799,14 +14799,35 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
@@ -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
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: 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 {
.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-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%;
@@ -14824,11 +14845,7 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
@@ -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; } }
@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: 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) {
@media (prefers-reduced-motion: reduce) {
.hero-stat-full:not([hidden]) { animation: none; }
.hero-stat-full:not([hidden]) { animation: none; }
@@ -14851,3 +14868,7 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
@@ -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; }
#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. */
/* 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; }
#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; } }
@@ -88,9 +88,9 @@
@@ -88,9 +88,9 @@
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=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-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="/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="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">
<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
@@ -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="/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/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-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>
@@ -1291,12 +1291,12 @@ function renderHeroStats(stats) {
@@ -1291,12 +1291,12 @@ function renderHeroStats(stats) {
// ── Community ────────────────────────────────────────────────
// ── Community ────────────────────────────────────────────────
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' },
+ { 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: '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 },
{ 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 },
{ key: 'chatMessages', 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' },
+ { 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) {
@@ -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: '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: '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: '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; } })();
const open = (() => { try { return localStorage.getItem('ov_stats_open') === '1'; } catch { return false; } })();
wrap.innerHTML = `
wrap.innerHTML = `
@@ -26,7 +26,7 @@
@@ -26,7 +26,7 @@
not the line box, and digits overflow it. */
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{--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-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{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-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-up .ovnum-digit.changed{animation:ovnumUp .62s ease-out}
@@ -219,6 +219,7 @@ router.get('/stats-live', (req, res) => {
@@ -219,6 +219,7 @@ router.get('/stats-live', (req, res) => {
const data = {
const data = {
liveNow: s.liveNow, viewersNow: s.viewersNow, weeklyActive: s.weeklyActive,
liveNow: s.liveNow, viewersNow: s.viewersNow, weeklyActive: s.weeklyActive,
users: s.users, weeklyVisitors: s.weeklyVisitors, anons: s.anons, chatMessages: s.chatMessages,
users: s.users, weeklyVisitors: s.weeklyVisitors, anons: s.anons, chatMessages: s.chatMessages,
+ streamers: s.streamers, hoursWatched: s.hoursWatched,
};
};
_liveStats = { at: Date.now(), data };
_liveStats = { at: Date.now(), data };
res.json(data);
res.json(data);