openvibers/openvibe.help Commits

Short home on phones: one thing at a time, picked with chips (#143)

OP OpenVibers committed 14 days ago · e6a96954cb · 6 files, +253 −13

The home on a phone is one or two screens (the owner's short-pages rule): the site list is one pick block, a row of chips picks one site, and the popular questions, the network list and the shipped list are folds. Radio inputs and :has(), no script for the chips; every site stays in the page for crawlers, and a wide screen shows them all and no chips.

Changes

ddiff --git a/public/css/app.css b/public/css/app.css
iindex fe1f4fe..622e468 100644
---- a/public/css/app.css
++++ b/public/css/app.css
@@@ -123,3 +123,57 @@ mark { background: var(--warning, #f59e0b); color: #10151f; border-radius: 3px;
p.small a:only-child { position: relative; }
p.small a:only-child::after { content: ''; position: absolute; left: 0; right: 0; top: -14px; bottom: -14px; }
}
++
++
++/* 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. */
++.hp-fold-block { margin-top: 32px; }
++.hp-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); }
++.hp-fold-hint { display: none; }
++/* The fold's summary carries the heading, so the section's own heading gives way to it. */
++.hp-fold-block .sc-sec > h2, .hp-fold-block > .sc-sec > h2 { display: none; }
++@media (min-width: 641px) {
++ .hp-fold-block { border: 0; background: none; border-radius: 0; margin-top: 56px; padding: 0; }
++ .hp-fold-block > summary { display: block; min-height: 0; padding: 0; }
++ .hp-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); }
++ .hp-fold-block > summary span { display: none; }
++ .hp-fold-block .sc-sec { margin: 0; padding: 0; }
++}
++@media (max-width: 640px) {
++ .hp-fold-block { margin-top: 24px; }
++ .hp-fold-block > summary { cursor: pointer; }
++ .hp-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; }
++}
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex a92b852..47b9d84 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -18,7 +18,10 @@ const cache = require('openvibe-shared/cache-policy');
const { asyncRouter } = require('./router');
const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
const { sameOrigin } = require('./principal');
--const { html, raw, table, notice, time, badge } = require('../render/html');
++const { html, raw, render, table, notice, time, badge } = require('../render/html');
++// The short home (the owner's rule, 2026-10-30): a pick block for the site list, folds for the parts a reader
++// rarely needs.
++const { pickList, pickListRules, foldBlock } = require('../render/short');
const { send } = require('../render/layout');
const { searchArticles, markTerms } = require('../search');
const tickets = require('../tickets/tickets');
@@@ -118,22 +121,38 @@ function createPageRoutes(ctx) {
jsonLd: faqLd ? [...homeJsonLd(config), faqLd] : homeJsonLd(config),
description: `Search the answers of every OpenVibe site, browse them one by one, or open a support ticket that stays one conversation.`,
styles: [showcase.STYLESHEET],
++ // The rule that shows the picked site of the pick list: one per site, written for this page.
++ inlineStyles: [`@supports selector(:has(*)){@media (max-width:640px){${pickListRules('sites', catalog.sites.length)}}}`],
body: html`${raw(hero)}
<section class="sc-sec" id="search" aria-labelledby="h-search"><h2 id="h-search">Search the help centre</h2>
<p class="sc-lede">One box over every site's answers, and over the whole OpenVibe network.</p>
${searchForm()}</section>
--<section class="sc-sec" aria-labelledby="h-popular"><h2 id="h-popular">Popular questions</h2>
++${raw(pickList({
++ key: 'sites',
++ label: 'Browse by site',
++ head: 'Browse by site',
++ labels: catalog.sites.map((x) => x.shortName),
++ html: render(html`<section class="sc-sec" aria-labelledby="h-sites"><h2 id="h-sites">Browse by site</h2>
++<p class="sc-lede">Live sites first; each name opens that site's page, with its own words and its own questions. <a href="/sites">The full list, with what each one is</a>.</p>
++<ul class="site-cards pick-list">${catalog.sites.map((x, i) => html`<li data-i="${i}"><a class="site-card${x.live ? '' : ' coming'}" href="/sites/${x.id}"><span class="site-card-top"><b>${x.shortName}</b><span class="site-state">${x.live ? 'live' : 'coming'}</span></span><span class="site-card-line">${x.oneLine}</span>${answersBySite.get(x.domain) ? html`<span class="site-card-count">${String(answersBySite.get(x.domain))} ${answersBySite.get(x.domain) === 1 ? 'answer' : 'answers'}</span>` : ''}</a></li>`)}</ul>
++<p class="small"><a href="/sites">All ${catalog.sites.length} sites, with what each one is</a></p></section>`),
++}))}
++${raw(foldBlock({
++ id: 'popular',
++ head: '<h2 id="h-popular">Popular questions</h2>',
++ html: render(html`<section class="sc-sec" aria-labelledby="h-popular"><h2>Popular questions</h2>
<p class="sc-lede">One question from each site in turn, answered the way the site answers it in its own manifest.</p>
<div class="q-faq">${catalog.popular.map((a) => html`<details><summary><span>${a.question}</span><span class="q-site">${String(a.siteName).replace(/^OpenVibe\./, '')}</span></summary><p>${a.answer}</p><p class="small"><a href="${a.path}">The answer's own page</a> · <a href="/sites/${a.site}">Every ${String(a.siteName).replace(/^OpenVibe\./, '')} question</a></p></details>`)}</div>
--<p class="small"><a href="/sites">Every question, site by site</a></p></section>
--<section class="sc-sec" aria-labelledby="h-sites"><h2 id="h-sites">Browse by site</h2>
--<p class="sc-lede">Live sites first; each name opens that site's page, with its own words and its own questions. <a href="/sites">The full list, with what each one is</a>.</p>
--<ul class="site-cards">${catalog.sites.map((x) => html`<li><a class="site-card${x.live ? '' : ' coming'}" href="/sites/${x.id}"><span class="site-card-top"><b>${x.shortName}</b><span class="site-state">${x.live ? 'live' : 'coming'}</span></span><span class="site-card-line">${x.oneLine}</span>${answersBySite.get(x.domain) ? html`<span class="site-card-count">${String(answersBySite.get(x.domain))} ${answersBySite.get(x.domain) === 1 ? 'answer' : 'answers'}</span>` : ''}</a></li>`)}</ul>
--<p class="small"><a href="/sites">All ${catalog.sites.length} sites, with what each one is</a></p></section>
++<p class="small"><a href="/sites">Every question, site by site</a></p></section>`),
++}))}
<section class="sc-sec" aria-labelledby="h-contact"><h2 id="h-contact">Contact support</h2>
<p class="sc-lede">Nothing here answered it? Open a ticket: you and the people who run OpenVibe, in one thread.</p>
${contactBlock(req)}</section>
--${raw(showcase.network({ service: 'help' }))}`,
++${raw(foldBlock({
++ id: 'network',
++ head: '<h2 id="h-network">Part of OpenVibe</h2>',
++ html: showcase.network({ service: 'help' }),
++}))}`,
});
});
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex caf33ae..7442ed6 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -14,6 +14,7 @@ const fs = require('fs');
const path = require('path');
const appIcon = require('openvibe-shared/app-icon');
const frame = require('openvibe-shared/frame');
++const { foldBlock } = require('./short');
const shell = require('openvibe-shared/shell');
const cache = require('openvibe-shared/cache-policy');
const { html, raw, esc } = require('./html');
@@@ -98,6 +99,7 @@ function renderPage(o) {
${appIcon.headTags({ site: 'help' })}
<link rel="stylesheet" href="${asset('css/app.css')}">
${(o.styles || []).map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`).join('\n')}
++${(o.inlineStyles || []).map((css) => `<style>${css}</style>`).join('\n')}
${render(scripts)}
<meta name="ov-boost" content="help@${esc(RELEASE)}">
<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,
@@@ -107,7 +109,7 @@ ${render(scripts)}
<main id="main" class="page">
${render(crumbs)}
${render(o.body)}
--${o.path === '/' ? frame.shipped({ service: 'help', title: `Recently shipped on ${SITE_NAME}` }) : ''}
++${o.path === '/' ? foldBlock({ id: 'shipped', head: `<h2 id="h-shipped">Recently shipped on ${SITE_NAME}</h2>`, html: frame.shipped({ service: 'help', title: `Recently shipped on ${SITE_NAME}` }) }) : ''}
</main>
<script>
window.__OV_PAGE = ${JSON.stringify({ navbar: nav, footer }).replace(/</g, '\\u003c')};
ddiff --git a/server/render/short.js b/server/render/short.js
nnew file mode 100644
iindex 0000000..ba1b957
---- /dev/null
++++ b/server/render/short.js
@@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
++
++/**
++ * A pick block. The panes are the page's own blocks; each one keeps its heading on a wide screen, and on a phone the
++ * heading gives way to the chip that names it.
++ *
++ * pickBlock({ key, label, head, panes }) 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="hp-fold hp-fold-block" id="' + esc(id) + '" open><summary>' + head + '<span class="hp-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 };
ddiff --git a/test/home-short.test.js b/test/home-short.test.js
nnew file mode 100644
iindex 0000000..9e4869b
---- /dev/null
++++ b/test/home-short.test.js
@@@ -0,0 +1,56 @@
++'use strict';
++/**
++ * The home on a phone (the owner's short-pages rule, 2026-10-30): the site list is one pick block, a row of chips
++ * picks one site, and the parts a reader rarely needs are folds. Radio inputs and :has(), no script for the chips;
++ * every site 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();
++ try {
++ await check('the home 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-sites">');
++ assert.ok(start > 0, 'the pick block is on the home');
++ const block = page.slice(start, page.indexOf('<details class="hp-fold hp-fold-block" id="popular"'));
++ assert.match(block, /<div class="pick-row" role="radiogroup" aria-label="Browse by site">/);
++ const chips = [...block.matchAll(/<input type="radio" name="pick-sites" id="pk-sites-(\d+)" value="\1"( checked)?><label for="pk-sites-\1">([^<]+)<\/label>/g)];
++ assert.ok(chips.length >= 3, `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 card stays in the page, with the index its chip picks.
++ const cards = [...block.matchAll(/<li data-i="(\d+)"><a class="site-card/g)];
++ assert.strictEqual(cards.length, chips.length, 'every site is in the page');
++ assert.deepStrictEqual(cards.map((m) => m[1]), chips.map((m) => m[1]), 'each site knows its chip');
++ });
++
++ await check('the CSS guard: the chips are hidden and the sites 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-sites:has\(#pk-sites-0:checked\) \.pick-list>li\[data-i="0"\]\{display:block\}/, 'the rule that shows the picked site');
++ const css = require('fs').readFileSync(require('path').join(__dirname, '..', 'public', 'css', 'app.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 sites 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 ['popular', 'network', 'shipped']) {
++ assert.ok(page.includes(`<details class="hp-fold hp-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="h-popular">Popular questions<\/h2>/);
++ assert.match(page, /<h2 id="h-network">Part of OpenVibe<\/h2>/);
++ assert.match(page, /<h2 id="h-shipped">Recently shipped on OpenVibe.Help<\/h2>/);
++ });
++ } finally {
++ await done(t);
++ }
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex 92f7376..b769696 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -19,14 +19,14 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
// against 11.6 KB of the shared showcase.css. Raised 2026-10-08 from 16.5/4.1 to 18.5/4.5 for a measured
// 17.5/4.3. The HTML budget did NOT move: those pages fit in the skeleton home page's 24.9 KB budget with room.
const BUDGETS = {
-- htmlRawKB: 56, // 55.6 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 50.5 with the Part of OpenVibe section (Shared 4.13, 2026-10-26); measured 45.7 on 2026-10-22: the 12 popular questions now carry their answers inline (and as FAQPage data), plus the hero's figures; 35.7 with 40 sites in the catalog (contracts 0.186.0, 2026-10-15); 32.6 on 2026-10-08 (the home page: hero, search, popular questions, the site cards with one line each, contact)
-- htmlBrotliKB: 12, // 11 with the Part of OpenVibe section (Shared 4.13, 2026-10-26); 9.7 on 2026-10-22 (answers inline); 8.3 with 40 sites (2026-10-15); 7.3
++ htmlRawKB: 68, // 65.6 with the short home (2026-10-30: the site list is one pick block, a chip per site, and the parts a reader rarely needs are folds); 55.6 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 50.5 with the Part of OpenVibe section (Shared 4.13, 2026-10-26); measured 45.7 on 2026-10-22: the 12 popular questions now carry their answers inline (and as FAQPage data), plus the hero's figures; 35.7 with 40 sites in the catalog (contracts 0.186.0, 2026-10-15); 32.6 on 2026-10-08 (the home page: hero, search, popular questions, the site cards with one line each, contact)
++ htmlBrotliKB: 14, // 13 with the short home (2026-10-30: the pick block's chips and the folds' scripts); 11 with the Part of OpenVibe section (Shared 4.13, 2026-10-26); 9.7 on 2026-10-22 (answers inline); 8.3 with 40 sites (2026-10-15); 7.3
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)
jsRawKB: 322, // 321.1 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 318.1 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 315.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 307.8 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 300.5 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 293 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 287.2 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 273.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 264.3 with Shared 4.13.0 (2026-10-26: account links); 239.8
jsBrotliKB: 79, // 78.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 77.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 76.1 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 74.2 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 70.5 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 67.9 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 65.3 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 56.5
cssFiles: 2, // 2 (app.css + the cached /shared/showcase.css)
-- cssRawKB: 39, // 38 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); 32.4 openvibe-shared 4.26.1 (hero visual); 26.9 with Shared 4.13.0 (2026-10-26: account links); 19.3
-- cssBrotliKB: 8.4, // 8.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); 7.8 the home's phone layout (2026-10-29); 6 with Shared 4.13.0 (2026-10-26: account links); 4.6 // raised 7 -> 8 for the openvibe-shared Kit 2 pin (kit.css grew) // back to the pre-Kit 2 value: Kit 2 has its own files (kit-data.css) since openvibe-shared v4.37.0, so kit.css weighs what it did // back to the pre-Kit 2 value: Kit 2 has its own files (kit-data.css) since openvibe-shared v4.37.0, so kit.css weighs what it did
++ cssRawKB: 42, // 41.3 with the short home (2026-10-30: the pick block's and the fold's own rules); 38 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); 32.4 openvibe-shared 4.26.1 (hero visual); 26.9 with Shared 4.13.0 (2026-10-26: account links); 19.3
++ cssBrotliKB: 9.2, // 8.9 with the short home (2026-10-30: the pick block's and the fold's own rules); 8.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); 7.8 the home's phone layout (2026-10-29); 6 with Shared 4.13.0 (2026-10-26: account links); 4.6 // raised 7 -> 8 for the openvibe-shared Kit 2 pin (kit.css grew) // back to the pre-Kit 2 value: Kit 2 has its own files (kit-data.css) since openvibe-shared v4.37.0, so kit.css weighs what it did // back to the pre-Kit 2 value: Kit 2 has its own files (kit-data.css) since openvibe-shared v4.37.0, so kit.css weighs what it did
externalFiles: 0, // 0
};