openvibers/openvibe.community Commits

Home: the roadmap and Across OpenVibe are plain sections with marks, and Questions shows two and fades (#342)

OP OpenVibers committed 3 days ago · bc81cf9cb0 · 7 files, +145 −195

* Home: the roadmap and Across OpenVibe are plain sections with marks, and Questions shows two and fades

What is happening. The heading and its two chips are gone: both lists were on the page anyway, so the chips only
jumped down it, and the lists' own headings sat pushed to the right under them with a gap twice the page's. They are
two sections like the rest (the same head, the same 28 px), side by side where there is room.

Rows. A roadmap item's state is its mark (an icon in the state's colour) with the word once under the title, in
place of a badge on every row. A thing that happened has the person's picture with the site's icon on its corner, or
the site's icon alone where no person did it (a spark where OpenVibe.AI wrote it); the site is in words with its
capital (Today and Network were raw service ids), with the item's own line beside it and its picture at the row's
end. One row a place: a stream's go-live and its day's card linked the same channel twice. Five rows.

Questions. The closed fold showed a heading and "6 questions" and nothing that looked openable. Every question is
on the page with a mark and an arrow that turns; on a phone the list shows its first two and fades into the third,
and one button under it opens the rest and closes them again. Opening a question inside the faded list opens the
list. Without the script, and where there is room, the whole list shows.

server/render/short.js and the pick and fold styles go with the blocks they drew.

* The SSR check reads a question from its row; the home budget takes the marks

CI measured the home at 47.8 KB raw and 10.5 KB brotli with a mark on every row (the same few icons, repeated).
The budgets go to just above that, dated. The FAQPage check finds each question in its new element.

Changes

