openvibers/openvibe.blog Commits

Post pages read better: measure, read time, contents, one-line series (#144)

OP OpenVibers committed 14 days ago · eef6c03631 · 6 files, +203 −14

* Post pages read better: a 65-character measure, a read time, a contents list and a one-line series

From the market research outcomes for the blog: 18 px text with a 65ch measure (17 px / 1.5 on phones), "≈ N min
read" at 265 words a minute, the post's own sections as a contents list (a fold under the title on phones, a rail
to the left from 1100 px; headings inside a fold get ids but stay out of the list), and the series as one line
("Part 36 of 118") with previous/next and the parts around this one after the text, instead of 50 links before it.
The series position comes from the post's place in the series when it has no number.

* Budget: blog.css carries the post pages' reading layout (47.7 KB raw, 10.1 brotli)

Changes

ddiff --git a/public/css/blog.css b/public/css/blog.css
iindex 3f967d7..5579eb3 100644
---- a/public/css/blog.css
++++ b/public/css/blog.css
@@@ -28,8 +28,8 @@ h2 { font-size: 1.35rem; margin: 1.6rem 0 .5rem; }
.ov-media { margin: 1.2rem 0; }
.ov-media figcaption { font-size: .9rem; color: var(--text-muted); }
.ov-media-missing { padding: 28px 16px; border: 1px dashed var(--border-light, #2c3d5c); border-radius: 8px; text-align: center; color: var(--text-secondary); background: var(--bg-tertiary, #172238); }
--.disclosure { border: 1px solid var(--warning, #f59e0b); border-radius: 8px; padding: 8px 12px; }
--.series { border: 1px solid var(--border, #1f2d47); border-radius: 8px; padding: 8px 14px; margin: 12px 0; font-size: .95rem; }
++.disclosure { border-left: 3px solid var(--warning, #f59e0b); border-radius: 2px; padding: 4px 0 4px 12px; margin: 12px 0; color: var(--text-secondary, #96a7c2); font-size: .92rem; line-height: 1.5; }
++.disclosure strong { color: var(--text-primary); }
.sources ol { padding-left: 1.2rem; }
.desk-list { list-style: none; padding: 0; }
.desk-card h2 { margin: 16px 0 4px; font-size: 1.2rem; }
@@@ -202,3 +202,49 @@ body.blog-home .page { max-width: 1120px; }
.pick, .pick-body, .pick-pane { display: contents; }
.pick-bar { display: none; }
}
++
++/* ── The reading layout (the blog outcomes, research-2026-10-30/market/outcomes/blog.md): a 65-character measure at
++ 18 px, mobile leading at 1.5, a stated read time, the post's own sections as a contents list (a fold under the title
++ on phones, a rail to the left of the text from 1100 px), and the series as one line above and its neighbours below. */
++body.post-page .page { max-width: 1160px; }
++.post-wrap { display: grid; grid-template-columns: minmax(0, 1fr); }
++.post-main { min-width: 0; width: 100%; max-width: 720px; margin: 0 auto; }
++.post-body { font-size: 1.125rem; line-height: 1.65; }
++.post-body p, .post-body li, .post-body blockquote { max-width: 65ch; }
++.post-body h3[id] { scroll-margin-top: calc(var(--ovnav-real-h, 56px) + 16px); }
++.post-body h3:target { color: var(--accent, #3b82f6); }
++.read-time { white-space: nowrap; }
++.series-line { margin: 4px 0 0; color: var(--text-secondary, #96a7c2); font-size: .95rem; }
++.toc-fold, .series-more { margin: 14px 0 4px; border: 1px solid var(--border, #1f2d47); border-radius: 12px; background: var(--bg-secondary, transparent); }
++.toc-fold > summary, .series-more > summary { display: flex; align-items: center; gap: 8px; min-height: 46px; padding: 0 14px; font-weight: 700; cursor: pointer; list-style: none; }
++.toc-fold > summary::-webkit-details-marker, .series-more > summary::-webkit-details-marker { display: none; }
++.toc-fold > summary::after, .series-more > summary::after { content: ''; margin-left: auto; width: 8px; height: 8px; border-right: 2px solid currentColor; border-bottom: 2px solid currentColor; transform: rotate(45deg); opacity: .6; transition: transform .15s; }
++.toc-fold[open] > summary::after, .series-more[open] > summary::after { transform: rotate(225deg); }
++.toc-fold > summary span, .series-more > summary span { color: var(--text-muted, #7386a3); font-weight: 500; font-size: .88rem; }
++.toc-fold ol, .series-more ol { margin: 0; padding: 0 14px 10px 36px; }
++.toc-fold li a, .series-more li a { display: block; padding: 9px 0; color: var(--text-secondary, #96a7c2); text-decoration: none; line-height: 1.35; }
++.toc-fold li a:hover, .series-more li a:hover { color: var(--text-primary); text-decoration: underline; }
++.series-more li[aria-current] { padding: 9px 0; font-weight: 700; color: var(--text-primary); }
++.series-more p { margin: 0; padding: 0 14px 12px; }
++.series-nav { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin: 32px 0 0; }
++.series-nav a { display: flex; flex-direction: column; gap: 3px; min-width: 0; padding: 12px 14px; border: 1px solid var(--border, #1f2d47); border-radius: 12px; color: var(--text-primary); text-decoration: none; }
++.series-nav a:hover { border-color: var(--accent, #3b82f6); }
++.series-nav a[rel="next"] { grid-column: 2; text-align: right; }
++.series-nav small { color: var(--text-muted, #7386a3); font-size: .74rem; font-weight: 800; letter-spacing: .07em; text-transform: uppercase; }
++.series-nav span { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; line-height: 1.35; }
++.toc-rail { display: none; }
++@media (max-width: 600px) {
++ .post-body { font-size: 1.0625rem; line-height: 1.5; }
++ .series-nav { grid-template-columns: minmax(0, 1fr); }
++ .series-nav a[rel="next"] { grid-column: 1; }
++}
++@media (min-width: 1100px) {
++ .post-wrap.has-toc { grid-template-columns: 220px minmax(0, 720px); column-gap: 56px; justify-content: center; }
++ .post-wrap.has-toc .post-main { margin: 0; }
++ .post-wrap.has-toc .toc-fold { display: none; }
++ .toc-rail { display: block; position: sticky; top: calc(var(--ovnav-real-h, 56px) + 24px); align-self: start; max-height: calc(100vh - var(--ovnav-real-h, 56px) - 48px); overflow-y: auto; margin-top: 1.4rem; padding-bottom: 16px; }
++ .toc-rail h2 { margin: 0 0 8px; font-size: .74rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
++ .toc-rail ol { list-style: none; margin: 0; padding: 0; }
++ .toc-rail a { display: block; padding: 5px 0 5px 12px; border-left: 2px solid var(--border, #1f2d47); color: var(--text-secondary, #96a7c2); font-size: .9rem; line-height: 1.35; text-decoration: none; }
++ .toc-rail a:hover { color: var(--text-primary); border-left-color: var(--accent, #3b82f6); }
++}
ddiff --git a/server/http/public.js b/server/http/public.js
iindex 1b3bd88..d47915d 100644
---- a/server/http/public.js
++++ b/server/http/public.js
@@@ -315,14 +315,28 @@ function createPublicRoutes(ctx) {
}
}
++ /**
++ * The series around this post: its place in the whole series, the previous and next parts, and up to 11 parts
++ * around it for the fold. Every part's row is read (they are small), but titles only for the parts shown.
++ */
async function seriesView(blog, post) {
const series = await blogs.seriesById(post.series_id);
if (!series) return null;
-- const { posts: rows } = await posts.listPublished({ blogId: blog.id, seriesId: series.id, limit: 50, order: 'series' });
-- const revs = await store.revisions.getMany(rows.map((p) => ({ entityId: p.id, revision: p.published_revision })));
++ const { posts: rows, total } = await posts.listPublished({ blogId: blog.id, seriesId: series.id, limit: 5000, order: 'series' });
++ const at = rows.findIndex((p) => p.id === post.id);
++ const from = at < 0 ? 0 : Math.max(0, Math.min(at - 5, rows.length - 11));
++ const shown = rows.slice(from, from + 11);
++ const near = [at > 0 ? rows[at - 1] : null, at >= 0 && at + 1 < rows.length ? rows[at + 1] : null];
++ const want = [...shown, ...near.filter(Boolean)];
++ const revs = await store.revisions.getMany(want.map((p) => ({ entityId: p.id, revision: p.published_revision })));
++ const titleOf = new Map(want.map((p, i) => [p.id, (revs[i] || { fields: {} }).fields.title]));
++ const part = (p) => (p ? { title: titleOf.get(p.id), url: publication.postPath(blog, p) } : null);
return {
-- title: series.title, url: reading.urls.series(blog, series), position: post.series_position,
-- items: rows.map((p, i) => ({ title: (revs[i] || { fields: {} }).fields.title, url: publication.postPath(blog, p), current: p.id === post.id })),
++ title: series.title, url: reading.urls.series(blog, series),
++ position: post.series_position || (at >= 0 ? at + 1 : null),
++ total: Number(total) || rows.length, start: from + 1,
++ prev: part(near[0]), next: part(near[1]),
++ items: shown.map((p) => ({ title: titleOf.get(p.id), url: publication.postPath(blog, p), current: p.id === post.id })),
};
}
@@@ -343,6 +357,7 @@ function createPublicRoutes(ctx) {
const csrf = req.viewer.kind === 'user' ? csrfToken(config, req.viewer) : '';
send(req, res, status, {
title: rev.fields.title,
++ bodyClass: 'post-page',
description: rev.fields.summary || ssr.markdownToText(rev.content, 160),
decision,
canonical: publication.postUrl(blog, post),
ddiff --git a/server/render/pages.js b/server/render/pages.js
iindex 2a74a11..dfea64e 100644
---- a/server/render/pages.js
++++ b/server/render/pages.js
@@@ -33,6 +33,65 @@ function renderMarkdownWithDetails(source, opts) {
}
const dateLabel = (v) => (v ? new Date(v).toISOString().slice(0, 10) : null);
++
++// ── The reading layout (owner ask 3, the blog outcomes: research-2026-10-30/market/outcomes/blog.md) ──
++// A stated read time, and a contents list built from the post's own sections. Markdown's "##" renders as <h3> (the
++// page's h1 is the title), so a post's sections are its top-level <h3>s; those inside a fold (<details>) get an id but
++// stay out of the list, since a link into a closed fold lands on nothing.
++const WORDS_PER_MINUTE = 265;
++/** "≈ N min read" from the Markdown: rounded, never 0; code blocks, media lines and link targets are not read. */
++function readMinutes(content) {
++ const text = String(content || '')
++ .replace(/```[\s\S]*?```/g, ' ')
++ .replace(/\[\[media:[^\]]*\]\]/g, ' ')
++ .replace(/\]\([^)]*\)/g, ' ')
++ .replace(/<[^>]+>/g, ' ');
++ const words = (text.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) || []).length;
++ return Math.max(1, Math.round(words / WORDS_PER_MINUTE));
++}
++const RESERVED_IDS = new Set(['main', 'comments', 'contents', 'sources', 'series', 'top']);
++const ENTITIES = { '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&#39;': "'", '&#x27;': "'" };
++const decode = (t) => t.replace(/&(?:amp|lt|gt|quot|#39|#x27);/g, (m) => ENTITIES[m]);
++const slug = (t) => t.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 64);
++/** The body with an id on every section heading, and the top-level sections for the contents list. */
++function sectionsOf(bodyHtml) {
++ const used = new Set(RESERVED_IDS);
++ const sections = [];
++ let depth = 0;
++ const html = String(bodyHtml).replace(/<details\b[^>]*>|<\/details>|<h3>([\s\S]*?)<\/h3>/g, (m, inner) => {
++ if (m.startsWith('<details')) { depth++; return m; }
++ if (m === '</details>') { depth = Math.max(0, depth - 1); return m; }
++ const text = decode(inner.replace(/<[^>]+>/g, '')).replace(/\s+/g, ' ').trim();
++ const base = slug(text) || 'section';
++ let id = base;
++ for (let n = 2; used.has(id); n++) id = `${base}-${n}`;
++ used.add(id);
++ if (depth === 0 && text) sections.push({ id, text });
++ return `<h3 id="${id}">${inner}</h3>`;
++ });
++ return { html, sections };
++}
++/** The contents list: a fold under the title on phones, a rail beside the text on wide screens (CSS picks one). */
++function contentsHtml(sections) {
++ const list = h`<ol>${sections.map((x) => h`<li><a href="#${x.id}">${x.text}</a></li>`)}</ol>`;
++ return String(h`<nav class="toc-rail" aria-labelledby="toc-rail-h"><h2 id="toc-rail-h">On this page</h2>${list}</nav>`);
++}
++function contentsFold(sections) {
++ const list = h`<ol>${sections.map((x) => h`<li><a href="#${x.id}">${x.text}</a></li>`)}</ol>`;
++ return String(h`<details class="toc-fold"><summary>On this page <span>${sections.length} sections</span></summary>${list}</details>`);
++}
++/** The series: one line under the title; the previous and next parts, and the parts around this one, after the text. */
++function seriesLine(series) {
++ const where = series.position && series.total ? `Part ${series.position} of ${series.total} in the series` : (series.position ? `Part ${series.position} of the series` : 'Part of the series');
++ return String(h`<p class="series-line">${where} <a href="${series.url}">${series.title}</a></p>`);
++}
++function seriesFoot(series) {
++ const step = (it, rel, label) => (it ? h`<a rel="${rel}" href="${it.url}"><small>${label}</small><span>${it.title}</span></a>` : '');
++ const nav = series.prev || series.next ? h`<nav class="series-nav" aria-label="${`More in ${series.title}`}">${step(series.prev, 'prev', 'Previous')}${step(series.next, 'next', 'Next')}</nav>` : '';
++ const items = series.items || [];
++ const more = items.length > 1 ? h`<details class="series-more"><summary>More in ${series.title}${series.total ? h` <span>${series.total} parts</span>` : ''}</summary><ol start="${series.start || 1}">${items.map((x) => (x.current ? h`<li aria-current="page">${x.title}</li>` : h`<li><a href="${x.url}">${x.title}</a></li>`))}</ol><p><a href="${series.url}">${series.total ? `All ${series.total} parts` : 'The whole series'}</a></p></details>` : '';
++ return String(h`${nav}${more}`);
++}
const time = (v) => raw(ssr.timeTag(v == null ? null : new Date(v).toISOString(), { label: dateLabel(v) || undefined }));
/**
@@@ -139,25 +198,35 @@ function postPage(o) {
const gallery = attachments.filter((a) => a.role === 'gallery');
const badge = { members: 'Members only', private: 'Private', unlisted: 'Unlisted' }[post.visibility];
const updated = rev.number > 1 && post.first_published_at && Date.parse(rev.createdAt) > post.first_published_at + 60000;
++ const body = sectionsOf(renderBody(rev.content, attachments, { urlFor, rel }));
++ const toc = body.sections.length >= 3 ? body.sections : null;
++ const minutes = readMinutes(rev.content);
return String(h`${raw(ssr.breadcrumbsHtml(o.breadcrumbs))}
++<div class="post-wrap${toc ? ' has-toc' : ''}">
++${raw(toc ? contentsHtml(toc) : '')}
++<div class="post-main">
<article class="post" data-post-id="${post.id}" data-revision="${rev.number}">
<header>
<h1>${rev.fields.title}</h1>
--<p class="byline">${author ? h`By <a href="${author.url}" rel="author">${author.name}</a>` : ''}${post.published_at ? h` · ${time(post.first_published_at || post.published_at)}` : ''}${updated ? h` · updated ${time(Date.parse(rev.createdAt))}` : ''}${badge ? h` <span class="badge">${badge}</span>` : ''}${post.state !== 'published' ? h` <span class="badge">${post.state}</span>` : ''}</p>
++<p class="byline">${author ? h`By <a href="${author.url}" rel="author">${author.name}</a> · ` : ''}${post.published_at ? h`${time(post.first_published_at || post.published_at)} · ` : ''}${updated ? h`updated ${time(Date.parse(rev.createdAt))} · ` : ''}<span class="read-time" title="At ${WORDS_PER_MINUTE} words a minute: a guide, not a promise">≈ ${minutes} min read</span>${badge ? h` <span class="badge">${badge}</span>` : ''}${post.state !== 'published' ? h` <span class="badge">${post.state}</span>` : ''}</p>
++${series ? raw(seriesLine(series)) : ''}
${disclosure ? h`<p class="disclosure" role="note"><strong>${disclosure.short}.</strong> ${disclosure.long}</p>` : ''}
--${series ? h`<nav class="series" aria-label="Series"><p>Part ${series.position || '?'} of the series <a href="${series.url}">${series.title}</a></p><ol>${series.items.map((s) => (s.current ? h`<li aria-current="page">${s.title}</li>` : h`<li><a href="${s.url}">${s.title}</a></li>`))}</ol></nav>` : ''}
++${raw(toc ? contentsFold(toc) : '')}
${raw(cover.map((a) => figureHtml(a, { urlFor })).join(''))}
</header>
--<div class="post-body">${raw(renderBody(rev.content, attachments, { urlFor, rel }))}</div>
++<div class="post-body">${raw(body.html)}</div>
${raw(gallery.length ? `<div class="gallery">${gallery.map((a) => figureHtml(a, { urlFor })).join('')}</div>` : '')}
${citations.length ? h`<section class="sources"><h2>Sources</h2><ol>${citations.map((c) => h`<li>${c.url ? h`<a href="${c.url}" rel="noopener">${c.title || c.url}</a>` : (c.title || c.sourceItemId)}${c.quote && c.quote.text ? h` — “${c.quote.text}”` : ''}${c.retrievedAt ? h` (retrieved ${dateLabel(c.retrievedAt)})` : ''}</li>`)}</ol></section>` : ''}
++${series ? raw(seriesFoot(series)) : ''}
<footer class="post-footer">
${tags.length ? h`<p class="tags">${tags.map((t) => h`<a href="${t.url}" rel="tag">#${t.name}</a> `)}</p>` : ''}
${categories.length ? h`<p class="categories">Filed under ${categories.map((c) => h`<a href="${c.url}">${c.name}</a> `)}</p>` : ''}
<p class="meta">Revision ${rev.number}${decisionNote ? h` · ${decisionNote}` : ''} · <a href="${o.jsonUrl}">JSON</a> · <a href="${blogUrl}">More from ${blog.title}</a>${canEdit ? h` · <a href="/write/posts/${post.id}">Edit</a>` : ''}</p>
</footer>
</article>
--${raw(comments ? commentsSection(comments, o) : '')}`);
++${raw(comments ? commentsSection(comments, o) : '')}
++</div>
++</div>`);
}
/** A members-only post for a reader without access: title, the author's summary, a join link — never the text. */
@@@ -178,4 +247,5 @@ function message({ heading, text, action }) {
return String(h`<section class="message"><h1>${heading}</h1><p>${text}</p>${action ? h`<p><a class="button" href="${action.href}">${action.label}</a></p>` : ''}</section>`);
}
--module.exports = { renderBody, renderMarkdownWithDetails, blogIndex, collection, postPage, membersTeaser, message, postList, categoryTree, time, dateLabel };
++module.exports = {
++ readMinutes, sectionsOf, renderBody, renderMarkdownWithDetails, blogIndex, collection, postPage, membersTeaser, message, postList, categoryTree, time, dateLabel };
ddiff --git a/test/nojs-editor.test.js b/test/nojs-editor.test.js
iindex d7e3043..7847973 100644
---- a/test/nojs-editor.test.js
++++ b/test/nojs-editor.test.js
@@@ -73,7 +73,8 @@ const idFrom = (location) => (location.match(/\/write\/posts\/(pst_[0-9A-Z]+)/)
const page = await t.get('/@eve/notes-without-script');
assert.strictEqual(page.status, 200);
assert.match(page.text, /Plain <em>text<\/em>, edited\./);
-- assert.match(page.text, /Part 1 of the series <a href="\/@eve\/series\/field-notes">Field notes<\/a>/);
++ assert.match(page.text, /Part 1 of 1 in the series <a href="\/@eve\/series\/field-notes">Field notes<\/a>/);
++ assert.match(page.text, /class="read-time"[^>]*>≈ \d+ min read</, 'the post states a read time');
assert.match((await t.get('/@eve/series/field-notes')).text, /Notes without script/);
});
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex 6b25c08..e1da173 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -20,8 +20,8 @@ const BUDGETS = {
jsRawKB: 322, // 321.1 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 318.1 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 315.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 307.8 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 300.5 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 293 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 287.2 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 273.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 263.4 with shared 4.11.0's seasonal navbar; 254.1 with openvibe-shared 4.9.0 (status text twins in every theme, 2026-10-23); 246.2 with openvibe-shared 4.2.x (Ask Actor in the navbar, 2026-10-15); 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)
jsBrotliKB: 79, // 78.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 77.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 76.1 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 74.2 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 70.5 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 67.9 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 65.3 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 61.7 with shared 4.11.0's seasonal navbar; 59.1 with openvibe-shared 4.4.0 (every site in the launcher, 2026-10-17); 56.3 (49.9 before web-runtime.js)
cssFiles: 2, // 1
-- cssRawKB: 44, // 43.1 with the short home (2026-10-30: the pick block's and the fold's own rules); 39.8 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 38.1 touch targets on phones (2026-10-29); 37.6 the home's phone limits, desk row and fold (2026-10-29); 36 measured with openvibe-shared v4.39.0 (+1.5 KB raw of kit/room/showcase CSS; raised from 34 by the measured growth; sizes are rounded down to 1 decimal, so the limit must clear 36.0); 29.1 openvibe-shared 4.23.0: the showcase hero on a phone (near the bar, buttons side by side, numbers three across); 27.9 on 2026-10-26 (Shared 4.13's home kit: showcase.css 12.2 → 18.3 KB); 22 on 2026-10-22 (the home's post cards, week bars, desks, topics, questions and links); 17.7 (2026-10-15: blog.css carries the AI desks' few rules; the shared showcase.css grew to ~12 KB)
-- cssBrotliKB: 9.6, // 9.2 with the short home (2026-10-30: the pick block's and the fold's own rules); 7.3 openvibe-shared 4.26.1 (hero visual); 6.2 (2026-10-26, Shared 4.13's home kit); 5.1 (2026-10-22); 1.4
++ cssRawKB: 48, // 47.7 with the post pages' reading layout (2026-10-30: the measure, the contents fold and rail, the series line and neighbours, all in blog.css); 43.1 with the short home (2026-10-30: the pick block's and the fold's own rules); 39.8 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 38.1 touch targets on phones (2026-10-29); 37.6 the home's phone limits, desk row and fold (2026-10-29); 36 measured with openvibe-shared v4.39.0 (+1.5 KB raw of kit/room/showcase CSS; raised from 34 by the measured growth; sizes are rounded down to 1 decimal, so the limit must clear 36.0); 29.1 openvibe-shared 4.23.0: the showcase hero on a phone (near the bar, buttons side by side, numbers three across); 27.9 on 2026-10-26 (Shared 4.13's home kit: showcase.css 12.2 → 18.3 KB); 22 on 2026-10-22 (the home's post cards, week bars, desks, topics, questions and links); 17.7 (2026-10-15: blog.css carries the AI desks' few rules; the shared showcase.css grew to ~12 KB)
++ cssBrotliKB: 10.4, // 10.1 with the post pages' reading layout (2026-10-30); 9.2 with the short home (2026-10-30: the pick block's and the fold's own rules); 7.3 openvibe-shared 4.26.1 (hero visual); 6.2 (2026-10-26, Shared 4.13's home kit); 5.1 (2026-10-22); 1.4
externalFiles: 1, // 0
};
ddiff --git a/test/reading-layout.test.js b/test/reading-layout.test.js
nnew file mode 100644
iindex 0000000..9d74a14
---- /dev/null
++++ b/test/reading-layout.test.js
@@@ -0,0 +1,57 @@
++'use strict';
++// The post page's reading layout (the blog outcomes in research-2026-10-30/market/outcomes/blog.md): a stated read time,
++// ids on every section heading, a contents list of the top-level sections (none inside a fold), and the series as a
++// line above the text with its previous, next and nearby parts after it.
++const assert = require('assert');
++const pages = require('../server/render/pages');
++
++const words = (n) => Array.from({ length: n }, (_, i) => `word${i}`).join(' ');
++
++// ── Read time: 265 words a minute, rounded, never 0; code, media lines and link targets are not read ──
++assert.strictEqual(pages.readMinutes(''), 1, 'never "0 min"');
++assert.strictEqual(pages.readMinutes(words(265 * 7)), 7);
++assert.strictEqual(pages.readMinutes(words(265 * 7 + 100)), 7, 'rounded');
++assert.strictEqual(pages.readMinutes(`${words(530)}\n\n\`\`\`\n${words(5000)}\n\`\`\`\n`), 2, 'a code block is not read');
++assert.strictEqual(pages.readMinutes(`${words(530)} [a link](https://example.com/${words(3000).replace(/ /g, '/')})`), 2, 'a link target is not read');
++
++// ── Sections: ids, unique, never a page id; the contents list leaves out headings inside a fold ──
++{
++ const body = '<h3>Highlights</h3><p>x</p><h3>OpenVibe.Live</h3><h3>OpenVibe.Live</h3><h3>Comments</h3>'
++ + '<details class="ov-details"><summary>All 80 commits</summary><h3>Inside</h3></details><h3>Café &amp; <em>bar</em></h3>';
++ const out = pages.sectionsOf(body);
++ assert.deepStrictEqual(out.sections.map((x) => x.id), ['highlights', 'openvibe-live', 'openvibe-live-2', 'comments-2', 'cafe-bar']);
++ assert.deepStrictEqual(out.sections.map((x) => x.text), ['Highlights', 'OpenVibe.Live', 'OpenVibe.Live', 'Comments', 'Café & bar']);
++ assert.ok(out.html.includes('<h3 id="inside">Inside</h3>'), 'a heading in a fold still gets an id');
++ assert.ok(!out.sections.some((x) => x.id === 'inside'), 'but stays out of the contents list');
++ assert.ok(out.html.includes('<h3 id="cafe-bar">Café &amp; <em>bar</em></h3>'), 'the heading keeps its markup');
++}
++
++// ── The page ──
++const base = {
++ blog: { kind: 'official', title: 'The OpenVibe blog' }, blogUrl: '/@openvibe', author: { name: 'Eve', url: '/@openvibe/authors/eve' },
++ tags: [], categories: [], attachments: [], citations: [], disclosure: null, decisionNote: null, comments: null, canEdit: false,
++ urlFor: () => '#', breadcrumbs: [], jsonUrl: '/x.json',
++ post: { id: 'pst_1', visibility: 'public', state: 'published', published_at: 1761000000000, first_published_at: 1761000000000 },
++};
++{
++ const long = `## One\n\n${words(300)}\n\n## Two\n\n${words(300)}\n\n## Three <x>\n\n${words(300)}\n`;
++ const html = pages.postPage({ ...base, rev: { number: 1, fields: { title: 'A long post' }, content: long, createdAt: new Date(1761000000000).toISOString() },
++ series: { title: 'Patch notes', url: '/@openvibe/series/patch-notes', position: 36, total: 118, start: 31,
++ prev: { title: 'Part 35 <b>', url: '/p35' }, next: { title: 'Part 37', url: '/p37' },
++ items: [{ title: 'Part 35 <b>', url: '/p35' }, { title: 'Part 36', url: '/p36', current: true }, { title: 'Part 37', url: '/p37' }] } });
++ assert.ok(html.includes('<div class="post-wrap has-toc">'), 'three sections: a contents list');
++ assert.ok(html.includes('<nav class="toc-rail" aria-labelledby="toc-rail-h">') && html.includes('<details class="toc-fold">'), 'the rail for wide screens, the fold for phones');
++ assert.ok(/<a href="#three-x">Three &lt;x&gt;<\/a>|<a href="#three">Three<\/a>/.test(html), `section text is escaped in the list: ${(html.match(/toc-fold[\s\S]*?<\/details>/) || [''])[0].slice(0, 400)}`);
++ assert.ok(html.includes('≈ 3 min read'));
++ assert.ok(html.includes('Part 36 of 118 in the series <a href="/@openvibe/series/patch-notes">Patch notes</a>'), 'the series is one line, not 50 links');
++ assert.ok(html.includes('<a rel="prev" href="/p35"><small>Previous</small><span>Part 35 &lt;b&gt;</span></a>') && html.includes('<a rel="next" href="/p37">'));
++ assert.ok(html.includes('<ol start="31">') && html.includes('<li aria-current="page">Part 36</li>') && html.includes('All 118 parts'));
++ assert.ok(html.indexOf('series-nav') > html.indexOf('post-body'), 'the neighbours come after the text');
++}
++{
++ const short = pages.postPage({ ...base, series: null, rev: { number: 1, fields: { title: 'Short' }, content: '## Only one\n\nHi.', createdAt: new Date().toISOString() } });
++ assert.ok(short.includes('<div class="post-wrap">') && !short.includes('toc-rail') && !short.includes('toc-fold'), 'fewer than three sections: no contents list');
++ assert.ok(short.includes('≈ 1 min read') && !short.includes('series-line'));
++}
++
++console.log('reading layout: read time, section ids, the contents list and the series ok');