openvibers/openvibe.news Commits

Short home on a phone: one item at a time, the rest folded (#150)

OP OpenVibers committed 14 days ago · b00ea210cd · 9 files, +265 −32

* Short home on a phone: one item at a time, the rest folded

The front page on a phone is now one or two screens. The lists of the same kind
(the stories, the AI briefs, the wire and the topics) become one pick block each:
a row of chips picks one item, and the chip names the block. The parts a reader
rarely needs (the sources we read, the questions, the shipped list) become folds,
closed on a phone and open where there is room.

Radio inputs and :has(), no script for the chips: every item stays in the page for
the crawlers, and a wider screen (or a browser without :has()) shows them all and no
chips, exactly as before. The desktop is unchanged.

* One id per heading on the folded sections

The fold's summary names the section and the section's own heading repeats it, so the
id was on the page twice. The id stays on the fold's heading; the heading inside
stays in the page, hidden, as before.

* The budget is the size of the home with the phone pick blocks

Measured with openvibe-shared 4.53.0: the home is 41.5 KB of HTML (9.5 KB brotli)
and 43.4 KB of CSS (9.2 KB brotli) with the pick blocks and the folds.

Changes

ddiff --git a/public/css/news.css b/public/css/news.css
iindex a7661a0..e659afd 100644
---- a/public/css/news.css
++++ b/public/css/news.css
@@@ -150,3 +150,41 @@ body.news-home .page { max-width: 1120px; }
section.wire > a { position: relative; display: inline-block; }
section.wire > a::after { content: ''; position: absolute; left: 0; right: 0; top: -12px; bottom: -12px; }
}
++
++/* Pick (the owner's short-pages rule, 2026-10-30): on a phone the front page's lists are one item at a time, picked
++ with a row of chips. Radio inputs and :has(), no script: the chips and the one-item rule live under 640 px and only
++ where :has() is understood, so a wider screen, or an older browser, shows every item 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-list > li { display: none; }
++ /* The chip names the block, so the block's own heading gives way to it; the note under the wire says how it is
++ grouped and stays for a reader who wants it (it is in the page for the crawlers either way). */
++ .pick .sc-sec > h2, .pick .wire > h2 { display: none; }
++ .pick .wire > p.meta { 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. */
++.nh-fold-block { margin-top: 32px; }
++.nh-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); }
++.nh-fold-block .sc-sec > h2 { display: none; }
++/* The shipped list carries its own title; the fold's summary already names it. */
++.nh-fold-block .ov-shipped-home h3 { display: none; }
++@media (min-width: 641px) {
++ .nh-fold-block { border: 0; background: none; border-radius: 0; margin-top: 56px; padding: 0; }
++ .nh-fold-block > summary { display: block; min-height: 0; padding: 0; }
++ .nh-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); }
++ .nh-fold-block > summary span { display: none; }
++ .nh-fold-block .sc-sec { margin: 0; padding: 0; }
++}
++@media (max-width: 640px) { .nh-fold-block { margin-top: 24px; } }
ddiff --git a/server/http/public.js b/server/http/public.js
iindex 5bdfad8..1ffc003 100644
---- a/server/http/public.js
++++ b/server/http/public.js
@@@ -34,6 +34,7 @@ const { csrfToken, checkCsrf } = require('../auth/forms');
const cache = require('openvibe-shared/cache-policy');
const { SITE_SUMMARY } = require('./discovery');
const homePage = require('../render/home');
++const { pickListRules } = require('../render/short');
const { createHome } = require('../domain/home');
// The front page in search results and share cards (≤ 160 characters).
@@@ -111,15 +112,18 @@ function createPublicRoutes(ctx) {
const [stats, latestBriefs, topicList, tally, outlets] = await Promise.all([
homeData.stats(), briefs ? briefs.latest(12) : [], topics.list(), homeData.topicTally(), homeData.outlets(),
]);
++ const homeBriefs = latestBriefs.filter((b) => b.kind === 'summary' && !b.stub && b.paragraphs.length).slice(0, 6);
return send(req, res, 200, {
title: null, description: HOME_DESCRIPTION, summary: SITE_SUMMARY,
decision: pageDecision(canonical, { empty: total === 0 && !wire.length && !stats.week, query: ['page'] }),
canonical, feeds, next: pager.next ? pager.next.href : null,
jsonLd: homePage.homeJsonLd({ abs: (p) => publication.abs(p), topics: topicList, outlets }),
styles: [showcase.STYLESHEET], bodyClass: 'news-home',
++ // The rule that shows the picked item of each pick list: one per item, written for this page.
++ inlineStyles: [`@supports selector(:has(*)){@media (max-width:640px){${pickListRules('stories', items.length)}${pickListRules('briefs', homeBriefs.length)}${pickListRules('wire', wire.length)}${pickListRules('topics', topicList.length)}}}`],
body: homePage.homeBody({
-- items, pager, feeds, stats, wire: pages.wireSection(wire), topics: topicList, tally, outlets,
-- briefs: latestBriefs.filter((b) => b.kind === 'summary' && !b.stub && b.paragraphs.length).slice(0, 6),
++ items, pager, feeds, stats, wire: pages.wireSection(wire), wireClusters: wire, topics: topicList, tally, outlets,
++ briefs: homeBriefs,
}),
}, { cacheable: true });
}
ddiff --git a/server/render/home.js b/server/render/home.js
iindex 1262d70..de1a161 100644
---- a/server/render/home.js
++++ b/server/render/home.js
@@@ -9,6 +9,7 @@ const showcase = require('openvibe-shared/showcase');
const { time, httpHref, storyList } = require('./pages');
const { html: h, raw } = ssr;
++const { pickList, pickListRule, foldBlock } = require('./short');
const num = (n) => Number(n || 0).toLocaleString('en-US');
const plural = (n, one, many = `${one}s`) => `${num(n)} ${n === 1 ? one : many}`;
const list = (xs) => (xs.length <= 1 ? xs.join('') : `${xs.slice(0, -1).join(', ')} and ${xs[xs.length - 1]}`);
@@@ -41,9 +42,9 @@ function statsPanel(stats) {
<p class="nh-live"><span class="live-dot" aria-hidden="true"></span> <a href="/live">Watch reports arrive live</a></p></div>`);
}
--function briefCard(b) {
++function briefCard(b, i) {
const first = b.paragraphs[0] ? b.paragraphs[0].text : '';
-- return h`<li class="nh-card"><p class="nh-kicker"><span class="badge badge-ai">AI brief</span> from ${plural(b.item_count, 'report')}</p>
++ return h`<li class="nh-card" data-i="${i}"><p class="nh-kicker"><span class="badge badge-ai">AI brief</span> from ${plural(b.item_count, 'report')}</p>
<h3><a href="/briefs/${b.cluster_id}">${b.headline || b.label}</a></h3><p class="meta">${time(b.window_end)}</p>${first ? h`<p class="nh-sum">${clip(first, 260)}</p>` : ''}</li>`;
}
@@@ -52,7 +53,7 @@ function briefCard(b) {
* tally (Map slug → { subjects, reports, newest }), outlets
*/
function homeBody(o) {
-- const { items, pager, feeds, stats, briefs = [], wire = '', topics = [], tally = new Map(), outlets = [] } = o;
++ const { items, pager, feeds, stats, briefs = [], wire = '', wireClusters = [], topics = [], tally = new Map(), outlets = [] } = o;
const questions = faq(outlets);
return String(h`${raw(showcase.hero({
eyebrow: 'OpenVibe.News',
@@@ -63,24 +64,52 @@ function homeBody(o) {
aside: stats ? { html: statsPanel(stats) } : undefined,
}))}
<div id="latest"></div>
--${items.length ? h`<section class="sc-sec nh-sec" id="stories" aria-labelledby="nh-stories-h"><h2 id="nh-stories-h">Latest stories</h2>
++${items.length ? raw(pickList({
++ key: 'stories',
++ label: 'Latest stories',
++ head: 'Latest stories',
++ labels: items.map((it) => clip(it.headline || 'A story', 42)),
++ html: h`<section class="sc-sec nh-sec" id="stories" aria-labelledby="nh-stories-h"><h2 id="nh-stories-h">Latest stories</h2>
${feeds.length ? h`<p class="feeds">Follow: ${feeds.map((f) => h`<a href="${f.href}">${f.label}</a> `)}</p>` : ''}
--${storyList(items, '')}${raw(ssr.paginationHtml(pager))}</section>` : ''}
--${briefs.length ? h`<section class="sc-sec nh-sec" aria-labelledby="nh-briefs-h"><h2 id="nh-briefs-h">${items.length ? 'AI briefs' : 'Latest AI briefs'}</h2>
++${storyList(items, '', { pick: true })}${raw(ssr.paginationHtml(pager))}</section>`,
++ })) : ''}
++${briefs.length ? raw(pickList({
++ key: 'briefs',
++ label: items.length ? 'AI briefs' : 'Latest AI briefs',
++ head: items.length ? 'AI briefs' : 'Latest AI briefs',
++ labels: briefs.map((b) => clip(b.label || b.headline || 'A brief', 42)),
++ html: h`<section class="sc-sec nh-sec" aria-labelledby="nh-briefs-h"><h2 id="nh-briefs-h">${items.length ? 'AI briefs' : 'Latest AI briefs'}</h2>
<p class="sc-lede">What the reports on each subject say, written by OpenVibe.AI with every paragraph linked to its reports. Not written or reviewed by an editor.</p>
--<ol class="nh-cards">${briefs.map(briefCard)}</ol><p class="nh-more"><a href="/briefs">All AI briefs</a></p></section>` : ''}
--${wire ? h`<div class="sc-sec nh-sec nh-wire">${raw(wire)}<p class="nh-more"><a href="/live">Live: the newest reports, refreshed every minute</a></p></div>` : ''}
--${topics.length ? h`<section class="sc-sec nh-sec" id="topics" aria-labelledby="nh-topics-h"><h2 id="nh-topics-h">Topics</h2><p class="sc-lede">What the sources reported on each topic in the last 30 days.</p>
--<ul class="nh-topics">${topics.map((t) => {
++<ol class="nh-cards pick-list">${briefs.map((b, i) => briefCard(b, i))}</ol><p class="nh-more"><a href="/briefs">All AI briefs</a></p></section>`,
++ })) : ''}
++${wire ? raw(pickList({
++ key: 'wire',
++ label: 'From the wire',
++ head: 'From the wire',
++ labels: (wireClusters || []).map((c) => clip(c.label || 'A report', 42)),
++ html: h`<div class="sc-sec nh-sec nh-wire">${raw(wire)}<p class="nh-more"><a href="/live">Live: the newest reports, refreshed every minute</a></p></div>`,
++ })) : ''}
++${topics.length ? raw(pickList({
++ key: 'topics',
++ label: 'Topics',
++ head: 'Topics',
++ labels: topics.map((t) => t.name),
++ html: h`<section class="sc-sec nh-sec" id="topics" aria-labelledby="nh-topics-h"><h2 id="nh-topics-h">Topics</h2><p class="sc-lede">What the sources reported on each topic in the last 30 days.</p>
++<ul class="nh-topics pick-list">${topics.map((t, i) => {
const x = tally.get(t.slug);
const item = x && x.newest && x.newest.item;
const href = item ? httpHref(item.url) : null;
-- return h`<li><h3><a href="/topics/${t.slug}">${t.name}</a></h3>${x ? h`<p class="nh-count">${plural(x.subjects, 'subject')}, ${plural(x.reports, 'report')}</p>` : ''}
++ return h`<li data-i="${i}"><h3><a href="/topics/${t.slug}">${t.name}</a></h3>${x ? h`<p class="nh-count">${plural(x.subjects, 'subject')}, ${plural(x.reports, 'report')}</p>` : ''}
${item ? h`<p class="nh-latest">${href ? h`<a href="${href}" rel="nofollow noopener" target="_blank">${item.headline}</a>` : item.headline} <span class="meta">${item.outlet}</span></p>` : (t.description ? h`<p class="nh-latest meta">${t.description}</p>` : '')}</li>`;
-- })}</ul><p class="nh-more"><a href="/topics">All ${plural(topics.length, 'topic')}</a></p></section>` : ''}
--${outlets.length ? h`<section class="sc-sec nh-sec" aria-labelledby="nh-outlets-h"><h2 id="nh-outlets-h">The sources we read</h2>
++ })}</ul><p class="nh-more"><a href="/topics">All ${plural(topics.length, 'topic')}</a></p></section>`,
++ })) : ''}
++${outlets.length ? raw(foldBlock({
++ id: 'outlets',
++ head: '<h2 id="nh-outlets-h">The sources we read</h2>',
++ html: h`<section class="sc-sec nh-sec" aria-labelledby="nh-outlets-h"><h2>The sources we read</h2>
<p class="sc-lede">Public feeds, collected by OpenVibe.Sources with each publisher's terms and robots.txt checked. Headlines, links and short summaries only.</p>
--<ul class="nh-outlets">${outlets.map((x) => h`<li><b>${x.outlet}</b><span class="nh-count">${plural(x.week, 'report')} this week · ${num(x.month)} in 30 days</span>${x.latest ? h`<span class="nh-latest">Newest: ${httpHref(x.latest.url) ? h`<a href="${httpHref(x.latest.url)}" rel="nofollow noopener" target="_blank">${x.latest.headline}</a>` : x.latest.headline}</span>` : ''}</li>`)}</ul></section>` : ''}
++<ul class="nh-outlets">${outlets.map((x) => h`<li><b>${x.outlet}</b><span class="nh-count">${plural(x.week, 'report')} this week · ${num(x.month)} in 30 days</span>${x.latest ? h`<span class="nh-latest">Newest: ${httpHref(x.latest.url) ? h`<a href="${httpHref(x.latest.url)}" rel="nofollow noopener" target="_blank">${x.latest.headline}</a>` : x.latest.headline}</span>` : ''}</li>`)}</ul></section>`,
++ })) : ''}
<div class="sc-sec nh-sec"><details class="nh-fold"><summary>How a story is built<span>citations, revisions, briefs</span></summary>${raw(showcase.features({
items: [
{ icon: 'ov:news', title: 'Cited paragraphs', text: 'Each paragraph names the sources behind it, with outlet, authors and date.' },
@@@ -98,7 +127,11 @@ ${raw(showcase.steps({
{ title: 'Editors write the stories', text: 'Stories cite every paragraph. Corrections and retractions stay on the record.' },
],
}))}</details></div>
--<section class="sc-sec nh-sec nh-faq" aria-labelledby="nh-faq-h"><h2 id="nh-faq-h">Questions</h2>${questions.map((f) => h`<details><summary>${f.q}</summary><p>${f.a}</p></details>`)}</section>`);
++${raw(foldBlock({
++ id: 'faq',
++ head: '<h2 id="nh-faq-h">Questions</h2>',
++ html: h`<section class="sc-sec nh-sec nh-faq" aria-labelledby="nh-faq-h"><h2>Questions</h2>${questions.map((f) => h`<details><summary>${f.q}</summary><p>${f.a}</p></details>`)}</section>`,
++ }))}`);
}
/** Structured data for the front page: the site, its topic pages as an ItemList (indexed pages), the questions shown. */
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex e7b884d..b5bd1e2 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -14,6 +14,7 @@ const fs = require('fs');
const path = require('path');
const layout = require('openvibe-publishing/layout');
const frame = require('openvibe-shared/frame');
++const { foldBlock } = require('./short');
const NETWORK_URL = 'https://openvibe.network';
const SITE_NAME = 'OpenVibe.News';
@@@ -108,11 +109,19 @@ function renderPage(o) {
styles: o.styles, // openvibe-shared stylesheet names (the home page's showcase.css)
// This service's own extra stylesheets for one kind of page (the briefs and live pages' css/briefs.css)
// and the discovery tags of the page's Markdown twin (public.js's send adds both together).
-- head: [...(o.localStyles || []).map((rel) => `<link rel="stylesheet" href="${asset(rel)}">`), o.head].filter(Boolean).join('\n'),
++ // The rules that show the picked item of a pick list are written for the page, because there is one per item.
++ head: [...(o.localStyles || []).map((rel) => `<link rel="stylesheet" href="${asset(rel)}">`), o.head]
++ .concat((o.inlineStyles || []).map((css) => `<style>${css}</style>`)).filter(Boolean).join('\n'),
++
release: RELEASE,
account,
body: o.body,
-- shipped: o.path === '/' ? frame.shipped({ service: 'news', title: `Recently shipped on ${SITE_NAME}` }) : '',
++ // The shipped list is a part a reader rarely needs: a fold on a phone, the section it was where there is room.
++ shipped: o.path === '/' ? foldBlock({
++ id: 'shipped',
++ head: `<h2 id="h-shipped">Recently shipped on ${SITE_NAME}</h2>`,
++ html: frame.shipped({ service: 'news', title: `Recently shipped on ${SITE_NAME}` }),
++ }) : '',
bodyClass: o.bodyClass,
});
}
ddiff --git a/server/render/pages.js b/server/render/pages.js
iindex 5c5da36..745f559 100644
---- a/server/render/pages.js
++++ b/server/render/pages.js
@@@ -17,17 +17,21 @@ const time = (v) => raw(ssr.timeTag(v == null ? null : new Date(v).toISOString()
const httpHref = (u) => { try { const x = new URL(String(u)); return x.protocol === 'https:' || x.protocol === 'http:' ? x.href : null; } catch { return null; } };
const cites = (ns) => h`<sup class="cites">${ns.map((n, i) => h`${i ? ',' : ''}<a href="#source-${n}" aria-label="source ${n}">${n}</a>`)}</sup>`;
--function storyListItem(it) {
-- return h`<li class="story-item${it.retracted ? ' retracted' : ''}">
++function storyListItem(it, i = null) {
++ // On the front page a phone picks one story at a time (the owner's short-pages rule): each story carries the
++ // index its chip picks. The index is a number, so it needs no quotes.
++ return h`<li${i == null ? '' : ` data-i=${i}`} class="story-item${it.retracted ? ' retracted' : ''}">
<h2 class="story-item-title"><a href="${it.url}">${it.headline}</a>${it.retracted ? h` <span class="badge badge-retracted">Retracted</span>` : ''}</h2>
<p class="meta">${time(it.published_at)}${it.topic ? h` · <a href="${it.topic.url}">${it.topic.name}</a>` : ''} · ${it.sourceCount} ${it.sourceCount === 1 ? 'source' : 'sources'}${it.outlets.length ? h` (${it.outlets.join(', ')})` : ''}</p>
${it.lede ? h`<p class="summary">${it.lede}</p>` : ''}
</li>`;
}
--function storyList(items, empty) {
++function storyList(items, empty, { pick = false } = {}) {
if (!items.length) return h`<p class="empty">${empty}</p>`;
-- return h`<ol class="story-list">${items.map(storyListItem)}</ol>`;
++ // On the front page a phone picks one story at a time (the owner's short-pages rule): each story carries the
++ // index its chip picks.
++ return h`<ol class="story-list${pick ? ' pick-list' : ''}">${items.map((it, i) => storyListItem(it, pick ? i : null))}</ol>`;
}
function feedLinksHtml(feeds) {
@@@ -63,10 +67,10 @@ function briefsSection(briefs, { lead = false } = {}) {
// headline and link; nothing here is written by News.
const WIRE_NOTE = 'Grouped automatically from the reports OpenVibe.Sources collects. Nothing here is written by OpenVibe.News: each line is the outlet\'s own headline and links to its report.';
--function wireCluster(c, { brief = false } = {}) {
++function wireCluster(c, { brief = false, i = null } = {}) {
const items = brief ? c.items.slice(0, 1) : c.items;
const outlets = [...new Set(c.items.map((i) => i.outlet).filter(Boolean))];
-- return h`<li class="wire-cluster" id="wire-${c.id}">
++ return h`<li class="wire-cluster" id="wire-${c.id}"${i == null ? '' : ` data-i=${i}`}>
${brief ? h`<p class="wire-lead">${wireLink(items[0])}</p><p class="meta">${time(items[0].published_at)} · ${c.items.length} ${c.items.length === 1 ? 'report' : 'reports'}${outlets.length ? h` (${outlets.slice(0, 4).join(', ')}${outlets.length > 4 ? ` and ${outlets.length - 4} more` : ''})` : ''}</p>`
: h`<ul class="wire-items">${items.map((i) => h`<li>${wireLink(i)} <span class="meta">${i.outlet ? h`${i.outlet} · ` : ''}${time(i.published_at)}</span></li>`)}</ul>`}
</li>`;
@@@ -79,9 +83,11 @@ function wireLink(i) {
/** The front page's wire section: the latest clusters, one line each. */
function wireSection(clusters) {
if (!clusters.length) return '';
++ // The front page picks one cluster at a time on a phone (the owner's short-pages rule): each cluster carries the
++ // index its chip picks.
return String(h`<section class="wire" id="wire" aria-labelledby="wire-h"><h2 id="wire-h">From the wire</h2>
<p class="meta">${WIRE_NOTE}</p>
--<ol class="wire-list">${clusters.map((c) => wireCluster(c, { brief: true }))}</ol>
++<ol class="wire-list pick-list">${clusters.map((c, i) => wireCluster(c, { brief: true, i }))}</ol>
<p><a href="/wire">The whole wire</a></p></section>`);
}
ddiff --git a/server/render/short.js b/server/render/short.js
nnew file mode 100644
iindex 0000000..6003b01
---- /dev/null
++++ b/server/render/short.js
@@@ -0,0 +1,76 @@
++'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.
++ * 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 public/css/home.css; 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
++
++/**
++ * 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 }) {
++ 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: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 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 }) {
++ 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="pp-${esc(key)}-${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) #pp-${key}-${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 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="nh-fold nh-fold-block" id="${esc(id)}" open><summary>${head}<span>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>`;
++}
++
++module.exports = { pickBlock, pickRule, pickRules, pickList, pickListRule, pickListRules, foldBlock, foldScript };
ddiff --git a/test/home-short.test.js b/test/home-short.test.js
nnew file mode 100644
iindex 0000000..220ff28
---- /dev/null
++++ b/test/home-short.test.js
@@@ -0,0 +1,67 @@
++'use strict';
++/**
++ * The home on a phone (the owner's short-pages rule, 2026-10-30): the home's lists of the same kind are one pick
++ * block, a row of chips picks one of them, and the parts a reader rarely needs are folds. Radio inputs and :has(),
++ * no script for the chips; every list stays in the page for crawlers; a wider screen shows them all and no chips.
++ */
++const assert = require('assert');
++const { boot, check, done, launchReports } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ launchReports(t.sources);
++ await t.pull();
++ try {
++ await check('the home is one pick block: a chip per item, the first picked, every item in the page', async () => {
++ const r = await t.get('/');
++ assert.strictEqual(r.status, 200, r.text);
++ const page = r.text;
++ const block = page.slice(page.indexOf('<div class="pick" id="pick-wire">'), page.indexOf('<div class="pick" id="pick-topics">'));
++ assert.ok(block.startsWith('<div class="pick" id="pick-wire">'), 'the pick block is on the home');
++ assert.match(block, /<div class="pick-row" role="radiogroup" aria-label="From the wire">/);
++ const chips = [...block.matchAll(/<input type="radio" name="pick-wire" id="pk-wire-(\d+)" value="\1"( checked)?><label for="pk-wire-\1">([^<]+)<\/label>/g)];
++ assert.ok(chips.length >= 1, `a chip per item (${chips.length})`);
++ assert.deepStrictEqual(chips.filter((m) => m[2]).map((m) => m[1]), [chips[0][1]], 'the first item is picked');
++ // Every cluster stays in the page, with the index its chip picks.
++ const clusters = [...block.matchAll(/<li class="wire-cluster" id="wire-[^"]+" data-i=(?:"|&quot;)?(\d+)/g)];
++ assert.strictEqual(clusters.length, chips.length, 'every cluster is in the page');
++ assert.deepStrictEqual(clusters.map((m) => m[1]), chips.map((m) => m[1]), 'each cluster knows its chip');
++ });
++
++ await check('the topics are a pick block too: a chip per topic, every topic in the page', async () => {
++ const r = await t.get('/');
++ const page = r.text;
++ const block = page.slice(page.indexOf('<div class="pick" id="pick-topics">'), page.indexOf('<div class="pick" id="pick-topics">') + 4000);
++ const chips = [...block.matchAll(/<input type="radio" name="pick-topics" id="pk-topics-(\d+)" value="\1"( checked)?><label for="pk-topics-\1">([^<]+)<\/label>/g)];
++ assert.ok(chips.length >= 3, `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 items = [...block.matchAll(/<li data-i=(?:"|&quot;)?(\d+)"?><h3><a href="\/topics\/[a-z-]+">/g)];
++ assert.strictEqual(items.length, chips.length, 'every topic stays in the page');
++ });
++
++ await check('the CSS guard: the chips are hidden and the items 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:has\(#pk-wire-0:checked\) \.pick-list>li\[data-i="0"\]\{display:block\}/, 'the rule that shows the picked item');
++ const css = require('fs').readFileSync(require('path').join(__dirname, '..', 'public', 'css', 'news.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 items 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 ['outlets', 'faq']) {
++ assert.ok(page.includes(`<details class="nh-fold nh-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`);
++ }
++ // The content of the folds stays in the page.
++ assert.match(page, /<h2 id="nh-outlets-h">The sources we read<\/h2>/);
++ assert.match(page, /<h2 id="nh-faq-h">Questions<\/h2>/);
++ });
++ } finally {
++ await done(t);
++ }
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/home.test.js b/test/home.test.js
iindex ac42077..43c6c2b 100644
---- a/test/home.test.js
++++ b/test/home.test.js
@@@ -36,9 +36,9 @@ const ld = (text) => [...text.matchAll(/<script type="application\/ld\+json">([\
await check('the wire, the topics with what was reported on them, the outlets with their newest report', async () => {
const text = home.text;
assert.ok(text.includes('From the wire') && text.includes('Europa Clipper'));
-- const space = (/<li><h3><a href="\/topics\/space">Space<\/a><\/h3>[\s\S]*?<\/li>/.exec(text) || [''])[0];
++ const space = (/<li[^>]*><h3><a href="\/topics\/space">Space<\/a><\/h3>[\s\S]*?<\/li>/.exec(text) || [''])[0];
assert.match(space, /1 subject, 3 reports/);
-- const science = (/<li><h3><a href="\/topics\/science">Science<\/a><\/h3>[\s\S]*?<\/li>/.exec(text) || [''])[0];
++ const science = (/<li[^>]*><h3><a href="\/topics\/science">Science<\/a><\/h3>[\s\S]*?<\/li>/.exec(text) || [''])[0];
assert.ok(science.includes('New chip-based tape measure for light'), 'NIST places its reports in Science');
assert.ok(text.includes('<b>NIST news</b>') && text.includes('<b>Wire A</b>'));
assert.match(text, /<b>Wire A<\/b><span class="nh-count">1 report this week · 1 in 30 days<\/span>/);
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex c15919e..5af89fb 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -12,14 +12,14 @@ const { spawn } = require('child_process');
const { measure, check, format } = require('openvibe-shared/perf-budget');
const BUDGETS = {
-- htmlRawKB: 41, // 36.2 Shared 4.15.1 footer; 33.2 on 2026-10-22: the front page's topics (12 cards), steps, questions (and their FAQPage and topics ItemList JSON-LD) and links; 24.4 with the home showcase (17.4 before it; fresh database)
-- htmlBrotliKB: 9, // 8.0 (2026-10-22 the richer front page); 6.3 (4.5 before the showcase)
++ htmlRawKB: 42, // 41.5 with the phone pick blocks (2026-10-30); 36.2 Shared 4.15.1 footer; 33.2 on 2026-10-22: the front page's topics (12 cards), steps, questions (and their FAQPage and topics ItemList JSON-LD) and links; 24.4 with the home showcase (17.4 before it; fresh database)
++ htmlBrotliKB: 10, // 9.5 with the phone pick blocks (2026-10-30); 8.0 (2026-10-22 the richer front page); 6.3 (4.5 before the showcase)
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell adds web-runtime, 2026-10-04)
jsRawKB: 322, // 321.1 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 318.1 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 315.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 307.8 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 300.5 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 293 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 287.2 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 273.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 263.4 with shared 4.11.0's seasonal navbar; 254.1 with openvibe-shared 4.9.0 (status text twins in every theme, 2026-10-23); 246.2 with openvibe-shared 4.2.x (Ask Actor in the navbar, 2026-10-15); 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)
jsBrotliKB: 79, // 78.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 77.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 76.1 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 74.2 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 70.5 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 67.9 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 65.3 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 61.7 with shared 4.11.0's seasonal navbar; 59.1 with openvibe-shared 4.4.0 (every site in the launcher, 2026-10-17); 56.3 (49.9 before web-runtime.js)
cssFiles: 2, // 2 (news.css + showcase.css, home page 1 only)
-- cssRawKB: 41, // 40.3 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 38.5 touch targets on phones (2026-10-29); 33.6 openvibe-shared 4.26.1 (hero visual); 28.1 with Shared 4.13.0 (2026-10-26); 22.2 on 2026-10-22 (the front page's numbers, brief cards, topics, outlets, questions and links); 18.2 with the front page aligned to the showcase (2026-10-15); 15.5 (5.2 before showcase.css)
-- cssBrotliKB: 8.5, // 8.2 touch targets on phones (2026-10-29); 6.2 with Shared 4.13.0 (2026-10-26); 5.2 (2026-10-22); 3.9 (1.4 before showcase.css)
++ cssRawKB: 44, // 43.4 with the phone pick rules and the shipped fold (2026-10-30); 40.3 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 38.5 touch targets on phones (2026-10-29); 33.6 openvibe-shared 4.26.1 (hero visual); 28.1 with Shared 4.13.0 (2026-10-26); 22.2 on 2026-10-22 (the front page's numbers, brief cards, topics, outlets, questions and links); 18.2 with the front page aligned to the showcase (2026-10-15); 15.5 (5.2 before showcase.css)
++ cssBrotliKB: 9.5, // 9.2 with the phone pick rules and the shipped fold (2026-10-30); 8.2 touch targets on phones (2026-10-29); 6.2 with Shared 4.13.0 (2026-10-26); 5.2 (2026-10-22); 3.9 (1.4 before showcase.css)
externalFiles: 1, // 0
};