openvibers/openvibe.blog Commits
Post pages read better: measure, read time, contents, one-line series (#144)
Changes
@@@ -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); }++}@@@ -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),@@@ -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 = { '&': '&', '<': '<', '>': '>', '"': '"', ''': "'", ''': "'" };++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 };@@@ -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/);});@@@ -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.4externalFiles: 1, // 0};nnew file mode 100644@@@ -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é & <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é & <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 <x><\/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 <b></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');
@@ -28,8 +28,8 @@ h2 { font-size: 1.35rem; margin: 1.6rem 0 .5rem; }
@@ -28,8 +28,8 @@ h2 { font-size: 1.35rem; margin: 1.6rem 0 .5rem; }
.ov-media { margin: 1.2rem 0; }
.ov-media { margin: 1.2rem 0; }
.ov-media figcaption { font-size: .9rem; color: var(--text-muted); }
.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); }
.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; }
.sources ol { padding-left: 1.2rem; }
.desk-list { list-style: none; padding: 0; }
.desk-list { list-style: none; padding: 0; }
.desk-card h2 { margin: 16px 0 4px; font-size: 1.2rem; }
.desk-card h2 { margin: 16px 0 4px; font-size: 1.2rem; }
@@ -202,3 +202,49 @@ body.blog-home .page { max-width: 1120px; }
@@ -202,3 +202,49 @@ body.blog-home .page { max-width: 1120px; }
.pick, .pick-body, .pick-pane { display: contents; }
.pick, .pick-body, .pick-pane { display: contents; }
.pick-bar { display: none; }
.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); }
+}
@@ -315,14 +315,28 @@ function createPublicRoutes(ctx) {
@@ -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) {
async function seriesView(blog, post) {
const series = await blogs.seriesById(post.series_id);
const series = await blogs.seriesById(post.series_id);
if (!series) return null;
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 {
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) {
@@ -343,6 +357,7 @@ function createPublicRoutes(ctx) {
const csrf = req.viewer.kind === 'user' ? csrfToken(config, req.viewer) : '';
const csrf = req.viewer.kind === 'user' ? csrfToken(config, req.viewer) : '';
send(req, res, status, {
send(req, res, status, {
title: rev.fields.title,
title: rev.fields.title,
+ bodyClass: 'post-page',
description: rev.fields.summary || ssr.markdownToText(rev.content, 160),
description: rev.fields.summary || ssr.markdownToText(rev.content, 160),
decision,
decision,
canonical: publication.postUrl(blog, post),
canonical: publication.postUrl(blog, post),
@@ -33,6 +33,65 @@ function renderMarkdownWithDetails(source, opts) {
@@ -33,6 +33,65 @@ function renderMarkdownWithDetails(source, opts) {
}
}
const dateLabel = (v) => (v ? new Date(v).toISOString().slice(0, 10) : null);
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 = { '&': '&', '<': '<', '>': '>', '"': '"', ''': "'", ''': "'" };
+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 }));
const time = (v) => raw(ssr.timeTag(v == null ? null : new Date(v).toISOString(), { label: dateLabel(v) || undefined }));
/**
/**
@@ -139,25 +198,35 @@ function postPage(o) {
@@ -139,25 +198,35 @@ function postPage(o) {
const gallery = attachments.filter((a) => a.role === 'gallery');
const gallery = attachments.filter((a) => a.role === 'gallery');
const badge = { members: 'Members only', private: 'Private', unlisted: 'Unlisted' }[post.visibility];
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 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))}
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}">
<article class="post" data-post-id="${post.id}" data-revision="${rev.number}">
<header>
<header>
<h1>${rev.fields.title}</h1>
<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>` : ''}
${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(''))}
${raw(cover.map((a) => figureHtml(a, { urlFor })).join(''))}
</header>
</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>` : '')}
${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>` : ''}
${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">
<footer class="post-footer">
${tags.length ? h`<p class="tags">${tags.map((t) => h`<a href="${t.url}" rel="tag">#${t.name}</a> `)}</p>` : ''}
${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>` : ''}
${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>
<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>
</footer>
</article>
</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. */
/** 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 }) {
@@ -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>`);
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 };
@@ -73,7 +73,8 @@ const idFrom = (location) => (location.match(/\/write\/posts\/(pst_[0-9A-Z]+)/)
@@ -73,7 +73,8 @@ const idFrom = (location) => (location.match(/\/write\/posts\/(pst_[0-9A-Z]+)/)
const page = await t.get('/@eve/notes-without-script');
const page = await t.get('/@eve/notes-without-script');
assert.strictEqual(page.status, 200);
assert.strictEqual(page.status, 200);
assert.match(page.text, /Plain <em>text<\/em>, edited\./);
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/);
assert.match((await t.get('/@eve/series/field-notes')).text, /Notes without script/);
});
});
@@ -20,8 +20,8 @@ const BUDGETS = {
@@ -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)
jsRawKB: 322, // 321.1 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 318.1 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 315.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 307.8 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 300.5 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 293 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 287.2 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 273.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 263.4 with shared 4.11.0's seasonal navbar; 254.1 with openvibe-shared 4.9.0 (status text twins in every theme, 2026-10-23); 246.2 with openvibe-shared 4.2.x (Ask Actor in the navbar, 2026-10-15); 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)
jsBrotliKB: 79, // 78.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 77.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 76.1 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 74.2 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 70.5 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 67.9 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 65.3 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 61.7 with shared 4.11.0's seasonal navbar; 59.1 with openvibe-shared 4.4.0 (every site in the launcher, 2026-10-17); 56.3 (49.9 before web-runtime.js)
jsBrotliKB: 79, // 78.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 77.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 76.1 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 74.2 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 70.5 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 67.9 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 65.3 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 61.7 with shared 4.11.0's seasonal navbar; 59.1 with openvibe-shared 4.4.0 (every site in the launcher, 2026-10-17); 56.3 (49.9 before web-runtime.js)
cssFiles: 2, // 1
cssFiles: 2, // 1
- cssRawKB: 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
externalFiles: 1, // 0
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,57 @@
@@ -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é & <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é & <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 <x><\/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 <b></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');