openvibers/openvibe.community Commits
Home: the roadmap and Across OpenVibe are plain sections with marks, and Questions shows two and fades (#342)
Changes
@@@ -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 theforum's launcher; kit.css: the numbers, lists, chips and the fold; room.css: the rails of cards); this is only thespacing 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 aphone 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); eachreader's own choice (Shared's ov-labels.js) shows, covers or hides it. */@@@ -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_idWHERE 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(), {}),]);@@@ -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>`;ddeleted file mode 100644@@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');----/**-- * A pick block. The panes are the page's own blocks; each one keeps its heading on a wide screen, and on a phone the-- * heading gives way to the chip that names it.-- *-- * pickBlock({ key, label, head, panes }) 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 };@@@ -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();@@@ -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.7jsFiles: 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)@@@ -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(/'/g, "'"));++ const shown = [...r.text.matchAll(/<span class="cm-faq-q">([^<]*)<\/span>/g)].map((m) => m[1].replace(/'/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"');
@@ -438,7 +438,7 @@ button.rx:hover { border-color: var(--accent); }
@@ -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
/* 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
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. */
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-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 { min-width: 0; margin-top: 28px; }
.cm-sec > .ovk-rail { margin-top: 0; }
.cm-sec > .ovk-rail { margin-top: 0; }
@@ -455,55 +455,50 @@ button.rx:hover { border-color: var(--accent); }
@@ -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
/* 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. */
phone gets to the pastes. */
@media (max-width: 860px) { .cm-home .sc-hero-aside { display: none; } }
@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
/* 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. */
reader's own choice (Shared's ov-labels.js) shows, covers or hides it. */
@@ -469,7 +469,11 @@ async function createApp(opts = {}) {
@@ -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(), []),
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
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(), []),
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,
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(), {}),
(SELECT COUNT(*) FROM pulse_items) AS pulse`).get(), {}),
]);
]);
@@ -14,7 +14,6 @@ const showcase = require('openvibe-shared/showcase');
@@ -14,7 +14,6 @@ const showcase = require('openvibe-shared/showcase');
const kit = require('openvibe-shared/kit');
const kit = require('openvibe-shared/kit');
const { highlight, escapeHtml: esc, languageLabel, extensionFor, LANGUAGES } = require('./highlight');
const { highlight, escapeHtml: esc, languageLabel, extensionFor, LANGUAGES } = require('./highlight');
const { renderMarkdown } = require('./markdown');
const { renderMarkdown } = require('./markdown');
-const { pickBlock, foldBlock } = require('./short');
const { canonicalSlug } = require('../pastes/url');
const { canonicalSlug } = require('../pastes/url');
const pastePathSlug = (p) => (p && (p.canonical_slug || canonicalSlug(p))) || '';
const pastePathSlug = (p) => (p && (p.canonical_slug || canonicalSlug(p))) || '';
@@ -166,7 +165,11 @@ const HOME_FAQ = [
@@ -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_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 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.
// 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' };
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) {
@@ -245,16 +248,38 @@ function forumShelf(spaces) {
const whenOf = (v) => (v ? kit.when(v) : { text: '', iso: '' });
const whenOf = (v) => (v ? kit.when(v) : { text: '', iso: '' });
function roadmapShelf(items) {
function roadmapShelf(items) {
if (!items || !items.length) return '';
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' })}
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) {
function pulseShelf(items) {
if (!items || !items.length) return '';
if (!items || !items.length) return '';
// Links to the network's other sites keep the nofollow they have on /pulse.
// 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' })}
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. */
/** The API, read once: folded, with the two samples to copy. */
@@ -273,26 +298,29 @@ const { url } = await res.json(); // "/p/<slug>"` })}`,
@@ -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() {
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
@@ -311,7 +339,7 @@ function homePage({ latest, fromStreams = [], languages, user, spaces = [], road
${latestShelf(latest, languages)}
${latestShelf(latest, languages)}
${streamsShelf(fromStreams)}
${streamsShelf(fromStreams)}
${forumShelf(spaces)}
${forumShelf(spaces)}
-${pickWhatIsHappening(roadmap, activity)}
+${whatIsHappening(roadmap, activity)}
<div class="cm-sec">${apiFold()}</div>
<div class="cm-sec">${apiFold()}</div>
${homeFaq()}
${homeFaq()}
<section class="section">${frame.shipped({ service: 'community', title: 'Recently shipped on OpenVibe.Community' })}</section>`;
<section class="section">${frame.shipped({ service: 'community', title: 'Recently shipped on OpenVibe.Community' })}</section>`;
deleted file mode 100644
deleted file mode 100644
@@ -1,92 +0,0 @@
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
-
-/**
- * A pick block. The panes are the page's own blocks; each one keeps its heading on a wide screen, and on a phone the
- * heading gives way to the chip that names it.
- *
- * pickBlock({ key, label, head, panes }) 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 };
@@ -1,8 +1,9 @@
@@ -1,8 +1,9 @@
'use strict';
'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 assert = require('assert');
const fs = require('fs');
const fs = require('fs');
@@ -12,40 +13,54 @@ const { boot, check, done } = require('./helpers/app');
@@ -12,40 +13,54 @@ const { boot, check, done } = require('./helpers/app');
(async () => {
(async () => {
const t = await boot();
const t = await boot();
try {
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 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('/');
const r = await t.get('/');
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
const page = 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 {
} finally {
await t.close();
await t.close();
@@ -10,10 +10,10 @@ const { spawn } = require('child_process');
@@ -10,10 +10,10 @@ const { spawn } = require('child_process');
const { measure, check, format } = require('openvibe-shared/perf-budget');
const { measure, check, format } = require('openvibe-shared/perf-budget');
const BUDGETS = {
const BUDGETS = {
- htmlRawKB: 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
// 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
// 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)
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)
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)
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)
@@ -79,7 +79,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -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(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');
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 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(/'/g, "'"));
+ const shown = [...r.text.matchAll(/<span class="cm-faq-q">([^<]*)<\/span>/g)].map((m) => m[1].replace(/'/g, "'"));
assert.deepStrictEqual(faq.mainEntity.map((q) => q.name), shown, 'every question in the FAQPage is on the page');
assert.deepStrictEqual(faq.mainEntity.map((q) => q.name), shown, 'every question in the FAQPage is on the page');
// Verbiage: never "free"/"no cost".
// 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"');
assert.ok(!/\bfree\b(?! speech)/i.test(r.text.replace(/<script[\s\S]*?<\/script>/g, '')), 'no "free" claims outside "free speech"');