openvibers/openvibe.work Commits
Work: the sections below the search are picked one at a time on a phone (#135)
Changes
@@@ -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; }++}@@@ -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' }),++ }))}`,});});@@@ -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')};nnew file mode 100644@@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');++++/** 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 };nnew file mode 100644@@@ -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); });@@@ -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.2jsFiles: 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.8jsBrotliKB: 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.5cssFiles: 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.3externalFiles: 0, // 0};
@@ -46,3 +46,55 @@
@@ -46,3 +46,55 @@
.home-stats li { flex: 0 0 auto; width: 136px; }
.home-stats li { flex: 0 0 auto; width: 136px; }
.home-fold > summary span { display: none; }
.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; }
+}
@@ -18,6 +18,7 @@ const { asyncRouter } = require('./router');
@@ -18,6 +18,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 } = require('../render/html');
const { html, raw, table, notice, time } = require('../render/html');
+const { foldBlock, pickBlock, pickRules } = require('../render/short');
const { send } = require('../render/layout');
const { send } = require('../render/layout');
const listing = require('../render/listing');
const listing = require('../render/listing');
const store = require('../jobs/store');
const store = require('../jobs/store');
@@ -138,6 +139,27 @@ ${facets.type.map((t) => link({ type: t.type }, t.type, filters.type === t.type,
@@ -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: sum.remote, label: 'remote listings', href: '/jobs?remote=true' },
{ value: sources.SOURCES.length, label: 'job boards read', href: '/sources' },
{ value: sources.SOURCES.length, label: 'job boards read', href: '/sources' },
].filter((x) => x.value);
].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, {
page(req, res, {
index: true, cache: signedIn(req) ? null : PUBLIC_CACHE,
index: true, cache: signedIn(req) ? null : PUBLIC_CACHE,
title: 'Jobs from open job boards, with sources',
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,
@@ -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,
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 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)}
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>` : ''}
${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>
<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' })}
${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>
<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">
<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>
<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) => {
${table(['Source', 'Listings', 'Last read', 'Terms'], sources.SOURCES.map((src) => {
@@ -191,9 +201,19 @@ ${raw(showcase.steps({
@@ -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.' },
{ 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>
}))}</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' }),
+ }))}`,
});
});
});
});
@@ -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 { linkHeaderTags } = require('openvibe-shared/agent-layer');
const { linkHeaderTags } = require('openvibe-shared/agent-layer');
@@ -104,6 +105,7 @@ ${appIcon.headTags({ site: 'work' })}
@@ -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.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.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="work@${esc(RELEASE)}">
<meta name="ov-boost" content="work@${esc(RELEASE)}">
<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,
<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,
@@ -113,7 +115,12 @@ ${render(scripts)}
@@ -113,7 +115,12 @@ ${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: '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>
</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,60 @@
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
+
+/** 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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,82 @@
@@ -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); });
@@ -17,14 +17,14 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
@@ -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
// 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.
// budget — the listings replace the skeleton's placeholder sections rather than adding to them.
const BUDGETS = {
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)
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
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
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)
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
externalFiles: 0, // 0
};
};