ddiff --git a/public/css/community.css b/public/css/community.css
iindex 18fcebd..712b372 100644
---- a/public/css/community.css
++++ b/public/css/community.css
@@@ -438,7 +438,7 @@ button.rx:hover { border-color: var(--accent); }
/* Home (server/render/pages.js homePage): a taste and a door. The shared kit draws it (showcase.css: the hero and the
forum's launcher; kit.css: the numbers, lists, chips and the fold; room.css: the rails of cards); this is only the
spacing between its parts, one heading size for all of them, and the quick filters' strip. */
--.cm-home .page > .sc-sec { margin-top: 32px; }
++.cm-home .page > .sc-sec { margin-top: 28px; }
.cm-home .page > .sc-sec > h2, .cm-h { margin: 0 0 12px; font-family: inherit; font-size: 1.15rem; font-weight: 800; letter-spacing: -.01em; }
.cm-sec { min-width: 0; margin-top: 28px; }
.cm-sec > .ovk-rail { margin-top: 0; }
@@@ -455,55 +455,50 @@ button.rx:hover { border-color: var(--accent); }
/* Where the hero stacks (showcase.css, 860 px) it skips the terminal sample: the fold "Paste from a script" has it, and a
phone gets to the pastes. */
@media (max-width: 860px) { .cm-home .sc-hero-aside { display: none; } }
--.cm-faq { display: grid; gap: 8px; max-width: 860px; }
--.cm-faq details { border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-card); }
--.cm-faq summary { min-height: 44px; box-sizing: border-box; cursor: pointer; padding: 12px 16px; font-weight: 700; }
--.cm-faq details[open] summary { border-bottom: 1px solid var(--border); }
--.cm-faq p { margin: 0; padding: 12px 16px 14px; color: var(--text-secondary); line-height: 1.6; }
--/* The phone home (2026-10-30, the owner's short-pages rule): the roadmap and the pulse are chips, one shelf at a
-- time, and the questions are a fold. Both only work under 640 px, so a wider screen shows both shelves and no chips. */
--/* Pick: on a phone every pane is shown, stacked, each with its own heading; the chips are a row of links that
-- jump to a pane (the owner's rule, 2026-10-31: never hide content to shorten). A wide screen shows the panes as
-- before, with no chips. */
--.pick-head, .pick-row { display: none; }
--@media (max-width: 640px) {
-- .pick-bar { margin-top: 30px; }
-- .pick-head { display: block; }
-- .pick-head h2 { margin: 0 0 4px; font: 700 clamp(20px, 2.4vw, 28px)/1.2 var(--ov-font, 'Inter', system-ui, sans-serif); letter-spacing: -.02em; }
-- .pick-row { display: flex; gap: 8px; overflow-x: auto; margin: 10px -16px 0; padding: 2px 16px 4px; scrollbar-width: none; overscroll-behavior-x: contain; }
-- .pick-row::-webkit-scrollbar { display: none; }
-- .pick-chip { display: inline-flex; align-items: center; flex: none; min-height: 44px; padding: 0 15px; border-radius: 999px; border: 1px solid var(--border, #1f2d47); color: var(--text-secondary, #96a7c2); font-size: .9rem; font-weight: 650; white-space: nowrap; text-decoration: none; }
-- .pick-chip:hover, .pick-chip:focus-visible { color: var(--text-primary, #e6edf7); border-color: var(--accent, #3b82f6); }
-- .pick-chip:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
-- .pick .pick-pane { scroll-margin-top: 72px; }
-- .pick .pick-pane + .pick-pane { margin-top: 26px; }
--}
++/* The roadmap and "Across OpenVibe": two sections like the others (the same head, the same gap), their rows with a
++ mark at the start. A roadmap item's mark is its state as an icon in the state's colour; a thing that happened has
++ the person's picture with the site's icon on its corner, or the site's icon alone where no person did it. */
++.cm-pair > .cm-sec .ovk-rail-head { justify-content: space-between; }
++/* One gap between the home's blocks: a list at the end of a section adds none of its own. */
++.cm-sec > .ovk-list:last-child { margin-bottom: 0; }
++.cm-mark { display: grid; place-items: center; flex: none; width: 40px; height: 40px; border-radius: 50%; background: color-mix(in srgb, var(--text-primary, #e6edf7) 8%, transparent); color: var(--text-secondary, #96a7c2); }
++.cm-mark .ovk-i { width: 20px; height: 20px; }
++.cm-mark--accent, .cm-mark--site { background: color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent); color: var(--accent-text, var(--accent, #3b82f6)); }
++.cm-mark--ok { background: color-mix(in srgb, var(--success, #34d399) 16%, transparent); color: var(--success-text, var(--success, #34d399)); }
++.cm-mark--warn { background: color-mix(in srgb, var(--warning, #fbbf24) 16%, transparent); color: var(--warning-text, var(--warning, #fbbf24)); }
++.cm-mark--bad { background: color-mix(in srgb, var(--danger, #f87171) 14%, transparent); color: var(--danger-text, var(--danger, #f87171)); }
++.cm-pmark { position: relative; display: inline-grid; flex: none; }
++.cm-pmark-site { position: absolute; right: -4px; bottom: -4px; display: grid; place-items: center; width: 20px; height: 20px; border-radius: 50%; background: var(--bg-card, #131c2e); color: var(--accent-text, var(--accent, #3b82f6)); box-shadow: 0 0 0 2px var(--bg-card, #131c2e); }
++.cm-pmark-site .ovk-i { width: 12px; height: 12px; }
++.cm-pthumb { display: block; width: 44px; height: 44px; border-radius: 10px; object-fit: cover; background: var(--bg-secondary, #0e1626); }
--/* 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. */
--.hm-fold-block { margin-top: 32px; }
--.hm-fold-block > summary { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 44px; cursor: pointer; list-style: none; }
--.hm-fold-block > summary::-webkit-details-marker { display: none; }
--.hm-fold-block > summary h2 { margin: 0; font: 700 clamp(19px, 2.2vw, 26px)/1.2 var(--ov-font, 'Inter', system-ui, sans-serif); }
--.hm-fold-block > summary span { color: var(--text-muted, #7386a3); font-weight: 600; font-size: .86rem; white-space: nowrap; }
--.hm-fold-block > summary:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
--@media (min-width: 641px) {
-- .hm-fold-block { border: 0; background: none; border-radius: 0; margin: 56px auto 0; padding: 0; }
-- .hm-fold-block > summary { display: block; min-height: 0; }
-- .hm-fold-block > summary h2 { margin: 0 0 6px; font: 700 clamp(22px, 2.6vw, 30px)/1.2 var(--ov-font, 'Inter', system-ui, sans-serif); }
-- .hm-fold-block > summary span { display: none; }
-- .hm-fold-block .hm-sec, .hm-fold-block .sc-sec { margin: 0; max-width: none; padding: 0; }
--}
--@media (max-width: 640px) { .hm-fold-block { margin-top: 24px; } .hm-fold-block > summary h2 { font-size: 1.05rem; } }
--/* A fold that sits inside the section it replaces adds no space of its own. */
--:where(.sc-sec, .cm-sec, .hm-sec, .section) > .hm-fold-block { margin: 0; }
--/* The chip names the shelf, so the shelf's own heading gives way to it; the link to the whole list stays. */
--@supports selector(:has(*)) { @media (max-width: 640px) {
-- .pick .pick-pane .ovk-rail-head { justify-content: flex-end; }
--} }
--/* The fold holds the questions, so its list gets the room the card gives it. */
--.hm-fold-block .cm-faq { margin: 0; padding: 0 16px 16px; }
++/* The questions. Each is a row with its mark, the question and an arrow that turns when it is open; the answer comes
++ in under it. On a phone the list shows its first two and fades into the third, and the button under it opens the
++ rest (community's home script sets data-clamped; without it the whole list shows). */
++.cm-faq { display: grid; gap: 8px; max-width: 860px; }
++.cm-faq details { border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-card); transition: border-color .15s ease; }
++.cm-faq details[open] { border-color: color-mix(in srgb, var(--accent, #3b82f6) 45%, var(--border)); }
++.cm-faq summary { display: flex; align-items: center; gap: 10px; min-height: 48px; box-sizing: border-box; padding: 8px 12px 8px 10px; cursor: pointer; font-weight: 700; list-style: none; }
++.cm-faq summary::-webkit-details-marker { display: none; }
++.cm-faq summary:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; border-radius: var(--radius); }
++.cm-faq-i { display: grid; place-items: center; flex: none; width: 28px; height: 28px; border-radius: 50%; background: color-mix(in srgb, var(--accent, #3b82f6) 14%, transparent); color: var(--accent-text, var(--accent, #3b82f6)); }
++.cm-faq-i .ovk-i { width: 16px; height: 16px; }
++.cm-faq-q { flex: 1; min-width: 0; }
++.cm-faq-c { flex: none; width: 18px; height: 18px; color: var(--text-muted, #7386a3); transition: transform .2s ease; }
++.cm-faq details[open] .cm-faq-c { transform: rotate(90deg); }
++.cm-faq p { margin: 0; padding: 2px 14px 14px 48px; color: var(--text-secondary); line-height: 1.6; }
++.cm-faq details[open] > p { animation: cm-faq-in .22s ease both; }
++@keyframes cm-faq-in { from { opacity: 0; transform: translateY(calc(-6px * var(--ov-move, 1))); } }
++.cm-peek { overflow: hidden; transition: max-height .38s cubic-bezier(.2, .8, .2, 1); }
++.cm-peek[data-clamped] { max-height: 150px; -webkit-mask-image: linear-gradient(#000 58%, transparent 98%); mask-image: linear-gradient(#000 58%, transparent 98%); }
++.cm-peek-more { display: flex; align-items: center; justify-content: center; gap: 6px; width: 100%; min-height: 44px; margin: 4px 0 0; padding: 0 16px; border: 1px solid var(--border); border-radius: 999px; background: var(--bg-card); color: var(--text-primary); font: inherit; font-weight: 700; cursor: pointer; transition: border-color .15s ease, background-color .15s ease; }
++.cm-peek-more[hidden] { display: none; }
++.cm-peek-more:hover { border-color: var(--accent, #3b82f6); }
++.cm-peek-more:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
++.cm-peek-more .ovk-i { width: 18px; height: 18px; color: var(--accent-text, var(--accent, #3b82f6)); transition: transform .25s ease; animation: cm-peek-nudge 1.5s ease 1.2s 2; }
++.cm-peek-more[aria-expanded="true"] .ovk-i { transform: rotate(180deg); animation: none; }
++@keyframes cm-peek-nudge { 30% { transform: translateY(calc(3px * var(--ov-move, 1))); } 60% { transform: none; } }
/* Content labels (L1): what a thread carries, in words beside its title (as Shared's kit.labels draws them); each
reader's own choice (Shared's ov-labels.js) shows, covers or hides it. */
ddiff --git a/server/app.js b/server/app.js
iindex ba19e67..ef1a3d8 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -469,7 +469,11 @@ async function createApp(opts = {}) {
ORDER BY (t.status = 'in_progress') DESC, t.last_activity_at DESC, t.id DESC LIMIT 5`).all(), []),
quiet(db.prepare(`SELECT t.status, COUNT(*) AS n FROM threads t JOIN spaces s ON s.id = t.space_id
WHERE s.slug = 'roadmap' AND t.deleted_at IS NULL GROUP BY t.status`).all(), []),
-- quiet(pulse.list({ limit: 12 }).then((out) => (out.items || []).filter((it) => it.source && it.source.type !== 'paste').slice(0, 4)), []),
++ quiet(pulse.list({ limit: 16 }).then((out) => {
++ // One row a place: a stream's go-live and the day's card that follows it link the same channel.
++ const seen = new Set();
++ return (out.items || []).filter((it) => it.source && it.source.type !== 'paste' && !seen.has(it.url) && seen.add(it.url)).slice(0, 5);
++ }), []),
quiet(db.prepare(`SELECT (SELECT COUNT(*) FROM pastes WHERE deleted_at IS NULL AND visibility = 'public') AS pastes,
(SELECT COUNT(*) FROM pulse_items) AS pulse`).get(), {}),
]);
ddiff --git a/server/render/pages.js b/server/render/pages.js
iindex b6b0001..53622ac 100644
---- a/server/render/pages.js
++++ b/server/render/pages.js
@@@ -14,7 +14,6 @@ const showcase = require('openvibe-shared/showcase');
const kit = require('openvibe-shared/kit');
const { highlight, escapeHtml: esc, languageLabel, extensionFor, LANGUAGES } = require('./highlight');
const { renderMarkdown } = require('./markdown');
--const { pickBlock, foldBlock } = require('./short');
const { canonicalSlug } = require('../pastes/url');
const pastePathSlug = (p) => (p && (p.canonical_slug || canonicalSlug(p))) || '';
@@@ -166,7 +165,11 @@ const HOME_FAQ = [
const STATUS_WORDS = { in_progress: 'In progress', done: 'Done', planned: 'Planned', open: 'Open', paused: 'Paused', declined: 'Declined' };
const STATUS_TONE = { in_progress: 'accent', done: 'ok', planned: '', open: '', paused: 'warn', declined: 'bad' };
--const SERVICE_WORDS = { community: 'Community', live: 'Live', blog: 'Blog', wiki: 'Wiki', news: 'News', games: 'Games', media: 'Media' };
++const SERVICE_WORDS = { community: 'Community', live: 'Live', blog: 'Blog', wiki: 'Wiki', news: 'News', games: 'Games', media: 'Media', 'media-hub': 'Video', video: 'Video', pics: 'Pics', meme: 'Meme', network: 'Network', today: 'Today', tools: 'Tools', reviews: 'Reviews', quest: 'Quest', chat: 'Chat' };
++// The site an item came from, as the kit's icon beside the person's picture (or alone, where no person did it).
++const SERVICE_ICON = { community: 'chat', live: 'live', blog: 'book', wiki: 'book', news: 'flag', games: 'game', media: 'video', 'media-hub': 'video', video: 'video', pics: 'image', meme: 'image', network: 'members', today: 'calendar', tools: 'grid', reviews: 'star', quest: 'trophy', chat: 'chat' };
++// A roadmap item's state as the row's mark: the word is under the title, once, in plain text.
++const STATUS_ICON = { in_progress: 'steps', done: 'check', planned: 'clock', open: 'flag', paused: 'pause', declined: 'close' };
// The forum's places in the launcher: the network's ring icons (openvibe-shared ov-icons) by space.
const SPACE_ICON = { general: 'ov:chat', help: 'ov:help', feedback: 'ov:bell', showcase: 'ov:image', 'off-topic': 'ov:community' };
@@@ -245,16 +248,38 @@ function forumShelf(spaces) {
const whenOf = (v) => (v ? kit.when(v) : { text: '', iso: '' });
function roadmapShelf(items) {
if (!items || !items.length) return '';
++ const mark = (st) => `<span class="cm-mark cm-mark--${esc(STATUS_TONE[st] || 'plain')}" aria-hidden="true">${kit.icon(STATUS_ICON[st] || 'flag')}</span>`;
return `<section class="cm-sec" id="roadmap" aria-labelledby="h-roadmap">${shelfHead('h-roadmap', 'On the roadmap', { href: '/forum/roadmap', label: 'The whole roadmap' })}
--${kit.list(items.slice(0, 5).map((t) => kit.row({ href: `/forum/roadmap/${t.slug}`, title: t.title, subHtml: kit.badge(STATUS_WORDS[t.status] || 'Open', STATUS_TONE[t.status] || ''), meta: whenOf(t.last_activity_at).text, metaIso: whenOf(t.last_activity_at).iso })))}</section>`;
++${kit.list(items.slice(0, 5).map((t) => kit.row({ href: `/forum/roadmap/${t.slug}`, mark: mark(t.status), title: t.title, sub: STATUS_WORDS[t.status] || 'Open', meta: whenOf(t.last_activity_at).text, metaIso: whenOf(t.last_activity_at).iso })))}</section>`;
++}
++
++/**
++ * One thing that happened on the network, as a row: the person's picture with the site's icon on its corner (the
++ * site's icon alone where no person did it: a count, a system notice; a spark where OpenVibe.AI wrote it), what
++ * happened, the site in words with the item's own line where it has one, and its picture at the row's end.
++ */
++function pulseRow(it) {
++ const svc = it.source.service;
++ const site = SERVICE_WORDS[svc] || (it.via ? it.via.replace(/^via /, '') : svc);
++ const glyph = kit.icon(it.origin === 'ai' ? 'spark' : SERVICE_ICON[svc] || 'globe');
++ const person = it.actor && !it.actor.is_ai && !it.actor.is_system ? it.actor : null;
++ const mark = person
++ ? `<span class="cm-pmark">${kit.mark({ name: authorName(person), src: avatarUrl(person, 96) || '', size: 40 })}<span class="cm-pmark-site" title="${esc(site)}">${glyph}</span></span>`
++ : `<span class="cm-mark cm-mark--site" aria-hidden="true">${glyph}</span>`;
++ const line = it.text ? ld.clean(it.text, 80) : '';
++ const thumb = it.picture_url && /^https:\/\//i.test(it.picture_url) ? `<img class="cm-pthumb" src="${esc(kit.mediaW(it.picture_url, 96))}" alt="" width="44" height="44" loading="lazy" decoding="async">` : '';
++ return kit.row({
++ href: it.url, rel: svc === 'community' ? '' : 'nofollow ugc noopener', mark, title: it.title,
++ badge: it.origin === 'ai' ? kit.badge('AI', 'accent') : '', sub: line ? `${site} · ${line}` : site, trail: thumb,
++ meta: whenOf(it.occurred_at).text, metaIso: whenOf(it.occurred_at).iso,
++ });
}
function pulseShelf(items) {
if (!items || !items.length) return '';
// Links to the network's other sites keep the nofollow they have on /pulse.
-- const rows = items.slice(0, 4).map((it) => kit.row({ href: it.url, rel: it.source.service === 'community' ? '' : 'nofollow ugc noopener', title: it.title, badge: it.origin === 'ai' ? kit.badge('AI', 'accent') : '', sub: SERVICE_WORDS[it.source.service] || it.source.service, meta: whenOf(it.occurred_at).text, metaIso: whenOf(it.occurred_at).iso }));
return `<section class="cm-sec" id="pulse" aria-labelledby="h-pulse">${shelfHead('h-pulse', 'Across OpenVibe', { href: '/pulse', label: 'All of Pulse' })}
--${kit.list(rows)}</section>`;
++${kit.list(items.slice(0, 5).map(pulseRow))}</section>`;
}
/** The API, read once: folded, with the two samples to copy. */
@@@ -273,26 +298,29 @@ const { url } = await res.json(); // "/p/<slug>"` })}`,
});
}
++/**
++ * The questions. Every one is in the page; on a phone the list shows its first two and fades, and one button under
++ * it opens the rest (the owner, 2026-11-10: a closed fold "doesn't even look collapsible, so you can't even see the
++ * questions … show the first two and then fade out"). Each question opens its own answer. Without the script, and
++ * where there is room, the whole list is shown.
++ */
function homeFaq() {
-- // The questions are a fold on a phone (the owner's short-pages rule, 2026-10-30): they stay in the page for the
-- // crawlers, and the script closes them where the reader has room for more.
-- return `<section class="cm-sec" id="questions" aria-labelledby="h-faq">${foldBlock({
-- id: 'faq-fold',
-- head: '<h2 id="h-faq" class="cm-h">Questions</h2>',
-- html: `<div class="cm-faq">${HOME_FAQ.map((f) => `<details><summary>${esc(f.q)}</summary><p>${esc(f.a)}</p></details>`).join('')}</div>`,
-- })}</section>`;
++ const n = HOME_FAQ.length;
++ const item = (f) => `<details><summary><span class="cm-faq-i" aria-hidden="true">${kit.icon('help')}</span><span class="cm-faq-q">${esc(f.q)}</span>${kit.icon('chevron', { cls: 'cm-faq-c' })}</summary><p>${esc(f.a)}</p></details>`;
++ return `<section class="cm-sec" id="questions" aria-labelledby="h-faq"><div class="ovk-rail-head"><h2 id="h-faq">Questions</h2></div>
++<div class="cm-peek" id="faq-peek"><div class="cm-faq">${HOME_FAQ.map(item).join('')}</div></div>
++<button type="button" class="cm-peek-more" id="faq-more" aria-controls="faq-peek" aria-expanded="false" hidden><span data-more="All ${n} questions" data-less="Show fewer">All ${n} questions</span>${kit.icon('down')}</button>
++<script>(function(){var p=document.getElementById('faq-peek'),b=document.getElementById('faq-more');if(!p||!b||!window.matchMedia||!window.matchMedia('(max-width:640px)').matches)return;var l=b.querySelector('span');p.setAttribute('data-clamped','');b.hidden=false;
++b.addEventListener('click',function(){var open=b.getAttribute('aria-expanded')!=='true';b.setAttribute('aria-expanded',open?'true':'false');l.textContent=l.getAttribute(open?'data-less':'data-more');
++if(open){p.style.maxHeight=p.scrollHeight+'px';p.removeAttribute('data-clamped');p.setAttribute('data-open','');setTimeout(function(){if(p.hasAttribute('data-open'))p.style.maxHeight='none'},420)}
++else{p.style.maxHeight=p.scrollHeight+'px';p.offsetHeight;p.removeAttribute('data-open');p.setAttribute('data-clamped','');p.style.maxHeight='';b.scrollIntoView({block:'nearest'})}});
++p.addEventListener('toggle',function(e){if(p.hasAttribute('data-clamped')){if(e.target.open)b.click()}else if(p.hasAttribute('data-open'))p.style.maxHeight='none'},true);})();</script></section>`;
}
--/**
-- * The roadmap and the pulse, one at a time on a phone (the owner's short-pages rule, 2026-10-30): the chips pick one
-- * of the two shelves, and both stay in the page for the crawlers. On a wide screen they stay side by side.
-- */
--function pickWhatIsHappening(roadmap, activity) {
-- const panes = [
-- { id: 'shelf-roadmap', label: 'Roadmap', html: roadmapShelf(roadmap) },
-- { id: 'shelf-pulse', label: 'Across OpenVibe', html: pulseShelf(activity) },
-- ];
-- return pickBlock({ key: 'happening', label: 'What is happening', head: 'What is happening', panes, bodyClass: 'cm-pair' });
++/** The roadmap and the pulse: two sections, one under the other on a phone, side by side where there is room. */
++function whatIsHappening(roadmap, activity) {
++ const pair = roadmapShelf(roadmap) + pulseShelf(activity);
++ return pair ? `<div class="cm-pair">${pair}</div>` : '';
}
@@@ -311,7 +339,7 @@ function homePage({ latest, fromStreams = [], languages, user, spaces = [], road
${latestShelf(latest, languages)}
${streamsShelf(fromStreams)}
${forumShelf(spaces)}
--${pickWhatIsHappening(roadmap, activity)}
++${whatIsHappening(roadmap, activity)}
<div class="cm-sec">${apiFold()}</div>
${homeFaq()}
<section class="section">${frame.shipped({ service: 'community', title: 'Recently shipped on OpenVibe.Community' })}</section>`;
ddiff --git a/server/render/short.js b/server/render/short.js
ddeleted file mode 100644
iindex fea6554..0000000
---- a/server/render/short.js
++++ /dev/null
@@@ -1,92 +0,0 @@
--'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 the site's own stylesheet; the rule that shows the picked block is written for the
-- * page, because there is one per block.
-- */
--const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
--
--/**
-- * A pick block. The panes are the page's own blocks; each one keeps its heading on a wide screen, and on a phone the
-- * heading gives way to the chip that names it.
-- *
-- * pickBlock({ key, label, head, panes }) head: the heading of the block, or '' when the block already has one.
-- * panes: [{ id, label, html }]
-- */
--function pickBlock({ key, label, head = '', panes, bodyClass = '' }) {
-- // A pane with nothing to show is left out, so the chips and the rules that show them match the page.
-- panes = (panes || []).filter((p) => p.html);
-- if (!panes.length) return '';
-- const chips = panes.map((p) => `<a class="pick-chip" href="#${esc(p.id)}">${esc(p.label)}</a>`).join('');
-- // The pane carries the id the page already links to (an anchor in the hero, for instance), so a link to it still
-- // opens that pane on a phone (the CSS shows the pane a link points at).
-- const body = panes.map((p) => `<div class="pick-pane" id="${esc(p.id)}">${p.html}</div>`).join('');
-- const bar = `${head ? `<div class="pick-head"><h2>${esc(head)}</h2></div>` : ''}<nav class="pick-row" aria-label="${esc(label)}">${chips}</nav>`;
-- return `<div class="pick" id="pick-${esc(key)}"><div class="pick-bar">${bar}</div><div class="pick-body${bodyClass ? ' ' + esc(bodyClass) : ''}">${body}</div></div>`;
--}
--
--
--
--/**
-- * What a fold holds, counted, for its title row (the owner, 2026-10-31: a fold that shortens a page shows its count):
-- * "6 questions", "12 items", "latest 5" for the shipped list, or nothing when there is nothing to count. Replaces the
-- * old "tap to open".
-- */
--function foldMeta(body, cls = '') {
-- const s = typeof body === 'string' ? body : String((body && (body.s || body.html || body.value)) || body || '');
-- const n = (re) => (s.match(re) || []).length;
-- const shipped = /data-ov-shipped="list"[^>]*?data-limit="(\d+)"|data-limit="(\d+)"[^>]*?data-ov-shipped="list"/.exec(s);
-- const q = n(/<details[\s>]/g), li = n(/<li[\s>]/g), a = n(/<a\s/g);
-- const text = shipped ? `latest ${shipped[1] || shipped[2]}` : q > 1 ? `${q} questions` : li > 1 ? `${li} items` : a > 1 ? `${a} links` : '';
-- return text ? `<span${cls ? ` class="${cls}"` : ''}>${text}</span>` : '';
--}
--
--/**
-- * 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="hm-fold hm-fold-block" id="${esc(id)}" open><summary>${head}${foldMeta(html)}</summary>${html}</details>${foldScript(id)}`;
--}
--
--/** The script a fold carries: closed on a phone unless the reader opened it before, and it remembers that. */
--function foldScript(id) {
-- return `<script>(function(){var d=document.getElementById(${JSON.stringify(id)});if(!d)return;var k='ov_fold_'+${JSON.stringify(id)};var s=null;try{s=localStorage.getItem(k)}catch(e){}if(s==='open'){d.open=true;return}if(s==='closed'){d.open=false;return}var t=0;if(window.matchMedia&&window.matchMedia('(max-width:640px)').matches){t=Date.now();d.open=false}d.addEventListener('toggle',function(){if(Date.now()-t<300)return;try{localStorage.setItem(k,d.open?'open':'closed')}catch(e){}})})();</script>`;
--}
--
--/**
-- * A section of the page's own markup as a fold. The section stays where it was; its own heading becomes the fold's
-- * heading, and the fold is closed on a phone (the script) and open where there is room.
-- *
-- * foldSection(html, needle, id) needle: a mark of the part to fold (a class, for instance)
-- */
--function foldSection(html, needle, id) {
-- const at = html.indexOf(needle);
-- if (at < 0) return html;
-- const start = html.lastIndexOf('<section', at);
-- const close = html.indexOf('</section>', at);
-- if (start < 0 || close < 0) return html;
-- const openEnd = html.indexOf('>', start) + 1;
-- const openTag = html.slice(start, openEnd);
-- let inner = html.slice(openEnd, close);
-- const h2 = /<h2[^>]*>[\s\S]*?<\/h2>/.exec(inner);
-- if (!h2) return html;
-- const head = h2[0];
-- inner = inner.slice(h2.index + head.length);
-- // The lede under the heading goes with it: the fold's heading says enough on a phone.
-- const lede = /<p class="sc-lede"[^>]*>[\s\S]*?<\/p>/.exec(inner);
-- if (lede) inner = inner.slice(0, lede.index) + inner.slice(lede.index + lede[0].length);
-- const fold = `<details class="hm-fold hm-fold-block" id="${esc(id)}" open><summary>${head}${foldMeta(inner.trim())}</summary>${inner.trim()}</details>${foldScript(id)}`;
-- return html.slice(0, start) + openTag + fold + html.slice(close);
--}
--
--module.exports = { pickBlock, foldBlock, foldMeta, foldScript, foldSection };
ddiff --git a/test/home-short.test.js b/test/home-short.test.js
iindex 403661c..2fec487 100644
---- a/test/home-short.test.js
++++ b/test/home-short.test.js
@@@ -1,8 +1,9 @@
'use strict';
/**
-- * The home on a phone (the owner's short-pages rule, 2026-10-30): the roadmap and the pulse are a pick block, a row
-- * of chips picks one of the two shelves, and the questions are a fold. Radio inputs and :has(), no script for the
-- * chips; every shelf stays in the page for crawlers; a wider screen shows both shelves and no chips.
++ * The home's lower half (the owner, 2026-11-10): the roadmap and "Across OpenVibe" are two plain sections, each row
++ * with a mark at its start (a roadmap item's state as an icon; the site a thing happened on as an icon, beside the
++ * person's picture where a person did it); one row a place; and the questions are all in the page, with a button
++ * that opens the rest where a phone shows the first two.
*/
const assert = require('assert');
const fs = require('fs');
@@@ -12,40 +13,54 @@ const { boot, check, done } = require('./helpers/app');
(async () => {
const t = await boot();
try {
-- // A roadmap item and a pulse item, so the home has something to show in its pick block.
await t.db.prepare("INSERT INTO threads (space_id, slug, title, status, last_activity_at) VALUES (4, 'test-item', 'A test item', 'in_progress', ov_now())").run();
-- await t.db.prepare("INSERT INTO pulse_items (source_service, source_type, source_id, title, url, origin, visibility, occurred_at) VALUES ('network', 'notice', 'n1', 'Something happened', 'https://openvibe.network/', 'system', 'public', ov_now())").run();
++ await t.db.prepare("INSERT INTO threads (space_id, slug, title, status, last_activity_at) VALUES (4, 'done-item', 'A finished item', 'done', ov_now())").run();
++ const pulse = (type, id, title, url, origin, extra = '') => t.db.prepare(`INSERT INTO pulse_items (source_service, source_type, source_id, title, url, origin, visibility, occurred_at${extra ? ', picture_url, text' : ''}) VALUES (?, ?, ?, ?, ?, ?, 'public', ov_now()${extra ? ', ?, ?' : ''})`);
++ await pulse().run('network', 'members', 'n1', '3 people joined today', 'https://openvibe.network/', 'system');
++ await pulse().run('live', 'stream', 's1', 'Dee went live: Raid night', 'https://openvibe.live/@Dee', 'system');
++ await pulse().run('today', 'event', 't1', 'Live: Raid night', 'https://openvibe.live/@Dee', 'system');
++ await pulse('x', 'x', 'x', 'x', 'x', 'with').run('blog', 'update', 'b1', 'A new post', 'https://openvibe.blog/a-new-post', 'ai', 'https://openvibe.media/o/med_01JZ00000000000000000000B1', 'What changed this week, in one paragraph.');
-- await check('the roadmap and the pulse are chips: one per shelf, the first picked, every shelf in the page', async () => {
++ await check('the roadmap and the pulse are two sections, not a pick block', async () => {
const r = await t.get('/');
assert.strictEqual(r.status, 200, r.text);
const page = r.text;
-- assert.ok(page.includes('<div class="pick" id="pick-happening">'), 'the pick block is on the home');
-- const block = page.slice(page.indexOf('<div class="pick" id="pick-happening">'));
-- const chips = [...block.matchAll(/<a class="pick-chip" href="#(?:pp-happening-)?([a-z-]+)">()([^<]+)<\/a>/g)];
-- assert.deepStrictEqual(chips.map((m) => m[1]), ['shelf-roadmap', 'shelf-pulse'], 'a chip per shelf');
-- assert.ok(!/type="radio"/.test(block), 'no chip is a radio: nothing is picked, every pane shows');
-- const panes = [...block.matchAll(/<div class="pick-pane" id="([a-z-]+)">/g)].map((m) => m[1]);
-- assert.deepStrictEqual(panes, ['shelf-roadmap', 'shelf-pulse'], 'every shelf is in the page, one per chip');
-- // Nothing was removed: both shelves are still there.
-- for (const title of ['On the roadmap', 'A test item', 'Across OpenVibe', 'Recently shipped on OpenVibe.Community']) {
-- assert.ok(page.includes(title), `${title} is on the page`);
-- }
++ assert.ok(!page.includes('class="pick"') && !page.includes('pick-chip') && !page.includes('What is happening'), 'no chips and no extra heading over the two');
++ assert.match(page, /<div class="cm-pair"><section class="cm-sec" id="roadmap" aria-labelledby="h-roadmap">/);
++ assert.match(page, /<section class="cm-sec" id="pulse" aria-labelledby="h-pulse">/);
++ for (const title of ['On the roadmap', 'A test item', 'Across OpenVibe', 'Recently shipped on OpenVibe.Community']) assert.ok(page.includes(title), `${title} is on the page`);
});
-- await check('the CSS guard: the chips are hidden and both shelves show until 640 px and where :has() is understood', async () => {
-- const r = await t.get('/');
-- const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'community.css'), 'utf8');
-- assert.match(css, /\.pick-row \{ display: none; \}/, 'the chips are hidden by default');
++ await check('a roadmap row: its state is the mark, and the word once under the title', async () => {
++ const page = (await t.get('/')).text;
++ const road = page.slice(page.indexOf('id="roadmap"'), page.indexOf('id="pulse"'));
++ assert.match(road, /<span class="cm-mark cm-mark--accent" aria-hidden="true"><svg class="ovk-i"/, 'in progress: the accent mark');
++ assert.match(road, /<span class="cm-mark cm-mark--ok" aria-hidden="true">/, 'done: the green mark');
++ assert.match(road, /<span class="ovk-row-sub"><span>In progress<\/span>/);
++ assert.ok(!/ovk-badge/.test(road), 'no badge repeated on every row');
});
-- await check('the questions are a fold, open in the page and closed on a phone', async () => {
-- const r = await t.get('/');
-- assert.ok(r.text.includes('<details class="hm-fold hm-fold-block" id="faq-fold" open>'), 'the questions are a fold, open in the page');
-- assert.ok(r.text.includes('document.getElementById("faq-fold")'), 'the fold has the script that closes it on a phone');
-- // The questions stay in the page.
-- assert.ok(r.text.includes('id="questions"'));
-- assert.match(r.text, /<div class="cm-faq">/);
++ await check('a thing that happened: the site as an icon, its words, its picture; one row a place', async () => {
++ const page = (await t.get('/')).text;
++ const rows = page.slice(page.indexOf('id="pulse"'), page.indexOf('id="api"'));
++ assert.match(rows, /<span class="cm-mark cm-mark--site" aria-hidden="true">/, 'no person: the site alone');
++ assert.match(rows, /<span class="ovk-row-sub"><span>Network<\/span>/, 'the site in words, with its capital');
++ assert.strictEqual((rows.match(/href="https:\/\/openvibe\.live\/@Dee"/g) || []).length, 1, "a stream's go-live and its day's card are one row");
++ assert.match(rows, /<span>Blog · What changed this week, in one paragraph\.<\/span>/, "the item's own line beside the site");
++ assert.match(rows, /<img class="cm-pthumb" src="https:\/\/openvibe\.media\/o\/med_01JZ00000000000000000000B1[^"]*" alt="" width="44" height="44" loading="lazy"/, 'its picture at the end of the row');
++ assert.match(rows, /ovk-badge[^>]*>AI</, 'what OpenVibe.AI wrote says so');
++ });
++
++ await check('the questions: all in the page, a mark and an arrow on each, and the button a phone opens the rest with', async () => {
++ const page = (await t.get('/')).text;
++ const q = page.slice(page.indexOf('id="questions"'));
++ assert.strictEqual((q.slice(0, q.indexOf('</section>')).match(/<details>/g) || []).length, 6, 'six questions, none left out');
++ assert.match(q, /<div class="cm-peek" id="faq-peek"><div class="cm-faq"><details><summary><span class="cm-faq-i" aria-hidden="true">/);
++ assert.match(q, /<button type="button" class="cm-peek-more" id="faq-more" aria-controls="faq-peek" aria-expanded="false" hidden><span data-more="All 6 questions" data-less="Show fewer">All 6 questions<\/span>/);
++ assert.ok(!page.includes('hm-fold'), 'the closed fold is gone');
++ const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'community.css'), 'utf8');
++ assert.match(css, /\.cm-peek\[data-clamped\] \{ max-height: 150px;/, 'the list fades only once the script has clamped it');
++ assert.ok(!/\.pick-|\.hm-fold/.test(css), 'the styles of the blocks that are gone went with them');
});
} finally {
await t.close();
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex 5c862fd..b78a063 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -10,10 +10,10 @@ const { spawn } = require('child_process');
const { measure, check, format } = require('openvibe-shared/perf-budget');
const BUDGETS = {
-- htmlRawKB: 46.1, // 46.1: measured 44.1 with openvibe-shared 4.163.0, plus room for 4.162.0–4.163.0, 2026-11-09
++ htmlRawKB: 48.2, // 48.2: measured 47.8 with the home's rows carrying their marks (a state icon on each roadmap row, the site's icon on each thing that happened, a mark and an arrow on each question), 2026-11-10; 46.1: measured 44.1 with openvibe-shared 4.163.0, plus room for 4.162.0–4.163.0, 2026-11-09
// 36.4 with the Part of OpenVibe section (Shared 4.13, 2026-10-26); measured 31.7 on 10-22: the home gained the forum's places, the roadmap, Pulse, real counts and six
// questions with their FAQPage JSON-LD; was 26 at 22.0
-- htmlBrotliKB: 10.3, // 10.1 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 8.6 with the Part of OpenVibe section (Shared 4.13, 2026-10-26); 7.3 on 10-22 (the same shelves); was 6.5 at 5.7
++ htmlBrotliKB: 10.7, // 10.5 measured with the rows' marks, 2026-11-10 (the same icons repeat, so brotli takes most of it); 10.1 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 8.6 with the Part of OpenVibe section (Shared 4.13, 2026-10-26); 7.3 on 10-22 (the same shelves); was 6.5 at 5.7
jsFiles: 7, // 6: openvibe-shared/shell adds web-runtime.js (raised with the shell, 2026-10-04)
jsRawKB: 409, // 408.2 with openvibe-shared 4.77.0: the navbar loads the glyphs when a page first shows an icon (2026-11-01); 408: 408: /shared/ov-glyphs.js in place of Font Awesome (2026-11-01, plan L54 K4; Font Awesome's CSS and font were external, uncounted, and heavier); 339.1 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 338.3 with openvibe-shared 4.64.0: the look travels between sites, the Look panel in the menu (2026-10-31); 337.5 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face (2026-10-31); 335.4 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 332.4 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 329.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); 322 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 314 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; 306.5 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 300.7 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 286.6 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 276.8 with shared 4.11.0's seasonal navbar; 267.5 with openvibe-shared 4.9.0 (status text twins in every theme, 2026-10-23); 251.3 (was 208.8 before the shell's web-runtime.js)
jsBrotliKB: 100.5, // 100.5: /shared/ov-glyphs.js in place of Font Awesome (2026-11-01, plan L54 K4); 83.1 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 82.3 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 81.7 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); 80.2 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 78.1 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; 74.4 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 71.7 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 69.1 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 65.5 with shared 4.11.0's seasonal navbar; 59.9 (was 48.9)
ddiff --git a/test/ssr.test.js b/test/ssr.test.js
iindex 9283617..f5d7106 100644
---- a/test/ssr.test.js
++++ b/test/ssr.test.js
@@@ -79,7 +79,7 @@ const { boot, check, done } = require('./helpers/app');
assert.ok(forum.includes('<ul class="sc-apps">') && forum.includes('<a href="/forum/general">') && forum.includes('href="/forum">All forums'), 'the forum\'s places as a launcher');
assert.ok(/<dt>public pastes<\/dt><dd>\d+<\/dd>/.test(r.text), 'the real count of public pastes');
const faq = [...r.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].flatMap((m) => [].concat(JSON.parse(m[1]))).find((x) => x['@type'] === 'FAQPage');
-- const shown = [...r.text.matchAll(/<summary>([^<]*)<\/summary>/g)].map((m) => m[1].replace(/&#39;/g, "'"));
++ const shown = [...r.text.matchAll(/<span class="cm-faq-q">([^<]*)<\/span>/g)].map((m) => m[1].replace(/&#39;/g, "'"));
assert.deepStrictEqual(faq.mainEntity.map((q) => q.name), shown, 'every question in the FAQPage is on the page');
// Verbiage: never "free"/"no cost".
assert.ok(!/\bfree\b(?! speech)/i.test(r.text.replace(/<script[\s\S]*?<\/script>/g, '')), 'no "free" claims outside "free speech"');