openvibers/openvibe.reviews Commits
Short home on phones: one thing at a time, picked with chips (#152)
Changes
@@@ -194,3 +194,57 @@ main .rv-h-panel-h { margin: 0 0 12px; padding: 0; border: 0; font-size: 1rem; }.rv-brand::after { content: ''; position: absolute; left: 0; right: 0; top: -10px; bottom: -10px; }.rv-main p > a:only-child::after { content: ''; position: absolute; left: 0; right: 0; top: -13px; bottom: -13px; }}++++++/* Pick (the owner's short-pages rule, 2026-10-30): on a phone the front page shows one block at a time, picked with a++ row of chips. Radio inputs and :has(), no script: the chips and the one-block rule live under 640 px and only++ where :has() is understood, so a wider screen, or an older browser, shows every block and no chips. */++.pick-head { display: none; }++.pick-row { display: none; }++@supports selector(:has(*)) { @media (max-width: 640px) {++ .pick-bar { margin-top: 0; }++ .pick-head { display: block; }++ .pick-head h2 { margin: 0 0 4px; font: 700 clamp(20px,2.4vw,28px)/1.2 var(--ov-font,'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif); letter-spacing: -.02em; }++ .pick-row { display: flex; gap: 8px; overflow-x: auto; margin: 10px -16px 0; padding: 2px 16px 4px; scrollbar-width: none; overscroll-behavior-x: contain; }++ .pick-row::-webkit-scrollbar { display: none; }++ .pick-row input { position: absolute; opacity: 0; width: 1px; height: 1px; pointer-events: none; }++ .pick-row label { display: inline-flex; align-items: center; flex: none; min-height: 44px; padding: 0 15px; border-radius: 999px; border: 1px solid var(--border, #1f2d47); color: var(--text-secondary, #96a7c2); font-size: .9rem; font-weight: 650; white-space: nowrap; cursor: pointer; }++ .pick-row input:checked + label { color: var(--text-primary, #e6edf7); border-color: var(--accent, #3b82f6); background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); }++ .pick-row input:focus-visible + label { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }++ .pick .pick-pane { display: none; }++ .pick .pick-list > li { display: none; }++ /* The chip names the block, so the block's own heading gives way to it; the content stays in the page for the++ crawlers and for the reader on a wide screen. */++ .pick .sc-sec > h2, .pick section > h2, .pick > h2 { display: none; }++} }++++/* The fold (the owner's short-pages rule): closed on a phone, and where there is room it looks like the section it++ replaces: a heading and its content, with no card around it. */++.rv-fold-block { margin-top: 32px; }++.rv-fold-block > summary h2 { margin: 0; font: 700 clamp(19px,2.2vw,26px)/1.2 var(--ov-font,'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif); }++.rv-fold-hint { display: none; }++/* The fold's summary carries the heading, so the section's own heading gives way to it. */++.rv-fold-block .sc-sec > h2, .rv-fold-block > .sc-sec > h2 { display: none; }++@media (min-width: 641px) {++ .rv-fold-block { border: 0; background: none; border-radius: 0; margin-top: 56px; padding: 0; }++ .rv-fold-block > summary { display: block; min-height: 0; padding: 0; }++ .rv-fold-block > summary h2 { margin: 0 0 6px; font: 700 clamp(22px,2.6vw,30px)/1.2 var(--ov-font,'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif); }++ .rv-fold-block > summary span { display: none; }++ .rv-fold-block .sc-sec { margin: 0; padding: 0; }++}++@media (max-width: 640px) {++ .rv-fold-block { margin-top: 24px; }++ .rv-fold-block > summary { cursor: pointer; }++ .rv-fold-hint { display: inline; margin-left: 8px; font-size: .78rem; font-weight: 600; color: var(--text-secondary, #96a7c2); }++}++++/* A link that points into a pick block opens it: the pane carries the id the link names. */++@supports selector(:has(*)) { @media (max-width: 640px) {++ .pick .pick-pane:target { display: block; }++} }++++/* On a wide screen the pick block is invisible: its blocks sit where they were, in the order they were. */++@media (min-width: 641px) {++ .pick, .pick-body, .pick-pane { display: contents; }++ .pick-bar { display: none; }++}@@@ -28,6 +28,8 @@ const frame = require('openvibe-shared/frame');const seo = require('openvibe-publishing/seo');const { renderPage, SITE_SUMMARY } = require('../render/layout');const views = require('../render/views');++// The short home (the owner's rule, 2026-10-30): pick blocks and folds.++const { pickRules } = require('../render/short');const { ratingForStructuredData } = require('../reviews/aggregate');const { actorMiddleware, crossSite } = require('./common');const everywhere = require('../reviews/everywhere');@@@ -156,9 +158,12 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limisvc.listEntities({ limit: 1 }), svc.entitiesWithData(60), svc.recentMemberReviews(6), svc.kindCounts(),home ? home.stats() : null, home ? home.latestInspections(8) : [], home ? home.mostHazardous(6) : [], home ? home.mostComplaints(6) : [],]);++ // The rule that shows the picked block of the records' pick block: one per block, written for this page.++ const recordIds = [inspections.length ? 'rv-insp' : null, hazardous.length ? 'rv-hpd' : null, complaints.length ? 'rv-cfpb' : null].filter(Boolean);send(req, res, 200, views.home({ entities, total: all.total, editor: isEditor(req), recent, kinds, stats, sites: everywhere.SITES.length, inspections, hazardous, complaints }), {robots: 'index, follow', cache: 'public', active: 'home', path: '/', summary: SITE_SUMMARY, styles: [showcase.STYLESHEET], description: HOME_DESCRIPTION,jsonLd: views.homeJsonLd({ base: config.baseUrl.replace(/\/+$/, ''), entities }),++ inlineStyles: [`@supports selector(:has(*)){@media (max-width:640px){${pickRules('records', recordIds)}}}`],});}));// What shipped on OpenVibe.Reviews: the shared update log every OpenVibe site has.@@@ -12,6 +12,7 @@ const fs = require('fs');const path = require('path');const layout = require('openvibe-publishing/layout');const frame = require('openvibe-shared/frame');++const { foldBlock } = require('./short');const { escapeHtml: esc } = require('openvibe-publishing/ssr');const SITE_NAME = 'OpenVibe.Reviews';@@@ -107,13 +108,16 @@ function renderPage(o) {// This service's own extra stylesheets for one page (/everywhere's css/everywhere.css).// The extra head HTML: the page's own stylesheets, whatever the page passed, and the link that// names this page's Markdown twin (openvibe-shared/agent-layer).++ // The rules that show the picked block of a pick block are written for the page, because there is one per block.head: [o.head, (o.localStyles || []).map((rel) => `<link rel="stylesheet" href="${asset(rel)}">`).join('\n'),++ ...(o.inlineStyles || []).map((css) => `<style>${css}</style>`),agentHeadTags({ canonical: o.canonical || `${config.baseUrl}${o.path || '/'}` })].filter(Boolean).join('\n'),release: RELEASE,mainClass: 'rv-main',header: `<header class="rv-bar"><a class="rv-brand" href="/">${SITE_NAME}</a><form class="rv-search" action="/search" method="get" role="search"><label for="rv-q" class="rv-sr">Search entities</label><input id="rv-q" name="q" type="search" placeholder="Search entities" value="${esc(o.query || '')}"><button type="submit">Search</button></form><noscript><span class="rv-account">${who}</span></noscript></header>`,body: o.body,-- shipped: o.path === '/' ? frame.shipped({ service: 'reviews', title: `Recently shipped on ${SITE_NAME}` }) : '',++ // The shipped list is a part a reader rarely needs: a fold on a phone, the section it was where there is room.++ shipped: o.path === '/' ? foldBlock({ id: 'shipped', head: `<h2 id="rv-shipped-h">Recently shipped on ${SITE_NAME}</h2>`, html: frame.shipped({ service: 'reviews', title: `Recently shipped on ${SITE_NAME}` }) }) : '',});}nnew file mode 100644@@@ -0,0 +1,109 @@++'use strict';++/**++ * The home on a phone (the owner's short-pages rule, 2026-10-30): one or two screens, app-like.++ *++ * pickBlock() several blocks of the same kind, one at a time on a phone, picked with a row of chips. Radio++ * inputs and :has(), no script: every block stays in the page for crawlers, and a wider screen (or a++ * browser without :has()) shows them all and no chips.++ * pickList() a list of things of the same kind, one at a time on a phone, picked with a row of chips.++ * foldBlock() a part a reader rarely needs, closed on a phone and open where there is room. The page's own markup++ * keeps it open, so a browser without the script shows it as before; the script closes it on a narrow++ * screen and remembers the reader's choice.++ *++ * The CSS for both lives in this site's own stylesheet; the rule that shows the picked block is written for the++ * page, because there is one per block.++ */++const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');++++/**++ * A pick block. The panes are the page's own blocks; each one keeps its heading on a wide screen, and on a phone the++ * heading gives way to the chip that names it.++ *++ * pickBlock({ key, label, head, panes }) panes: [{ id, label, html }]++ */++function pickBlock({ key, label, head = '', panes }) {++ // A pane with nothing to show is left out, so the chips and the rules that show them match the page.++ panes = (panes || []).filter((p) => p.html);++ if (!panes.length) return '';++ const chips = panes.map((p, i) => '<input type="radio" name="pick-' + esc(key) + '" id="pk-' + esc(key) + '-' + esc(p.id) + '" value="' + esc(p.id) + '"' + (i === 0 ? ' checked' : '') + '><label for="pk-' + esc(key) + '-' + esc(p.id) + '">' + esc(p.label) + '</label>').join('');++ const body = panes.map((p) => '<div class="pick-pane" id="' + esc(p.id) + '">' + p.html + '</div>').join('');++ return '<div class="pick" id="pick-' + esc(key) + '"><div class="pick-bar"><div class="pick-head"><h2>' + esc(head || label) + '</h2></div><div class="pick-row" role="radiogroup" aria-label="' + esc(label) + '">' + chips + '</div></div><div class="pick-body">' + body + '</div></div>';++}++++/** The rule that shows the picked block of a pick block: one per block, so the CSS is written for the page. */++function pickRule(key, id) {++ return '.pick:has(#pk-' + key + '-' + id + ':checked) #' + id + '{display:block}';++}++++/** The rules for every block of a pick block, in the order they appear. */++function pickRules(key, ids) {++ return ids.map((id) => pickRule(key, id)).join('');++}++++/**++ * A pick list: a list of things of the same kind, one at a time on a phone, picked with a row of chips. The list++ * keeps its own markup and every item stays in the page; the caller marks each item with the index its chip picks.++ *++ * pickList({ key, label, head, labels, html }) labels: the chip labels, in the order of the items++ */++function pickList({ key, label, head = '', labels, html }) {++ labels = labels || [];++ if (!labels.length) return '';++ const chips = labels.map((l, i) => '<input type="radio" name="pick-' + esc(key) + '" id="pk-' + esc(key) + '-' + i + '" value="' + i + '"' + (i === 0 ? ' checked' : '') + '><label for="pk-' + esc(key) + '-' + i + '">' + esc(l) + '</label>').join('');++ return '<div class="pick" id="pick-' + esc(key) + '"><div class="pick-bar"><div class="pick-head"><h2>' + esc(head || label) + '</h2></div><div class="pick-row" role="radiogroup" aria-label="' + esc(label) + '">' + chips + '</div></div>' + html + '</div>';++}++++/** The rule that shows the picked item of a pick list: one per item, so the CSS is written for the page. */++function pickListRule(key, i) {++ return '.pick#pick-' + key + ':has(#pk-' + key + '-' + i + ':checked) .pick-list>li[data-i="' + i + '"]{display:block}';++}++++/** The rules for every item of a pick list. */++function pickListRules(key, n) {++ return Array.from({ length: n }, (_, i) => pickListRule(key, i)).join('');++}++++/**++ * A fold. The head is the heading the block had; the html is what it holds.++ *++ * foldBlock({ id, head, html })++ */++function foldBlock({ id, head, html }) {++ return '<details class="rv-fold rv-fold-block" id="' + esc(id) + '" open><summary>' + head + '<span class="rv-fold-hint">tap to open</span></summary>' + html + '</details>' + foldScript(id);++}++++/** The script a fold carries: closed on a phone unless the reader opened it before, and it remembers that. */++function foldScript(id) {++ return "<script>(function(){var d=document.getElementById(" + JSON.stringify(id) + ");if(!d)return;var k='ov_fold_'+" + JSON.stringify(id) + ";var s=null;try{s=localStorage.getItem(k)}catch(e){}if(s==='open'){d.open=true;return}if(s==='closed'){d.open=false;return}var t=0;if(window.matchMedia&&window.matchMedia('(max-width:640px)').matches){t=Date.now();d.open=false}d.addEventListener('toggle',function(){if(Date.now()-t<300)return;try{localStorage.setItem(k,d.open?'open':'closed')}catch(e){}})})();</script>";++}++++++/**++ * A part of a page's own markup, found by the heading it carries: the site can then show it in a pick block or a++ * fold. { start, end, html } — the section that holds the needle.++ */++function part(html, needle) {++ const at = html.indexOf(needle);++ if (at < 0) return null;++ const start = html.lastIndexOf('<section', at);++ const close = html.indexOf('</section>', at);++ if (start < 0 || close < 0) return null;++ const end = close + '</section>'.length;++ return { start, end, html: html.slice(start, end) };++}++++/** The same part, but replaced by something else. */++function swap(html, needle, withHtml) {++ const p = part(html, needle);++ if (!p) return html;++ return html.slice(0, p.start) + withHtml + html.slice(p.end);++}++++/** The part that carries this heading, as a fold: closed on a phone, the section it was where there is room. */++function foldAround(html, needle, id, head) {++ const p = part(html, needle);++ if (!p) return html;++ return html.slice(0, p.start) + foldBlock({ id, head, html: p.html }) + html.slice(p.end);++}++++module.exports = { pickBlock, pickRule, pickRules, pickList, pickListRule, pickListRules, foldBlock, foldScript, part, swap, foldAround };@@@ -9,6 +9,8 @@*/const ssr = require('openvibe-publishing/ssr');const showcase = require('openvibe-shared/showcase');++// The short home (the owner's rule, 2026-10-30): pick blocks and folds.++const { pickBlock, foldBlock } = require('./short');const { html, raw } = ssr;const t = (v) => raw(ssr.timeTag(v));@@@ -312,6 +314,23 @@ function homeStats(stats, sites) {function home({ entities, total, editor, recent = [], kinds = {}, stats = null, sites = 0, inspections = [], hazardous = [], complaints = [] }) {const browse = ['employer', 'landlord', 'restaurant', 'hosting', 'business', 'organization', 'product', 'app', 'game', 'vehicle', 'community', 'platform'];++ // The short home (the owner's rule, 2026-10-30): the three public-record lists are one pick block, a row of chips++ // picks one of them, and the questions are a fold.++ const records = [++ inspections.length ? { id: 'rv-insp', label: 'Inspections', html: html`<section class="sc-sec" aria-labelledby="rv-insp-h"><h2 id="rv-insp-h">Latest restaurant inspections</h2>++<p class="sc-lede">New York City restaurants by their newest NYC Health inspection, as the inspector recorded it. A letter grade is given only at some inspections.</p>++<ul class="rv-h-list">${inspections.map((x) => html`<li><a href="/e/${e(x.slug)}"><b>${x.name}</b><span class="rv-muted">${[x.cuisine, BORO(x.boro)].filter(Boolean).join(' · ')}</span>++<span>Inspected ${t(x.occurred_on)}: ${n(x.violations)} violation${x.violations === 1 ? '' : 's'}${x.critical ? html`, <b class="rv-h-crit">${n(x.critical)} critical</b>` : ''}</span>${x.grade ? html`<span class="rv-muted">Latest grade on record: ${GRADE_TEXT[x.grade] || `Grade ${x.grade}`}</span>` : ''}</a></li>`)}</ul>++<p><a href="/k/restaurant">Every restaurant (${n(kinds.restaurant || 0)})</a></p></section>` } : null,++ hazardous.length ? { id: 'rv-hpd', label: 'Hazardous buildings', html: html`<section class="sc-sec" aria-labelledby="rv-hpd-h"><h2 id="rv-hpd-h">Buildings with the most hazardous open violations</h2>++<p class="sc-lede">Buildings by their open class C (immediately hazardous) violations in NYC HPD's records. Open means HPD has not recorded a violation as corrected, so the count can run ahead of a building's condition today.</p>++<ol class="rv-h-rank">${hazardous.map((x) => html`<li><a href="/e/${e(x.slug)}"><b>${x.name}</b><span class="rv-muted">${BORO(x.boro)}</span><span>${n(x.c)} class C · ${n(x.open)} open in all</span></a></li>`)}</ol>++<p><a href="/k/landlord">Every building (${n(kinds.landlord || 0)})</a></p></section>` } : null,++ complaints.length ? { id: 'rv-cfpb', label: 'CFPB complaints', html: html`<section class="sc-sec" aria-labelledby="rv-cfpb-h"><h2 id="rv-cfpb-h">Companies with the most CFPB complaints</h2>++<p class="sc-lede">Complaints consumers sent the Consumer Financial Protection Bureau about financial products. They are not findings: the CFPB does not verify them, and a company with more customers gets more.</p>++<ol class="rv-h-rank">${complaints.map((x) => html`<li><a href="/e/${e(x.slug)}"><b>${x.name}</b><span>${n(x.complaints)} complaints</span></a></li>`)}</ol>++<p><a href="/k/organization">Every company (${n(kinds.organization || 0)})</a></p></section>` } : null,++ ].filter(Boolean);return html`${raw(reviewsHero(stats ? homeStats(stats, sites) : null))}<section class="sc-sec" aria-labelledby="rv-kinds-h"><h2 id="rv-kinds-h">Browse and review</h2><ul class="rv-kinds">${browse.map((k) => html`<li><a href="/k/${k}"><b>${KIND_PLURAL[k]}</b><span class="rv-muted">${kinds[k] ? `${n(kinds[k])} here` : 'Be the first'}</span></a></li>`)}</ul>@@@ -322,19 +341,7 @@ ${recent.length ? html`<section class="sc-sec" aria-labelledby="rv-recent-h"><h2${entities.length ? html`<p class="sc-lede">Each score comes from named sources. Open one to see every signal behind it, when it was read, and how the number was worked out.</p><ul class="rv-board">${entities.map(boardCard)}</ul>`: html`<p class="rv-muted">Nothing yet. Entities appear here once a source has stated something about them (or an editor has published a summary).</p>`}<p class="rv-muted">${n(total)} pages in all. <a href="/feed.atom">Atom</a> · <a href="/feed.json">JSON Feed</a>${editor ? html` · <a href="/editor">Editor desk</a>` : ''}</p></section>--${inspections.length ? html`<section class="sc-sec" aria-labelledby="rv-insp-h"><h2 id="rv-insp-h">Latest restaurant inspections</h2>--<p class="sc-lede">New York City restaurants by their newest NYC Health inspection, as the inspector recorded it. A letter grade is given only at some inspections.</p>--<ul class="rv-h-list">${inspections.map((x) => html`<li><a href="/e/${e(x.slug)}"><b>${x.name}</b><span class="rv-muted">${[x.cuisine, BORO(x.boro)].filter(Boolean).join(' · ')}</span>--<span>Inspected ${t(x.occurred_on)}: ${n(x.violations)} violation${x.violations === 1 ? '' : 's'}${x.critical ? html`, <b class="rv-h-crit">${n(x.critical)} critical</b>` : ''}</span>${x.grade ? html`<span class="rv-muted">Latest grade on record: ${GRADE_TEXT[x.grade] || `Grade ${x.grade}`}</span>` : ''}</a></li>`)}</ul>--<p><a href="/k/restaurant">Every restaurant (${n(kinds.restaurant || 0)})</a></p></section>` : ''}--${hazardous.length ? html`<section class="sc-sec" aria-labelledby="rv-hpd-h"><h2 id="rv-hpd-h">Buildings with the most hazardous open violations</h2>--<p class="sc-lede">Buildings by their open class C (immediately hazardous) violations in NYC HPD's records. Open means HPD has not recorded a violation as corrected, so the count can run ahead of a building's condition today.</p>--<ol class="rv-h-rank">${hazardous.map((x) => html`<li><a href="/e/${e(x.slug)}"><b>${x.name}</b><span class="rv-muted">${BORO(x.boro)}</span><span>${n(x.c)} class C · ${n(x.open)} open in all</span></a></li>`)}</ol>--<p><a href="/k/landlord">Every building (${n(kinds.landlord || 0)})</a></p></section>` : ''}--${complaints.length ? html`<section class="sc-sec" aria-labelledby="rv-cfpb-h"><h2 id="rv-cfpb-h">Companies with the most CFPB complaints</h2>--<p class="sc-lede">Complaints consumers sent the Consumer Financial Protection Bureau about financial products. They are not findings: the CFPB does not verify them, and a company with more customers gets more.</p>--<ol class="rv-h-rank">${complaints.map((x) => html`<li><a href="/e/${e(x.slug)}"><b>${x.name}</b><span>${n(x.complaints)} complaints</span></a></li>`)}</ol>--<p><a href="/k/organization">Every company (${n(kinds.organization || 0)})</a></p></section>` : ''}++${raw(pickBlock({ key: 'records', label: 'Public records', head: 'Public records', panes: records }))}<details class="rv-h-fold"><summary>How it works<span>what every page shows, and where the numbers come from</span></summary>${raw(reviewsFeatures())}${raw(showcase.steps({title: 'How it works',items: [@@@ -344,7 +351,7 @@ ${complaints.length ? html`<section class="sc-sec" aria-labelledby="rv-cfpb-h"><{ title: 'Every review site is one click away', text: 'Each page links the review sites that cover its kind (Google, Yelp, Glassdoor, the BBB, Trustpilot, Reddit and more), searched for that name.', href: '/everywhere' },],}))}</details>--<section class="sc-sec rv-h-faq" aria-labelledby="rv-faq-h"><h2 id="rv-faq-h">Questions</h2>${HOME_FAQ.map((f) => html`<details><summary>${f.q}</summary><p>${f.a}</p></details>`)}</section>`;++${raw(foldBlock({ id: 'faq', head: '<h2 id="rv-faq-h">Questions</h2>', html: html`<section class="sc-sec rv-h-faq" aria-labelledby="rv-faq-h"><h2 id="rv-faq-h-in">Questions</h2>${HOME_FAQ.map((f) => html`<details><summary>${f.q}</summary><p>${f.a}</p></details>`)}</section>` }))}`;}/** The front page's structured data: the site and its search, the scored pages as an ItemList, the questions shown. */nnew file mode 100644@@@ -0,0 +1,63 @@++'use strict';++/**++ * The home on a phone (the owner's short-pages rule, 2026-10-30): the public-record lists are one pick block, a row of++ * chips picks one of them, and the questions are a fold. Radio inputs and :has(), no script for the chips; every list++ * stays in the page for crawlers; a wider screen shows them all and no chips.++ */++const assert = require('assert');++const { boot, req, suite, baseItem, importItem } = require('./helpers');++++const t = suite('home-short');++const cfpb = (company, complaints) => baseItem({ source: 'cfpb-complaints-by-company', kind: 'complaint_tally', identity: company, title: company, fields: { company, complaints } });++const nyc = (fields) => baseItem({ source: 'nyc-restaurant-inspections', kind: 'inspection_violation', identity: `row-${Math.random().toString(36).slice(2, 10)}`, title: fields.dba, fields: {++ camis: '50071318', dba: 'THE COFFEE CLUB', boro: 'Brooklyn', building: '9526', street: '4 AVENUE', zipcode: '11209', cuisine: 'Coffee/Tea',++ inspection_type: 'Cycle Inspection / Initial Inspection', action: 'Violations were cited in the following area(s).', critical_flag: 'Not Critical', score: '20', grade: null, ...fields } });++const hpd = (fields) => baseItem({ source: 'nyc-hpd-open-violations', kind: 'housing_violation_tally', identity: fields.buildingid, title: fields.streetname, fields: {++ boro: 'BROOKLYN', zip: '11208', last: '2026-10-12T00:00:00.000', ...fields } });++++t('the records are one pick block: a chip per list, the first picked, every list in the page', async () => {++ const h = await boot();++ try {++ await importItem(h, nyc({ inspection_date: '2026-10-01T00:00:00.000', violation_code: '06D', violation: 'Food contact surface not washed.', critical_flag: 'Critical' }));++ await importItem(h, hpd({ buildingid: '288857', housenumber: '760', streetname: 'ELDERT LANE', open: '1794', c: '423', b: '1118', a: '252' }));++ await importItem(h, cfpb('TRANSUNION INTERMEDIATE HOLDINGS, INC.', 5119486));++ const page = (await req(h, 'GET', '/')).text;++ const start = page.indexOf('<div class="pick" id="pick-records">');++ assert.ok(start > 0, 'the pick block is on the home');++ const block = page.slice(start, page.indexOf('<details class="rv-h-fold"'));++ assert.match(block, /<div class="pick-row" role="radiogroup" aria-label="Public records">/);++ const chips = [...block.matchAll(/<input type="radio" name="pick-records" id="pk-records-([a-z-]+)" value="([a-z-]+)"( checked)?><label for="pk-records-[a-z-]+">([^<]+)<\/label>/g)];++ assert.strictEqual(chips.length, 3, `a chip per list (${chips.length})`);++ assert.deepStrictEqual(chips.filter((m) => m[3]).map((m) => m[1]), [chips[0][1]], 'the first list is picked');++ // Every list stays in the page, each in its own pane.++ for (const id of chips.map((m) => m[1])) assert.ok(block.includes(`id="${id}"`), `the pane of ${id} is in the page`);++ assert.ok(block.includes('The Coffee Club') && block.includes('Eldert Lane') && block.includes('TransUnion'), 'every list is in the page');++ } finally { await h.stop(); }++});++++t('the CSS guard: the chips are hidden and the lists all show until 640 px and where :has() is understood', async () => {++ const h = await boot();++ try {++ await importItem(h, nyc({ inspection_date: '2026-10-01T00:00:00.000', violation_code: '06D', violation: 'Food contact surface not washed.', critical_flag: 'Critical' }));++ const page = (await req(h, 'GET', '/')).text;++ assert.match(page, /<style>@supports selector\(:has\(\*\)\)\{@media \(max-width:640px\)\{\.pick:has\(#pk-records-rv-insp:checked\) #rv-insp\{display:block\}/, 'the rule that shows the picked list');++ const css = require('fs').readFileSync(require('path').join(__dirname, '..', 'public', 'css', 'reviews.css'), 'utf8');++ assert.match(css, /\.pick-row \{ display: none; \}/, 'the chips are hidden by default');++ assert.match(css, /@supports selector\(:has\(\*\)\) \{ @media \(max-width: 640px\) \{/, 'the phone rules are under :has() and 640 px');++ assert.match(css, /\.pick-row label \{[^}]*min-height: 44px/, 'a chip is a 44 px target');++ assert.match(css, /\.pick \.pick-pane \{ display: none; \}/, 'the lists are hidden on a phone');++ } finally { await h.stop(); }++});++++t('the questions are a fold, open in the page and closed on a phone', async () => {++ const h = await boot();++ try {++ const page = (await req(h, 'GET', '/')).text;++ assert.ok(page.includes('<details class="rv-fold rv-fold-block" id="faq" open>'), 'the questions are a fold, open in the page');++ assert.ok(page.includes('document.getElementById("faq")'), 'the fold has the script that closes it on a phone');++ assert.ok(page.includes('<details class="rv-fold rv-fold-block" id="shipped" open>'), 'the shipped list is a fold too');++ // The content of the folds stays in the page.++ assert.match(page, /<h2 id="rv-faq-h">Questions<\/h2>/);++ assert.match(page, /<h2 id="rv-shipped-h">Recently shipped on OpenVibe.Reviews<\/h2>/);++ } finally { await h.stop(); }++});@@@ -13,13 +13,13 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');const BUDGETS = {htmlRawKB: 41, // 36.3 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 33.8 on 2026-10-22 (the front page's numbers, how it works, questions with their FAQPage JSON-LD, links); 28.7 (fresh database; 27.0 before 2026-10-17's three more kinds to browse, 23.7 before 2026-10-15's two more features with their ring icons, 17.8 before the showcase)-- htmlBrotliKB: 9, // 8.0 (2026-10-22 the richer front page); 6.6 (5.9, 4.6 before the showcase)++ htmlBrotliKB: 10, // 9.2 with the short home (2026-10-30: the records' pick block and the fold's scripts); 8.0 (2026-10-22 the richer front page); 6.6 (5.9, 4.6 before the showcase)jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell adds web-runtime)jsRawKB: 322, // 321.1 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 318.1 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 315.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 307.8 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 300.5 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 293 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 287.2 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 273.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 263.4 with shared 4.11.0's seasonal navbar; 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)jsBrotliKB: 79, // 78.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 77.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 76.1 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 74.2 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 70.5 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 67.9 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 65.3 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 61.7 with shared 4.11.0's seasonal navbar; 56.3 (49.9 before web-runtime.js)cssFiles: 2, // 2 (reviews.css, openvibe-shared showcase.css)-- cssRawKB: 44, // 43.2 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 41.4 with 10 touch targets (2026-10-29: the site bar, the search, the questions and three lone links); 40.8 the home's fold and phone list rules (2026-10-29); 36.8 openvibe-shared 4.26.1 (hero visual); 31.3 with Shared 4.13.0 (2026-10-26); 25.4 on 2026-10-22 (the front page's numbers, inspections, ranked records, questions and links); 22.1 with members' reviews, kinds and other review sites (2026-10-17); 19.4 (18.2 before 2026-10-15's scoreboard, 6.7 before showcase.css)-- cssBrotliKB: 9.3, // 9.1 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 8.8 with 10 touch targets (2026-10-29: the site bar, the search, the questions and three lone links); 8.6 the home's fold and phone list rules (2026-10-29); 6.7 with Shared 4.13.0; raised from 8 for openvibe-shared v4.39.0 (+1.5 KB raw of kit/room/showcase CSS); measured 8.2++ cssRawKB: 47, // 46.6 with the short home (2026-10-30: the pick block's and the fold's own rules); 43.2 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 41.4 with 10 touch targets (2026-10-29: the site bar, the search, the questions and three lone links); 40.8 the home's fold and phone list rules (2026-10-29); 36.8 openvibe-shared 4.26.1 (hero visual); 31.3 with Shared 4.13.0 (2026-10-26); 25.4 on 2026-10-22 (the front page's numbers, inspections, ranked records, questions and links); 22.1 with members' reviews, kinds and other review sites (2026-10-17); 19.4 (18.2 before 2026-10-15's scoreboard, 6.7 before showcase.css)++ cssBrotliKB: 10, // 9.9 with the short home (2026-10-30: the pick block's and the fold's own rules); 9.1 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 8.8 with 10 touch targets (2026-10-29: the site bar, the search, the questions and three lone links); 8.6 the home's fold and phone list rules (2026-10-29); 6.7 with Shared 4.13.0; raised from 8 for openvibe-shared v4.39.0 (+1.5 KB raw of kit/room/showcase CSS); measured 8.2externalFiles: 1, // 0};
@@ -194,3 +194,57 @@ main .rv-h-panel-h { margin: 0 0 12px; padding: 0; border: 0; font-size: 1rem; }
@@ -194,3 +194,57 @@ main .rv-h-panel-h { margin: 0 0 12px; padding: 0; border: 0; font-size: 1rem; }
.rv-brand::after { content: ''; position: absolute; left: 0; right: 0; top: -10px; bottom: -10px; }
.rv-brand::after { content: ''; position: absolute; left: 0; right: 0; top: -10px; bottom: -10px; }
.rv-main p > a:only-child::after { content: ''; position: absolute; left: 0; right: 0; top: -13px; bottom: -13px; }
.rv-main p > a:only-child::after { content: ''; position: absolute; left: 0; right: 0; top: -13px; bottom: -13px; }
}
}
+
+
+/* Pick (the owner's short-pages rule, 2026-10-30): on a phone the front page shows one block at a time, picked with a
+ row of chips. Radio inputs and :has(), no script: the chips and the one-block rule live under 640 px and only
+ where :has() is understood, so a wider screen, or an older browser, shows every block and no chips. */
+.pick-head { display: none; }
+.pick-row { display: none; }
+@supports selector(:has(*)) { @media (max-width: 640px) {
+ .pick-bar { margin-top: 0; }
+ .pick-head { display: block; }
+ .pick-head h2 { margin: 0 0 4px; font: 700 clamp(20px,2.4vw,28px)/1.2 var(--ov-font,'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif); letter-spacing: -.02em; }
+ .pick-row { display: flex; gap: 8px; overflow-x: auto; margin: 10px -16px 0; padding: 2px 16px 4px; scrollbar-width: none; overscroll-behavior-x: contain; }
+ .pick-row::-webkit-scrollbar { display: none; }
+ .pick-row input { position: absolute; opacity: 0; width: 1px; height: 1px; pointer-events: none; }
+ .pick-row label { display: inline-flex; align-items: center; flex: none; min-height: 44px; padding: 0 15px; border-radius: 999px; border: 1px solid var(--border, #1f2d47); color: var(--text-secondary, #96a7c2); font-size: .9rem; font-weight: 650; white-space: nowrap; cursor: pointer; }
+ .pick-row input:checked + label { color: var(--text-primary, #e6edf7); border-color: var(--accent, #3b82f6); background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); }
+ .pick-row input:focus-visible + label { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
+ .pick .pick-pane { display: none; }
+ .pick .pick-list > li { display: none; }
+ /* The chip names the block, so the block's own heading gives way to it; the content stays in the page for the
+ crawlers and for the reader on a wide screen. */
+ .pick .sc-sec > h2, .pick section > h2, .pick > h2 { display: none; }
+} }
+
+/* The fold (the owner's short-pages rule): closed on a phone, and where there is room it looks like the section it
+ replaces: a heading and its content, with no card around it. */
+.rv-fold-block { margin-top: 32px; }
+.rv-fold-block > summary h2 { margin: 0; font: 700 clamp(19px,2.2vw,26px)/1.2 var(--ov-font,'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif); }
+.rv-fold-hint { display: none; }
+/* The fold's summary carries the heading, so the section's own heading gives way to it. */
+.rv-fold-block .sc-sec > h2, .rv-fold-block > .sc-sec > h2 { display: none; }
+@media (min-width: 641px) {
+ .rv-fold-block { border: 0; background: none; border-radius: 0; margin-top: 56px; padding: 0; }
+ .rv-fold-block > summary { display: block; min-height: 0; padding: 0; }
+ .rv-fold-block > summary h2 { margin: 0 0 6px; font: 700 clamp(22px,2.6vw,30px)/1.2 var(--ov-font,'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif); }
+ .rv-fold-block > summary span { display: none; }
+ .rv-fold-block .sc-sec { margin: 0; padding: 0; }
+}
+@media (max-width: 640px) {
+ .rv-fold-block { margin-top: 24px; }
+ .rv-fold-block > summary { cursor: pointer; }
+ .rv-fold-hint { display: inline; margin-left: 8px; font-size: .78rem; font-weight: 600; color: var(--text-secondary, #96a7c2); }
+}
+
+/* A link that points into a pick block opens it: the pane carries the id the link names. */
+@supports selector(:has(*)) { @media (max-width: 640px) {
+ .pick .pick-pane:target { display: block; }
+} }
+
+/* On a wide screen the pick block is invisible: its blocks sit where they were, in the order they were. */
+@media (min-width: 641px) {
+ .pick, .pick-body, .pick-pane { display: contents; }
+ .pick-bar { display: none; }
+}
@@ -28,6 +28,8 @@ const frame = require('openvibe-shared/frame');
@@ -28,6 +28,8 @@ const frame = require('openvibe-shared/frame');
const seo = require('openvibe-publishing/seo');
const seo = require('openvibe-publishing/seo');
const { renderPage, SITE_SUMMARY } = require('../render/layout');
const { renderPage, SITE_SUMMARY } = require('../render/layout');
const views = require('../render/views');
const views = require('../render/views');
+// The short home (the owner's rule, 2026-10-30): pick blocks and folds.
+const { pickRules } = require('../render/short');
const { ratingForStructuredData } = require('../reviews/aggregate');
const { ratingForStructuredData } = require('../reviews/aggregate');
const { actorMiddleware, crossSite } = require('./common');
const { actorMiddleware, crossSite } = require('./common');
const everywhere = require('../reviews/everywhere');
const everywhere = require('../reviews/everywhere');
@@ -156,9 +158,12 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
@@ -156,9 +158,12 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
svc.listEntities({ limit: 1 }), svc.entitiesWithData(60), svc.recentMemberReviews(6), svc.kindCounts(),
svc.listEntities({ limit: 1 }), svc.entitiesWithData(60), svc.recentMemberReviews(6), svc.kindCounts(),
home ? home.stats() : null, home ? home.latestInspections(8) : [], home ? home.mostHazardous(6) : [], home ? home.mostComplaints(6) : [],
home ? home.stats() : null, home ? home.latestInspections(8) : [], home ? home.mostHazardous(6) : [], home ? home.mostComplaints(6) : [],
]);
]);
+ // The rule that shows the picked block of the records' pick block: one per block, written for this page.
+ const recordIds = [inspections.length ? 'rv-insp' : null, hazardous.length ? 'rv-hpd' : null, complaints.length ? 'rv-cfpb' : null].filter(Boolean);
send(req, res, 200, views.home({ entities, total: all.total, editor: isEditor(req), recent, kinds, stats, sites: everywhere.SITES.length, inspections, hazardous, complaints }), {
send(req, res, 200, views.home({ entities, total: all.total, editor: isEditor(req), recent, kinds, stats, sites: everywhere.SITES.length, inspections, hazardous, complaints }), {
robots: 'index, follow', cache: 'public', active: 'home', path: '/', summary: SITE_SUMMARY, styles: [showcase.STYLESHEET], description: HOME_DESCRIPTION,
robots: 'index, follow', cache: 'public', active: 'home', path: '/', summary: SITE_SUMMARY, styles: [showcase.STYLESHEET], description: HOME_DESCRIPTION,
jsonLd: views.homeJsonLd({ base: config.baseUrl.replace(/\/+$/, ''), entities }),
jsonLd: views.homeJsonLd({ base: config.baseUrl.replace(/\/+$/, ''), entities }),
+ inlineStyles: [`@supports selector(:has(*)){@media (max-width:640px){${pickRules('records', recordIds)}}}`],
});
});
}));
}));
// What shipped on OpenVibe.Reviews: the shared update log every OpenVibe site has.
// What shipped on OpenVibe.Reviews: the shared update log every OpenVibe site has.
@@ -12,6 +12,7 @@ const fs = require('fs');
@@ -12,6 +12,7 @@ const fs = require('fs');
const path = require('path');
const path = require('path');
const layout = require('openvibe-publishing/layout');
const layout = require('openvibe-publishing/layout');
const frame = require('openvibe-shared/frame');
const frame = require('openvibe-shared/frame');
+const { foldBlock } = require('./short');
const { escapeHtml: esc } = require('openvibe-publishing/ssr');
const { escapeHtml: esc } = require('openvibe-publishing/ssr');
const SITE_NAME = 'OpenVibe.Reviews';
const SITE_NAME = 'OpenVibe.Reviews';
@@ -107,13 +108,16 @@ function renderPage(o) {
@@ -107,13 +108,16 @@ function renderPage(o) {
// This service's own extra stylesheets for one page (/everywhere's css/everywhere.css).
// This service's own extra stylesheets for one page (/everywhere's css/everywhere.css).
// The extra head HTML: the page's own stylesheets, whatever the page passed, and the link that
// The extra head HTML: the page's own stylesheets, whatever the page passed, and the link that
// names this page's Markdown twin (openvibe-shared/agent-layer).
// names this page's Markdown twin (openvibe-shared/agent-layer).
+ // The rules that show the picked block of a pick block are written for the page, because there is one per block.
head: [o.head, (o.localStyles || []).map((rel) => `<link rel="stylesheet" href="${asset(rel)}">`).join('\n'),
head: [o.head, (o.localStyles || []).map((rel) => `<link rel="stylesheet" href="${asset(rel)}">`).join('\n'),
+ ...(o.inlineStyles || []).map((css) => `<style>${css}</style>`),
agentHeadTags({ canonical: o.canonical || `${config.baseUrl}${o.path || '/'}` })].filter(Boolean).join('\n'),
agentHeadTags({ canonical: o.canonical || `${config.baseUrl}${o.path || '/'}` })].filter(Boolean).join('\n'),
release: RELEASE,
release: RELEASE,
mainClass: 'rv-main',
mainClass: 'rv-main',
header: `<header class="rv-bar"><a class="rv-brand" href="/">${SITE_NAME}</a><form class="rv-search" action="/search" method="get" role="search"><label for="rv-q" class="rv-sr">Search entities</label><input id="rv-q" name="q" type="search" placeholder="Search entities" value="${esc(o.query || '')}"><button type="submit">Search</button></form><noscript><span class="rv-account">${who}</span></noscript></header>`,
header: `<header class="rv-bar"><a class="rv-brand" href="/">${SITE_NAME}</a><form class="rv-search" action="/search" method="get" role="search"><label for="rv-q" class="rv-sr">Search entities</label><input id="rv-q" name="q" type="search" placeholder="Search entities" value="${esc(o.query || '')}"><button type="submit">Search</button></form><noscript><span class="rv-account">${who}</span></noscript></header>`,
body: o.body,
body: o.body,
- shipped: o.path === '/' ? frame.shipped({ service: 'reviews', title: `Recently shipped on ${SITE_NAME}` }) : '',
+ // The shipped list is a part a reader rarely needs: a fold on a phone, the section it was where there is room.
+ shipped: o.path === '/' ? foldBlock({ id: 'shipped', head: `<h2 id="rv-shipped-h">Recently shipped on ${SITE_NAME}</h2>`, html: frame.shipped({ service: 'reviews', title: `Recently shipped on ${SITE_NAME}` }) }) : '',
});
});
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,109 @@
@@ -0,0 +1,109 @@
+'use strict';
+/**
+ * The home on a phone (the owner's short-pages rule, 2026-10-30): one or two screens, app-like.
+ *
+ * pickBlock() several blocks of the same kind, one at a time on a phone, picked with a row of chips. Radio
+ * inputs and :has(), no script: every block stays in the page for crawlers, and a wider screen (or a
+ * browser without :has()) shows them all and no chips.
+ * pickList() a list of things of the same kind, one at a time on a phone, picked with a row of chips.
+ * foldBlock() a part a reader rarely needs, closed on a phone and open where there is room. The page's own markup
+ * keeps it open, so a browser without the script shows it as before; the script closes it on a narrow
+ * screen and remembers the reader's choice.
+ *
+ * The CSS for both lives in this site's own stylesheet; the rule that shows the picked block is written for the
+ * page, because there is one per block.
+ */
+const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
+
+/**
+ * A pick block. The panes are the page's own blocks; each one keeps its heading on a wide screen, and on a phone the
+ * heading gives way to the chip that names it.
+ *
+ * pickBlock({ key, label, head, panes }) panes: [{ id, label, html }]
+ */
+function pickBlock({ key, label, head = '', panes }) {
+ // A pane with nothing to show is left out, so the chips and the rules that show them match the page.
+ panes = (panes || []).filter((p) => p.html);
+ if (!panes.length) return '';
+ const chips = panes.map((p, i) => '<input type="radio" name="pick-' + esc(key) + '" id="pk-' + esc(key) + '-' + esc(p.id) + '" value="' + esc(p.id) + '"' + (i === 0 ? ' checked' : '') + '><label for="pk-' + esc(key) + '-' + esc(p.id) + '">' + esc(p.label) + '</label>').join('');
+ const body = panes.map((p) => '<div class="pick-pane" id="' + esc(p.id) + '">' + p.html + '</div>').join('');
+ return '<div class="pick" id="pick-' + esc(key) + '"><div class="pick-bar"><div class="pick-head"><h2>' + esc(head || label) + '</h2></div><div class="pick-row" role="radiogroup" aria-label="' + esc(label) + '">' + chips + '</div></div><div class="pick-body">' + body + '</div></div>';
+}
+
+/** The rule that shows the picked block of a pick block: one per block, so the CSS is written for the page. */
+function pickRule(key, id) {
+ return '.pick:has(#pk-' + key + '-' + id + ':checked) #' + id + '{display:block}';
+}
+
+/** The rules for every block of a pick block, in the order they appear. */
+function pickRules(key, ids) {
+ return ids.map((id) => pickRule(key, id)).join('');
+}
+
+/**
+ * A pick list: a list of things of the same kind, one at a time on a phone, picked with a row of chips. The list
+ * keeps its own markup and every item stays in the page; the caller marks each item with the index its chip picks.
+ *
+ * pickList({ key, label, head, labels, html }) labels: the chip labels, in the order of the items
+ */
+function pickList({ key, label, head = '', labels, html }) {
+ labels = labels || [];
+ if (!labels.length) return '';
+ const chips = labels.map((l, i) => '<input type="radio" name="pick-' + esc(key) + '" id="pk-' + esc(key) + '-' + i + '" value="' + i + '"' + (i === 0 ? ' checked' : '') + '><label for="pk-' + esc(key) + '-' + i + '">' + esc(l) + '</label>').join('');
+ return '<div class="pick" id="pick-' + esc(key) + '"><div class="pick-bar"><div class="pick-head"><h2>' + esc(head || label) + '</h2></div><div class="pick-row" role="radiogroup" aria-label="' + esc(label) + '">' + chips + '</div></div>' + html + '</div>';
+}
+
+/** The rule that shows the picked item of a pick list: one per item, so the CSS is written for the page. */
+function pickListRule(key, i) {
+ return '.pick#pick-' + key + ':has(#pk-' + key + '-' + i + ':checked) .pick-list>li[data-i="' + i + '"]{display:block}';
+}
+
+/** The rules for every item of a pick list. */
+function pickListRules(key, n) {
+ return Array.from({ length: n }, (_, i) => pickListRule(key, i)).join('');
+}
+
+/**
+ * A fold. The head is the heading the block had; the html is what it holds.
+ *
+ * foldBlock({ id, head, html })
+ */
+function foldBlock({ id, head, html }) {
+ return '<details class="rv-fold rv-fold-block" id="' + esc(id) + '" open><summary>' + head + '<span class="rv-fold-hint">tap to open</span></summary>' + html + '</details>' + foldScript(id);
+}
+
+/** The script a fold carries: closed on a phone unless the reader opened it before, and it remembers that. */
+function foldScript(id) {
+ return "<script>(function(){var d=document.getElementById(" + JSON.stringify(id) + ");if(!d)return;var k='ov_fold_'+" + JSON.stringify(id) + ";var s=null;try{s=localStorage.getItem(k)}catch(e){}if(s==='open'){d.open=true;return}if(s==='closed'){d.open=false;return}var t=0;if(window.matchMedia&&window.matchMedia('(max-width:640px)').matches){t=Date.now();d.open=false}d.addEventListener('toggle',function(){if(Date.now()-t<300)return;try{localStorage.setItem(k,d.open?'open':'closed')}catch(e){}})})();</script>";
+}
+
+
+/**
+ * A part of a page's own markup, found by the heading it carries: the site can then show it in a pick block or a
+ * fold. { start, end, html } — the section that holds the needle.
+ */
+function part(html, needle) {
+ const at = html.indexOf(needle);
+ if (at < 0) return null;
+ const start = html.lastIndexOf('<section', at);
+ const close = html.indexOf('</section>', at);
+ if (start < 0 || close < 0) return null;
+ const end = close + '</section>'.length;
+ return { start, end, html: html.slice(start, end) };
+}
+
+/** The same part, but replaced by something else. */
+function swap(html, needle, withHtml) {
+ const p = part(html, needle);
+ if (!p) return html;
+ return html.slice(0, p.start) + withHtml + html.slice(p.end);
+}
+
+/** The part that carries this heading, as a fold: closed on a phone, the section it was where there is room. */
+function foldAround(html, needle, id, head) {
+ const p = part(html, needle);
+ if (!p) return html;
+ return html.slice(0, p.start) + foldBlock({ id, head, html: p.html }) + html.slice(p.end);
+}
+
+module.exports = { pickBlock, pickRule, pickRules, pickList, pickListRule, pickListRules, foldBlock, foldScript, part, swap, foldAround };
@@ -9,6 +9,8 @@
@@ -9,6 +9,8 @@
*/
*/
const ssr = require('openvibe-publishing/ssr');
const ssr = require('openvibe-publishing/ssr');
const showcase = require('openvibe-shared/showcase');
const showcase = require('openvibe-shared/showcase');
+// The short home (the owner's rule, 2026-10-30): pick blocks and folds.
+const { pickBlock, foldBlock } = require('./short');
const { html, raw } = ssr;
const { html, raw } = ssr;
const t = (v) => raw(ssr.timeTag(v));
const t = (v) => raw(ssr.timeTag(v));
@@ -312,6 +314,23 @@ function homeStats(stats, sites) {
@@ -312,6 +314,23 @@ function homeStats(stats, sites) {
function home({ entities, total, editor, recent = [], kinds = {}, stats = null, sites = 0, inspections = [], hazardous = [], complaints = [] }) {
function home({ entities, total, editor, recent = [], kinds = {}, stats = null, sites = 0, inspections = [], hazardous = [], complaints = [] }) {
const browse = ['employer', 'landlord', 'restaurant', 'hosting', 'business', 'organization', 'product', 'app', 'game', 'vehicle', 'community', 'platform'];
const browse = ['employer', 'landlord', 'restaurant', 'hosting', 'business', 'organization', 'product', 'app', 'game', 'vehicle', 'community', 'platform'];
+ // The short home (the owner's rule, 2026-10-30): the three public-record lists are one pick block, a row of chips
+ // picks one of them, and the questions are a fold.
+ const records = [
+ inspections.length ? { id: 'rv-insp', label: 'Inspections', html: html`<section class="sc-sec" aria-labelledby="rv-insp-h"><h2 id="rv-insp-h">Latest restaurant inspections</h2>
+<p class="sc-lede">New York City restaurants by their newest NYC Health inspection, as the inspector recorded it. A letter grade is given only at some inspections.</p>
+<ul class="rv-h-list">${inspections.map((x) => html`<li><a href="/e/${e(x.slug)}"><b>${x.name}</b><span class="rv-muted">${[x.cuisine, BORO(x.boro)].filter(Boolean).join(' · ')}</span>
+<span>Inspected ${t(x.occurred_on)}: ${n(x.violations)} violation${x.violations === 1 ? '' : 's'}${x.critical ? html`, <b class="rv-h-crit">${n(x.critical)} critical</b>` : ''}</span>${x.grade ? html`<span class="rv-muted">Latest grade on record: ${GRADE_TEXT[x.grade] || `Grade ${x.grade}`}</span>` : ''}</a></li>`)}</ul>
+<p><a href="/k/restaurant">Every restaurant (${n(kinds.restaurant || 0)})</a></p></section>` } : null,
+ hazardous.length ? { id: 'rv-hpd', label: 'Hazardous buildings', html: html`<section class="sc-sec" aria-labelledby="rv-hpd-h"><h2 id="rv-hpd-h">Buildings with the most hazardous open violations</h2>
+<p class="sc-lede">Buildings by their open class C (immediately hazardous) violations in NYC HPD's records. Open means HPD has not recorded a violation as corrected, so the count can run ahead of a building's condition today.</p>
+<ol class="rv-h-rank">${hazardous.map((x) => html`<li><a href="/e/${e(x.slug)}"><b>${x.name}</b><span class="rv-muted">${BORO(x.boro)}</span><span>${n(x.c)} class C · ${n(x.open)} open in all</span></a></li>`)}</ol>
+<p><a href="/k/landlord">Every building (${n(kinds.landlord || 0)})</a></p></section>` } : null,
+ complaints.length ? { id: 'rv-cfpb', label: 'CFPB complaints', html: html`<section class="sc-sec" aria-labelledby="rv-cfpb-h"><h2 id="rv-cfpb-h">Companies with the most CFPB complaints</h2>
+<p class="sc-lede">Complaints consumers sent the Consumer Financial Protection Bureau about financial products. They are not findings: the CFPB does not verify them, and a company with more customers gets more.</p>
+<ol class="rv-h-rank">${complaints.map((x) => html`<li><a href="/e/${e(x.slug)}"><b>${x.name}</b><span>${n(x.complaints)} complaints</span></a></li>`)}</ol>
+<p><a href="/k/organization">Every company (${n(kinds.organization || 0)})</a></p></section>` } : null,
+ ].filter(Boolean);
return html`${raw(reviewsHero(stats ? homeStats(stats, sites) : null))}
return html`${raw(reviewsHero(stats ? homeStats(stats, sites) : null))}
<section class="sc-sec" aria-labelledby="rv-kinds-h"><h2 id="rv-kinds-h">Browse and review</h2>
<section class="sc-sec" aria-labelledby="rv-kinds-h"><h2 id="rv-kinds-h">Browse and review</h2>
<ul class="rv-kinds">${browse.map((k) => html`<li><a href="/k/${k}"><b>${KIND_PLURAL[k]}</b><span class="rv-muted">${kinds[k] ? `${n(kinds[k])} here` : 'Be the first'}</span></a></li>`)}</ul>
<ul class="rv-kinds">${browse.map((k) => html`<li><a href="/k/${k}"><b>${KIND_PLURAL[k]}</b><span class="rv-muted">${kinds[k] ? `${n(kinds[k])} here` : 'Be the first'}</span></a></li>`)}</ul>
@@ -322,19 +341,7 @@ ${recent.length ? html`<section class="sc-sec" aria-labelledby="rv-recent-h"><h2
@@ -322,19 +341,7 @@ ${recent.length ? html`<section class="sc-sec" aria-labelledby="rv-recent-h"><h2
${entities.length ? html`<p class="sc-lede">Each score comes from named sources. Open one to see every signal behind it, when it was read, and how the number was worked out.</p><ul class="rv-board">${entities.map(boardCard)}</ul>`
${entities.length ? html`<p class="sc-lede">Each score comes from named sources. Open one to see every signal behind it, when it was read, and how the number was worked out.</p><ul class="rv-board">${entities.map(boardCard)}</ul>`
: html`<p class="rv-muted">Nothing yet. Entities appear here once a source has stated something about them (or an editor has published a summary).</p>`}
: html`<p class="rv-muted">Nothing yet. Entities appear here once a source has stated something about them (or an editor has published a summary).</p>`}
<p class="rv-muted">${n(total)} pages in all. <a href="/feed.atom">Atom</a> · <a href="/feed.json">JSON Feed</a>${editor ? html` · <a href="/editor">Editor desk</a>` : ''}</p></section>
<p class="rv-muted">${n(total)} pages in all. <a href="/feed.atom">Atom</a> · <a href="/feed.json">JSON Feed</a>${editor ? html` · <a href="/editor">Editor desk</a>` : ''}</p></section>
-${inspections.length ? html`<section class="sc-sec" aria-labelledby="rv-insp-h"><h2 id="rv-insp-h">Latest restaurant inspections</h2>
-<p class="sc-lede">New York City restaurants by their newest NYC Health inspection, as the inspector recorded it. A letter grade is given only at some inspections.</p>
-<ul class="rv-h-list">${inspections.map((x) => html`<li><a href="/e/${e(x.slug)}"><b>${x.name}</b><span class="rv-muted">${[x.cuisine, BORO(x.boro)].filter(Boolean).join(' · ')}</span>
-<span>Inspected ${t(x.occurred_on)}: ${n(x.violations)} violation${x.violations === 1 ? '' : 's'}${x.critical ? html`, <b class="rv-h-crit">${n(x.critical)} critical</b>` : ''}</span>${x.grade ? html`<span class="rv-muted">Latest grade on record: ${GRADE_TEXT[x.grade] || `Grade ${x.grade}`}</span>` : ''}</a></li>`)}</ul>
-<p><a href="/k/restaurant">Every restaurant (${n(kinds.restaurant || 0)})</a></p></section>` : ''}
-${hazardous.length ? html`<section class="sc-sec" aria-labelledby="rv-hpd-h"><h2 id="rv-hpd-h">Buildings with the most hazardous open violations</h2>
-<p class="sc-lede">Buildings by their open class C (immediately hazardous) violations in NYC HPD's records. Open means HPD has not recorded a violation as corrected, so the count can run ahead of a building's condition today.</p>
-<ol class="rv-h-rank">${hazardous.map((x) => html`<li><a href="/e/${e(x.slug)}"><b>${x.name}</b><span class="rv-muted">${BORO(x.boro)}</span><span>${n(x.c)} class C · ${n(x.open)} open in all</span></a></li>`)}</ol>
-<p><a href="/k/landlord">Every building (${n(kinds.landlord || 0)})</a></p></section>` : ''}
-${complaints.length ? html`<section class="sc-sec" aria-labelledby="rv-cfpb-h"><h2 id="rv-cfpb-h">Companies with the most CFPB complaints</h2>
-<p class="sc-lede">Complaints consumers sent the Consumer Financial Protection Bureau about financial products. They are not findings: the CFPB does not verify them, and a company with more customers gets more.</p>
-<ol class="rv-h-rank">${complaints.map((x) => html`<li><a href="/e/${e(x.slug)}"><b>${x.name}</b><span>${n(x.complaints)} complaints</span></a></li>`)}</ol>
-<p><a href="/k/organization">Every company (${n(kinds.organization || 0)})</a></p></section>` : ''}
+${raw(pickBlock({ key: 'records', label: 'Public records', head: 'Public records', panes: records }))}
<details class="rv-h-fold"><summary>How it works<span>what every page shows, and where the numbers come from</span></summary>${raw(reviewsFeatures())}${raw(showcase.steps({
<details class="rv-h-fold"><summary>How it works<span>what every page shows, and where the numbers come from</span></summary>${raw(reviewsFeatures())}${raw(showcase.steps({
title: 'How it works',
title: 'How it works',
items: [
items: [
@@ -344,7 +351,7 @@ ${complaints.length ? html`<section class="sc-sec" aria-labelledby="rv-cfpb-h"><
@@ -344,7 +351,7 @@ ${complaints.length ? html`<section class="sc-sec" aria-labelledby="rv-cfpb-h"><
{ title: 'Every review site is one click away', text: 'Each page links the review sites that cover its kind (Google, Yelp, Glassdoor, the BBB, Trustpilot, Reddit and more), searched for that name.', href: '/everywhere' },
{ title: 'Every review site is one click away', text: 'Each page links the review sites that cover its kind (Google, Yelp, Glassdoor, the BBB, Trustpilot, Reddit and more), searched for that name.', href: '/everywhere' },
],
],
}))}</details>
}))}</details>
-<section class="sc-sec rv-h-faq" aria-labelledby="rv-faq-h"><h2 id="rv-faq-h">Questions</h2>${HOME_FAQ.map((f) => html`<details><summary>${f.q}</summary><p>${f.a}</p></details>`)}</section>`;
+${raw(foldBlock({ id: 'faq', head: '<h2 id="rv-faq-h">Questions</h2>', html: html`<section class="sc-sec rv-h-faq" aria-labelledby="rv-faq-h"><h2 id="rv-faq-h-in">Questions</h2>${HOME_FAQ.map((f) => html`<details><summary>${f.q}</summary><p>${f.a}</p></details>`)}</section>` }))}`;
}
}
/** The front page's structured data: the site and its search, the scored pages as an ItemList, the questions shown. */
/** The front page's structured data: the site and its search, the scored pages as an ItemList, the questions shown. */
new file mode 100644
new file mode 100644
@@ -0,0 +1,63 @@
@@ -0,0 +1,63 @@
+'use strict';
+/**
+ * The home on a phone (the owner's short-pages rule, 2026-10-30): the public-record lists are one pick block, a row of
+ * chips picks one of them, and the questions are a fold. Radio inputs and :has(), no script for the chips; every list
+ * stays in the page for crawlers; a wider screen shows them all and no chips.
+ */
+const assert = require('assert');
+const { boot, req, suite, baseItem, importItem } = require('./helpers');
+
+const t = suite('home-short');
+const cfpb = (company, complaints) => baseItem({ source: 'cfpb-complaints-by-company', kind: 'complaint_tally', identity: company, title: company, fields: { company, complaints } });
+const nyc = (fields) => baseItem({ source: 'nyc-restaurant-inspections', kind: 'inspection_violation', identity: `row-${Math.random().toString(36).slice(2, 10)}`, title: fields.dba, fields: {
+ camis: '50071318', dba: 'THE COFFEE CLUB', boro: 'Brooklyn', building: '9526', street: '4 AVENUE', zipcode: '11209', cuisine: 'Coffee/Tea',
+ inspection_type: 'Cycle Inspection / Initial Inspection', action: 'Violations were cited in the following area(s).', critical_flag: 'Not Critical', score: '20', grade: null, ...fields } });
+const hpd = (fields) => baseItem({ source: 'nyc-hpd-open-violations', kind: 'housing_violation_tally', identity: fields.buildingid, title: fields.streetname, fields: {
+ boro: 'BROOKLYN', zip: '11208', last: '2026-10-12T00:00:00.000', ...fields } });
+
+t('the records are one pick block: a chip per list, the first picked, every list in the page', async () => {
+ const h = await boot();
+ try {
+ await importItem(h, nyc({ inspection_date: '2026-10-01T00:00:00.000', violation_code: '06D', violation: 'Food contact surface not washed.', critical_flag: 'Critical' }));
+ await importItem(h, hpd({ buildingid: '288857', housenumber: '760', streetname: 'ELDERT LANE', open: '1794', c: '423', b: '1118', a: '252' }));
+ await importItem(h, cfpb('TRANSUNION INTERMEDIATE HOLDINGS, INC.', 5119486));
+ const page = (await req(h, 'GET', '/')).text;
+ const start = page.indexOf('<div class="pick" id="pick-records">');
+ assert.ok(start > 0, 'the pick block is on the home');
+ const block = page.slice(start, page.indexOf('<details class="rv-h-fold"'));
+ assert.match(block, /<div class="pick-row" role="radiogroup" aria-label="Public records">/);
+ const chips = [...block.matchAll(/<input type="radio" name="pick-records" id="pk-records-([a-z-]+)" value="([a-z-]+)"( checked)?><label for="pk-records-[a-z-]+">([^<]+)<\/label>/g)];
+ assert.strictEqual(chips.length, 3, `a chip per list (${chips.length})`);
+ assert.deepStrictEqual(chips.filter((m) => m[3]).map((m) => m[1]), [chips[0][1]], 'the first list is picked');
+ // Every list stays in the page, each in its own pane.
+ for (const id of chips.map((m) => m[1])) assert.ok(block.includes(`id="${id}"`), `the pane of ${id} is in the page`);
+ assert.ok(block.includes('The Coffee Club') && block.includes('Eldert Lane') && block.includes('TransUnion'), 'every list is in the page');
+ } finally { await h.stop(); }
+});
+
+t('the CSS guard: the chips are hidden and the lists all show until 640 px and where :has() is understood', async () => {
+ const h = await boot();
+ try {
+ await importItem(h, nyc({ inspection_date: '2026-10-01T00:00:00.000', violation_code: '06D', violation: 'Food contact surface not washed.', critical_flag: 'Critical' }));
+ const page = (await req(h, 'GET', '/')).text;
+ assert.match(page, /<style>@supports selector\(:has\(\*\)\)\{@media \(max-width:640px\)\{\.pick:has\(#pk-records-rv-insp:checked\) #rv-insp\{display:block\}/, 'the rule that shows the picked list');
+ const css = require('fs').readFileSync(require('path').join(__dirname, '..', 'public', 'css', 'reviews.css'), 'utf8');
+ assert.match(css, /\.pick-row \{ display: none; \}/, 'the chips are hidden by default');
+ assert.match(css, /@supports selector\(:has\(\*\)\) \{ @media \(max-width: 640px\) \{/, 'the phone rules are under :has() and 640 px');
+ assert.match(css, /\.pick-row label \{[^}]*min-height: 44px/, 'a chip is a 44 px target');
+ assert.match(css, /\.pick \.pick-pane \{ display: none; \}/, 'the lists are hidden on a phone');
+ } finally { await h.stop(); }
+});
+
+t('the questions are a fold, open in the page and closed on a phone', async () => {
+ const h = await boot();
+ try {
+ const page = (await req(h, 'GET', '/')).text;
+ assert.ok(page.includes('<details class="rv-fold rv-fold-block" id="faq" open>'), 'the questions are a fold, open in the page');
+ assert.ok(page.includes('document.getElementById("faq")'), 'the fold has the script that closes it on a phone');
+ assert.ok(page.includes('<details class="rv-fold rv-fold-block" id="shipped" open>'), 'the shipped list is a fold too');
+ // The content of the folds stays in the page.
+ assert.match(page, /<h2 id="rv-faq-h">Questions<\/h2>/);
+ assert.match(page, /<h2 id="rv-shipped-h">Recently shipped on OpenVibe.Reviews<\/h2>/);
+ } finally { await h.stop(); }
+});
@@ -13,13 +13,13 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
@@ -13,13 +13,13 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
const BUDGETS = {
const BUDGETS = {
htmlRawKB: 41, // 36.3 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 33.8 on 2026-10-22 (the front page's numbers, how it works, questions with their FAQPage JSON-LD, links); 28.7 (fresh database; 27.0 before 2026-10-17's three more kinds to browse, 23.7 before 2026-10-15's two more features with their ring icons, 17.8 before the showcase)
htmlRawKB: 41, // 36.3 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 33.8 on 2026-10-22 (the front page's numbers, how it works, questions with their FAQPage JSON-LD, links); 28.7 (fresh database; 27.0 before 2026-10-17's three more kinds to browse, 23.7 before 2026-10-15's two more features with their ring icons, 17.8 before the showcase)
- htmlBrotliKB: 9, // 8.0 (2026-10-22 the richer front page); 6.6 (5.9, 4.6 before the showcase)
+ htmlBrotliKB: 10, // 9.2 with the short home (2026-10-30: the records' pick block and the fold's scripts); 8.0 (2026-10-22 the richer front page); 6.6 (5.9, 4.6 before the showcase)
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell adds web-runtime)
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell adds web-runtime)
jsRawKB: 322, // 321.1 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 318.1 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 315.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 307.8 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 300.5 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 293 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 287.2 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 273.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 263.4 with shared 4.11.0's seasonal navbar; 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)
jsRawKB: 322, // 321.1 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 318.1 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 315.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 307.8 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 300.5 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 293 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 287.2 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 273.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 263.4 with shared 4.11.0's seasonal navbar; 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)
jsBrotliKB: 79, // 78.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 77.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 76.1 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 74.2 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 70.5 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 67.9 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 65.3 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 61.7 with shared 4.11.0's seasonal navbar; 56.3 (49.9 before web-runtime.js)
jsBrotliKB: 79, // 78.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 77.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 76.1 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 74.2 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 70.5 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 67.9 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 65.3 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 61.7 with shared 4.11.0's seasonal navbar; 56.3 (49.9 before web-runtime.js)
cssFiles: 2, // 2 (reviews.css, openvibe-shared showcase.css)
cssFiles: 2, // 2 (reviews.css, openvibe-shared showcase.css)
- cssRawKB: 44, // 43.2 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 41.4 with 10 touch targets (2026-10-29: the site bar, the search, the questions and three lone links); 40.8 the home's fold and phone list rules (2026-10-29); 36.8 openvibe-shared 4.26.1 (hero visual); 31.3 with Shared 4.13.0 (2026-10-26); 25.4 on 2026-10-22 (the front page's numbers, inspections, ranked records, questions and links); 22.1 with members' reviews, kinds and other review sites (2026-10-17); 19.4 (18.2 before 2026-10-15's scoreboard, 6.7 before showcase.css)
- cssBrotliKB: 9.3, // 9.1 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 8.8 with 10 touch targets (2026-10-29: the site bar, the search, the questions and three lone links); 8.6 the home's fold and phone list rules (2026-10-29); 6.7 with Shared 4.13.0; raised from 8 for openvibe-shared v4.39.0 (+1.5 KB raw of kit/room/showcase CSS); measured 8.2
+ cssRawKB: 47, // 46.6 with the short home (2026-10-30: the pick block's and the fold's own rules); 43.2 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 41.4 with 10 touch targets (2026-10-29: the site bar, the search, the questions and three lone links); 40.8 the home's fold and phone list rules (2026-10-29); 36.8 openvibe-shared 4.26.1 (hero visual); 31.3 with Shared 4.13.0 (2026-10-26); 25.4 on 2026-10-22 (the front page's numbers, inspections, ranked records, questions and links); 22.1 with members' reviews, kinds and other review sites (2026-10-17); 19.4 (18.2 before 2026-10-15's scoreboard, 6.7 before showcase.css)
+ cssBrotliKB: 10, // 9.9 with the short home (2026-10-30: the pick block's and the fold's own rules); 9.1 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 8.8 with 10 touch targets (2026-10-29: the site bar, the search, the questions and three lone links); 8.6 the home's fold and phone list rules (2026-10-29); 6.7 with Shared 4.13.0; raised from 8 for openvibe-shared v4.39.0 (+1.5 KB raw of kit/room/showcase CSS); measured 8.2
externalFiles: 1, // 0
externalFiles: 1, // 0
};
};