openvibers/openvibe.zone Commits

Short home on a phone: the code samples become chips, the rest folds (#90)

OP OpenVibers committed 14 days ago · e54309a013 · 6 files, +218 −43

The home was 5.8 screens tall at 390 px. The four code samples are now one
pick block: a row of chips picks one and the chip names it, and every sample
stays in the page. What a zone gives you, the limits, the questions and what
shipped are folds: closed on a phone, open where there is room, and their
content stays in the page. The home is 3.1 screens tall at 390 px and looks
the same as before at 1280 px.

Changes

ddiff --git a/public/css/app.css b/public/css/app.css
iindex c9d1e44..8588fec 100644
---- a/public/css/app.css
++++ b/public/css/app.css
@@@ -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; }
++}
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 95b5c24..495dfe2 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -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/ --recursive
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
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 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 tokens
const 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);
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex c6f10b1..a97bf7b 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -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')};
ddiff --git a/server/render/short.js b/server/render/short.js
nnew file mode 100644
iindex 0000000..9cdf474
---- /dev/null
++++ b/server/render/short.js
@@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
++
++/**
++ * 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 };
ddiff --git a/test/home-short.test.js b/test/home-short.test.js
nnew file mode 100644
iindex 0000000..1795826
---- /dev/null
++++ b/test/home-short.test.js
@@@ -0,0 +1,58 @@
++'use strict';
++/**
++ * The home on a phone (the owner's short-pages rule, 2026-10-30): the 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); });
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex 351a964..960ae54 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -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.2
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
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)
-- 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
};