openvibers/openvibe.news Commits
Short home on a phone: one item at a time, the rest folded (#150)
Changes
@@@ -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; } }@@@ -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 });}@@@ -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. */@@@ -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,});}@@@ -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>`);}nnew file mode 100644@@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');++++/**++ * 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 };nnew file mode 100644@@@ -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=(?:"|")?(\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=(?:"|")?(\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); });@@@ -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>/);@@@ -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};
@@ -150,3 +150,41 @@ body.news-home .page { max-width: 1120px; }
@@ -150,3 +150,41 @@ body.news-home .page { max-width: 1120px; }
section.wire > a { position: relative; display: inline-block; }
section.wire > a { position: relative; display: inline-block; }
section.wire > a::after { content: ''; position: absolute; left: 0; right: 0; top: -12px; bottom: -12px; }
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; } }
@@ -34,6 +34,7 @@ const { csrfToken, checkCsrf } = require('../auth/forms');
@@ -34,6 +34,7 @@ const { csrfToken, checkCsrf } = require('../auth/forms');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
const { SITE_SUMMARY } = require('./discovery');
const { SITE_SUMMARY } = require('./discovery');
const homePage = require('../render/home');
const homePage = require('../render/home');
+const { pickListRules } = require('../render/short');
const { createHome } = require('../domain/home');
const { createHome } = require('../domain/home');
// The front page in search results and share cards (≤ 160 characters).
// The front page in search results and share cards (≤ 160 characters).
@@ -111,15 +112,18 @@ function createPublicRoutes(ctx) {
@@ -111,15 +112,18 @@ function createPublicRoutes(ctx) {
const [stats, latestBriefs, topicList, tally, outlets] = await Promise.all([
const [stats, latestBriefs, topicList, tally, outlets] = await Promise.all([
homeData.stats(), briefs ? briefs.latest(12) : [], topics.list(), homeData.topicTally(), homeData.outlets(),
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, {
return send(req, res, 200, {
title: null, description: HOME_DESCRIPTION, summary: SITE_SUMMARY,
title: null, description: HOME_DESCRIPTION, summary: SITE_SUMMARY,
decision: pageDecision(canonical, { empty: total === 0 && !wire.length && !stats.week, query: ['page'] }),
decision: pageDecision(canonical, { empty: total === 0 && !wire.length && !stats.week, query: ['page'] }),
canonical, feeds, next: pager.next ? pager.next.href : null,
canonical, feeds, next: pager.next ? pager.next.href : null,
jsonLd: homePage.homeJsonLd({ abs: (p) => publication.abs(p), topics: topicList, outlets }),
jsonLd: homePage.homeJsonLd({ abs: (p) => publication.abs(p), topics: topicList, outlets }),
styles: [showcase.STYLESHEET], bodyClass: 'news-home',
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({
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 });
}, { cacheable: true });
}
}
@@ -9,6 +9,7 @@ const showcase = require('openvibe-shared/showcase');
@@ -9,6 +9,7 @@ const showcase = require('openvibe-shared/showcase');
const { time, httpHref, storyList } = require('./pages');
const { time, httpHref, storyList } = require('./pages');
const { html: h, raw } = ssr;
const { html: h, raw } = ssr;
+const { pickList, pickListRule, foldBlock } = require('./short');
const num = (n) => Number(n || 0).toLocaleString('en-US');
const num = (n) => Number(n || 0).toLocaleString('en-US');
const plural = (n, one, many = `${one}s`) => `${num(n)} ${n === 1 ? one : many}`;
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]}`);
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) {
@@ -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>`);
<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 : '';
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>`;
<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) {
@@ -52,7 +53,7 @@ function briefCard(b) {
* tally (Map slug → { subjects, reports, newest }), outlets
* tally (Map slug → { subjects, reports, newest }), outlets
*/
*/
function homeBody(o) {
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);
const questions = faq(outlets);
return String(h`${raw(showcase.hero({
return String(h`${raw(showcase.hero({
eyebrow: 'OpenVibe.News',
eyebrow: 'OpenVibe.News',
@@ -63,24 +64,52 @@ function homeBody(o) {
@@ -63,24 +64,52 @@ function homeBody(o) {
aside: stats ? { html: statsPanel(stats) } : undefined,
aside: stats ? { html: statsPanel(stats) } : undefined,
}))}
}))}
<div id="latest"></div>
<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>` : ''}
${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>
<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 x = tally.get(t.slug);
const item = x && x.newest && x.newest.item;
const item = x && x.newest && x.newest.item;
const href = item ? httpHref(item.url) : null;
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>`;
${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>
<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({
<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: [
items: [
{ icon: 'ov:news', title: 'Cited paragraphs', text: 'Each paragraph names the sources behind it, with outlet, authors and date.' },
{ 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({
@@ -98,7 +127,11 @@ ${raw(showcase.steps({
{ title: 'Editors write the stories', text: 'Stories cite every paragraph. Corrections and retractions stay on the record.' },
{ title: 'Editors write the stories', text: 'Stories cite every paragraph. Corrections and retractions stay on the record.' },
],
],
}))}</details></div>
}))}</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. */
/** Structured data for the front page: the site, its topic pages as an ItemList (indexed pages), the questions shown. */
@@ -14,6 +14,7 @@ const fs = require('fs');
@@ -14,6 +14,7 @@ const fs = require('fs');
const path = require('path');
const path = require('path');
const layout = require('openvibe-publishing/layout');
const layout = require('openvibe-publishing/layout');
const frame = require('openvibe-shared/frame');
const frame = require('openvibe-shared/frame');
+const { foldBlock } = require('./short');
const NETWORK_URL = 'https://openvibe.network';
const NETWORK_URL = 'https://openvibe.network';
const SITE_NAME = 'OpenVibe.News';
const SITE_NAME = 'OpenVibe.News';
@@ -108,11 +109,19 @@ function renderPage(o) {
@@ -108,11 +109,19 @@ function renderPage(o) {
styles: o.styles, // openvibe-shared stylesheet names (the home page's showcase.css)
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)
// 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).
// 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,
release: RELEASE,
account,
account,
body: o.body,
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,
bodyClass: o.bodyClass,
});
});
}
}
@@ -17,17 +17,21 @@ const time = (v) => raw(ssr.timeTag(v == null ? null : new Date(v).toISOString()
@@ -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 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>`;
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>
<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>
<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>` : ''}
${it.lede ? h`<p class="summary">${it.lede}</p>` : ''}
</li>`;
</li>`;
}
}
-function storyList(items, empty) {
+function storyList(items, empty, { pick = false } = {}) {
if (!items.length) return h`<p class="empty">${empty}</p>`;
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) {
function feedLinksHtml(feeds) {
@@ -63,10 +67,10 @@ function briefsSection(briefs, { lead = false } = {}) {
@@ -63,10 +67,10 @@ function briefsSection(briefs, { lead = false } = {}) {
// headline and link; nothing here is written by News.
// 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.';
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 items = brief ? c.items.slice(0, 1) : c.items;
const outlets = [...new Set(c.items.map((i) => i.outlet).filter(Boolean))];
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>`
${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>`}
: 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>`;
</li>`;
@@ -79,9 +83,11 @@ function wireLink(i) {
@@ -79,9 +83,11 @@ function wireLink(i) {
/** The front page's wire section: the latest clusters, one line each. */
/** The front page's wire section: the latest clusters, one line each. */
function wireSection(clusters) {
function wireSection(clusters) {
if (!clusters.length) return '';
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>
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>
<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>`);
<p><a href="/wire">The whole wire</a></p></section>`);
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,76 @@
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
+
+/**
+ * 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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,67 @@
@@ -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=(?:"|")?(\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=(?:"|")?(\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); });
@@ -36,9 +36,9 @@ const ld = (text) => [...text.matchAll(/<script type="application\/ld\+json">([\
@@ -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 () => {
await check('the wire, the topics with what was reported on them, the outlets with their newest report', async () => {
const text = home.text;
const text = home.text;
assert.ok(text.includes('From the wire') && text.includes('Europa Clipper'));
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/);
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(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.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>/);
assert.match(text, /<b>Wire A<\/b><span class="nh-count">1 report this week · 1 in 30 days<\/span>/);
@@ -12,14 +12,14 @@ const { spawn } = require('child_process');
@@ -12,14 +12,14 @@ const { spawn } = require('child_process');
const { measure, check, format } = require('openvibe-shared/perf-budget');
const { measure, check, format } = require('openvibe-shared/perf-budget');
const BUDGETS = {
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)
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)
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)
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)
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
externalFiles: 1, // 0
};
};