openvibers/openvibe.wiki Commits

Short home on a phone: one group of pages at a time, the rest folded (#159)

OP OpenVibers committed 14 days ago · 62fb0bc1e2 · 7 files, +254 −21

* Short home on a phone: one group of pages at a time, the rest folded

The home on a phone is now one or two screens. The groups of the official wiki's
pages (the launcher's groups) are one pick block: a row of chips picks one group and
the chip names it, so the group's own heading gives way to it. The lists of pages to
start with (most linked, recently revised) are a pick block the same way. The parts a
reader rarely need (the wikis people made, the questions, the other OpenVibe sites,
what shipped) become folds, closed on a phone and open where there is room.

Radio inputs and :has(), no script for the chips: every page stays in the page for
the crawlers, and a wider screen (or a browser without :has()) shows them all and no
chips, exactly as before. The desktop is unchanged.

* The budget is the size of the home with the phone pick blocks

Measured with openvibe-shared 4.53.0: the home is 42.9 KB of HTML (9.9 KB brotli)
and 46.1 KB of CSS (9.7 KB brotli) with the pick blocks and the folds.

Changes

ddiff --git a/public/css/wiki.css b/public/css/wiki.css
iindex 7cecdac..31ebd0d 100644
---- a/public/css/wiki.css
++++ b/public/css/wiki.css
@@@ -190,3 +190,44 @@ main .wk-h-group h3 { display: flex; flex-wrap: wrap; align-items: baseline; gap
.wk-h-rank > li > a { position: relative; display: inline-block; }
.wk-h-rank > li > a::after { content: ''; position: absolute; left: 0; right: 0; top: -9px; bottom: -9px; }
}
++
++/* Pick (the owner's short-pages rule, 2026-10-30): on a phone the home's blocks of the same kind are one 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; padding: 0; border: 0; font-size: 1.3rem; }
++ .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(--wk-line); color: var(--wk-muted); font-size: .9rem; font-weight: 650; white-space: nowrap; cursor: pointer; }
++ .pick-row input:checked + label { color: var(--wk-text); border-color: var(--wk-accent); background: color-mix(in srgb, var(--wk-accent) 12%, transparent); }
++ .pick-row input:focus-visible + label { outline: 2px solid var(--wk-accent); outline-offset: 2px; }
++ .pick-pane { display: none; }
++ /* The chip names the block, so the block's own heading gives way to it. */
++ .pick-pane .sc-sec > h2, .pick-pane .wk-h-group > h3 { 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. */
++.wk-fold-block { margin-top: 32px; }
++.wk-fold-block > summary { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 48px; padding: 0 16px; cursor: pointer; font-weight: 700; list-style: none; border: 1px solid var(--wk-line); border-radius: 14px; background: var(--wk-panel); }
++.wk-fold-block > summary::-webkit-details-marker { display: none; }
++.wk-fold-block > summary h2 { margin: 0; padding: 0; border: 0; font-size: 1.05rem; }
++.wk-fold-block > summary span { color: var(--wk-muted); font-weight: 600; font-size: .86rem; text-align: right; }
++.wk-fold-block > summary:focus-visible { outline: 2px solid var(--wk-accent); outline-offset: -2px; border-radius: 14px; }
++.wk-fold-block .sc-sec { margin: 0; padding: 0 16px 16px; }
++.wk-fold-block .sc-sec > h2 { display: none; }
++/* The shipped list carries its own title; the fold's summary already names it. */
++.wk-fold-block .ov-shipped-home h3 { display: none; }
++@media (min-width: 641px) {
++ .wk-fold-block { border: 0; background: none; border-radius: 0; margin-top: 56px; padding: 0; }
++ .wk-fold-block > summary { display: block; min-height: 0; padding: 0; border: 0; background: none; }
++ .wk-fold-block > summary h2 { margin: 0 0 6px; font-size: 1.3rem; border-bottom: 1px solid var(--wk-line); padding-bottom: .2em; }
++ .wk-fold-block > summary span { display: none; }
++ .wk-fold-block .sc-sec { padding: 0; }
++}
++@media (max-width: 640px) { .wk-fold-block { margin-top: 24px; } .wk-fold-block > summary span { display: none; } }
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 556dbeb..1894e96 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -35,6 +35,7 @@ const seo = require('openvibe-publishing/seo');
const ssr = require('openvibe-publishing/ssr');
const { renderPage, AI_SUMMARY, asset } = require('../render/layout');
const views = require('../render/views');
++const { foldBlock } = require('../render/short');
const frame = require('openvibe-shared/frame');
const showcase = require('openvibe-shared/showcase');
const content = require('../wiki/content');
@@@ -139,9 +140,15 @@ function createPages({ svc, viewers, platform, config, log = console, limits, vi
.map(async (s) => [s.id, await svc.shelf(s, req.actor, 60)])));
// The site's one-line AI summary: the shell turns it into the ai-summary meta and the home's
// WebPage JSON-LD (openvibe-publishing/layout v1.3.0 forwards `summary`).
-- send(req, res, 200, views.home({ spaces, recent, shelves, stats, linked, groups, generated, actor: req.actor }) + frame.shipped({ service: 'wiki', title: 'Recently shipped on OpenVibe.Wiki' }), {
++ // The rules that show the picked block of each pick block: one per block, written for this page.
++ const rules = [];
++ const body = views.home({ spaces, recent, shelves, stats, linked, groups, generated, actor: req.actor, rules })
++ // 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="wk-shipped-h">Recently shipped on OpenVibe.Wiki</h2>', html: frame.shipped({ service: 'wiki', title: 'Recently shipped on OpenVibe.Wiki' }) });
++ send(req, res, 200, body, {
robots: 'index, follow', cache: 'public', active: 'home', path: '/', styles: [showcase.STYLESHEET],
summary: AI_SUMMARY, description: HOME_DESCRIPTION,
++ head: rules.length ? `<style>@supports selector(:has(*)){@media (max-width:640px){${rules.join('')}}</style>` : '',
jsonLd: views.homeJsonLd({ shelves, spaces: spaces.filter((s) => s.kind === 'official' && s.visibility === 'public'), baseUrl: config.baseUrl }),
});
}));
ddiff --git a/server/render/short.js b/server/render/short.js
nnew file mode 100644
iindex 0000000..1f4852c
---- /dev/null
++++ b/server/render/short.js
@@@ -0,0 +1,54 @@
++'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.
++ * 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 public/css/wiki.css; 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 }) {
++ 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="pp-${esc(key)}-${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) #pp-${key}-${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 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="wk-fold wk-fold-block" id="${esc(id)}" open><summary>${head}<span>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>`;
++}
++
++module.exports = { pickBlock, pickRule, pickRules, foldBlock, foldScript };
ddiff --git a/server/render/views.js b/server/render/views.js
iindex c86db02..7917464 100644
---- a/server/render/views.js
++++ b/server/render/views.js
@@@ -7,10 +7,13 @@ const ssr = require('openvibe-publishing/ssr');
const showcase = require('openvibe-shared/showcase');
const { figureHtml } = require('openvibe-publishing/media');
const { infoboxToText, mediaRefs } = require('../wiki/content');
++const { pickBlock, pickRules, foldBlock } = require('./short');
const { html, raw } = ssr;
const t = (v) => raw(ssr.timeTag(v));
const e = encodeURIComponent;
++/** A chip's id: the name, safe to put in an id. */
++const slug = (s) => String(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
const VIS_LABEL = { public: 'Public', members: 'Members (any signed-in OpenVibe account)', vip: 'VIP (the wiki owner\'s OpenVibe.VIP members)', private: 'Private (people with a role in this wiki)' };
function wpath(space, page) { return `/w/${e(space.slug)}/${e(page.slug)}`; }
@@@ -64,7 +67,7 @@ const FAQ = [
{ q: 'How do I follow changes?', a: 'The whole site and each public wiki have Atom and JSON feeds, and signed-in readers can watch a page. Every public page is also served as JSON: add .json to its address.' },
];
--function home({ spaces, recent, shelves = new Map(), stats = null, linked = [], groups = null, generated = [], actor }) {
++function home({ spaces, recent, shelves = new Map(), stats = null, linked = [], groups = null, generated = [], actor, rules = [] }) {
const official = spaces.filter((s) => s.kind === 'official');
const user = spaces.filter((s) => s.kind === 'user');
const signedIn = !!(actor && actor.subject);
@@@ -81,7 +84,7 @@ function home({ spaces, recent, shelves = new Map(), stats = null, linked = [],
const inside = shelved.map((s) => {
const shelf = shelves.get(s.id);
const day = shelf.updated ? new Date(Number(shelf.updated)).toISOString().slice(0, 10) : null;
-- let body;
++ let blocks;
if (groups) {
const byGroup = new Map(groups.groups.map((g) => [g.id, []]));
const rest = [];
@@@ -91,12 +94,22 @@ function home({ spaces, recent, shelves = new Map(), stats = null, linked = [],
const g = host && groups.hosts.get(host);
(g && byGroup.has(g) ? byGroup.get(g) : rest).push(item);
}
-- const blocks = groups.groups.filter((g) => byGroup.get(g.id).length).map((g) => ({ name: g.name, items: byGroup.get(g.id) }));
++ blocks = groups.groups.filter((g) => byGroup.get(g.id).length).map((g) => ({ name: g.name, items: byGroup.get(g.id) }));
if (rest.length) blocks.push({ name: 'Platform and libraries', items: rest });
-- body = blocks.map((b) => html`<div class="wk-h-group"><h3>${b.name} <span>${plural(b.items.length, 'page')}</span></h3><div class="sc-grid">${b.items.map((item) => pageCard(s, item))}</div></div>`);
} else {
-- body = html`<div class="sc-grid">${shelf.pages.map((item) => pageCard(s, item))}</div>`;
++ blocks = [{ name: 'Pages', items: shelf.pages }];
}
++ // The groups are blocks of the same kind: on a phone a row of chips picks one of them and the chip names it.
++ const groupHtml = (b) => html`<div class="wk-h-group"><h3>${b.name} <span>${plural(b.items.length, 'page')}</span></h3><div class="sc-grid">${b.items.map((item) => pageCard(s, item))}</div></div>`;
++ const body = blocks.length > 1
++ ? raw(pickBlock({
++ key: `shelf-${s.slug}`,
++ label: `Pages of ${s.name}`,
++ head: `Pages of ${s.name}`,
++ panes: blocks.map((b) => ({ id: slug(b.name), label: b.name, html: String(groupHtml(b)) })),
++ }))
++ : html`${blocks.map(groupHtml)}`;
++ if (blocks.length > 1) rules.push(pickRules(`shelf-${s.slug}`, blocks.map((b) => slug(b.name))));
const lede = `${s.description ? `${s.description.replace(/\.?\s*$/, '.')} ` : ''}${plural(shelf.total, 'page')}${day ? `, updated ${day}` : ''}.`;
return html`<section class="sc-sec wk-h-shelf" id="inside-${s.slug}" aria-labelledby="wk-in-${s.slug}"><h2 id="wk-in-${s.slug}">The ${s.name} wiki</h2><p class="sc-lede">${lede}</p>
${shelf.root ? html`<p class="wk-h-root"><a href="${wpath(s, shelf.root.page)}"><b>Start with ${shelf.root.page.title}</b>${shelf.root.summary ? html`<span>${shelf.root.summary}</span>` : ''}</a></p>` : ''}
@@@ -124,6 +137,21 @@ ${stats && stats.pages ? html`<dl class="wk-h-stats">${tiles.slice(0, 4).map(([k
{ title: 'Publish and keep going', text: 'Publish now or schedule it. Every revision stays in the history, ready to compare or restore.' },
],
});
++ // The lists of the same kind are one pick block each: on a phone a row of chips picks one of them and the chip
++ // names it, so the list's own heading gives way to it. Every list stays in the page.
++ const pick = (key, label, head, panes) => {
++ const list = panes.filter(Boolean);
++ if (!list.length) return '';
++ if (list.length === 1) return raw(list[0].html);
++ rules.push(pickRules(key, list.map((p) => p.id)));
++ return raw(pickBlock({ key, label, head, panes: list }));
++ };
++ const linkedPane = linked.length ? { id: 'linked', label: 'Most linked pages', html: String(html`<section class="sc-sec" aria-labelledby="wk-linked-h"><h2 id="wk-linked-h">Most linked pages</h2><p class="sc-lede">The pages other pages point to most often: a good place to start reading.</p>
++<ol class="wk-h-rank">${linked.map((x) => html`<li><a href="${wpath(x.space, x.page)}">${x.page.title}</a><span class="wk-h-count">Linked from ${plural(x.links, 'page')}</span>${x.rev && x.rev.fields && x.rev.fields.summary ? html`<p>${x.rev.fields.summary}</p>` : ''}</li>`)}</ol></section>`) } : null;
++ const recentPane = recent.length ? { id: 'recent', label: 'Recently revised', html: String(html`<section class="sc-sec" aria-labelledby="wk-recent-h"><h2 id="wk-recent-h">Recently revised</h2><ul class="wk-recent">${recent.map((r) => html`<li><span><a href="${wpath(r.space, r.page)}">${r.page.title}</a> <span class="wk-muted">in ${r.space.name} · revision ${String(r.page.published_revision)}</span></span> <span class="wk-muted">${t(r.page.revision_published_at)}</span></li>`)}</ul>
++<p class="wk-more"><a href="/recent">All recent changes</a> · <a href="/feed.atom">Atom</a> · <a href="/feed.json">JSON Feed</a></p></section>`) } : null;
++ const officialPane = unshelved.length ? { id: 'official', label: 'Official wikis', html: String(html`<section class="sc-sec" aria-labelledby="wk-official"><h2 id="wk-official">Official wikis</h2><ul class="wk-spaces">${unshelved.map(card)}</ul></section>`) } : null;
++ const communityPane = user.length ? { id: 'community', label: 'Community wikis', html: String(html`<section class="sc-sec" aria-labelledby="wk-community"><h2 id="wk-community">Community wikis</h2><ul class="wk-spaces">${user.map(card)}</ul></section>`) } : null;
return html`${raw(showcase.hero({
eyebrow: 'OpenVibe.Wiki',
title: 'Any topic.', accent: 'A whole wiki.',
@@@ -131,16 +159,20 @@ ${stats && stats.pages ? html`<dl class="wk-h-stats">${tiles.slice(0, 4).map(([k
actions: [{ label: 'Vibe a wiki', href: '/vibe', primary: true }, { ...start, primary: false }],
aside: { html: String(aside) },
}))}
--${generated && generated.length ? html`<section class="sc-sec" aria-labelledby="wk-gen-h"><h2 id="wk-gen-h">Wikis people vibed</h2><p class="sc-lede">Each one researched and written by AI from the sources it lists, with its pictures credited. Open one, or make your own.</p>
--${raw(vibeCards(generated))}<p class="wk-more"><a href="/vibe">Vibe a wiki</a></p></section>` : ''}
++${generated && generated.length ? raw(foldBlock({
++ id: 'vibed',
++ head: '<h2 id="wk-gen-h">Wikis people vibed</h2>',
++ html: String(html`<section class="sc-sec" aria-labelledby="wk-gen-h"><h2>Wikis people vibed</h2><p class="sc-lede">Each one researched and written by AI from the sources it lists, with its pictures credited. Open one, or make your own.</p>
++${raw(vibeCards(generated))}<p class="wk-more"><a href="/vibe">Vibe a wiki</a></p></section>`),
++})) : ''}
<div id="spaces"></div>
${inside}
--${linked.length ? html`<section class="sc-sec" aria-labelledby="wk-linked-h"><h2 id="wk-linked-h">Most linked pages</h2><p class="sc-lede">The pages other pages point to most often: a good place to start reading.</p>
--<ol class="wk-h-rank">${linked.map((x) => html`<li><a href="${wpath(x.space, x.page)}">${x.page.title}</a><span class="wk-h-count">Linked from ${plural(x.links, 'page')}</span>${x.rev && x.rev.fields && x.rev.fields.summary ? html`<p>${x.rev.fields.summary}</p>` : ''}</li>`)}</ol></section>` : ''}
--${recent.length ? html`<section class="sc-sec" aria-labelledby="wk-recent-h"><h2 id="wk-recent-h">Recently revised</h2><ul class="wk-recent">${recent.map((r) => html`<li><span><a href="${wpath(r.space, r.page)}">${r.page.title}</a> <span class="wk-muted">in ${r.space.name} · revision ${String(r.page.published_revision)}</span></span> <span class="wk-muted">${t(r.page.revision_published_at)}</span></li>`)}</ul>
--<p class="wk-more"><a href="/recent">All recent changes</a> · <a href="/feed.atom">Atom</a> · <a href="/feed.json">JSON Feed</a></p></section>` : ''}
--${unshelved.length ? html`<section class="sc-sec" aria-labelledby="wk-official"><h2 id="wk-official">Official wikis</h2><ul class="wk-spaces">${unshelved.map(card)}</ul></section>` : ''}
--${user.length ? html`<section class="sc-sec" aria-labelledby="wk-community"><h2 id="wk-community">Community wikis</h2><ul class="wk-spaces">${user.map(card)}</ul></section>` : ''}
++${pick('read', 'Pages to start with', 'Pages to start with', [linkedPane, recentPane])}
++${(officialPane || communityPane) ? raw(foldBlock({
++ id: 'wikis',
++ head: '<h2 id="wk-wikis-h">Wikis to explore</h2>',
++ html: String(html`${officialPane ? raw(officialPane.html) : ''}${communityPane ? raw(communityPane.html) : ''}`),
++})) : ''}
<div class="sc-sec"><details class="wk-h-fold"><summary>Start a wiki of your own<span>what it gets, how a page comes together</span></summary>${raw(showcase.features({
title: 'What a wiki gets',
items: [
@@@ -153,8 +185,16 @@ ${user.length ? html`<section class="sc-sec" aria-labelledby="wk-community"><h2
],
}))}
${raw(steps)}</details></div>
--<section class="sc-sec wk-h-faq" aria-labelledby="wk-faq-h"><h2 id="wk-faq-h">Questions</h2>${FAQ.map((f) => html`<details><summary>${f.q}</summary><p>${f.a}</p></details>`)}</section>
--${raw(showcase.network({ service: 'wiki' }))}`;
++${raw(foldBlock({
++ id: 'faq',
++ head: '<h2 id="wk-faq-h">Questions</h2>',
++ html: String(html`<section class="sc-sec wk-h-faq" aria-labelledby="wk-faq-h"><h2>Questions</h2>${FAQ.map((f) => html`<details><summary>${f.q}</summary><p>${f.a}</p></details>`)}</section>`),
++}))}
++${raw(foldBlock({
++ id: 'network',
++ head: '<h2 id="wk-part-h">Part of OpenVibe</h2>',
++ html: String(showcase.network({ service: 'wiki' })),
++}))}`;
}
// ── "Vibe a wiki" (server/wiki/vibe.js) ─────────────────────
ddiff --git a/test/home-short.test.js b/test/home-short.test.js
nnew file mode 100644
iindex 0000000..2133a04
---- /dev/null
++++ b/test/home-short.test.js
@@@ -0,0 +1,88 @@
++'use strict';
++/**
++ * The home on a phone (the owner's short-pages rule, 2026-10-30): the home's blocks 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 block stays in the page for crawlers; a wider screen shows them all and no chips.
++ */
++const assert = require('assert');
++const H = require('./helpers');
++const { seed } = require('../server/wiki/seed');
++const views = require('../server/render/views');
++
++const cite = { url: 'https://github.com/OpenVibers/OpenVibe.Wiki/blob/main/README.md', title: 'README', retrievedAt: '2026-10-11T12:00:00.000Z', quote: 'A fixture page.' };
++const page = (title, parent, links, domain) => ({
++ title, parent,
++ body: `**${title}** is a fixture page. ${links.map((l) => `It links to [[${l}]].`).join(' ')} ${H.LONG}\n`,
++ summary: `${title} in one line.`,
++ infobox: domain ? [{ key: 'domain', label: 'Domain', type: 'text', value: domain }] : [],
++ citations: [cite],
++});
++const data = {
++ space: { slug: 'openvibe', name: 'OpenVibe', description: 'The official fixture wiki.', kind: 'official', visibility: 'public' },
++ pages: [
++ page('OpenVibe', null, ['OpenVibe.Live', 'OpenVibe.Network'], 'openvibe.network'),
++ page('OpenVibe.Network', 'OpenVibe', [], 'openvibe.network'),
++ page('OpenVibe.Live', 'OpenVibe', ['OpenVibe.Network'], 'openvibe.live'),
++ page('OpenVibe.Publishing', 'OpenVibe', ['OpenVibe.Network'], null),
++ ],
++};
++
++(async () => {
++ const h = await H.boot();
++ try {
++ await seed(h.svc, data, { log: H.quiet });
++ // A community wiki, so the home shows the wikis people made.
++ await h.svc.createSpace({ slug: 'classic-rock', name: 'Classic rock', description: 'Every band and album.', kind: 'user', visibility: 'public' }, { kind: 'user', subject: H.subject(), staff: false });
++
++ // The home: the lists of pages are one pick block, the parts a reader rarely needs are folds.
++ const r = await H.req(h, 'GET', '/');
++ assert.strictEqual(r.status, 200);
++ const text = r.text;
++
++ assert.ok(text.includes('<div class="pick" id="pick-read">'), 'the pages to start with are one pick block');
++ assert.match(text, /<div class="pick-row" role="radiogroup" aria-label="Pages to start with">/);
++ const chips = [...text.matchAll(/<input type="radio" name="pick-read" id="pk-read-([a-z]+)" value="\1"( checked)?><label for="pk-read-\1">([^<]+)<\/label>/g)];
++ assert.deepStrictEqual(chips.map((m) => m[1]), ['linked', 'recent'], 'a chip per list');
++ assert.deepStrictEqual(chips.filter((m) => m[2]).map((m) => m[1]), ['linked'], 'the first list is picked');
++ assert.strictEqual((text.match(/class="pick-pane"/g) || []).length, chips.length, 'one pane per chip');
++ // Every list stays in the page for the crawlers.
++ assert.ok(text.includes('Most linked pages') && text.includes('Recently revised'), 'both lists are in the page');
++
++ // The folds: open in the page, closed on a phone, and their content stays in the page.
++ for (const id of ['wikis', 'faq', 'network', 'shipped']) {
++ assert.ok(text.includes(`<details class="wk-fold wk-fold-block" id="${id}" open>`), `${id} is a fold, open in the page`);
++ assert.ok(text.includes(`document.getElementById("${id}")`), `${id} has the script that closes it on a phone`);
++ }
++ assert.ok(text.includes('Who can start a wiki?'), 'the questions are still in the page');
++ assert.ok(text.includes('Part of OpenVibe'), 'the other OpenVibe places are still in the page');
++
++ // The shelf: the groups of the official wiki are one pick block, a chip per group.
++ const spaces = await h.svc.listSpaces({ kind: 'anonymous' });
++ const shelf = await h.svc.shelf(spaces[0], { kind: 'anonymous' }, 60);
++ const groups = {
++ groups: [{ id: 'watch', name: 'Watch & stream' }, { id: 'you', name: 'Your OpenVibe' }],
++ hosts: new Map([['openvibe.live', 'watch'], ['openvibe.network', 'you']]),
++ };
++ const rules = [];
++ const grouped = String(views.home({ spaces, recent: [], shelves: new Map([[spaces[0].id, shelf]]), groups, actor: { kind: 'anonymous' }, rules }));
++ assert.ok(grouped.includes('<div class="pick" id="pick-shelf-openvibe">'), 'the groups are one pick block');
++ const groupChips = [...grouped.matchAll(/<input type="radio" name="pick-shelf-openvibe" id="pk-shelf-openvibe-([a-z-]+)" value="\1"( checked)?><label for="pk-shelf-openvibe-\1">([^<]+)<\/label>/g)];
++ assert.deepStrictEqual(groupChips.map((m) => m[1]), ['watch-stream', 'your-openvibe', 'platform-and-libraries'], 'a chip per group');
++ assert.deepStrictEqual(groupChips.filter((m) => m[2]).map((m) => m[1]), ['watch-stream'], 'the first group is picked');
++ // Every page stays in the page.
++ assert.strictEqual((grouped.match(/class="sc-card wk-h-card"/g) || []).length, 3, 'every page card is in the page');
++ // The rule that shows the picked group is written for the page.
++ assert.ok(rules.join('').includes('.pick:has(#pk-shelf-openvibe-watch-stream:checked) #pp-shelf-openvibe-watch-stream{display:block}'), 'the picked group shows');
++
++ // The CSS guard: the chips are hidden and the blocks all show until 640 px and where :has() is understood.
++ const css = require('fs').readFileSync(require('path').join(__dirname, '..', 'public', 'css', 'wiki.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-pane \{ display: none; \}/, 'the blocks are hidden on a phone');
++
++ console.log('home-short ok');
++ } finally {
++ await h.stop();
++ }
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/home.test.js b/test/home.test.js
iindex dd67290..1be5881 100644
---- a/test/home.test.js
++++ b/test/home.test.js
@@@ -81,8 +81,11 @@ const ld = (text) => [...text.matchAll(/<script type="application\/ld\+json">([\
const groups = { groups: [{ id: 'watch', name: 'Watch & stream' }, { id: 'you', name: 'Your OpenVibe' }, { id: 'talk', name: 'Talk & learn' }],
hosts: new Map([['openvibe.live', 'watch'], ['openvibe.network', 'you']]) };
const grouped = String(views.home({ spaces, recent: [], shelves: new Map([[spaces[0].id, shelf]]), groups, actor: { kind: 'anonymous' } }));
-- const order = ['Watch &amp; stream', 'OpenVibe.<wbr>Live', 'Your OpenVibe', 'OpenVibe.<wbr>Network', 'Platform and libraries', 'OpenVibe.<wbr>Publishing'].map((s) => grouped.indexOf(s));
-- assert.ok(order.every((i, n) => i > 0 && (n === 0 || i > order[n - 1])), `grouped in order: ${order}`);
++ // The groups are one pick block: the chips name them in order, and each group's pages follow in its pane.
++ const chips = ['Watch &amp; stream', 'Your OpenVibe', 'Platform and libraries'].map((s) => grouped.indexOf(s));
++ assert.ok(chips.every((i, n) => i > 0 && (n === 0 || i > chips[n - 1])), `the groups are in order: ${chips}`);
++ const inOrder = (list, what) => assert.ok(list.every((i, n) => i > 0 && (n === 0 || i > list[n - 1])), `${what} in order: ${list}`);
++ inOrder(['OpenVibe.<wbr>Live', 'OpenVibe.<wbr>Network', 'OpenVibe.<wbr>Publishing'].map((s) => grouped.indexOf(s)), 'the pages');
assert.ok(!grouped.includes('Talk &amp; learn'), 'a group with no pages is left out');
// The Frame reader: groups and hosts from /api/chrome, cached; unreachable → null (one flat shelf).
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex 77029e2..95c3bea 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -12,14 +12,14 @@ const { spawn } = require('child_process');
const { measure, check, format } = require('openvibe-shared/perf-budget');
const BUDGETS = {
-- htmlRawKB: 42, // 41.1 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 37 with the Part of OpenVibe section (Shared 4.13, 2026-10-26); 31.5 on 2026-10-22: the home's how-it-works steps, questions (and their FAQPage JSON-LD), links around the site and closing call; 24.9 (fresh database; 2026-09-29 the showcase hero and features with six inline ring icons)
++ htmlRawKB: 43, // 42.9 with the phone pick blocks (2026-10-30); 41.1 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 37 with the Part of OpenVibe section (Shared 4.13, 2026-10-26); 31.5 on 2026-10-22: the home's how-it-works steps, questions (and their FAQPage JSON-LD), links around the site and closing call; 24.9 (fresh database; 2026-09-29 the showcase hero and features with six inline ring icons)
htmlBrotliKB: 10, // 8.8 with the Part of OpenVibe section (Shared 4.13, 2026-10-26); 7.7 (2026-10-22 the richer home); 6.3
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 (197.7 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 (45.6 before web-runtime.js)
cssFiles: 2, // 1
-- cssRawKB: 44, // 42.9 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 41.1 touch targets on phones (2026-10-29); 36.2 openvibe-shared 4.26.1 (hero visual); 30.7 with Shared 4.13.0 (2026-10-26: account links); 23.5 on 2026-10-22 (the home's numbers, shelves by site group, ranked pages, questions and links); 19.1 (wiki.css + the cached /shared/showcase.css; 2026-10-15 the home's space cards, shelf and recent list)
-- cssBrotliKB: 9.3, // 9.1 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 8.6 the home's phone rows and fold (2026-10-29); 6.6–6.7 with Shared 4.13.0; raised from 8 for openvibe-shared v4.39.0 (+1.5 KB raw of kit/room/showcase CSS); measured 8.3
++ cssRawKB: 47, // 46.1 with the phone pick and fold rules (2026-10-30); 42.9 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 41.1 touch targets on phones (2026-10-29); 36.2 openvibe-shared 4.26.1 (hero visual); 30.7 with Shared 4.13.0 (2026-10-26: account links); 23.5 on 2026-10-22 (the home's numbers, shelves by site group, ranked pages, questions and links); 19.1 (wiki.css + the cached /shared/showcase.css; 2026-10-15 the home's space cards, shelf and recent list)
++ cssBrotliKB: 10, // 9.7 with the phone pick and fold rules (2026-10-30); 9.1 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 8.6 the home's phone rows and fold (2026-10-29); 6.6–6.7 with Shared 4.13.0; raised from 8 for openvibe-shared v4.39.0 (+1.5 KB raw of kit/room/showcase CSS); measured 8.3
externalFiles: 1, // 0
};