openvibers/openvibe.tips Commits

Home on phones: one part of Tips at a time (#132)

OP OpenVibers committed 14 days ago · 6bf9965873 · 6 files, +198 −12

The front page's five parts (what you can send, what a creator gets, the other
ways to be paid, how it works and what it costs) were about 3,200 px of a
7.3-screen phone home. On a phone (640 px and under) a row of chips picks one
of them: radio inputs and :has(), no script, and every part stays in the page
for crawlers. A wider screen, or a browser without :has(), shows every part and
no chips.

The questions, the network list and the shipped list are parts a reader rarely
needs: they are folds now, closed on a phone and open where there is room. The
creators and the live channels stay where they are.

The phone home goes from 6,179 px (7.3 screens) to 2,690 px (3.2 screens) with
the same data; the footer counted. At 1280 px the page looks as it did.

Changes

ddiff --git a/public/css/tips.css b/public/css/tips.css
iindex 8f0b65e..120965e 100644
---- a/public/css/tips.css
++++ b/public/css/tips.css
@@@ -127,3 +127,48 @@ ul.mod-list, ul.plain { list-style: none; padding: 0; }
.tp-more { position: relative; }
.tp-more::after { content: ''; position: absolute; left: 0; right: 0; top: -11px; bottom: -11px; }
}
++
++/* Pick (the owner's short-pages rule, 2026-10-30): on a phone the front page's five parts are one at a time, picked
++ with a row of chips. Radio inputs and :has(), no script: the chips and the one-part rule live under 640 px and only
++ where :has() is understood, so a wider screen, or an older browser, shows every part and no chips. Every part stays
++ in the page for crawlers. */
++.pick-head { display: none; }
++.pick-row { display: none; }
++@supports selector(:has(*)) { @media (max-width: 640px) {
++ .pick-bar { margin-top: 30px; }
++ .pick-head { display: block; }
++ .pick-head h2 { margin: 0 0 4px; font: 700 clamp(20px, 2.4vw, 28px)/1.2 var(--ov-font, 'Inter', system-ui, sans-serif); letter-spacing: -.02em; }
++ .pick-row { display: flex; gap: 8px; overflow-x: auto; margin: 10px -16px 0; padding: 2px 16px 4px; scrollbar-width: none; overscroll-behavior-x: contain; }
++ .pick-row::-webkit-scrollbar { display: none; }
++ .pick-row input { position: absolute; opacity: 0; width: 1px; height: 1px; pointer-events: none; }
++ .pick-row label { display: inline-flex; align-items: center; flex: none; min-height: 44px; padding: 0 15px; border-radius: 999px; border: 1px solid var(--border, #1f2d47); color: var(--text-secondary, #96a7c2); font-size: .9rem; font-weight: 650; white-space: nowrap; cursor: pointer; }
++ .pick-row input:checked + label { color: var(--text-primary, #e6edf7); border-color: var(--accent, #3b82f6); background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); }
++ .pick-row input:focus-visible + label { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
++ .pick .pick-pane { display: none; }
++ /* A link that points at a part opens that part on a phone too. */
++ .pick .pick-pane:target { display: block !important; }
++ .pick:has(.pick-pane:target) .pick-pane:not(:target) { display: none !important; }
++ /* The chip names the part, so the part's own heading gives way to it. */
++ .pick .pick-pane .tp-head h2, .pick .pick-pane .sc-sec > h2 { display: none; }
++ .pick .pick-pane .tp-head { justify-content: flex-end; }
++} }
++
++/* 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. */
++.tp-fold-block { margin-top: 32px; border: 1px solid var(--border, #1f2d47); border-radius: 14px; background: var(--bg-card, #131c2e); }
++.tp-fold-block > summary { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 48px; padding: 0 16px; cursor: pointer; font-weight: 700; list-style: none; }
++.tp-fold-block > summary::-webkit-details-marker { display: none; }
++.tp-fold-block > summary h2 { margin: 0; font: 700 clamp(19px, 2.2vw, 26px)/1.2 var(--ov-font, 'Inter', system-ui, sans-serif); }
++.tp-fold-block > summary:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -2px; border-radius: 14px; }
++.tp-fold-block .tp-sec { margin: 0; padding: 0 16px 16px; }
++.tp-fold-block .tp-head h2 { display: none; }
++.tp-fold-block .sc-sec { margin: 0; max-width: none; padding: 0 16px 16px; }
++.tp-fold-block .sc-sec > h2 { display: none; }
++@media (min-width: 641px) {
++ .tp-fold-block { border: 0; background: none; border-radius: 0; margin: 56px auto 0; padding: 0; }
++ .tp-fold-block > summary { display: block; min-height: 0; padding: 0; }
++ .tp-fold-block > summary h2 { margin: 0 0 6px; font: 700 clamp(22px, 2.6vw, 30px)/1.2 var(--ov-font, 'Inter', system-ui, sans-serif); }
++ .tp-fold-block > summary span { display: none; }
++ .tp-fold-block .tp-sec, .tp-fold-block .sc-sec { margin: 0; max-width: none; padding: 0; }
++}
++@media (max-width: 640px) { .tp-fold-block { margin-top: 24px; } .tp-fold-block > summary h2 { font-size: 1.05rem; } }
ddiff --git a/server/render/short.js b/server/render/short.js
nnew file mode 100644
iindex 0000000..89c630c
---- /dev/null
++++ b/server/render/short.js
@@@ -0,0 +1,58 @@
++'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/tips.css; the rule that shows the picked block is written for the page,
++ * because there is one per block.
++ */
++const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
++
++/**
++ * A pick block. The panes are the page's own blocks; each one keeps its heading on a wide screen, and on a phone the
++ * heading gives way to the chip that names it.
++ *
++ * pickBlock({ key, label, head, panes }) panes: [{ id, label, html }]
++ */
++function pickBlock({ key, label, head = '', panes }) {
++ // A pane with nothing to show is left out, so the chips and the rules that show them match the page.
++ panes = (panes || []).filter((p) => p.html);
++ if (!panes.length) return '';
++ const chips = panes.map((p, i) => `<input type="radio" name="pick-${esc(key)}" id="pk-${esc(key)}-${esc(p.id)}" value="${esc(p.id)}"${i === 0 ? ' checked' : ''}><label for="pk-${esc(key)}-${esc(p.id)}">${esc(p.label)}</label>`).join('');
++ // The pane carries the id the page already links to (an anchor in the hero, for instance), so a link to it still
++ // opens that pane on a phone (the CSS shows the pane a link points at).
++ const body = panes.map((p) => `<div class="pick-pane" id="${esc(p.id)}">${p.html}</div>`).join('');
++ return `<div class="pick" id="pick-${esc(key)}"><div class="pick-bar"><div class="pick-head"><h2>${esc(head || label)}</h2></div><div class="pick-row" role="radiogroup" aria-label="${esc(label)}">${chips}</div></div><div class="pick-body">${body}</div></div>`;
++}
++
++/** The rule that shows the picked block of a pick block: one per block, so the CSS is written for the page. */
++function pickRule(key, id) {
++ return `.pick:has(#pk-${key}-${id}:checked) #${id}{display:block}`;
++}
++
++/** The rules for every block of a pick block, in the order they appear. */
++function pickRules(key, ids) {
++ return ids.map((id) => pickRule(key, id)).join('');
++}
++
++/**
++ * A 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="tp-fold tp-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, foldBlock, foldScript };
ddiff --git a/server/web/layout.js b/server/web/layout.js
iindex 66e6628..89f6bb9 100644
---- a/server/web/layout.js
++++ b/server/web/layout.js
@@@ -12,6 +12,7 @@ const fs = require('fs');
const path = require('path');
const appIcon = require('openvibe-shared/app-icon');
const frame = require('openvibe-shared/frame');
++const { foldBlock } = require('../render/short');
const shell = require('openvibe-shared/shell');
const SITE_NAME = 'OpenVibe.Tips';
@@@ -86,12 +87,14 @@ function createLayout({ config, release }) {
`<meta name="ov-boost" content="tips@${esc(RELEASE)}">`,
`<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,
`<script src="${asset('js/tips.js')}" defer></script>`,
++ // The rules that show the picked part of a pick block are written for the page, because there is one per part.
++ ...(o.inlineStyles || []).map((css) => `<style>${css}</style>`),
].filter(Boolean).join('\n'),
body: `<div id="navbar-mount"></div>
<header class="site-head"><a class="brand" href="/">${SITE_NAME}</a><nav>${NAV_LINKS.map((l) => `<a href="${l.href}"${o.active === l.label.toLowerCase() ? ' aria-current="page"' : ''}>${l.label}</a>`).join('')}</nav>${who}</header>
<main id="main" class="page${o.wide ? ' page-wide' : ''}">
${o.body || ''}
--${o.canonicalPath === '/' && o.active === 'home' ? frame.shipped({ service: 'tips', title: `Recently shipped on ${SITE_NAME}` }) : ''}
++${o.canonicalPath === '/' && o.active === 'home' ? foldBlock({ id: 'shipped', head: `<h2 id="h-shipped">Recently shipped on ${SITE_NAME}</h2>`, html: frame.shipped({ service: 'tips', title: `Recently shipped on ${SITE_NAME}` }) }) : ''}
</main>
<script>
window.__OV_PAGE = ${JSON.stringify({ navbar: nav, footer }).replace(/</g, '\\u003c')};
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex 851c181..4efd13c 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -9,6 +9,7 @@ const showcase = require('openvibe-shared/showcase');
const { esc } = require('./layout');
const { VOICES } = require('../domain/profiles');
const supportLinks = require('../domain/support-links');
++const { pickBlock, pickRules, foldBlock } = require('../render/short');
const n = (v) => Number(v || 0).toLocaleString('en-US');
const when = (s) => (s ? esc(String(s).replace('T', ' ').slice(0, 16)) + ' UTC' : '');
@@@ -60,11 +61,15 @@ const SUPPORT_SERVICES = Object.freeze(['Ko-fi', 'PayPal.me', 'Patreon', 'Buy Me
const watching = (v) => (v === 1 ? '1 watching' : `${n(v)} watching`);
/** A titled home section with an optional "see all" link; nothing when it has no body. */
--function homeSection({ id, title, lede, more, body }) {
++function homeSection({ id, title, lede, more, body, headId }) {
if (!body) return '';
-- return `<section class="tp-sec" aria-labelledby="${esc(id)}"><div class="tp-head"><h2 id="${esc(id)}">${esc(title)}</h2>${more ? `<a class="tp-more" href="${esc(more.href)}">${esc(more.label)}<span aria-hidden="true"> →</span></a>` : ''}</div>${lede ? `<p class="tp-lede">${esc(lede)}</p>` : ''}${body}</section>`;
++ const h = headId || id;
++ return `<section class="tp-sec" aria-labelledby="${esc(h)}"><div class="tp-head"><h2 id="${esc(h)}">${esc(title)}</h2>${more ? `<a class="tp-more" href="${esc(more.href)}">${esc(more.label)}<span aria-hidden="true"> →</span></a>` : ''}</div>${lede ? `<p class="tp-lede">${esc(lede)}</p>` : ''}${body}</section>`;
}
++/** The home's pick block, one part at a time on a phone: the ids of its panes, in order. */
++const HOME_PANE_IDS = ['send', 'for-creators', 'services', 'how', 'costs'];
++
/**
* The home: what Tips is for, who is live on OpenVibe right now, the creators who switched their page on, what a
* supporter can send, what a creator gets (overlays for any streaming app, other ways to be paid), what it costs, how it
@@@ -85,7 +90,8 @@ function home({ creators, live = [] }) {
],
}) + homeSection({ id: 'live', title: 'Live on OpenVibe now', lede: 'Open a channel to watch, chat and support the creator.', more: { href: 'https://openvibe.live/', label: 'Everyone live' }, body: liveCards })
+ homeSection({ id: 'creators', title: 'Creators on OpenVibe.Tips', lede: 'Every creator who switched their tip page on, the most recently updated first.', body: creatorCards })
-- + showcase.features({
++ + pickBlock({ key: 'parts', label: 'What Tips does', head: 'What Tips does', panes: [
++ { id: 'send', label: 'What you can send', html: showcase.features({
title: 'What you can send',
items: [
{ icon: 'ov:tips', title: 'A tip', text: 'Vibes straight to the creator, with an optional message.' },
@@@ -95,7 +101,8 @@ function home({ creators, live = [] }) {
{ icon: 'ov:audio', title: 'A sound', text: "One of the creator's sounds, played on stream by their overlay, where they turned it on." },
{ icon: 'ov:shield', title: 'Your privacy, your choice', text: 'Send as Anonymous, hide the amount, or keep a message for the creator only.' },
],
-- }) + showcase.features({
++ }) },
++ { id: 'for-creators', label: 'For creators', html: showcase.features({
title: 'For creators',
lede: 'A tip page at openvibe.tips/your-name, set up from your dashboard in a few minutes.',
items: [
@@@ -106,15 +113,17 @@ function home({ creators, live = [] }) {
{ icon: 'ov:tips', title: 'Other ways to be paid', text: `Link your ${SUPPORT_SERVICES.slice(0, 4).join(', ')} and ${SUPPORT_SERVICES.length - 4} more services beside your tip form.`, href: '/dashboard' },
{ icon: 'ov:docs', title: 'Receipts both ways', text: 'Supporters keep a receipt for every tip; you see each one settle and reach your stream.' },
],
-- }) + homeSection({ id: 'services', title: 'Other ways to support', lede: 'A creator can list these beside their tip form. Each link is built from the creator\'s handle on that service\'s own site, and it is paid there, outside OpenVibe.', body: `<ul class="tp-chips">${SUPPORT_SERVICES.map((x) => `<li>${esc(x)}</li>`).join('')}</ul>` })
-- + showcase.steps({
++ }) },
++ { id: 'services', label: 'Other ways', html: homeSection({ id: 'services', headId: 'services-h', title: 'Other ways to support', lede: 'A creator can list these beside their tip form. Each link is built from the creator\'s handle on that service\'s own site, and it is paid there, outside OpenVibe.', body: `<ul class="tp-chips">${SUPPORT_SERVICES.map((x) => `<li>${esc(x)}</li>`).join('')}</ul>` }) },
++ { id: 'how', label: 'How it works', html: showcase.steps({
title: 'How it works',
items: [
{ title: 'Pick a creator', text: 'Every creator who switched their page on has one at openvibe.tips/name, linked from their channel.' },
{ title: 'Choose what to send', text: "A tip, a paid message, text-to-speech, a media request or a sound, within the creator's own limits." },
{ title: 'Pay with Vibes', text: 'From your Vibes balance, or through checkout. Payments are handled by OpenVibe.Billing; your receipt shows when the payment settled and when your message was delivered.' },
],
-- }) + showcase.pricing({
++ }) },
++ { id: 'costs', label: 'What it costs', html: showcase.pricing({
title: 'What it costs to send',
lede: 'The least you can send of each. Every creator sets their own minimums; these are what a new page starts with.',
tiers: [
@@@ -125,8 +134,16 @@ function home({ creators, live = [] }) {
{ name: 'Sound', price: vibes(m.sound), items: ['Played on stream by their overlay', 'Only where the creator turned it on'] },
],
note: "The creator's page shows their own minimums before you pay.",
-- }) + `<section class="tp-sec" aria-labelledby="faq"><div class="tp-head"><h2 id="faq">Questions and answers</h2></div><div class="tp-faq">${HOME_FAQ.map((x) => `<details><summary>${esc(x.q)}</summary><p>${esc(x.a)}</p></details>`).join('')}</div></section>`
-- + showcase.network({ service: 'tips' }) + showcase.cta({ title: 'Stream? Get tipped anywhere.', text: 'Start a tip page, add the overlay to your streaming app and share your link.', actions: [{ label: 'Start your tip page', href: '/dashboard', primary: true }, { label: 'Memberships on OpenVibe.VIP', href: 'https://openvibe.vip/' }] });
++ }) },
++ ] }) + foldBlock({
++ id: 'faq',
++ head: '<h2 id="faq-h">Questions and answers</h2>',
++ html: `<section class="tp-sec" aria-labelledby="faq-h"><div class="tp-head"><h2>Questions and answers</h2></div><div class="tp-faq">${HOME_FAQ.map((x) => `<details><summary>${esc(x.q)}</summary><p>${esc(x.a)}</p></details>`).join('')}</div></section>`,
++ }) + foldBlock({
++ id: 'network',
++ head: '<h2 id="network-h">Part of OpenVibe</h2>',
++ html: showcase.network({ service: 'tips' }),
++ }) + showcase.cta({ title: 'Stream? Get tipped anywhere.', text: 'Start a tip page, add the overlay to your streaming app and share your link.', actions: [{ label: 'Start your tip page', href: '/dashboard', primary: true }, { label: 'Memberships on OpenVibe.VIP', href: 'https://openvibe.vip/' }] });
}
/** The tip form: every field works as a plain POST. */
@@@ -493,4 +510,5 @@ function errorPage({ status, title, message }) {
return `<section class="card"><h1>${esc(title)}</h1><p>${esc(message)}</p><p><a href="/">OpenVibe.Tips home</a></p></section><!-- ${status} -->`;
}
--module.exports = { NEW_PAGE_MINIMUMS, HOME_FAQ, SUPPORT_SERVICES, home, creatorPage, goalsPage, supportersPage, receiptsPage, erasePage, receiptPage, dashboard, moderatePage, startPage, invitePage, inviteCreated, tokenCreated, errorPage, tipForm };
++module.exports = {
++ HOME_PANE_IDS, NEW_PAGE_MINIMUMS, HOME_FAQ, SUPPORT_SERVICES, home, creatorPage, goalsPage, supportersPage, receiptsPage, erasePage, receiptPage, dashboard, moderatePage, startPage, invitePage, inviteCreated, tokenCreated, errorPage, tipForm };
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex 8432396..9c37ac0 100644
---- a/server/web/routes.js
++++ b/server/web/routes.js
@@@ -97,7 +97,9 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow = { list: a
{ '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: pages.HOME_FAQ.map((x) => ({ '@type': 'Question', name: x.q, acceptedAnswer: { '@type': 'Answer', text: x.a } })) },
];
// Who is live comes from Live once a minute, so the page may be cached as long.
-- html(res, pageFor(req, { active: 'home', canonicalPath: '/', title: 'Tips, paid messages and stream alerts', description: HOME_DESCRIPTION, styles: [require('openvibe-shared/showcase').STYLESHEET], wide: true, body: pages.home({ creators, live }), jsonLd }));
++ html(res, pageFor(req, { active: 'home', canonicalPath: '/', title: 'Tips, paid messages and stream alerts', description: HOME_DESCRIPTION, styles: [require('openvibe-shared/showcase').STYLESHEET], wide: true, body: pages.home({ creators, live }),
++ // The rule that shows the picked part of the home's pick block: one per part, written for this page.
++ inlineStyles: [`@supports selector(:has(*)){@media (max-width:640px){${require('../render/short').pickRules('parts', pages.HOME_PANE_IDS)}}}`], jsonLd }));
});
// What shipped on OpenVibe.Tips: the shared update log every OpenVibe site has.
r.get('/updates', withViewer, (req, res) => html(res, pageFor(req, { canonicalPath: '/updates', title: 'What shipped on OpenVibe.Tips', body: frame.updatesBody({ service: 'tips', siteName: 'OpenVibe.Tips' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>` })));
ddiff --git a/test/home-short.test.js b/test/home-short.test.js
nnew file mode 100644
iindex 0000000..6f9665c
---- /dev/null
++++ b/test/home-short.test.js
@@@ -0,0 +1,60 @@
++'use strict';
++/**
++ * The home on a phone (the owner's short-pages rule, 2026-10-30): the home's five parts 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 part stays in the page for crawlers; a wider screen shows them all and no chips.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const { boot, check, done } = require('./helpers/app');
++
++(async () => {
++ const t = await boot();
++ try {
++ await check('the home is one pick block: a chip per part, the first picked, every part in the page', async () => {
++ const r = await t.call('GET', '/', { token: null });
++ assert.strictEqual(r.status, 200, r.text);
++ const page = r.text;
++ const block = page.slice(page.indexOf('<div class="pick" id="pick-parts">'), page.indexOf('<div class="pick" id="pick-parts">') + 60000);
++ assert.ok(block.startsWith('<div class="pick" id="pick-parts">'), 'the pick block is on the home');
++ assert.match(block, /<div class="pick-row" role="radiogroup" aria-label="What Tips does">/);
++ const chips = [...block.matchAll(/<input type="radio" name="pick-parts" id="pk-parts-([a-z-]+)" value="\1"( checked)?><label for="pk-parts-\1">([^<]+)<\/label>/g)];
++ assert.ok(chips.length >= 5, `a chip per part (${chips.length})`);
++ assert.deepStrictEqual(chips.filter((m) => m[2]).map((m) => m[1]), [chips[0][1]], 'the first part is picked');
++ // Every part stays in the page, with the id its chip picks.
++ const panes = [...block.matchAll(/<div class="pick-pane" id="([a-z-]+)">/g)].map((m) => m[1]);
++ assert.deepStrictEqual(panes, chips.map((m) => m[1]), 'every part is in the page, one per chip');
++ // The parts themselves are all there: nothing was removed.
++ for (const title of ['What you can send', 'For creators', 'Other ways to support', 'How it works', 'What it costs to send']) {
++ assert.ok(page.includes(title), `${title} is on the page`);
++ }
++ });
++
++ await check('the CSS guard: the chips are hidden and the parts all show until 640 px and where :has() is understood', async () => {
++ const r = await t.call('GET', '/', { token: null });
++ const page = r.text;
++ assert.match(page, /<style>@supports selector\(:has\(\*\)\)\{@media \(max-width:640px\)\{\.pick:has\(#pk-parts-send:checked\) #send\{display:block\}/, 'the rule that shows the picked part');
++ const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'tips.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-pane \{ display: none; \}/, 'the parts 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.call('GET', '/', { token: null });
++ const page = r.text;
++ for (const id of ['faq', 'network', 'shipped']) {
++ assert.ok(page.includes(`<details class="tp-fold tp-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, /What are Vibes\?/);
++ assert.match(page, /Part of OpenVibe/);
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });