openvibers/openvibe.food Commits
Short home on a phone: one block at a time, the rest folded (#147)
Changes
@@@ -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; } }@@@ -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' }),++ }))}`,});});@@@ -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')};nnew file mode 100644@@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');++++/**++ * A pick block. The panes are the page's own blocks; each one keeps its heading on a wide screen, and on a phone the++ * heading gives way to the chip that names it.++ *++ * pickBlock({ key, label, head, panes }) panes: [{ id, label, html }]++ */++function pickBlock({ key, label, head = '', panes }) {++ if (!panes.length) return '';++ const chips = panes.map((p, i) => `<input type="radio" name="pick-${esc(key)}" id="pk-${esc(key)}-${esc(p.id)}" value="${esc(p.id)}"${i === 0 ? ' checked' : ''}><label for="pk-${esc(key)}-${esc(p.id)}">${esc(p.label)}</label>`).join('');++ 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 };nnew file mode 100644@@@ -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); });@@@ -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 beforecssBrotliKB: 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 panelexternalFiles: 0, // 0};
@@ -60,3 +60,38 @@
@@ -60,3 +60,38 @@
.home-stats b { font-size: 1.3rem; }
.home-stats b { font-size: 1.3rem; }
.home-stats span { font-size: .8rem; }
.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; } }
@@ -25,6 +25,7 @@ const { asyncRouter } = require('./router');
@@ -25,6 +25,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, table, notice, time, badge } = require('../render/html');
const { html, raw, table, notice, time, badge } = require('../render/html');
+const { pickBlock, pickRules, foldBlock } = require('../render/short');
const { markdown } = require('../render/markdown');
const { markdown } = require('../render/markdown');
const { send } = require('../render/layout');
const { send } = require('../render/layout');
const foods = require('../food/foods');
const foods = require('../food/foods');
@@ -144,44 +145,75 @@ ${watchable ? html`<p class="muted small">Watch for changes opens OpenVibe.Watch
@@ -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: delivery.KITS.length, label: 'meal kits side by side', href: '/delivery#kits' },
{ value: recalls.total, label: 'FDA food recalls, newest first', href: '/recalls' },
{ value: recalls.total, label: 'FDA food recalls, newest first', href: '/recalls' },
].filter((x) => x.value);
].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, {
page(req, res, {
index: true, cache: signedIn(req) ? null : PUBLIC_CACHE,
index: true, cache: signedIn(req) ? null : PUBLIC_CACHE,
title: 'Food banks, cheap meals, every delivery app',
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: [
jsonLd: [
...homeJsonLd(config),
...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` }),
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,
FAQ.length ? seo.jsonLd.faq(FAQ.map((f) => ({ q: f.question, a: f.answer }))) : null,
],
],
styles: [showcase.STYLESHEET], localStyles: ['css/home.css'],
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)}
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>` : ''}
${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({
<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.',
lede: 'All working today: no card, no account needed to look around.',
items: [
items: [
@@ -193,9 +225,16 @@ ${recalls.latest.length ? html`<section class="sc-sec" aria-labelledby="h-recall
@@ -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' },
{ 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>
}))}</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' }),
+ }))}`,
});
});
});
});
@@ -17,6 +17,7 @@ const frame = require('openvibe-shared/frame');
@@ -17,6 +17,7 @@ const frame = require('openvibe-shared/frame');
const shell = require('openvibe-shared/shell');
const shell = require('openvibe-shared/shell');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
const { html, raw, esc } = require('./html');
const { html, raw, esc } = require('./html');
+const { foldBlock } = require('./short');
const { TWIN_PATH_RE } = require('../agent-pages');
const { TWIN_PATH_RE } = require('../agent-pages');
const NETWORK_URL = 'https://openvibe.network';
const NETWORK_URL = 'https://openvibe.network';
@@ -103,6 +104,7 @@ ${appIcon.headTags({ site: 'food' })}
@@ -103,6 +104,7 @@ ${appIcon.headTags({ site: 'food' })}
<link rel="stylesheet" href="${asset('css/app.css')}">
<link rel="stylesheet" href="${asset('css/app.css')}">
${(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')}
${(o.localStyles || []).map((rel) => `<link rel="stylesheet" href="${asset(rel)}">`).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)}
${render(scripts)}
<meta name="ov-boost" content="food@${esc(RELEASE)}">
<meta name="ov-boost" content="food@${esc(RELEASE)}">
<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,
<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,
@@ -112,7 +114,7 @@ ${render(scripts)}
@@ -112,7 +114,7 @@ ${render(scripts)}
<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: '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>
</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,54 @@
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
+
+/**
+ * A pick block. The panes are the page's own blocks; each one keeps its heading on a wide screen, and on a phone the
+ * heading gives way to the chip that names it.
+ *
+ * pickBlock({ key, label, head, panes }) panes: [{ id, label, html }]
+ */
+function pickBlock({ key, label, head = '', panes }) {
+ if (!panes.length) return '';
+ const chips = panes.map((p, i) => `<input type="radio" name="pick-${esc(key)}" id="pk-${esc(key)}-${esc(p.id)}" value="${esc(p.id)}"${i === 0 ? ' checked' : ''}><label for="pk-${esc(key)}-${esc(p.id)}">${esc(p.label)}</label>`).join('');
+ 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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,56 @@
@@ -0,0 +1,56 @@
+'use strict';
+/**
+ * The home on a phone (the owner's short-pages rule, 2026-10-30): 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); });
@@ -17,7 +17,7 @@ const BUDGETS = {
@@ -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,
// 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
// 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.
// 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)
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)
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
@@ -25,7 +25,7 @@ const BUDGETS = {
@@ -25,7 +25,7 @@ const BUDGETS = {
cssFiles: 3, // 3 (app.css, the cached /shared/showcase.css and css/home.css)
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
// 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.
// 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
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
externalFiles: 0, // 0
};
};