openvibers/openvibe.space Commits

Home on phones: one part of Space at a time (#136)

OP OpenVibers committed 14 days ago · a1014fc335 · 5 files, +230 −55

The front page's six parts (the templates, how a space comes together, how a
deploy works, what works now, the API and the quotas) were about 4,100 px of a
6.6-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 are a part a reader rarely needs: they are a fold now, closed on
a phone and open where there is room. The "How it works" button still opens
the deploys, on a phone as well.

The phone home goes from 5,549 px (6.6 screens) to 2,481 px (2.9 screens); the
footer counted. At 1280 px the page is the same as it was.

Changes

ddiff --git a/public/css/space.css b/public/css/space.css
iindex 5a8f095..0d1450d 100644
---- a/public/css/space.css
++++ b/public/css/space.css
@@@ -82,3 +82,46 @@ body {
/* Touch targets on phones (fleet audit 2026-10-29): the touch-only hit area for this group. Nothing moves with a mouse. */
@media (pointer: coarse) { .space-faq summary { min-height: 44px; box-sizing: border-box; } }
++
++/* Pick (the owner's short-pages rule, 2026-10-30): on a phone the front page's six 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 (the "How it works" button) 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 > .sc-sec > h2 { display: none; }
++} }
++
++/* The fold (the owner's short-pages rule): closed on a phone, and where there is room it looks like the section it
++ replaces: a heading and its content, with no card around it. */
++.sp-fold-block { margin-top: 32px; border: 1px solid var(--border, #1f2d47); border-radius: 14px; background: var(--bg-card, #131c2e); }
++.sp-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; }
++.sp-fold-block > summary::-webkit-details-marker { display: none; }
++.sp-fold-block > summary h2 { margin: 0; font: 700 clamp(19px, 2.2vw, 26px)/1.2 var(--ov-font, 'Inter', system-ui, sans-serif); }
++.sp-fold-block > summary:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -2px; border-radius: 14px; }
++.sp-fold-block .sc-sec { margin: 0; padding: 0 16px 16px; }
++.sp-fold-block .sc-sec > h2 { display: none; }
++@media (min-width: 641px) {
++ /* Where there is room the fold looks like the section it replaces: the same width and gutters as .sc-sec. */
++ .sp-fold-block { border: 0; background: none; border-radius: 0; max-width: 1080px; margin: 56px auto 0; padding: 0 24px; }
++ .sp-fold-block > summary { display: block; min-height: 0; padding: 0; }
++ .sp-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); }
++ .sp-fold-block > summary span { display: none; }
++ .sp-fold-block .sc-sec { margin: 0; max-width: none; padding: 0; }
++}
++@media (max-width: 640px) { .sp-fold-block { margin-top: 24px; } .sp-fold-block > summary h2 { font-size: 1.05rem; } }
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex d96f70d..575af61 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -145,6 +145,8 @@ function renderPage(o) {
'<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer">',
`<meta name="ov-boost" content="space@${escapeHtml(RELEASE)}">`,
`<script src="${ovServe.url('boost.js')}" data-main="#main" 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'),
bodyClass: o.bodyClass,
bodyAttributes: { 'data-page': o.active || 'page' },
ddiff --git a/server/render/pages.js b/server/render/pages.js
iindex fd1d728..9959ecf 100644
---- a/server/render/pages.js
++++ b/server/render/pages.js
@@@ -10,6 +10,7 @@
const config = require('../config');
const sc = require('openvibe-shared/showcase');
const { renderPage, escapeHtml: esc } = require('./layout');
++const { pickBlock, pickRules, foldBlock } = require('./short');
const FORUM_URL = `${config.communityUrl}/s`;
const FORUM_LABEL = FORUM_URL.replace(/^https?:\/\//, '');
@@@ -40,6 +41,67 @@ function defaultQuotas() {
function homePage() {
const q = defaultQuotas();
const row = (label, key, fmt = String) => ({ label, values: [fmt(q.production[key]), fmt(q.sandbox[key])] });
++ // The home's six parts, one at a time on a phone (the owner's short-pages rule, 2026-10-30): the chips pick one of
++ // them and every part stays in the page for crawlers. The id of a part is the one the page already links to.
++ const parts = [
++ { id: 'templates', label: 'Templates', html: sc.features({
++ title: 'Start from a template',
++ lede: 'Each one is a working space you can open in the editor and change: sign-in, data and AI already wired.',
++ items: TEMPLATES.filter((t) => t.id !== 'blank').map((t) => ({ icon: t.icon, title: t.name, text: t.description, href: '/spaces/new' })),
++ }) },
++ { id: 'make', label: 'Making one', html: sc.steps({
++ title: 'How a space comes together',
++ items: [
++ { title: 'Describe it, or pick a template', text: 'Say what the app should do and OpenVibe AI writes a first version, or open a template.', href: '/spaces/new' },
++ { title: 'Change anything', text: 'The editor shows your space\'s files with a sandboxed preview beside them, so every change is visible at once.' },
++ { title: 'Sign-in, data and AI built in', text: 'The space SDK gives you the visitor\'s OpenVibe profile, shared lists, private storage, OpenVibe AI and server functions.' },
++ { title: 'Share it', text: 'People open it with their OpenVibe account; publishing at its own address opens with public hosting.' },
++ ],
++ }) },
++ { id: 'how', label: 'Deploys', html: sc.steps({
++ title: 'How a deploy works',
++ items: [
++ { title: 'Make a project and a site', text: 'A project holds sites and the people who may change them. Each site gets its own address under openvibe.website.' },
++ { title: 'Upload, or connect Git', text: 'Upload a folder or a .tar.gz from the dashboard or the API, or deploy from a public Git repository. Nothing is built or executed: the files are served as they are.' },
++ { title: 'Preview, then activate', text: 'Check a deploy at its private preview address, then make it live. Activation switches one pointer, so rolling back is one step too.' },
++ ],
++ }) },
++ { id: 'now', label: 'What works', html: sc.features({
++ title: 'What works now',
++ items: [
++ { icon: 'fa-cubes', title: 'Immutable deploys', text: 'Every deploy is content-addressed and kept: files are stored once by their hash, and a deploy never changes after upload.' },
++ { icon: 'fa-clock-rotate-left', title: 'Previews and rollback', text: 'A private preview for the project\'s members, one-step activation, and a rollback to the deploy that was live before.' },
++ { icon: 'fa-code-branch', title: 'Git deploys', text: 'Point a site at a public repository and branch; each ingest lands as a preview for someone to approve.' },
++ { icon: 'fa-globe', title: 'Your own address', text: 'Sites serve under openvibe.website. A custom domain is served once its TXT record proves it is yours.' },
++ { icon: 'fa-sliders', title: 'Headers and redirects', text: 'Per-site response headers, redirects and a single-page-app fallback. Platform security headers always win.' },
++ { icon: 'fa-users', title: 'Members and roles', text: 'Owners, maintainers and deployers per project; an app can deploy too, with a grant and a role, never as the owner.' },
++ { icon: 'fa-shield-halved', title: 'Takedowns kept for review', text: 'OpenVibe staff can stop serving a site or a whole project while its content is kept for review, and lift it again.' },
++ { icon: 'fa-terminal', title: 'An API for all of it', text: 'Projects, sites, deploys, domains and configuration over /api/v1, with OpenVibe.Network tokens and space.* capabilities.' },
++ ],
++ }) },
++ { id: 'api', label: 'The API', html: sc.code({
++ title: 'The same over the API',
++ lede: 'With a Network token for openvibe.space that holds space.deploy.create and a role in the site\'s project.',
++ samples: [{ label: 'Upload and activate a build', lang: 'bash', code: `tar -czf site.tar.gz -C dist .
++curl -X POST "https://openvibe.space/api/v1/sites/$SITE_ID/deploys?activate=1" \\
++ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/gzip' \\
++ --data-binary @site.tar.gz` }],
++ }) },
++ { id: 'quotas', label: 'Quotas', html: sc.limits({
++ title: 'Quotas per project',
++ lede: 'What a project may use today; staff can raise a project\'s quota.',
++ columns: ['Production', 'Sandbox'],
++ rows: [
++ row('Storage', 'storageBytes', size),
++ row('Deploys per day', 'deploysPerDay'),
++ row('Sites', 'sites'),
++ row('Custom domains', 'customDomains'),
++ row('Files per deploy', 'maxFiles', (n) => n.toLocaleString('en-US')),
++ row('Largest file', 'maxFileBytes', size),
++ ],
++ source: 'https://github.com/OpenVibers/OpenVibe.Space/blob/main/server/hosting-config.js',
++ }) },
++ ];
const body = sc.hero({
eyebrow: 'OpenVibe.Space · spaces',
title: 'Make a space.',
@@@ -50,67 +112,19 @@ function homePage() {
{ label: 'Browse spaces', href: '/spaces', icon: 'fa-shapes' },
],
note: 'The builder, the editor and the preview work for everyone signed in. Publishing a space at its own address opens with public hosting.',
-- }) + sc.features({
-- id: 'templates',
-- title: 'Start from a template',
-- lede: 'Each one is a working space you can open in the editor and change: sign-in, data and AI already wired.',
-- items: TEMPLATES.filter((t) => t.id !== 'blank').map((t) => ({ icon: t.icon, title: t.name, text: t.description, href: '/spaces/new' })),
-- }) + sc.steps({
-- title: 'How a space comes together',
-- items: [
-- { title: 'Describe it, or pick a template', text: 'Say what the app should do and OpenVibe AI writes a first version, or open a template.', href: '/spaces/new' },
-- { title: 'Change anything', text: 'The editor shows your space\'s files with a sandboxed preview beside them, so every change is visible at once.' },
-- { title: 'Sign-in, data and AI built in', text: 'The space SDK gives you the visitor\'s OpenVibe profile, shared lists, private storage, OpenVibe AI and server functions.' },
-- { title: 'Share it', text: 'People open it with their OpenVibe account; publishing at its own address opens with public hosting.' },
-- ],
-- }) + sc.cta({
++ }) + pickBlock({ key: 'parts', label: 'What Space does', head: 'What Space does', panes: parts })
++ + sc.cta({
title: 'Static hosting: publish a site, keep every deploy.',
text: 'Immutable, content-addressed deploys with one-step rollback, previews, Git deploys and verified custom domains. OpenVibe staff host here today; it opens to everyone once abuse handling and a designated DMCA agent are in place.',
actions: [
{ label: 'Hosting dashboard', href: '/dashboard', primary: true },
{ label: 'How it works', href: '#how' },
],
-- }) + sc.steps({
-- id: 'how',
-- title: 'How a deploy works',
-- items: [
-- { title: 'Make a project and a site', text: 'A project holds sites and the people who may change them. Each site gets its own address under openvibe.website.' },
-- { title: 'Upload, or connect Git', text: 'Upload a folder or a .tar.gz from the dashboard or the API, or deploy from a public Git repository. Nothing is built or executed: the files are served as they are.' },
-- { title: 'Preview, then activate', text: 'Check a deploy at its private preview address, then make it live. Activation switches one pointer, so rolling back is one step too.' },
-- ],
-- }) + sc.features({
-- title: 'What works now',
-- items: [
-- { icon: 'fa-cubes', title: 'Immutable deploys', text: 'Every deploy is content-addressed and kept: files are stored once by their hash, and a deploy never changes after upload.' },
-- { icon: 'fa-clock-rotate-left', title: 'Previews and rollback', text: 'A private preview for the project\'s members, one-step activation, and a rollback to the deploy that was live before.' },
-- { icon: 'fa-code-branch', title: 'Git deploys', text: 'Point a site at a public repository and branch; each ingest lands as a preview for someone to approve.' },
-- { icon: 'fa-globe', title: 'Your own address', text: 'Sites serve under openvibe.website. A custom domain is served once its TXT record proves it is yours.' },
-- { icon: 'fa-sliders', title: 'Headers and redirects', text: 'Per-site response headers, redirects and a single-page-app fallback. Platform security headers always win.' },
-- { icon: 'fa-users', title: 'Members and roles', text: 'Owners, maintainers and deployers per project; an app can deploy too, with a grant and a role, never as the owner.' },
-- { icon: 'fa-shield-halved', title: 'Takedowns kept for review', text: 'OpenVibe staff can stop serving a site or a whole project while its content is kept for review, and lift it again.' },
-- { icon: 'fa-terminal', title: 'An API for all of it', text: 'Projects, sites, deploys, domains and configuration over /api/v1, with OpenVibe.Network tokens and space.* capabilities.' },
-- ],
-- }) + sc.code({
-- title: 'The same over the API',
-- lede: 'With a Network token for openvibe.space that holds space.deploy.create and a role in the site\'s project.',
-- samples: [{ label: 'Upload and activate a build', lang: 'bash', code: `tar -czf site.tar.gz -C dist .
--curl -X POST "https://openvibe.space/api/v1/sites/$SITE_ID/deploys?activate=1" \\
-- -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/gzip' \\
-- --data-binary @site.tar.gz` }],
-- }) + sc.limits({
-- title: 'Quotas per project',
-- lede: 'What a project may use today; staff can raise a project\'s quota.',
-- columns: ['Production', 'Sandbox'],
-- rows: [
-- row('Storage', 'storageBytes', size),
-- row('Deploys per day', 'deploysPerDay'),
-- row('Sites', 'sites'),
-- row('Custom domains', 'customDomains'),
-- row('Files per deploy', 'maxFiles', (n) => n.toLocaleString('en-US')),
-- row('Largest file', 'maxFileBytes', size),
-- ],
-- source: 'https://github.com/OpenVibers/OpenVibe.Space/blob/main/server/hosting-config.js',
-- }) + `<section class="sc-sec" id="questions" aria-labelledby="space-faq-h"><h2 id="space-faq-h">Questions</h2>${HOME_FAQ.map((f) => `<details class="space-faq"><summary>${esc(f.q)}</summary><p>${esc(f.a)}</p></details>`).join('')}</section>` + sc.cta({
++ }) + foldBlock({
++ id: 'questions',
++ head: '<h2 id="space-faq-h">Questions</h2>',
++ html: `<section class="sc-sec" id="questions" aria-labelledby="space-faq-h"><h2>Questions</h2>${HOME_FAQ.map((f) => `<details class="space-faq"><summary>${esc(f.q)}</summary><p>${esc(f.a)}</p></details>`).join('')}</section>`,
++ }) + sc.cta({
title: 'Looking for the forum?',
text: 'The forum moved to OpenVibe.Community.',
actions: [{ label: 'Open the forum', href: FORUM_URL, primary: true }],
@@@ -119,6 +133,8 @@ curl -X POST "https://openvibe.space/api/v1/sites/$SITE_ID/deploys?activate=1" \
return renderPage({
canonicalPath: '/', active: 'home', styles: [sc.STYLESHEET], body, description: HOME_DESCRIPTION,
agentLayer: true, llms: '/llms.txt',
++ // 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){${pickRules('parts', parts.map((p) => p.id))}}}`],
jsonLd: [
{ '@context': 'https://schema.org', '@type': 'WebSite', name: 'OpenVibe.Space', url: `${site}/`, description: HOME_DESCRIPTION },
{ '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: HOME_FAQ.map((f) => ({ '@type': 'Question', name: f.q, acceptedAnswer: { '@type': 'Answer', text: f.a } })) },
ddiff --git a/server/render/short.js b/server/render/short.js
nnew file mode 100644
iindex 0000000..9730d05
---- /dev/null
++++ b/server/render/short.js
@@@ -0,0 +1,56 @@
++'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/space.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 }) {
++ 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="sp-fold sp-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/test/home-short.test.js b/test/home-short.test.js
nnew file mode 100644
iindex 0000000..31efba9
---- /dev/null
++++ b/test/home-short.test.js
@@@ -0,0 +1,58 @@
++'use strict';
++/**
++ * The home on a phone (the owner's short-pages rule, 2026-10-30): the home's six 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.get('/');
++ 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">') + 9000);
++ 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 Space 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 ['Start from a template', 'How a space comes together', 'How a deploy works', 'What works now', 'The same over the API', 'Quotas per project']) {
++ 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.get('/');
++ const page = r.text;
++ assert.match(page, /<style>@supports selector\(:has\(\*\)\)\{@media \(max-width:640px\)\{\.pick:has\(#pk-parts-templates:checked\) #templates\{display:block\}/, 'the rule that shows the picked part');
++ const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'space.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.get('/');
++ const page = r.text;
++ assert.ok(page.includes('<details class="sp-fold sp-fold-block" id="questions" open>'), 'the questions are a fold, open in the page');
++ assert.ok(page.includes('document.getElementById("questions")'), 'the fold has the script that closes it on a phone');
++ // The content of the fold stays in the page.
++ assert.match(page, /What is a space\?/);
++ assert.match(page, /Where did the forum go\?/);
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });