openvibers/openvibe.blog Commits
Short home on phones: one thing at a time, picked with chips (#140)
Changes
@@@ -148,3 +148,57 @@ body.blog-home .page { max-width: 1120px; }.bh-more a:only-child { position: relative; display: inline-block; }.bh-more a:only-child::after { content: ''; position: absolute; left: 0; right: 0; top: -12px; bottom: -12px; }}++++++/* 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. */++.bl-fold-block { margin-top: 32px; }++.bl-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); }++.bl-fold-hint { display: none; }++/* The fold's summary carries the heading, so the section's own heading gives way to it. */++.bl-fold-block .sc-sec > h2, .bl-fold-block > .sc-sec > h2 { display: none; }++@media (min-width: 641px) {++ .bl-fold-block { border: 0; background: none; border-radius: 0; margin-top: 56px; padding: 0; }++ .bl-fold-block > summary { display: block; min-height: 0; padding: 0; }++ .bl-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); }++ .bl-fold-block > summary span { display: none; }++ .bl-fold-block .sc-sec { margin: 0; padding: 0; }++}++@media (max-width: 640px) {++ .bl-fold-block { margin-top: 24px; }++ .bl-fold-block > summary { cursor: pointer; }++ .bl-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; }++}@@@ -27,6 +27,9 @@ const cache = require('openvibe-shared/cache-policy');const { renderPage, SITE_SUMMARY } = require('../render/layout');const pages = require('../render/pages');const homePage = require('../render/home');++const deskCatalog = require('../desks/catalog');++// The short home (the owner's rule, 2026-10-30): pick blocks and folds.++const { pickListRules, foldBlock } = require('../render/short');const { createHome } = require('../domain/home');// The home's own words in search results and share cards (the official blog's later pages keep the blog's).@@@ -128,6 +131,8 @@ function createPublicRoutes(ctx) {Promise.all(seriesShown.map(async (sr) => ({ title: sr.title, url: `/@${blog.handle}/series/${encodeURIComponent(sr.slug)}`, posts: (await posts.listPublished({ blogId: blog.id, seriesId: sr.id, ...scope, limit: 1 })).total }))),]);const memberItems = memberRows.length ? await reading.listItems(memberRows) : [];++ // The desks with a newest post: how many chips the AI desks' pick list carries.++ const deskCount = deskCatalog.ALL.filter((d) => latestDesks.get(d.key)).length;return send(req, res, 200, {title: blog.title, fullTitle: HOME_TITLE, description: HOME_DESCRIPTION, summary: SITE_SUMMARY,decision: pageDecision(canonical, { empty: false, query: ['page'] }),@@@ -135,8 +140,11 @@ function createPublicRoutes(ctx) {next: pager.next ? pager.next.href : null,jsonLd: [blogLd, ...homePage.homeJsonLd({ base: config.baseUrl.replace(/\/+$/, ''), items })],styles: [showcase.STYLESHEET], bodyClass: 'blog-home',++ // The rule that shows the picked item of each pick list: one per item, written for this page.++ inlineStyles: [`@supports selector(:has(*)){@media (max-width:640px){${pickListRules('week', week.length)}${pickListRules('desks', deskCount)}}}`],body: homePage.homeBody({ blog, items, pager, feeds, stats, week, latestDesks, tags, series: seriesCounts, memberItems, signedIn: req.viewer.kind === 'user' })-- + frame.shipped({ service: 'blog', title: 'Recently shipped on OpenVibe.Blog' }),++ // The shipped list is a part a reader rarely needs: a fold on a phone, the section it was where there is room.++ + foldBlock({ id: 'shipped', head: '<h2 id="bh-shipped-h">Recently shipped on OpenVibe.Blog</h2>', html: frame.shipped({ service: 'blog', title: 'Recently shipped on OpenVibe.Blog' }) }),}, { cacheable: !scope.restricted && !scope.restrictedAuthor });}send(req, res, 200, {@@@ -9,6 +9,9 @@ const showcase = require('openvibe-shared/showcase');const catalog = require('../desks/catalog');const { html: h, raw } = ssr;++// The short home (the owner's rule, 2026-10-30): the lists of the same kind are one pick block, a row of chips picks++// one of them, and the parts a reader rarely needs are folds.++const { pickList, foldBlock } = require('./short');const dateLabel = (v) => (v ? new Date(v).toISOString().slice(0, 10) : null);const time = (v) => raw(ssr.timeTag(v == null ? null : new Date(v).toISOString(), { label: dateLabel(v) || undefined }));const num = (n) => Number(n || 0).toLocaleString('en-US');@@@ -68,17 +71,33 @@ function homeBody(o) {${blog.description ? h`<p class="sc-lede">${blog.description}</p>` : ''}${feeds.length ? h`<p class="feeds">Follow: ${feeds.map((f) => h`<a href="${f.href}" type="${f.mime}">${f.label}</a> `)}</p>` : ''}${items.length ? h`<ol class="bh-cards">${items.map(postCard)}</ol>${raw(ssr.paginationHtml(pager))}<p class="bh-more bh-all"><a href="/@${blog.handle}">Every post, newest first</a></p>` : h`<p class="empty">Nothing published here yet.</p>`}</section>--${week.length ? h`<section class="sc-sec bh-sec" aria-labelledby="bh-week-h"><h2 id="bh-week-h">This week across OpenVibe</h2>++${week.length ? raw(pickList({++ key: 'week',++ label: 'This week across OpenVibe',++ head: 'This week across OpenVibe',++ labels: week.map((w) => siteName(w.service)),++ html: h`<section class="sc-sec bh-sec" aria-labelledby="bh-week-h"><h2 id="bh-week-h">This week across OpenVibe</h2><p class="sc-lede">${plural(stats.weekChanges, 'change')} shipped to ${plural(stats.weekSites, 'site')} in the last seven days. Patch notes on this blog round them up.</p>--<ol class="bh-bars">${week.map((w) => h`<li><a href="https://openvibe.network/updates?site=${encodeURIComponent(w.service)}">${siteName(w.service)}</a><span class="bh-bar" aria-hidden="true"><span style="width:${String(Math.max(2, Math.round((w.changes / top) * 100)))}%"></span></span><span class="bh-n">${plural(w.changes, 'change')}</span></li>`)}</ol>--<p class="bh-more"><a href="https://openvibe.network/updates">Every change, as it ships</a>${series.length ? h` · <a href="${series[0].url}">${series[0].title}</a>` : ''}</p></section>` : ''}--${desks.length ? h`<section class="sc-sec bh-sec" aria-labelledby="bh-desks-h"><h2 id="bh-desks-h">From the AI desks</h2>++<ol class="bh-bars pick-list">${week.map((w, i) => h`<li data-i="${i}"><a href="https://openvibe.network/updates?site=${encodeURIComponent(w.service)}">${siteName(w.service)}</a><span class="bh-bar" aria-hidden="true"><span style="width:${String(Math.max(2, Math.round((w.changes / top) * 100)))}%"></span></span><span class="bh-n">${plural(w.changes, 'change')}</span></li>`)}</ol>++<p class="bh-more"><a href="https://openvibe.network/updates">Every change, as it ships</a>${series.length ? h` · <a href="${series[0].url}">${series[0].title}</a>` : ''}</p></section>`,++})) : ''}++${desks.length ? raw(pickList({++ key: 'desks',++ label: 'From the AI desks',++ head: 'From the AI desks',++ labels: desks.map(({ desk }) => desk.title),++ html: h`<section class="sc-sec bh-sec" aria-labelledby="bh-desks-h"><h2 id="bh-desks-h">From the AI desks</h2><p class="sc-lede">Daily and weekly digests of what public sources published, every item linked, plus a satire column. Written by AI and not reviewed by a person; kept out of search engines.</p>--<ul class="bh-desks">${desks.map(({ desk, post }) => h`<li><p class="bh-desk">${desk.title} <span class="badge">${desk.satire ? 'Satire' : 'AI'}</span></p><h3><a href="/desks/${desk.key}/${post.slug}">${post.title}</a></h3>${post.dek ? h`<p class="bh-sum">${post.dek}</p>` : ''}<p class="meta">${time(post.published_at)} · <a href="/desks/${desk.key}">More from this desk</a></p></li>`)}</ul>--<p class="bh-more"><a href="/desks">Every desk and its feed</a></p></section>` : ''}--${tags.length ? h`<section class="sc-sec bh-sec" aria-labelledby="bh-tags-h"><h2 id="bh-tags-h">Topics</h2><p class="sc-lede">Tags across every public post, with how many posts carry each.</p>++<ul class="bh-desks pick-list">${desks.map(({ desk, post }, i) => h`<li data-i="${i}"><p class="bh-desk">${desk.title} <span class="badge">${desk.satire ? 'Satire' : 'AI'}</span></p><h3><a href="/desks/${desk.key}/${post.slug}">${post.title}</a></h3>${post.dek ? h`<p class="bh-sum">${post.dek}</p>` : ''}<p class="meta">${time(post.published_at)} · <a href="/desks/${desk.key}">More from this desk</a></p></li>`)}</ul>++<p class="bh-more"><a href="/desks">Every desk and its feed</a></p></section>`,++})) : ''}++${tags.length ? raw(foldBlock({++ id: 'topics',++ head: '<h2 id="bh-tags-h">Topics</h2>',++ html: h`<section class="sc-sec bh-sec" aria-labelledby="bh-tags-h"><h2>Topics</h2><p class="sc-lede">Tags across every public post, with how many posts carry each.</p><ul class="bh-chips">${tags.map((t) => h`<li><a href="/tags/${encodeURIComponent(t.slug)}">#${t.name} <span>${num(t.posts)}</span></a></li>`)}</ul>--${series.length ? h`<p class="bh-more">Series: ${series.map((s, i) => h`${i ? ' · ' : ''}<a href="${s.url}">${s.title}</a> (${plural(s.posts, 'post')})`)}</p>` : ''}</section>` : ''}++${series.length ? h`<p class="bh-more">Series: ${series.map((s, i) => h`${i ? ' · ' : ''}<a href="${s.url}">${s.title}</a> (${plural(s.posts, 'post')})`)}</p>` : ''}</section>`,++})) : ''}${memberItems.length ? h`<section class="sc-sec bh-sec" aria-labelledby="bh-members-h"><h2 id="bh-members-h">From members' blogs</h2><ol class="bh-cards">${memberItems.map(postCard)}</ol></section>` : ''}<div class="sc-sec bh-sec"><details class="bh-fold"><summary>Start your blog today<span>what it gets, how it works</span></summary>${raw(showcase.features({title: 'What your blog gets',@@@ -100,7 +119,11 @@ ${raw(showcase.steps({{ title: 'Readers follow along', text: 'Feeds for the blog, each series and each category, and comments on OpenVibe.Community.' },],}))}<p class="bh-fold-go"><a class="sc-btn sc-primary" href="${start.href}">${start.label}</a></p></details></div>--<section class="sc-sec bh-sec bh-faq" aria-labelledby="bh-faq-h"><h2 id="bh-faq-h">Questions</h2>${FAQ.map((f) => h`<details><summary>${f.q}</summary><p>${f.a}</p></details>`)}</section>`);++${raw(foldBlock({++ id: 'faq',++ head: '<h2 id="bh-faq-h">Questions</h2>',++ html: h`<section class="sc-sec bh-sec bh-faq" aria-labelledby="bh-faq-h"><h2>Questions</h2>${FAQ.map((f) => h`<details><summary>${f.q}</summary><p>${f.a}</p></details>`)}</section>`,++}))}`);}/** Structured data for the home: the site, the newest posts as an ItemList, and the questions shown. */@@@ -113,7 +113,8 @@ function renderPage(o) {bodyClass: o.bodyClass,// The discovery tags of the page's Markdown twin (public.js's send adds them; renderDocument has no// agentLayer switch).-- head: o.head,++ // The rules that show the picked item of a pick list are written for the page, because there is one per item.++ head: [o.head, ...(o.inlineStyles || []).map((css) => `<style>${css}</style>`)].filter(Boolean).join('\n'),});// A blog's theme preset applies to its content surface only: the custom properties and the preset's// slug stay on <main> (the layout writes that tag before any page body, so the first match is it).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="bl-fold bl-fold-block" id="' + esc(id) + '" open><summary>' + head + '<span class="bl-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 };nnew file mode 100644@@@ -0,0 +1,86 @@++'use strict';++/**++ * The home on a phone (the owner's short-pages rule, 2026-10-30): the lists of the same kind are one pick block, a++ * row of chips picks one of them, and the parts a reader rarely needs are folds. Radio inputs and :has(), no script++ * for the chips; every item stays in the page for crawlers; a wider screen shows them all and no chips.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const t = await boot();++ const db = t.ctx.store.db;++ try {++ await check('setup: a week of changes, two desk posts and a tagged post, so the lists have something to show', async () => {++ const now = t.clock.now();++ const iso = (ms) => new Date(ms).toISOString();++ const entry = db.prepare('INSERT INTO changelog_entries (service, repo, sha, subject, deployed_at, release) VALUES (?, ?, ?, ?, ?, ?)');++ await entry.run('live', 'OpenVibers/live', 'sha1', 'change 1', iso(now - 86400_000), 'r1');++ await entry.run('wiki', 'OpenVibers/wiki', 'sha2', 'change 2', iso(now - 2 * 86400_000), 'r1');++ await entry.run('news', 'OpenVibers/news', 'sha3', 'change 3', iso(now - 3 * 86400_000), 'r1');++ const desk = db.prepare('INSERT INTO blog_desk_posts (id, desk, slug, status, title, dek, period_from, period_to, created_at, published_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');++ await desk.run('dp_news', 'news', 'headlines-oct-21', 'published', 'Headlines of Oct 21', 'What governments announced.', now - 2 * 86400_000, now - 86400_000, now, now, now);++ await desk.run('dp_satire', 'satire', 'too-many-vibes', 'published', 'Is it possible to take too many vibes?', 'A sober inquiry.', now - 86400_000, now, now, now, now);++ const post = await t.get('/api/v1/blogs/openvibe/posts', { as: t.official, json: { body: 'A letter from the team.\n\nThe network ships.', title: 'A letter from the team', tags: 'live,patch-notes' } });++ assert.strictEqual(post.status, 201, post.text);++ await t.get(`/api/v1/posts/${post.json().post.id}/publish`, { as: t.official, json: {} });++ });++++ await check('the week is one pick block: a chip per site, the first picked, every site in the page', async () => {++ const r = await t.get('/');++ assert.strictEqual(r.status, 200, r.text);++ const page = r.text;++ const start = page.indexOf('<div class="pick" id="pick-week">');++ assert.ok(start > 0, 'the pick block is on the home');++ const block = page.slice(start, page.indexOf('<div class="pick" id="pick-desks">'));++ assert.match(block, /<div class="pick-row" role="radiogroup" aria-label="This week across OpenVibe">/);++ const chips = [...block.matchAll(/<input type="radio" name="pick-week" id="pk-week-(\d+)" value="\1"( checked)?><label for="pk-week-\1">([^<]+)<\/label>/g)];++ assert.ok(chips.length >= 1, `a chip per site (${chips.length})`);++ assert.deepStrictEqual(chips.filter((m) => m[2]).map((m) => m[1]), [chips[0][1]], 'the first site is picked');++ // Every site stays in the page, with the index its chip picks.++ const bars = [...block.matchAll(/<li data-i="(\d+)"><a href="https:\/\/openvibe.network\/updates/g)];++ assert.strictEqual(bars.length, chips.length, 'every site is in the page');++ assert.deepStrictEqual(bars.map((m) => m[1]), chips.map((m) => m[1]), 'each site knows its chip');++ });++++ await check('the AI desks are a pick block too: a chip per desk, every desk in the page', async () => {++ const r = await t.get('/');++ const page = r.text;++ const start = page.indexOf('<div class="pick" id="pick-desks">');++ assert.ok(start > 0, 'the desks are a pick block');++ const block = page.slice(start, start + 6000);++ const chips = [...block.matchAll(/<input type="radio" name="pick-desks" id="pk-desks-(\d+)" value="\1"( checked)?><label for="pk-desks-\1">([^<]+)<\/label>/g)];++ assert.ok(chips.length >= 1, `a chip per desk (${chips.length})`);++ assert.deepStrictEqual(chips.filter((m) => m[2]).map((m) => m[1]), [chips[0][1]], 'the first desk is picked');++ const desks = [...block.matchAll(/<li data-i="(\d+)"><p class="bh-desk">/g)];++ assert.strictEqual(desks.length, chips.length, 'every desk is in the page');++ });++++ await check('the CSS guard: the chips are hidden and the items all show until 640 px and where :has() is understood', async () => {++ const r = await t.get('/');++ const page = r.text;++ assert.match(page, /<style>@supports selector\(:has\(\*\)\)\{@media \(max-width:640px\)\{\.pick#pick-week:has\(#pk-week-0:checked\) \.pick-list>li\[data-i="0"\]\{display:block\}/, 'the rule that shows the picked item');++ const css = require('fs').readFileSync(require('path').join(__dirname, '..', 'public', 'css', 'blog.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-list > li \{ display: none; \}/, 'the items are hidden on a phone');++ });++++ await check('the parts a reader rarely needs are folds, open in the page and closed on a phone', async () => {++ const r = await t.get('/');++ const page = r.text;++ for (const id of ['topics', 'faq', 'shipped']) {++ assert.ok(page.includes(`<details class="bl-fold bl-fold-block" id="${id}" open>`), `${id} is a fold, open in the page`);++ assert.ok(page.includes(`document.getElementById("${id}")`), `${id} has the script that closes it on a phone`);++ }++ // The content of the folds stays in the page.++ assert.match(page, /<h2 id="bh-tags-h">Topics<\/h2>/);++ assert.match(page, /<h2 id="bh-faq-h">Questions<\/h2>/);++ assert.match(page, /<h2 id="bh-shipped-h">Recently shipped on OpenVibe.Blog<\/h2>/);++ assert.match(page, /<summary>Who can start a blog\?<\/summary>/, 'the questions are still on the page');++ });++ } finally {++ await done(t);++ }++})().catch((e) => { console.error(e); process.exit(1); });@@@ -15,13 +15,13 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');// /shared/showcase.css: measured html 26.2 KB (6.8 br), css 15.9 KB (4.0 br). Raised as a decision.const BUDGETS = {htmlRawKB: 41, // 36.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 33.5 on 2026-10-22: the home's numbers panel, how-it-works steps, questions (and their FAQPage JSON-LD), links around the site and closing call; 17.4 (fresh database)-- htmlBrotliKB: 9, // 8.2 (2026-10-22 the richer home); 4.5++ htmlBrotliKB: 10, // 9.2 with the short home (2026-10-30: the pick blocks' chips and the folds' scripts); 8.2 (2026-10-22 the richer home); 4.5jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell adds web-runtime, 2026-10-04)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; 254.1 with openvibe-shared 4.9.0 (status text twins in every theme, 2026-10-23); 246.2 with openvibe-shared 4.2.x (Ask Actor in the navbar, 2026-10-15); 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; 59.1 with openvibe-shared 4.4.0 (every site in the launcher, 2026-10-17); 56.3 (49.9 before web-runtime.js)cssFiles: 2, // 1-- cssRawKB: 41, // 39.8 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); 38.1 touch targets on phones (2026-10-29); 37.6 the home's phone limits, desk row and fold (2026-10-29); 36 measured with openvibe-shared v4.39.0 (+1.5 KB raw of kit/room/showcase CSS; raised from 34 by the measured growth; sizes are rounded down to 1 decimal, so the limit must clear 36.0); 29.1 openvibe-shared 4.23.0: the showcase hero on a phone (near the bar, buttons side by side, numbers three across); 27.9 on 2026-10-26 (Shared 4.13's home kit: showcase.css 12.2 → 18.3 KB); 22 on 2026-10-22 (the home's post cards, week bars, desks, topics, questions and links); 17.7 (2026-10-15: blog.css carries the AI desks' few rules; the shared showcase.css grew to ~12 KB)-- cssBrotliKB: 9, // 7.3 openvibe-shared 4.26.1 (hero visual); 6.2 (2026-10-26, Shared 4.13's home kit); 5.1 (2026-10-22); 1.4++ cssRawKB: 44, // 43.1 with the short home (2026-10-30: the pick block's and the fold's own rules); 39.8 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); 38.1 touch targets on phones (2026-10-29); 37.6 the home's phone limits, desk row and fold (2026-10-29); 36 measured with openvibe-shared v4.39.0 (+1.5 KB raw of kit/room/showcase CSS; raised from 34 by the measured growth; sizes are rounded down to 1 decimal, so the limit must clear 36.0); 29.1 openvibe-shared 4.23.0: the showcase hero on a phone (near the bar, buttons side by side, numbers three across); 27.9 on 2026-10-26 (Shared 4.13's home kit: showcase.css 12.2 → 18.3 KB); 22 on 2026-10-22 (the home's post cards, week bars, desks, topics, questions and links); 17.7 (2026-10-15: blog.css carries the AI desks' few rules; the shared showcase.css grew to ~12 KB)++ cssBrotliKB: 9.6, // 9.2 with the short home (2026-10-30: the pick block's and the fold's own rules); 7.3 openvibe-shared 4.26.1 (hero visual); 6.2 (2026-10-26, Shared 4.13's home kit); 5.1 (2026-10-22); 1.4externalFiles: 1, // 0};
@@ -148,3 +148,57 @@ body.blog-home .page { max-width: 1120px; }
@@ -148,3 +148,57 @@ body.blog-home .page { max-width: 1120px; }
.bh-more a:only-child { position: relative; display: inline-block; }
.bh-more a:only-child { position: relative; display: inline-block; }
.bh-more a:only-child::after { content: ''; position: absolute; left: 0; right: 0; top: -12px; bottom: -12px; }
.bh-more a:only-child::after { content: ''; position: absolute; left: 0; right: 0; top: -12px; bottom: -12px; }
}
}
+
+
+/* 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. */
+.bl-fold-block { margin-top: 32px; }
+.bl-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); }
+.bl-fold-hint { display: none; }
+/* The fold's summary carries the heading, so the section's own heading gives way to it. */
+.bl-fold-block .sc-sec > h2, .bl-fold-block > .sc-sec > h2 { display: none; }
+@media (min-width: 641px) {
+ .bl-fold-block { border: 0; background: none; border-radius: 0; margin-top: 56px; padding: 0; }
+ .bl-fold-block > summary { display: block; min-height: 0; padding: 0; }
+ .bl-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); }
+ .bl-fold-block > summary span { display: none; }
+ .bl-fold-block .sc-sec { margin: 0; padding: 0; }
+}
+@media (max-width: 640px) {
+ .bl-fold-block { margin-top: 24px; }
+ .bl-fold-block > summary { cursor: pointer; }
+ .bl-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; }
+}
@@ -27,6 +27,9 @@ const cache = require('openvibe-shared/cache-policy');
@@ -27,6 +27,9 @@ const cache = require('openvibe-shared/cache-policy');
const { renderPage, SITE_SUMMARY } = require('../render/layout');
const { renderPage, SITE_SUMMARY } = require('../render/layout');
const pages = require('../render/pages');
const pages = require('../render/pages');
const homePage = require('../render/home');
const homePage = require('../render/home');
+const deskCatalog = require('../desks/catalog');
+// The short home (the owner's rule, 2026-10-30): pick blocks and folds.
+const { pickListRules, foldBlock } = require('../render/short');
const { createHome } = require('../domain/home');
const { createHome } = require('../domain/home');
// The home's own words in search results and share cards (the official blog's later pages keep the blog's).
// The home's own words in search results and share cards (the official blog's later pages keep the blog's).
@@ -128,6 +131,8 @@ function createPublicRoutes(ctx) {
@@ -128,6 +131,8 @@ function createPublicRoutes(ctx) {
Promise.all(seriesShown.map(async (sr) => ({ title: sr.title, url: `/@${blog.handle}/series/${encodeURIComponent(sr.slug)}`, posts: (await posts.listPublished({ blogId: blog.id, seriesId: sr.id, ...scope, limit: 1 })).total }))),
Promise.all(seriesShown.map(async (sr) => ({ title: sr.title, url: `/@${blog.handle}/series/${encodeURIComponent(sr.slug)}`, posts: (await posts.listPublished({ blogId: blog.id, seriesId: sr.id, ...scope, limit: 1 })).total }))),
]);
]);
const memberItems = memberRows.length ? await reading.listItems(memberRows) : [];
const memberItems = memberRows.length ? await reading.listItems(memberRows) : [];
+ // The desks with a newest post: how many chips the AI desks' pick list carries.
+ const deskCount = deskCatalog.ALL.filter((d) => latestDesks.get(d.key)).length;
return send(req, res, 200, {
return send(req, res, 200, {
title: blog.title, fullTitle: HOME_TITLE, description: HOME_DESCRIPTION, summary: SITE_SUMMARY,
title: blog.title, fullTitle: HOME_TITLE, description: HOME_DESCRIPTION, summary: SITE_SUMMARY,
decision: pageDecision(canonical, { empty: false, query: ['page'] }),
decision: pageDecision(canonical, { empty: false, query: ['page'] }),
@@ -135,8 +140,11 @@ function createPublicRoutes(ctx) {
@@ -135,8 +140,11 @@ function createPublicRoutes(ctx) {
next: pager.next ? pager.next.href : null,
next: pager.next ? pager.next.href : null,
jsonLd: [blogLd, ...homePage.homeJsonLd({ base: config.baseUrl.replace(/\/+$/, ''), items })],
jsonLd: [blogLd, ...homePage.homeJsonLd({ base: config.baseUrl.replace(/\/+$/, ''), items })],
styles: [showcase.STYLESHEET], bodyClass: 'blog-home',
styles: [showcase.STYLESHEET], bodyClass: 'blog-home',
+ // The rule that shows the picked item of each pick list: one per item, written for this page.
+ inlineStyles: [`@supports selector(:has(*)){@media (max-width:640px){${pickListRules('week', week.length)}${pickListRules('desks', deskCount)}}}`],
body: homePage.homeBody({ blog, items, pager, feeds, stats, week, latestDesks, tags, series: seriesCounts, memberItems, signedIn: req.viewer.kind === 'user' })
body: homePage.homeBody({ blog, items, pager, feeds, stats, week, latestDesks, tags, series: seriesCounts, memberItems, signedIn: req.viewer.kind === 'user' })
- + frame.shipped({ service: 'blog', title: 'Recently shipped on OpenVibe.Blog' }),
+ // The shipped list is a part a reader rarely needs: a fold on a phone, the section it was where there is room.
+ + foldBlock({ id: 'shipped', head: '<h2 id="bh-shipped-h">Recently shipped on OpenVibe.Blog</h2>', html: frame.shipped({ service: 'blog', title: 'Recently shipped on OpenVibe.Blog' }) }),
}, { cacheable: !scope.restricted && !scope.restrictedAuthor });
}, { cacheable: !scope.restricted && !scope.restrictedAuthor });
}
}
send(req, res, 200, {
send(req, res, 200, {
@@ -9,6 +9,9 @@ const showcase = require('openvibe-shared/showcase');
@@ -9,6 +9,9 @@ const showcase = require('openvibe-shared/showcase');
const catalog = require('../desks/catalog');
const catalog = require('../desks/catalog');
const { html: h, raw } = ssr;
const { html: h, raw } = ssr;
+// The short home (the owner's rule, 2026-10-30): the lists of the same kind are one pick block, a row of chips picks
+// one of them, and the parts a reader rarely needs are folds.
+const { pickList, foldBlock } = require('./short');
const dateLabel = (v) => (v ? new Date(v).toISOString().slice(0, 10) : null);
const dateLabel = (v) => (v ? new Date(v).toISOString().slice(0, 10) : null);
const time = (v) => raw(ssr.timeTag(v == null ? null : new Date(v).toISOString(), { label: dateLabel(v) || undefined }));
const time = (v) => raw(ssr.timeTag(v == null ? null : new Date(v).toISOString(), { label: dateLabel(v) || undefined }));
const num = (n) => Number(n || 0).toLocaleString('en-US');
const num = (n) => Number(n || 0).toLocaleString('en-US');
@@ -68,17 +71,33 @@ function homeBody(o) {
@@ -68,17 +71,33 @@ function homeBody(o) {
${blog.description ? h`<p class="sc-lede">${blog.description}</p>` : ''}
${blog.description ? h`<p class="sc-lede">${blog.description}</p>` : ''}
${feeds.length ? h`<p class="feeds">Follow: ${feeds.map((f) => h`<a href="${f.href}" type="${f.mime}">${f.label}</a> `)}</p>` : ''}
${feeds.length ? h`<p class="feeds">Follow: ${feeds.map((f) => h`<a href="${f.href}" type="${f.mime}">${f.label}</a> `)}</p>` : ''}
${items.length ? h`<ol class="bh-cards">${items.map(postCard)}</ol>${raw(ssr.paginationHtml(pager))}<p class="bh-more bh-all"><a href="/@${blog.handle}">Every post, newest first</a></p>` : h`<p class="empty">Nothing published here yet.</p>`}</section>
${items.length ? h`<ol class="bh-cards">${items.map(postCard)}</ol>${raw(ssr.paginationHtml(pager))}<p class="bh-more bh-all"><a href="/@${blog.handle}">Every post, newest first</a></p>` : h`<p class="empty">Nothing published here yet.</p>`}</section>
-${week.length ? h`<section class="sc-sec bh-sec" aria-labelledby="bh-week-h"><h2 id="bh-week-h">This week across OpenVibe</h2>
+${week.length ? raw(pickList({
+ key: 'week',
+ label: 'This week across OpenVibe',
+ head: 'This week across OpenVibe',
+ labels: week.map((w) => siteName(w.service)),
+ html: h`<section class="sc-sec bh-sec" aria-labelledby="bh-week-h"><h2 id="bh-week-h">This week across OpenVibe</h2>
<p class="sc-lede">${plural(stats.weekChanges, 'change')} shipped to ${plural(stats.weekSites, 'site')} in the last seven days. Patch notes on this blog round them up.</p>
<p class="sc-lede">${plural(stats.weekChanges, 'change')} shipped to ${plural(stats.weekSites, 'site')} in the last seven days. Patch notes on this blog round them up.</p>
-<ol class="bh-bars">${week.map((w) => h`<li><a href="https://openvibe.network/updates?site=${encodeURIComponent(w.service)}">${siteName(w.service)}</a><span class="bh-bar" aria-hidden="true"><span style="width:${String(Math.max(2, Math.round((w.changes / top) * 100)))}%"></span></span><span class="bh-n">${plural(w.changes, 'change')}</span></li>`)}</ol>
-<p class="bh-more"><a href="https://openvibe.network/updates">Every change, as it ships</a>${series.length ? h` · <a href="${series[0].url}">${series[0].title}</a>` : ''}</p></section>` : ''}
-${desks.length ? h`<section class="sc-sec bh-sec" aria-labelledby="bh-desks-h"><h2 id="bh-desks-h">From the AI desks</h2>
+<ol class="bh-bars pick-list">${week.map((w, i) => h`<li data-i="${i}"><a href="https://openvibe.network/updates?site=${encodeURIComponent(w.service)}">${siteName(w.service)}</a><span class="bh-bar" aria-hidden="true"><span style="width:${String(Math.max(2, Math.round((w.changes / top) * 100)))}%"></span></span><span class="bh-n">${plural(w.changes, 'change')}</span></li>`)}</ol>
+<p class="bh-more"><a href="https://openvibe.network/updates">Every change, as it ships</a>${series.length ? h` · <a href="${series[0].url}">${series[0].title}</a>` : ''}</p></section>`,
+})) : ''}
+${desks.length ? raw(pickList({
+ key: 'desks',
+ label: 'From the AI desks',
+ head: 'From the AI desks',
+ labels: desks.map(({ desk }) => desk.title),
+ html: h`<section class="sc-sec bh-sec" aria-labelledby="bh-desks-h"><h2 id="bh-desks-h">From the AI desks</h2>
<p class="sc-lede">Daily and weekly digests of what public sources published, every item linked, plus a satire column. Written by AI and not reviewed by a person; kept out of search engines.</p>
<p class="sc-lede">Daily and weekly digests of what public sources published, every item linked, plus a satire column. Written by AI and not reviewed by a person; kept out of search engines.</p>
-<ul class="bh-desks">${desks.map(({ desk, post }) => h`<li><p class="bh-desk">${desk.title} <span class="badge">${desk.satire ? 'Satire' : 'AI'}</span></p><h3><a href="/desks/${desk.key}/${post.slug}">${post.title}</a></h3>${post.dek ? h`<p class="bh-sum">${post.dek}</p>` : ''}<p class="meta">${time(post.published_at)} · <a href="/desks/${desk.key}">More from this desk</a></p></li>`)}</ul>
-<p class="bh-more"><a href="/desks">Every desk and its feed</a></p></section>` : ''}
-${tags.length ? h`<section class="sc-sec bh-sec" aria-labelledby="bh-tags-h"><h2 id="bh-tags-h">Topics</h2><p class="sc-lede">Tags across every public post, with how many posts carry each.</p>
+<ul class="bh-desks pick-list">${desks.map(({ desk, post }, i) => h`<li data-i="${i}"><p class="bh-desk">${desk.title} <span class="badge">${desk.satire ? 'Satire' : 'AI'}</span></p><h3><a href="/desks/${desk.key}/${post.slug}">${post.title}</a></h3>${post.dek ? h`<p class="bh-sum">${post.dek}</p>` : ''}<p class="meta">${time(post.published_at)} · <a href="/desks/${desk.key}">More from this desk</a></p></li>`)}</ul>
+<p class="bh-more"><a href="/desks">Every desk and its feed</a></p></section>`,
+})) : ''}
+${tags.length ? raw(foldBlock({
+ id: 'topics',
+ head: '<h2 id="bh-tags-h">Topics</h2>',
+ html: h`<section class="sc-sec bh-sec" aria-labelledby="bh-tags-h"><h2>Topics</h2><p class="sc-lede">Tags across every public post, with how many posts carry each.</p>
<ul class="bh-chips">${tags.map((t) => h`<li><a href="/tags/${encodeURIComponent(t.slug)}">#${t.name} <span>${num(t.posts)}</span></a></li>`)}</ul>
<ul class="bh-chips">${tags.map((t) => h`<li><a href="/tags/${encodeURIComponent(t.slug)}">#${t.name} <span>${num(t.posts)}</span></a></li>`)}</ul>
-${series.length ? h`<p class="bh-more">Series: ${series.map((s, i) => h`${i ? ' · ' : ''}<a href="${s.url}">${s.title}</a> (${plural(s.posts, 'post')})`)}</p>` : ''}</section>` : ''}
+${series.length ? h`<p class="bh-more">Series: ${series.map((s, i) => h`${i ? ' · ' : ''}<a href="${s.url}">${s.title}</a> (${plural(s.posts, 'post')})`)}</p>` : ''}</section>`,
+})) : ''}
${memberItems.length ? h`<section class="sc-sec bh-sec" aria-labelledby="bh-members-h"><h2 id="bh-members-h">From members' blogs</h2><ol class="bh-cards">${memberItems.map(postCard)}</ol></section>` : ''}
${memberItems.length ? h`<section class="sc-sec bh-sec" aria-labelledby="bh-members-h"><h2 id="bh-members-h">From members' blogs</h2><ol class="bh-cards">${memberItems.map(postCard)}</ol></section>` : ''}
<div class="sc-sec bh-sec"><details class="bh-fold"><summary>Start your blog today<span>what it gets, how it works</span></summary>${raw(showcase.features({
<div class="sc-sec bh-sec"><details class="bh-fold"><summary>Start your blog today<span>what it gets, how it works</span></summary>${raw(showcase.features({
title: 'What your blog gets',
title: 'What your blog gets',
@@ -100,7 +119,11 @@ ${raw(showcase.steps({
@@ -100,7 +119,11 @@ ${raw(showcase.steps({
{ title: 'Readers follow along', text: 'Feeds for the blog, each series and each category, and comments on OpenVibe.Community.' },
{ title: 'Readers follow along', text: 'Feeds for the blog, each series and each category, and comments on OpenVibe.Community.' },
],
],
}))}<p class="bh-fold-go"><a class="sc-btn sc-primary" href="${start.href}">${start.label}</a></p></details></div>
}))}<p class="bh-fold-go"><a class="sc-btn sc-primary" href="${start.href}">${start.label}</a></p></details></div>
-<section class="sc-sec bh-sec bh-faq" aria-labelledby="bh-faq-h"><h2 id="bh-faq-h">Questions</h2>${FAQ.map((f) => h`<details><summary>${f.q}</summary><p>${f.a}</p></details>`)}</section>`);
+${raw(foldBlock({
+ id: 'faq',
+ head: '<h2 id="bh-faq-h">Questions</h2>',
+ html: h`<section class="sc-sec bh-sec bh-faq" aria-labelledby="bh-faq-h"><h2>Questions</h2>${FAQ.map((f) => h`<details><summary>${f.q}</summary><p>${f.a}</p></details>`)}</section>`,
+}))}`);
}
}
/** Structured data for the home: the site, the newest posts as an ItemList, and the questions shown. */
/** Structured data for the home: the site, the newest posts as an ItemList, and the questions shown. */
@@ -113,7 +113,8 @@ function renderPage(o) {
@@ -113,7 +113,8 @@ function renderPage(o) {
bodyClass: o.bodyClass,
bodyClass: o.bodyClass,
// The discovery tags of the page's Markdown twin (public.js's send adds them; renderDocument has no
// The discovery tags of the page's Markdown twin (public.js's send adds them; renderDocument has no
// agentLayer switch).
// agentLayer switch).
- head: o.head,
+ // The rules that show the picked item of a pick list are written for the page, because there is one per item.
+ head: [o.head, ...(o.inlineStyles || []).map((css) => `<style>${css}</style>`)].filter(Boolean).join('\n'),
});
});
// A blog's theme preset applies to its content surface only: the custom properties and the preset's
// A blog's theme preset applies to its content surface only: the custom properties and the preset's
// slug stay on <main> (the layout writes that tag before any page body, so the first match is it).
// slug stay on <main> (the layout writes that tag before any page body, so the first match is it).
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="bl-fold bl-fold-block" id="' + esc(id) + '" open><summary>' + head + '<span class="bl-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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,86 @@
@@ -0,0 +1,86 @@
+'use strict';
+/**
+ * The home on a phone (the owner's short-pages rule, 2026-10-30): the lists of the same kind are one pick block, a
+ * row of chips picks one of them, and the parts a reader rarely needs are folds. Radio inputs and :has(), no script
+ * for the chips; every item stays in the page for crawlers; a wider screen shows them all and no chips.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const t = await boot();
+ const db = t.ctx.store.db;
+ try {
+ await check('setup: a week of changes, two desk posts and a tagged post, so the lists have something to show', async () => {
+ const now = t.clock.now();
+ const iso = (ms) => new Date(ms).toISOString();
+ const entry = db.prepare('INSERT INTO changelog_entries (service, repo, sha, subject, deployed_at, release) VALUES (?, ?, ?, ?, ?, ?)');
+ await entry.run('live', 'OpenVibers/live', 'sha1', 'change 1', iso(now - 86400_000), 'r1');
+ await entry.run('wiki', 'OpenVibers/wiki', 'sha2', 'change 2', iso(now - 2 * 86400_000), 'r1');
+ await entry.run('news', 'OpenVibers/news', 'sha3', 'change 3', iso(now - 3 * 86400_000), 'r1');
+ const desk = db.prepare('INSERT INTO blog_desk_posts (id, desk, slug, status, title, dek, period_from, period_to, created_at, published_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
+ await desk.run('dp_news', 'news', 'headlines-oct-21', 'published', 'Headlines of Oct 21', 'What governments announced.', now - 2 * 86400_000, now - 86400_000, now, now, now);
+ await desk.run('dp_satire', 'satire', 'too-many-vibes', 'published', 'Is it possible to take too many vibes?', 'A sober inquiry.', now - 86400_000, now, now, now, now);
+ const post = await t.get('/api/v1/blogs/openvibe/posts', { as: t.official, json: { body: 'A letter from the team.\n\nThe network ships.', title: 'A letter from the team', tags: 'live,patch-notes' } });
+ assert.strictEqual(post.status, 201, post.text);
+ await t.get(`/api/v1/posts/${post.json().post.id}/publish`, { as: t.official, json: {} });
+ });
+
+ await check('the week is one pick block: a chip per site, the first picked, every site in the page', async () => {
+ const r = await t.get('/');
+ assert.strictEqual(r.status, 200, r.text);
+ const page = r.text;
+ const start = page.indexOf('<div class="pick" id="pick-week">');
+ assert.ok(start > 0, 'the pick block is on the home');
+ const block = page.slice(start, page.indexOf('<div class="pick" id="pick-desks">'));
+ assert.match(block, /<div class="pick-row" role="radiogroup" aria-label="This week across OpenVibe">/);
+ const chips = [...block.matchAll(/<input type="radio" name="pick-week" id="pk-week-(\d+)" value="\1"( checked)?><label for="pk-week-\1">([^<]+)<\/label>/g)];
+ assert.ok(chips.length >= 1, `a chip per site (${chips.length})`);
+ assert.deepStrictEqual(chips.filter((m) => m[2]).map((m) => m[1]), [chips[0][1]], 'the first site is picked');
+ // Every site stays in the page, with the index its chip picks.
+ const bars = [...block.matchAll(/<li data-i="(\d+)"><a href="https:\/\/openvibe.network\/updates/g)];
+ assert.strictEqual(bars.length, chips.length, 'every site is in the page');
+ assert.deepStrictEqual(bars.map((m) => m[1]), chips.map((m) => m[1]), 'each site knows its chip');
+ });
+
+ await check('the AI desks are a pick block too: a chip per desk, every desk in the page', async () => {
+ const r = await t.get('/');
+ const page = r.text;
+ const start = page.indexOf('<div class="pick" id="pick-desks">');
+ assert.ok(start > 0, 'the desks are a pick block');
+ const block = page.slice(start, start + 6000);
+ const chips = [...block.matchAll(/<input type="radio" name="pick-desks" id="pk-desks-(\d+)" value="\1"( checked)?><label for="pk-desks-\1">([^<]+)<\/label>/g)];
+ assert.ok(chips.length >= 1, `a chip per desk (${chips.length})`);
+ assert.deepStrictEqual(chips.filter((m) => m[2]).map((m) => m[1]), [chips[0][1]], 'the first desk is picked');
+ const desks = [...block.matchAll(/<li data-i="(\d+)"><p class="bh-desk">/g)];
+ assert.strictEqual(desks.length, chips.length, 'every desk is in the page');
+ });
+
+ await check('the CSS guard: the chips are hidden and the items all show until 640 px and where :has() is understood', async () => {
+ const r = await t.get('/');
+ const page = r.text;
+ assert.match(page, /<style>@supports selector\(:has\(\*\)\)\{@media \(max-width:640px\)\{\.pick#pick-week:has\(#pk-week-0:checked\) \.pick-list>li\[data-i="0"\]\{display:block\}/, 'the rule that shows the picked item');
+ const css = require('fs').readFileSync(require('path').join(__dirname, '..', 'public', 'css', 'blog.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-list > li \{ display: none; \}/, 'the items are hidden on a phone');
+ });
+
+ await check('the parts a reader rarely needs are folds, open in the page and closed on a phone', async () => {
+ const r = await t.get('/');
+ const page = r.text;
+ for (const id of ['topics', 'faq', 'shipped']) {
+ assert.ok(page.includes(`<details class="bl-fold bl-fold-block" id="${id}" open>`), `${id} is a fold, open in the page`);
+ assert.ok(page.includes(`document.getElementById("${id}")`), `${id} has the script that closes it on a phone`);
+ }
+ // The content of the folds stays in the page.
+ assert.match(page, /<h2 id="bh-tags-h">Topics<\/h2>/);
+ assert.match(page, /<h2 id="bh-faq-h">Questions<\/h2>/);
+ assert.match(page, /<h2 id="bh-shipped-h">Recently shipped on OpenVibe.Blog<\/h2>/);
+ assert.match(page, /<summary>Who can start a blog\?<\/summary>/, 'the questions are still on the page');
+ });
+ } finally {
+ await done(t);
+ }
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -15,13 +15,13 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
@@ -15,13 +15,13 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
// /shared/showcase.css: measured html 26.2 KB (6.8 br), css 15.9 KB (4.0 br). Raised as a decision.
// /shared/showcase.css: measured html 26.2 KB (6.8 br), css 15.9 KB (4.0 br). Raised as a decision.
const BUDGETS = {
const BUDGETS = {
htmlRawKB: 41, // 36.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 33.5 on 2026-10-22: the home's numbers panel, how-it-works steps, questions (and their FAQPage JSON-LD), links around the site and closing call; 17.4 (fresh database)
htmlRawKB: 41, // 36.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 33.5 on 2026-10-22: the home's numbers panel, how-it-works steps, questions (and their FAQPage JSON-LD), links around the site and closing call; 17.4 (fresh database)
- htmlBrotliKB: 9, // 8.2 (2026-10-22 the richer home); 4.5
+ htmlBrotliKB: 10, // 9.2 with the short home (2026-10-30: the pick blocks' chips and the folds' scripts); 8.2 (2026-10-22 the richer home); 4.5
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell adds web-runtime, 2026-10-04)
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell adds web-runtime, 2026-10-04)
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; 254.1 with openvibe-shared 4.9.0 (status text twins in every theme, 2026-10-23); 246.2 with openvibe-shared 4.2.x (Ask Actor in the navbar, 2026-10-15); 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; 254.1 with openvibe-shared 4.9.0 (status text twins in every theme, 2026-10-23); 246.2 with openvibe-shared 4.2.x (Ask Actor in the navbar, 2026-10-15); 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; 59.1 with openvibe-shared 4.4.0 (every site in the launcher, 2026-10-17); 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; 59.1 with openvibe-shared 4.4.0 (every site in the launcher, 2026-10-17); 56.3 (49.9 before web-runtime.js)
cssFiles: 2, // 1
cssFiles: 2, // 1
- cssRawKB: 41, // 39.8 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); 38.1 touch targets on phones (2026-10-29); 37.6 the home's phone limits, desk row and fold (2026-10-29); 36 measured with openvibe-shared v4.39.0 (+1.5 KB raw of kit/room/showcase CSS; raised from 34 by the measured growth; sizes are rounded down to 1 decimal, so the limit must clear 36.0); 29.1 openvibe-shared 4.23.0: the showcase hero on a phone (near the bar, buttons side by side, numbers three across); 27.9 on 2026-10-26 (Shared 4.13's home kit: showcase.css 12.2 → 18.3 KB); 22 on 2026-10-22 (the home's post cards, week bars, desks, topics, questions and links); 17.7 (2026-10-15: blog.css carries the AI desks' few rules; the shared showcase.css grew to ~12 KB)
- cssBrotliKB: 9, // 7.3 openvibe-shared 4.26.1 (hero visual); 6.2 (2026-10-26, Shared 4.13's home kit); 5.1 (2026-10-22); 1.4
+ cssRawKB: 44, // 43.1 with the short home (2026-10-30: the pick block's and the fold's own rules); 39.8 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); 38.1 touch targets on phones (2026-10-29); 37.6 the home's phone limits, desk row and fold (2026-10-29); 36 measured with openvibe-shared v4.39.0 (+1.5 KB raw of kit/room/showcase CSS; raised from 34 by the measured growth; sizes are rounded down to 1 decimal, so the limit must clear 36.0); 29.1 openvibe-shared 4.23.0: the showcase hero on a phone (near the bar, buttons side by side, numbers three across); 27.9 on 2026-10-26 (Shared 4.13's home kit: showcase.css 12.2 → 18.3 KB); 22 on 2026-10-22 (the home's post cards, week bars, desks, topics, questions and links); 17.7 (2026-10-15: blog.css carries the AI desks' few rules; the shared showcase.css grew to ~12 KB)
+ cssBrotliKB: 9.6, // 9.2 with the short home (2026-10-30: the pick block's and the fold's own rules); 7.3 openvibe-shared 4.26.1 (hero visual); 6.2 (2026-10-26, Shared 4.13's home kit); 5.1 (2026-10-22); 1.4
externalFiles: 1, // 0
externalFiles: 1, // 0
};
};