openvibers/openvibe.report Commits
openvibe.report's hero is an after-show report in miniature (#77)
Changes
@@@ -328,3 +328,17 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex.dh-find { grid-template-columns: minmax(0, 1fr); }.dh-text { grid-column: 1; }}++++/* ── The home's picture: an after-show report in miniature (MOTION-1, 2026-11-02) ──────────────────────────────────── */++.rp-motif { width: min(100%, 360px); margin: 0 auto; display: grid; gap: 12px; padding: 16px; border-radius: 18px; border: 1px solid var(--ovk-line, var(--border, rgba(255, 255, 255, .1))); background: var(--ovk-panel, var(--bg-card, #141c2a)); box-shadow: 0 12px 30px rgba(0, 0, 0, .22); }++.rp-mf-head { display: grid; gap: 2px; }++.rp-mf-k { font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: var(--ovk-muted, #93a0b4); }++.rp-mf-head b { font-size: 15px; color: var(--ovk-text, #e8ecf3); }++.rp-mf-curve { position: relative; padding-top: 18px; }++.rp-mf-curve .rp-spark { display: block; width: 100%; height: 64px; }++.rp-mf-peak { position: absolute; top: 0; left: calc(53% - 22px); font-size: 11.5px; font-weight: 700; color: var(--room-strong, var(--ovk-accent, #3b82f6)); }++.rp-mf-facts { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }++.rp-mf-facts li { display: grid; gap: 1px; padding: 8px 10px; border-radius: 10px; background: var(--ovk-bg, var(--bg-secondary, #0f1522)); }++.rp-mf-facts b { font-size: 16px; font-variant-numeric: tabular-nums; color: var(--ovk-text, #e8ecf3); }++.rp-mf-facts span { font-size: 11.5px; color: var(--ovk-muted, #93a0b4); }++.rp-mf-note { margin: 0; font-size: 12.5px; color: var(--ovk-muted, #93a0b4); }@@@ -152,6 +152,21 @@ function spark(points) {const line = xy.map(([x, y], i) => `${i ? 'L' : 'M'}${x.toFixed(1)} ${y.toFixed(1)}`).join(' ');return `<svg class="rp-spark" viewBox="0 0 ${W} ${H}" aria-hidden="true"><path class="rp-spark-area" d="${line} L${W} ${H} L0 ${H}Z"/><path class="rp-spark-line" d="${line}"/></svg>`;}++++/**++ * The home's picture (MOTION-1, 2026-11-02: sc.room drew its default motif): an after-show report in miniature, the++ * thing Report writes for every stream. A sample stream, its viewers minute by minute with the peak, chat, and the top++ * moment. The numbers are a sample, said as one; the real reports are a tap away.++ */++function reportMotif() {++ const sample = [3, 5, 9, 14, 18, 22, 27, 31, 41, 37, 33, 35, 29, 24, 19, 15].map((v) => ({ n: v }));++ return `<div class="rp-motif" aria-hidden="true">++<div class="rp-mf-head"><span class="rp-mf-k">After the show · sample</span><b>Tuesday stream · 2 h 14 m</b></div>++<div class="rp-mf-curve">${spark(sample)}<span class="rp-mf-peak">peak 41</span></div>++<ul class="rp-mf-facts"><li><b>1,204</b><span>chat messages</span></li><li><b>186</b><span>people came by</span></li><li><b>01:12</b><span>top moment</span></li></ul>++<p class="rp-mf-note">“The cat walks in” · the clip with the most replays</p>++</div>`;++}/** A stream's viewers minute by minute (Live's curve: [time, viewers, chat messages]). */function curve(points) {const pts = (points || []).filter((p) => Array.isArray(p) && ts(p[0]) != null);@@@ -223,6 +238,7 @@ function home({ stats, rundown, topics } = {}) {lede: 'The network\'s numbers, open to everyone. What streamers talked about, written up. Every stream\'s after-show report. Each number with what it counts and when.',start: { href: '/stats', label: 'See the numbers' },chips: [{ label: 'Last 7 days', href: '/stats?days=7' }, { label: 'Last 30 days', href: '/stats?days=30' }, { label: 'The Rundown', href: '/rundown' }],++ motif: reportMotif(),},proof: [{ value: n(T.pageviews), label: 'page views this week', asOf: stats && stats.generated_at ? day(stats.generated_at) : null },@@@ -19,6 +19,12 @@ const { boot, check, done } = require('./helpers/boot');assert.strictEqual(topics.length, chips.length, 'every topic is in the page');assert.deepStrictEqual(topics.map((m) => m[1]), chips.map((m) => m[1]), 'each topic knows its chip');});++ await check('the hero draws an after-show report in miniature, not sc.room\'s default motif (MOTION-1)', async () => {++ const r = await t.get('/');++ assert.match(r.text, /<div class="rp-motif" aria-hidden="true">/);++ assert.match(r.text, /After the show · sample[\s\S]*?peak 41[\s\S]*?top moment/);++ assert.ok(!/class="sc-room-motif"/.test(r.text), 'no default motif');++ });await check('the CSS guard: the chips are hidden and the topics all show until 640 px and where :has() is understood', async () => {const r = await t.get('/');const page = r.text;
@@ -328,3 +328,17 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex
@@ -328,3 +328,17 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex
.dh-find { grid-template-columns: minmax(0, 1fr); }
.dh-find { grid-template-columns: minmax(0, 1fr); }
.dh-text { grid-column: 1; }
.dh-text { grid-column: 1; }
}
}
+
+/* ── The home's picture: an after-show report in miniature (MOTION-1, 2026-11-02) ──────────────────────────────────── */
+.rp-motif { width: min(100%, 360px); margin: 0 auto; display: grid; gap: 12px; padding: 16px; border-radius: 18px; border: 1px solid var(--ovk-line, var(--border, rgba(255, 255, 255, .1))); background: var(--ovk-panel, var(--bg-card, #141c2a)); box-shadow: 0 12px 30px rgba(0, 0, 0, .22); }
+.rp-mf-head { display: grid; gap: 2px; }
+.rp-mf-k { font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: var(--ovk-muted, #93a0b4); }
+.rp-mf-head b { font-size: 15px; color: var(--ovk-text, #e8ecf3); }
+.rp-mf-curve { position: relative; padding-top: 18px; }
+.rp-mf-curve .rp-spark { display: block; width: 100%; height: 64px; }
+.rp-mf-peak { position: absolute; top: 0; left: calc(53% - 22px); font-size: 11.5px; font-weight: 700; color: var(--room-strong, var(--ovk-accent, #3b82f6)); }
+.rp-mf-facts { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
+.rp-mf-facts li { display: grid; gap: 1px; padding: 8px 10px; border-radius: 10px; background: var(--ovk-bg, var(--bg-secondary, #0f1522)); }
+.rp-mf-facts b { font-size: 16px; font-variant-numeric: tabular-nums; color: var(--ovk-text, #e8ecf3); }
+.rp-mf-facts span { font-size: 11.5px; color: var(--ovk-muted, #93a0b4); }
+.rp-mf-note { margin: 0; font-size: 12.5px; color: var(--ovk-muted, #93a0b4); }
@@ -152,6 +152,21 @@ function spark(points) {
@@ -152,6 +152,21 @@ function spark(points) {
const line = xy.map(([x, y], i) => `${i ? 'L' : 'M'}${x.toFixed(1)} ${y.toFixed(1)}`).join(' ');
const line = xy.map(([x, y], i) => `${i ? 'L' : 'M'}${x.toFixed(1)} ${y.toFixed(1)}`).join(' ');
return `<svg class="rp-spark" viewBox="0 0 ${W} ${H}" aria-hidden="true"><path class="rp-spark-area" d="${line} L${W} ${H} L0 ${H}Z"/><path class="rp-spark-line" d="${line}"/></svg>`;
return `<svg class="rp-spark" viewBox="0 0 ${W} ${H}" aria-hidden="true"><path class="rp-spark-area" d="${line} L${W} ${H} L0 ${H}Z"/><path class="rp-spark-line" d="${line}"/></svg>`;
}
}
+
+/**
+ * The home's picture (MOTION-1, 2026-11-02: sc.room drew its default motif): an after-show report in miniature, the
+ * thing Report writes for every stream. A sample stream, its viewers minute by minute with the peak, chat, and the top
+ * moment. The numbers are a sample, said as one; the real reports are a tap away.
+ */
+function reportMotif() {
+ const sample = [3, 5, 9, 14, 18, 22, 27, 31, 41, 37, 33, 35, 29, 24, 19, 15].map((v) => ({ n: v }));
+ return `<div class="rp-motif" aria-hidden="true">
+<div class="rp-mf-head"><span class="rp-mf-k">After the show · sample</span><b>Tuesday stream · 2 h 14 m</b></div>
+<div class="rp-mf-curve">${spark(sample)}<span class="rp-mf-peak">peak 41</span></div>
+<ul class="rp-mf-facts"><li><b>1,204</b><span>chat messages</span></li><li><b>186</b><span>people came by</span></li><li><b>01:12</b><span>top moment</span></li></ul>
+<p class="rp-mf-note">“The cat walks in” · the clip with the most replays</p>
+</div>`;
+}
/** A stream's viewers minute by minute (Live's curve: [time, viewers, chat messages]). */
/** A stream's viewers minute by minute (Live's curve: [time, viewers, chat messages]). */
function curve(points) {
function curve(points) {
const pts = (points || []).filter((p) => Array.isArray(p) && ts(p[0]) != null);
const pts = (points || []).filter((p) => Array.isArray(p) && ts(p[0]) != null);
@@ -223,6 +238,7 @@ function home({ stats, rundown, topics } = {}) {
@@ -223,6 +238,7 @@ function home({ stats, rundown, topics } = {}) {
lede: 'The network\'s numbers, open to everyone. What streamers talked about, written up. Every stream\'s after-show report. Each number with what it counts and when.',
lede: 'The network\'s numbers, open to everyone. What streamers talked about, written up. Every stream\'s after-show report. Each number with what it counts and when.',
start: { href: '/stats', label: 'See the numbers' },
start: { href: '/stats', label: 'See the numbers' },
chips: [{ label: 'Last 7 days', href: '/stats?days=7' }, { label: 'Last 30 days', href: '/stats?days=30' }, { label: 'The Rundown', href: '/rundown' }],
chips: [{ label: 'Last 7 days', href: '/stats?days=7' }, { label: 'Last 30 days', href: '/stats?days=30' }, { label: 'The Rundown', href: '/rundown' }],
+ motif: reportMotif(),
},
},
proof: [
proof: [
{ value: n(T.pageviews), label: 'page views this week', asOf: stats && stats.generated_at ? day(stats.generated_at) : null },
{ value: n(T.pageviews), label: 'page views this week', asOf: stats && stats.generated_at ? day(stats.generated_at) : null },
@@ -19,6 +19,12 @@ const { boot, check, done } = require('./helpers/boot');
@@ -19,6 +19,12 @@ const { boot, check, done } = require('./helpers/boot');
assert.strictEqual(topics.length, chips.length, 'every topic is in the page');
assert.strictEqual(topics.length, chips.length, 'every topic is in the page');
assert.deepStrictEqual(topics.map((m) => m[1]), chips.map((m) => m[1]), 'each topic knows its chip');
assert.deepStrictEqual(topics.map((m) => m[1]), chips.map((m) => m[1]), 'each topic knows its chip');
});
});
+ await check('the hero draws an after-show report in miniature, not sc.room\'s default motif (MOTION-1)', async () => {
+ const r = await t.get('/');
+ assert.match(r.text, /<div class="rp-motif" aria-hidden="true">/);
+ assert.match(r.text, /After the show · sample[\s\S]*?peak 41[\s\S]*?top moment/);
+ assert.ok(!/class="sc-room-motif"/.test(r.text), 'no default motif');
+ });
await check('the CSS guard: the chips are hidden and the topics all show until 640 px and where :has() is understood', async () => {
await check('the CSS guard: the chips are hidden and the topics all show until 640 px and where :has() is understood', async () => {
const r = await t.get('/');
const r = await t.get('/');
const page = r.text;
const page = r.text;