openvibers/openvibe.rent Commits
Short front page on openvibe.rent and openvibe.homes: the search first, rails, folds, a launcher (#136)
Changes
@@@ -396,6 +396,6 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [<!-- versions:start -->- openvibe-contracts: v0.222.0- openvibe-sdk: v0.67.0--- openvibe-shared: v4.37.0++- openvibe-shared: v4.38.0- openvibe-publishing: v1.4.0<!-- versions:end -->@@@ -9,11 +9,11 @@"domain": "openvibe.rent","contracts": "openvibe-contracts v0.222.0","sdk": "openvibe-sdk v0.67.0",-- "shared": "openvibe-shared v4.37.0",++ "shared": "openvibe-shared v4.38.0","packages": ["openvibe-contracts v0.222.0","openvibe-sdk v0.67.0",-- "openvibe-shared v4.37.0",++ "openvibe-shared v4.38.0","openvibe-publishing v1.4.0"],"summary": "OpenVibe.Rent — Find a place, or rent yours out. One place to find and offer things to rent: apartments, rooms, houses, commercial space, parking and storage, and equipment. In this first version every listing is posted by a person on this site: no scraped listings and no partner feeds, no payments, no deposits and no messaging. Search with filters and facets, a listing page with a plain safety note, reports that hide a listing at 3 until staff look, saved searches that count what is new, a 30-day expiry an owner can renew, and a FHFA House Price Index card on the affordable-housing page.",@@@ -18,7 +18,7 @@"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.37.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.38.0","pg": "^8.23.0"},"devDependencies": {@@@ -1209,9 +1209,9 @@}},"node_modules/openvibe-shared": {-- "version": "4.37.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.37.0",-- "integrity": "sha512-zT73fINooICUxuoxT8bwS1C5bh6ieNaDAX6aUaMdzMth1nTXbkwh54bgiPMytAJ2D0a8b3pqxhLYCvxFVzCVqA==",++ "version": "4.38.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.38.0",++ "integrity": "sha512-4sEQ78EQaw+cWWJs+AlPRhxObzHT1nwTMz6yHVBL20Mr+raAhhP10DtL1OTnbuMCDVsYhVwpd3QGgmyC6391EA==","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0",@@@ -25,7 +25,7 @@"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.37.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.38.0","pg": "^8.23.0"},"engines": {@@@ -87,7 +87,6 @@ form.inline { display: inline; }/* The home page's showcase sections (openvibe-shared/showcase.css) sit inside .page, which has its own gutter. */.page .sc-hero, .page .sc-sec, .page .sc-cta { padding-left: 0; padding-right: 0; }--.page .sc-ic .ov-icon { --ovi-size: 28px !important; }button.sc-btn { background: transparent; }button.sc-btn.sc-primary { border: 0; }/* The sites the search-everywhere form opens (home); the results page has css/everywhere.css. */@@@ -1,30 +1,38 @@--/* OpenVibe.Rent's and OpenVibe.Homes' home pages (server/render/home-blocks.js): counts, kinds, city cards, ranked lists,-- state chips and questions. Theme tokens only, so light and dark both read; every grid fits a 320px phone. */--.home-stats { list-style: none; padding: 0; margin: 32px 0 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fit, minmax(min(130px, 100%), 1fr)); }--.home-stats li > a, .home-stats li > b { display: block; }--.home-stats li { border-radius: 14px; border: 1px solid var(--border, #1f2d47); border-top: 3px solid var(--accent, #3b82f6); background: var(--bg-secondary, #0f1729); }--.home-stats a { padding: 14px 16px; color: inherit; text-decoration: none; border-radius: 14px; height: 100%; box-sizing: border-box; }--.home-stats li:not(:has(a)) { padding: 14px 16px; }--.home-stats a:hover, .home-stats a:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -2px; }--.home-stats b { display: block; font-size: 1.75rem; line-height: 1.1; font-weight: 800; font-variant-numeric: tabular-nums; color: var(--text-primary, #e6edf7); }--.home-stats span { display: block; margin-top: 4px; font-size: .88rem; color: var(--text-secondary, #96a7c2); }++/* OpenVibe.Rent's and OpenVibe.Homes' front pages (server/render/home-blocks.js). The numbers, rails, cards, folds and++ the search pill are the shared kit's (kit.css, room.css); this is only what the homes draw themselves and how the++ kit's pieces sit in the hero. Theme tokens only, so light and dark both read; everything fits a 320px phone. */--.home-kinds, .home-cities { list-style: none; padding: 0; margin: 0 0 8px; display: grid; grid-template-columns: minmax(0, 1fr); gap: 10px; }--.home-kinds { grid-template-columns: repeat(auto-fill, minmax(min(230px, 100%), 1fr)); }--.home-cities { grid-template-columns: repeat(auto-fill, minmax(min(220px, 100%), 1fr)); }--.home-kinds a, .home-cities a { display: flex; flex-direction: column; gap: 4px; height: 100%; box-sizing: border-box; padding: 12px 14px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); text-decoration: none; transition: border-color .15s ease, transform .15s ease; }--.home-kinds a:hover, .home-cities a:hover, .home-kinds a:focus-visible, .home-cities a:focus-visible { border-color: var(--accent, #3b82f6); transform: translateY(-1px); outline: 0; }--.home-kinds b { font-size: 1rem; }--.home-kinds span { font-size: .86rem; color: var(--text-secondary, #96a7c2); overflow-wrap: anywhere; }--.hc-name { font-weight: 700; font-size: 1.02rem; overflow-wrap: anywhere; }--.hc-state { margin-left: 6px; font-size: .78rem; font-weight: 600; color: var(--text-muted, #7386a3); }--.hc-main { font-size: .88rem; color: var(--text-secondary, #96a7c2); }--.hc-main span { white-space: nowrap; }--.hc-main b { font-size: 1.3rem; color: var(--text-primary, #e6edf7); font-variant-numeric: tabular-nums; }--.hc-sub { font-size: .8rem; color: var(--text-muted, #7386a3); font-variant-numeric: tabular-nums; }++/* The hero: a line of what it is, the search, two small doors and the one rule that matters. */++.sc-hero-aside .ovk-find { margin: 0; min-height: 52px; }++.sc-hero-aside .ovk-find input { font-size: 1.02rem; }++.home-doors { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0 0; }++.home-note { margin: 10px 0 0; font-size: .84rem; line-height: 1.45; color: var(--text-muted, #7386a3); }--.home-ranks { display: grid; gap: 14px; grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr)); }--.home-rank { padding: 14px 16px; border-radius: 14px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); }++/* Sections of rails and folds sit at the kit's rail rhythm, closer than the showcase's sections; the rail brings its heading. */++.sc-sec.home-sec { margin-top: clamp(30px, 4vw, 44px); }++.home-sec > .ovk-rail:first-child { margin-top: 0; }++.home-sec .ovk-fold { margin: 10px 0 0; }++.home-sec .ovk-fold + .ovk-fold { margin-top: 8px; }++.home-under { margin: 2px 0 12px; }++/* A computer sees every card at once: the rail wraps into a grid (a mouse has no ‹ › buttons without kit-web.js).++ Phones and touch tablets keep the rail they swipe. */++@media (min-width: 760px) and (hover: hover), (min-width: 1024px) {++ .home-sec .ovk-rail-track { grid-auto-flow: row; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); grid-auto-columns: auto; overflow: visible; margin: 0; padding: 2px 0 10px; }++}++++/* The listings' kinds, as quick filters under their rail: one strip that scrolls sideways on a phone. */++.home-kinds { display: flex; flex-wrap: wrap; gap: 8px; margin: 4px 0 0; }++@media (max-width: 599px) {++ .home-kinds { flex-wrap: nowrap; overflow-x: auto; margin-inline: -16px; padding: 2px 16px; scrollbar-width: none; }++ .home-kinds::-webkit-scrollbar { display: none; }++ .home-kinds > * { flex: none; }++}++/* The narrowest phones give the place's box the room the pill's magnifier took (the go button still says Search). */++@media (max-width: 359px) { .sc-hero-aside .ovk-find > .ovk-i:first-child { display: none; } }++++/* Inside the folds: the cheapest and dearest cities side by side, every state as a chip, every site searched. */++.home-ranks { display: grid; gap: 14px; grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr)); margin-top: 10px; }++.home-rank { padding: 12px 14px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-secondary, #0f1729); }.home-rank h3 { margin: 0 0 8px; font-size: 1rem; }.home-rank ol { margin: 0; padding-left: 22px; display: grid; gap: 6px; }.home-rank li { padding-left: 2px; }@@@ -32,23 +40,17 @@.home-rank li > a, .home-rank li > span:first-child { overflow-wrap: anywhere; }.hr-val { float: right; margin-left: 10px; font-weight: 700; font-variant-numeric: tabular-nums; }--.home-chips { list-style: none; padding: 0; margin: 0 0 12px; display: flex; flex-wrap: wrap; gap: 8px; }--.home-chips a { display: inline-flex; align-items: baseline; gap: 6px; padding: 6px 12px; border-radius: 999px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-secondary, #0f1729); color: var(--text-primary, #e6edf7); text-decoration: none; font-size: .92rem; }++.home-chips { list-style: none; padding: 0; margin: 10px 0 0; display: flex; flex-wrap: wrap; gap: 8px; }++.home-chips a { display: inline-flex; align-items: baseline; gap: 6px; min-height: 36px; box-sizing: border-box; padding: 6px 12px; border-radius: 999px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-secondary, #0f1729); color: var(--text-primary, #e6edf7); text-decoration: none; font-size: .92rem; }.home-chips a:hover, .home-chips a:focus-visible { border-color: var(--accent, #3b82f6); }.home-chips span { font-size: .78rem; color: var(--text-muted, #7386a3); }--.home-sites { list-style: none; padding: 0; margin: 0 0 10px; display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(min(230px, 100%), 1fr)); }--.home-sites li { padding: 12px 14px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); }--.home-sites b { display: block; font-size: 1.02rem; }--.home-sites span { display: block; font-size: .8rem; color: var(--text-muted, #7386a3); overflow-wrap: anywhere; }--.home-sites p { margin: 6px 0 0; font-size: .88rem; color: var(--text-secondary, #96a7c2); }++.home-sites { list-style: none; padding: 0; margin: 10px 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(min(230px, 100%), 1fr)); }++.home-sites li { padding: 10px 12px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-secondary, #0f1729); }++.home-sites b { font-size: 1rem; }++.home-sites span { font-size: .8rem; color: var(--text-muted, #7386a3); overflow-wrap: anywhere; }++.home-sites p { margin: 4px 0 0; font-size: .88rem; color: var(--text-secondary, #96a7c2); }--.home-faq { display: grid; gap: 8px; max-width: 820px; }--.home-faq details { border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); }--.home-faq summary { cursor: pointer; padding: 12px 16px; font-weight: 650; list-style-position: inside; }--.home-faq summary:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -2px; border-radius: 12px; }--.home-faq details p { margin: 0; padding: 0 16px 14px; color: var(--text-secondary, #96a7c2); line-height: 1.55; }--@media (prefers-reduced-motion: reduce) { .home-kinds a, .home-cities a { transition: none; } .home-kinds a:hover, .home-cities a:hover { transform: none; } }--/* The hero's search box: what to look for on its own row (its options are long), price and bedrooms side by side. */--.sc-hero-aside .field-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }--.sc-hero-aside .field-row .field:first-child { grid-column: 1 / -1; }++/* 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; }@@@ -22,7 +22,7 @@ const showcase = require('openvibe-shared/showcase');const seo = require('openvibe-shared/seo');const HOMES_SUMMARY = 'Buying a home: one search across Zillow, Realtor.com, Redfin, Homes.com, Trulia and Craigslist, with HUD and Fannie Mae homes, foreclosure auctions and county property records.';const { send } = require('../render/layout');--const { html, raw, notice } = require('../render/html');++const { html, raw, render, notice } = require('../render/html');const sites = require('./sites');const { monthly } = require('./mortgage');const housingPlaces = require('../housing/places');@@@ -84,7 +84,9 @@ function createHomesRoutes(ctx) {const valueRow = (c) => ({ name: `${c.name}, ${c.state}`, href: c.path, value: cities.valueText(c.value) });const withValue = cities.cities.filter((c) => c.value != null).length;const stateRow = (st) => ({ name: st.place_name, href: `/in/${String(st.place_id).toLowerCase()}`, value: pctText(st.year_change_pct) });-- const kinds = sites.KINDS.map((k) => ({ k, list: sites.SITES.filter((x) => x.kinds.includes(k)) })).filter((x) => x.list.length);++ const trend = moves && moves.metros.length >= 12;++ // A taste and a door, as on OpenVibe.Rent: the search first, one strip of numbers, the largest cities as a rail,++ // the parts read once folded, the site's places as a launcher.return page(req, res, {index: true, cache: PUBLIC_CACHE,title: 'Homes for sale on every site, one search',@@@ -95,50 +97,41 @@ function createHomesRoutes(ctx) {FAQ.length ? seo.jsonLd.faq(FAQ.map((f) => ({ q: f.question, a: f.answer }))) : null,],description: 'Search Zillow, Realtor.com, Redfin, Homes.com, Trulia and Craigslist at once, with HUD and bank homes, home values for 885 cities and county records.',-- styles: [showcase.STYLESHEET], localStyles: ['css/home.css'],++ styles: [showcase.STYLESHEET, ...home.STYLES], localStyles: ['css/home.css'],body: html`${raw(showcase.hero({-- eyebrow: 'OpenVibe.Homes', title: 'Buying a home?', accent: 'Search every site at once.',-- lede: 'Type a place once and every big for-sale site searches it for you: Zillow, Realtor.com, Redfin, Homes.com, Trulia and Craigslist, next to HUD and Fannie Mae homes, foreclosure auctions and the county\'s property records. See what homes are worth in your city and what the payment would be.',-- actions: [{ label: 'Search every site', href: '/search', primary: true }, { label: 'Homes by city', href: '/in' }, { label: 'Mortgage calculator', href: '/mortgage' }],-- aside: { html: String(searchForm({ kind: 'home' })) },++ title: 'Buying a home?', accent: 'Search every site at once.',++ lede: 'Type a place once and Zillow, Realtor.com, Redfin, Homes.com, Trulia and Craigslist search it, next to HUD and Fannie Mae homes, foreclosure auctions and the county\'s records.',++ aside: { html: render(html`${home.search({ action: '/search', placeholder: 'City or ZIP', kinds: sites.KINDS, kindText: sites.KIND_TEXT, kind: 'home', go: 'Search every site' })}++${home.doors([{ href: '/in', label: 'Homes by city' }, { href: '/mortgage', label: 'Mortgage calculator' }])}++<p class="home-note">Offers, inspections and payments happen with the seller or an agent on each site, never through a stranger's link.</p>`) },}))}${home.stats([{ value: sites.SITES.length, label: 'for-sale, land and auction sites', href: '/search' },{ value: withValue, label: 'US cities with home values', href: '/in' },-- { value: moves && moves.metros.length >= 12 ? moves.metros.length : null, label: 'metro areas with a price trend', href: '#h-moves' },++ { value: trend ? moves.metros.length : null, label: 'metro areas with a price trend', href: '#moves' },{ value: Object.keys(US_STATE_NAMES).length, label: 'states with county records', href: '/records' },], { label: 'OpenVibe.Homes in numbers' })}--${home.section('kinds', 'What are you buying?', 'Every site that sells it opens at your place, with your price and bedrooms where the site takes them.',-- html`<ul class="home-kinds">${kinds.map(({ k, list }) => html`<li><a href="/search?kind=${k}"><b>${sites.KIND_TEXT[k]}</b><span>${list.map((x) => x.name).join(', ')}</span></a></li>`)}</ul>`)}++<section class="sc-sec home-sec home-cities">${home.cityRail({ id: 'cities', title: 'What homes are worth in the largest cities', more: { href: '/in', label: `All ${cities.cities.length} cities` }, list: largest, pick: (c) => ({ corner: c.value != null ? cities.valueText(c.value) : null, peek: c.rent.b2 != null ? `2-bedroom rent ${cities.rentText(c.rent.b2)}` : '' }) })}++<p class="small muted home-under">The median value of owner-occupied homes (Census Bureau, ${cities.SOURCE.years}); each city's page has the price trend, the monthly payment and every for-sale site there.</p>++${trend ? home.fold('moves', 'Where home prices moved over the past year', moves.latest, html`<p class="small muted">The FHFA House Price Index, ${moves.latest} against a year before: an index of repeat sales of the same homes, not a price. <a href="${moves.source.url}" rel="noopener">Source: ${moves.source.name}</a></p><div class="home-ranks">${home.ranked('Metro areas rising fastest', moves.metros.slice(0, 6).map(metroRow))}${home.ranked('Metro areas rising slowest or falling', moves.metros.slice(-6).reverse().map(metroRow))}${moves.states.length >= 10 ? home.ranked('States rising fastest', moves.states.slice(0, 6).map(stateRow)) : ''}</div>`) : ''}++${big.length >= 10 ? home.fold('range', 'Where homes cost least and most', '', html`<p class="small muted">Big cities (250,000 people or more) by median home value.</p><div class="home-ranks">${home.ranked('Lowest median value', byValue.slice(0, 6).map(valueRow))}${home.ranked('Highest median value', byValue.slice(-6).reverse().map(valueRow))}</div>`) : ''}++${home.fold('states', 'Homes by state', `${cities.states().length} states and territories`, html`<p class="small muted">Home values for every city of 50,000 people or more, and each state's largest, with every for-sale site and the county records for each.</p>${home.stateChips('/in')}`)}++</section>--${home.section('cities', 'What homes are worth in the largest cities', html`The median value of owner-occupied homes, with the typical 2-bedroom rent beside it (Census Bureau, ${cities.SOURCE.years}). Each city page has the price trend, the monthly payment and every for-sale site there. <a href="/in">All ${cities.cities.length} cities</a>`,-- home.cityCards(largest, (c) => ({ main: cities.valueText(c.value), mainLabel: 'median home', sub: c.rent.b2 != null ? `2-bedroom rent ${cities.rentText(c.rent.b2)}` : '' })))}----${moves && moves.metros.length >= 12 ? home.section('moves', `Where home prices moved over the past year`, html`The FHFA House Price Index, ${moves.latest} against a year before: an index of repeat sales of the same homes, not a price. <a href="${moves.source.url}" rel="noopener">Source: ${moves.source.name}</a>`,-- html`<div class="home-ranks">${home.ranked('Metro areas rising fastest', moves.metros.slice(0, 6).map(metroRow))}${home.ranked('Metro areas rising slowest or falling', moves.metros.slice(-6).reverse().map(metroRow))}${moves.states.length >= 10 ? home.ranked('States rising fastest', moves.states.slice(0, 6).map(stateRow)) : ''}</div>`) : ''}----${big.length >= 10 ? home.section('range', 'Where homes cost least and most', 'Big cities (250,000 people or more) by median home value.',-- html`<div class="home-ranks">${home.ranked('Lowest median value', byValue.slice(0, 6).map(valueRow))}${home.ranked('Highest median value', byValue.slice(-6).reverse().map(valueRow))}</div>`) : ''}----${home.section('states', 'Homes by state', 'Home values for every city of 50,000 people or more, and each state\'s largest, with every for-sale site and the county records for each.', home.stateChips('/in'))}----${home.section('sites', 'The sites it searches', 'Each one opens its own search at your place. Offers, inspections and payments happen with the seller or an agent on that site.',-- html`<ul class="home-sites">${sites.SITES.map((x) => html`<li><b>${x.name}</b><span>${x.host}</span><p>${x.best}</p></li>`)}</ul>`)}----${raw(showcase.features({-- title: 'What is here',++${raw(showcase.apps({++ id: 'places', title: 'What is here',items: [-- { icon: 'ov:search', title: 'Every for-sale site', text: 'Zillow, Realtor.com, Redfin, Homes.com, Trulia and Craigslist, each opened at your place and price.', href: '/search' },-- { icon: 'ov:shield', title: 'Government and bank homes', text: 'HUD Homestore, Fannie Mae HomePath and foreclosure auctions, often below market and first offered to people who will live there.', href: '/search?kind=foreclosure' },-- { icon: 'ov:docs', title: 'Property records', text: 'The county assessor and recorder for any US state: owners, sales, taxes and deeds, from the official sites.', href: '/records' },-- { icon: 'ov:map', title: 'Homes by city', text: `What homes are worth in ${cities.cities.length} US cities, beside what renting costs, from Census data.`, href: '/in' },-- { icon: 'ov:gauge', title: 'Mortgage calculator', text: 'The monthly cost of a loan with taxes, insurance and HOA, from the numbers you type.', href: '/mortgage' },-- { icon: 'ov:rent', title: 'Keep them on your board', text: 'Your OpenVibe.Rent board keeps homes from every site in one list, with your notes.', href: `${config.baseUrl}/board` },++ { icon: 'ov:search', name: 'Search', text: 'Every for-sale site at once', href: '/search' },++ { icon: 'ov:shield', name: 'Bank homes', text: 'HUD, HomePath and auctions', href: '/search?kind=foreclosure' },++ { icon: 'ov:docs', name: 'Records', text: 'Each county\'s assessor and recorder', href: '/records' },++ { icon: 'ov:map', name: 'Cities', text: `Home values in ${cities.cities.length} US cities`, href: '/in' },++ { icon: 'ov:gauge', name: 'Mortgage', text: 'What a loan costs a month', href: '/mortgage' },++ { icon: 'ov:check', name: 'Your board', text: 'Homes from every site, on OpenVibe.Rent', href: `${config.baseUrl}/board` },],}))}--${home.section('faq', 'Questions people ask', '', home.faq(FAQ))}++${home.section('faq', 'Questions people ask', '', html`${home.fold('sites', 'Which sites does it search?', `${sites.SITES.length} sites`, html`<p class="small muted">Each one opens its own search at your place. Offers, inspections and payments happen with the seller or an agent on that site.</p>${home.siteList(sites.SITES)}`)}${home.faq(FAQ)}`)}${raw(showcase.cta({ title: 'Renting instead?', text: 'OpenVibe.Rent opens every rental site at once, with typical rents for the same cities and affordable housing from HUD.', actions: [{ label: 'Open OpenVibe.Rent', href: `${config.baseUrl}/`, primary: true }, { label: 'Search every for-sale site', href: '/search' }] }))}`,});@@@ -15,12 +15,13 @@ const express = require('express');const ovServe = require('openvibe-shared/serve');const frame = require('openvibe-shared/frame');const showcase = require('openvibe-shared/showcase');++const kit = require('openvibe-shared/kit');const seo = require('openvibe-shared/seo');const cache = require('openvibe-shared/cache-policy');const { asyncRouter } = require('./router');const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');const { sameOrigin } = require('./principal');--const { html, raw, table, notice, time, badge } = require('../render/html');++const { html, raw, render, table, notice, time, badge } = require('../render/html');const { send } = require('../render/layout');const listings = require('../listings/listings');const store = require('../listings/store');@@@ -37,7 +38,7 @@ const { products } = require('openvibe-contracts');const SITE_NAME = 'OpenVibe.Rent';const TAGLINE = 'Every rental site, one search.';--const HOME_LATEST = 6;++const HOME_LATEST = 12;const STATE_KIND = { active: 'ok', hidden: 'warn', expired: '', removed: 'bad' };const STATE_TEXT = { active: 'active', hidden: 'hidden — waiting for staff', expired: 'expired', removed: 'removed' };@@@ -158,11 +159,26 @@ ${notice(p && p.detail ? p.detail : 'You are over the limit for this.', 'warn')}// ── Home ─────────────────────────────────────────────────// What the home shows is real and current: every rental site this search opens, the Census rents of 885 cities,// the FHFA index's metro count, listings posted here. A block with nothing to show is left out.++ // A taste and a door, not the index (the owner, 2026-10-28): the search first, the numbers as one strip, the++ // newest listings and the largest cities as rails with See all, the site's places as a launcher, and the parts read++ // once (the cheapest and dearest cities, every state, every site searched, the questions) folded.const FAQ = ((products.get('openvibe.rent') || {}).faq || []);-- const sitesFor = (kind) => everywhere.SITES.filter((x) => x.kinds.includes(kind));++ const PERIOD_SHORT = { month: '/mo', week: '/wk', day: '/day', hour: '/hr', once: '' };++ const priceShort = (row) => {++ const n = Number(row.price);++ const amount = Number.isFinite(n) ? n.toLocaleString('en-US', { maximumFractionDigits: Number.isInteger(n) ? 0 : 2 }) : String(row.price);++ return `${amount} ${row.currency}${PERIOD_SHORT[row.period] == null ? '' : PERIOD_SHORT[row.period]}`;++ };++ /** One listing in a rail: the city on the picture, the price in its corner, kind and place on hover. */++ const listingCard = (row) => kit.card({++ href: `/listings/${row.id}`, title: row.title, type: 'listing', ratio: '4/3', share: false,++ badge: row.city, corner: priceShort(row), peek: [listings.KIND_TEXT[row.kind] || row.kind, whereOf(row)].filter(Boolean).join(' · '), when: row.created_at,++ });r.get('/', async (req, res) => {-- const latest = await store.search(s, listings.normalizeFilters({}), { limit: HOME_LATEST });-- const posted = latest.rows.length ? await store.countMatching(s, listings.normalizeFilters({})) : 0;++ const all = listings.normalizeFilters({});++ const latest = await store.search(s, all, { limit: HOME_LATEST });++ const kinds = latest.rows.length ? (await store.facets(s, all)).kind : [];++ const posted = kinds.reduce((n, k) => n + k.count, 0);let moves = null;try { moves = await housingHpi.areaMoves(s); } catch { moves = null; }const homesBase = String(config.homes.url).replace(/\/+$/, '');@@@ -171,18 +187,15 @@ ${notice(p && p.detail ? p.detail : 'You are over the limit for this.', 'warn')}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({-- eyebrow: `${SITE_NAME} · ${TAGLINE}`,title: 'Every rental site.',accent: 'One search.',-- lede: `Type a place once and ${SITE_NAME} opens Craigslist, Facebook Marketplace, Zillow, Apartments.com, Zumper, Rent.com and ${everywhere.SITES.length - 6} more at it: apartments, houses, rooms, furnished stays, commercial space, parking, storage and cars. Check any rent against what the city usually pays, keep the places you like on one board, and find affordable housing from HUD.`,-- actions: [-- { label: 'Search every site', href: '/everywhere', primary: true },-- { label: 'Is this rent fair?', href: '/fair' },-- { label: 'Affordable housing', href: '/housing' },-- ],-- note: 'Open source (AGPL-3.0). Listings, messages and payments stay on each site; OpenVibe never takes a deposit.',-- aside: { html: String(everywhereForm({}, { big: true })) },++ lede: `Type a place once and ${SITE_NAME} opens Craigslist, Facebook Marketplace, Zillow, Apartments.com, Zumper and ${everywhere.SITES.length - 5} more at it.`,++ aside: { html: render(html`${home.search({ action: '/everywhere', placeholder: 'City or ZIP', kinds: everywhere.KINDS, kindText: everywhere.KIND_TEXT, kind: 'apartment', go: 'Search every site' })}++${home.doors([{ href: '/fair', label: 'Is this rent fair?' }, { href: '/housing', label: 'Affordable housing' }])}++<p class="home-note">Listings, messages and payments stay on each site. Never pay or send a deposit before you have seen the place.</p>`) },});const jsonLd = [...homeJsonLd(config),@@@ -194,55 +207,42 @@ ${notice(p && p.detail ? p.detail : 'You are over the limit for this.', 'warn')}title: 'Every rental site in one search',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], localStyles: ['css/home.css'],++ styles: [showcase.STYLESHEET, ...home.STYLES], localStyles: ['css/home.css'],body: 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' },-- { value: cityList.states().length, label: 'states and territories', href: '/in' },++ { value: states, label: 'states and territories', href: '/in' },{ value: moves ? moves.metros.length : null, label: 'metro areas with home-price trends', href: `${homesBase}/` },{ value: posted || null, label: posted === 1 ? 'listing posted here' : 'listings posted here', href: '/listings' },], { label: `${SITE_NAME} in numbers` })}--${home.section('kinds', 'What are you looking for?', 'Pick a kind and a place: every site that lists it opens at once, with your price and bedrooms where it takes them.',-- html`<ul class="home-kinds">${everywhere.KINDS.map((k) => { const list = sitesFor(k); return list.length ? html`<li><a href="/everywhere?kind=${k}"><b>${everywhere.KIND_TEXT[k]}</b><span>${list.length} ${list.length === 1 ? 'site' : 'sites'}: ${list.slice(0, 4).map((x) => x.name).join(', ')}${list.length > 4 ? ' and more' : ''}</span></a></li>` : ''; })}</ul>`)}----${home.section('cities', 'What rent costs in the largest cities', html`The middle rent for a 2-bedroom, with studios, 1- and 3-bedrooms beside it (Census Bureau, ${cityList.SOURCE.years}). Each city page opens every rental site there. <a href="/in">All ${cityList.cities.length} cities</a> · <a href="/fair">Check a rent</a>`,-- home.cityCards(largest, (c) => ({ main: cityList.rentText(c.rent.b2), mainLabel: 'for 2 bedrooms', sub: sizes(c) })))}----${big.length >= 10 ? home.section('range', 'Where renting costs least and most', '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.',-- html`<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.section('states', 'Rent by state', 'Typical rents for every city of 50,000 people or more, and each state\'s largest, with every rental site\'s search for each.', home.stateChips('/in'))}++${latest.rows.length ? html`<section class="sc-sec home-sec home-latest">${raw(kit.rail({ id: 'latest', 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>` : ''}--${latest.rows.length ? home.section('latest', `Posted on ${SITE_NAME}`, html`Listings people post here themselves: apartments, rooms, houses, commercial space, parking, storage and equipment. <a href="/listings">Browse them all</a>.`,-- html`${listingList(latest.rows, { empty: '' })}<p><a class="sc-btn" href="/listings">Every listing posted here</a> <a class="sc-btn" href="/post">Post a listing</a></p>`) : ''}----${home.section('sites', 'The sites it searches', 'Each one opens its own search at your place, so you see its newest listings without typing the search again.',-- html`<ul class="home-sites">${everywhere.SITES.map((x) => html`<li><b>${x.name}</b><span>${x.host}${x.us ? '' : ' · worldwide'}</span><p>${x.best}</p></li>`)}</ul>--<p class="small muted">The United States first; more countries next. Listings, the landlord and the payment stay on each site.</p>`)}----${home.section('housing', 'Affordable housing near you', 'Public housing, tax-credit (LIHTC) and HUD-assisted properties, and the housing authorities and counselling agencies to apply through, from U.S. HUD open data.',-- html`<ul class="home-chips">${largest.slice(0, 8).map((c) => html`<li><a href="/housing?${new URLSearchParams({ q: `${c.name}, ${c.state}` })}">${c.name}</a></li>`)}</ul>--<p><a class="sc-btn sc-primary" href="/housing">Find housing near you</a></p>`)}----${home.section('buy', 'Buying instead?', html`OpenVibe.Homes is the owning side: every for-sale site in one search, home values for the same cities, county records and a mortgage calculator. <a href="${homesBase}/">Open OpenVibe.Homes</a>`,-- home.cityCards(largest.slice(0, 4).filter((c) => c.value != null), (c) => ({ main: cityList.valueText(c.value), mainLabel: 'median home', sub: c.rent.b2 != null ? `2-bedroom rent ${cityList.rentText(c.rent.b2)}` : '' }), { href: (c) => `${homesBase}${c.path}` }))}++<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>++${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>--${raw(showcase.features({-- title: 'How it works',-- lede: 'One account for every rental search you make, wherever the listing lives.',++${raw(showcase.apps({++ id: 'places', title: `Everything on ${SITE_NAME}`,items: [-- { icon: 'ov:search', title: 'One search, every site', text: 'Type a place once and every site above opens its own search there, with your price and bedrooms.', href: '/everywhere' },-- { icon: 'ov:check', title: 'Your board', text: 'Keep the listings you like from any site in one list, with your notes and how far you got.', href: '/board' },-- { icon: 'ov:map', title: 'Is the rent fair?', text: `What rent usually costs in ${cityList.cities.length} US cities by size, and a check for any price you are quoted, from Census data.`, href: '/fair' },-- { icon: 'ov:bell', title: 'Alerts that keep looking', text: 'Save a search and get alerts through OpenVibe.Watch when something new matches.', href: '/listings' },-- { icon: 'ov:gauge', title: 'Safety first', text: 'Never pay or send a deposit before you have seen the place and signed an agreement. Reported listings are hidden until staff look.', href: '/safety' },-- { icon: 'ov:rent', title: 'Post your own', text: 'Rooms, apartments, parking or gear: a description, a price and where it is. Edit, renew or hide it any time.', href: '/post' },++ { icon: 'ov:search', name: 'Search', text: 'Every rental site at once', href: '/everywhere' },++ { icon: 'ov:gauge', name: 'Fair rent', text: 'Is the rent you were quoted fair?', href: '/fair' },++ { icon: 'ov:map', name: 'Cities', text: `Typical rents in ${cityList.cities.length} US cities`, href: '/in' },++ { icon: 'ov:homes', name: 'Affordable', text: 'Public and HUD-assisted housing', href: '/housing' },++ { icon: 'ov:check', name: 'Your board', text: 'The places you like, from any site', href: '/board' },++ { icon: 'ov:bell', name: 'Alerts', text: 'Save a search, hear what is new', href: '/listings' },++ { icon: 'ov:docs', name: 'Listings', text: 'Posted here by people', href: '/listings' },++ { icon: 'ov:rent', name: 'Post', text: 'Rent yours out', href: '/post' },++ { icon: 'ov:shield', name: 'Safety', text: 'No deposit before you see it', href: '/safety' },++ { icon: 'ov:homes', name: 'Buying', text: 'Homes for sale on OpenVibe.Homes', href: `${homesBase}/` },],}))}--${home.section('faq', 'Questions people ask', '', home.faq(FAQ))}++${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' }] }))}`,@@@ -254,7 +254,7 @@ ${raw(showcase.cta({ title: 'Have something to rent out?', text: 'Post it in a mconst str = (v) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, 200) : null);const OWN_KIND = { apartment: 'apartment', house: 'house', room: 'room', stay: null, commercial: 'commercial', parking: 'parking', storage: 'parking', vehicle: 'equipment' };const HUD_KINDS = new Set(['apartment', 'house', 'room']);-- const everywhereForm = (v = {}, { big = false } = {}) => html`<form class="search-form everywhere-form${big ? ' everywhere-big' : ''}" method="get" action="/everywhere" role="search">++ const everywhereForm = (v = {}) => html`<form class="search-form everywhere-form" method="get" action="/everywhere" role="search"><div class="field"><label for="ew-q">Where</label><input id="ew-q" name="q" type="search" required maxlength="200" placeholder="A city and state, or a ZIP code" value="${v.q || ''}" autocomplete="address-level2"></div><div class="field-row"><div class="field"><label for="ew-kind">Looking for</label><select id="ew-kind" name="kind">${everywhere.KINDS.map((k) => html`<option value="${k}"${k === (v.kind || 'apartment') ? raw(' selected') : ''}>${everywhere.KIND_TEXT[k]}</option>`)}</select></div>@@@ -1,38 +1,46 @@'use strict';/**-- * The pieces OpenVibe.Rent's and OpenVibe.Homes' home pages share (public/css/home.css): a row of counts, city cards,-- * a ranked list, state chips and the questions people ask. Each takes real rows only and renders nothing when it has-- * none, so a section never stands empty.++ * The pieces OpenVibe.Rent's and OpenVibe.Homes' front pages share. A front page is a taste and a door, not the index++ * (the owner, 2026-10-28: "act more like an app, not a huge long web page"), so these are the shared kit's pieces++ * (openvibe-shared/kit: kit.css for the numbers and folds, room.css for the cards and rails) around the few things++ * each home draws itself (a ranked list, state chips; public/css/home.css). Each takes real rows only and renders++ * nothing when it has none, so a block never stands empty.*/--const { html, render } = require('./html');++const kit = require('openvibe-shared/kit');++const { html, raw, render } = require('./html');const cities = require('../aggregate/cities');--/** Counts that are true right now: [{ value, label, href? }]; an item without a value is left out. */--function stats(items, { label = 'In numbers' } = {}) {-- const real = (items || []).filter((i) => i && i.value != null && i.value !== '' && i.value !== 0);-- if (!real.length) return '';-- return html`<ul class="home-stats" aria-label="${label}">${real.map((i) => html`<li>${i.href ? html`<a href="${i.href}"><b>${i.value}</b><span>${i.label}</span></a>` : html`<b>${i.value}</b><span>${i.label}</span>`}</li>`)}</ul>`;--}++/** The stylesheets a front page loads after app.css: the kit's controls and folds, then its cards and rails. */++const STYLES = ['kit.css', 'room.css'];++++/** Counts that are true right now, as one strip (it scrolls sideways on a phone): [{ value, label, href? }]. */++const stats = (items, { label = 'In numbers' } = {}) => raw(kit.stats((items || []).filter(Boolean).map((i) => [i.value, i.label, i.href || null]), { label }));--/** The section wrapper every home block uses: an h2 with an id, a lede, the body. */++/** The section wrapper a home block uses: an h2 with an id, a lede, the body. */const section = (id, title, lede, body) => (render(body).trim()? html`<section class="sc-sec" aria-labelledby="h-${id}"><h2 id="h-${id}">${title}</h2>${lede ? html`<p class="sc-lede">${lede}</p>` : ''}${body}</section>`: '');/**-- * City cards: the name, the headline number and a smaller line under it. `pick(c)` → { main, mainLabel, sub }.++ * The largest cities as a rail of cards (it scrolls sideways and snaps; wide screens wrap it, home.css): the name,++ * its state, the headline number in the card's corner and the rest on hover. `pick(c)` → { corner, peek };* `href(c)` is where the card goes (the city page by default).*/--function cityCards(list, pick, { href = (c) => c.path } = {}) {-- if (!list.length) return '';-- return html`<ul class="home-cities">${list.map((c) => {-- const p = pick(c);-- return html`<li><a href="${href(c)}"><span class="hc-name">${c.name}<span class="hc-state">${c.state}</span></span><span class="hc-main"><b>${p.main || 'not published'}</b> <span>${p.mainLabel}</span></span>${p.sub ? html`<span class="hc-sub">${p.sub}</span>` : ''}</a></li>`;-- })}</ul>`;++function cityRail({ id, title, more, list, pick, href = (c) => c.path }) {++ return raw(kit.rail({++ id, title, more, size: 's',++ items: (list || []).map((c) => {++ const p = pick(c);++ return kit.card({ href: href(c), title: c.name, type: 'map', ratio: '4/3', badge: c.state, corner: p.corner || 'not published', peek: p.peek || '', share: false });++ }),++ }));}--/** A numbered list of [{ name, href?, value, note? }], for "lowest" and "highest" pairs side by side. */++/** 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) })) : '');++++/** A numbered list of [{ name, href?, value }], for "lowest" and "highest" pairs side by side. */function ranked(title, rows) {if (!rows.length) return '';return html`<div class="home-rank"><h3>${title}</h3><ol>${rows.map((r) => html`<li>${r.href ? html`<a href="${r.href}">${r.name}</a>` : r.name}<span class="hr-val">${r.value}</span></li>`)}</ol></div>`;@@@ -41,13 +49,25 @@ function ranked(title, rows) {/** Every state with cities, as chips to its page. */const stateChips = (prefix = '/in') => html`<ul class="home-chips">${cities.states().map((st) => html`<li><a href="${prefix}/${st.slug}">${st.name} <span>${st.count}</span></a></li>`)}</ul>`;--/** The questions people ask (the product's own FAQ from openvibe-contracts), open on click, readable without JS. */--function faq(items) {-- if (!items || !items.length) return '';-- return html`<div class="home-faq">${items.map((f) => html`<details><summary>${f.question}</summary><p>${f.answer}</p></details>`)}</div>`;++/** The sites a search opens, for the fold that names them: [{ name, host, best }]. */++const siteList = (list, note = '') => html`<ul class="home-sites">${list.map((x) => html`<li><b>${x.name}</b> <span>${x.host}</span><p>${x.best}</p></li>`)}</ul>${note ? html`<p class="small muted">${note}</p>` : ''}`;++++/** The questions people ask (the product's own FAQ from openvibe-contracts), each a fold, readable without JS. */++const faq = (items) => (items || []).map((f) => raw(kit.fold({ title: f.question, body: render(html`<p>${f.answer}</p>`) })));++++/**++ * The search a home opens with (kit.find): the place, what kind (a select inside the pill) and a round go button.++ * Price and bedrooms are on the results page, which keeps the place typed here.++ */++function search({ action, placeholder, label = 'Where', kinds, kindText, kind, kindLabel = 'Looking for', go }) {++ const select = html`<select name="kind" aria-label="${kindLabel}">${kinds.map((k) => html`<option value="${k}"${k === kind ? raw(' selected') : ''}>${kindText[k]}</option>`)}</select>`;++ return raw(kit.find({ action, name: 'q', placeholder, label, required: true, maxlength: 200, extra: render(select), button: { icon: 'forward', label: go }, id: 'home-q' }));}++/** Up to two small doors under the search: [{ href, label }]. */++const doors = (list) => html`<p class="home-doors">${raw(list.slice(0, 2).map((d) => kit.button({ href: d.href, label: d.label, kind: 'quiet', small: true })).join(' '))}</p>`;++/** 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 = { stats, section, cityCards, ranked, stateChips, faq, bigCities };++module.exports = { STYLES, stats, section, cityRail, fold, ranked, stateChips, siteList, faq, search, doors, bigCities };@@@ -127,6 +127,9 @@ const housingFetchImpl = async (url) => {assert.ok(r.text.includes('Every rental site.') && r.text.includes('One search.'));assert.ok(r.text.includes('action="/everywhere"'), 'the hero search goes everywhere');for (const name of ['Craigslist', 'Facebook Marketplace', 'Zillow', 'Apartments.com', 'Zumper', 'Turo']) assert.ok(r.text.includes(name), name);++ // Every site it searches is named on the home, in a fold (read once), with the real count.++ const sites = (/<details class="ovk-fold" id="sites">[\s\S]*?<\/details>/.exec(r.text) || [''])[0];++ assert.ok(sites.includes(`· ${everywhere.SITES.length} sites`) && everywhere.SITES.every((x) => sites.includes(`<b>${x.name.replace(/&/g, '&')}</b>`)), 'every site searched, in its fold');assert.ok(!/nothing (here )?is scraped|no scraped listings/i.test(r.text), 'the old "nothing is scraped" framing is gone');});} finally { await t.close(); }@@@ -81,6 +81,12 @@ const housingFetchImpl = async (url) => (new URL(String(url)).pathname.endsWith(assert.ok(r.text.includes('<link rel="canonical" href="https://openvibe.homes/">'), 'its own canonical');assert.ok(r.text.includes('What homes are worth in the largest cities') && r.text.includes('href="/in/ny/new-york"'), 'the largest cities\' home values, each opening its city page');assert.ok(r.text.includes('Where homes cost least and most') && r.text.includes('Homes by state'), 'the cheapest and dearest big cities, and every state');++ // The same front-page shape as Rent's: the search first, the cities as a rail of cards with All N, the parts++ // read once folded, the site's places as a launcher.++ assert.ok(r.text.includes('<form class="ovk-find" method="get" action="/search" role="search">'), 'the search is the kit\'s pill');++ assert.ok(/<section class="ovk-rail[^"]*" id="cities"/.test(r.text) && /<a class="ovk-rail-more" href="\/in">All \d+ cities/.test(r.text), 'the cities are a rail with All N');++ assert.ok(/<details class="ovk-fold" id="range">/.test(r.text) && /<details class="ovk-fold" id="states">/.test(r.text) && /<details class="ovk-fold" id="sites">/.test(r.text), 'the ranks, the states and the sites are folds');++ assert.ok(/<section class="sc-sec sc-apps-sec" id="places"/.test(r.text) && r.text.includes('href="/records"') && r.text.includes('href="/mortgage"'), 'the places as a launcher');assert.ok(r.text.includes('Questions people ask') && /"@type":"FAQPage"/.test(r.text) && /"@type":"ItemList"/.test(r.text), 'the questions shown are the FAQPage; the cities are an ItemList');assert.ok(!r.text.includes('Where home prices moved'), 'no price-trend block without FHFA rows');const rent = await t.get('/');@@@ -61,8 +61,14 @@ const BOOST = /<script src="\/shared\/boost\.js\?v=[0-9a-f]{12}" data-main="#maiassert.strictEqual(r.status, 200, r.text.slice(0, 300));assert.match(r.headers.get('content-type'), /^text\/html/);assert.ok(r.text.includes('OpenVibe.Rent'), 'the service name is on the page');-- assert.ok(r.text.includes('Every rental site, one search.'), 'the tagline is on the page');++ // The tagline is the hero's one heading (an eyebrow saying it again above it was left out).++ assert.ok(r.text.includes('<h1>Every rental site.<span class="sc-accent"> One search.</span></h1>'), 'the tagline is on the page');assert.ok(r.text.includes('/shared/showcase.css'), 'the showcase stylesheet');++ // Only the shared pieces the home draws: the kit's numbers, search and folds (kit.css), its cards and rails++ // (room.css); no Kit 2 file and no kit script (no menus on these cards; wide screens wrap the rails).++ assert.ok(/\/shared\/kit\.css\?v=/.test(r.text) && /\/shared\/room\.css\?v=/.test(r.text), 'kit.css and room.css');++ assert.ok(!/\/shared\/kit-data\.css|\/shared\/kit-web\.js|\/shared\/kit-data-web\.js/.test(r.text), 'nothing the home does not draw');++ assert.ok(r.text.includes('<dl class="ovk-stats"') && r.text.includes('<form class="ovk-find"'), 'the numbers as one strip; the search as the kit\'s pill');assert.ok(!r.text.includes('/home/') && !r.text.includes('/mnt/'), 'no local filesystem path on the page');});@@@ -16,15 +16,23 @@ const listings = require('../server/listings/listings');const lee = t.network.addUser('lee');const nia = t.network.addUser('nia');-- await check('the home page: a search over every rental site, the latest listings posted here, how it works, and no deposits said plainly', async () => {++ await check('the home page: a search over every rental site, the latest listings posted here, the site\'s places, and no deposits said plainly', async () => {const made = await post(t, kim, { title: 'A flat on the home page' });assert.strictEqual(made.status, 201, made.text);const r = await t.get('/');assert.strictEqual(r.status, 200);assert.match(r.text, /action="\/everywhere"/, 'the search box searches every rental site');-- assert.match(r.text, /href="\/listings"/, 'the listings posted here are one click away');-- assert.ok(r.text.includes('A flat on the home page'), 'the latest listings are on the home page');-- assert.match(r.text, /How it works/);++ // A taste and a door, not the index: the newest listings as a rail of cards, See all with the real count, and++ // the kinds that have listings as quick filters into /listings.++ const latest = (/<section class="ovk-rail[^"]*" id="latest"[\s\S]*?<\/section>/.exec(r.text) || [''])[0];++ assert.ok(latest.includes('class="ovk-card') && latest.includes('A flat on the home page'), 'the latest listings are a rail of cards on the home page');++ assert.ok(latest.includes(`href="/listings/${made.json().id}"`), 'a card opens its listing');++ assert.match(latest, /<a class="ovk-rail-more" href="\/listings">See all \d+/, 'See all opens every listing posted here');++ assert.match(r.text, /<a class="ovk-btn[^"]*" href="\/listings\?kind=apartment"><span>Apartment \d+<\/span><\/a>/, 'the kinds are quick filters');++ assert.ok(!r.text.includes('class="listing-list"'), 'the home is not the index: no stacked list of listings');++ // The site's places as a launcher (showcase.apps), each a link.++ const places = (/<section class="sc-sec sc-apps-sec" id="places"[\s\S]*?<\/section>/.exec(r.text) || [''])[0];++ for (const href of ['/everywhere', '/fair', '/in', '/housing', '/board', '/listings', '/post', '/safety']) assert.ok(places.includes(`href="${href}"`), `the launcher opens ${href}`);assert.ok(r.text.includes('Never pay or send a deposit before you have seen the place'), 'the no-deposit rule');assert.match(r.text, /Where do the listings come from\?/, 'where the listings come from (the product\'s FAQ)');assert.match(r.text, /What rent costs in the largest cities/, 'the largest cities\' rents');@@@ -21,15 +21,18 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');// largest cities' rents, the cheapest and dearest big cities, every state, every site searched, HUD links, Homes, the// product's questions, and ItemList + FAQPage JSON-LD. Measured 49.3/10.8 KB HTML and 25.5/5.9 KB CSS with one more// file (css/home.css); the budgets keep about 10% headroom over that.++// Raised on 2026-10-29 for the short home (a taste and a door: the search first, rails, folds, a launcher; 14.6 -> 4.2++// screens at 390 px): the shared kit's stylesheets (kit.css, room.css) and their markup, measured 62.2 KB HTML and++// 78.3/17.4 KB CSS in 5 files; the budgets keep about 10% headroom over that.const BUDGETS = {-- htmlRawKB: 60, // 55.4 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 49.3 on 2026-10-22 (the curated home); 31.0 before++ htmlRawKB: 68, // 62.2 the short home (2026-10-29): the kit's cards (an icon each), folds and search pill around the same cities, states and sites, now folded; 55.4 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 49.3 on 2026-10-22 (the curated home); 31.0 beforehtmlBrotliKB: 14, // 12.1 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 10.8 on 2026-10-22; 8.1 on 2026-10-16 (Rent by city: the largest cities' rents where an empty "posted here" was); 7.4 beforejsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)jsRawKB: 305, // 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: 76, // 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, css/home.css)-- cssRawKB: 41, // 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: 10, // 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++ 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 beforeexternalFiles: 0, // 0};
@@ -396,6 +396,6 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
@@ -396,6 +396,6 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
<!-- versions:start -->
<!-- versions:start -->
- openvibe-contracts: v0.222.0
- openvibe-contracts: v0.222.0
- openvibe-sdk: v0.67.0
- openvibe-sdk: v0.67.0
-- openvibe-shared: v4.37.0
+- openvibe-shared: v4.38.0
- openvibe-publishing: v1.4.0
- openvibe-publishing: v1.4.0
<!-- versions:end -->
<!-- versions:end -->
@@ -9,11 +9,11 @@
@@ -9,11 +9,11 @@
"domain": "openvibe.rent",
"domain": "openvibe.rent",
"contracts": "openvibe-contracts v0.222.0",
"contracts": "openvibe-contracts v0.222.0",
"sdk": "openvibe-sdk v0.67.0",
"sdk": "openvibe-sdk v0.67.0",
- "shared": "openvibe-shared v4.37.0",
+ "shared": "openvibe-shared v4.38.0",
"packages": [
"packages": [
"openvibe-contracts v0.222.0",
"openvibe-contracts v0.222.0",
"openvibe-sdk v0.67.0",
"openvibe-sdk v0.67.0",
- "openvibe-shared v4.37.0",
+ "openvibe-shared v4.38.0",
"openvibe-publishing v1.4.0"
"openvibe-publishing v1.4.0"
],
],
"summary": "OpenVibe.Rent — Find a place, or rent yours out. One place to find and offer things to rent: apartments, rooms, houses, commercial space, parking and storage, and equipment. In this first version every listing is posted by a person on this site: no scraped listings and no partner feeds, no payments, no deposits and no messaging. Search with filters and facets, a listing page with a plain safety note, reports that hide a listing at 3 until staff look, saved searches that count what is new, a 30-day expiry an owner can renew, and a FHFA House Price Index card on the affordable-housing page.",
"summary": "OpenVibe.Rent — Find a place, or rent yours out. One place to find and offer things to rent: apartments, rooms, houses, commercial space, parking and storage, and equipment. In this first version every listing is posted by a person on this site: no scraped listings and no partner feeds, no payments, no deposits and no messaging. Search with filters and facets, a listing page with a plain safety note, reports that hide a listing at 3 until staff look, saved searches that count what is new, a 30-day expiry an owner can renew, and a FHFA House Price Index card on the affordable-housing page.",
@@ -18,7 +18,7 @@
@@ -18,7 +18,7 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.37.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.38.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"devDependencies": {
"devDependencies": {
@@ -1209,9 +1209,9 @@
@@ -1209,9 +1209,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "4.37.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.37.0",
- "integrity": "sha512-zT73fINooICUxuoxT8bwS1C5bh6ieNaDAX6aUaMdzMth1nTXbkwh54bgiPMytAJ2D0a8b3pqxhLYCvxFVzCVqA==",
+ "version": "4.38.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.38.0",
+ "integrity": "sha512-4sEQ78EQaw+cWWJs+AlPRhxObzHT1nwTMz6yHVBL20Mr+raAhhP10DtL1OTnbuMCDVsYhVwpd3QGgmyC6391EA==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -25,7 +25,7 @@
@@ -25,7 +25,7 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.37.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.38.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"engines": {
"engines": {
@@ -87,7 +87,6 @@ form.inline { display: inline; }
@@ -87,7 +87,6 @@ form.inline { display: inline; }
/* The home page's showcase sections (openvibe-shared/showcase.css) sit inside .page, which has its own gutter. */
/* The home page's showcase sections (openvibe-shared/showcase.css) sit inside .page, which has its own gutter. */
.page .sc-hero, .page .sc-sec, .page .sc-cta { padding-left: 0; padding-right: 0; }
.page .sc-hero, .page .sc-sec, .page .sc-cta { padding-left: 0; padding-right: 0; }
-.page .sc-ic .ov-icon { --ovi-size: 28px !important; }
button.sc-btn { background: transparent; }
button.sc-btn { background: transparent; }
button.sc-btn.sc-primary { border: 0; }
button.sc-btn.sc-primary { border: 0; }
/* The sites the search-everywhere form opens (home); the results page has css/everywhere.css. */
/* The sites the search-everywhere form opens (home); the results page has css/everywhere.css. */
@@ -1,30 +1,38 @@
@@ -1,30 +1,38 @@
-/* OpenVibe.Rent's and OpenVibe.Homes' home pages (server/render/home-blocks.js): counts, kinds, city cards, ranked lists,
- state chips and questions. Theme tokens only, so light and dark both read; every grid fits a 320px phone. */
-.home-stats { list-style: none; padding: 0; margin: 32px 0 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fit, minmax(min(130px, 100%), 1fr)); }
-.home-stats li > a, .home-stats li > b { display: block; }
-.home-stats li { border-radius: 14px; border: 1px solid var(--border, #1f2d47); border-top: 3px solid var(--accent, #3b82f6); background: var(--bg-secondary, #0f1729); }
-.home-stats a { padding: 14px 16px; color: inherit; text-decoration: none; border-radius: 14px; height: 100%; box-sizing: border-box; }
-.home-stats li:not(:has(a)) { padding: 14px 16px; }
-.home-stats a:hover, .home-stats a:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -2px; }
-.home-stats b { display: block; font-size: 1.75rem; line-height: 1.1; font-weight: 800; font-variant-numeric: tabular-nums; color: var(--text-primary, #e6edf7); }
-.home-stats span { display: block; margin-top: 4px; font-size: .88rem; color: var(--text-secondary, #96a7c2); }
+/* OpenVibe.Rent's and OpenVibe.Homes' front pages (server/render/home-blocks.js). The numbers, rails, cards, folds and
+ the search pill are the shared kit's (kit.css, room.css); this is only what the homes draw themselves and how the
+ kit's pieces sit in the hero. Theme tokens only, so light and dark both read; everything fits a 320px phone. */
-.home-kinds, .home-cities { list-style: none; padding: 0; margin: 0 0 8px; display: grid; grid-template-columns: minmax(0, 1fr); gap: 10px; }
-.home-kinds { grid-template-columns: repeat(auto-fill, minmax(min(230px, 100%), 1fr)); }
-.home-cities { grid-template-columns: repeat(auto-fill, minmax(min(220px, 100%), 1fr)); }
-.home-kinds a, .home-cities a { display: flex; flex-direction: column; gap: 4px; height: 100%; box-sizing: border-box; padding: 12px 14px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); text-decoration: none; transition: border-color .15s ease, transform .15s ease; }
-.home-kinds a:hover, .home-cities a:hover, .home-kinds a:focus-visible, .home-cities a:focus-visible { border-color: var(--accent, #3b82f6); transform: translateY(-1px); outline: 0; }
-.home-kinds b { font-size: 1rem; }
-.home-kinds span { font-size: .86rem; color: var(--text-secondary, #96a7c2); overflow-wrap: anywhere; }
-.hc-name { font-weight: 700; font-size: 1.02rem; overflow-wrap: anywhere; }
-.hc-state { margin-left: 6px; font-size: .78rem; font-weight: 600; color: var(--text-muted, #7386a3); }
-.hc-main { font-size: .88rem; color: var(--text-secondary, #96a7c2); }
-.hc-main span { white-space: nowrap; }
-.hc-main b { font-size: 1.3rem; color: var(--text-primary, #e6edf7); font-variant-numeric: tabular-nums; }
-.hc-sub { font-size: .8rem; color: var(--text-muted, #7386a3); font-variant-numeric: tabular-nums; }
+/* The hero: a line of what it is, the search, two small doors and the one rule that matters. */
+.sc-hero-aside .ovk-find { margin: 0; min-height: 52px; }
+.sc-hero-aside .ovk-find input { font-size: 1.02rem; }
+.home-doors { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0 0; }
+.home-note { margin: 10px 0 0; font-size: .84rem; line-height: 1.45; color: var(--text-muted, #7386a3); }
-.home-ranks { display: grid; gap: 14px; grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr)); }
-.home-rank { padding: 14px 16px; border-radius: 14px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); }
+/* Sections of rails and folds sit at the kit's rail rhythm, closer than the showcase's sections; the rail brings its heading. */
+.sc-sec.home-sec { margin-top: clamp(30px, 4vw, 44px); }
+.home-sec > .ovk-rail:first-child { margin-top: 0; }
+.home-sec .ovk-fold { margin: 10px 0 0; }
+.home-sec .ovk-fold + .ovk-fold { margin-top: 8px; }
+.home-under { margin: 2px 0 12px; }
+/* A computer sees every card at once: the rail wraps into a grid (a mouse has no ‹ › buttons without kit-web.js).
+ Phones and touch tablets keep the rail they swipe. */
+@media (min-width: 760px) and (hover: hover), (min-width: 1024px) {
+ .home-sec .ovk-rail-track { grid-auto-flow: row; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); grid-auto-columns: auto; overflow: visible; margin: 0; padding: 2px 0 10px; }
+}
+
+/* The listings' kinds, as quick filters under their rail: one strip that scrolls sideways on a phone. */
+.home-kinds { display: flex; flex-wrap: wrap; gap: 8px; margin: 4px 0 0; }
+@media (max-width: 599px) {
+ .home-kinds { flex-wrap: nowrap; overflow-x: auto; margin-inline: -16px; padding: 2px 16px; scrollbar-width: none; }
+ .home-kinds::-webkit-scrollbar { display: none; }
+ .home-kinds > * { flex: none; }
+}
+/* The narrowest phones give the place's box the room the pill's magnifier took (the go button still says Search). */
+@media (max-width: 359px) { .sc-hero-aside .ovk-find > .ovk-i:first-child { display: none; } }
+
+/* Inside the folds: the cheapest and dearest cities side by side, every state as a chip, every site searched. */
+.home-ranks { display: grid; gap: 14px; grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr)); margin-top: 10px; }
+.home-rank { padding: 12px 14px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-secondary, #0f1729); }
.home-rank h3 { margin: 0 0 8px; font-size: 1rem; }
.home-rank h3 { margin: 0 0 8px; font-size: 1rem; }
.home-rank ol { margin: 0; padding-left: 22px; display: grid; gap: 6px; }
.home-rank ol { margin: 0; padding-left: 22px; display: grid; gap: 6px; }
.home-rank li { padding-left: 2px; }
.home-rank li { padding-left: 2px; }
@@ -32,23 +40,17 @@
@@ -32,23 +40,17 @@
.home-rank li > a, .home-rank li > span:first-child { overflow-wrap: anywhere; }
.home-rank li > a, .home-rank li > span:first-child { overflow-wrap: anywhere; }
.hr-val { float: right; margin-left: 10px; font-weight: 700; font-variant-numeric: tabular-nums; }
.hr-val { float: right; margin-left: 10px; font-weight: 700; font-variant-numeric: tabular-nums; }
-.home-chips { list-style: none; padding: 0; margin: 0 0 12px; display: flex; flex-wrap: wrap; gap: 8px; }
-.home-chips a { display: inline-flex; align-items: baseline; gap: 6px; padding: 6px 12px; border-radius: 999px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-secondary, #0f1729); color: var(--text-primary, #e6edf7); text-decoration: none; font-size: .92rem; }
+.home-chips { list-style: none; padding: 0; margin: 10px 0 0; display: flex; flex-wrap: wrap; gap: 8px; }
+.home-chips a { display: inline-flex; align-items: baseline; gap: 6px; min-height: 36px; box-sizing: border-box; padding: 6px 12px; border-radius: 999px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-secondary, #0f1729); color: var(--text-primary, #e6edf7); text-decoration: none; font-size: .92rem; }
.home-chips a:hover, .home-chips a:focus-visible { border-color: var(--accent, #3b82f6); }
.home-chips a:hover, .home-chips a:focus-visible { border-color: var(--accent, #3b82f6); }
.home-chips span { font-size: .78rem; color: var(--text-muted, #7386a3); }
.home-chips span { font-size: .78rem; color: var(--text-muted, #7386a3); }
-.home-sites { list-style: none; padding: 0; margin: 0 0 10px; display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(min(230px, 100%), 1fr)); }
-.home-sites li { padding: 12px 14px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); }
-.home-sites b { display: block; font-size: 1.02rem; }
-.home-sites span { display: block; font-size: .8rem; color: var(--text-muted, #7386a3); overflow-wrap: anywhere; }
-.home-sites p { margin: 6px 0 0; font-size: .88rem; color: var(--text-secondary, #96a7c2); }
+.home-sites { list-style: none; padding: 0; margin: 10px 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(min(230px, 100%), 1fr)); }
+.home-sites li { padding: 10px 12px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-secondary, #0f1729); }
+.home-sites b { font-size: 1rem; }
+.home-sites span { font-size: .8rem; color: var(--text-muted, #7386a3); overflow-wrap: anywhere; }
+.home-sites p { margin: 4px 0 0; font-size: .88rem; color: var(--text-secondary, #96a7c2); }
-.home-faq { display: grid; gap: 8px; max-width: 820px; }
-.home-faq details { border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); }
-.home-faq summary { cursor: pointer; padding: 12px 16px; font-weight: 650; list-style-position: inside; }
-.home-faq summary:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -2px; border-radius: 12px; }
-.home-faq details p { margin: 0; padding: 0 16px 14px; color: var(--text-secondary, #96a7c2); line-height: 1.55; }
-@media (prefers-reduced-motion: reduce) { .home-kinds a, .home-cities a { transition: none; } .home-kinds a:hover, .home-cities a:hover { transform: none; } }
-/* The hero's search box: what to look for on its own row (its options are long), price and bedrooms side by side. */
-.sc-hero-aside .field-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
-.sc-hero-aside .field-row .field:first-child { grid-column: 1 / -1; }
+/* 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; }
@@ -22,7 +22,7 @@ const showcase = require('openvibe-shared/showcase');
@@ -22,7 +22,7 @@ const showcase = require('openvibe-shared/showcase');
const seo = require('openvibe-shared/seo');
const seo = require('openvibe-shared/seo');
const HOMES_SUMMARY = 'Buying a home: one search across Zillow, Realtor.com, Redfin, Homes.com, Trulia and Craigslist, with HUD and Fannie Mae homes, foreclosure auctions and county property records.';
const HOMES_SUMMARY = 'Buying a home: one search across Zillow, Realtor.com, Redfin, Homes.com, Trulia and Craigslist, with HUD and Fannie Mae homes, foreclosure auctions and county property records.';
const { send } = require('../render/layout');
const { send } = require('../render/layout');
-const { html, raw, notice } = require('../render/html');
+const { html, raw, render, notice } = require('../render/html');
const sites = require('./sites');
const sites = require('./sites');
const { monthly } = require('./mortgage');
const { monthly } = require('./mortgage');
const housingPlaces = require('../housing/places');
const housingPlaces = require('../housing/places');
@@ -84,7 +84,9 @@ function createHomesRoutes(ctx) {
@@ -84,7 +84,9 @@ function createHomesRoutes(ctx) {
const valueRow = (c) => ({ name: `${c.name}, ${c.state}`, href: c.path, value: cities.valueText(c.value) });
const valueRow = (c) => ({ name: `${c.name}, ${c.state}`, href: c.path, value: cities.valueText(c.value) });
const withValue = cities.cities.filter((c) => c.value != null).length;
const withValue = cities.cities.filter((c) => c.value != null).length;
const stateRow = (st) => ({ name: st.place_name, href: `/in/${String(st.place_id).toLowerCase()}`, value: pctText(st.year_change_pct) });
const stateRow = (st) => ({ name: st.place_name, href: `/in/${String(st.place_id).toLowerCase()}`, value: pctText(st.year_change_pct) });
- const kinds = sites.KINDS.map((k) => ({ k, list: sites.SITES.filter((x) => x.kinds.includes(k)) })).filter((x) => x.list.length);
+ const trend = moves && moves.metros.length >= 12;
+ // A taste and a door, as on OpenVibe.Rent: the search first, one strip of numbers, the largest cities as a rail,
+ // the parts read once folded, the site's places as a launcher.
return page(req, res, {
return page(req, res, {
index: true, cache: PUBLIC_CACHE,
index: true, cache: PUBLIC_CACHE,
title: 'Homes for sale on every site, one search',
title: 'Homes for sale on every site, one search',
@@ -95,50 +97,41 @@ function createHomesRoutes(ctx) {
@@ -95,50 +97,41 @@ function createHomesRoutes(ctx) {
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,
],
],
description: 'Search Zillow, Realtor.com, Redfin, Homes.com, Trulia and Craigslist at once, with HUD and bank homes, home values for 885 cities and county records.',
description: 'Search Zillow, Realtor.com, Redfin, Homes.com, Trulia and Craigslist at once, with HUD and bank homes, home values for 885 cities and county records.',
- styles: [showcase.STYLESHEET], localStyles: ['css/home.css'],
+ styles: [showcase.STYLESHEET, ...home.STYLES], localStyles: ['css/home.css'],
body: html`${raw(showcase.hero({
body: html`${raw(showcase.hero({
- eyebrow: 'OpenVibe.Homes', title: 'Buying a home?', accent: 'Search every site at once.',
- lede: 'Type a place once and every big for-sale site searches it for you: Zillow, Realtor.com, Redfin, Homes.com, Trulia and Craigslist, next to HUD and Fannie Mae homes, foreclosure auctions and the county\'s property records. See what homes are worth in your city and what the payment would be.',
- actions: [{ label: 'Search every site', href: '/search', primary: true }, { label: 'Homes by city', href: '/in' }, { label: 'Mortgage calculator', href: '/mortgage' }],
- aside: { html: String(searchForm({ kind: 'home' })) },
+ title: 'Buying a home?', accent: 'Search every site at once.',
+ lede: 'Type a place once and Zillow, Realtor.com, Redfin, Homes.com, Trulia and Craigslist search it, next to HUD and Fannie Mae homes, foreclosure auctions and the county\'s records.',
+ aside: { html: render(html`${home.search({ action: '/search', placeholder: 'City or ZIP', kinds: sites.KINDS, kindText: sites.KIND_TEXT, kind: 'home', go: 'Search every site' })}
+${home.doors([{ href: '/in', label: 'Homes by city' }, { href: '/mortgage', label: 'Mortgage calculator' }])}
+<p class="home-note">Offers, inspections and payments happen with the seller or an agent on each site, never through a stranger's link.</p>`) },
}))}
}))}
${home.stats([
${home.stats([
{ value: sites.SITES.length, label: 'for-sale, land and auction sites', href: '/search' },
{ value: sites.SITES.length, label: 'for-sale, land and auction sites', href: '/search' },
{ value: withValue, label: 'US cities with home values', href: '/in' },
{ value: withValue, label: 'US cities with home values', href: '/in' },
- { value: moves && moves.metros.length >= 12 ? moves.metros.length : null, label: 'metro areas with a price trend', href: '#h-moves' },
+ { value: trend ? moves.metros.length : null, label: 'metro areas with a price trend', href: '#moves' },
{ value: Object.keys(US_STATE_NAMES).length, label: 'states with county records', href: '/records' },
{ value: Object.keys(US_STATE_NAMES).length, label: 'states with county records', href: '/records' },
], { label: 'OpenVibe.Homes in numbers' })}
], { label: 'OpenVibe.Homes in numbers' })}
-${home.section('kinds', 'What are you buying?', 'Every site that sells it opens at your place, with your price and bedrooms where the site takes them.',
- html`<ul class="home-kinds">${kinds.map(({ k, list }) => html`<li><a href="/search?kind=${k}"><b>${sites.KIND_TEXT[k]}</b><span>${list.map((x) => x.name).join(', ')}</span></a></li>`)}</ul>`)}
+<section class="sc-sec home-sec home-cities">${home.cityRail({ id: 'cities', title: 'What homes are worth in the largest cities', more: { href: '/in', label: `All ${cities.cities.length} cities` }, list: largest, pick: (c) => ({ corner: c.value != null ? cities.valueText(c.value) : null, peek: c.rent.b2 != null ? `2-bedroom rent ${cities.rentText(c.rent.b2)}` : '' }) })}
+<p class="small muted home-under">The median value of owner-occupied homes (Census Bureau, ${cities.SOURCE.years}); each city's page has the price trend, the monthly payment and every for-sale site there.</p>
+${trend ? home.fold('moves', 'Where home prices moved over the past year', moves.latest, html`<p class="small muted">The FHFA House Price Index, ${moves.latest} against a year before: an index of repeat sales of the same homes, not a price. <a href="${moves.source.url}" rel="noopener">Source: ${moves.source.name}</a></p><div class="home-ranks">${home.ranked('Metro areas rising fastest', moves.metros.slice(0, 6).map(metroRow))}${home.ranked('Metro areas rising slowest or falling', moves.metros.slice(-6).reverse().map(metroRow))}${moves.states.length >= 10 ? home.ranked('States rising fastest', moves.states.slice(0, 6).map(stateRow)) : ''}</div>`) : ''}
+${big.length >= 10 ? home.fold('range', 'Where homes cost least and most', '', html`<p class="small muted">Big cities (250,000 people or more) by median home value.</p><div class="home-ranks">${home.ranked('Lowest median value', byValue.slice(0, 6).map(valueRow))}${home.ranked('Highest median value', byValue.slice(-6).reverse().map(valueRow))}</div>`) : ''}
+${home.fold('states', 'Homes by state', `${cities.states().length} states and territories`, html`<p class="small muted">Home values for every city of 50,000 people or more, and each state's largest, with every for-sale site and the county records for each.</p>${home.stateChips('/in')}`)}
+</section>
-${home.section('cities', 'What homes are worth in the largest cities', html`The median value of owner-occupied homes, with the typical 2-bedroom rent beside it (Census Bureau, ${cities.SOURCE.years}). Each city page has the price trend, the monthly payment and every for-sale site there. <a href="/in">All ${cities.cities.length} cities</a>`,
- home.cityCards(largest, (c) => ({ main: cities.valueText(c.value), mainLabel: 'median home', sub: c.rent.b2 != null ? `2-bedroom rent ${cities.rentText(c.rent.b2)}` : '' })))}
-
-${moves && moves.metros.length >= 12 ? home.section('moves', `Where home prices moved over the past year`, html`The FHFA House Price Index, ${moves.latest} against a year before: an index of repeat sales of the same homes, not a price. <a href="${moves.source.url}" rel="noopener">Source: ${moves.source.name}</a>`,
- html`<div class="home-ranks">${home.ranked('Metro areas rising fastest', moves.metros.slice(0, 6).map(metroRow))}${home.ranked('Metro areas rising slowest or falling', moves.metros.slice(-6).reverse().map(metroRow))}${moves.states.length >= 10 ? home.ranked('States rising fastest', moves.states.slice(0, 6).map(stateRow)) : ''}</div>`) : ''}
-
-${big.length >= 10 ? home.section('range', 'Where homes cost least and most', 'Big cities (250,000 people or more) by median home value.',
- html`<div class="home-ranks">${home.ranked('Lowest median value', byValue.slice(0, 6).map(valueRow))}${home.ranked('Highest median value', byValue.slice(-6).reverse().map(valueRow))}</div>`) : ''}
-
-${home.section('states', 'Homes by state', 'Home values for every city of 50,000 people or more, and each state\'s largest, with every for-sale site and the county records for each.', home.stateChips('/in'))}
-
-${home.section('sites', 'The sites it searches', 'Each one opens its own search at your place. Offers, inspections and payments happen with the seller or an agent on that site.',
- html`<ul class="home-sites">${sites.SITES.map((x) => html`<li><b>${x.name}</b><span>${x.host}</span><p>${x.best}</p></li>`)}</ul>`)}
-
-${raw(showcase.features({
- title: 'What is here',
+${raw(showcase.apps({
+ id: 'places', title: 'What is here',
items: [
items: [
- { icon: 'ov:search', title: 'Every for-sale site', text: 'Zillow, Realtor.com, Redfin, Homes.com, Trulia and Craigslist, each opened at your place and price.', href: '/search' },
- { icon: 'ov:shield', title: 'Government and bank homes', text: 'HUD Homestore, Fannie Mae HomePath and foreclosure auctions, often below market and first offered to people who will live there.', href: '/search?kind=foreclosure' },
- { icon: 'ov:docs', title: 'Property records', text: 'The county assessor and recorder for any US state: owners, sales, taxes and deeds, from the official sites.', href: '/records' },
- { icon: 'ov:map', title: 'Homes by city', text: `What homes are worth in ${cities.cities.length} US cities, beside what renting costs, from Census data.`, href: '/in' },
- { icon: 'ov:gauge', title: 'Mortgage calculator', text: 'The monthly cost of a loan with taxes, insurance and HOA, from the numbers you type.', href: '/mortgage' },
- { icon: 'ov:rent', title: 'Keep them on your board', text: 'Your OpenVibe.Rent board keeps homes from every site in one list, with your notes.', href: `${config.baseUrl}/board` },
+ { icon: 'ov:search', name: 'Search', text: 'Every for-sale site at once', href: '/search' },
+ { icon: 'ov:shield', name: 'Bank homes', text: 'HUD, HomePath and auctions', href: '/search?kind=foreclosure' },
+ { icon: 'ov:docs', name: 'Records', text: 'Each county\'s assessor and recorder', href: '/records' },
+ { icon: 'ov:map', name: 'Cities', text: `Home values in ${cities.cities.length} US cities`, href: '/in' },
+ { icon: 'ov:gauge', name: 'Mortgage', text: 'What a loan costs a month', href: '/mortgage' },
+ { icon: 'ov:check', name: 'Your board', text: 'Homes from every site, on OpenVibe.Rent', href: `${config.baseUrl}/board` },
],
],
}))}
}))}
-${home.section('faq', 'Questions people ask', '', home.faq(FAQ))}
+${home.section('faq', 'Questions people ask', '', html`${home.fold('sites', 'Which sites does it search?', `${sites.SITES.length} sites`, html`<p class="small muted">Each one opens its own search at your place. Offers, inspections and payments happen with the seller or an agent on that site.</p>${home.siteList(sites.SITES)}`)}${home.faq(FAQ)}`)}
${raw(showcase.cta({ title: 'Renting instead?', text: 'OpenVibe.Rent opens every rental site at once, with typical rents for the same cities and affordable housing from HUD.', actions: [{ label: 'Open OpenVibe.Rent', href: `${config.baseUrl}/`, primary: true }, { label: 'Search every for-sale site', href: '/search' }] }))}`,
${raw(showcase.cta({ title: 'Renting instead?', text: 'OpenVibe.Rent opens every rental site at once, with typical rents for the same cities and affordable housing from HUD.', actions: [{ label: 'Open OpenVibe.Rent', href: `${config.baseUrl}/`, primary: true }, { label: 'Search every for-sale site', href: '/search' }] }))}`,
});
});
@@ -15,12 +15,13 @@ const express = require('express');
@@ -15,12 +15,13 @@ const express = require('express');
const ovServe = require('openvibe-shared/serve');
const ovServe = require('openvibe-shared/serve');
const frame = require('openvibe-shared/frame');
const frame = require('openvibe-shared/frame');
const showcase = require('openvibe-shared/showcase');
const showcase = require('openvibe-shared/showcase');
+const kit = require('openvibe-shared/kit');
const seo = require('openvibe-shared/seo');
const seo = require('openvibe-shared/seo');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
const { asyncRouter } = require('./router');
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, badge } = require('../render/html');
+const { html, raw, render, table, notice, time, badge } = require('../render/html');
const { send } = require('../render/layout');
const { send } = require('../render/layout');
const listings = require('../listings/listings');
const listings = require('../listings/listings');
const store = require('../listings/store');
const store = require('../listings/store');
@@ -37,7 +38,7 @@ const { products } = require('openvibe-contracts');
@@ -37,7 +38,7 @@ const { products } = require('openvibe-contracts');
const SITE_NAME = 'OpenVibe.Rent';
const SITE_NAME = 'OpenVibe.Rent';
const TAGLINE = 'Every rental site, one search.';
const TAGLINE = 'Every rental site, one search.';
-const HOME_LATEST = 6;
+const HOME_LATEST = 12;
const STATE_KIND = { active: 'ok', hidden: 'warn', expired: '', removed: 'bad' };
const STATE_KIND = { active: 'ok', hidden: 'warn', expired: '', removed: 'bad' };
const STATE_TEXT = { active: 'active', hidden: 'hidden — waiting for staff', expired: 'expired', removed: 'removed' };
const STATE_TEXT = { active: 'active', hidden: 'hidden — waiting for staff', expired: 'expired', removed: 'removed' };
@@ -158,11 +159,26 @@ ${notice(p && p.detail ? p.detail : 'You are over the limit for this.', 'warn')}
@@ -158,11 +159,26 @@ ${notice(p && p.detail ? p.detail : 'You are over the limit for this.', 'warn')}
// ── Home ─────────────────────────────────────────────────
// ── Home ─────────────────────────────────────────────────
// What the home shows is real and current: every rental site this search opens, the Census rents of 885 cities,
// What the home shows is real and current: every rental site this search opens, the Census rents of 885 cities,
// the FHFA index's metro count, listings posted here. A block with nothing to show is left out.
// the FHFA index's metro count, listings posted here. A block with nothing to show is left out.
+ // A taste and a door, not the index (the owner, 2026-10-28): the search first, the numbers as one strip, the
+ // newest listings and the largest cities as rails with See all, the site's places as a launcher, and the parts read
+ // once (the cheapest and dearest cities, every state, every site searched, the questions) folded.
const FAQ = ((products.get('openvibe.rent') || {}).faq || []);
const FAQ = ((products.get('openvibe.rent') || {}).faq || []);
- const sitesFor = (kind) => everywhere.SITES.filter((x) => x.kinds.includes(kind));
+ const PERIOD_SHORT = { month: '/mo', week: '/wk', day: '/day', hour: '/hr', once: '' };
+ const priceShort = (row) => {
+ const n = Number(row.price);
+ const amount = Number.isFinite(n) ? n.toLocaleString('en-US', { maximumFractionDigits: Number.isInteger(n) ? 0 : 2 }) : String(row.price);
+ return `${amount} ${row.currency}${PERIOD_SHORT[row.period] == null ? '' : PERIOD_SHORT[row.period]}`;
+ };
+ /** One listing in a rail: the city on the picture, the price in its corner, kind and place on hover. */
+ const listingCard = (row) => kit.card({
+ href: `/listings/${row.id}`, title: row.title, type: 'listing', ratio: '4/3', share: false,
+ badge: row.city, corner: priceShort(row), peek: [listings.KIND_TEXT[row.kind] || row.kind, whereOf(row)].filter(Boolean).join(' · '), when: row.created_at,
+ });
r.get('/', async (req, res) => {
r.get('/', async (req, res) => {
- const latest = await store.search(s, listings.normalizeFilters({}), { limit: HOME_LATEST });
- const posted = latest.rows.length ? await store.countMatching(s, listings.normalizeFilters({})) : 0;
+ const all = listings.normalizeFilters({});
+ const latest = await store.search(s, all, { limit: HOME_LATEST });
+ const kinds = latest.rows.length ? (await store.facets(s, all)).kind : [];
+ const posted = kinds.reduce((n, k) => n + k.count, 0);
let moves = null;
let moves = null;
try { moves = await housingHpi.areaMoves(s); } catch { moves = null; }
try { moves = await housingHpi.areaMoves(s); } catch { moves = null; }
const homesBase = String(config.homes.url).replace(/\/+$/, '');
const homesBase = String(config.homes.url).replace(/\/+$/, '');
@@ -171,18 +187,15 @@ ${notice(p && p.detail ? p.detail : 'You are over the limit for this.', 'warn')}
@@ -171,18 +187,15 @@ ${notice(p && p.detail ? p.detail : 'You are over the limit for this.', 'warn')}
const byTwo = [...big].sort((a, b) => a.rent.b2 - b.rent.b2);
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 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 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({
const hero = showcase.hero({
- eyebrow: `${SITE_NAME} · ${TAGLINE}`,
title: 'Every rental site.',
title: 'Every rental site.',
accent: 'One search.',
accent: 'One search.',
- lede: `Type a place once and ${SITE_NAME} opens Craigslist, Facebook Marketplace, Zillow, Apartments.com, Zumper, Rent.com and ${everywhere.SITES.length - 6} more at it: apartments, houses, rooms, furnished stays, commercial space, parking, storage and cars. Check any rent against what the city usually pays, keep the places you like on one board, and find affordable housing from HUD.`,
- actions: [
- { label: 'Search every site', href: '/everywhere', primary: true },
- { label: 'Is this rent fair?', href: '/fair' },
- { label: 'Affordable housing', href: '/housing' },
- ],
- note: 'Open source (AGPL-3.0). Listings, messages and payments stay on each site; OpenVibe never takes a deposit.',
- aside: { html: String(everywhereForm({}, { big: true })) },
+ lede: `Type a place once and ${SITE_NAME} opens Craigslist, Facebook Marketplace, Zillow, Apartments.com, Zumper and ${everywhere.SITES.length - 5} more at it.`,
+ aside: { html: render(html`${home.search({ action: '/everywhere', placeholder: 'City or ZIP', kinds: everywhere.KINDS, kindText: everywhere.KIND_TEXT, kind: 'apartment', go: 'Search every site' })}
+${home.doors([{ href: '/fair', label: 'Is this rent fair?' }, { href: '/housing', label: 'Affordable housing' }])}
+<p class="home-note">Listings, messages and payments stay on each site. Never pay or send a deposit before you have seen the place.</p>`) },
});
});
const jsonLd = [
const jsonLd = [
...homeJsonLd(config),
...homeJsonLd(config),
@@ -194,55 +207,42 @@ ${notice(p && p.detail ? p.detail : 'You are over the limit for this.', 'warn')}
@@ -194,55 +207,42 @@ ${notice(p && p.detail ? p.detail : 'You are over the limit for this.', 'warn')}
title: 'Every rental site in one search',
title: 'Every rental site in one search',
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.',
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,
jsonLd,
- styles: [showcase.STYLESHEET], localStyles: ['css/home.css'],
+ styles: [showcase.STYLESHEET, ...home.STYLES], localStyles: ['css/home.css'],
body: html`${raw(hero)}
body: html`${raw(hero)}
${home.stats([
${home.stats([
{ value: everywhere.SITES.length, label: 'rental sites in one search', href: '/everywhere' },
{ value: everywhere.SITES.length, label: 'rental sites in one search', href: '/everywhere' },
{ value: cityList.cities.length, label: 'US cities with typical rents', href: '/in' },
{ value: cityList.cities.length, label: 'US cities with typical rents', href: '/in' },
- { value: cityList.states().length, label: 'states and territories', href: '/in' },
+ { value: states, label: 'states and territories', href: '/in' },
{ value: moves ? moves.metros.length : null, label: 'metro areas with home-price trends', href: `${homesBase}/` },
{ value: moves ? moves.metros.length : null, label: 'metro areas with home-price trends', href: `${homesBase}/` },
{ value: posted || null, label: posted === 1 ? 'listing posted here' : 'listings posted here', href: '/listings' },
{ value: posted || null, label: posted === 1 ? 'listing posted here' : 'listings posted here', href: '/listings' },
], { label: `${SITE_NAME} in numbers` })}
], { label: `${SITE_NAME} in numbers` })}
-${home.section('kinds', 'What are you looking for?', 'Pick a kind and a place: every site that lists it opens at once, with your price and bedrooms where it takes them.',
- html`<ul class="home-kinds">${everywhere.KINDS.map((k) => { const list = sitesFor(k); return list.length ? html`<li><a href="/everywhere?kind=${k}"><b>${everywhere.KIND_TEXT[k]}</b><span>${list.length} ${list.length === 1 ? 'site' : 'sites'}: ${list.slice(0, 4).map((x) => x.name).join(', ')}${list.length > 4 ? ' and more' : ''}</span></a></li>` : ''; })}</ul>`)}
-
-${home.section('cities', 'What rent costs in the largest cities', html`The middle rent for a 2-bedroom, with studios, 1- and 3-bedrooms beside it (Census Bureau, ${cityList.SOURCE.years}). Each city page opens every rental site there. <a href="/in">All ${cityList.cities.length} cities</a> · <a href="/fair">Check a rent</a>`,
- home.cityCards(largest, (c) => ({ main: cityList.rentText(c.rent.b2), mainLabel: 'for 2 bedrooms', sub: sizes(c) })))}
-
-${big.length >= 10 ? home.section('range', 'Where renting costs least and most', '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.',
- html`<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.section('states', 'Rent by state', 'Typical rents for every city of 50,000 people or more, and each state\'s largest, with every rental site\'s search for each.', home.stateChips('/in'))}
+${latest.rows.length ? html`<section class="sc-sec home-sec home-latest">${raw(kit.rail({ id: 'latest', 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>` : ''}
-${latest.rows.length ? home.section('latest', `Posted on ${SITE_NAME}`, html`Listings people post here themselves: apartments, rooms, houses, commercial space, parking, storage and equipment. <a href="/listings">Browse them all</a>.`,
- html`${listingList(latest.rows, { empty: '' })}<p><a class="sc-btn" href="/listings">Every listing posted here</a> <a class="sc-btn" href="/post">Post a listing</a></p>`) : ''}
-
-${home.section('sites', 'The sites it searches', 'Each one opens its own search at your place, so you see its newest listings without typing the search again.',
- html`<ul class="home-sites">${everywhere.SITES.map((x) => html`<li><b>${x.name}</b><span>${x.host}${x.us ? '' : ' · worldwide'}</span><p>${x.best}</p></li>`)}</ul>
-<p class="small muted">The United States first; more countries next. Listings, the landlord and the payment stay on each site.</p>`)}
-
-${home.section('housing', 'Affordable housing near you', 'Public housing, tax-credit (LIHTC) and HUD-assisted properties, and the housing authorities and counselling agencies to apply through, from U.S. HUD open data.',
- html`<ul class="home-chips">${largest.slice(0, 8).map((c) => html`<li><a href="/housing?${new URLSearchParams({ q: `${c.name}, ${c.state}` })}">${c.name}</a></li>`)}</ul>
-<p><a class="sc-btn sc-primary" href="/housing">Find housing near you</a></p>`)}
-
-${home.section('buy', 'Buying instead?', html`OpenVibe.Homes is the owning side: every for-sale site in one search, home values for the same cities, county records and a mortgage calculator. <a href="${homesBase}/">Open OpenVibe.Homes</a>`,
- home.cityCards(largest.slice(0, 4).filter((c) => c.value != null), (c) => ({ main: cityList.valueText(c.value), mainLabel: 'median home', sub: c.rent.b2 != null ? `2-bedroom rent ${cityList.rentText(c.rent.b2)}` : '' }), { href: (c) => `${homesBase}${c.path}` }))}
+<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>
+${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>
-${raw(showcase.features({
- title: 'How it works',
- lede: 'One account for every rental search you make, wherever the listing lives.',
+${raw(showcase.apps({
+ id: 'places', title: `Everything on ${SITE_NAME}`,
items: [
items: [
- { icon: 'ov:search', title: 'One search, every site', text: 'Type a place once and every site above opens its own search there, with your price and bedrooms.', href: '/everywhere' },
- { icon: 'ov:check', title: 'Your board', text: 'Keep the listings you like from any site in one list, with your notes and how far you got.', href: '/board' },
- { icon: 'ov:map', title: 'Is the rent fair?', text: `What rent usually costs in ${cityList.cities.length} US cities by size, and a check for any price you are quoted, from Census data.`, href: '/fair' },
- { icon: 'ov:bell', title: 'Alerts that keep looking', text: 'Save a search and get alerts through OpenVibe.Watch when something new matches.', href: '/listings' },
- { icon: 'ov:gauge', title: 'Safety first', text: 'Never pay or send a deposit before you have seen the place and signed an agreement. Reported listings are hidden until staff look.', href: '/safety' },
- { icon: 'ov:rent', title: 'Post your own', text: 'Rooms, apartments, parking or gear: a description, a price and where it is. Edit, renew or hide it any time.', href: '/post' },
+ { icon: 'ov:search', name: 'Search', text: 'Every rental site at once', href: '/everywhere' },
+ { icon: 'ov:gauge', name: 'Fair rent', text: 'Is the rent you were quoted fair?', href: '/fair' },
+ { icon: 'ov:map', name: 'Cities', text: `Typical rents in ${cityList.cities.length} US cities`, href: '/in' },
+ { icon: 'ov:homes', name: 'Affordable', text: 'Public and HUD-assisted housing', href: '/housing' },
+ { icon: 'ov:check', name: 'Your board', text: 'The places you like, from any site', href: '/board' },
+ { icon: 'ov:bell', name: 'Alerts', text: 'Save a search, hear what is new', href: '/listings' },
+ { icon: 'ov:docs', name: 'Listings', text: 'Posted here by people', href: '/listings' },
+ { icon: 'ov:rent', name: 'Post', text: 'Rent yours out', href: '/post' },
+ { icon: 'ov:shield', name: 'Safety', text: 'No deposit before you see it', href: '/safety' },
+ { icon: 'ov:homes', name: 'Buying', text: 'Homes for sale on OpenVibe.Homes', href: `${homesBase}/` },
],
],
}))}
}))}
-${home.section('faq', 'Questions people ask', '', home.faq(FAQ))}
+${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.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' }] }))}`,
@@ -254,7 +254,7 @@ ${raw(showcase.cta({ title: 'Have something to rent out?', text: 'Post it in a m
@@ -254,7 +254,7 @@ ${raw(showcase.cta({ title: 'Have something to rent out?', text: 'Post it in a m
const str = (v) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, 200) : null);
const str = (v) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, 200) : null);
const OWN_KIND = { apartment: 'apartment', house: 'house', room: 'room', stay: null, commercial: 'commercial', parking: 'parking', storage: 'parking', vehicle: 'equipment' };
const OWN_KIND = { apartment: 'apartment', house: 'house', room: 'room', stay: null, commercial: 'commercial', parking: 'parking', storage: 'parking', vehicle: 'equipment' };
const HUD_KINDS = new Set(['apartment', 'house', 'room']);
const HUD_KINDS = new Set(['apartment', 'house', 'room']);
- const everywhereForm = (v = {}, { big = false } = {}) => html`<form class="search-form everywhere-form${big ? ' everywhere-big' : ''}" method="get" action="/everywhere" role="search">
+ const everywhereForm = (v = {}) => html`<form class="search-form everywhere-form" method="get" action="/everywhere" role="search">
<div class="field"><label for="ew-q">Where</label><input id="ew-q" name="q" type="search" required maxlength="200" placeholder="A city and state, or a ZIP code" value="${v.q || ''}" autocomplete="address-level2"></div>
<div class="field"><label for="ew-q">Where</label><input id="ew-q" name="q" type="search" required maxlength="200" placeholder="A city and state, or a ZIP code" value="${v.q || ''}" autocomplete="address-level2"></div>
<div class="field-row">
<div class="field-row">
<div class="field"><label for="ew-kind">Looking for</label><select id="ew-kind" name="kind">${everywhere.KINDS.map((k) => html`<option value="${k}"${k === (v.kind || 'apartment') ? raw(' selected') : ''}>${everywhere.KIND_TEXT[k]}</option>`)}</select></div>
<div class="field"><label for="ew-kind">Looking for</label><select id="ew-kind" name="kind">${everywhere.KINDS.map((k) => html`<option value="${k}"${k === (v.kind || 'apartment') ? raw(' selected') : ''}>${everywhere.KIND_TEXT[k]}</option>`)}</select></div>
@@ -1,38 +1,46 @@
@@ -1,38 +1,46 @@
'use strict';
'use strict';
/**
/**
- * The pieces OpenVibe.Rent's and OpenVibe.Homes' home pages share (public/css/home.css): a row of counts, city cards,
- * a ranked list, state chips and the questions people ask. Each takes real rows only and renders nothing when it has
- * none, so a section never stands empty.
+ * The pieces OpenVibe.Rent's and OpenVibe.Homes' front pages share. A front page is a taste and a door, not the index
+ * (the owner, 2026-10-28: "act more like an app, not a huge long web page"), so these are the shared kit's pieces
+ * (openvibe-shared/kit: kit.css for the numbers and folds, room.css for the cards and rails) around the few things
+ * each home draws itself (a ranked list, state chips; public/css/home.css). Each takes real rows only and renders
+ * nothing when it has none, so a block never stands empty.
*/
*/
-const { html, render } = require('./html');
+const kit = require('openvibe-shared/kit');
+const { html, raw, render } = require('./html');
const cities = require('../aggregate/cities');
const cities = require('../aggregate/cities');
-/** Counts that are true right now: [{ value, label, href? }]; an item without a value is left out. */
-function stats(items, { label = 'In numbers' } = {}) {
- const real = (items || []).filter((i) => i && i.value != null && i.value !== '' && i.value !== 0);
- if (!real.length) return '';
- return html`<ul class="home-stats" aria-label="${label}">${real.map((i) => html`<li>${i.href ? html`<a href="${i.href}"><b>${i.value}</b><span>${i.label}</span></a>` : html`<b>${i.value}</b><span>${i.label}</span>`}</li>`)}</ul>`;
-}
+/** The stylesheets a front page loads after app.css: the kit's controls and folds, then its cards and rails. */
+const STYLES = ['kit.css', 'room.css'];
+
+/** Counts that are true right now, as one strip (it scrolls sideways on a phone): [{ value, label, href? }]. */
+const stats = (items, { label = 'In numbers' } = {}) => raw(kit.stats((items || []).filter(Boolean).map((i) => [i.value, i.label, i.href || null]), { label }));
-/** The section wrapper every home block uses: an h2 with an id, a lede, the body. */
+/** The section wrapper a home block uses: an h2 with an id, a lede, the body. */
const section = (id, title, lede, body) => (render(body).trim()
const section = (id, title, lede, body) => (render(body).trim()
? html`<section class="sc-sec" aria-labelledby="h-${id}"><h2 id="h-${id}">${title}</h2>${lede ? html`<p class="sc-lede">${lede}</p>` : ''}${body}</section>`
? html`<section class="sc-sec" aria-labelledby="h-${id}"><h2 id="h-${id}">${title}</h2>${lede ? html`<p class="sc-lede">${lede}</p>` : ''}${body}</section>`
: '');
: '');
/**
/**
- * City cards: the name, the headline number and a smaller line under it. `pick(c)` → { main, mainLabel, sub }.
+ * The largest cities as a rail of cards (it scrolls sideways and snaps; wide screens wrap it, home.css): the name,
+ * its state, the headline number in the card's corner and the rest on hover. `pick(c)` → { corner, peek };
* `href(c)` is where the card goes (the city page by default).
* `href(c)` is where the card goes (the city page by default).
*/
*/
-function cityCards(list, pick, { href = (c) => c.path } = {}) {
- if (!list.length) return '';
- return html`<ul class="home-cities">${list.map((c) => {
- const p = pick(c);
- return html`<li><a href="${href(c)}"><span class="hc-name">${c.name}<span class="hc-state">${c.state}</span></span><span class="hc-main"><b>${p.main || 'not published'}</b> <span>${p.mainLabel}</span></span>${p.sub ? html`<span class="hc-sub">${p.sub}</span>` : ''}</a></li>`;
- })}</ul>`;
+function cityRail({ id, title, more, list, pick, href = (c) => c.path }) {
+ return raw(kit.rail({
+ id, title, more, size: 's',
+ items: (list || []).map((c) => {
+ const p = pick(c);
+ return kit.card({ href: href(c), title: c.name, type: 'map', ratio: '4/3', badge: c.state, corner: p.corner || 'not published', peek: p.peek || '', share: false });
+ }),
+ }));
}
}
-/** A numbered list of [{ name, href?, value, note? }], for "lowest" and "highest" pairs side by side. */
+/** 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) })) : '');
+
+/** A numbered list of [{ name, href?, value }], for "lowest" and "highest" pairs side by side. */
function ranked(title, rows) {
function ranked(title, rows) {
if (!rows.length) return '';
if (!rows.length) return '';
return html`<div class="home-rank"><h3>${title}</h3><ol>${rows.map((r) => html`<li>${r.href ? html`<a href="${r.href}">${r.name}</a>` : r.name}<span class="hr-val">${r.value}</span></li>`)}</ol></div>`;
return html`<div class="home-rank"><h3>${title}</h3><ol>${rows.map((r) => html`<li>${r.href ? html`<a href="${r.href}">${r.name}</a>` : r.name}<span class="hr-val">${r.value}</span></li>`)}</ol></div>`;
@@ -41,13 +49,25 @@ function ranked(title, rows) {
@@ -41,13 +49,25 @@ function ranked(title, rows) {
/** Every state with cities, as chips to its page. */
/** Every state with cities, as chips to its page. */
const stateChips = (prefix = '/in') => html`<ul class="home-chips">${cities.states().map((st) => html`<li><a href="${prefix}/${st.slug}">${st.name} <span>${st.count}</span></a></li>`)}</ul>`;
const stateChips = (prefix = '/in') => html`<ul class="home-chips">${cities.states().map((st) => html`<li><a href="${prefix}/${st.slug}">${st.name} <span>${st.count}</span></a></li>`)}</ul>`;
-/** The questions people ask (the product's own FAQ from openvibe-contracts), open on click, readable without JS. */
-function faq(items) {
- if (!items || !items.length) return '';
- return html`<div class="home-faq">${items.map((f) => html`<details><summary>${f.question}</summary><p>${f.answer}</p></details>`)}</div>`;
+/** The sites a search opens, for the fold that names them: [{ name, host, best }]. */
+const siteList = (list, note = '') => html`<ul class="home-sites">${list.map((x) => html`<li><b>${x.name}</b> <span>${x.host}</span><p>${x.best}</p></li>`)}</ul>${note ? html`<p class="small muted">${note}</p>` : ''}`;
+
+/** The questions people ask (the product's own FAQ from openvibe-contracts), each a fold, readable without JS. */
+const faq = (items) => (items || []).map((f) => raw(kit.fold({ title: f.question, body: render(html`<p>${f.answer}</p>`) })));
+
+/**
+ * The search a home opens with (kit.find): the place, what kind (a select inside the pill) and a round go button.
+ * Price and bedrooms are on the results page, which keeps the place typed here.
+ */
+function search({ action, placeholder, label = 'Where', kinds, kindText, kind, kindLabel = 'Looking for', go }) {
+ const select = html`<select name="kind" aria-label="${kindLabel}">${kinds.map((k) => html`<option value="${k}"${k === kind ? raw(' selected') : ''}>${kindText[k]}</option>`)}</select>`;
+ return raw(kit.find({ action, name: 'q', placeholder, label, required: true, maxlength: 200, extra: render(select), button: { icon: 'forward', label: go }, id: 'home-q' }));
}
}
+/** Up to two small doors under the search: [{ href, label }]. */
+const doors = (list) => html`<p class="home-doors">${raw(list.slice(0, 2).map((d) => kit.button({ href: d.href, label: d.label, kind: 'quiet', small: true })).join(' '))}</p>`;
+
/** Cities with at least `min` people, largest first. */
/** 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);
const bigCities = (min) => cities.cities.filter((c) => c.population >= min).sort((a, b) => b.population - a.population);
-module.exports = { stats, section, cityCards, ranked, stateChips, faq, bigCities };
+module.exports = { STYLES, stats, section, cityRail, fold, ranked, stateChips, siteList, faq, search, doors, bigCities };
@@ -127,6 +127,9 @@ const housingFetchImpl = async (url) => {
@@ -127,6 +127,9 @@ const housingFetchImpl = async (url) => {
assert.ok(r.text.includes('Every rental site.') && r.text.includes('One search.'));
assert.ok(r.text.includes('Every rental site.') && r.text.includes('One search.'));
assert.ok(r.text.includes('action="/everywhere"'), 'the hero search goes everywhere');
assert.ok(r.text.includes('action="/everywhere"'), 'the hero search goes everywhere');
for (const name of ['Craigslist', 'Facebook Marketplace', 'Zillow', 'Apartments.com', 'Zumper', 'Turo']) assert.ok(r.text.includes(name), name);
for (const name of ['Craigslist', 'Facebook Marketplace', 'Zillow', 'Apartments.com', 'Zumper', 'Turo']) assert.ok(r.text.includes(name), name);
+ // Every site it searches is named on the home, in a fold (read once), with the real count.
+ const sites = (/<details class="ovk-fold" id="sites">[\s\S]*?<\/details>/.exec(r.text) || [''])[0];
+ assert.ok(sites.includes(`· ${everywhere.SITES.length} sites`) && everywhere.SITES.every((x) => sites.includes(`<b>${x.name.replace(/&/g, '&')}</b>`)), 'every site searched, in its fold');
assert.ok(!/nothing (here )?is scraped|no scraped listings/i.test(r.text), 'the old "nothing is scraped" framing is gone');
assert.ok(!/nothing (here )?is scraped|no scraped listings/i.test(r.text), 'the old "nothing is scraped" framing is gone');
});
});
} finally { await t.close(); }
} finally { await t.close(); }
@@ -81,6 +81,12 @@ const housingFetchImpl = async (url) => (new URL(String(url)).pathname.endsWith(
@@ -81,6 +81,12 @@ const housingFetchImpl = async (url) => (new URL(String(url)).pathname.endsWith(
assert.ok(r.text.includes('<link rel="canonical" href="https://openvibe.homes/">'), 'its own canonical');
assert.ok(r.text.includes('<link rel="canonical" href="https://openvibe.homes/">'), 'its own canonical');
assert.ok(r.text.includes('What homes are worth in the largest cities') && r.text.includes('href="/in/ny/new-york"'), 'the largest cities\' home values, each opening its city page');
assert.ok(r.text.includes('What homes are worth in the largest cities') && r.text.includes('href="/in/ny/new-york"'), 'the largest cities\' home values, each opening its city page');
assert.ok(r.text.includes('Where homes cost least and most') && r.text.includes('Homes by state'), 'the cheapest and dearest big cities, and every state');
assert.ok(r.text.includes('Where homes cost least and most') && r.text.includes('Homes by state'), 'the cheapest and dearest big cities, and every state');
+ // The same front-page shape as Rent's: the search first, the cities as a rail of cards with All N, the parts
+ // read once folded, the site's places as a launcher.
+ assert.ok(r.text.includes('<form class="ovk-find" method="get" action="/search" role="search">'), 'the search is the kit\'s pill');
+ assert.ok(/<section class="ovk-rail[^"]*" id="cities"/.test(r.text) && /<a class="ovk-rail-more" href="\/in">All \d+ cities/.test(r.text), 'the cities are a rail with All N');
+ assert.ok(/<details class="ovk-fold" id="range">/.test(r.text) && /<details class="ovk-fold" id="states">/.test(r.text) && /<details class="ovk-fold" id="sites">/.test(r.text), 'the ranks, the states and the sites are folds');
+ assert.ok(/<section class="sc-sec sc-apps-sec" id="places"/.test(r.text) && r.text.includes('href="/records"') && r.text.includes('href="/mortgage"'), 'the places as a launcher');
assert.ok(r.text.includes('Questions people ask') && /"@type":"FAQPage"/.test(r.text) && /"@type":"ItemList"/.test(r.text), 'the questions shown are the FAQPage; the cities are an ItemList');
assert.ok(r.text.includes('Questions people ask') && /"@type":"FAQPage"/.test(r.text) && /"@type":"ItemList"/.test(r.text), 'the questions shown are the FAQPage; the cities are an ItemList');
assert.ok(!r.text.includes('Where home prices moved'), 'no price-trend block without FHFA rows');
assert.ok(!r.text.includes('Where home prices moved'), 'no price-trend block without FHFA rows');
const rent = await t.get('/');
const rent = await t.get('/');
@@ -61,8 +61,14 @@ const BOOST = /<script src="\/shared\/boost\.js\?v=[0-9a-f]{12}" data-main="#mai
@@ -61,8 +61,14 @@ const BOOST = /<script src="\/shared\/boost\.js\?v=[0-9a-f]{12}" data-main="#mai
assert.strictEqual(r.status, 200, r.text.slice(0, 300));
assert.strictEqual(r.status, 200, r.text.slice(0, 300));
assert.match(r.headers.get('content-type'), /^text\/html/);
assert.match(r.headers.get('content-type'), /^text\/html/);
assert.ok(r.text.includes('OpenVibe.Rent'), 'the service name is on the page');
assert.ok(r.text.includes('OpenVibe.Rent'), 'the service name is on the page');
- assert.ok(r.text.includes('Every rental site, one search.'), 'the tagline is on the page');
+ // The tagline is the hero's one heading (an eyebrow saying it again above it was left out).
+ assert.ok(r.text.includes('<h1>Every rental site.<span class="sc-accent"> One search.</span></h1>'), 'the tagline is on the page');
assert.ok(r.text.includes('/shared/showcase.css'), 'the showcase stylesheet');
assert.ok(r.text.includes('/shared/showcase.css'), 'the showcase stylesheet');
+ // Only the shared pieces the home draws: the kit's numbers, search and folds (kit.css), its cards and rails
+ // (room.css); no Kit 2 file and no kit script (no menus on these cards; wide screens wrap the rails).
+ assert.ok(/\/shared\/kit\.css\?v=/.test(r.text) && /\/shared\/room\.css\?v=/.test(r.text), 'kit.css and room.css');
+ assert.ok(!/\/shared\/kit-data\.css|\/shared\/kit-web\.js|\/shared\/kit-data-web\.js/.test(r.text), 'nothing the home does not draw');
+ assert.ok(r.text.includes('<dl class="ovk-stats"') && r.text.includes('<form class="ovk-find"'), 'the numbers as one strip; the search as the kit\'s pill');
assert.ok(!r.text.includes('/home/') && !r.text.includes('/mnt/'), 'no local filesystem path on the page');
assert.ok(!r.text.includes('/home/') && !r.text.includes('/mnt/'), 'no local filesystem path on the page');
});
});
@@ -16,15 +16,23 @@ const listings = require('../server/listings/listings');
@@ -16,15 +16,23 @@ const listings = require('../server/listings/listings');
const lee = t.network.addUser('lee');
const lee = t.network.addUser('lee');
const nia = t.network.addUser('nia');
const nia = t.network.addUser('nia');
- await check('the home page: a search over every rental site, the latest listings posted here, how it works, and no deposits said plainly', async () => {
+ await check('the home page: a search over every rental site, the latest listings posted here, the site\'s places, and no deposits said plainly', async () => {
const made = await post(t, kim, { title: 'A flat on the home page' });
const made = await post(t, kim, { title: 'A flat on the home page' });
assert.strictEqual(made.status, 201, made.text);
assert.strictEqual(made.status, 201, made.text);
const r = await t.get('/');
const r = await t.get('/');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
assert.match(r.text, /action="\/everywhere"/, 'the search box searches every rental site');
assert.match(r.text, /action="\/everywhere"/, 'the search box searches every rental site');
- assert.match(r.text, /href="\/listings"/, 'the listings posted here are one click away');
- assert.ok(r.text.includes('A flat on the home page'), 'the latest listings are on the home page');
- assert.match(r.text, /How it works/);
+ // A taste and a door, not the index: the newest listings as a rail of cards, See all with the real count, and
+ // the kinds that have listings as quick filters into /listings.
+ const latest = (/<section class="ovk-rail[^"]*" id="latest"[\s\S]*?<\/section>/.exec(r.text) || [''])[0];
+ assert.ok(latest.includes('class="ovk-card') && latest.includes('A flat on the home page'), 'the latest listings are a rail of cards on the home page');
+ assert.ok(latest.includes(`href="/listings/${made.json().id}"`), 'a card opens its listing');
+ assert.match(latest, /<a class="ovk-rail-more" href="\/listings">See all \d+/, 'See all opens every listing posted here');
+ assert.match(r.text, /<a class="ovk-btn[^"]*" href="\/listings\?kind=apartment"><span>Apartment \d+<\/span><\/a>/, 'the kinds are quick filters');
+ assert.ok(!r.text.includes('class="listing-list"'), 'the home is not the index: no stacked list of listings');
+ // The site's places as a launcher (showcase.apps), each a link.
+ const places = (/<section class="sc-sec sc-apps-sec" id="places"[\s\S]*?<\/section>/.exec(r.text) || [''])[0];
+ for (const href of ['/everywhere', '/fair', '/in', '/housing', '/board', '/listings', '/post', '/safety']) assert.ok(places.includes(`href="${href}"`), `the launcher opens ${href}`);
assert.ok(r.text.includes('Never pay or send a deposit before you have seen the place'), 'the no-deposit rule');
assert.ok(r.text.includes('Never pay or send a deposit before you have seen the place'), 'the no-deposit rule');
assert.match(r.text, /Where do the listings come from\?/, 'where the listings come from (the product\'s FAQ)');
assert.match(r.text, /Where do the listings come from\?/, 'where the listings come from (the product\'s FAQ)');
assert.match(r.text, /What rent costs in the largest cities/, 'the largest cities\' rents');
assert.match(r.text, /What rent costs in the largest cities/, 'the largest cities\' rents');
@@ -21,15 +21,18 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
@@ -21,15 +21,18 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
// largest cities' rents, the cheapest and dearest big cities, every state, every site searched, HUD links, Homes, the
// largest cities' rents, the cheapest and dearest big cities, every state, every site searched, HUD links, Homes, the
// product's questions, and ItemList + FAQPage JSON-LD. Measured 49.3/10.8 KB HTML and 25.5/5.9 KB CSS with one more
// product's questions, and ItemList + FAQPage JSON-LD. Measured 49.3/10.8 KB HTML and 25.5/5.9 KB CSS with one more
// file (css/home.css); the budgets keep about 10% headroom over that.
// file (css/home.css); the budgets keep about 10% headroom over that.
+// Raised on 2026-10-29 for the short home (a taste and a door: the search first, rails, folds, a launcher; 14.6 -> 4.2
+// screens at 390 px): the shared kit's stylesheets (kit.css, room.css) and their markup, measured 62.2 KB HTML and
+// 78.3/17.4 KB CSS in 5 files; the budgets keep about 10% headroom over that.
const BUDGETS = {
const BUDGETS = {
- htmlRawKB: 60, // 55.4 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 49.3 on 2026-10-22 (the curated home); 31.0 before
+ htmlRawKB: 68, // 62.2 the short home (2026-10-29): the kit's cards (an icon each), folds and search pill around the same cities, states and sites, now folded; 55.4 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 49.3 on 2026-10-22 (the curated home); 31.0 before
htmlBrotliKB: 14, // 12.1 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 10.8 on 2026-10-22; 8.1 on 2026-10-16 (Rent by city: the largest cities' rents where an empty "posted here" was); 7.4 before
htmlBrotliKB: 14, // 12.1 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 10.8 on 2026-10-22; 8.1 on 2026-10-16 (Rent by city: the largest cities' rents where an empty "posted here" was); 7.4 before
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: 305, // 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: 305, // 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: 76, // 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: 76, // 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, css/home.css)
- cssRawKB: 41, // 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: 10, // 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
+ 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
externalFiles: 0, // 0
externalFiles: 0, // 0
};
};