openvibers/openvibe.news Commits

Front page lists are rails on a phone, every item shown (#199)

OP OpenVibers committed 10 days ago · 3c05c7131d · 5 files, +42 −72

The owner's rule of 10-31: never hide content to shorten a page. On a phone the front page's lists (latest stories,
AI briefs, the wire, topics) were one item at a time behind a row of radio chips, the rest display:none: 21 items
hidden on the 11-03 home, plus anything past the third brief, the fourth cluster or the sixth topic. Each list is
now a rail you swipe, every item in it, the next one peeking in; a wider screen shows them as before.

Changes

ddiff --git a/public/css/news.css b/public/css/news.css
iindex 8c69b29..6700c78 100644
---- a/public/css/news.css
++++ b/public/css/news.css
@@@ -127,11 +127,7 @@ body.news-home .page { max-width: 1120px; }
.nh-fold > summary span { color: var(--text-muted, #7386a3); font-weight: 600; font-size: .86rem; }
.nh-fold > summary:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -2px; border-radius: 14px; }
.nh-fold .sc-sec { margin: 0; max-width: none; padding: 0 16px 16px; }
--@media (max-width: 599px) {
-- .nh-cards > li:nth-child(n+4), .nh-outlets > li:nth-child(n+4) { display: none; }
-- .nh-wire .wire-list > li:nth-child(n+5) { display: none; }
-- .nh-topics > li:nth-child(n+7) { display: none; }
--}
++
/* Touch targets on phones (fleet audit, 2026-10-29): touch pointers only. */
@media (pointer: coarse) {
@@@ -151,27 +147,14 @@ body.news-home .page { max-width: 1120px; }
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 front page's lists (the owner, 2026-10-31: never hide content to shorten a page): on a phone each list is a
++ rail you swipe, every item in it, the next one peeking in; a wider screen shows them as before. They used to be
++ one item at a time behind a row of radio chips, the rest display:none. */
++@media (max-width: 640px) {
++ .pick--rail .pick-list { display: flex; gap: 12px; overflow-x: auto; scroll-snap-type: x mandatory; margin: 0 -16px; padding: 2px 16px 10px; list-style: none; scrollbar-width: none; overscroll-behavior-x: contain; }
++ .pick--rail .pick-list::-webkit-scrollbar { display: none; }
++ .pick--rail .pick-list > li { flex: 0 0 84%; min-width: 0; scroll-snap-align: start; }
++}
/* 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. */
ddiff --git a/server/http/public.js b/server/http/public.js
iindex 21d0799..74b40e3 100644
---- a/server/http/public.js
++++ b/server/http/public.js
@@@ -34,7 +34,6 @@ 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).
@@@ -119,8 +118,6 @@ function createPublicRoutes(ctx) {
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), wireClusters: wire, topics: topicList, tally, outlets,
briefs: homeBriefs,
ddiff --git a/server/render/home.js b/server/render/home.js
iindex 76fff0e..7ba58ec 100644
---- a/server/render/home.js
++++ b/server/render/home.js
@@@ -9,7 +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 { pickList, 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]}`);
ddiff --git a/server/render/short.js b/server/render/short.js
iindex c49f68c..c2e2f24 100644
---- a/server/render/short.js
++++ b/server/render/short.js
@@@ -2,6 +2,7 @@
/**
* The home on a phone (the owner's short-pages rule, 2026-10-30): one or two screens, app-like.
*
++ * pickList() a list on the front page: a rail you swipe on a phone, every item shown (2026-10-31).
* 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.
@@@ -15,26 +16,21 @@
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.
++ * A list on the front page (the owner, 2026-10-31: never hide content to shorten a page). On a phone it is a rail you
++ * swipe, every item in it; a wider screen shows the list as before. It used to be one item at a time, picked with a
++ * row of radio chips, the rest display:none (21 items hidden on the 11-03 home). `labels` is kept for the callers and
++ * no longer drawn: the list's own heading names it.
*
-- * pickList({ key, label, head, labels, html }) labels: the chip labels, in the order of the items
++ * pickList({ key, label, head, labels, html })
*/
--function pickList({ key, label, head = '', labels, html }) {
++function pickList({ key, 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>`;
++ return `<div class="pick pick--rail" id="pick-${esc(key)}">${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('');
--}
++/** No longer needed (every item shows): kept so a caller that still asks for the rules gets none. */
++function pickListRule() { return ''; }
++function pickListRules() { return ''; }
/**
* 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
ddiff --git a/test/home-short.test.js b/test/home-short.test.js
iindex 220ff28..5c75454 100644
---- a/test/home-short.test.js
++++ b/test/home-short.test.js
@@@ -1,8 +1,8 @@
'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.
++ * The home on a phone (the owner, 2026-10-31: never hide content to shorten a page): the home's lists are rails you
++ * swipe, every item shown, and the parts a reader rarely needs are folds. They used to be one item at a time behind a
++ * row of radio chips, the rest display:none.
*/
const assert = require('assert');
const { boot, check, done, launchReports } = require('./helpers/boot');
@@@ -12,42 +12,36 @@ const { boot, check, done, launchReports } = require('./helpers/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 () => {
++ await check('the wire is a rail on a phone: every cluster shown, no chips, nothing hidden', 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');
++ const block = page.slice(page.indexOf('<div class="pick pick--rail" id="pick-wire">'), page.indexOf('<div class="pick pick--rail" id="pick-topics">'));
++ assert.ok(block.startsWith('<div class="pick pick--rail" id="pick-wire">'), 'the wire rail is on the home');
++ assert.ok(!/type="radio"|pick-row/.test(block), 'no radio chips');
++ const clusters = [...block.matchAll(/<li class="wire-cluster" id="wire-[^"]+"/g)];
++ assert.ok(clusters.length >= 1, `every cluster is in the page (${clusters.length})`);
});
-- await check('the topics are a pick block too: a chip per topic, every topic in the page', async () => {
++ await check('the topics are a rail too: every topic in the page, no chips', 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 at = page.indexOf('<div class="pick pick--rail" id="pick-topics">');
++ assert.ok(at > 0, 'the topics rail is on the home');
++ const block = page.slice(at, at + 4000);
++ assert.ok(!/type="radio"/.test(block), 'no radio chips');
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');
++ assert.ok(items.length >= 3, `every topic stays in the page (${items.length})`);
});
-- await check('the CSS guard: the chips are hidden and the items all show until 640 px and where :has() is understood', async () => {
++ await check('the CSS: on a phone a list is a rail you swipe, and no rule hides an item (the owner, 2026-10-31)', 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');
++ assert.ok(!/\.pick:has\(#pk-/.test(r.text), 'no per-item rule in the page');
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');
++ assert.match(css, /\.pick--rail \.pick-list \{ display: flex;[^}]*overflow-x: auto;[^}]*scroll-snap-type: x mandatory/, 'the list is a rail');
++ assert.match(css, /\.pick--rail \.pick-list > li \{ flex: 0 0 84%/, 'the next item peeks in');
++ assert.ok(!/pick-list > li \{ display: none/.test(css), 'no item is hidden');
++ assert.ok(!/li:nth-child\(n\+\d+\) \{ display: none/.test(css), 'nothing is cut after the third or sixth');
});
await check('the parts a reader rarely needs are folds, open in the page and closed on a phone', async () => {