openvibers/openvibe.zone Commits
Short home on a phone: the code samples become chips, the rest folds (#90)
Changes
@@@ -97,3 +97,44 @@ details input { font: inherit; padding: 7px 9px; border-radius: 8px; border: 1px/* 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) { .zn-faq summary { min-height: 44px; } }++++/* Pick (the owner's short-pages rule, 2026-10-30): on a phone the home's blocks of the same kind are one at a++ time, picked with a row of chips. Radio inputs and :has(), no script: the chips and the one-block rule live under++ 640 px and only where :has() is understood, so a wider screen, or an older browser, shows every block and no chips. */++.pick-head { display: none; }++.pick-row { display: none; }++.pick-pane { min-width: 0; }++@supports selector(:has(*)) { @media (max-width: 640px) {++ .pick-head { display: block; }++ .pick-head h2 { margin: 0 0 4px; font-size: 1.3rem; }++ .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-pane { display: none; }++ /* The chip names the block, so the block's own heading gives way to it. */++ .zn-pick-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. */++.zn-fold-block { margin: 28px 0; }++.zn-fold-block > summary { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 48px; padding: 0 16px; cursor: pointer; color: var(--text-primary, #e6edf7); font-weight: 700; list-style: none; border: 1px solid var(--border, #1f2d47); border-radius: 14px; background: var(--bg-card, #131c2e); }++.zn-fold-block > summary::-webkit-details-marker { display: none; }++.zn-fold-block > summary h2 { margin: 0; padding: 0; border: 0; font-size: 1.05rem; }++.zn-fold-block > summary span { color: var(--text-secondary, #96a7c2); font-weight: 600; font-size: .86rem; text-align: right; }++.zn-fold-block > summary:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -2px; border-radius: 14px; }++.zn-fold-block .sc-sec { margin: 0; }++.zn-fold-block .sc-sec > h2 { display: none; }++.zn-fold-block .zn-list, .zn-fold-block .zn-faq { padding: 8px 16px 0; }++.zn-fold-block .ov-shipped-home { padding: 8px 16px 0; }++.zn-fold-block .ov-shipped-home h3 { display: none; }++@media (min-width: 641px) {++ .zn-fold-block { border: 0; background: none; margin-top: 40px; }++ .zn-fold-block > summary { display: block; min-height: 0; padding: 0; border: 0; background: none; }++ .zn-fold-block > summary h2 { margin: 0 0 6px; font-size: 1.3rem; border-bottom: 1px solid var(--border, #1f2d47); padding-bottom: .2em; }++ .zn-fold-block > summary span { display: none; }++ .zn-fold-block .zn-list, .zn-fold-block .zn-faq, .zn-fold-block .ov-shipped-home { padding: 0; }++}@@@ -26,6 +26,7 @@ const { asyncRouter } = require('./router');const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');const { sameOrigin } = require('./principal');const { html, raw } = require('../render/html');++const { pickBlock, pickRules, foldBlock } = require('../render/short');const { send } = require('../render/layout');const { ZoneError, RESERVED } = require('../zone/zones');const { ClientError } = require('../zone/clients');@@@ -96,33 +97,17 @@ function createPageRoutes(ctx) {note: prod ? `Each project includes ${bytes(prod.production)} in production and ${bytes(prod.sandbox)} in its sandbox at no charge.` : 'Each project includes storage in production and in its sandbox at no charge.',html: stats.length ? `<dl class="zn-stats">${stats.map(([dt, dd]) => `<div><dt>${dt}</dt><dd>${dd}</dd></div>`).join('')}</dl>` : undefined,});-- return {-- agent: true, index: true, cache: me ? null : PUBLIC_CACHE, styles: [showcase.STYLESHEET],-- jsonLd: [...homeJsonLd(config), { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: faq.map((f) => ({ '@type': 'Question', name: f.q, acceptedAnswer: { '@type': 'Answer', text: f.a } })) }],-- description: 'Private S3 buckets for your OpenVibe projects: create zones, issue access keys scoped to them, and use the AWS SDKs, rclone or any S3 tool.',-- body: html`${raw(hero)}--${raw(showcase.steps({-- title: 'From nothing to a bucket',-- items: [-- { title: 'Pick a project', text: 'Zones belong to an OpenVibe project. Make one on OpenVibe.Services if you have none.', href: 'https://openvibe.services/' },-- { title: 'Create a zone', text: 'A zone is a bucket: a name in one environment, sandbox or production, with an optional quota.' },-- { title: 'Issue an access key', text: 'Read, write or both, for every zone of the environment or only some. The secret is shown once.' },-- { title: 'Use any S3 tool', text: `Point it at ${s3} with path-style addressing.`, href: '/docs' },-- ],-- }))}--${raw(showcase.code({-- id: 'tools',-- title: 'With the tools you have',-- lede: `Path-style addressing, region us-east-1, Signature Version 4: ${sizeRule(L)}.`,-- samples: [-- { label: 'AWS CLI', lang: 'bash', code: `aws configure set aws_access_key_id ovk_…++ // The code samples are four blocks of the same kind: on a phone a row of chips picks one of them and the chip++ // names it. Every sample stays in the page, and a wide screen shows them all.++ const codePanes = [++ { id: 'aws-cli', label: 'AWS CLI', lang: 'bash', code: `aws configure set aws_access_key_id ovk_…aws configure set aws_secret_access_key …aws --endpoint-url ${s3} s3 cp ./site s3://assets/ --recursiveaws --endpoint-url ${s3} s3 ls s3://assets/` },-- { label: 'rclone', lang: 'bash', code: `rclone config create oz s3 provider Other endpoint ${s3} \\++ { id: 'rclone', label: 'rclone', lang: 'bash', code: `rclone config create oz s3 provider Other endpoint ${s3} \\access_key_id ovk_… secret_access_key … no_check_bucket truerclone sync ./backups oz:backups` },-- { label: 'Node (AWS SDK v3)', lang: 'js', code: `const { S3Client, PutObjectCommand, GetObjectCommand } = require('@aws-sdk/client-s3');++ { id: 'node', label: 'Node (AWS SDK v3)', lang: 'js', code: `const { S3Client, PutObjectCommand, GetObjectCommand } = require('@aws-sdk/client-s3');const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');const s3 = new S3Client({@@@ -131,38 +116,78 @@ const s3 = new S3Client({});await s3.send(new PutObjectCommand({ Bucket: 'assets', Key: 'hello.txt', Body: 'Hello' }));const url = await getSignedUrl(s3, new GetObjectCommand({ Bucket: 'assets', Key: 'hello.txt' }), { expiresIn: 3600 });` },-- { label: 'Manage zones (openvibe-sdk)', lang: 'js', code: `const { createClient } = require('openvibe-sdk/core');++ { id: 'sdk', label: 'Manage zones (openvibe-sdk)', lang: 'js', code: `const { createClient } = require('openvibe-sdk/core');const { createZoneClient } = require('openvibe-sdk/zone');const zone = createZoneClient(createClient({ tokenProvider: tokens })); // a project member's or app's tokensconst z = await zone.zones.create('prj_…', { env: 'production', name: 'assets', quota_bytes: 512 * 1024 ** 2 });console.log(await zone.zones.usage(z.id));` },++ ].map((c) => ({ id: c.id, label: c.label, html: String(html`<figure><figcaption>${c.label}</figcaption><pre tabindex="0" aria-label="${c.label}"><code${c.lang ? html` class="language-${c.lang}"` : ''}>${c.code}</code></pre></figure>`) }));++ // The rule that shows the picked sample is written for this page.++ const rules = [pickRules('tools', codePanes.map((p) => p.id))];++ return {++ agent: true, index: true, cache: me ? null : PUBLIC_CACHE, styles: [showcase.STYLESHEET],++ // The rules that show the picked block of each pick block live in the page's head.++ head: `<style>@supports selector(:has(*)){@media (max-width:640px){${rules.join('')}}</style>`,++ shipped: false,++ jsonLd: [...homeJsonLd(config), { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: faq.map((f) => ({ '@type': 'Question', name: f.q, acceptedAnswer: { '@type': 'Answer', text: f.a } })) }],++ description: 'Private S3 buckets for your OpenVibe projects: create zones, issue access keys scoped to them, and use the AWS SDKs, rclone or any S3 tool.',++ body: html`${raw(hero)}++${raw(showcase.steps({++ title: 'From nothing to a bucket',++ items: [++ { title: 'Pick a project', text: 'Zones belong to an OpenVibe project. Make one on OpenVibe.Services if you have none.', href: 'https://openvibe.services/' },++ { title: 'Create a zone', text: 'A zone is a bucket: a name in one environment, sandbox or production, with an optional quota.' },++ { title: 'Issue an access key', text: 'Read, write or both, for every zone of the environment or only some. The secret is shown once.' },++ { title: 'Use any S3 tool', text: `Point it at ${s3} with path-style addressing.`, href: '/docs' },],}))}--<section class="zn-section zn-home" aria-labelledby="zn-what"><h2 id="zn-what">What a zone gives you</h2>--<ul class="zn-list">++${raw(html`<section class="sc-sec zn-pick-sec" id="tools" aria-labelledby="zn-tools-h"><h2>With the tools you have</h2>++<p class="sc-lede">Path-style addressing, region us-east-1, Signature Version 4: ${sizeRule(L)}.</p>++${raw(pickBlock({ key: 'tools', label: 'The tools that reach a zone', head: 'With the tools you have', headId: 'zn-tools-h', bodyClass: 'sc-code', panes: codePanes }))}++</section>`)}++${raw(foldBlock({++ id: 'zn-what',++ head: '<h2 id="zn-what">What a zone gives you</h2>',++ html: String(html`<ul class="zn-list"><li><strong>Private by default.</strong> A named zone's objects are reached only with a signed request or a presigned URL, never a public link.</li><li><strong>Names that stay yours.</strong> A zone's name is never given to another zone, even after it is deleted, so a URL signed for one bucket can never reach a different one.</li><li><strong>Scoped keys.</strong> A key reads, writes or both, in one environment, for every zone or only the ones you name. Revoking it takes effect at once.</li><li><strong>Quotas you set.</strong> Each zone may have its own ceiling below the environment's.</li><li><strong>One deletion path.</strong> Deleting a zone removes every object and every copy of it.</li>--</ul></section>--${L.size ? raw(showcase.limits({-- title: 'Limits',-- lede: 'What one project gets in each environment, as OpenVibe.Media, which stores the objects, enforces them.',-- columns: ['Production', 'Sandbox'],-- rows: ['storage_bytes', 'single_upload_bytes', 'multipart_bytes', 'part_bytes', 'unfinished_upload_hours', 'deleted_retention_days'].filter((id) => L.get(id)).map((id) => {-- const l = L.get(id);-- const fmt = (v) => (l.unit === 'bytes' ? bytes(v) : `${Number(v).toLocaleString('en-US')} ${l.unit}`);-- return { label: l.label, values: [fmt(l.production), fmt(l.sandbox != null ? l.sandbox : l.production)] };++</ul>`),++ }))}++${L.size ? raw(foldBlock({++ id: 'zn-limits',++ head: '<h2 id="zn-limits-h">Limits</h2>',++ html: showcase.limits({++ title: 'Limits',++ lede: 'What one project gets in each environment, as OpenVibe.Media, which stores the objects, enforces them.',++ columns: ['Production', 'Sandbox'],++ rows: ['storage_bytes', 'single_upload_bytes', 'multipart_bytes', 'part_bytes', 'unfinished_upload_hours', 'deleted_retention_days'].filter((id) => L.get(id)).map((id) => {++ const l = L.get(id);++ const fmt = (v) => (l.unit === 'bytes' ? bytes(v) : `${Number(v).toLocaleString('en-US')} ${l.unit}`);++ return { label: l.label, values: [fmt(l.production), fmt(l.sandbox != null ? l.sandbox : l.production)] };++ }),++ source: 'https://openvibe.media/limits.json',}),-- source: 'https://openvibe.media/limits.json',})) : ''}--<section class="zn-section zn-home zn-faq" aria-labelledby="zn-faq-h"><h2 id="zn-faq-h">Questions</h2>${faq.map((f) => html`<details><summary>${f.q}</summary><p>${f.a}</p></details>`)}</section>`,++${raw(foldBlock({++ id: 'zn-faq',++ head: '<h2 id="zn-faq-h">Questions</h2>',++ html: String(html`<div class="zn-faq">${faq.map((f) => html`<details><summary>${f.q}</summary><p>${f.a}</p></details>`)}</div>`),++ }))}++${raw(foldBlock({++ id: 'zn-shipped',++ head: '<h2 id="zn-shipped-h">Recently shipped on OpenVibe.Zone</h2>',++ html: frame.shipped({ service: 'zone', title: 'Recently shipped on OpenVibe.Zone' }),++ }))}`,};}-- r.get('/', async (req, res) => page(req, res, await homePage({ signed: !!person(req) })));--++ r.get('/', async (req, res) => {++ const o = await homePage({ signed: !!person(req) });++ page(req, res, o);++ });// ── A person's projects ──────────────────────────────────r.get('/projects', async (req, res) => {const me = person(req);@@@ -99,14 +99,15 @@ ${appIcon.headTags({ site: 'zone' })}${(o.styles || []).map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`).join('\n')}${render(scripts)}<meta name="ov-boost" content="zone@${esc(RELEASE)}">--<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,++<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>++${o.head || ''}`,body: `<a class="skip" href="#main">Skip to content</a><div id="navbar-mount"></div><noscript><div class="account-bar" role="navigation" aria-label="Account">${render(account)}</div></noscript><main id="main" class="page">${render(crumbs)}${render(o.body)}--${o.path === '/' ? frame.shipped({ service: 'zone', title: `Recently shipped on ${SITE_NAME}` }) : ''}++${o.path === '/' && o.shipped !== false ? frame.shipped({ service: 'zone', title: `Recently shipped on ${SITE_NAME}` }) : ''}</main><script>window.__OV_PAGE = ${JSON.stringify({ navbar: nav, footer }).replace(/</g, '\\u003c')};nnew file mode 100644@@@ -0,0 +1,50 @@++'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/app.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; the block's heading gives way to the chip that names it on a++ * phone, and the chips are gone on a wide screen, where every pane shows at once.++ *++ * pickBlock({ key, label, head, panes }) panes: [{ id, label, html }]++ */++function pickBlock({ key, label, head = '', headId = '', bodyClass = '', panes }) {++ if (!panes || !panes.length) return '';++ const chips = panes.map((p, i) => `<input type="radio" name="pick-${esc(key)}" id="pk-${esc(key)}-${esc(p.id)}" value="${esc(p.id)}"${i === 0 ? ' checked' : ''}><label for="pk-${esc(key)}-${esc(p.id)}">${esc(p.label)}</label>`).join('');++ const body = panes.map((p) => `<div class="pick-pane" id="pp-${esc(key)}-${esc(p.id)}">${p.html}</div>`).join('');++ const heading = `<h2${headId ? ` id="${esc(headId)}"` : ''}>${esc(head || label)}</h2>`;++ return `<div class="pick" id="pick-${esc(key)}"><div class="pick-bar"><div class="pick-head">${heading}</div><div class="pick-row" role="radiogroup" aria-label="${esc(label)}">${chips}</div></div><div class="pick-body${bodyClass ? ` ${bodyClass}` : ''}">${body}</div></div>`;++}++++/** The rules for every pane of a pick block, in the order they appear: one rule per pane, so the CSS is written for the page. */++function pickRules(key, ids) {++ return ids.map((id) => `.pick:has(#pk-${key}-${id}:checked) #pp-${key}-${id}{display:block}`).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="zn-fold zn-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, 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 code samples 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 block 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/boot');++++(async () => {++ // Media's published limits, so the page shows the limits table as it does in production.++ const t = await boot({++ mediaLimits: {++ get: async () => new Map([++ ['storage_bytes', { id: 'storage_bytes', label: 'Stored bytes', unit: 'bytes', production: 1024 ** 3, sandbox: 100 * 1024 ** 2 }],++ ]),++ },++ });++ await check('the home on a phone: the code samples are chips, the rest are folds', async () => {++ const r = await t.get('/');++ assert.strictEqual(r.status, 200);++ const text = r.text;++++ // The code samples: one pick block, a chip per sample, the first one picked.++ assert.ok(text.includes('<div class="pick" id="pick-tools">'), 'the code samples are one pick block');++ assert.match(text, /<div class="pick-row" role="radiogroup" aria-label="The tools that reach a zone">/);++ const chips = [...text.matchAll(/<input type="radio" name="pick-tools" id="pk-tools-([a-z-]+)" value="\1"( checked)?><label for="pk-tools-\1">([^<]+)<\/label>/g)];++ assert.deepStrictEqual(chips.map((m) => m[1]), ['aws-cli', 'rclone', 'node', 'sdk'], 'a chip per sample');++ assert.deepStrictEqual(chips.filter((m) => m[2]).map((m) => m[1]), ['aws-cli'], 'the first sample is picked');++ assert.strictEqual((text.match(/class="pick-pane"/g) || []).length, chips.length, 'one pane per chip');++ // Every sample stays in the page for the crawlers.++ for (const label of ['AWS CLI', 'rclone', 'Node (AWS SDK v3)', 'Manage zones (openvibe-sdk)']) {++ assert.ok(text.includes(`<figcaption>${label}</figcaption>`), `${label} is in the page`);++ }++ // The rule that shows the picked sample is written for this page.++ assert.ok(text.includes('.pick:has(#pk-tools-aws-cli:checked) #pp-tools-aws-cli{display:block}'), 'the picked sample shows');++++ // The folds: open in the page, closed on a phone, and their content stays in the page.++ for (const id of ['zn-what', 'zn-limits', 'zn-faq', 'zn-shipped']) {++ assert.ok(text.includes(`<details class="zn-fold zn-fold-block" id="${id}" open>`), `${id} is a fold, open in the page`);++ assert.ok(text.includes(`document.getElementById("${id}")`), `${id} has the script that closes it on a phone`);++ }++ assert.ok(text.includes('Private by default.'), 'what a zone gives you is still in the page');++ assert.ok(text.includes('Which S3 operations work?'), 'the questions are still in the page');++ assert.ok(text.includes('ov-shipped-home'), 'what shipped is still in the page');++++ // The CSS guard: the chips are hidden and the blocks all show until 640 px and where :has() is understood.++ const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'app.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-pane \{ display: none; \}/, 'the blocks are hidden on a phone');++ });++ await t.close();++ done();++})().catch((e) => { console.error(e); process.exit(1); });@@@ -14,14 +14,14 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');// Budgets are set from the numbers measured on 2026-10-08 (the real ones, fresh database), rounded up with about 10%// headroom; file counts are the measured counts and external files stay at 0.const BUDGETS = {-- htmlRawKB: 33, // 28.8 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; measured 24.9 (the home page: hero, features, steps, cta, JSON-LD)++ htmlRawKB: 35, // 34.6 with the phone pick blocks and folds (2026-10-30); 28.8 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; measured 24.9 (the home page: hero, features, steps, cta, JSON-LD)htmlBrotliKB: 9, // 7.1 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 6.2jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)jsRawKB: 322, // 321.1 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 318.1 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 315.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 307.8 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 300.5 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 293 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 287.2 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 273.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 264.3 with Shared 4.13.0 (2026-10-26: account links); 239.8jsBrotliKB: 79, // 78.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 77.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 76.1 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 74.2 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 70.5 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 67.9 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 65.3 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 56.5cssFiles: 2, // 2 (app.css + the cached /shared/showcase.css)-- cssRawKB: 37, // 36.1 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 31.5 openvibe-shared 4.26.1 (hero visual); 26 with Shared 4.13.0 (2026-10-26: account links); 18.7 (app.css 7.1 with Play's cards, boards and achievement tiles, plus showcase.css; 2026-10-14)-- cssBrotliKB: 7.9, // 7.7 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); raised for openvibe-shared v4.39.0 (rail wrap, card sub/preview, 44 px touch targets, stats links, launcher columns: +1.3 KB raw over kit.css, room.css and showcase.css); measured 7.3++ cssRawKB: 40, // 39.4 with the phone pick and fold rules (2026-10-30); 36.1 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 31.5 openvibe-shared 4.26.1 (hero visual); 26 with Shared 4.13.0 (2026-10-26: account links); 18.7 (app.css 7.1 with Play's cards, boards and achievement tiles, plus showcase.css; 2026-10-14)++ cssBrotliKB: 8.4, // 8.3 with the phone pick and fold rules (2026-10-30); 7.7 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); raised for openvibe-shared v4.39.0 (rail wrap, card sub/preview, 44 px touch targets, stats links, launcher columns: +1.3 KB raw over kit.css, room.css and showcase.css); measured 7.3externalFiles: 0, // 0};
@@ -97,3 +97,44 @@ details input { font: inherit; padding: 7px 9px; border-radius: 8px; border: 1px
@@ -97,3 +97,44 @@ details input { font: inherit; padding: 7px 9px; border-radius: 8px; border: 1px
/* 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) { .zn-faq summary { min-height: 44px; } }
@media (pointer: coarse) { .zn-faq summary { min-height: 44px; } }
+
+/* Pick (the owner's short-pages rule, 2026-10-30): on a phone the home's blocks of the same kind are one at a
+ time, picked with a row of chips. Radio inputs and :has(), no script: the chips and the one-block rule live under
+ 640 px and only where :has() is understood, so a wider screen, or an older browser, shows every block and no chips. */
+.pick-head { display: none; }
+.pick-row { display: none; }
+.pick-pane { min-width: 0; }
+@supports selector(:has(*)) { @media (max-width: 640px) {
+ .pick-head { display: block; }
+ .pick-head h2 { margin: 0 0 4px; font-size: 1.3rem; }
+ .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-pane { display: none; }
+ /* The chip names the block, so the block's own heading gives way to it. */
+ .zn-pick-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. */
+.zn-fold-block { margin: 28px 0; }
+.zn-fold-block > summary { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 48px; padding: 0 16px; cursor: pointer; color: var(--text-primary, #e6edf7); font-weight: 700; list-style: none; border: 1px solid var(--border, #1f2d47); border-radius: 14px; background: var(--bg-card, #131c2e); }
+.zn-fold-block > summary::-webkit-details-marker { display: none; }
+.zn-fold-block > summary h2 { margin: 0; padding: 0; border: 0; font-size: 1.05rem; }
+.zn-fold-block > summary span { color: var(--text-secondary, #96a7c2); font-weight: 600; font-size: .86rem; text-align: right; }
+.zn-fold-block > summary:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -2px; border-radius: 14px; }
+.zn-fold-block .sc-sec { margin: 0; }
+.zn-fold-block .sc-sec > h2 { display: none; }
+.zn-fold-block .zn-list, .zn-fold-block .zn-faq { padding: 8px 16px 0; }
+.zn-fold-block .ov-shipped-home { padding: 8px 16px 0; }
+.zn-fold-block .ov-shipped-home h3 { display: none; }
+@media (min-width: 641px) {
+ .zn-fold-block { border: 0; background: none; margin-top: 40px; }
+ .zn-fold-block > summary { display: block; min-height: 0; padding: 0; border: 0; background: none; }
+ .zn-fold-block > summary h2 { margin: 0 0 6px; font-size: 1.3rem; border-bottom: 1px solid var(--border, #1f2d47); padding-bottom: .2em; }
+ .zn-fold-block > summary span { display: none; }
+ .zn-fold-block .zn-list, .zn-fold-block .zn-faq, .zn-fold-block .ov-shipped-home { padding: 0; }
+}
@@ -26,6 +26,7 @@ const { asyncRouter } = require('./router');
@@ -26,6 +26,7 @@ const { asyncRouter } = require('./router');
const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
const { sameOrigin } = require('./principal');
const { sameOrigin } = require('./principal');
const { html, raw } = require('../render/html');
const { html, raw } = require('../render/html');
+const { pickBlock, pickRules, foldBlock } = require('../render/short');
const { send } = require('../render/layout');
const { send } = require('../render/layout');
const { ZoneError, RESERVED } = require('../zone/zones');
const { ZoneError, RESERVED } = require('../zone/zones');
const { ClientError } = require('../zone/clients');
const { ClientError } = require('../zone/clients');
@@ -96,33 +97,17 @@ function createPageRoutes(ctx) {
@@ -96,33 +97,17 @@ function createPageRoutes(ctx) {
note: prod ? `Each project includes ${bytes(prod.production)} in production and ${bytes(prod.sandbox)} in its sandbox at no charge.` : 'Each project includes storage in production and in its sandbox at no charge.',
note: prod ? `Each project includes ${bytes(prod.production)} in production and ${bytes(prod.sandbox)} in its sandbox at no charge.` : 'Each project includes storage in production and in its sandbox at no charge.',
html: stats.length ? `<dl class="zn-stats">${stats.map(([dt, dd]) => `<div><dt>${dt}</dt><dd>${dd}</dd></div>`).join('')}</dl>` : undefined,
html: stats.length ? `<dl class="zn-stats">${stats.map(([dt, dd]) => `<div><dt>${dt}</dt><dd>${dd}</dd></div>`).join('')}</dl>` : undefined,
});
});
- return {
- agent: true, index: true, cache: me ? null : PUBLIC_CACHE, styles: [showcase.STYLESHEET],
- jsonLd: [...homeJsonLd(config), { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: faq.map((f) => ({ '@type': 'Question', name: f.q, acceptedAnswer: { '@type': 'Answer', text: f.a } })) }],
- description: 'Private S3 buckets for your OpenVibe projects: create zones, issue access keys scoped to them, and use the AWS SDKs, rclone or any S3 tool.',
- body: html`${raw(hero)}
-${raw(showcase.steps({
- title: 'From nothing to a bucket',
- items: [
- { title: 'Pick a project', text: 'Zones belong to an OpenVibe project. Make one on OpenVibe.Services if you have none.', href: 'https://openvibe.services/' },
- { title: 'Create a zone', text: 'A zone is a bucket: a name in one environment, sandbox or production, with an optional quota.' },
- { title: 'Issue an access key', text: 'Read, write or both, for every zone of the environment or only some. The secret is shown once.' },
- { title: 'Use any S3 tool', text: `Point it at ${s3} with path-style addressing.`, href: '/docs' },
- ],
- }))}
-${raw(showcase.code({
- id: 'tools',
- title: 'With the tools you have',
- lede: `Path-style addressing, region us-east-1, Signature Version 4: ${sizeRule(L)}.`,
- samples: [
- { label: 'AWS CLI', lang: 'bash', code: `aws configure set aws_access_key_id ovk_…
+ // The code samples are four blocks of the same kind: on a phone a row of chips picks one of them and the chip
+ // names it. Every sample stays in the page, and a wide screen shows them all.
+ const codePanes = [
+ { id: 'aws-cli', label: 'AWS CLI', lang: 'bash', code: `aws configure set aws_access_key_id ovk_…
aws configure set aws_secret_access_key …
aws configure set aws_secret_access_key …
aws --endpoint-url ${s3} s3 cp ./site s3://assets/ --recursive
aws --endpoint-url ${s3} s3 cp ./site s3://assets/ --recursive
aws --endpoint-url ${s3} s3 ls s3://assets/` },
aws --endpoint-url ${s3} s3 ls s3://assets/` },
- { label: 'rclone', lang: 'bash', code: `rclone config create oz s3 provider Other endpoint ${s3} \\
+ { id: 'rclone', label: 'rclone', lang: 'bash', code: `rclone config create oz s3 provider Other endpoint ${s3} \\
access_key_id ovk_… secret_access_key … no_check_bucket true
access_key_id ovk_… secret_access_key … no_check_bucket true
rclone sync ./backups oz:backups` },
rclone sync ./backups oz:backups` },
- { label: 'Node (AWS SDK v3)', lang: 'js', code: `const { S3Client, PutObjectCommand, GetObjectCommand } = require('@aws-sdk/client-s3');
+ { id: 'node', label: 'Node (AWS SDK v3)', lang: 'js', code: `const { S3Client, PutObjectCommand, GetObjectCommand } = require('@aws-sdk/client-s3');
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
const s3 = new S3Client({
const s3 = new S3Client({
@@ -131,38 +116,78 @@ const s3 = new S3Client({
@@ -131,38 +116,78 @@ const s3 = new S3Client({
});
});
await s3.send(new PutObjectCommand({ Bucket: 'assets', Key: 'hello.txt', Body: 'Hello' }));
await s3.send(new PutObjectCommand({ Bucket: 'assets', Key: 'hello.txt', Body: 'Hello' }));
const url = await getSignedUrl(s3, new GetObjectCommand({ Bucket: 'assets', Key: 'hello.txt' }), { expiresIn: 3600 });` },
const url = await getSignedUrl(s3, new GetObjectCommand({ Bucket: 'assets', Key: 'hello.txt' }), { expiresIn: 3600 });` },
- { label: 'Manage zones (openvibe-sdk)', lang: 'js', code: `const { createClient } = require('openvibe-sdk/core');
+ { id: 'sdk', label: 'Manage zones (openvibe-sdk)', lang: 'js', code: `const { createClient } = require('openvibe-sdk/core');
const { createZoneClient } = require('openvibe-sdk/zone');
const { createZoneClient } = require('openvibe-sdk/zone');
const zone = createZoneClient(createClient({ tokenProvider: tokens })); // a project member's or app's tokens
const zone = createZoneClient(createClient({ tokenProvider: tokens })); // a project member's or app's tokens
const z = await zone.zones.create('prj_…', { env: 'production', name: 'assets', quota_bytes: 512 * 1024 ** 2 });
const z = await zone.zones.create('prj_…', { env: 'production', name: 'assets', quota_bytes: 512 * 1024 ** 2 });
console.log(await zone.zones.usage(z.id));` },
console.log(await zone.zones.usage(z.id));` },
+ ].map((c) => ({ id: c.id, label: c.label, html: String(html`<figure><figcaption>${c.label}</figcaption><pre tabindex="0" aria-label="${c.label}"><code${c.lang ? html` class="language-${c.lang}"` : ''}>${c.code}</code></pre></figure>`) }));
+ // The rule that shows the picked sample is written for this page.
+ const rules = [pickRules('tools', codePanes.map((p) => p.id))];
+ return {
+ agent: true, index: true, cache: me ? null : PUBLIC_CACHE, styles: [showcase.STYLESHEET],
+ // The rules that show the picked block of each pick block live in the page's head.
+ head: `<style>@supports selector(:has(*)){@media (max-width:640px){${rules.join('')}}</style>`,
+ shipped: false,
+ jsonLd: [...homeJsonLd(config), { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: faq.map((f) => ({ '@type': 'Question', name: f.q, acceptedAnswer: { '@type': 'Answer', text: f.a } })) }],
+ description: 'Private S3 buckets for your OpenVibe projects: create zones, issue access keys scoped to them, and use the AWS SDKs, rclone or any S3 tool.',
+ body: html`${raw(hero)}
+${raw(showcase.steps({
+ title: 'From nothing to a bucket',
+ items: [
+ { title: 'Pick a project', text: 'Zones belong to an OpenVibe project. Make one on OpenVibe.Services if you have none.', href: 'https://openvibe.services/' },
+ { title: 'Create a zone', text: 'A zone is a bucket: a name in one environment, sandbox or production, with an optional quota.' },
+ { title: 'Issue an access key', text: 'Read, write or both, for every zone of the environment or only some. The secret is shown once.' },
+ { title: 'Use any S3 tool', text: `Point it at ${s3} with path-style addressing.`, href: '/docs' },
],
],
}))}
}))}
-<section class="zn-section zn-home" aria-labelledby="zn-what"><h2 id="zn-what">What a zone gives you</h2>
-<ul class="zn-list">
+${raw(html`<section class="sc-sec zn-pick-sec" id="tools" aria-labelledby="zn-tools-h"><h2>With the tools you have</h2>
+<p class="sc-lede">Path-style addressing, region us-east-1, Signature Version 4: ${sizeRule(L)}.</p>
+${raw(pickBlock({ key: 'tools', label: 'The tools that reach a zone', head: 'With the tools you have', headId: 'zn-tools-h', bodyClass: 'sc-code', panes: codePanes }))}
+</section>`)}
+${raw(foldBlock({
+ id: 'zn-what',
+ head: '<h2 id="zn-what">What a zone gives you</h2>',
+ html: String(html`<ul class="zn-list">
<li><strong>Private by default.</strong> A named zone's objects are reached only with a signed request or a presigned URL, never a public link.</li>
<li><strong>Private by default.</strong> A named zone's objects are reached only with a signed request or a presigned URL, never a public link.</li>
<li><strong>Names that stay yours.</strong> A zone's name is never given to another zone, even after it is deleted, so a URL signed for one bucket can never reach a different one.</li>
<li><strong>Names that stay yours.</strong> A zone's name is never given to another zone, even after it is deleted, so a URL signed for one bucket can never reach a different one.</li>
<li><strong>Scoped keys.</strong> A key reads, writes or both, in one environment, for every zone or only the ones you name. Revoking it takes effect at once.</li>
<li><strong>Scoped keys.</strong> A key reads, writes or both, in one environment, for every zone or only the ones you name. Revoking it takes effect at once.</li>
<li><strong>Quotas you set.</strong> Each zone may have its own ceiling below the environment's.</li>
<li><strong>Quotas you set.</strong> Each zone may have its own ceiling below the environment's.</li>
<li><strong>One deletion path.</strong> Deleting a zone removes every object and every copy of it.</li>
<li><strong>One deletion path.</strong> Deleting a zone removes every object and every copy of it.</li>
-</ul></section>
-${L.size ? raw(showcase.limits({
- title: 'Limits',
- lede: 'What one project gets in each environment, as OpenVibe.Media, which stores the objects, enforces them.',
- columns: ['Production', 'Sandbox'],
- rows: ['storage_bytes', 'single_upload_bytes', 'multipart_bytes', 'part_bytes', 'unfinished_upload_hours', 'deleted_retention_days'].filter((id) => L.get(id)).map((id) => {
- const l = L.get(id);
- const fmt = (v) => (l.unit === 'bytes' ? bytes(v) : `${Number(v).toLocaleString('en-US')} ${l.unit}`);
- return { label: l.label, values: [fmt(l.production), fmt(l.sandbox != null ? l.sandbox : l.production)] };
+</ul>`),
+ }))}
+${L.size ? raw(foldBlock({
+ id: 'zn-limits',
+ head: '<h2 id="zn-limits-h">Limits</h2>',
+ html: showcase.limits({
+ title: 'Limits',
+ lede: 'What one project gets in each environment, as OpenVibe.Media, which stores the objects, enforces them.',
+ columns: ['Production', 'Sandbox'],
+ rows: ['storage_bytes', 'single_upload_bytes', 'multipart_bytes', 'part_bytes', 'unfinished_upload_hours', 'deleted_retention_days'].filter((id) => L.get(id)).map((id) => {
+ const l = L.get(id);
+ const fmt = (v) => (l.unit === 'bytes' ? bytes(v) : `${Number(v).toLocaleString('en-US')} ${l.unit}`);
+ return { label: l.label, values: [fmt(l.production), fmt(l.sandbox != null ? l.sandbox : l.production)] };
+ }),
+ source: 'https://openvibe.media/limits.json',
}),
}),
- source: 'https://openvibe.media/limits.json',
})) : ''}
})) : ''}
-<section class="zn-section zn-home zn-faq" aria-labelledby="zn-faq-h"><h2 id="zn-faq-h">Questions</h2>${faq.map((f) => html`<details><summary>${f.q}</summary><p>${f.a}</p></details>`)}</section>`,
+${raw(foldBlock({
+ id: 'zn-faq',
+ head: '<h2 id="zn-faq-h">Questions</h2>',
+ html: String(html`<div class="zn-faq">${faq.map((f) => html`<details><summary>${f.q}</summary><p>${f.a}</p></details>`)}</div>`),
+ }))}
+${raw(foldBlock({
+ id: 'zn-shipped',
+ head: '<h2 id="zn-shipped-h">Recently shipped on OpenVibe.Zone</h2>',
+ html: frame.shipped({ service: 'zone', title: 'Recently shipped on OpenVibe.Zone' }),
+ }))}`,
};
};
}
}
- r.get('/', async (req, res) => page(req, res, await homePage({ signed: !!person(req) })));
-
+ r.get('/', async (req, res) => {
+ const o = await homePage({ signed: !!person(req) });
+ page(req, res, o);
+ });
// ── A person's projects ──────────────────────────────────
// ── A person's projects ──────────────────────────────────
r.get('/projects', async (req, res) => {
r.get('/projects', async (req, res) => {
const me = person(req);
const me = person(req);
@@ -99,14 +99,15 @@ ${appIcon.headTags({ site: 'zone' })}
@@ -99,14 +99,15 @@ ${appIcon.headTags({ site: 'zone' })}
${(o.styles || []).map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`).join('\n')}
${(o.styles || []).map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`).join('\n')}
${render(scripts)}
${render(scripts)}
<meta name="ov-boost" content="zone@${esc(RELEASE)}">
<meta name="ov-boost" content="zone@${esc(RELEASE)}">
-<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,
+<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>
+${o.head || ''}`,
body: `<a class="skip" href="#main">Skip to content</a>
body: `<a class="skip" href="#main">Skip to content</a>
<div id="navbar-mount"></div>
<div id="navbar-mount"></div>
<noscript><div class="account-bar" role="navigation" aria-label="Account">${render(account)}</div></noscript>
<noscript><div class="account-bar" role="navigation" aria-label="Account">${render(account)}</div></noscript>
<main id="main" class="page">
<main id="main" class="page">
${render(crumbs)}
${render(crumbs)}
${render(o.body)}
${render(o.body)}
-${o.path === '/' ? frame.shipped({ service: 'zone', title: `Recently shipped on ${SITE_NAME}` }) : ''}
+${o.path === '/' && o.shipped !== false ? frame.shipped({ service: 'zone', title: `Recently shipped on ${SITE_NAME}` }) : ''}
</main>
</main>
<script>
<script>
window.__OV_PAGE = ${JSON.stringify({ navbar: nav, footer }).replace(/</g, '\\u003c')};
window.__OV_PAGE = ${JSON.stringify({ navbar: nav, footer }).replace(/</g, '\\u003c')};
new file mode 100644
new file mode 100644
@@ -0,0 +1,50 @@
@@ -0,0 +1,50 @@
+'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/app.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; the block's heading gives way to the chip that names it on a
+ * phone, and the chips are gone on a wide screen, where every pane shows at once.
+ *
+ * pickBlock({ key, label, head, panes }) panes: [{ id, label, html }]
+ */
+function pickBlock({ key, label, head = '', headId = '', bodyClass = '', panes }) {
+ if (!panes || !panes.length) return '';
+ const chips = panes.map((p, i) => `<input type="radio" name="pick-${esc(key)}" id="pk-${esc(key)}-${esc(p.id)}" value="${esc(p.id)}"${i === 0 ? ' checked' : ''}><label for="pk-${esc(key)}-${esc(p.id)}">${esc(p.label)}</label>`).join('');
+ const body = panes.map((p) => `<div class="pick-pane" id="pp-${esc(key)}-${esc(p.id)}">${p.html}</div>`).join('');
+ const heading = `<h2${headId ? ` id="${esc(headId)}"` : ''}>${esc(head || label)}</h2>`;
+ return `<div class="pick" id="pick-${esc(key)}"><div class="pick-bar"><div class="pick-head">${heading}</div><div class="pick-row" role="radiogroup" aria-label="${esc(label)}">${chips}</div></div><div class="pick-body${bodyClass ? ` ${bodyClass}` : ''}">${body}</div></div>`;
+}
+
+/** The rules for every pane of a pick block, in the order they appear: one rule per pane, so the CSS is written for the page. */
+function pickRules(key, ids) {
+ return ids.map((id) => `.pick:has(#pk-${key}-${id}:checked) #pp-${key}-${id}{display:block}`).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="zn-fold zn-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, 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 code samples 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 block 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/boot');
+
+(async () => {
+ // Media's published limits, so the page shows the limits table as it does in production.
+ const t = await boot({
+ mediaLimits: {
+ get: async () => new Map([
+ ['storage_bytes', { id: 'storage_bytes', label: 'Stored bytes', unit: 'bytes', production: 1024 ** 3, sandbox: 100 * 1024 ** 2 }],
+ ]),
+ },
+ });
+ await check('the home on a phone: the code samples are chips, the rest are folds', async () => {
+ const r = await t.get('/');
+ assert.strictEqual(r.status, 200);
+ const text = r.text;
+
+ // The code samples: one pick block, a chip per sample, the first one picked.
+ assert.ok(text.includes('<div class="pick" id="pick-tools">'), 'the code samples are one pick block');
+ assert.match(text, /<div class="pick-row" role="radiogroup" aria-label="The tools that reach a zone">/);
+ const chips = [...text.matchAll(/<input type="radio" name="pick-tools" id="pk-tools-([a-z-]+)" value="\1"( checked)?><label for="pk-tools-\1">([^<]+)<\/label>/g)];
+ assert.deepStrictEqual(chips.map((m) => m[1]), ['aws-cli', 'rclone', 'node', 'sdk'], 'a chip per sample');
+ assert.deepStrictEqual(chips.filter((m) => m[2]).map((m) => m[1]), ['aws-cli'], 'the first sample is picked');
+ assert.strictEqual((text.match(/class="pick-pane"/g) || []).length, chips.length, 'one pane per chip');
+ // Every sample stays in the page for the crawlers.
+ for (const label of ['AWS CLI', 'rclone', 'Node (AWS SDK v3)', 'Manage zones (openvibe-sdk)']) {
+ assert.ok(text.includes(`<figcaption>${label}</figcaption>`), `${label} is in the page`);
+ }
+ // The rule that shows the picked sample is written for this page.
+ assert.ok(text.includes('.pick:has(#pk-tools-aws-cli:checked) #pp-tools-aws-cli{display:block}'), 'the picked sample shows');
+
+ // The folds: open in the page, closed on a phone, and their content stays in the page.
+ for (const id of ['zn-what', 'zn-limits', 'zn-faq', 'zn-shipped']) {
+ assert.ok(text.includes(`<details class="zn-fold zn-fold-block" id="${id}" open>`), `${id} is a fold, open in the page`);
+ assert.ok(text.includes(`document.getElementById("${id}")`), `${id} has the script that closes it on a phone`);
+ }
+ assert.ok(text.includes('Private by default.'), 'what a zone gives you is still in the page');
+ assert.ok(text.includes('Which S3 operations work?'), 'the questions are still in the page');
+ assert.ok(text.includes('ov-shipped-home'), 'what shipped is still in the page');
+
+ // The CSS guard: the chips are hidden and the blocks all show until 640 px and where :has() is understood.
+ const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'app.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-pane \{ display: none; \}/, 'the blocks are hidden on a phone');
+ });
+ await t.close();
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -14,14 +14,14 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
@@ -14,14 +14,14 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
// Budgets are set from the numbers measured on 2026-10-08 (the real ones, fresh database), rounded up with about 10%
// Budgets are set from the numbers measured on 2026-10-08 (the real ones, fresh database), rounded up with about 10%
// headroom; file counts are the measured counts and external files stay at 0.
// headroom; file counts are the measured counts and external files stay at 0.
const BUDGETS = {
const BUDGETS = {
- htmlRawKB: 33, // 28.8 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; measured 24.9 (the home page: hero, features, steps, cta, JSON-LD)
+ htmlRawKB: 35, // 34.6 with the phone pick blocks and folds (2026-10-30); 28.8 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; measured 24.9 (the home page: hero, features, steps, cta, JSON-LD)
htmlBrotliKB: 9, // 7.1 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 6.2
htmlBrotliKB: 9, // 7.1 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 6.2
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)
jsRawKB: 322, // 321.1 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 318.1 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 315.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 307.8 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 300.5 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 293 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 287.2 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 273.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 264.3 with Shared 4.13.0 (2026-10-26: account links); 239.8
jsRawKB: 322, // 321.1 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 318.1 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 315.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 307.8 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 300.5 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 293 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 287.2 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 273.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 264.3 with Shared 4.13.0 (2026-10-26: account links); 239.8
jsBrotliKB: 79, // 78.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 77.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 76.1 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 74.2 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 70.5 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 67.9 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 65.3 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 56.5
jsBrotliKB: 79, // 78.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 77.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 76.1 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 74.2 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 70.5 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 67.9 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 65.3 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 56.5
cssFiles: 2, // 2 (app.css + the cached /shared/showcase.css)
cssFiles: 2, // 2 (app.css + the cached /shared/showcase.css)
- cssRawKB: 37, // 36.1 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 31.5 openvibe-shared 4.26.1 (hero visual); 26 with Shared 4.13.0 (2026-10-26: account links); 18.7 (app.css 7.1 with Play's cards, boards and achievement tiles, plus showcase.css; 2026-10-14)
- cssBrotliKB: 7.9, // 7.7 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); raised for openvibe-shared v4.39.0 (rail wrap, card sub/preview, 44 px touch targets, stats links, launcher columns: +1.3 KB raw over kit.css, room.css and showcase.css); measured 7.3
+ cssRawKB: 40, // 39.4 with the phone pick and fold rules (2026-10-30); 36.1 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 31.5 openvibe-shared 4.26.1 (hero visual); 26 with Shared 4.13.0 (2026-10-26: account links); 18.7 (app.css 7.1 with Play's cards, boards and achievement tiles, plus showcase.css; 2026-10-14)
+ cssBrotliKB: 8.4, // 8.3 with the phone pick and fold rules (2026-10-30); 7.7 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); raised for openvibe-shared v4.39.0 (rail wrap, card sub/preview, 44 px touch targets, stats links, launcher columns: +1.3 KB raw over kit.css, room.css and showcase.css); measured 7.3
externalFiles: 0, // 0
externalFiles: 0, // 0
};
};