openvibers/openvibe.wiki Commits
Short home on a phone: one group of pages at a time, the rest folded (#159)
Changes
@@@ -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; } }@@@ -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 }),});}));nnew file mode 100644@@@ -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, '&').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 }) {++ 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 };@@@ -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(([kactions: [{ 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) ─────────────────────nnew file mode 100644@@@ -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); });@@@ -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 & 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 & 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 & learn'), 'a group with no pages is left out');// The Frame reader: groups and hosts from /api/chrome, cached; unreachable → null (one flat shelf).@@@ -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.3jsFiles: 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.3externalFiles: 1, // 0};
@@ -190,3 +190,44 @@ main .wk-h-group h3 { display: flex; flex-wrap: wrap; align-items: baseline; gap
@@ -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 { position: relative; display: inline-block; }
.wk-h-rank > li > a::after { content: ''; position: absolute; left: 0; right: 0; top: -9px; bottom: -9px; }
.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; } }
@@ -35,6 +35,7 @@ const seo = require('openvibe-publishing/seo');
@@ -35,6 +35,7 @@ const seo = require('openvibe-publishing/seo');
const ssr = require('openvibe-publishing/ssr');
const ssr = require('openvibe-publishing/ssr');
const { renderPage, AI_SUMMARY, asset } = require('../render/layout');
const { renderPage, AI_SUMMARY, asset } = require('../render/layout');
const views = require('../render/views');
const views = require('../render/views');
+const { foldBlock } = require('../render/short');
const frame = require('openvibe-shared/frame');
const frame = require('openvibe-shared/frame');
const showcase = require('openvibe-shared/showcase');
const showcase = require('openvibe-shared/showcase');
const content = require('../wiki/content');
const content = require('../wiki/content');
@@ -139,9 +140,15 @@ function createPages({ svc, viewers, platform, config, log = console, limits, vi
@@ -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)])));
.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
// 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`).
// 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],
robots: 'index, follow', cache: 'public', active: 'home', path: '/', styles: [showcase.STYLESHEET],
summary: AI_SUMMARY, description: HOME_DESCRIPTION,
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 }),
jsonLd: views.homeJsonLd({ shelves, spaces: spaces.filter((s) => s.kind === 'official' && s.visibility === 'public'), baseUrl: config.baseUrl }),
});
});
}));
}));
new file mode 100644
new file mode 100644
@@ -0,0 +1,54 @@
@@ -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, '&').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 }) {
+ 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 };
@@ -7,10 +7,13 @@ const ssr = require('openvibe-publishing/ssr');
@@ -7,10 +7,13 @@ const ssr = require('openvibe-publishing/ssr');
const showcase = require('openvibe-shared/showcase');
const showcase = require('openvibe-shared/showcase');
const { figureHtml } = require('openvibe-publishing/media');
const { figureHtml } = require('openvibe-publishing/media');
const { infoboxToText, mediaRefs } = require('../wiki/content');
const { infoboxToText, mediaRefs } = require('../wiki/content');
+const { pickBlock, pickRules, foldBlock } = require('./short');
const { html, raw } = ssr;
const { html, raw } = ssr;
const t = (v) => raw(ssr.timeTag(v));
const t = (v) => raw(ssr.timeTag(v));
const e = encodeURIComponent;
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)' };
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)}`; }
function wpath(space, page) { return `/w/${e(space.slug)}/${e(page.slug)}`; }
@@ -64,7 +67,7 @@ const FAQ = [
@@ -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.' },
{ 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 official = spaces.filter((s) => s.kind === 'official');
const user = spaces.filter((s) => s.kind === 'user');
const user = spaces.filter((s) => s.kind === 'user');
const signedIn = !!(actor && actor.subject);
const signedIn = !!(actor && actor.subject);
@@ -81,7 +84,7 @@ function home({ spaces, recent, shelves = new Map(), stats = null, linked = [],
@@ -81,7 +84,7 @@ function home({ spaces, recent, shelves = new Map(), stats = null, linked = [],
const inside = shelved.map((s) => {
const inside = shelved.map((s) => {
const shelf = shelves.get(s.id);
const shelf = shelves.get(s.id);
const day = shelf.updated ? new Date(Number(shelf.updated)).toISOString().slice(0, 10) : null;
const day = shelf.updated ? new Date(Number(shelf.updated)).toISOString().slice(0, 10) : null;
- let body;
+ let blocks;
if (groups) {
if (groups) {
const byGroup = new Map(groups.groups.map((g) => [g.id, []]));
const byGroup = new Map(groups.groups.map((g) => [g.id, []]));
const rest = [];
const rest = [];
@@ -91,12 +94,22 @@ function home({ spaces, recent, shelves = new Map(), stats = null, linked = [],
@@ -91,12 +94,22 @@ function home({ spaces, recent, shelves = new Map(), stats = null, linked = [],
const g = host && groups.hosts.get(host);
const g = host && groups.hosts.get(host);
(g && byGroup.has(g) ? byGroup.get(g) : rest).push(item);
(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 });
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 {
} 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}` : ''}.`;
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>
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>` : ''}
${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
@@ -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.' },
{ 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({
return html`${raw(showcase.hero({
eyebrow: 'OpenVibe.Wiki',
eyebrow: 'OpenVibe.Wiki',
title: 'Any topic.', accent: 'A whole 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
@@ -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 }],
actions: [{ label: 'Vibe a wiki', href: '/vibe', primary: true }, { ...start, primary: false }],
aside: { html: String(aside) },
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>
<div id="spaces"></div>
${inside}
${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({
<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',
title: 'What a wiki gets',
items: [
items: [
@@ -153,8 +185,16 @@ ${user.length ? html`<section class="sc-sec" aria-labelledby="wk-community"><h2
@@ -153,8 +185,16 @@ ${user.length ? html`<section class="sc-sec" aria-labelledby="wk-community"><h2
],
],
}))}
}))}
${raw(steps)}</details></div>
${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) ─────────────────────
// ── "Vibe a wiki" (server/wiki/vibe.js) ─────────────────────
new file mode 100644
new file mode 100644
@@ -0,0 +1,88 @@
@@ -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); });
@@ -81,8 +81,11 @@ const ld = (text) => [...text.matchAll(/<script type="application\/ld\+json">([\
@@ -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' }],
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']]) };
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 grouped = String(views.home({ spaces, recent: [], shelves: new Map([[spaces[0].id, shelf]]), groups, actor: { kind: 'anonymous' } }));
- const order = ['Watch & 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 & 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 & learn'), 'a group with no pages is left out');
assert.ok(!grouped.includes('Talk & learn'), 'a group with no pages is left out');
// The Frame reader: groups and hosts from /api/chrome, cached; unreachable → null (one flat shelf).
// The Frame reader: groups and hosts from /api/chrome, cached; unreachable → null (one flat shelf).
@@ -12,14 +12,14 @@ const { spawn } = require('child_process');
@@ -12,14 +12,14 @@ const { spawn } = require('child_process');
const { measure, check, format } = require('openvibe-shared/perf-budget');
const { measure, check, format } = require('openvibe-shared/perf-budget');
const BUDGETS = {
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
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)
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)
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)
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
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
externalFiles: 1, // 0
};
};