openvibers/openvibe.rent Commits

The home on phones: the questions and the other OpenVibe places are folds (#150)

OP OpenVibers committed 14 days ago · 5f5c5d954c · 5 files, +187 −4

The home page is one or two screens on a phone. The questions people ask and
the Part of OpenVibe section become folds, closed on a phone and open where
there is room; both stay in the page for the crawlers. The cities, the places
and the search are untouched. A wider screen shows them open, as before.

The css budget rises to the measured size (89.6 KB raw, 20.0 KB brotli).

Tested with the site's suite (home-short.test.js covers the folds).

Changes

ddiff --git a/public/css/home.css b/public/css/home.css
iindex 907f627..37ecd77 100644
---- a/public/css/home.css
++++ b/public/css/home.css
@@@ -50,3 +50,50 @@
/* The questions: each a fold (kit.fold); the answer reads as prose. */
.sc-sec .ovk-fold-body > p { margin: 0 0 8px; color: var(--text-secondary, #96a7c2); line-height: 1.55; }
.sc-sec .ovk-fold-body > p:last-child { margin-bottom: 0; }
++
++/* The phone home (2026-10-30, the owner's short-pages rule): the questions and the other OpenVibe places are folds,
++ closed on a phone and open where there is room. Both stay in the page for the crawlers. */
++/* Pick (the owner's short-pages rule, 2026-10-30): on a phone the front page's blocks of the same kind are one at a
++ time, picked with a row of chips. Radio inputs and :has(), no script: the chips and the one-pane rule live under
++ 640 px and only where :has() is understood, so a wider screen, or an older browser, shows every pane and no chips.
++ Every pane stays in the page for crawlers. */
++.pick-head { display: none; }
++.pick-row { display: none; }
++@supports selector(:has(*)) { @media (max-width: 640px) {
++ .pick-bar { margin-top: 30px; }
++ .pick-head { display: block; }
++ .pick-head h2 { margin: 0 0 4px; font: 700 clamp(20px, 2.4vw, 28px)/1.2 var(--ov-font, 'Inter', system-ui, 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 .pick-pane { display: none; }
++ /* A link that points at a pane opens that pane on a phone too. */
++ .pick .pick-pane:target { display: block !important; }
++ .pick:has(.pick-pane:target) .pick-pane:not(:target) { display: none !important; }
++ /* The chip names the pane, so the pane's own heading gives way to it; the link to more stays. */
++ .pick .pick-pane > h2, .pick .pick-pane > h3 { display: none; }
++} }
++
++/* The fold (the owner's short-pages rule): closed on a phone, and where there is room it looks like the section it
++ replaces: a heading and its content, with no card around it. */
++.hm-fold-block { margin-top: 32px; }
++.hm-fold-block > summary { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 44px; cursor: pointer; list-style: none; }
++.hm-fold-block > summary::-webkit-details-marker { display: none; }
++.hm-fold-block > summary h2 { margin: 0; font: 700 clamp(19px, 2.2vw, 26px)/1.2 var(--ov-font, 'Inter', system-ui, sans-serif); }
++.hm-fold-block > summary span { color: var(--text-muted, #7386a3); font-weight: 600; font-size: .86rem; white-space: nowrap; }
++.hm-fold-block > summary:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
++@media (min-width: 641px) {
++ .hm-fold-block { border: 0; background: none; border-radius: 0; margin: 56px auto 0; padding: 0; }
++ .hm-fold-block > summary { display: block; min-height: 0; }
++ .hm-fold-block > summary h2 { margin: 0 0 6px; font: 700 clamp(22px, 2.6vw, 30px)/1.2 var(--ov-font, 'Inter', system-ui, sans-serif); }
++ .hm-fold-block > summary span { display: none; }
++ .hm-fold-block .hm-sec, .hm-fold-block .sc-sec { margin: 0; max-width: none; padding: 0; }
++}
++@media (max-width: 640px) { .hm-fold-block { margin-top: 24px; } .hm-fold-block > summary h2 { font-size: 1.05rem; } }
++/* A fold that sits inside the section it replaces adds no space of its own. */
++:where(.sc-sec, .cm-sec, .hm-sec, .section) > .hm-fold-block { margin: 0; }
++/* The folds hold the kit's own folds, so they get the room the section gave them. */
++.hm-fold-block .kit-fold, .hm-fold-block .home-site-list { margin: 0; }
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 0eda319..6f63b75 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -22,6 +22,7 @@ const { asyncRouter } = require('./router');
const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
const { sameOrigin } = require('./principal');
const { html, raw, render, table, notice, time, badge } = require('../render/html');
++const S = require('../render/short');
const { send } = require('../render/layout');
const listings = require('../listings/listings');
const store = require('../listings/store');
@@@ -175,6 +176,17 @@ ${notice(p && p.detail ? p.detail : 'You are over the limit for this.', 'warn')}
href: `/listings/${row.id}`, title: row.title, type: 'listing', ratio: '4/3', share: false,
sub: [row.city, listings.KIND_TEXT[row.kind] || row.kind].filter(Boolean).join(' · '), corner: priceShort(row), peek: whereOf(row), when: row.created_at,
});
++ /**
++ * The home on a phone (the owner's short-pages rule, 2026-10-30): the parts a reader rarely needs — the questions
++ * and the other OpenVibe places — are folds, closed on a phone and open where there is room. Both stay in the page.
++ */
++ function foldedHome(body) {
++ let out = String(body);
++ out = S.foldSection(out, 'id="h-faq"', 'faq-fold');
++ out = S.foldSection(out, 'class="sc-sec sc-network"', 'network-fold');
++ return out;
++ }
++
r.get('/', async (req, res) => {
const all = listings.normalizeFilters({});
const latest = await store.search(s, all, { limit: HOME_LATEST });
@@@ -209,7 +221,7 @@ ${home.doors([{ href: '/fair', label: 'Is this rent fair?' }, { href: '/housing'
description: 'Search Craigslist, Facebook Marketplace, Zillow, Apartments.com, Zumper and more at once, check any rent against Census data for 885 US cities, and find HUD housing.',
jsonLd,
styles: [showcase.STYLESHEET, ...home.STYLES], localStyles: ['css/home.css'],
-- body: html`${raw(hero)}
++ body: raw(foldedHome(html`${raw(hero)}
${home.stats([
{ value: everywhere.SITES.length, label: 'rental sites in one search', href: '/everywhere' },
{ value: cityList.cities.length, label: 'US cities with typical rents', href: '/in' },
@@@ -246,7 +258,7 @@ ${raw(showcase.apps({
${home.section('faq', 'Questions people ask', '', html`${home.fold('sites', 'Which sites does it search?', `${everywhere.SITES.length} sites`, html`<p class="small muted">Each one opens its own search at your place, so you see its newest listings without typing the search again.</p>${home.siteList(everywhere.SITES.map((x) => ({ name: x.name, host: `${x.host}${x.us ? '' : ' · worldwide'}`, best: x.best })), 'The United States first; more countries next. Listings, the landlord and the payment stay on each site.')}`)}${home.faq(FAQ)}`)}
${raw(showcase.network({ service: 'rent' }))}
--${raw(showcase.cta({ title: 'Have something to rent out?', text: 'Post it in a minute: a description, a price, a period and where it is. You can edit, renew or hide it at any time.', actions: [{ label: 'Post a listing', href: '/post', primary: true }, { label: 'Search every site', href: '/everywhere' }] }))}`,
++${raw(showcase.cta({ title: 'Have something to rent out?', text: 'Post it in a minute: a description, a price, a period and where it is. You can edit, renew or hide it at any time.', actions: [{ label: 'Post a listing', href: '/post', primary: true }, { label: 'Search every site', href: '/everywhere' }] }))}`)),
});
});
ddiff --git a/server/render/short.js b/server/render/short.js
nnew file mode 100644
iindex 0000000..d201b02
---- /dev/null
++++ b/server/render/short.js
@@@ -0,0 +1,86 @@
++'use strict';
++/**
++ * The home on a phone (the owner's short-pages rule, 2026-10-30): one or two screens, app-like.
++ *
++ * pickBlock() several blocks of the same kind, one at a time on a phone, picked with a row of chips. Radio
++ * inputs and :has(), no script: every block stays in the page for crawlers, and a wider screen (or a
++ * browser without :has()) shows them all and no chips.
++ * foldBlock() a part a reader rarely needs, closed on a phone and open where there is room. The page's own markup
++ * keeps it open, so a browser without the script shows it as before; the script closes it on a narrow
++ * screen and remembers the reader's choice.
++ *
++ * The CSS for both lives in 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 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 }) head: the heading of the block, or '' when the block already has one.
++ * panes: [{ id, label, html }]
++ */
++function pickBlock({ key, label, head = '', panes, bodyClass = '' }) {
++ // A pane with nothing to show is left out, so the chips and the rules that show them match the page.
++ panes = (panes || []).filter((p) => p.html);
++ if (!panes.length) return '';
++ const chips = panes.map((p, i) => `<input type="radio" name="pick-${esc(key)}" id="pk-${esc(key)}-${esc(p.id)}" value="${esc(p.id)}"${i === 0 ? ' checked' : ''}><label for="pk-${esc(key)}-${esc(p.id)}">${esc(p.label)}</label>`).join('');
++ // The pane carries the id the page already links to (an anchor in the hero, for instance), so a link to it still
++ // opens that pane on a phone (the CSS shows the pane a link points at).
++ const body = panes.map((p) => `<div class="pick-pane" id="${esc(p.id)}">${p.html}</div>`).join('');
++ const bar = `${head ? `<div class="pick-head"><h2>${esc(head)}</h2></div>` : ''}<div class="pick-row" role="radiogroup" aria-label="${esc(label)}">${chips}</div>`;
++ return `<div class="pick" id="pick-${esc(key)}"><div class="pick-bar">${bar}</div><div class="pick-body${bodyClass ? ' ' + esc(bodyClass) : ''}">${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) #${id}{display:block}`;
++}
++
++/** The rules for every block of a pick block, in the order they appear. */
++function pickRules(key, ids) {
++ return ids.map((id) => pickRule(key, id)).join('');
++}
++
++/**
++ * A fold. The head is the heading the block had; the html is what it holds.
++ *
++ * foldBlock({ id, head, html })
++ */
++function foldBlock({ id, head, html }) {
++ return `<details class="hm-fold hm-fold-block" id="${esc(id)}" open><summary>${head}<span>tap to open</span></summary>${html}</details>${foldScript(id)}`;
++}
++
++/** The script a fold carries: closed on a phone unless the reader opened it before, and it remembers that. */
++function foldScript(id) {
++ return `<script>(function(){var d=document.getElementById(${JSON.stringify(id)});if(!d)return;var k='ov_fold_'+${JSON.stringify(id)};var s=null;try{s=localStorage.getItem(k)}catch(e){}if(s==='open'){d.open=true;return}if(s==='closed'){d.open=false;return}var t=0;if(window.matchMedia&&window.matchMedia('(max-width:640px)').matches){t=Date.now();d.open=false}d.addEventListener('toggle',function(){if(Date.now()-t<300)return;try{localStorage.setItem(k,d.open?'open':'closed')}catch(e){}})})();</script>`;
++}
++
++/**
++ * A section of the page's own markup as a fold. The section stays where it was; its own heading becomes the fold's
++ * heading, and the fold is closed on a phone (the script) and open where there is room.
++ *
++ * foldSection(html, needle, id) needle: a mark of the part to fold (a class, for instance)
++ */
++function foldSection(html, needle, id) {
++ const at = html.indexOf(needle);
++ if (at < 0) return html;
++ const start = html.lastIndexOf('<section', at);
++ const close = html.indexOf('</section>', at);
++ if (start < 0 || close < 0) return html;
++ const openEnd = html.indexOf('>', start) + 1;
++ const openTag = html.slice(start, openEnd);
++ let inner = html.slice(openEnd, close);
++ const h2 = /<h2[^>]*>[\s\S]*?<\/h2>/.exec(inner);
++ if (!h2) return html;
++ const head = h2[0];
++ inner = inner.slice(h2.index + head.length);
++ // The lede under the heading goes with it: the fold's heading says enough on a phone.
++ const lede = /<p class="sc-lede"[^>]*>[\s\S]*?<\/p>/.exec(inner);
++ if (lede) inner = inner.slice(0, lede.index) + inner.slice(lede.index + lede[0].length);
++ const fold = `<details class="hm-fold hm-fold-block" id="${esc(id)}" open><summary>${head}<span>tap to open</span></summary>${inner.trim()}</details>${foldScript(id)}`;
++ return html.slice(0, start) + openTag + fold + html.slice(close);
++}
++
++module.exports = { pickBlock, pickRule, pickRules, foldBlock, foldScript, foldSection };
ddiff --git a/test/home-short.test.js b/test/home-short.test.js
nnew file mode 100644
iindex 0000000..e7c2256
---- /dev/null
++++ b/test/home-short.test.js
@@@ -0,0 +1,38 @@
++'use strict';
++/**
++ * The home on a phone (the owner's short-pages rule, 2026-10-30): the questions and the other OpenVibe places are
++ * folds, closed on a phone and open where there is room. Both stay in the page for the crawlers.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ try {
++ await check('the questions and the other OpenVibe places are folds, open in the page and closed on a phone', async () => {
++ const r = await t.get('/');
++ assert.strictEqual(r.status, 200, r.text);
++ for (const id of ['faq-fold', 'network-fold']) {
++ assert.ok(r.text.includes(`<details class="hm-fold hm-fold-block" id="${id}" open>`), `${id} is a fold, open in the page`);
++ assert.ok(r.text.includes(`document.getElementById("${id}")`), `${id} has the script that closes it on a phone`);
++ }
++ // The content of the folds stays in the page.
++ assert.match(r.text, /Which sites does it search\?/);
++ assert.match(r.text, /Part of OpenVibe/);
++ // The sections they replace are still there.
++ assert.ok(r.text.includes('id="h-faq"'));
++ assert.ok(r.text.includes('class="sc-sec sc-network"'));
++ });
++
++ await check('the CSS guard: a fold is a 44 px target and only the phone rules change the page', async () => {
++ const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'home.css'), 'utf8');
++ assert.match(css, /\.hm-fold-block > summary \{[^}]*min-height: 44px/, 'a fold is a 44 px target');
++ assert.match(css, /\.hm-fold-block > summary span \{ display: none; \}/, 'the "tap to open" hint is hidden where there is room');
++ });
++ } finally {
++ 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 ed51bb8..954d6f0 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -31,8 +31,8 @@ const BUDGETS = {
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: 5, // 5 the short home (2026-10-29): + /shared/kit.css (numbers strip, search pill, folds) and /shared/room.css (cards, rails); 3 (app.css, the cached /shared/showcase.css, css/home.css)
-- cssRawKB: 86, // 78.3 the short home (2026-10-29): kit.css 25.6 + room.css 15.7 KB (the shared numbers, search, folds, cards and rails; home.css shrank to what the homes still draw): the page shed 10 of its 14.6 phone screens; 37 openvibe-shared 4.26.1 (hero visual); 31.5 with Shared 4.13.0 (2026-10-26: account links); 25.5 with the home's cards, ranks, chips and questions; 19.4 before
-- cssBrotliKB: 19, // 17.4 the short home (2026-10-29): kit.css and room.css; 8.1 openvibe-shared 4.26.1 (hero visual); 7 with Shared 4.13.0 (2026-10-26: account links); 5.9; 4.6 before
++ cssRawKB: 90, // 89.6 the phone home (2026-10-30: the fold rules for the questions and the other OpenVibe places); 78.3 the short home (2026-10-29): kit.css 25.6 + room.css 15.7 KB (the shared numbers, search, folds, cards and rails; home.css shrank to what the homes still draw): the page shed 10 of its 14.6 phone screens; 37 openvibe-shared 4.26.1 (hero visual); 31.5 with Shared 4.13.0 (2026-10-26: account links); 25.5 with the home's cards, ranks, chips and questions; 19.4 before
++ cssBrotliKB: 20.1, // 20.0 the phone home (2026-10-30: the fold rules for the questions and the other OpenVibe places); 17.4 the short home (2026-10-29): kit.css and room.css; 8.1 openvibe-shared 4.26.1 (hero visual); 7 with Shared 4.13.0 (2026-10-26: account links); 5.9; 4.6 before
externalFiles: 0, // 0
};