openvibers/openvibe.work Commits

Work: the sections below the search are picked one at a time on a phone (#135)

OP OpenVibers committed 14 days ago · fdabcc3aee · 6 files, +245 −24

The home was 5.9 screens at 390 px. The browse sections and the job lists are
one block now: on a phone a row of chips picks one at a time (the newest
listings first), and where there is room every section shows and there are no
chips. Radio inputs and :has(), no script, and every section stays in the page
for the crawlers. The questions, the rest of OpenVibe and what shipped fold
closed on a phone, and are open where there is room. The search and the newest
listings stay first. 2.9 screens at 390 px.

The HTML and CSS budgets move to the measured size (38.1 KB raw, 9.1 KB brotli
of HTML; 45.3 KB raw, 10 KB brotli of CSS).

Changes

ddiff --git a/public/css/home.css b/public/css/home.css
iindex 901ef53..472ff37 100644
---- a/public/css/home.css
++++ b/public/css/home.css
@@@ -46,3 +46,55 @@
.home-stats li { flex: 0 0 auto; width: 136px; }
.home-fold > summary span { display: none; }
}
++
++/* Pick (the owner's short-pages rule, 2026-10-30): on a phone the sections below the search are one at a time,
++ picked with a row of chips. Radio inputs and :has(), no script: the chips and the one-section rule live under
++ 640 px and only where :has() is understood, so a wider screen, or an older browser, shows every section and no
++ chips. Every section stays in the page for the crawlers. */
++.pick-head, .pick-row { display: none; }
++@supports selector(:has(*)) {
++ @media (max-width: 640px) {
++ .pick-head { display: block; }
++ .pick-head h2 { margin: 0 0 4px; font: 700 clamp(20px, 2.4vw, 28px)/1.2 var(--ov-font, 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif); letter-spacing: -.02em; }
++ .pick-row { display: flex; gap: 8px; overflow-x: auto; margin: 10px -16px 0; padding: 2px 16px 4px; scrollbar-width: none; overscroll-behavior-x: contain; }
++ .pick-row::-webkit-scrollbar { display: none; }
++ .pick-row input { position: absolute; opacity: 0; width: 1px; height: 1px; pointer-events: none; }
++ .pick-row label { display: inline-flex; align-items: center; flex: none; min-height: 44px; padding: 0 15px; border-radius: 999px; border: 1px solid var(--border, #1f2d47); color: var(--text-secondary, #96a7c2); font-size: .9rem; font-weight: 650; white-space: nowrap; cursor: pointer; }
++ .pick-row input:checked + label { color: var(--text-primary, #e6edf7); border-color: var(--accent, #3b82f6); background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); }
++ .pick-row input:focus-visible + label { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
++ .pick-pane { display: none; }
++ /* The chip names the section, so the section's own heading gives way to it. */
++ .pick-pane > .sc-sec > h2 { display: none; }
++ }
++}
++
++/* The fold (the owner's short-pages rule, 2026-10-30): a part a reader rarely needs. On a phone it is closed — the
++ title row is a 44 px target that opens it — and where there is room it is open and looks like the section it
++ replaces. A checkbox and a label, no script: the part stays in the page for the crawlers. */
++.fold { margin-top: 26px; }
++.fold-toggle { position: absolute; opacity: 0; width: 1px; height: 1px; pointer-events: none; }
++.fold-h { display: flex; align-items: center; gap: 10px; min-height: 44px; cursor: pointer; }
++.fold-h > h2 { margin: 0; font-size: clamp(22px, 2.6vw, 30px); letter-spacing: -.02em; flex: 1 1 auto; }
++.fold-h:focus-within { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
++.fold-mark { flex: none; }
++.fold-mark::after { content: "+"; font-size: 20px; font-weight: 400; color: var(--text-muted, #7386a3); }
++.fold-toggle:checked ~ .fold-h .fold-mark::after { content: "\2212"; }
++.fold-body { display: none; }
++.fold-toggle:checked ~ .fold-body { display: block; }
++.fold-body > .sc-sec { margin-top: 16px; }
++/* The title row names the part, so a part that keeps its own heading gives way to it on a phone (and keeps it where
++ there is room). */
++@media (max-width: 640px) {
++ .fold-bare .fold-body > section > h2, .fold-bare .fold-body > .sc-sec > h2, .fold-bare .fold-body .ov-shipped-home h3 { display: none; }
++}
++.fold:not(.fold-bare) .fold-body .ov-shipped-home h3 { display: none; }
++@media (min-width: 641px) {
++ .fold { margin-top: 56px; }
++ .fold-toggle { display: none; }
++ .fold-h { min-height: 0; margin-bottom: 10px; cursor: default; }
++ .fold-mark { display: none; }
++ .fold-body { display: block; }
++ .fold-bare > .fold-h { display: none; }
++ .fold-bare { margin-top: 0; }
++ .fold:not(.fold-bare) .fold-body > .sc-sec { margin-top: 0; }
++}
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex c95c66f..581f869 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -18,6 +18,7 @@ const { asyncRouter } = require('./router');
const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
const { sameOrigin } = require('./principal');
const { html, raw, table, notice, time } = require('../render/html');
++const { foldBlock, pickBlock, pickRules } = require('../render/short');
const { send } = require('../render/layout');
const listing = require('../render/listing');
const store = require('../jobs/store');
@@@ -138,6 +139,27 @@ ${facets.type.map((t) => link({ type: t.type }, t.type, filters.type === t.type,
{ value: sum.remote, label: 'remote listings', href: '/jobs?remote=true' },
{ value: sources.SOURCES.length, label: 'job boards read', href: '/sources' },
].filter((x) => x.value);
++ // The sections below the search, as the panes of one block: on a phone a row of chips picks one at a time, so
++ // the home stays short (the owner's short-pages rule, 2026-10-30). Radio inputs and :has(), no script, and
++ // every section stays in the page for the crawlers; a wider screen shows them all and no chips. The order is
++ // the one the page had, and the newest listings are the one a phone shows first.
++ const panes = [];
++ if (sum.tags.length) panes.push({ id: 'fields', label: 'Fields', html: String(html`<section class="sc-sec" aria-labelledby="h-fields"><h2 id="h-fields">Browse by field</h2>
++<p class="sc-lede">The labels the boards put on most listings right now. Each opens a search for it.</p>
++<ul class="home-cards short">${sum.tags.map((t) => html`<li><a href="/jobs?${new URLSearchParams({ q: t.tag })}"><b>${sentence(t.tag)}</b><span>${n(t.count)} listing${t.count === 1 ? '' : 's'}</span></a></li>`)}</ul></section>`) });
++ if (sum.places.length) panes.push({ id: 'cities', label: 'Cities', html: String(html`<section class="sc-sec" aria-labelledby="h-places"><h2 id="h-places">Jobs by city</h2>
++<p class="sc-lede">The places most listings name. Remote jobs are <a href="/jobs?remote=true">their own search</a>.</p>
++<ul class="home-chips">${sum.places.map((p) => html`<li><a href="/jobs?${new URLSearchParams({ location: p.place })}">${p.place} <span>${n(p.count)}</span></a></li>`)}</ul></section>`) });
++ panes.push({ id: 'latest', label: 'Latest', html: String(html`<section class="sc-sec home-row" aria-labelledby="h-latest"><h2 id="h-latest">Latest remote jobs</h2>
++${listing.listingList(latest, { sourceOf, empty: 'No listings yet — the ingest has not run.' })}
++<p><a class="sc-btn" href="/jobs?remote=true">All remote listings</a></p></section>`) });
++ if (salaried.length) panes.push({ id: 'pay', label: 'Pay', html: String(html`<section class="sc-sec home-row" aria-labelledby="h-pay"><h2 id="h-pay">Newest jobs that state the pay</h2>
++<p class="sc-lede">${n(sum.salaried)} listing${sum.salaried === 1 ? '' : 's'} say what the job pays, in the board's own words.</p>
++${listing.listingList(salaried, { sourceOf, empty: '' })}</section>`) });
++ if (sum.hiring.length) panes.push({ id: 'companies', label: 'Companies', html: String(html`<section class="sc-sec" aria-labelledby="h-hiring"><h2 id="h-hiring">Companies with the most openings</h2>
++<p class="sc-lede">Counted across every board. Each opens the company's page: its openings, where they are and which boards list them.</p>
++<ul class="home-cards short">${sum.hiring.map((c) => { const slug = companies.slugOf(c.company); return html`<li><a href="${slug ? `/companies/${slug}` : `/jobs?${new URLSearchParams({ q: c.company })}`}"><b>${c.company}</b><span>${n(c.count)} open listing${c.count === 1 ? '' : 's'}</span></a></li>`; })}</ul>
++<p><a href="/companies">Every company hiring</a></p></section>`) });
page(req, res, {
index: true, cache: signedIn(req) ? null : PUBLIC_CACHE,
title: 'Jobs from open job boards, with sources',
@@@ -148,27 +170,15 @@ ${facets.type.map((t) => link({ type: t.type }, t.type, filters.type === t.type,
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 section of the browse block is written for the page, because there is one
++ // per section (CSS cannot pair a radio with a pane by index).
++ inlineStyles: ['@supports selector(:has(*)){@media (max-width:640px){' + pickRules('browse', 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>${n(x.value)}</b><span>${x.label}</span></a></li>`)}</ul>` : ''}
<section class="sc-sec" id="search" aria-labelledby="h-search"><h2 id="h-search">Search the listings</h2>
${filterForm({ q: '', remote: '', type: '', location: '' }, { action: '/jobs' })}
<p class="muted">${n(total)} listing${total === 1 ? '' : 's'} from ${sources.SOURCES.length} job boards, newest first.${sum.day ? ` ${n(sum.day)} arrived in the last day.` : ''}</p></section>
--${sum.tags.length ? html`<section class="sc-sec" aria-labelledby="h-fields"><h2 id="h-fields">Browse by field</h2>
--<p class="sc-lede">The labels the boards put on most listings right now. Each opens a search for it.</p>
--<ul class="home-cards short">${sum.tags.map((t) => html`<li><a href="/jobs?${new URLSearchParams({ q: t.tag })}"><b>${sentence(t.tag)}</b><span>${n(t.count)} listing${t.count === 1 ? '' : 's'}</span></a></li>`)}</ul></section>` : ''}
--${sum.places.length ? html`<section class="sc-sec" aria-labelledby="h-places"><h2 id="h-places">Jobs by city</h2>
--<p class="sc-lede">The places most listings name. Remote jobs are <a href="/jobs?remote=true">their own search</a>.</p>
--<ul class="home-chips">${sum.places.map((p) => html`<li><a href="/jobs?${new URLSearchParams({ location: p.place })}">${p.place} <span>${n(p.count)}</span></a></li>`)}</ul></section>` : ''}
--<section class="sc-sec home-row" aria-labelledby="h-latest"><h2 id="h-latest">Latest remote jobs</h2>
--${listing.listingList(latest, { sourceOf, empty: 'No listings yet — the ingest has not run.' })}
--<p><a class="sc-btn" href="/jobs?remote=true">All remote listings</a></p></section>
--${salaried.length ? html`<section class="sc-sec home-row" aria-labelledby="h-pay"><h2 id="h-pay">Newest jobs that state the pay</h2>
--<p class="sc-lede">${n(sum.salaried)} listing${sum.salaried === 1 ? '' : 's'} say what the job pays, in the board's own words.</p>
--${listing.listingList(salaried, { sourceOf, empty: '' })}</section>` : ''}
--${sum.hiring.length ? html`<section class="sc-sec" aria-labelledby="h-hiring"><h2 id="h-hiring">Companies with the most openings</h2>
--<p class="sc-lede">Counted across every board. Each opens the company's page: its openings, where they are and which boards list them.</p>
--<ul class="home-cards short">${sum.hiring.map((c) => { const slug = companies.slugOf(c.company); return html`<li><a href="${slug ? `/companies/${slug}` : `/jobs?${new URLSearchParams({ q: c.company })}`}"><b>${c.company}</b><span>${n(c.count)} open listing${c.count === 1 ? '' : 's'}</span></a></li>`; })}</ul>
--<p><a href="/companies">Every company hiring</a></p></section>` : ''}
++${raw(pickBlock({ key: 'browse', label: 'Browse the listings', head: 'Browse the listings', panes, checked: 'latest' }))}
<section class="sc-sec" aria-labelledby="h-sources"><details class="home-fold"><summary><h2 id="h-sources">Where the listings come from</h2><span>${sources.SOURCES.length} public boards, how it works</span></summary><div class="home-fold-body">
<p>Each source below is a public job board read through its own API, under its own terms. Every listing on this site names its source and links to the original; we keep a short excerpt and send you to the board for the rest.</p>
${table(['Source', 'Listings', 'Last read', 'Terms'], sources.SOURCES.map((src) => {
@@@ -191,9 +201,19 @@ ${raw(showcase.steps({
{ title: 'Track your applications', text: 'Sign in and press Track this job on a listing: keep your status, notes and next steps in one place, and ask your OpenVibe.Actor agent to help prepare each application.' },
],
}))}</details></section>
--${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: 'work' }))}`,
++${FAQ.length ? raw(foldBlock({
++ id: 'faq',
++ bare: true,
++ head: '<h2>Questions people ask</h2>',
++ html: String(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(foldBlock({
++ id: 'network',
++ bare: true,
++ head: '<h2>Part of OpenVibe</h2>',
++ html: showcase.network({ service: 'work' }),
++ }))}`,
});
});
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex ae7f77b..f1aee55 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 { linkHeaderTags } = require('openvibe-shared/agent-layer');
@@@ -104,6 +105,7 @@ ${appIcon.headTags({ site: 'work' })}
${o.feed ? `<link rel="alternate" type="application/atom+xml" title="${esc(o.feed.title)}" href="${esc(o.feed.href)}">` : ''}
${(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="work@${esc(RELEASE)}">
<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,
@@@ -113,7 +115,12 @@ ${render(scripts)}
<main id="main" class="page">
${render(crumbs)}
${render(o.body)}
--${o.path === '/' ? frame.shipped({ service: 'work', title: `Recently shipped on ${SITE_NAME}` }) : ''}
++${o.path === '/' ? foldBlock({
++ id: 'shipped',
++ bare: true,
++ head: `<h2>Recently shipped on ${SITE_NAME}</h2>`,
++ html: frame.shipped({ service: 'work', 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..ec74c00
---- /dev/null
++++ b/server/render/short.js
@@@ -0,0 +1,60 @@
++'use strict';
++/**
++ * The home on a phone (the owner's short-pages rule, 2026-10-30): one or two screens, app-like.
++ *
++ * foldBlock() a part a reader rarely needs: closed on a phone, where the title row opens it, and open where there
++ * is room, where it looks like the section it replaces. A checkbox and a label, no script: the part
++ * stays in the page for the crawlers, and a browser without CSS shows it as it was.
++ * 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, and a wider screen (or a browser without
++ * :has()) shows them all and no chips.
++ *
++ * The CSS for both lives in the site's own stylesheet; 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 piece of markup as a string: a plain string, or the raw html a template returned. */
++const str = (v) => (v && typeof v === 'object' && typeof v.s === 'string') ? v.s : String(v == null ? '' : v);
++
++/**
++ * A fold. The head is the heading the block had (an <h2>, so it stays a heading on a phone too); the html is what it
++ * holds. `bare` is for a part that keeps its own heading in the body: where there is room the title row gives way to
++ * it, so the part looks exactly as it did.
++ *
++ * foldBlock({ id, head, html, bare })
++ */
++function foldBlock({ id, head, html, bare = false }) {
++ const key = `fold-${esc(id)}`;
++ return `<div class="fold${bare ? ' fold-bare' : ''}" id="${key}">`
++ + `<input class="fold-toggle" type="checkbox" id="${key}-open">`
++ + `<label class="fold-h" for="${key}-open">${str(head)}<span class="fold-mark" aria-hidden="true"></span></label>`
++ + `<div class="fold-body">${str(html)}</div></div>`;
++}
++
++/**
++ * 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, checked }) panes: [{ id, label, html }]; checked: the pane the first
++ * chip picks (the first one by default)
++ */
++function pickBlock({ key, label, head = '', panes, checked = '' }) {
++ if (!panes.length) return '';
++ const first = panes.find((p) => p.id === checked) ? checked : panes[0].id;
++ const chips = panes.map((p) => `<input type="radio" name="pick-${esc(key)}" id="pk-${esc(key)}-${esc(p.id)}" value="${esc(p.id)}"${p.id === first ? ' 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)}">${str(p.html)}</div>`).join('');
++ return `<div class="pick" id="pick-${esc(key)}"><div class="pick-bar"><div class="pick-head"><h2>${esc(head || label)}</h2></div><div class="pick-row" role="radiogroup" aria-label="${esc(label)}">${chips}</div></div><div class="pick-body">${body}</div></div>`;
++}
++
++/** The rule that shows the picked block of a pick block: one per block, so the CSS is written for the page. */
++function pickRule(key, id) {
++ return `.pick:has(#pk-${key}-${id}:checked) #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('');
++}
++
++module.exports = { foldBlock, pickBlock, pickRule, pickRules };
ddiff --git a/test/home-short.test.js b/test/home-short.test.js
nnew file mode 100644
iindex 0000000..40bb088
---- /dev/null
++++ b/test/home-short.test.js
@@@ -0,0 +1,82 @@
++'use strict';
++// The home on a phone (the owner's short-pages rule, 2026-10-30): the sections below the search are one at a time,
++// picked with a row of chips, and the parts a reader rarely needs fold closed. Radio inputs and :has() for the chips
++// and a checkbox and a label for the folds, no script: every section and every part stays in the page for the
++// crawlers, and a wider screen shows them all and no chips.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ // A few listings, so the home has a field, a city, a company and a pay to show: the sections below the search
++ // are the panes of the pick block, and a pane only exists when the site has something for it.
++ const now = new Date().toISOString();
++ for (const [i, row] of [
++ { title: 'Backend engineer', company: 'Nordlicht Systems', location: 'Berlin, Germany', remote: true, tags: 'nodejs,postgresql', salary: '60,000 EUR a year' },
++ { title: 'Frontend engineer', company: 'Helles Werk', location: 'Amsterdam, Netherlands', remote: false, tags: 'typescript,css', salary: '' },
++ { title: 'Data scientist', company: 'Nordlicht Systems', location: 'Berlin, Germany', remote: true, tags: 'python,ml', salary: '70,000 EUR a year' },
++ ].entries()) {
++ await t.ctx.s.db.query(
++ `INSERT INTO work_listings (id, source, source_id, url, title, company, location, remote, tags, job_type, salary, excerpt, search_text, posted_at, sort_at, fetched_at, first_seen_at, last_seen_at)
++ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$14,$14,$14,$14)`,
++ [`job_short${'0'.repeat(20)}${i}`, 'arbeitnow', `short-${i}`, `https://example.org/job/${i}`, row.title, row.company, row.location,
++ row.remote, row.tags, 'permanent', row.salary, 'A short excerpt of the listing.', `${row.title} ${row.company} ${row.tags}`.toLowerCase(), now]);
++ }
++
++ await check('the home picks one section at a time on a phone, and keeps every section in the page', async () => {
++ const r = await t.get('/');
++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
++ const home = r.text;
++ assert.match(home, /<div class="pick" id="pick-browse">/, 'the browse block is on the home');
++ assert.match(home, /<div class="pick-row" role="radiogroup" aria-label="Browse the listings">/, 'the chips are on the home');
++ // One chip per section, in the order the sections have, and the newest listings are the one a phone shows.
++ const chips = [...home.matchAll(/<input type="radio" name="pick-browse" id="pk-browse-([a-z]+)" value="\1"( checked)?><label for="pk-browse-\1">[^<]+<\/label>/g)];
++ const panes = [...home.matchAll(/<div class="pick-pane" id="pp-browse-([a-z]+)">/g)].map((m) => m[1]);
++ assert.ok(panes.length >= 3, `the sections are all in the page (${panes.length})`);
++ assert.deepStrictEqual(chips.map((m) => m[1]), panes, 'one chip per section, in the same order');
++ assert.deepStrictEqual(chips.filter((m) => m[2]).map((m) => m[1]), ['latest'], 'the newest listings are picked first');
++ // The sections themselves are in the page, not removed.
++ for (const h of ['Browse by field', 'Jobs by city', 'Latest remote jobs', 'Newest jobs that state the pay', 'Companies with the most openings']) {
++ assert.ok(home.includes(`>${h}</h2>`), `${h} is in the page`);
++ }
++ // The search stays first, and the newest listings come with it.
++ assert.ok(home.indexOf('id="search"') < home.indexOf('id="pick-browse"'), 'the search stays first');
++ assert.ok(home.indexOf('id="search"') < home.indexOf('id="h-latest"'), 'the newest listings stay with the search');
++ });
++
++ await check('the parts a reader rarely need fold, closed on a phone and open where there is room', async () => {
++ const r = await t.get('/');
++ const home = r.text;
++ const panes = [...home.matchAll(/<div class="pick-pane" id="pp-browse-([a-z]+)">/g)].map((m) => m[1]);
++ for (const [id, title] of [['faq', 'Questions people ask'], ['network', 'Part of OpenVibe'], ['shipped', 'Recently shipped on OpenVibe.Work']]) {
++ const re = new RegExp(`<div class="fold[^"]*" id="fold-${id}"><input class="fold-toggle" type="checkbox" id="fold-${id}-open">`
++ + `<label class="fold-h" for="fold-${id}-open"><h2[^>]*>[^<]*${title}[^<]*</h2>.*?<div class="fold-body">`);
++ assert.ok(re.test(home), `the ${title} fold is on the home`);
++ }
++ // The parts themselves are in the page, not removed.
++ assert.match(home, /<div class="home-faq">/, 'the questions are in the page');
++ assert.match(home, /class="sc-net"/, 'the rest of OpenVibe is in the page');
++ assert.match(home, /data-ov-shipped="list"/, 'what shipped is in the page');
++ // The CSS: the chips and their rule under 640 px and only with :has(); the fold closed on a phone, open where
++ // there is room.
++ const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'home.css'), 'utf8');
++ const pick = css.slice(css.indexOf('/* Pick (the owner'));
++ assert.ok(pick.length > 0, 'the pick rules are in home.css');
++ assert.match(pick, /\.pick-head, \.pick-row \{ display: none; \}/, 'the chips are hidden by default');
++ assert.match(pick, /@supports selector\(:has\(\*\)\) \{\n @media \(max-width: 640px\) \{/, 'the chips are only under 640 px and only with :has()');
++ assert.match(pick, /\.pick-pane \{ display: none; \}/, 'one section at a time');
++ const head = home.slice(home.indexOf('<style>@supports selector(:has(*))'));
++ assert.ok(head.startsWith('<style>@supports selector(:has(*))'), 'the rule that shows the picked section is on the page');
++ for (const id of panes) assert.ok(head.includes(`.pick:has(#pk-browse-${id}:checked) #pp-browse-${id}{display:block}`), `${id} shows when its chip is picked`);
++ const fold = css.slice(css.indexOf('/* The fold (the owner'));
++ assert.match(fold, /\.fold-body \{ display: none; \}/, 'the fold body is closed by default');
++ assert.match(fold, /\.fold-toggle:checked ~ \.fold-body \{ display: block; \}/, 'the title row opens it');
++ assert.match(fold, /\.fold-toggle \{ display: none; \}/, 'the toggle is hidden where there is room');
++ assert.match(fold, /\.fold-body \{ display: block; \}/, 'the body is open where there is room');
++ });
++
++ await t.close();
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex 9943827..d420e0f 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -17,14 +17,14 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
// 18.2 KB raw / 4.3 KB brotli, still two stylesheets and no external file. The home page itself stayed inside its own
// budget — the listings replace the skeleton's placeholder sections rather than adding to them.
const BUDGETS = {
-- htmlRawKB: 37, // 36.5 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 31.2 with the Part of OpenVibe section (Shared 4.13, 2026-10-26); measured 24.9 (the home page: hero, features, steps, cta, JSON-LD)
-- htmlBrotliKB: 9, // 7.8 with the Part of OpenVibe section (Shared 4.13, 2026-10-26); 6.2
++ htmlRawKB: 39, // 38.1 with the home's pick block and folds (the sections below the search are picked one at a time on a phone, 2026-10-30); 36.5 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 31.2 with the Part of OpenVibe section (Shared 4.13, 2026-10-26); measured 24.9 (the home page: hero, features, steps, cta, JSON-LD)
++ htmlBrotliKB: 9.5, // 9.1 with the home's pick block and folds (the sections below the search are picked one at a time on a phone, 2026-10-30); 7.8 with the Part of OpenVibe section (Shared 4.13, 2026-10-26); 6.2
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)
jsRawKB: 322, // 321.1 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 318.1 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 315.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 307.8 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 300.5 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 293 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 287.2 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 273.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 264.3 with Shared 4.13.0 (2026-10-26: account links); 239.8
jsBrotliKB: 79, // 78.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 77.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 76.1 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 74.2 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 70.5 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 67.9 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 65.3 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 56.5
cssFiles: 3, // 3 (app.css, the cached /shared/showcase.css and css/home.css, the curated home's counts, fields, cities, companies and questions, 2026-10-22)
-- cssRawKB: 43, // 41.7 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 39.5 the home's phone rows, limits and fold (2026-10-29); 34.8 openvibe-shared 4.26.1 (hero visual); 29.3 with Shared 4.13.0 (2026-10-26: account links); 23.1 with css/home.css (2026-10-22); 15.0 before the listings; 18.2 after (the search form, the listing list, the source blocks)
-- cssBrotliKB: 9.3, // 9.1 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 8.6 the home's phone rows, limits and fold (2026-10-29); 6.6–6.7 with Shared 4.13.0; raised from 8 for openvibe-shared v4.39.0 (+1.5 KB raw of kit/room/showcase CSS); measured 8.3
++ cssRawKB: 46, // 45.3 with the home's pick block and folds (the sections below the search are picked one at a time on a phone, 2026-10-30); 41.7 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 39.5 the home's phone rows, limits and fold (2026-10-29); 34.8 openvibe-shared 4.26.1 (hero visual); 29.3 with Shared 4.13.0 (2026-10-26: account links); 23.1 with css/home.css (2026-10-22); 15.0 before the listings; 18.2 after (the search form, the listing list, the source blocks)
++ cssBrotliKB: 10, // 10.0 with the home's pick block and folds (the sections below the search are picked one at a time on a phone, 2026-10-30); 9.1 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 8.6 the home's phone rows, limits and fold (2026-10-29); 6.6–6.7 with Shared 4.13.0; raised from 8 for openvibe-shared v4.39.0 (+1.5 KB raw of kit/room/showcase CSS); measured 8.3
externalFiles: 0, // 0
};