openvibers/openvibe.food Commits

Home: a taste and a door on phones (#134)

OP OpenVibers committed 15 days ago · 00b33e16b2 · 3 files, +22 −17

Each card list shows its first four on a phone (recalls two), every one still a
link away and in the HTML; the six tools fold; the closing call that repeated the
hero goes; the hero has two actions so they sit side by side. CSS brotli budget
8 -> 8.5 (measured 8.2).

Changes

ddiff --git a/public/css/home.css b/public/css/home.css
iindex 4f00899..5cdf279 100644
---- a/public/css/home.css
++++ b/public/css/home.css
@@@ -37,3 +37,14 @@
.home-faq details p { margin: 0; padding: 0 16px 14px; color: var(--text-secondary, #96a7c2); line-height: 1.55; }
@media (max-width: 520px) { .home-cards { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } .home-cards a { padding: 10px 12px; } .hc-main strong { font-size: 1.1rem; } }
@media (prefers-reduced-motion: reduce) { .home-cards a { transition: none; } .home-cards a:hover { transform: none; } }
++
++/* Phones (2026-10-29): a taste and a door. Each card list shows its first four (recalls two); every one is a link away. */
++.home-fold { margin: 28px 0 0; border: 1px solid var(--border, #1f2d47); border-radius: 14px; background: var(--bg-card, #131c2e); }
++.home-fold > summary { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 48px; padding: 0 16px; cursor: pointer; font-weight: 700; list-style: none; }
++.home-fold > summary::-webkit-details-marker { display: none; }
++.home-fold > summary span { color: var(--text-muted, #7386a3); font-weight: 600; font-size: .86rem; }
++.home-fold > summary:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -2px; border-radius: 14px; }
++.home-fold .sc-sec { margin: 0; padding: 0 16px 14px; }
++@media (max-width: 599px) {
++ .home-cards.short > li:nth-child(n+5), .home-cards.short.two > li:nth-child(n+3) { display: none; }
++}
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 8ef7947..ac8e580 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -128,10 +128,10 @@ ${watchable ? html`<p class="muted small">Watch for changes opens OpenVibe.Watch
eyebrow: `${SITE_NAME} · ${TAGLINE}`,
title: 'Eat well.', accent: 'Spend less.',
lede: 'Find food banks and budget grocers near you, plan meals with the shopping list and its cost, cook from what is in your cupboard, and compare every delivery app and meal kit at once.',
++ // Two, so they sit side by side on a phone; the food list has its own number and section below.
actions: [
{ label: 'Find food near you', href: '/near', primary: true },
{ label: 'Plan a week', href: '/plan' },
-- { label: 'The food list', href: '/foods' },
],
note: 'Open source (AGPL-3.0). Places come from OpenStreetMap © contributors (ODbL); prices are typical, not live.',
aside: { html: String(searchForm({ panel: true })) },
@@@ -158,13 +158,13 @@ ${watchable ? html`<p class="muted small">Watch for changes opens OpenVibe.Watch
${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">${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>` : ''}
++<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">${byKcal.map((f) => foodCard(f, f.kcal_per_dollar.toLocaleString('en-US'), 'calories for $1'))}</ul></section>
++<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">${byProtein.map((f) => foodCard(f, `${proteinPerDollar(f)} g`, 'of protein for $1'))}</ul></section>
++<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>
@@@ -173,17 +173,16 @@ ${lib.cheap.length ? html`<section class="sc-sec" aria-labelledby="h-meals"><h2
<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.</p>
--<ul class="home-cards five">${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>
++<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">${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(showcase.features({
-- title: 'Everything food',
++<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>` : ''}
++<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: [
{ icon: 'ov:map', title: 'Food near you', text: 'Food banks, soup kitchens, community fridges and free pantry boxes, plus budget grocers, with hours.', href: '/near' },
@@@ -193,15 +192,10 @@ ${raw(showcase.features({
{ icon: 'ov:search', title: 'Delivery, compared', text: 'Search DoorDash, Uber Eats, Grubhub and more at once, and compare meal kits.', href: '/delivery' },
{ 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' }))}
--${raw(showcase.cta({
-- title: 'Start where you are',
-- text: 'Search your town for a food bank, plan the next three days for what you can spend, or look up what is in the tin before you open it.',
-- actions: [{ label: 'Find food near you', href: '/near', primary: true }, { label: 'Plan meals', href: '/plan' }, { label: 'The food list', href: '/foods' }],
-- }))}`,
++${raw(showcase.network({ service: 'food' }))}`,
});
});
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex 60c467b..ac70324 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -26,7 +26,7 @@ const BUDGETS = {
// 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: 38, // 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: 8, // 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: 8.5, // 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
};