openvibers/openvibe.rent Commits

Home: the largest cities' rent as bars (#177)

OP OpenVibers committed 12 days ago · 7dcf5ce1b0 · 4 files, +40 −6

* Home: the largest cities' rent as bars, the dearest first

* Budget: 2026-11-01: the city bars

Changes

ddiff --git a/public/css/home.css b/public/css/home.css
iindex 37ecd77..4cc336a 100644
---- a/public/css/home.css
++++ b/public/css/home.css
@@@ -97,3 +97,24 @@
: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; }
++
++/* The largest cities as bars, the dearest first (2026-11-01): the name and state, a bar, the figure. Each row is a link;
++ the bar is the accent at full strength against a quiet track. */
++.home-bars-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin: 0 0 10px; }
++.home-bars-head h2 { margin: 0; }
++.home-more { flex: none; font-size: .9rem; font-weight: 600; }
++.home-bars { list-style: none; margin: 0; padding: 0; display: grid; gap: 2px; }
++.home-bars a { display: grid; grid-template-columns: minmax(0, 11em) minmax(0, 1fr) auto; align-items: center; gap: 12px; min-height: 44px; padding: 6px 10px; border-radius: 10px; color: inherit; text-decoration: none; }
++.home-bars a:hover, .home-bars a:focus-visible { background: color-mix(in srgb, var(--text-primary, #fff) 5%, transparent); }
++.hb-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
++.hb-name small { margin-left: 6px; font-size: .78rem; font-weight: 600; color: var(--text-muted, #7386a3); }
++.hb-bar { height: 10px; border-radius: 999px; background: color-mix(in srgb, var(--text-primary, #fff) 7%, transparent); overflow: hidden; }
++.hb-bar i { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--accent-dark, #1d4ed8), var(--accent, #3b82f6)); }
++.hb-n { font-weight: 700; font-variant-numeric: tabular-nums; }
++@media (max-width: 520px) {
++ .home-bars a { grid-template-columns: minmax(0, 8.5em) minmax(0, 1fr) auto; gap: 8px; padding: 6px 4px; }
++}
++@media (min-width: 900px) {
++ .home-bars { display: block; columns: 2; column-gap: 32px; }
++ .home-bars li { break-inside: avoid; }
++}
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex a7f1f57..515b832 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -231,7 +231,6 @@ ${notice(p && p.detail ? p.detail : 'You are over the limit for this.', 'warn')}
const big = home.bigCities(250000).filter((c) => c.rent.b2 != null && c.rent.b2 < 3501);
const byTwo = [...big].sort((a, b) => a.rent.b2 - b.rent.b2);
const rentRow = (c) => ({ name: `${c.name}, ${c.state}`, href: c.path, value: cityList.rentText(c.rent.b2) });
-- const sizes = (c) => [c.rent.studio != null ? `Studio ${cityList.rentText(c.rent.studio)}` : null, c.rent.b1 != null ? `1 bed ${cityList.rentText(c.rent.b1)}` : null, c.rent.b3 != null ? `3 beds ${cityList.rentText(c.rent.b3)}` : null].filter(Boolean).join(' · ');
const states = cityList.states().length;
// The search comes first on a phone: the hero is a line of what it is, then the search, then two doors.
const hero = showcase.hero({
@@@ -265,8 +264,8 @@ ${home.stats([
${latest.rows.length ? html`<section class="sc-sec home-sec home-latest">${raw(kit.rail({ id: 'latest', wrap: true, title: `Posted on ${SITE_NAME}`, more: { href: '/listings', label: `See all ${posted}` }, size: 's', items: latest.rows.map(listingCard) }))}
<p class="home-kinds" aria-label="Listings by kind">${raw(kinds.map((k) => kit.button({ href: `/listings?${listings.filtersToQuery({ kind: k.value })}`, label: `${listings.KIND_TEXT[k.value] || k.value} ${k.count}`, kind: 'quiet', small: true })).join(' '))}</p></section>` : ''}
--<section class="sc-sec home-sec home-cities">${home.cityRail({ id: 'cities', title: 'What rent costs in the largest cities', more: { href: '/in', label: `All ${cityList.cities.length} cities` }, list: largest, pick: (c) => ({ corner: c.rent.b2 != null ? cityList.rentText(c.rent.b2) : null, peek: sizes(c) }) })}
--<p class="small muted home-under">The middle rent for a 2-bedroom (Census Bureau, ${cityList.SOURCE.years}); studios, 1- and 3-bedrooms on hover and on each city's page, with every rental site there. <a href="/fair">Check a rent</a></p>
++<section class="sc-sec home-sec home-cities">${home.cityBars({ id: 'cities', title: 'What rent costs in the largest cities', more: { href: '/in', label: `All ${cityList.cities.length} cities` }, list: largest, value: (c) => (c.rent.b2 != null ? Number(c.rent.b2) : NaN), text: (c) => cityList.rentText(c.rent.b2) })}
++<p class="small muted home-under">The middle rent for a 2-bedroom (Census Bureau, ${cityList.SOURCE.years}); studios, 1- and 3-bedrooms on each city's page, with every rental site there. <a href="/fair">Check a rent</a></p>
${big.length >= 10 ? home.fold('range', 'Where renting costs least and most', '', html`<p class="small muted">Big cities (250,000 people or more) by the middle 2-bedroom rent. A price far below a city's middle rent is a common sign of a scam.</p><div class="home-ranks">${home.ranked('Lowest 2-bedroom rent', byTwo.slice(0, 6).map(rentRow))}${home.ranked('Highest 2-bedroom rent', byTwo.slice(-6).reverse().map(rentRow))}</div>`) : ''}
${home.fold('states', 'Rent by state', `${states} states and territories`, html`<p class="small muted">Typical rents for every city of 50,000 people or more, and each state's largest, with every rental site's search for each.</p>${home.stateChips('/in')}`)}
</section>
ddiff --git a/server/render/home-blocks.js b/server/render/home-blocks.js
iindex 20631bc..08b1dda 100644
---- a/server/render/home-blocks.js
++++ b/server/render/home-blocks.js
@@@ -37,6 +37,20 @@ function cityRail({ id, title, more, list, pick, href = (c) => c.path }) {
}));
}
++/**
++ * The largest cities as bars, the dearest first (2026-11-01): one look compares them, where a rail of cards drew the
++ * same map icon twelve times. Each row is a link to its city: the name and state, a bar as long as its rent against
++ * the dearest, and the figure. `value(c)` is the number the bar measures, `text(c)` how it reads; cities without a
++ * value are left out (their pages say why).
++ */
++function cityBars({ id, title, more, list, value, text, href = (c) => c.path }) {
++ const rows = (list || []).filter((c) => Number.isFinite(value(c)) && value(c) > 0).sort((a, b) => value(b) - value(a));
++ if (!rows.length) return '';
++ const max = value(rows[0]);
++ return html`<div class="home-bars-head"><h2 id="h-${id}">${title}</h2>${more ? html`<a class="home-more" href="${more.href}">${more.label}</a>` : ''}</div>
++<ol class="home-bars" aria-labelledby="h-${id}">${rows.map((c) => html`<li><a href="${href(c)}"><span class="hb-name">${c.name}<small>${c.state}</small></span><span class="hb-bar" aria-hidden="true"><i style="width:${Math.max(6, Math.round((value(c) / max) * 100))}%"></i></span><span class="hb-n">${text(c)}</span></a></li>`)}</ol>`;
++}
++
/** A part read once, folded: the title says what is inside, `meta` how much. */
const fold = (id, title, meta, body) => (render(body).trim() ? raw(kit.fold({ id, title, meta, body: render(body) })) : '');
@@@ -70,4 +84,4 @@ const doors = (list) => html`<p class="home-doors">${raw(list.slice(0, 2).map((d
/** Cities with at least `min` people, largest first. */
const bigCities = (min) => cities.cities.filter((c) => c.population >= min).sort((a, b) => b.population - a.population);
--module.exports = { STYLES, stats, section, cityRail, fold, ranked, stateChips, siteList, faq, search, doors, bigCities };
++module.exports = { STYLES, stats, section, cityRail, cityBars, fold, ranked, stateChips, siteList, faq, search, doors, bigCities };
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex cfa31e0..5ff776f 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -31,8 +31,8 @@ const BUDGETS = {
jsRawKB: 331, // 328.2 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 324.8 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 323.2 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face (2026-10-31); 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: 80.8, // 80.2 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 79.5 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face (2026-10-31); 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: 108, // 107.4 with 2026-11-01: phone table cards (2026-11-01); 104.8 with openvibe-shared 4.82.0: the motion tokens in kit.css and room.css (2026-11-01); 102.7 with openvibe-shared 4.81.0: shape and elevation tokens in kit.css and room.css (2026-11-01); 98.3 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 94.8 with openvibe-shared 4.62.0: the shared sidebar in kit.css, the glyphs and their fonts (2026-10-31); 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: 24, // 23.6 with 2026-11-01: sign-in prompt layout (2026-11-01); 23.2 with openvibe-shared 4.82.0: the motion tokens in kit.css and room.css (2026-11-01); 22.7 with openvibe-shared 4.81.0: shape and elevation tokens in kit.css and room.css (2026-11-01); 20.8 with openvibe-shared 4.62.0: the shared sidebar in kit.css, the glyphs and their fonts (2026-10-31); 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
++ cssRawKB: 110, // 109.2 with 2026-11-01: the city bars (2026-11-01); 107.4 with 2026-11-01: phone table cards (2026-11-01); 104.8 with openvibe-shared 4.82.0: the motion tokens in kit.css and room.css (2026-11-01); 102.7 with openvibe-shared 4.81.0: shape and elevation tokens in kit.css and room.css (2026-11-01); 98.3 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 94.8 with openvibe-shared 4.62.0: the shared sidebar in kit.css, the glyphs and their fonts (2026-10-31); 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: 24.5, // 24.3 with 2026-11-01: the city bars (2026-11-01); 23.6 with 2026-11-01: sign-in prompt layout (2026-11-01); 23.2 with openvibe-shared 4.82.0: the motion tokens in kit.css and room.css (2026-11-01); 22.7 with openvibe-shared 4.81.0: shape and elevation tokens in kit.css and room.css (2026-11-01); 20.8 with openvibe-shared 4.62.0: the shared sidebar in kit.css, the glyphs and their fonts (2026-10-31); 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
};