openvibers/openvibe.report Commits
Short home on phones: one thing at a time, picked with chips (#21)
Changes
@@@ -197,3 +197,69 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex.rp-more::after { content: ''; position: absolute; left: 0; right: 0; top: -14px; bottom: -14px; }.rp-lead a::after { content: ''; position: absolute; left: 0; right: 0; top: -11px; bottom: -11px; }}++++/* Pick (the owner's short-pages rule, 2026-10-30): on a phone the front page shows one block at a time, picked with a++ row of chips. Radio inputs and :has(), no script: the chips and the one-block rule live under 640 px and only++ where :has() is understood, so a wider screen, or an older browser, shows every block and no chips. */++.pick-head { display: none; }++.pick-row { display: none; }++@supports selector(:has(*)) { @media (max-width: 640px) {++ .pick-bar { margin-top: 0; }++ .pick-head { display: block; }++ .pick-head h2 { margin: 0 0 4px; font: 700 clamp(20px,2.4vw,28px)/1.2 var(--ov-font,'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif); letter-spacing: -.02em; }++ .pick-row { display: flex; gap: 8px; overflow-x: auto; margin: 10px -16px 0; padding: 2px 16px 4px; scrollbar-width: none; overscroll-behavior-x: contain; }++ .pick-row::-webkit-scrollbar { display: none; }++ .pick-row input { position: absolute; opacity: 0; width: 1px; height: 1px; pointer-events: none; }++ .pick-row label { display: inline-flex; align-items: center; flex: none; min-height: 44px; padding: 0 15px; border-radius: 999px; border: 1px solid var(--border, #1f2d47); color: var(--text-secondary, #96a7c2); font-size: .9rem; font-weight: 650; white-space: nowrap; cursor: pointer; }++ .pick-row input:checked + label { color: var(--text-primary, #e6edf7); border-color: var(--accent, #3b82f6); background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); }++ .pick-row input:focus-visible + label { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }++ .pick .pick-pane { display: none; }++ .pick .pick-list > li { display: none; }++ /* The chip names the block, so the block's own heading gives way to it; the content stays in the page for the++ crawlers and for the reader on a wide screen. */++ .pick .sc-sec > h2, .pick section > h2, .pick > h2 { display: none; }++} }++++/* The fold (the owner's short-pages rule): closed on a phone, and where there is room it looks like the section it++ replaces: a heading and its content, with no card around it. */++.rp-fold-block { margin-top: 32px; }++.rp-fold-block > summary h2 { margin: 0; font: 700 clamp(19px,2.2vw,26px)/1.2 var(--ov-font,'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif); }++.rp-fold-hint { display: none; }++/* The fold's summary carries the heading, so the section's own heading gives way to it. */++.rp-fold-block .sc-sec > h2, .rp-fold-block > .sc-sec > h2 { display: none; }++@media (min-width: 641px) {++ .rp-fold-block { border: 0; background: none; border-radius: 0; margin-top: 56px; padding: 0; }++ .rp-fold-block > summary { display: block; min-height: 0; padding: 0; }++ .rp-fold-block > summary h2 { margin: 0 0 6px; font: 700 clamp(22px,2.6vw,30px)/1.2 var(--ov-font,'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif); }++ .rp-fold-block > summary span { display: none; }++ .rp-fold-block .sc-sec { margin: 0; padding: 0; }++}++@media (max-width: 640px) {++ .rp-fold-block { margin-top: 24px; }++ .rp-fold-block > summary { cursor: pointer; }++ .rp-fold-hint { display: inline; margin-left: 8px; font-size: .78rem; font-weight: 600; color: var(--text-secondary, #96a7c2); }++}++++++/* A link that points into a pick block opens it: the pane carries the id the link names. */++@supports selector(:has(*)) { @media (max-width: 640px) {++ .pick .pick-pane:target { display: block; }++} }++++/* On a wide screen the pick block is invisible: its blocks sit where they were, in the order they were. */++@media (min-width: 641px) {++ .pick, .pick-body, .pick-pane { display: contents; }++ .pick-bar { display: none; }++}++++/* The chip names the scene, so the scene's own heading gives way to it. */++@supports selector(:has(*)) { @media (max-width: 640px) {++ .pick .sc-scene-text > h2 { display: none; }++} }++++/* The fold's summary carries the heading, so the section's own heading gives way to it. */++.rp-fold-block .sc-scene-text > h2 { display: none; }++/* The chip names the topic, so the topics' own heading gives way to it. */++@supports selector(:has(*)) { @media (max-width: 640px) {++ .pick .rp-head-row h2 { display: none; }++} }@@@ -12,6 +12,10 @@ const shell = require('openvibe-shared/shell');const ovServe = require('openvibe-shared/serve');const appIcon = require('openvibe-shared/app-icon');const kit = require('openvibe-shared/kit');++// The short home (the owner's rule, 2026-10-30): pick blocks and folds.++const { pickList, pickListRules, foldAround } = require('./short');++// A short name for a chip: the title, cut where it stops saying anything.++const clip = (s, n = 40) => { const x = String(s == null ? '' : s); return x.length > n ? `${x.slice(0, n - 1).trimEnd()}…` : x; };const sc = require('openvibe-shared/showcase');const rooms = require('openvibe-shared/rooms');const { asset } = require('../assets');@@@ -77,7 +81,7 @@ const NAV = [{ label: 'API', href: '/developers', icon: 'fa-code', more: true },];--function layout({ title, description, canonical = null, path = null, robots = 'index, follow', body, active = '/', jsonLd = [], agent = false }) {++function layout({ title, description, canonical = null, path = null, robots = 'index, follow', body, active = '/', jsonLd = [], agent = false, inlineStyles = [] }) {canonical = canonical || (path ? canon(path) : undefined);const navbar = {service: 'report',@@@ -113,6 +117,7 @@ function layout({ title, description, canonical = null, path = null, robots = 'i<script src="${esc(ovServe.url('kit-web.js'))}" defer></script><script src="${esc(ovServe.url('kit-data-web.js'))}" defer></script><script src="${esc(ovServe.url('motion.js'))}" defer></script>++${(inlineStyles || []).map((css) => `<style>${css}</style>`).join('\n')}<meta name="ov-boost" content="report@${esc(RELEASE)}">`,body: `<a class="skip" href="#main">Skip to content</a><div id="navbar-mount"></div>@@@ -165,9 +170,9 @@ function takeRow(t) {return `<li class="rp-take"><h3>${sid ? `<a href="/rundown/e/${encodeURIComponent(sid)}">${esc(t.title)}</a>` : esc(t.title)}</h3>${t.what ? `<p>${esc(t.what)}</p>` : ''}`+ `<p class="rp-meta">${by ? `<a href="/rundown/s/${encodeURIComponent((t.user || (t.stream && t.stream.user) || {}).username || '')}">${esc(by)}</a>` : ''}${t.at || t.started_at ? `<time>${esc(dayTime(t.at || t.started_at))}</time>` : ''}</p></li>`;}--function topicCard(t) {++function topicCard(t, i) {if (!t || !t.slug) return '';-- return `<li><a class="rp-topic" href="/rundown/t/${encodeURIComponent(t.slug)}"><span class="rp-kind">${esc(KIND[t.kind] || t.kind || 'Topic')}</span><b>${esc(t.title)}</b>${t.headline ? `<span class="rp-topic-h">${esc(t.headline)}</span>` : ''}`++ return `<li${i == null ? '' : ` data-i="${i}"`}><a class="rp-topic" href="/rundown/t/${encodeURIComponent(t.slug)}"><span class="rp-kind">${esc(KIND[t.kind] || t.kind || 'Topic')}</span><b>${esc(t.title)}</b>${t.headline ? `<span class="rp-topic-h">${esc(t.headline)}</span>` : ''}`+ `<span class="rp-meta">${[n(t.takes) ? `${exact(t.takes)} takes` : '', n(t.streamers) ? `${exact(t.streamers)} streamer${Number(t.streamers) === 1 ? '' : 's'}` : '', t.last_seen_at ? day(t.last_seen_at) : ''].filter(Boolean).map(esc).join(' · ')}</span></a></li>`;}function streamCard(s) {@@@ -186,7 +191,16 @@ function home({ stats, rundown, topics } = {}) {const lead = R.lead;const leadHTML = lead ? `<section class="rp-lead" aria-labelledby="rp-lead-h"><p class="rp-kicker">The Rundown · lead</p><h2 id="rp-lead-h">${lead.stream_id || (lead.stream && lead.stream.id) ? `<a href="/rundown/e/${encodeURIComponent(lead.stream_id || lead.stream.id)}">${esc(lead.title)}</a>` : esc(lead.title)}</h2>`+ `${lead.what ? `<p class="rp-lead-what">${esc(lead.what)}</p>` : ''}${lead.take ? `<p class="rp-lead-take">${esc(lead.take)}</p>` : ''}<p class="rp-meta">${AI}${who(lead.user) ? ` · ${esc(who(lead.user))}` : ''}</p></section>` : '';-- const topicsHTML = trending.length ? `<section aria-labelledby="rp-tr-h"><div class="rp-head-row"><h2 id="rp-tr-h">What streamers talked about</h2><a class="rp-more" href="/rundown">The Rundown${kit.icon('chevron')}</a></div><ul class="rp-topics">${trending.map(topicCard).join('')}</ul></section>` : '';++ const topicsHTML = trending.length ? `<section aria-labelledby="rp-tr-h"><div class="rp-head-row"><h2 id="rp-tr-h">What streamers talked about</h2><a class="rp-more" href="/rundown">The Rundown${kit.icon('chevron')}</a></div><ul class="rp-topics pick-list">${trending.map((t, i) => topicCard(t, i)).join('')}</ul></section>` : '';++ // The short home (the owner's rule, 2026-10-30): the topics are one pick block, a row of chips picks one of them,++ // and the parts a reader rarely needs are folds.++ const topicsBlock = trending.length ? pickList({++ key: 'topics',++ label: 'What streamers talked about',++ head: 'What streamers talked about',++ labels: trending.map((t) => clip(t.title, 40)),++ html: topicsHTML,++ }) : '';const pv = stats && stats.pageviews_daily;const numbersScene = pv && pv.length > 1 ? {title: 'Every number says what it counts',@@@ -209,7 +223,7 @@ function home({ stats, rundown, topics } = {}) {{ value: n(R.counts && R.counts.takes), label: 'takes written up' },].filter((p) => p.value),rails: streams.length ? [{ id: 'after-show', title: 'After-show reports', more: { href: '/rundown', label: 'The Rundown' }, items: streams }] : [],-- html: leadHTML + topicsHTML,++ html: leadHTML + topicsBlock,scenes: [numbersScene].filter(Boolean),explore: { title: 'More on Report', items: [{ title: 'Every site\'s numbers', text: 'Page views, people, streams and how fast each service answers', href: '/stats', icon: 'ov:gauge' },@@@ -227,7 +241,15 @@ function home({ stats, rundown, topics } = {}) {['Who writes the after-show reports?', 'An AI, from the stream\'s own numbers, chat and speech, and it says so on every report. It never describes how anyone looks.'],] },});-- return layout({ title: null, body, active: '/', path: '/', agent: true });++ // The numbers, the code samples and the questions are the parts a reader rarely needs on a phone.++ let out = foldAround(body, '>Every number says what it counts</h2>', 'numbers', '<h2 id="rp-numbers-h">Every number says what it counts</h2>');++ out = foldAround(out, '>For developers</h2>', 'dev', '<h2 id="rp-dev-h">For developers</h2>');++ out = foldAround(out, '>Questions</h2>', 'faq', '<h2 id="rp-faq-h">Questions</h2>');++ return layout({++ title: null, body: out, active: '/', path: '/', agent: true,++ // The rule that shows the picked topic of the pick list: one per topic, written for this page.++ inlineStyles: [`@supports selector(:has(*)){@media (max-width:640px){${pickListRules('topics', trending.length)}}}`],++ });}function stats({ stats: d, days = 30 } = {}) {nnew file mode 100644@@@ -0,0 +1,109 @@++'use strict';++/**++ * The home on a phone (the owner's short-pages rule, 2026-10-30): one or two screens, app-like.++ *++ * pickBlock() several blocks of the same kind, one at a time on a phone, picked with a row of chips. Radio++ * inputs and :has(), no script: every block stays in the page for crawlers, and a wider screen (or a++ * browser without :has()) shows them all and no chips.++ * pickList() a list of things of the same kind, one at a time on a phone, picked with a row of chips.++ * foldBlock() a part a reader rarely needs, closed on a phone and open where there is room. The page's own markup++ * keeps it open, so a browser without the script shows it as before; the script closes it on a narrow++ * screen and remembers the reader's choice.++ *++ * The CSS for both lives in this site's own stylesheet; the rule that shows the picked block is written for the++ * page, because there is one per block.++ */++const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');++++/**++ * A pick block. The panes are the page's own blocks; each one keeps its heading on a wide screen, and on a phone the++ * heading gives way to the chip that names it.++ *++ * pickBlock({ key, label, head, panes }) panes: [{ id, label, html }]++ */++function pickBlock({ key, label, head = '', panes }) {++ // A pane with nothing to show is left out, so the chips and the rules that show them match the page.++ panes = (panes || []).filter((p) => p.html);++ if (!panes.length) return '';++ const chips = panes.map((p, i) => '<input type="radio" name="pick-' + esc(key) + '" id="pk-' + esc(key) + '-' + esc(p.id) + '" value="' + esc(p.id) + '"' + (i === 0 ? ' checked' : '') + '><label for="pk-' + esc(key) + '-' + esc(p.id) + '">' + esc(p.label) + '</label>').join('');++ const body = panes.map((p) => '<div class="pick-pane" id="' + esc(p.id) + '">' + p.html + '</div>').join('');++ return '<div class="pick" id="pick-' + esc(key) + '"><div class="pick-bar"><div class="pick-head"><h2>' + esc(head || label) + '</h2></div><div class="pick-row" role="radiogroup" aria-label="' + esc(label) + '">' + chips + '</div></div><div class="pick-body">' + body + '</div></div>';++}++++/** The rule that shows the picked block of a pick block: one per block, so the CSS is written for the page. */++function pickRule(key, id) {++ return '.pick:has(#pk-' + key + '-' + id + ':checked) #' + id + '{display:block}';++}++++/** The rules for every block of a pick block, in the order they appear. */++function pickRules(key, ids) {++ return ids.map((id) => pickRule(key, id)).join('');++}++++/**++ * A pick list: a list of things of the same kind, one at a time on a phone, picked with a row of chips. The list++ * keeps its own markup and every item stays in the page; the caller marks each item with the index its chip picks.++ *++ * pickList({ key, label, head, labels, html }) labels: the chip labels, in the order of the items++ */++function pickList({ key, label, head = '', labels, html }) {++ labels = labels || [];++ if (!labels.length) return '';++ const chips = labels.map((l, i) => '<input type="radio" name="pick-' + esc(key) + '" id="pk-' + esc(key) + '-' + i + '" value="' + i + '"' + (i === 0 ? ' checked' : '') + '><label for="pk-' + esc(key) + '-' + i + '">' + esc(l) + '</label>').join('');++ return '<div class="pick" id="pick-' + esc(key) + '"><div class="pick-bar"><div class="pick-head"><h2>' + esc(head || label) + '</h2></div><div class="pick-row" role="radiogroup" aria-label="' + esc(label) + '">' + chips + '</div></div>' + html + '</div>';++}++++/** The rule that shows the picked item of a pick list: one per item, so the CSS is written for the page. */++function pickListRule(key, i) {++ return '.pick#pick-' + key + ':has(#pk-' + key + '-' + i + ':checked) .pick-list>li[data-i="' + i + '"]{display:block}';++}++++/** The rules for every item of a pick list. */++function pickListRules(key, n) {++ return Array.from({ length: n }, (_, i) => pickListRule(key, i)).join('');++}++++/**++ * A fold. The head is the heading the block had; the html is what it holds.++ *++ * foldBlock({ id, head, html })++ */++function foldBlock({ id, head, html }) {++ return '<details class="rp-fold rp-fold-block" id="' + esc(id) + '" open><summary>' + head + '<span class="rp-fold-hint">tap to open</span></summary>' + html + '</details>' + foldScript(id);++}++++/** The script a fold carries: closed on a phone unless the reader opened it before, and it remembers that. */++function foldScript(id) {++ return "<script>(function(){var d=document.getElementById(" + JSON.stringify(id) + ");if(!d)return;var k='ov_fold_'+" + JSON.stringify(id) + ";var s=null;try{s=localStorage.getItem(k)}catch(e){}if(s==='open'){d.open=true;return}if(s==='closed'){d.open=false;return}var t=0;if(window.matchMedia&&window.matchMedia('(max-width:640px)').matches){t=Date.now();d.open=false}d.addEventListener('toggle',function(){if(Date.now()-t<300)return;try{localStorage.setItem(k,d.open?'open':'closed')}catch(e){}})})();</script>";++}++++++/**++ * A part of a page's own markup, found by the heading it carries: the site can then show it in a pick block or a++ * fold. { start, end, html } — the section that holds the needle.++ */++function part(html, needle) {++ const at = html.indexOf(needle);++ if (at < 0) return null;++ const start = html.lastIndexOf('<section', at);++ const close = html.indexOf('</section>', at);++ if (start < 0 || close < 0) return null;++ const end = close + '</section>'.length;++ return { start, end, html: html.slice(start, end) };++}++++/** The same part, but replaced by something else. */++function swap(html, needle, withHtml) {++ const p = part(html, needle);++ if (!p) return html;++ return html.slice(0, p.start) + withHtml + html.slice(p.end);++}++++/** The part that carries this heading, as a fold: closed on a phone, the section it was where there is room. */++function foldAround(html, needle, id, head) {++ const p = part(html, needle);++ if (!p) return html;++ return html.slice(0, p.start) + foldBlock({ id, head, html: p.html }) + html.slice(p.end);++}++++module.exports = { pickBlock, pickRule, pickRules, pickList, pickListRule, pickListRules, foldBlock, foldScript, part, swap, foldAround };nnew file mode 100644@@@ -0,0 +1,46 @@++'use strict';++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++(async () => {++ const t = await boot();++ try {++ await check('the topics are one pick block: a chip per topic, the first picked, every topic in the page', async () => {++ const r = await t.get('/');++ assert.strictEqual(r.status, 200, r.text);++ const page = r.text;++ const start = page.indexOf('<div class="pick" id="pick-topics">');++ assert.ok(start > 0, 'the pick block is on the home');++ const block = page.slice(start, start + 8000);++ assert.match(block, /<div class="pick-row" role="radiogroup" aria-label="What streamers talked about">/);++ const chips = [...block.matchAll(/<input type="radio" name="pick-topics" id="pk-topics-(\d+)" value="\1"( checked)?><label for="pk-topics-\d+">([^<]+)<\/label>/g)];++ assert.ok(chips.length >= 1, 'a chip per topic (' + chips.length + ')');++ assert.deepStrictEqual(chips.filter((m) => m[2]).map((m) => m[1]), [chips[0][1]], 'the first topic is picked');++ const topics = [...block.matchAll(/<li data-i="(\d+)"><a class="rp-topic"/g)];++ 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 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;++ assert.match(page, /<style>@supports selector\(:has\(\*\)\)\{@media \(max-width:640px\)\{\.pick#pick-topics:has\(#pk-topics-0:checked\) \.pick-list>li\[data-i="0"\]\{display:block\}/, 'the rule that shows the picked topic');++ const css = require('fs').readFileSync(require('path').join(__dirname, '..', 'public', 'css', 'app.css'), 'utf8');++ assert.match(css, /\.pick-row \{ display: none; \}/, 'the chips are hidden by default');++ assert.match(css, /@supports selector\(:has\(\*\)\) \{ @media \(max-width: 640px\) \{/, 'the phone rules are under :has() and 640 px');++ assert.match(css, /\.pick-row label \{[^}]*min-height: 44px/, 'a chip is a 44 px target');++ assert.match(css, /\.pick \.pick-list > li \{ display: none; \}/, 'the topics are hidden on a phone');++ });++ await check('the parts a reader rarely needs are folds, open in the page and closed on a phone', async () => {++ const r = await t.get('/');++ const page = r.text;++ for (const id of ['dev', 'faq']) {++ assert.ok(page.includes('<details class="rp-fold rp-fold-block" id="' + id + '" open>'), id + ' is a fold, open in the page');++ assert.ok(page.includes('document.getElementById("' + id + '")'), id + ' has the script that closes it on a phone');++ }++ assert.ok(!page.includes('rp-numbers-h') || page.includes('<details class="rp-fold rp-fold-block" id="numbers" open>'), 'the numbers, when there are any, are a fold too');++ assert.match(page, /<h2 id="rp-dev-h">For developers<\/h2>/);++ assert.match(page, /<h2 id="rp-faq-h">Questions<\/h2>/);++ });++ } finally {++ await done(t);++ }++})().catch((e) => { console.error(e); process.exit(1); });
@@ -197,3 +197,69 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex
@@ -197,3 +197,69 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex
.rp-more::after { content: ''; position: absolute; left: 0; right: 0; top: -14px; bottom: -14px; }
.rp-more::after { content: ''; position: absolute; left: 0; right: 0; top: -14px; bottom: -14px; }
.rp-lead a::after { content: ''; position: absolute; left: 0; right: 0; top: -11px; bottom: -11px; }
.rp-lead a::after { content: ''; position: absolute; left: 0; right: 0; top: -11px; bottom: -11px; }
}
}
+
+/* Pick (the owner's short-pages rule, 2026-10-30): on a phone the front page shows one block at a time, picked with a
+ row of chips. Radio inputs and :has(), no script: the chips and the one-block rule live under 640 px and only
+ where :has() is understood, so a wider screen, or an older browser, shows every block and no chips. */
+.pick-head { display: none; }
+.pick-row { display: none; }
+@supports selector(:has(*)) { @media (max-width: 640px) {
+ .pick-bar { margin-top: 0; }
+ .pick-head { display: block; }
+ .pick-head h2 { margin: 0 0 4px; font: 700 clamp(20px,2.4vw,28px)/1.2 var(--ov-font,'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif); letter-spacing: -.02em; }
+ .pick-row { display: flex; gap: 8px; overflow-x: auto; margin: 10px -16px 0; padding: 2px 16px 4px; scrollbar-width: none; overscroll-behavior-x: contain; }
+ .pick-row::-webkit-scrollbar { display: none; }
+ .pick-row input { position: absolute; opacity: 0; width: 1px; height: 1px; pointer-events: none; }
+ .pick-row label { display: inline-flex; align-items: center; flex: none; min-height: 44px; padding: 0 15px; border-radius: 999px; border: 1px solid var(--border, #1f2d47); color: var(--text-secondary, #96a7c2); font-size: .9rem; font-weight: 650; white-space: nowrap; cursor: pointer; }
+ .pick-row input:checked + label { color: var(--text-primary, #e6edf7); border-color: var(--accent, #3b82f6); background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); }
+ .pick-row input:focus-visible + label { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
+ .pick .pick-pane { display: none; }
+ .pick .pick-list > li { display: none; }
+ /* The chip names the block, so the block's own heading gives way to it; the content stays in the page for the
+ crawlers and for the reader on a wide screen. */
+ .pick .sc-sec > h2, .pick section > h2, .pick > h2 { display: none; }
+} }
+
+/* The fold (the owner's short-pages rule): closed on a phone, and where there is room it looks like the section it
+ replaces: a heading and its content, with no card around it. */
+.rp-fold-block { margin-top: 32px; }
+.rp-fold-block > summary h2 { margin: 0; font: 700 clamp(19px,2.2vw,26px)/1.2 var(--ov-font,'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif); }
+.rp-fold-hint { display: none; }
+/* The fold's summary carries the heading, so the section's own heading gives way to it. */
+.rp-fold-block .sc-sec > h2, .rp-fold-block > .sc-sec > h2 { display: none; }
+@media (min-width: 641px) {
+ .rp-fold-block { border: 0; background: none; border-radius: 0; margin-top: 56px; padding: 0; }
+ .rp-fold-block > summary { display: block; min-height: 0; padding: 0; }
+ .rp-fold-block > summary h2 { margin: 0 0 6px; font: 700 clamp(22px,2.6vw,30px)/1.2 var(--ov-font,'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif); }
+ .rp-fold-block > summary span { display: none; }
+ .rp-fold-block .sc-sec { margin: 0; padding: 0; }
+}
+@media (max-width: 640px) {
+ .rp-fold-block { margin-top: 24px; }
+ .rp-fold-block > summary { cursor: pointer; }
+ .rp-fold-hint { display: inline; margin-left: 8px; font-size: .78rem; font-weight: 600; color: var(--text-secondary, #96a7c2); }
+}
+
+
+/* A link that points into a pick block opens it: the pane carries the id the link names. */
+@supports selector(:has(*)) { @media (max-width: 640px) {
+ .pick .pick-pane:target { display: block; }
+} }
+
+/* On a wide screen the pick block is invisible: its blocks sit where they were, in the order they were. */
+@media (min-width: 641px) {
+ .pick, .pick-body, .pick-pane { display: contents; }
+ .pick-bar { display: none; }
+}
+
+/* The chip names the scene, so the scene's own heading gives way to it. */
+@supports selector(:has(*)) { @media (max-width: 640px) {
+ .pick .sc-scene-text > h2 { display: none; }
+} }
+
+/* The fold's summary carries the heading, so the section's own heading gives way to it. */
+.rp-fold-block .sc-scene-text > h2 { display: none; }
+/* The chip names the topic, so the topics' own heading gives way to it. */
+@supports selector(:has(*)) { @media (max-width: 640px) {
+ .pick .rp-head-row h2 { display: none; }
+} }
@@ -12,6 +12,10 @@ const shell = require('openvibe-shared/shell');
@@ -12,6 +12,10 @@ const shell = require('openvibe-shared/shell');
const ovServe = require('openvibe-shared/serve');
const ovServe = require('openvibe-shared/serve');
const appIcon = require('openvibe-shared/app-icon');
const appIcon = require('openvibe-shared/app-icon');
const kit = require('openvibe-shared/kit');
const kit = require('openvibe-shared/kit');
+// The short home (the owner's rule, 2026-10-30): pick blocks and folds.
+const { pickList, pickListRules, foldAround } = require('./short');
+// A short name for a chip: the title, cut where it stops saying anything.
+const clip = (s, n = 40) => { const x = String(s == null ? '' : s); return x.length > n ? `${x.slice(0, n - 1).trimEnd()}…` : x; };
const sc = require('openvibe-shared/showcase');
const sc = require('openvibe-shared/showcase');
const rooms = require('openvibe-shared/rooms');
const rooms = require('openvibe-shared/rooms');
const { asset } = require('../assets');
const { asset } = require('../assets');
@@ -77,7 +81,7 @@ const NAV = [
@@ -77,7 +81,7 @@ const NAV = [
{ label: 'API', href: '/developers', icon: 'fa-code', more: true },
{ label: 'API', href: '/developers', icon: 'fa-code', more: true },
];
];
-function layout({ title, description, canonical = null, path = null, robots = 'index, follow', body, active = '/', jsonLd = [], agent = false }) {
+function layout({ title, description, canonical = null, path = null, robots = 'index, follow', body, active = '/', jsonLd = [], agent = false, inlineStyles = [] }) {
canonical = canonical || (path ? canon(path) : undefined);
canonical = canonical || (path ? canon(path) : undefined);
const navbar = {
const navbar = {
service: 'report',
service: 'report',
@@ -113,6 +117,7 @@ function layout({ title, description, canonical = null, path = null, robots = 'i
@@ -113,6 +117,7 @@ function layout({ title, description, canonical = null, path = null, robots = 'i
<script src="${esc(ovServe.url('kit-web.js'))}" defer></script>
<script src="${esc(ovServe.url('kit-web.js'))}" defer></script>
<script src="${esc(ovServe.url('kit-data-web.js'))}" defer></script>
<script src="${esc(ovServe.url('kit-data-web.js'))}" defer></script>
<script src="${esc(ovServe.url('motion.js'))}" defer></script>
<script src="${esc(ovServe.url('motion.js'))}" defer></script>
+${(inlineStyles || []).map((css) => `<style>${css}</style>`).join('\n')}
<meta name="ov-boost" content="report@${esc(RELEASE)}">`,
<meta name="ov-boost" content="report@${esc(RELEASE)}">`,
body: `<a class="skip" href="#main">Skip to content</a>
body: `<a class="skip" href="#main">Skip to content</a>
<div id="navbar-mount"></div>
<div id="navbar-mount"></div>
@@ -165,9 +170,9 @@ function takeRow(t) {
@@ -165,9 +170,9 @@ function takeRow(t) {
return `<li class="rp-take"><h3>${sid ? `<a href="/rundown/e/${encodeURIComponent(sid)}">${esc(t.title)}</a>` : esc(t.title)}</h3>${t.what ? `<p>${esc(t.what)}</p>` : ''}`
return `<li class="rp-take"><h3>${sid ? `<a href="/rundown/e/${encodeURIComponent(sid)}">${esc(t.title)}</a>` : esc(t.title)}</h3>${t.what ? `<p>${esc(t.what)}</p>` : ''}`
+ `<p class="rp-meta">${by ? `<a href="/rundown/s/${encodeURIComponent((t.user || (t.stream && t.stream.user) || {}).username || '')}">${esc(by)}</a>` : ''}${t.at || t.started_at ? `<time>${esc(dayTime(t.at || t.started_at))}</time>` : ''}</p></li>`;
+ `<p class="rp-meta">${by ? `<a href="/rundown/s/${encodeURIComponent((t.user || (t.stream && t.stream.user) || {}).username || '')}">${esc(by)}</a>` : ''}${t.at || t.started_at ? `<time>${esc(dayTime(t.at || t.started_at))}</time>` : ''}</p></li>`;
}
}
-function topicCard(t) {
+function topicCard(t, i) {
if (!t || !t.slug) return '';
if (!t || !t.slug) return '';
- return `<li><a class="rp-topic" href="/rundown/t/${encodeURIComponent(t.slug)}"><span class="rp-kind">${esc(KIND[t.kind] || t.kind || 'Topic')}</span><b>${esc(t.title)}</b>${t.headline ? `<span class="rp-topic-h">${esc(t.headline)}</span>` : ''}`
+ return `<li${i == null ? '' : ` data-i="${i}"`}><a class="rp-topic" href="/rundown/t/${encodeURIComponent(t.slug)}"><span class="rp-kind">${esc(KIND[t.kind] || t.kind || 'Topic')}</span><b>${esc(t.title)}</b>${t.headline ? `<span class="rp-topic-h">${esc(t.headline)}</span>` : ''}`
+ `<span class="rp-meta">${[n(t.takes) ? `${exact(t.takes)} takes` : '', n(t.streamers) ? `${exact(t.streamers)} streamer${Number(t.streamers) === 1 ? '' : 's'}` : '', t.last_seen_at ? day(t.last_seen_at) : ''].filter(Boolean).map(esc).join(' · ')}</span></a></li>`;
+ `<span class="rp-meta">${[n(t.takes) ? `${exact(t.takes)} takes` : '', n(t.streamers) ? `${exact(t.streamers)} streamer${Number(t.streamers) === 1 ? '' : 's'}` : '', t.last_seen_at ? day(t.last_seen_at) : ''].filter(Boolean).map(esc).join(' · ')}</span></a></li>`;
}
}
function streamCard(s) {
function streamCard(s) {
@@ -186,7 +191,16 @@ function home({ stats, rundown, topics } = {}) {
@@ -186,7 +191,16 @@ function home({ stats, rundown, topics } = {}) {
const lead = R.lead;
const lead = R.lead;
const leadHTML = lead ? `<section class="rp-lead" aria-labelledby="rp-lead-h"><p class="rp-kicker">The Rundown · lead</p><h2 id="rp-lead-h">${lead.stream_id || (lead.stream && lead.stream.id) ? `<a href="/rundown/e/${encodeURIComponent(lead.stream_id || lead.stream.id)}">${esc(lead.title)}</a>` : esc(lead.title)}</h2>`
const leadHTML = lead ? `<section class="rp-lead" aria-labelledby="rp-lead-h"><p class="rp-kicker">The Rundown · lead</p><h2 id="rp-lead-h">${lead.stream_id || (lead.stream && lead.stream.id) ? `<a href="/rundown/e/${encodeURIComponent(lead.stream_id || lead.stream.id)}">${esc(lead.title)}</a>` : esc(lead.title)}</h2>`
+ `${lead.what ? `<p class="rp-lead-what">${esc(lead.what)}</p>` : ''}${lead.take ? `<p class="rp-lead-take">${esc(lead.take)}</p>` : ''}<p class="rp-meta">${AI}${who(lead.user) ? ` · ${esc(who(lead.user))}` : ''}</p></section>` : '';
+ `${lead.what ? `<p class="rp-lead-what">${esc(lead.what)}</p>` : ''}${lead.take ? `<p class="rp-lead-take">${esc(lead.take)}</p>` : ''}<p class="rp-meta">${AI}${who(lead.user) ? ` · ${esc(who(lead.user))}` : ''}</p></section>` : '';
- const topicsHTML = trending.length ? `<section aria-labelledby="rp-tr-h"><div class="rp-head-row"><h2 id="rp-tr-h">What streamers talked about</h2><a class="rp-more" href="/rundown">The Rundown${kit.icon('chevron')}</a></div><ul class="rp-topics">${trending.map(topicCard).join('')}</ul></section>` : '';
+ const topicsHTML = trending.length ? `<section aria-labelledby="rp-tr-h"><div class="rp-head-row"><h2 id="rp-tr-h">What streamers talked about</h2><a class="rp-more" href="/rundown">The Rundown${kit.icon('chevron')}</a></div><ul class="rp-topics pick-list">${trending.map((t, i) => topicCard(t, i)).join('')}</ul></section>` : '';
+ // The short home (the owner's rule, 2026-10-30): the topics are one pick block, a row of chips picks one of them,
+ // and the parts a reader rarely needs are folds.
+ const topicsBlock = trending.length ? pickList({
+ key: 'topics',
+ label: 'What streamers talked about',
+ head: 'What streamers talked about',
+ labels: trending.map((t) => clip(t.title, 40)),
+ html: topicsHTML,
+ }) : '';
const pv = stats && stats.pageviews_daily;
const pv = stats && stats.pageviews_daily;
const numbersScene = pv && pv.length > 1 ? {
const numbersScene = pv && pv.length > 1 ? {
title: 'Every number says what it counts',
title: 'Every number says what it counts',
@@ -209,7 +223,7 @@ function home({ stats, rundown, topics } = {}) {
@@ -209,7 +223,7 @@ function home({ stats, rundown, topics } = {}) {
{ value: n(R.counts && R.counts.takes), label: 'takes written up' },
{ value: n(R.counts && R.counts.takes), label: 'takes written up' },
].filter((p) => p.value),
].filter((p) => p.value),
rails: streams.length ? [{ id: 'after-show', title: 'After-show reports', more: { href: '/rundown', label: 'The Rundown' }, items: streams }] : [],
rails: streams.length ? [{ id: 'after-show', title: 'After-show reports', more: { href: '/rundown', label: 'The Rundown' }, items: streams }] : [],
- html: leadHTML + topicsHTML,
+ html: leadHTML + topicsBlock,
scenes: [numbersScene].filter(Boolean),
scenes: [numbersScene].filter(Boolean),
explore: { title: 'More on Report', items: [
explore: { title: 'More on Report', items: [
{ title: 'Every site\'s numbers', text: 'Page views, people, streams and how fast each service answers', href: '/stats', icon: 'ov:gauge' },
{ title: 'Every site\'s numbers', text: 'Page views, people, streams and how fast each service answers', href: '/stats', icon: 'ov:gauge' },
@@ -227,7 +241,15 @@ function home({ stats, rundown, topics } = {}) {
@@ -227,7 +241,15 @@ function home({ stats, rundown, topics } = {}) {
['Who writes the after-show reports?', 'An AI, from the stream\'s own numbers, chat and speech, and it says so on every report. It never describes how anyone looks.'],
['Who writes the after-show reports?', 'An AI, from the stream\'s own numbers, chat and speech, and it says so on every report. It never describes how anyone looks.'],
] },
] },
});
});
- return layout({ title: null, body, active: '/', path: '/', agent: true });
+ // The numbers, the code samples and the questions are the parts a reader rarely needs on a phone.
+ let out = foldAround(body, '>Every number says what it counts</h2>', 'numbers', '<h2 id="rp-numbers-h">Every number says what it counts</h2>');
+ out = foldAround(out, '>For developers</h2>', 'dev', '<h2 id="rp-dev-h">For developers</h2>');
+ out = foldAround(out, '>Questions</h2>', 'faq', '<h2 id="rp-faq-h">Questions</h2>');
+ return layout({
+ title: null, body: out, active: '/', path: '/', agent: true,
+ // The rule that shows the picked topic of the pick list: one per topic, written for this page.
+ inlineStyles: [`@supports selector(:has(*)){@media (max-width:640px){${pickListRules('topics', trending.length)}}}`],
+ });
}
}
function stats({ stats: d, days = 30 } = {}) {
function stats({ stats: d, days = 30 } = {}) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,109 @@
@@ -0,0 +1,109 @@
+'use strict';
+/**
+ * The home on a phone (the owner's short-pages rule, 2026-10-30): one or two screens, app-like.
+ *
+ * pickBlock() several blocks of the same kind, one at a time on a phone, picked with a row of chips. Radio
+ * inputs and :has(), no script: every block stays in the page for crawlers, and a wider screen (or a
+ * browser without :has()) shows them all and no chips.
+ * pickList() a list of things of the same kind, one at a time on a phone, picked with a row of chips.
+ * foldBlock() a part a reader rarely needs, closed on a phone and open where there is room. The page's own markup
+ * keeps it open, so a browser without the script shows it as before; the script closes it on a narrow
+ * screen and remembers the reader's choice.
+ *
+ * The CSS for both lives in this site's own stylesheet; the rule that shows the picked block is written for the
+ * page, because there is one per block.
+ */
+const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
+
+/**
+ * A pick block. The panes are the page's own blocks; each one keeps its heading on a wide screen, and on a phone the
+ * heading gives way to the chip that names it.
+ *
+ * pickBlock({ key, label, head, panes }) panes: [{ id, label, html }]
+ */
+function pickBlock({ key, label, head = '', panes }) {
+ // A pane with nothing to show is left out, so the chips and the rules that show them match the page.
+ panes = (panes || []).filter((p) => p.html);
+ if (!panes.length) return '';
+ const chips = panes.map((p, i) => '<input type="radio" name="pick-' + esc(key) + '" id="pk-' + esc(key) + '-' + esc(p.id) + '" value="' + esc(p.id) + '"' + (i === 0 ? ' checked' : '') + '><label for="pk-' + esc(key) + '-' + esc(p.id) + '">' + esc(p.label) + '</label>').join('');
+ const body = panes.map((p) => '<div class="pick-pane" id="' + esc(p.id) + '">' + p.html + '</div>').join('');
+ return '<div class="pick" id="pick-' + esc(key) + '"><div class="pick-bar"><div class="pick-head"><h2>' + esc(head || label) + '</h2></div><div class="pick-row" role="radiogroup" aria-label="' + esc(label) + '">' + chips + '</div></div><div class="pick-body">' + body + '</div></div>';
+}
+
+/** The rule that shows the picked block of a pick block: one per block, so the CSS is written for the page. */
+function pickRule(key, id) {
+ return '.pick:has(#pk-' + key + '-' + id + ':checked) #' + id + '{display:block}';
+}
+
+/** The rules for every block of a pick block, in the order they appear. */
+function pickRules(key, ids) {
+ return ids.map((id) => pickRule(key, id)).join('');
+}
+
+/**
+ * A pick list: a list of things of the same kind, one at a time on a phone, picked with a row of chips. The list
+ * keeps its own markup and every item stays in the page; the caller marks each item with the index its chip picks.
+ *
+ * pickList({ key, label, head, labels, html }) labels: the chip labels, in the order of the items
+ */
+function pickList({ key, label, head = '', labels, html }) {
+ labels = labels || [];
+ if (!labels.length) return '';
+ const chips = labels.map((l, i) => '<input type="radio" name="pick-' + esc(key) + '" id="pk-' + esc(key) + '-' + i + '" value="' + i + '"' + (i === 0 ? ' checked' : '') + '><label for="pk-' + esc(key) + '-' + i + '">' + esc(l) + '</label>').join('');
+ return '<div class="pick" id="pick-' + esc(key) + '"><div class="pick-bar"><div class="pick-head"><h2>' + esc(head || label) + '</h2></div><div class="pick-row" role="radiogroup" aria-label="' + esc(label) + '">' + chips + '</div></div>' + html + '</div>';
+}
+
+/** The rule that shows the picked item of a pick list: one per item, so the CSS is written for the page. */
+function pickListRule(key, i) {
+ return '.pick#pick-' + key + ':has(#pk-' + key + '-' + i + ':checked) .pick-list>li[data-i="' + i + '"]{display:block}';
+}
+
+/** The rules for every item of a pick list. */
+function pickListRules(key, n) {
+ return Array.from({ length: n }, (_, i) => pickListRule(key, i)).join('');
+}
+
+/**
+ * A fold. The head is the heading the block had; the html is what it holds.
+ *
+ * foldBlock({ id, head, html })
+ */
+function foldBlock({ id, head, html }) {
+ return '<details class="rp-fold rp-fold-block" id="' + esc(id) + '" open><summary>' + head + '<span class="rp-fold-hint">tap to open</span></summary>' + html + '</details>' + foldScript(id);
+}
+
+/** The script a fold carries: closed on a phone unless the reader opened it before, and it remembers that. */
+function foldScript(id) {
+ return "<script>(function(){var d=document.getElementById(" + JSON.stringify(id) + ");if(!d)return;var k='ov_fold_'+" + JSON.stringify(id) + ";var s=null;try{s=localStorage.getItem(k)}catch(e){}if(s==='open'){d.open=true;return}if(s==='closed'){d.open=false;return}var t=0;if(window.matchMedia&&window.matchMedia('(max-width:640px)').matches){t=Date.now();d.open=false}d.addEventListener('toggle',function(){if(Date.now()-t<300)return;try{localStorage.setItem(k,d.open?'open':'closed')}catch(e){}})})();</script>";
+}
+
+
+/**
+ * A part of a page's own markup, found by the heading it carries: the site can then show it in a pick block or a
+ * fold. { start, end, html } — the section that holds the needle.
+ */
+function part(html, needle) {
+ const at = html.indexOf(needle);
+ if (at < 0) return null;
+ const start = html.lastIndexOf('<section', at);
+ const close = html.indexOf('</section>', at);
+ if (start < 0 || close < 0) return null;
+ const end = close + '</section>'.length;
+ return { start, end, html: html.slice(start, end) };
+}
+
+/** The same part, but replaced by something else. */
+function swap(html, needle, withHtml) {
+ const p = part(html, needle);
+ if (!p) return html;
+ return html.slice(0, p.start) + withHtml + html.slice(p.end);
+}
+
+/** The part that carries this heading, as a fold: closed on a phone, the section it was where there is room. */
+function foldAround(html, needle, id, head) {
+ const p = part(html, needle);
+ if (!p) return html;
+ return html.slice(0, p.start) + foldBlock({ id, head, html: p.html }) + html.slice(p.end);
+}
+
+module.exports = { pickBlock, pickRule, pickRules, pickList, pickListRule, pickListRules, foldBlock, foldScript, part, swap, foldAround };
new file mode 100644
new file mode 100644
@@ -0,0 +1,46 @@
@@ -0,0 +1,46 @@
+'use strict';
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+(async () => {
+ const t = await boot();
+ try {
+ await check('the topics are one pick block: a chip per topic, the first picked, every topic in the page', async () => {
+ const r = await t.get('/');
+ assert.strictEqual(r.status, 200, r.text);
+ const page = r.text;
+ const start = page.indexOf('<div class="pick" id="pick-topics">');
+ assert.ok(start > 0, 'the pick block is on the home');
+ const block = page.slice(start, start + 8000);
+ assert.match(block, /<div class="pick-row" role="radiogroup" aria-label="What streamers talked about">/);
+ const chips = [...block.matchAll(/<input type="radio" name="pick-topics" id="pk-topics-(\d+)" value="\1"( checked)?><label for="pk-topics-\d+">([^<]+)<\/label>/g)];
+ assert.ok(chips.length >= 1, 'a chip per topic (' + chips.length + ')');
+ assert.deepStrictEqual(chips.filter((m) => m[2]).map((m) => m[1]), [chips[0][1]], 'the first topic is picked');
+ const topics = [...block.matchAll(/<li data-i="(\d+)"><a class="rp-topic"/g)];
+ 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 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;
+ assert.match(page, /<style>@supports selector\(:has\(\*\)\)\{@media \(max-width:640px\)\{\.pick#pick-topics:has\(#pk-topics-0:checked\) \.pick-list>li\[data-i="0"\]\{display:block\}/, 'the rule that shows the picked topic');
+ const css = require('fs').readFileSync(require('path').join(__dirname, '..', 'public', 'css', 'app.css'), 'utf8');
+ assert.match(css, /\.pick-row \{ display: none; \}/, 'the chips are hidden by default');
+ assert.match(css, /@supports selector\(:has\(\*\)\) \{ @media \(max-width: 640px\) \{/, 'the phone rules are under :has() and 640 px');
+ assert.match(css, /\.pick-row label \{[^}]*min-height: 44px/, 'a chip is a 44 px target');
+ assert.match(css, /\.pick \.pick-list > li \{ display: none; \}/, 'the topics are hidden on a phone');
+ });
+ await check('the parts a reader rarely needs are folds, open in the page and closed on a phone', async () => {
+ const r = await t.get('/');
+ const page = r.text;
+ for (const id of ['dev', 'faq']) {
+ assert.ok(page.includes('<details class="rp-fold rp-fold-block" id="' + id + '" open>'), id + ' is a fold, open in the page');
+ assert.ok(page.includes('document.getElementById("' + id + '")'), id + ' has the script that closes it on a phone');
+ }
+ assert.ok(!page.includes('rp-numbers-h') || page.includes('<details class="rp-fold rp-fold-block" id="numbers" open>'), 'the numbers, when there are any, are a fold too');
+ assert.match(page, /<h2 id="rp-dev-h">For developers<\/h2>/);
+ assert.match(page, /<h2 id="rp-faq-h">Questions<\/h2>/);
+ });
+ } finally {
+ await done(t);
+ }
+})().catch((e) => { console.error(e); process.exit(1); });