openvibers/openvibe.food Commits

Short home on a phone: one block at a time, the rest folded (#147)

OP OpenVibers committed 14 days ago · 436b846be3 · 6 files, +219 −33

The home on a phone is now one or two screens. The eight blocks of the home become
one pick block: a row of chips picks one of them and the chip names it, so the
block's own heading gives way to it. The parts a reader rarely need (the questions,
the other OpenVibe sites, what shipped) become folds, closed on a phone and open
where there is room.

Radio inputs and :has(), no script for the chips: every block stays in the page for
the crawlers, and a wider screen (or a browser without :has()) shows them all and no
chips, exactly as before. The desktop is unchanged.

Changes

ddiff --git a/public/css/home.css b/public/css/home.css
iindex 1096b47..aedfaab 100644
---- a/public/css/home.css
++++ b/public/css/home.css
@@@ -60,3 +60,38 @@
.home-stats b { font-size: 1.3rem; }
.home-stats span { font-size: .8rem; }
}
++
++/* 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; }
++@supports selector(:has(*)) { @media (max-width: 640px) {
++ .pick-bar { margin-top: 0; }
++ .pick-head { display: block; }
++ .pick-head h2 { margin: 0 0 4px; font-size: 1.35rem; letter-spacing: -.01em; }
++ .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; }
++ .pick-pane > section > 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. */
++.home-fold-block { margin-top: 40px; }
++.home-fold-block > summary h2 { margin: 0; font-size: 1.35rem; letter-spacing: -.01em; }
++.home-fold-block .sc-sec > h2 { display: none; }
++/* The shipped list carries its own title; the fold's summary already names it. */
++.home-fold-block .ov-shipped h3 { display: none; }
++@media (min-width: 641px) {
++ .home-fold-block { max-width: 1080px; margin: 56px auto 0; padding: 0 24px; border: 0; border-radius: 0; background: none; }
++ .home-fold-block > summary { display: block; min-height: 0; padding: 0; }
++ .home-fold-block > summary h2 { margin: 0 0 6px; font-size: 1.5rem; }
++ .home-fold-block > summary span { display: none; }
++ .home-fold-block .sc-sec { margin: 0; padding: 0; }
++}
++@media (max-width: 640px) { .home-fold-block { margin-top: 32px; } }
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex ac8e580..2098f74 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -25,6 +25,7 @@ const { asyncRouter } = require('./router');
const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
const { sameOrigin } = require('./principal');
const { html, raw, table, notice, time, badge } = require('../render/html');
++const { pickBlock, pickRules, foldBlock } = require('../render/short');
const { markdown } = require('../render/markdown');
const { send } = require('../render/layout');
const foods = require('../food/foods');
@@@ -144,44 +145,75 @@ ${watchable ? html`<p class="muted small">Watch for changes opens OpenVibe.Watch
{ value: delivery.KITS.length, label: 'meal kits side by side', href: '/delivery#kits' },
{ value: recalls.total, label: 'FDA food recalls, newest first', href: '/recalls' },
].filter((x) => x.value);
++ // The home's blocks of the same kind (the owner's short-pages rule, 2026-10-30): on a phone a row of chips
++ // picks one block at a time, so the home stays a couple of screens tall. Every block stays in the page for
++ // crawlers, and a wider screen shows them all, one under the other, as before.
++ const panes = [];
++ if (lib.cheap.length) panes.push({
++ id: 'meals', label: 'Cheap meals',
++ html: html`<section class="sc-sec" aria-labelledby="h-meals"><h2 id="h-meals">Cheap meals from the recipe library</h2>
++<p class="sc-lede">The recipes that cost least per serving, with every ingredient priced from the food list. Written by OpenVibe.AI: check a recipe before you cook it. <a href="/recipes/library">All ${lib.total} recipes</a></p>
++<ul class="home-cards short">${lib.cheap.map((x) => html`<li><a href="/recipes/library/${x.slug}"><b>${x.title}</b><span class="hc-main"><strong>${money(x.cost / x.serves)}</strong> a serving</span><span class="hc-sub">${[`${money(x.cost)} to buy`, `serves ${x.serves}`, x.minutes ? `about ${x.minutes} min` : null].filter(Boolean).join(' · ')}</span></a></li>`)}</ul></section>`,
++ });
++ panes.push({
++ id: 'value', label: 'Food per dollar',
++ html: html`<section class="sc-sec" aria-labelledby="h-value"><h2 id="h-value">The most food for a dollar</h2>
++<p class="sc-lede">Calories per dollar at the cheapest budget chain's typical price, from <a href="/foods">the food list</a>. Prices are a band to plan by, not a live price.</p>
++<ul class="home-cards short">${byKcal.map((f) => foodCard(f, f.kcal_per_dollar.toLocaleString('en-US'), 'calories for $1'))}</ul></section>`,
++ });
++ panes.push({
++ id: 'protein', label: 'Cheapest protein',
++ html: html`<section class="sc-sec" aria-labelledby="h-protein"><h2 id="h-protein">The cheapest protein</h2>
++<p class="sc-lede">Grams of protein a dollar buys, at the same typical prices.</p>
++<ul class="home-cards short">${byProtein.map((f) => foodCard(f, `${proteinPerDollar(f)} g`, 'of protein for $1'))}</ul></section>`,
++ });
++ panes.push({
++ id: 'groups', label: 'Browse the list',
++ html: html`<section class="sc-sec" aria-labelledby="h-groups"><h2 id="h-groups">Browse the food list</h2>
++<p class="sc-lede">Every food with its nutrition per serving and per 100 g, its typical price and the cheapest chain for it.</p>
++<ul class="home-cards three">${Object.entries(foods.GROUPS).map(([id, g]) => { const n = foods.FOODS.filter((f) => f.group === id).length; return n ? html`<li><a href="/foods?group=${id}"><b>${g.name}</b><span class="hc-main"><strong>${n}</strong> foods</span><span class="hc-sub">${g.examples}</span></a></li>` : ''; })}</ul></section>`,
++ });
++ panes.push({
++ id: 'delivery', label: 'Delivery apps',
++ html: html`<section class="sc-sec" aria-labelledby="h-delivery"><h2 id="h-delivery">Every delivery app at once</h2>
++<p class="sc-lede">Type a dish, a restaurant or groceries and each app opens its own search for it, in your own account, where its prices and fees are.</p>
++<form class="home-search" method="get" action="/delivery" role="search"><label for="home-dv-q">A dish, a restaurant or groceries</label><span class="home-search-row"><input id="home-dv-q" name="q" type="search" maxlength="120" placeholder="pad thai, tacos, milk and eggs" required><button class="sc-btn sc-primary" type="submit">Search every app</button></span></form>
++<ul class="home-chips">${delivery.APPS.map((a) => html`<li title="${a.best}">${a.name}</li>`)}</ul></section>`,
++ });
++ panes.push({
++ id: 'kits', label: 'Meal kits',
++ html: html`<section class="sc-sec" id="kits" aria-labelledby="h-kits"><h2 id="h-kits">Meal kits, side by side</h2>
++<p class="sc-lede">From the cheapest kits to fully cooked meals. Each opens its own plans page, where the current price per serving is. <a href="/delivery#kits">All ${delivery.KITS.length} kits</a></p>
++<ul class="home-cards five short">${delivery.KITS.map((k) => html`<li><a href="${k.plans}" rel="noopener nofollow" target="_blank"><b>${k.name} <span class="hc-tag">${delivery.TIER_TEXT[k.tier]}</span></b><span class="hc-sub">${k.what}</span><span class="hc-host">${k.host} ↗</span></a></li>`)}</ul></section>`,
++ });
++ panes.push({
++ id: 'near', label: 'Food banks',
++ html: html`<section class="sc-sec" aria-labelledby="h-near"><h2 id="h-near">Find a food bank</h2>
++<p class="sc-lede">Food banks, soup kitchens, community fridges and free pantry boxes, with hours, from OpenStreetMap. Search any town or postcode, or start with a city.</p>
++<ul class="home-chips">${NEAR_CITIES.map((c) => html`<li><a href="/near?${new URLSearchParams({ q: c })}">${c.split(',')[0]}</a></li>`)}</ul>
++<p><a class="sc-btn sc-primary" href="/near">Search near you</a></p></section>`,
++ });
++ if (recalls.latest.length) panes.push({
++ id: 'recalls', label: 'Food recalls',
++ html: html`<section class="sc-sec" aria-labelledby="h-recalls"><h2 id="h-recalls">Food recalls</h2>
++<p class="sc-lede">The newest of the FDA's food recall reports: what was recalled, by whom and how serious it is. <a href="/recalls">All ${recalls.total} recalls</a></p>
++<ul class="home-cards short two">${recalls.latest.map((x) => html`<li><a href="/recalls/${x.recall_number}"><b>${x.product.length > 90 ? `${x.product.slice(0, 89).replace(/\s+\S*$/, '')}…` : x.product}</b><span class="hc-main"><strong>${x.classification || 'Recall'}</strong> ${x.firm || ''}</span><span class="hc-sub">${[x.reported_on ? `Reported ${recallDate(x.reported_on)}` : null, x.state].filter(Boolean).join(' · ')}</span></a></li>`)}</ul></section>`,
++ });
page(req, res, {
index: true, cache: signedIn(req) ? null : PUBLIC_CACHE,
title: 'Food banks, cheap meals, every delivery app',
-- description: 'Find food banks and budget grocers, plan cheap meals with the shopping list and its cost, cook from your cupboard and compare delivery apps.',
++ description: 'Find food banks and budget grocers, plan cheap meals with the shopping list and its cost, cook from what is in your cupboard and compare delivery apps.',
jsonLd: [
...homeJsonLd(config),
seo.jsonLd.itemList('The most food for a dollar on OpenVibe.Food', byKcal.map((f) => ({ name: shortName(f), url: `${base}/foods/${f.slug}` })), { url: `${base}/foods` }),
FAQ.length ? seo.jsonLd.faq(FAQ.map((f) => ({ q: f.question, a: f.answer }))) : null,
],
styles: [showcase.STYLESHEET], localStyles: ['css/home.css'],
++ // The rule that shows the picked block: one per block of the pick block, written for this page.
++ inlineStyles: [`@supports selector(:has(*)){@media (max-width:640px){${pickRules('food', panes.map((p) => p.id))}}}`],
body: html`${raw(hero)}
${stats.length ? html`<ul class="home-stats" aria-label="${SITE_NAME} in numbers">${stats.map((x) => html`<li><a href="${x.href}"><b>${x.value}</b><span>${x.label}</span></a></li>`)}</ul>` : ''}
--${lib.cheap.length ? html`<section class="sc-sec" aria-labelledby="h-meals"><h2 id="h-meals">Cheap meals from the recipe library</h2>
--<p class="sc-lede">The recipes that cost least per serving, with every ingredient priced from the food list. Written by OpenVibe.AI: check a recipe before you cook it. <a href="/recipes/library">All ${lib.total} recipes</a></p>
--<ul class="home-cards short">${lib.cheap.map((x) => html`<li><a href="/recipes/library/${x.slug}"><b>${x.title}</b><span class="hc-main"><strong>${money(x.cost / x.serves)}</strong> a serving</span><span class="hc-sub">${[`${money(x.cost)} to buy`, `serves ${x.serves}`, x.minutes ? `about ${x.minutes} min` : null].filter(Boolean).join(' · ')}</span></a></li>`)}</ul></section>` : ''}
--<section class="sc-sec" aria-labelledby="h-value"><h2 id="h-value">The most food for a dollar</h2>
--<p class="sc-lede">Calories per dollar at the cheapest budget chain's typical price, from <a href="/foods">the food list</a>. Prices are a band to plan by, not a live price.</p>
--<ul class="home-cards short">${byKcal.map((f) => foodCard(f, f.kcal_per_dollar.toLocaleString('en-US'), 'calories for $1'))}</ul></section>
--<section class="sc-sec" aria-labelledby="h-protein"><h2 id="h-protein">The cheapest protein</h2>
--<p class="sc-lede">Grams of protein a dollar buys, at the same typical prices.</p>
--<ul class="home-cards short">${byProtein.map((f) => foodCard(f, `${proteinPerDollar(f)} g`, 'of protein for $1'))}</ul></section>
--<section class="sc-sec" aria-labelledby="h-groups"><h2 id="h-groups">Browse the food list</h2>
--<p class="sc-lede">Every food with its nutrition per serving and per 100 g, its typical price and the cheapest chain for it.</p>
--<ul class="home-cards three">${Object.entries(foods.GROUPS).map(([id, g]) => { const n = foods.FOODS.filter((f) => f.group === id).length; return n ? html`<li><a href="/foods?group=${id}"><b>${g.name}</b><span class="hc-main"><strong>${n}</strong> foods</span><span class="hc-sub">${g.examples}</span></a></li>` : ''; })}</ul></section>
--<section class="sc-sec" aria-labelledby="h-delivery"><h2 id="h-delivery">Every delivery app at once</h2>
--<p class="sc-lede">Type a dish, a restaurant or groceries and each app opens its own search for it, in your own account, where its prices and fees are.</p>
--<form class="home-search" method="get" action="/delivery" role="search"><label for="home-dv-q">A dish, a restaurant or groceries</label><span class="home-search-row"><input id="home-dv-q" name="q" type="search" maxlength="120" placeholder="pad thai, tacos, milk and eggs" required><button class="sc-btn sc-primary" type="submit">Search every app</button></span></form>
--<ul class="home-chips">${delivery.APPS.map((a) => html`<li title="${a.best}">${a.name}</li>`)}</ul></section>
--<section class="sc-sec" id="kits" aria-labelledby="h-kits"><h2 id="h-kits">Meal kits, side by side</h2>
--<p class="sc-lede">From the cheapest kits to fully cooked meals. Each opens its own plans page, where the current price per serving is. <a href="/delivery#kits">All ${delivery.KITS.length} kits</a></p>
--<ul class="home-cards five short">${delivery.KITS.map((k) => html`<li><a href="${k.plans}" rel="noopener nofollow" target="_blank"><b>${k.name} <span class="hc-tag">${delivery.TIER_TEXT[k.tier]}</span></b><span class="hc-sub">${k.what}</span><span class="hc-host">${k.host} ↗</span></a></li>`)}</ul></section>
--<section class="sc-sec" aria-labelledby="h-near"><h2 id="h-near">Find a food bank</h2>
--<p class="sc-lede">Food banks, soup kitchens, community fridges and free pantry boxes, with hours, from OpenStreetMap. Search any town or postcode, or start with a city.</p>
--<ul class="home-chips">${NEAR_CITIES.map((c) => html`<li><a href="/near?${new URLSearchParams({ q: c })}">${c.split(',')[0]}</a></li>`)}</ul>
--<p><a class="sc-btn sc-primary" href="/near">Search near you</a></p></section>
--${recalls.latest.length ? html`<section class="sc-sec" aria-labelledby="h-recalls"><h2 id="h-recalls">Food recalls</h2>
--<p class="sc-lede">The newest of the FDA's food recall reports: what was recalled, by whom and how serious it is. <a href="/recalls">All ${recalls.total} recalls</a></p>
--<ul class="home-cards short two">${recalls.latest.map((x) => html`<li><a href="/recalls/${x.recall_number}"><b>${x.product.length > 90 ? `${x.product.slice(0, 89).replace(/\s+\S*$/, '')}…` : x.product}</b><span class="hc-main"><strong>${x.classification || 'Recall'}</strong> ${x.firm || ''}</span><span class="hc-sub">${[x.reported_on ? `Reported ${recallDate(x.reported_on)}` : null, x.state].filter(Boolean).join(' · ')}</span></a></li>`)}</ul></section>` : ''}
++${raw(pickBlock({ key: 'food', label: 'What to eat, and what it costs', head: 'What to eat, and what it costs', panes }))}
<details class="home-fold"><summary>Everything food<span>six tools, no account needed</span></summary>${raw(showcase.features({
lede: 'All working today: no card, no account needed to look around.',
items: [
@@@ -193,9 +225,16 @@ ${recalls.latest.length ? html`<section class="sc-sec" aria-labelledby="h-recall
{ icon: 'ov:ai', title: 'Recipes on a budget', text: 'Cheap recipes costed from the food list, and ideas from your cupboard. Written by AI: check before you cook.', href: '/recipes' },
],
}))}</details>
--${FAQ.length ? html`<section class="sc-sec" aria-labelledby="h-faq"><h2 id="h-faq">Questions people ask</h2>
--<div class="home-faq">${FAQ.map((f) => html`<details><summary>${f.question}</summary><p>${f.answer}</p></details>`)}</div></section>` : ''}
--${raw(showcase.network({ service: 'food' }))}`,
++${FAQ.length ? raw(foldBlock({
++ id: 'faq',
++ head: '<h2 id="h-faq">Questions people ask</h2>',
++ html: `<div class="home-faq">${FAQ.map((f) => html`<details><summary>${f.question}</summary><p>${f.answer}</p></details>`).join('')}</div>`,
++ })) : ''}
++${raw(foldBlock({
++ id: 'network',
++ head: '<h2 id="h-network">Part of OpenVibe</h2>',
++ html: showcase.network({ service: 'food' }),
++ }))}`,
});
});
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 3f9240b..1d72c25 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -17,6 +17,7 @@ const frame = require('openvibe-shared/frame');
const shell = require('openvibe-shared/shell');
const cache = require('openvibe-shared/cache-policy');
const { html, raw, esc } = require('./html');
++const { foldBlock } = require('./short');
const { TWIN_PATH_RE } = require('../agent-pages');
const NETWORK_URL = 'https://openvibe.network';
@@@ -103,6 +104,7 @@ ${appIcon.headTags({ site: 'food' })}
<link rel="stylesheet" href="${asset('css/app.css')}">
${(o.styles || []).map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`).join('\n')}
${(o.localStyles || []).map((rel) => `<link rel="stylesheet" href="${asset(rel)}">`).join('\n')}
++${(o.inlineStyles || []).map((css) => `<style>${css}</style>`).join('\n')}
${render(scripts)}
<meta name="ov-boost" content="food@${esc(RELEASE)}">
<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,
@@@ -112,7 +114,7 @@ ${render(scripts)}
<main id="main" class="page">
${render(crumbs)}
${render(o.body)}
--${o.path === '/' ? frame.shipped({ service: 'food', title: `Recently shipped on ${SITE_NAME}` }) : ''}
++${o.path === '/' ? foldBlock({ id: 'shipped', head: `<h2 id="h-shipped">Recently shipped on ${SITE_NAME}</h2>`, html: frame.shipped({ service: 'food', 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..c837ffa
---- /dev/null
++++ b/server/render/short.js
@@@ -0,0 +1,54 @@
++'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/home.css; the rule that shows the picked block is written for the page,
++ * because there is one per block.
++ */
++const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
++
++/**
++ * A pick block. The panes are the page's own blocks; each one keeps its heading on a wide screen, and on a phone the
++ * heading gives way to the chip that names it.
++ *
++ * pickBlock({ key, label, head, panes }) panes: [{ id, label, html }]
++ */
++function pickBlock({ key, label, head = '', panes }) {
++ if (!panes.length) return '';
++ const chips = panes.map((p, i) => `<input type="radio" name="pick-${esc(key)}" id="pk-${esc(key)}-${esc(p.id)}" value="${esc(p.id)}"${i === 0 ? ' checked' : ''}><label for="pk-${esc(key)}-${esc(p.id)}">${esc(p.label)}</label>`).join('');
++ const body = panes.map((p) => `<div class="pick-pane" id="pp-${esc(key)}-${esc(p.id)}">${p.html}</div>`).join('');
++ return `<div class="pick" id="pick-${esc(key)}"><div class="pick-bar sc-sec"><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) #pp-${key}-${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="home-fold home-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..f198ee0
---- /dev/null
++++ b/test/home-short.test.js
@@@ -0,0 +1,56 @@
++'use strict';
++/**
++ * The home on a phone (the owner's short-pages rule, 2026-10-30): the home's blocks of the same kind 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 { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ try {
++ await check('the home is one pick block: a chip per block, the first picked, every block in the page', async () => {
++ const r = await t.get('/');
++ assert.strictEqual(r.status, 200, r.text);
++ const page = r.text;
++ const block = page.slice(page.indexOf('<div class="pick" id="pick-food">'), page.indexOf('<details class="home-fold"><summary>Everything food'));
++ assert.ok(block.startsWith('<div class="pick" id="pick-food">'), 'the pick block is on the home');
++ assert.match(block, /<div class="pick-row" role="radiogroup" aria-label="What to eat, and what it costs">/);
++ const chips = [...block.matchAll(/<input type="radio" name="pick-food" id="pk-food-([a-z]+)" value="\1"( checked)?><label for="pk-food-\1">([^<]+)<\/label>/g)];
++ assert.ok(chips.length >= 6, `a chip per block (${chips.length})`);
++ assert.deepStrictEqual(chips.filter((m) => m[2]).map((m) => m[1]), [chips[0][1]], 'the first block is picked');
++ // Every block stays in the page, with its heading.
++ for (const h of ['The most food for a dollar', 'The cheapest protein', 'Browse the food list', 'Every delivery app at once', 'Meal kits, side by side', 'Find a food bank']) {
++ assert.ok(block.includes(h), `the block "${h}" is in the page`);
++ }
++ assert.strictEqual((block.match(/<div class="pick-pane"/g) || []).length, chips.length, 'one pane per chip');
++ });
++
++ await check('the CSS guard: the chips are hidden and the blocks 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-food-value:checked\) #pp-food-value\{display:block\}/, 'the rule that shows the picked block');
++ const css = require('fs').readFileSync(require('path').join(__dirname, '..', 'public', 'css', 'home.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 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 ['faq', 'network', 'shipped']) {
++ assert.ok(page.includes(`<details class="home-fold home-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, /<h2 id="h-faq">Questions people ask<\/h2>/);
++ assert.match(page, /<h2 id="h-network">Part of OpenVibe<\/h2>/);
++ assert.match(page, /One account across every OpenVibe site/, 'the other OpenVibe places are still in the page');
++ });
++ } finally {
++ await done(t);
++ }
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex de366e1..31eee00 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -17,7 +17,7 @@ const BUDGETS = {
// Raised 2026-10-22 for the curated home (the owner's "rich content on the home page"): the most food for a dollar,
// the cheapest protein, the food groups, every delivery app with a search, the meal kits, food banks by city, the
// questions, ItemList + FAQPage JSON-LD, and css/home.css. Measured 43.3/9.8 KB HTML (empty library) and 22.2/5.3 KB CSS.
-- htmlRawKB: 54, // 49.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 43.3 on 2026-10-22; measured 28.7 with plan together as the sixth feature (2026-10-15); 27.6 with Shared 4.1.0's manifest + apple-touch-icon links (24.9 before; the home page: hero, features, steps, cta, JSON-LD)
++ htmlRawKB: 56, // 54.1 with the home's pick block (the owner's short-pages rule, 2026-10-30: the chips and the blocks' wrappers); 49.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 43.3 on 2026-10-22; measured 28.7 with plan together as the sixth feature (2026-10-15); 27.6 with Shared 4.1.0's manifest + apple-touch-icon links (24.9 before; the home page: hero, features, steps, cta, JSON-LD)
htmlBrotliKB: 13, // 11.1 Shared 4.15.1 footer; 9.8 on 2026-10-22; 7.0 (6.2 before 4.1.0)
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
@@@ -25,7 +25,7 @@ const BUDGETS = {
cssFiles: 3, // 3 (app.css, the cached /shared/showcase.css and css/home.css)
// 16.4 since the product's own pages brought their stylesheet with them (the search and plan forms, the place
// cards, the pantry's tick list); app.css itself stayed within a few lines.
-- cssRawKB: 41, // 40.4 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); 38.3 the home's phone rows (2026-10-29); 33.6 openvibe-shared 4.26.1 (hero visual); 28.1 with Shared 4.13.0 (2026-10-26: account links); 22.2 with css/home.css; 16.4 before
++ cssRawKB: 44, // 42.9 with the home's pick block and fold rules (the owner's short-pages rule, 2026-10-30); 40.4 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); 38.3 the home's phone rows (2026-10-29); 33.6 openvibe-shared 4.26.1 (hero visual); 28.1 with Shared 4.13.0 (2026-10-26: account links); 22.2 with css/home.css; 16.4 before
cssBrotliKB: 9.5, // 8.9 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); 8.6 with 11 touch targets (2026-10-29: the chips and the food search's fields at 44 px); 8.2 the home's phone limits and fold (2026-10-29); 6.4 with Shared 4.13.0 (2026-10-26: account links); 5.3 with css/home.css; 4.3 with the hero search panel
externalFiles: 0, // 0
};