openvibers/openvibe.space Commits
Home on phones: one part of Space at a time (#136)
Changes
@@@ -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; } }@@@ -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' },@@@ -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 } })) },nnew file mode 100644@@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');++++/**++ * 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 };nnew file mode 100644@@@ -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); });
@@ -82,3 +82,46 @@ body {
@@ -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. */
/* 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; } }
@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; } }
@@ -145,6 +145,8 @@ function renderPage(o) {
@@ -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">',
'<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)}">`,
`<meta name="ov-boost" content="space@${escapeHtml(RELEASE)}">`,
`<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,
`<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'),
].filter(Boolean).join('\n'),
bodyClass: o.bodyClass,
bodyClass: o.bodyClass,
bodyAttributes: { 'data-page': o.active || 'page' },
bodyAttributes: { 'data-page': o.active || 'page' },
@@ -10,6 +10,7 @@
@@ -10,6 +10,7 @@
const config = require('../config');
const config = require('../config');
const sc = require('openvibe-shared/showcase');
const sc = require('openvibe-shared/showcase');
const { renderPage, escapeHtml: esc } = require('./layout');
const { renderPage, escapeHtml: esc } = require('./layout');
+const { pickBlock, pickRules, foldBlock } = require('./short');
const FORUM_URL = `${config.communityUrl}/s`;
const FORUM_URL = `${config.communityUrl}/s`;
const FORUM_LABEL = FORUM_URL.replace(/^https?:\/\//, '');
const FORUM_LABEL = FORUM_URL.replace(/^https?:\/\//, '');
@@ -40,6 +41,67 @@ function defaultQuotas() {
@@ -40,6 +41,67 @@ function defaultQuotas() {
function homePage() {
function homePage() {
const q = defaultQuotas();
const q = defaultQuotas();
const row = (label, key, fmt = String) => ({ label, values: [fmt(q.production[key]), fmt(q.sandbox[key])] });
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({
const body = sc.hero({
eyebrow: 'OpenVibe.Space · spaces',
eyebrow: 'OpenVibe.Space · spaces',
title: 'Make a space.',
title: 'Make a space.',
@@ -50,67 +112,19 @@ function homePage() {
@@ -50,67 +112,19 @@ function homePage() {
{ label: 'Browse spaces', href: '/spaces', icon: 'fa-shapes' },
{ 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.',
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.',
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.',
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: [
actions: [
{ label: 'Hosting dashboard', href: '/dashboard', primary: true },
{ label: 'Hosting dashboard', href: '/dashboard', primary: true },
{ label: 'How it works', href: '#how' },
{ 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?',
title: 'Looking for the forum?',
text: 'The forum moved to OpenVibe.Community.',
text: 'The forum moved to OpenVibe.Community.',
actions: [{ label: 'Open the forum', href: FORUM_URL, primary: true }],
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" \
@@ -119,6 +133,8 @@ curl -X POST "https://openvibe.space/api/v1/sites/$SITE_ID/deploys?activate=1" \
return renderPage({
return renderPage({
canonicalPath: '/', active: 'home', styles: [sc.STYLESHEET], body, description: HOME_DESCRIPTION,
canonicalPath: '/', active: 'home', styles: [sc.STYLESHEET], body, description: HOME_DESCRIPTION,
agentLayer: true, llms: '/llms.txt',
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: [
jsonLd: [
{ '@context': 'https://schema.org', '@type': 'WebSite', name: 'OpenVibe.Space', url: `${site}/`, description: HOME_DESCRIPTION },
{ '@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 } })) },
{ '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: HOME_FAQ.map((f) => ({ '@type': 'Question', name: f.q, acceptedAnswer: { '@type': 'Answer', text: f.a } })) },
new file mode 100644
new file mode 100644
@@ -0,0 +1,56 @@
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
+
+/**
+ * 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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,58 @@
@@ -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); });