openvibers/openvibe.deals Commits

Short home on phones: the deals and the browse ways become chips (#151)

OP OpenVibers committed 14 days ago · 242adf3164 · 7 files, +231 −28

The home on a phone was 6.3 screens. It is now 2.8: the deal sections and
the browse sections are two pick blocks, a row of chips picks one of them
(radio inputs and :has(), no script), and the parts a reader rarely needs
(the questions, the network, what was shipped) are folds, closed on a phone
and open where there is room. Every section stays in the page for crawlers,
and a wider screen shows them all with no chips.

Changes

ddiff --git a/public/css/deals.css b/public/css/deals.css
iindex 7167366..a20587b 100644
---- a/public/css/deals.css
++++ b/public/css/deals.css
@@@ -199,3 +199,44 @@ code { overflow-wrap: anywhere; }
.hm-card h3 a { display: inline-block; min-height: 44px; }
.cf-campaign a { display: inline-block; min-height: 44px; }
}
++
++/* Pick (the owner's short-pages rule, 2026-10-30): on a phone the front page's sections are one at a time, picked
++ with a row of chips. Radio inputs and :has(), no script: the chips and the one-pane rule live under 640 px and only
++ where :has() is understood, so a wider screen, or an older browser, shows every pane and no chips. Every pane 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 pane opens that pane 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 pane, so the pane's own heading gives way to it; the link to more stays. */
++ .pick .pick-pane .hm-head h2 { display: none; }
++ .pick .pick-pane .hm-head { justify-content: flex-end; }
++} }
++
++/* The fold (the owner's short-pages rule): closed on a phone, and where there is room it looks like the section it
++ replaces: a heading and its content, with no card around it. It reuses the .hm-fold card the page already has. */
++.hm-fold-block { margin-top: 32px; }
++.hm-fold-block > summary h2 { margin: 0; font: 700 clamp(19px, 2.2vw, 26px)/1.2 var(--ov-font, 'Inter', system-ui, sans-serif); }
++.hm-fold-block .hm-sec { margin: 0; padding: 0 16px 16px; }
++.hm-fold-block .hm-head h2 { display: none; }
++.hm-fold-block .sc-sec > h2 { display: none; }
++@media (min-width: 641px) {
++ .hm-fold-block { border: 0; background: none; border-radius: 0; margin: 56px auto 0; padding: 0; }
++ .hm-fold-block > summary { display: block; min-height: 0; padding: 0; }
++ .hm-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); }
++ .hm-fold-block > summary span { display: none; }
++ .hm-fold-block .hm-sec, .hm-fold-block .sc-sec { margin: 0; max-width: none; padding: 0; }
++}
++@media (max-width: 640px) { .hm-fold-block { margin-top: 24px; } .hm-fold-block > summary h2 { font-size: 1.05rem; } }
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex c846dae..f3b7c00 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -197,6 +197,11 @@ function createPages(ctx) {
});
const faq = home.faqItems({ freshnessHours: Math.round(config.freshnessMs / 3600000) });
const listed = [...picks, ...upvoted, ...latest];
++ const data = {
++ empty: total === 0, freshnessHours: Math.round(config.freshnessMs / 3600000), faq,
++ stats: { active: total, shops: sellers.length, seenDay, endingWeek, categories: categories.length, campaigns: cf.total },
++ picks, upvoted, latest, ending, under, underLimit: UNDER, categories, sellers, campaigns: cf.campaigns,
++ };
return {
agent: true,
title: 'Today\'s deals, every price with its time',
@@@ -215,11 +220,9 @@ function createPages(ctx) {
sharedSeo.jsonLd.organization(),
]),
bodyClass: 'home',
-- body: home.homePage({
-- empty: total === 0, freshnessHours: Math.round(config.freshnessMs / 3600000), faq,
-- stats: { active: total, shops: sellers.length, seenDay, endingWeek, categories: categories.length, campaigns: cf.total },
-- picks, upvoted, latest, ending, under, underLimit: UNDER, categories, sellers, campaigns: cf.campaigns,
-- }, urls),
++ body: home.homePage(data, urls),
++ // The rule that shows the picked pane of the home's pick blocks: one per pane, written for this page.
++ inlineStyles: [home.paneRules(data)],
};
}
ddiff --git a/server/render/home.js b/server/render/home.js
iindex f8f42a6..be0094c 100644
---- a/server/render/home.js
++++ b/server/render/home.js
@@@ -13,6 +13,7 @@ const { html, raw } = require('openvibe-publishing/ssr');
const showcase = require('openvibe-shared/showcase');
const { ago, when, priceText } = require('./views');
const { campaignCard } = require('../http/crowdfunding');
++const { pickBlock, pickRules, foldBlock } = require('./short');
const count = (n) => Number(n).toLocaleString('en-US');
const safeHref = (u) => (typeof u === 'string' && /^https?:\/\//i.test(u) ? u : null);
@@@ -99,13 +100,6 @@ function faqItems({ freshnessHours }) {
];
}
--function faq(items) {
-- return section({
-- id: 'hm-faq', title: 'Questions and answers',
-- body: html`<div class="hm-faq">${items.map((x) => html`<details><summary>${x.q}</summary><p>${x.a}</p></details>`)}</div>`,
-- });
--}
--
function faqJsonLd(items) {
return { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: items.map((x) => ({ '@type': 'Question', name: x.q, acceptedAnswer: { '@type': 'Answer', text: x.a } })) };
}
@@@ -138,16 +132,25 @@ function homePage(d, urls) {
let under = null;
try { under = d.underLimit ? new Intl.NumberFormat('en-US', { style: 'currency', currency: d.underLimit.currency, maximumFractionDigits: 0 }).format(d.underLimit.amount) : null; } catch { under = null; }
++ // The home's two pick blocks, one at a time on a phone (the owner's short-pages rule, 2026-10-30): the chips pick
++ // one of the sections and every section stays in the page for crawlers.
++ const dealPanes = [
++ { id: 'd-picks', label: 'Picks', html: section({ id: 'hm-picks', title: 'Editors\' picks', lede: 'Deals DealNews\' editors marked as their picks, newest first.', more: { href: '/picks', label: 'All picks' }, body: cards(d.picks, urls) }) },
++ { id: 'd-hot', label: 'Hot', html: section({ id: 'hm-hot', title: 'Hot right now', lede: 'Voted up by people on OpenVibe, hottest first.', more: { href: '/hot', label: 'All hot deals' }, body: cards(d.upvoted, urls) }) },
++ { id: 'd-new', label: 'Just in', html: section({ id: 'hm-new', title: 'Just in', lede: 'The newest deals from every source.', more: { href: '/new', label: 'All new deals' }, body: cards(d.latest, urls) }) },
++ { id: 'd-ending', label: 'Ending soon', html: section({ id: 'hm-ending', title: 'Ending soon', lede: 'Deals whose stated end is in the next seven days, soonest first.', more: { href: '/ending', label: 'Everything ending soon' }, body: cards(d.ending, urls, { ends: true }) }) },
++ { id: 'd-under', label: `Under ${under || 'the limit'}`, html: under ? section({ id: 'hm-under', title: `Under ${under}`, lede: `Deals with a stated price of ${under} or less, seen in the last ${d.freshnessHours} hours.`, body: cards(d.under, urls) }) : '' },
++ ];
++ const browsePanes = [
++ { id: 'd-categories', label: 'Categories', html: section({ id: 'hm-categories', title: 'Browse by category', lede: 'Categories as each deal\'s source names them, the most deals first.', more: { href: '/c', label: 'All categories' }, body: chips(catLinks, 'deal') }) },
++ { id: 'd-shops', label: 'Shops', html: section({ id: 'hm-shops', title: 'Shop by store', lede: 'Where the deals are sold, as their sources name the shop.', more: { href: '/at', label: `All ${count(d.sellers.length)} shops` }, body: chips(shopLinks, 'deal') }) },
++ { id: 'd-crowdfunding', label: 'Crowdfunding', html: section({ id: 'hm-crowdfunding', title: 'Crowdfunding', lede: 'Open-source and community campaigns from Open Collective with what they received, plus one search across Kickstarter, Indiegogo, GoFundMe and more.', more: { href: '/crowdfunding', label: 'Search crowdfunding' }, body: d.campaigns.length ? html`<ol class="cf-grid hm-cf">${d.campaigns.map(campaignCard)}</ol>` : '' }) },
++ ];
++
return html`${hero}
${d.empty ? html`<p class="empty hm-empty">No deals have been posted yet. <a href="/submit">Submit the first one</a>.</p>` : ''}
--${section({ id: 'hm-picks', title: 'Editors\' picks', lede: 'Deals DealNews\' editors marked as their picks, newest first.', more: { href: '/picks', label: 'All picks' }, body: cards(d.picks, urls) })}
--${section({ id: 'hm-hot', title: 'Hot right now', lede: 'Voted up by people on OpenVibe, hottest first.', more: { href: '/hot', label: 'All hot deals' }, body: cards(d.upvoted, urls) })}
--${section({ id: 'hm-new', title: 'Just in', lede: 'The newest deals from every source.', more: { href: '/new', label: 'All new deals' }, body: cards(d.latest, urls) })}
--${section({ id: 'hm-ending', title: 'Ending soon', lede: 'Deals whose stated end is in the next seven days, soonest first.', more: { href: '/ending', label: 'Everything ending soon' }, body: cards(d.ending, urls, { ends: true }) })}
--${under ? section({ id: 'hm-under', title: `Under ${under}`, lede: `Deals with a stated price of ${under} or less, seen in the last ${d.freshnessHours} hours.`, body: cards(d.under, urls) }) : ''}
--${section({ id: 'hm-categories', title: 'Browse by category', lede: 'Categories as each deal\'s source names them, the most deals first.', more: { href: '/c', label: 'All categories' }, body: chips(catLinks, 'deal') })}
--${section({ id: 'hm-shops', title: 'Shop by store', lede: 'Where the deals are sold, as their sources name the shop.', more: { href: '/at', label: `All ${count(d.sellers.length)} shops` }, body: chips(shopLinks, 'deal') })}
--${section({ id: 'hm-crowdfunding', title: 'Crowdfunding', lede: 'Open-source and community campaigns from Open Collective with what they received, plus one search across Kickstarter, Indiegogo, GoFundMe and more.', more: { href: '/crowdfunding', label: 'Search crowdfunding' }, body: d.campaigns.length ? html`<ol class="cf-grid hm-cf">${d.campaigns.map(campaignCard)}</ol>` : '' })}
++${raw(pickBlock({ key: 'deals', label: 'The deals', head: 'The deals', panes: dealPanes }))}
++${raw(pickBlock({ key: 'browse', label: 'Ways to browse', head: 'Ways to browse', panes: browsePanes }))}
<details class="hm-fold"><summary>How it works<span>prices, votes, shops, watches</span></summary>${raw(showcase.features({
lede: 'A deal is a link, a title and every price anyone saw for it, each with its source and time.',
items: [
@@@ -157,8 +160,27 @@ ${section({ id: 'hm-crowdfunding', title: 'Crowdfunding', lede: 'Open-source and
{ icon: 'ov:bell', title: 'Watches and saved searches', text: 'Watch words, a product or a price limit for new prices that match, or save a search to run again.', href: '/watches' },
],
}))}</details>
--${faq(d.faq)}
--${raw(showcase.network({ service: 'deals' }))}`;
++${raw(foldBlock({
++ id: 'hm-faq',
++ head: '<h2 id="hm-faq-h">Questions and answers</h2>',
++ html: html`<section class="hm-sec" aria-labelledby="hm-faq-h"><div class="hm-head"><h2>Questions and answers</h2></div><div class="hm-faq">${d.faq.map((x) => html`<details><summary>${x.q}</summary><p>${x.a}</p></details>`)}</div></section>`,
++ }))}
++${raw(foldBlock({
++ id: 'hm-network',
++ head: '<h2 id="hm-network-h">Part of OpenVibe</h2>',
++ html: showcase.network({ service: 'deals' }),
++ }))}`;
++}
++
++/**
++ * The rules that show the picked pane of the home's two pick blocks: one per pane, written for this page.
++ */
++function paneRules(d) {
++ const ids = (panes) => panes.filter((p) => p.html).map((p) => p.id);
++ const under = (() => { try { return d.underLimit ? new Intl.NumberFormat('en-US', { style: 'currency', currency: d.underLimit.currency, maximumFractionDigits: 0 }).format(d.underLimit.amount) : null; } catch { return null; } })();
++ const dealIds = ['d-picks', 'd-hot', 'd-new', 'd-ending', under ? 'd-under' : null].filter(Boolean);
++ const browseIds = ['d-categories', 'd-shops', 'd-crowdfunding'];
++ return `@supports selector(:has(*)){@media (max-width:640px){${pickRules('deals', dealIds)}${pickRules('browse', browseIds)}}}`;
}
--module.exports = { homePage, faqItems, faqJsonLd, card, money };
++module.exports = { homePage, paneRules, faqItems, faqJsonLd, card, money };
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 76eb31b..2e59f5a 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -14,6 +14,7 @@ const fs = require('fs');
const path = require('path');
const layout = require('openvibe-publishing/layout');
const frame = require('openvibe-shared/frame');
++const { foldBlock } = require('./short');
/**
* The page's JSON-LD in one script: every node in one @graph, so a page carries exactly one JSON-LD script.
@@@ -90,7 +91,9 @@ function renderPage(o) {
? require('openvibe-shared/agent-layer').linkHeaderTags(o.canonical, `${o.config.baseUrl.replace(/\/+$/, '')}/llms.txt`).head
: '';
return layout.renderDocument({
-- head: agentHead,
++ // The head names the page's own Markdown twin, and carries the rules that show the picked pane of a pick
++ // block (there is one per pane, written for the page).
++ head: [agentHead, ...(o.inlineStyles || []).map((css) => `<style>${css}</style>`)].filter(Boolean).join('\n'),
site: 'deals',
siteName: SITE_NAME,
lang: o.lang,
@@@ -117,7 +120,12 @@ function renderPage(o) {
release: RELEASE,
account,
body: o.body,
-- shipped: o.path === '/' ? frame.shipped({ service: 'deals', title: `Recently shipped on ${SITE_NAME}` }) : '',
++ // The shipped list is a part a reader rarely needs: a fold on a phone, the section it was where there is room.
++ shipped: o.path === '/' ? foldBlock({
++ id: 'hm-shipped',
++ head: `<h2 id="h-shipped">Recently shipped on ${SITE_NAME}</h2>`,
++ html: frame.shipped({ service: 'deals', title: `Recently shipped on ${SITE_NAME}` }),
++ }) : '',
bodyClass: o.bodyClass,
});
}
ddiff --git a/server/render/short.js b/server/render/short.js
nnew file mode 100644
iindex 0000000..086d87b
---- /dev/null
++++ b/server/render/short.js
@@@ -0,0 +1,58 @@
++'use strict';
++/**
++ * The home on a phone (the owner's short-pages rule, 2026-10-30): one or two screens, app-like.
++ *
++ * pickBlock() several blocks of the same kind, one at a time on a phone, picked with a row of chips. Radio
++ * inputs and :has(), no script: every block stays in the page for crawlers, and a wider screen (or a
++ * browser without :has()) shows them all and no chips.
++ * foldBlock() a part a reader rarely needs, closed on a phone and open where there is room. The page's own markup
++ * keeps it open, so a browser without the script shows it as before; the script closes it on a narrow
++ * screen and remembers the reader's choice.
++ *
++ * The CSS for both lives in public/css/deals.css; the rule that shows the picked block is written for the page,
++ * because there is one per block.
++ */
++const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
++
++/**
++ * A pick block. The panes are the page's own blocks; each one keeps its heading on a wide screen, and on a phone the
++ * heading gives way to the chip that names it.
++ *
++ * pickBlock({ key, label, head, panes }) panes: [{ id, label, html }]
++ */
++function pickBlock({ key, label, head = '', panes }) {
++ // A pane with nothing to show is left out, so the chips and the rules that show them match the page.
++ panes = (panes || []).filter((p) => p.html);
++ if (!panes.length) return '';
++ const chips = panes.map((p, i) => `<input type="radio" name="pick-${esc(key)}" id="pk-${esc(key)}-${esc(p.id)}" value="${esc(p.id)}"${i === 0 ? ' checked' : ''}><label for="pk-${esc(key)}-${esc(p.id)}">${esc(p.label)}</label>`).join('');
++ // The pane carries the id the page already links to (an anchor in the hero, for instance), so a link to it still
++ // opens that pane on a phone (the CSS shows the pane a link points at).
++ const body = panes.map((p) => `<div class="pick-pane" id="${esc(p.id)}">${p.html}</div>`).join('');
++ return `<div class="pick" id="pick-${esc(key)}"><div class="pick-bar"><div class="pick-head"><h2>${esc(head || label)}</h2></div><div class="pick-row" role="radiogroup" aria-label="${esc(label)}">${chips}</div></div><div class="pick-body">${body}</div></div>`;
++}
++
++/** The rule that shows the picked block of a pick block: one per block, so the CSS is written for the page. */
++function pickRule(key, id) {
++ return `.pick:has(#pk-${key}-${id}:checked) #${id}{display:block}`;
++}
++
++/** The rules for every block of a pick block, in the order they appear. */
++function pickRules(key, ids) {
++ return ids.map((id) => pickRule(key, id)).join('');
++}
++
++/**
++ * A fold. The head is the heading the block had; the html is what it holds.
++ *
++ * foldBlock({ id, head, html })
++ */
++function foldBlock({ id, head, html }) {
++ return `<details class="hm-fold hm-fold-block" id="${esc(id)}" open><summary>${head}<span>tap to open</span></summary>${html}</details>${foldScript(id)}`;
++}
++
++/** The script a fold carries: closed on a phone unless the reader opened it before, and it remembers that. */
++function foldScript(id) {
++ return `<script>(function(){var d=document.getElementById(${JSON.stringify(id)});if(!d)return;var k='ov_fold_'+${JSON.stringify(id)};var s=null;try{s=localStorage.getItem(k)}catch(e){}if(s==='open'){d.open=true;return}if(s==='closed'){d.open=false;return}var t=0;if(window.matchMedia&&window.matchMedia('(max-width:640px)').matches){t=Date.now();d.open=false}d.addEventListener('toggle',function(){if(Date.now()-t<300)return;try{localStorage.setItem(k,d.open?'open':'closed')}catch(e){}})})();</script>`;
++}
++
++module.exports = { pickBlock, pickRule, pickRules, foldBlock, foldScript };
ddiff --git a/test/home-short.test.js b/test/home-short.test.js
nnew file mode 100644
iindex 0000000..754e434
---- /dev/null
++++ b/test/home-short.test.js
@@@ -0,0 +1,71 @@
++'use strict';
++/**
++ * The home on a phone (the owner's short-pages rule, 2026-10-30): the home's sections are two pick blocks, 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 section 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 () => {
++ const t = await boot();
++ const alice = t.network.addUser('alice');
++ try {
++ // A deal, so the home has something to show in its pick blocks.
++ await t.submit(alice, { url: 'https://coffee.example/espresso', title: 'Espresso machine', price: '249.00', currency: 'EUR', category: 'Kitchen', seller: 'Coffee Shop' });
++ await t.submit(alice, { url: 'https://kettles.example/acme-2000', title: 'Acme Kettle 2000', price: '39.99', currency: 'USD', category: 'Kitchen', seller: 'Coffee Shop' });
++ // The form does not take a category or a seller, so the browse block's own rows are set here.
++ await t.ctx.store.db.prepare("UPDATE deal_offers SET category = 'Kitchen', seller = 'Coffee Shop', staff_pick = true WHERE id = (SELECT id FROM deal_offers ORDER BY seq DESC LIMIT 1)").run();
++ await t.ctx.store.db.prepare("UPDATE deal_offers SET category = 'Kitchen', seller = 'Coffee Shop' WHERE id = (SELECT id FROM deal_offers ORDER BY seq ASC LIMIT 1)").run();
++
++ await check('the home is two pick blocks: a chip per section, the first picked, every section in the page', async () => {
++ const r = await t.get('/');
++ assert.strictEqual(r.status, 200, r.text);
++ const page = r.text;
++ for (const key of ['deals', 'browse']) {
++ const start = page.indexOf(`<div class="pick" id="pick-${key}">`);
++ const next = page.indexOf('<div class="pick" id="pick-', start + 10);
++ const block = page.slice(start, next > 0 ? next : start + 20000);
++ assert.ok(block.startsWith(`<div class="pick" id="pick-${key}">`), `the ${key} block is on the home`);
++ const chips = [...block.matchAll(/<input type="radio" name="pick-[a-z]+" id="pk-[a-z]+-([a-z-]+)" value="\1"( checked)?><label for="pk-[a-z]+-\1">([^<]+)<\/label>/g)];
++ assert.ok(chips.length >= 1, `a chip per section (${chips.length})`);
++ assert.deepStrictEqual(chips.filter((m) => m[2]).map((m) => m[1]), [chips[0][1]], 'the first section is picked');
++ // Every section 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 section is in the page, one per chip');
++ }
++ // Nothing was removed: every section of the home is still in the page.
++ for (const title of ['Editors', 'Just in', 'Browse by category', 'Shop by store', 'Questions and answers']) {
++ assert.ok(page.includes(title), `${title} is on the page`);
++ }
++ });
++
++ await check('the CSS guard: the chips are hidden and the sections 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-deals-d-picks:checked\) #d-picks\{display:block\}/, 'the rule that shows the picked section');
++ const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'deals.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 sections 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;
++ for (const id of ['hm-faq', 'hm-network', 'hm-shipped']) {
++ assert.ok(page.includes(`<details class="hm-fold hm-fold-block" id="${id}" open>`), `${id} is a fold, open in the page`);
++ assert.ok(page.includes(`document.getElementById("${id}")`), `${id} has the script that closes it on a phone`);
++ }
++ // The content of the folds stays in the page.
++ assert.match(page, /Where do the deals come from\?/);
++ assert.match(page, /Part of OpenVibe/);
++ });
++ } finally {
++ 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 a5a5070..020f45f 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -12,14 +12,14 @@ const { spawn } = require('child_process');
const { measure, check, format } = require('openvibe-shared/perf-budget');
const BUDGETS = {
-- htmlRawKB: 38, // 33.2 with the Part of OpenVibe section (Shared 4.13, 2026-10-26); 29.1 with the curated front page's search, questions (FAQ + JSON-LD) and closing call (2026-10-22); 23.5 before
++ htmlRawKB: 41, // 40.1 with the short home's pick blocks and folds (2026-10-30); 33.2 with the Part of OpenVibe section (Shared 4.13, 2026-10-26); 29.1 with the curated front page's search, questions (FAQ + JSON-LD) and closing call (2026-10-22); 23.5 before
htmlBrotliKB: 10, // 8.3 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 7.1 with the curated front page (6.0 before; 4.5 before the showcase)
jsFiles: 5, // 5 (openvibe-shared 2.10's shell adds web-runtime.js)
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; 263.4 with shared 4.11.0's seasonal navbar; 254.1 with openvibe-shared 4.9.0 (status text twins in every theme, 2026-10-23); 239.1 (212.2 before web-runtime.js)
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; 61.7 with shared 4.11.0's seasonal navbar; 56.3 (49.9 before web-runtime.js)
cssFiles: 2, // 2 (deals.css, openvibe-shared showcase.css)
-- cssRawKB: 46, // 45.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); 43.3 touch targets on phones (2026-10-29); 38.2 openvibe-shared 4.26.1 (hero visual); 30.4 with Shared 4.13.0 (2026-10-26: account links); 24.2 with the front page's stats, cards, chips and questions (19.0 before)
-- cssBrotliKB: 10, // 8.1 openvibe-shared 4.26.1 (hero visual); 6.5 with Shared 4.13.0 (2026-10-26: account links); 5.4 with the front page (2026-10-22); 4.7 with the crowdfunding rules; 1.5 before showcase.css
++ cssRawKB: 49, // 48.2 with the pick chips and fold rules (2026-10-30); 45.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); 43.3 touch targets on phones (2026-10-29); 38.2 openvibe-shared 4.26.1 (hero visual); 30.4 with Shared 4.13.0 (2026-10-26: account links); 24.2 with the front page's stats, cards, chips and questions (19.0 before)
++ cssBrotliKB: 11, // 10.1 with the pick chips and fold rules (2026-10-30); 8.1 openvibe-shared 4.26.1 (hero visual); 6.5 with Shared 4.13.0 (2026-10-26: account links); 5.4 with the front page (2026-10-22); 4.7 with the crowdfunding rules; 1.5 before showcase.css
externalFiles: 1, // 0
};