openvibers/openvibe.community Commits

Home: a taste and a door, not the index (15.4 -> 4.6 screens at 390 px) (#179)

OP OpenVibers committed 15 days ago · 3266c608c6 · 15 files, +204 −143

The front page stacked three full paste grids (one card per row on a phone), the forum as tall tiles, six feature
cards that repeated it, the API twice, Part of OpenVibe and a closing call that repeated the hero. It is now built
from the shared kit, the pattern of OpenVibe.Media#219 and OpenVibe.Network#415:

- hero: two actions side by side (Start a paste; Browse pastes, or My pastes signed in), a shorter lede; the
  terminal sample only where the hero is split (above 860 px)
- the numbers as one kit.stats strip
- Latest pastes and From streams as kit.rail + kit.card with See all; the kinds (Most viewed, Images, the top
  languages) as quick filters; the Most viewed shelf is that filter now, so the home no longer reads trending
- the forum's places as a showcase.apps launcher (openvibe-shared v4.38.0)
- the roadmap (5) and Pulse (4) as kit.list rows, side by side from 1024 px; Pulse keeps nofollow off-site
- the API in a kit.fold with kit.snippet samples; the questions and Recently shipped stay
- What lives here, Part of OpenVibe and the closing call go: each repeated a part above, the app bar or the footer

The home links kit.css and room.css; kit-web.js (rail arrows, card menus, copy buttons) is loaded by community.js
only on a page that draws the kit (meta ov-kit, like ov-items), so every page keeps the same scripts for boost.
community.css drops the old shelves' rules, and its running-text underline rule is held to an element's weight
(:where) so it no longer overrides a shared piece's link style.

Budgets: htmlRawKB 42 -> 41 (39.4 measured), cssFiles 3 -> 5, cssRawKB 59 -> 100 (97.3: kit.css 25.6 KB +
room.css 15.7 KB), cssBrotliKB -> 21 (20.4); JS unchanged. Tests follow the new contract (two hero actions, the
rail, See all, the kit's stylesheets, the fold, the launcher, no repeated shelf; most viewed checked on
/pastes?sort=views).

Changes

ddiff --git a/README.md b/README.md
iindex a1a0e6e..01420b8 100644
---- a/README.md
++++ b/README.md
@@@ -42,7 +42,7 @@ little progressive JavaScript for pagination, copy buttons and the upload path.
(a space's room), OpenVibe.Events (the outbox relay and the Pulse and account subscriptions)
- OpenVibe.Live only for old VOD/clip comment imports (it reads and writes no pastes here any more)
- `openvibe-contracts` v0.112.0, `openvibe-sdk` v0.67.0 (events outbox, per-actor limits, no-looks),
-- `openvibe-shared` v4.37.0, pinned by release tarball
++ `openvibe-shared` v4.38.0, pinned by release tarball
## How it fits the network
@@@ -846,5 +846,5 @@ release after paste comments moved onto the typed comment threads. [Submissions]
<!-- versions:start -->
- openvibe-contracts: v0.222.0
- openvibe-sdk: v0.67.0
--- openvibe-shared: v4.37.0
++- openvibe-shared: v4.38.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 9c2f719..2e7ebe6 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -9,7 +9,7 @@
"domain": "openvibe.community",
"contracts": "openvibe-contracts v0.222.0",
"sdk": "openvibe-sdk v0.67.0",
-- "shared": "openvibe-shared v4.37.0",
++ "shared": "openvibe-shared v4.38.0",
"capabilities": [
"community.paste.create",
"community.paste.write",
ddiff --git a/package-lock.json b/package-lock.json
iindex 4297096..d0a190f 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -19,7 +19,7 @@
"multer": "^2.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.37.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.38.0",
"pg": "^8.23.0"
},
"devDependencies": {
@@@ -1236,9 +1236,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "4.37.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.37.0",
-- "integrity": "sha512-zT73fINooICUxuoxT8bwS1C5bh6ieNaDAX6aUaMdzMth1nTXbkwh54bgiPMytAJ2D0a8b3pqxhLYCvxFVzCVqA==",
++ "version": "4.38.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.38.0",
++ "integrity": "sha512-4sEQ78EQaw+cWWJs+AlPRhxObzHT1nwTMz6yHVBL20Mr+raAhhP10DtL1OTnbuMCDVsYhVwpd3QGgmyC6391EA==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/package.json b/package.json
iindex 8e84ca8..c5ce3b4 100644
---- a/package.json
++++ b/package.json
@@@ -30,7 +30,7 @@
"multer": "^2.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.37.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.38.0",
"pg": "^8.23.0"
},
"engines": {
ddiff --git a/public/css/community.css b/public/css/community.css
iindex a63fb8b..4f282cb 100644
---- a/public/css/community.css
++++ b/public/css/community.css
@@@ -51,13 +51,7 @@ img { max-width: 100%; height: auto; }
.section { padding: 28px 0 8px; }
.section-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
.section-head h2 { margin: 0; }
--/* The home's sections take the showcase's heading size (openvibe-shared/showcase.css .sc-sec), like "What lives here" between
-- them and the other OpenVibe homes; a paste page's "Comments" and "More like this" keep the compact size. */
--.home-sec > .section-head h2 { font-size: clamp(22px, 2.6vw, 30px); letter-spacing: -.02em; }
.more { font-size: .9rem; font-weight: 600; white-space: nowrap; }
--.lang-cloud { display: flex; flex-wrap: wrap; gap: 8px; margin: 18px 0 0; }
--.lang-cloud a { background: var(--bg-card); border: 1px solid var(--border); border-radius: 999px; padding: 4px 12px; font-size: .85rem; color: var(--text-primary); }
--.lang-cloud a small { color: var(--text-muted); margin-left: 4px; }
/* Cards */
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 14px; }
@@@ -367,24 +361,33 @@ button.rx:hover { border-color: var(--accent); }
.pulse-new { position: sticky; top: 8px; z-index: 5; display: flex; justify-content: center; margin: 0 0 10px; }
.pulse-new[hidden] { display: none; }
--/* A link inside running text is underlined: colour alone did not tell it apart (WCAG 1.4.1). */
--.lede a, .muted a, p a:not(.btn) { text-decoration: underline; text-underline-offset: 2px; }
++/* A link inside running text is underlined: colour alone did not tell it apart (WCAG 1.4.1). Held to an element's weight
++ (:where), so it beats the bare `a` rule but not a shared piece's own link style (showcase's "All forums →"). */
++:where(.lede, .muted, p) a:not(:where(.btn)) { text-decoration: underline; text-underline-offset: 2px; }
--/* Home shelves (server/render/pages.js homePage): real counts, the forum's places, the roadmap, Pulse, questions */
--.cm-stats { display: flex; flex-wrap: wrap; gap: 10px; margin: 4px 0 8px; padding: 0; }
--.cm-stats > div { display: flex; flex-direction: column-reverse; min-width: 0; padding: 8px 14px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-card); }
--.cm-stats dt { margin: 0; font-size: .8rem; color: var(--text-secondary); }
--.cm-stats dd { margin: 0; font-size: 1.3rem; font-weight: 800; font-variant-numeric: tabular-nums; line-height: 1.2; }
--.cm-shelf { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(min(240px, 100%), 1fr)); }
--.cm-tile { display: grid; gap: 4px; height: 100%; padding: 14px 16px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-card); color: var(--text-primary); }
--.cm-tile:hover, .cm-tile:focus-visible { border-color: var(--accent); text-decoration: none; }
--.cm-tile span { font-size: .9rem; color: var(--text-secondary); }
--.cm-tile small { font-size: .8rem; color: var(--text-secondary); }
--.cm-list { list-style: none; margin: 0; padding: 0; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-card); }
--.cm-list li { padding: 10px 14px; overflow-wrap: anywhere; }
--.cm-list li + li { border-top: 1px solid var(--border); }
++/* 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 > 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; }
++.cm-home .ovk-rail-more, .cm-home .sc-apps-more a { display: inline-flex; align-items: center; min-height: 44px; }
++.cm-note { margin: 0; color: var(--text-secondary); font-size: .88rem; }
++/* The kinds as quick filters: one strip that scrolls sideways on a phone (like the numbers), wrapping from 600 px. */
++.cm-kinds { display: flex; gap: 8px; margin: 2px -16px 0; padding: 0 16px 2px; overflow-x: auto; scrollbar-width: none; }
++.cm-kinds::-webkit-scrollbar { display: none; }
++.cm-kinds .ovk-btn { flex: none; min-height: 44px; }
++.cm-pair { display: grid; grid-auto-flow: row; gap: 0 28px; min-width: 0; }
++.cm-fold-lede { margin: 0 0 12px; color: var(--text-secondary); }
++.cm-snip-h { margin: 0 0 6px; color: var(--text-secondary); font-size: .8rem; font-weight: 700; }
++@media (min-width: 600px) { .cm-kinds { flex-wrap: wrap; margin: 2px 0 0; padding: 0; overflow: visible; } }
++@media (min-width: 1024px) { .cm-pair { grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); align-items: start; } }
++/* 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 { cursor: pointer; padding: 12px 16px; font-weight: 700; }
++.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; }
ddiff --git a/public/js/community.js b/public/js/community.js
iindex 15a5f9b..ae68570 100644
---- a/public/js/community.js
++++ b/public/js/community.js
@@@ -194,7 +194,21 @@
document.head.appendChild(s);
}
++ // ── The kit's behaviour ────────────────────────────────────
++ // A page with the kit's rails, card menus or copy buttons (openvibe-shared kit.js markup, the home) gets kit-web.js;
++ // it enhances what is there and, after that, every boosted page swap by itself.
++ function kitWeb() {
++ var meta = document.querySelector('meta[name="ov-kit"]');
++ if (!meta || window.OpenVibeKitWeb || document.getElementById('ov-kit-js')) return;
++ if (!document.querySelector('.ovk-rail, .ovk-more, [data-ovk-copy]')) return;
++ var s = document.createElement('script');
++ s.id = 'ov-kit-js';
++ s.src = meta.getAttribute('content');
++ document.head.appendChild(s);
++ }
++
wirePage();
wearItems();
-- document.addEventListener('ov:boost:load', function () { wirePage(); wearItems(); });
++ kitWeb();
++ document.addEventListener('ov:boost:load', function () { wirePage(); wearItems(); kitWeb(); });
})();
ddiff --git a/server/app.js b/server/app.js
iindex d834eed..25f62ba 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -323,8 +323,8 @@ async function createApp(opts = {}) {
// Every page response below (redirects and error pages too) that sets no policy of its own.
app.use(cache.applyHtml({ private: true }));
-- // The home's other shelves: the forum's public places, the roadmap (in progress first), the network's latest
-- // activity from Pulse (pastes are shown above it already) and real counts. Read at most once a minute; a shelf
++ // The home's other shelves: the forum's public places, the roadmap (its five next, in progress first), the network's
++ // four latest from Pulse (pastes are shown above it already) and real counts. Read at most once a minute; a shelf
// that fails or is empty is left out of the page.
let homeExtras = { at: 0, value: null };
async function readHomeExtras() {
@@@ -334,10 +334,10 @@ async function createApp(opts = {}) {
quiet(require('./forum/store').listSpaces(db, ['public']), []),
quiet(db.prepare(`SELECT t.title, t.slug, t.status, t.last_activity_at FROM threads t JOIN spaces s ON s.id = t.space_id
WHERE s.slug = 'roadmap' AND s.visibility = 'public' AND t.deleted_at IS NULL AND t.members_only_owner IS NULL
-- ORDER BY (t.status = 'in_progress') DESC, t.last_activity_at DESC, t.id DESC LIMIT 8`).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
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, 6)), []),
++ quiet(pulse.list({ limit: 12 }).then((out) => (out.items || []).filter((it) => it.source && it.source.type !== 'paste').slice(0, 4)), []),
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(), {}),
]);
@@@ -358,9 +358,10 @@ async function createApp(opts = {}) {
// server/agent-pages.js. A twin passes the anonymous viewer and noView: it must never carry a
// signed-in person's view, count a page view or consume a burn-after-read paste.
async function homeView(user) {
-- // People's pastes lead; OpenVibe.AI's moment pastes from streams have their own shelf.
-- const [latest, trending, fromStreams, languages, extras] = await Promise.all([catalog.latest(12, { from: 'people' }), catalog.trending(8, { from: 'people' }), catalog.latest(8, { from: 'streams' }), catalog.languages(), readHomeExtras()]);
-- return pages.homePage({ latest, trending, fromStreams, languages, user, ...extras });
++ // People's pastes lead; OpenVibe.AI's moment pastes from streams have their own rail. The most viewed are a quick
++ // filter there (/pastes?sort=views), not a shelf of their own.
++ const [latest, fromStreams, languages, extras] = await Promise.all([catalog.latest(12, { from: 'people' }), catalog.latest(8, { from: 'streams' }), catalog.languages(), readHomeExtras()]);
++ return pages.homePage({ latest, fromStreams, languages, user, ...extras });
}
async function browseView(query) {
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 2b50a8d..0365b35 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -146,6 +146,9 @@ function renderPage(o) {
`<script src="${asset('js/community.js')}" defer></script>`,
// openvibe-shared items.js + items.css: community.js loads them only on a page that shows someone.
`<meta name="ov-items" content="${escapeHtml(ovServe.url('items.js'))} ${escapeHtml(ovServe.url('items.css'))}">`,
++ // openvibe-shared kit-web.js (rails' arrows, card menus, copy buttons): community.js loads it only on a page
++ // that draws the kit's pieces, so every page keeps the same scripts and boost still swaps between them.
++ `<meta name="ov-kit" content="${escapeHtml(ovServe.url('kit-web.js'))}">`,
`<meta name="ov-boost" content="community@${escapeHtml(RELEASE)}">`,
`<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,
].filter(Boolean).join('\n'),
ddiff --git a/server/render/pages.js b/server/render/pages.js
iindex ac8b66d..f90685a 100644
---- a/server/render/pages.js
++++ b/server/render/pages.js
@@@ -11,6 +11,7 @@ const ld = require('./jsonld');
const { renderPage, SITE_NAME, DEFAULT_OG_IMAGE } = require('./layout');
const frame = require('openvibe-shared/frame');
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');
@@@ -122,8 +123,11 @@ function cardGrid(pastes, empty = 'Nothing here yet.') {
}
// ── Home ─────────────────────────────────────────────────────
--// The home (openvibe-shared/showcase): what Community is and where to start, the pastes people are sharing right
--// now, what else lives here, and the API a terminal can paste with. The paste grids stay this site's own markup.
++// The front page is a taste and a door, not the index (owner 2026-10-29: pages "so dang long"; a phone should "act more
++// like an app"): what Community is and the two things to do first, the numbers as one strip, the newest pastes and the
++// moments from streams as rails of the shared kit's cards (openvibe-shared kit.rail + kit.card, room.css) with See all
++// and the kinds as quick filters, the forum's places as a launcher (showcase.apps), the roadmap and Pulse as short lists,
++// and the parts read once (the API) folded. The full lists live on /pastes, /s, /s/roadmap and /pulse.
const TERMINAL = `$ curl -F 'content=<build.log' \\
https://openvibe.community/api/pastes
{ "slug": "amber-fox-42",
@@@ -140,115 +144,134 @@ 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' };
++// 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' };
--/** The numbers under the heading: only real, non-zero counts. */
++/** The numbers: only real, non-zero counts, as one strip (kit.stats; it scrolls sideways on a phone). */
function homeStats(c = {}) {
-- const items = [
-- c.pastes ? [num(c.pastes), 'public pastes'] : null,
-- c.shipped ? [num(c.shipped), 'roadmap items shipped'] : null,
-- c.building ? [num(c.building), 'being built now'] : null,
-- c.pulse ? [num(c.pulse), 'updates in Pulse'] : null,
-- ].filter(Boolean);
-- return items.length ? `<dl class="cm-stats">${items.map(([v, l]) => `<div><dt>${esc(l)}</dt><dd>${esc(v)}</dd></div>`).join('')}</dl>` : '';
++ return kit.stats([
++ [Number(c.pastes) || 0, 'public pastes'],
++ [Number(c.shipped) || 0, 'roadmap items shipped'],
++ [Number(c.building) || 0, 'being built now'],
++ [Number(c.pulse) || 0, 'updates in Pulse'],
++ ], { label: 'OpenVibe.Community in numbers' });
}
++/**
++ * A paste as the kit's card: its picture when it is a screenshot (never an NSFW one: that card shows the paste icon and
++ * says NSFW), else the paste icon with its language in the corner; who, views and when under the title.
++ */
++function pasteKitCard(p) {
++ const isShot = p.type === 'screenshot';
++ const image = isShot && !p.is_nsfw ? mediaAbs(p.screenshot_url) : '';
++ return kit.card({
++ href: `/p/${p.slug}`, title: p.title || 'Untitled', type: 'post', ratio: '16/9', image: image || '',
++ badge: p.is_nsfw ? 'NSFW' : p.origin === 'ai' ? 'AI' : p.pinned ? 'Pinned' : '',
++ corner: isShot ? '' : languageLabel(p.language || 'text'),
++ who: authorName(p), avatar: avatarUrl(p, 68) || '',
++ count: [Number(p.views) || 0, 'views'], when: p.created_at || null,
++ });
++}
++
++/** A section's head in the rails' look (room.css): its heading and the one link to the whole list. */
++function shelfHead(id, title, more) {
++ return `<div class="ovk-rail-head"><h2 id="${esc(id)}">${esc(title)}</h2><a class="ovk-rail-more" href="${esc(more.href)}">${esc(more.label)}${kit.icon('chevron')}</a></div>`;
++}
++
++/** The kinds of paste as quick filters under the newest: the most viewed, then the languages people use most. */
++function kindChips(languages) {
++ const items = [{ href: '/pastes?sort=views', label: 'Most viewed' }].concat((languages || []).slice(0, 7).map((l) => (l.language === 'image'
++ ? { href: '/pastes?type=images', label: `Images ${num(l.count)}` }
++ : { href: `/pastes?lang=${encodeURIComponent(l.language)}`, label: `${languageLabel(l.language)} ${num(l.count)}` })));
++ return `<nav class="cm-kinds" aria-label="Browse pastes by kind">${items.map((x) => kit.button({ ...x, kind: 'quiet', small: true })).join('')}</nav>`;
++}
++
++function latestShelf(latest, languages) {
++ const rail = kit.rail({ id: 'latest', title: 'Latest pastes', more: { href: '/pastes', label: 'See all' }, items: (latest || []).map(pasteKitCard) });
++ const none = `<section id="latest" aria-labelledby="latest-h">${shelfHead('latest-h', 'Latest pastes', { href: '/pastes', label: 'See all' })}${kit.empty({ title: 'No pastes yet', text: 'Be the first: paste something and share the link.', action: { href: '/new', label: 'Start a paste' } })}</section>`;
++ return `<div class="cm-sec">${rail || none}${languages && languages.length ? kindChips(languages) : ''}</div>`;
++}
++
++function streamsShelf(items) {
++ if (!items || !items.length) return '';
++ return `<div class="cm-sec">${kit.rail({ id: 'from-streams', title: 'From streams', more: { href: '/pastes?from=streams', label: 'See all' }, items: items.map(pasteKitCard) })}
++<p class="cm-note">Moments from streams on OpenVibe.Live, written up by OpenVibe.AI, each linking back to the stream.</p></div>`;
++}
++
++/** The forum's public places as a launcher: icon tiles on a phone, a row with each one's line from 600 px. */
function forumShelf(spaces) {
if (!spaces || !spaces.length) return '';
-- return `<section class="section home-sec" id="forum" aria-labelledby="h-forum">
-- <div class="section-head"><h2 id="h-forum">The forum</h2><a class="more" href="/s">All forums <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a></div>
-- <ul class="cm-shelf" role="list">${spaces.slice(0, 8).map((sp) => `<li><a class="cm-tile" href="/s/${esc(sp.slug)}"><strong>${esc(sp.name)}</strong>${sp.description ? `<span>${esc(ld.clean(sp.description, 140))}</span>` : ''}<small>${num(sp.thread_count)} ${Number(sp.thread_count) === 1 ? 'thread' : 'threads'}${sp.last_activity_at ? ` · active ${esc(timeAgo(sp.last_activity_at))}` : ''}</small></a></li>`).join('')}</ul>
--</section>`;
++ return showcase.apps({
++ id: 'forum', title: 'The forum',
++ items: spaces.slice(0, 8).map((sp) => ({
++ icon: SPACE_ICON[sp.slug] || 'ov:chat', name: sp.name, href: `/s/${sp.slug}`,
++ text: `${sp.description ? `${ld.clean(sp.description, 90)} · ` : ''}${num(sp.thread_count)} ${Number(sp.thread_count) === 1 ? 'thread' : 'threads'}`,
++ })),
++ more: { href: '/s', label: 'All forums' },
++ });
}
++const whenOf = (v) => (v ? kit.when(v) : { text: '', iso: '' });
function roadmapShelf(items) {
if (!items || !items.length) return '';
-- return `<section class="section home-sec" id="roadmap" aria-labelledby="h-roadmap">
-- <div class="section-head"><h2 id="h-roadmap">On the roadmap</h2><a class="more" href="/s/roadmap">The whole roadmap <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a></div>
-- <ul class="cm-list" role="list">${items.map((t) => `<li><a href="/s/roadmap/t/${esc(t.slug)}">${esc(t.title)}</a> <span class="badge badge-status status-${esc(t.status || 'open')}">${esc(STATUS_WORDS[t.status] || 'Open')}</span>${t.last_activity_at ? ` <span class="muted small">${timeTag(t.last_activity_at)}</span>` : ''}</li>`).join('')}</ul>
--</section>`;
++ return `<section class="cm-sec" id="roadmap" aria-labelledby="h-roadmap">${shelfHead('h-roadmap', 'On the roadmap', { href: '/s/roadmap', label: 'The whole roadmap' })}
++${kit.list(items.slice(0, 5).map((t) => kit.row({ href: `/s/roadmap/t/${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>`;
}
function pulseShelf(items) {
if (!items || !items.length) return '';
-- return `<section class="section home-sec" id="pulse" aria-labelledby="h-pulse">
-- <div class="section-head"><h2 id="h-pulse">Across OpenVibe</h2><a class="more" href="/pulse">All of Pulse <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a></div>
-- <ul class="cm-list" role="list">${items.map((it) => {
-- const local = it.source.service === 'community';
-- return `<li><span class="badge pulse-source">${esc(SERVICE_WORDS[it.source.service] || it.source.service)}</span> <a href="${esc(it.url)}"${local ? '' : ' rel="nofollow ugc noopener"'}>${esc(it.title)}</a>${it.origin === 'ai' ? ' <span class="badge badge-ai" title="Generated by AI">AI</span>' : ''} <span class="muted small">${timeTag(it.occurred_at)}</span></li>`;
-- }).join('')}</ul>
--</section>`;
++ const rows = items.slice(0, 4).map((it) => {
++ const row = kit.row({ href: it.url, 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 });
++ // Links to the network's other sites keep the nofollow they have on /pulse (kit.row has no rel of its own).
++ return it.source.service === 'community' ? row : row.replace('<a href=', '<a rel="nofollow ugc noopener" href=');
++ });
++ 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>`;
++}
++
++/** The API, read once: folded, with the two samples to copy. */
++function apiFold() {
++ return kit.fold({
++ id: 'api', title: 'Paste from a script', meta: 'curl or fetch',
++ body: `<p class="cm-fold-lede">The page you use is the API a script uses: no key for public pastes. Sign in, or send your OpenVibe token, to keep them under your account.</p>
++<p class="cm-snip-h">A file from a terminal</p>${kit.snippet({ id: 'api-curl', code: `curl -F 'content=<build.log' -F title='Build log' \\\n https://openvibe.community/api/pastes` })}
++<p class="cm-snip-h">JavaScript</p>${kit.snippet({ id: 'api-js', code: `const api = 'https://openvibe.community/api/pastes';
++const res = await fetch(api, {
++ method: 'POST',
++ headers: { 'content-type': 'application/json' },
++ body: JSON.stringify({ title: 'Snippet', content }),
++});
++const { url } = await res.json(); // "/p/<slug>"` })}`,
++ });
}
function homeFaq() {
-- return `<section class="section home-sec" id="questions" aria-labelledby="h-faq">
-- <div class="section-head"><h2 id="h-faq">Questions</h2></div>
++ return `<section class="cm-sec" id="questions" aria-labelledby="h-faq">
++ <h2 id="h-faq" class="cm-h">Questions</h2>
<div class="cm-faq">${HOME_FAQ.map((f) => `<details><summary>${esc(f.q)}</summary><p>${esc(f.a)}</p></details>`).join('')}</div>
</section>`;
}
--function homePage({ latest, trending, fromStreams = [], languages, user, spaces = [], roadmap = [], activity = [], counts = {} }) {
++function homePage({ latest, fromStreams = [], languages, user, spaces = [], roadmap = [], activity = [], counts = {} }) {
const body = showcase.hero({
-- eyebrow: 'OpenVibe.Community',
title: 'Share it with a link.', accent: 'Talk about it here.',
-- lede: 'Paste code, logs, configs or a screenshot and get a short link that works anywhere. Reading and sharing need no account. Sign in with your OpenVibe account to edit, comment and keep a list of your own, and talk things through in the Forum.',
++ lede: 'Paste code, logs, configs or a screenshot and get a short link that works anywhere. No account needed to read or share.',
++ // Two, side by side on a phone: the paste itself, then the list (signed in: your own; See all opens everyone's).
actions: [
{ label: 'Start a paste', href: '/new', primary: true, icon: 'fa-plus' },
-- { label: 'Browse pastes', href: '/pastes', icon: 'fa-paste' },
-- ...(user ? [{ label: 'My pastes', href: '/my', icon: 'fa-user' }] : []),
++ user ? { label: 'My pastes', href: '/my', icon: 'fa-user' } : { label: 'Browse pastes', href: '/pastes', icon: 'fa-paste' },
],
-- note: 'Run by the people using it and moderated in the open. Say it, own it.',
++ // The terminal beside the words on a computer; a phone skips it (community.css), and the API fold has it too.
aside: { html: `<pre class="cm-hero-code" aria-label="Paste a file from a terminal"><code>${esc(TERMINAL)}</code></pre>` },
}) + homeStats(counts) + `
--<section class="section home-sec" id="latest">
-- <div class="section-head"><h2>Latest pastes</h2><a class="more" href="/pastes">All pastes <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a></div>
-- ${cardGrid(latest, 'No pastes yet — be the first.')}
-- ${languages && languages.length ? `<p class="lang-cloud" aria-label="Browse by language">${languages.slice(0, 12).map((l) => `<a href="/pastes?lang=${encodeURIComponent(l.language)}">${esc(languageLabel(l.language))} <small>${l.count}</small></a>`).join(' ')}</p>` : ''}
--</section>
--
--<section class="section home-sec" id="trending">
-- <div class="section-head"><h2>Most viewed</h2><a class="more" href="/pastes?sort=views">By views <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a></div>
-- ${cardGrid(trending, 'Nothing trending yet.')}
--</section>
--${fromStreams.length ? `
--<section class="section home-sec" id="from-streams">
-- <div class="section-head"><h2>From streams</h2><a class="more" href="/pastes?from=streams">All from streams <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a></div>
-- <p class="muted">Moments from streams on OpenVibe.Live, written up by OpenVibe.AI, each linking back to the stream.</p>
-- ${cardGrid(fromStreams, '')}
--</section>` : ''}
++${latestShelf(latest, languages)}
++${streamsShelf(fromStreams)}
${forumShelf(spaces)}
--${roadmapShelf(roadmap)}
--${pulseShelf(activity)}
--` + showcase.features({
-- id: 'spaces',
-- title: 'What lives here',
-- items: [
-- { icon: 'fa-paste', title: 'Pastes', text: 'Code, logs and configs with highlighting for every common language, a raw link and a download.', href: '/new' },
-- { icon: 'fa-image', title: 'Screenshots', text: 'Drop an image and share it with a link. Location data is stripped before it is stored.', href: '/new' },
-- { icon: 'fa-layer-group', title: 'Forum', text: 'General, Feedback and Showcase: places to talk, share and get help.', href: '/s' },
-- { icon: 'fa-comments', title: 'Threads', text: 'Long-form discussion that outlives a chat scrollback. Start one in any open forum.', href: '/s/general' },
-- { icon: 'fa-wave-square', title: 'Pulse', text: 'What is happening across the OpenVibe network, in one feed.', href: '/pulse' },
-- { icon: 'fa-code-branch', title: 'Open source', text: 'Every line that runs this site is public. Read it, file an issue, send a fix.', href: 'https://github.com/OpenVibers/OpenVibe.Community' },
-- ],
-- }) + showcase.code({
-- id: 'api',
-- title: 'Paste from anywhere',
-- lede: 'The page you use is the API a script uses: no key for public pastes. Sign in, or send your OpenVibe token, to keep them under your account.',
-- samples: [
-- { label: 'A file from a terminal', lang: 'bash', code: `curl -F 'content=<build.log' -F title='Build log' \\\n https://openvibe.community/api/pastes` },
-- { label: 'JavaScript', lang: 'js', code: `const api = 'https://openvibe.community/api/pastes';
--const res = await fetch(api, {
-- method: 'POST',
-- headers: { 'content-type': 'application/json' },
-- body: JSON.stringify({ title: 'Snippet', content }),
--});
--const { url } = await res.json(); // "/p/<slug>"` },
-- ],
-- }) + homeFaq() + showcase.network({ service: 'community' }) + showcase.cta({
-- title: 'Start a paste',
-- text: 'Paste it, get a link, share it anywhere. Sign in to keep your pastes together under your OpenVibe account, or post without one.',
-- actions: [{ label: 'New paste', href: '/new', primary: true, icon: 'fa-plus' }, { label: 'Visit the Forum', href: '/s' }],
-- }) + `
++<div class="cm-pair">${roadmapShelf(roadmap)}${pulseShelf(activity)}</div>
++<div class="cm-sec">${apiFold()}</div>
++${homeFaq()}
<section class="section">${frame.shipped({ service: 'community', title: 'Recently shipped on OpenVibe.Community' })}</section>`;
return renderPage({
title: 'OpenVibe.Community: pastes, forums and the OpenVibe roadmap',
@@@ -261,7 +284,9 @@ const { url } = await res.json(); // "/p/<slug>"` },
...(latest && latest.length ? [{ '@context': 'https://schema.org', '@type': 'ItemList', name: 'Latest pastes', url: `${config.baseUrl}/pastes`, itemListElement: latest.slice(0, 12).map((p, i) => ({ '@type': 'ListItem', position: i + 1, name: p.title || 'Untitled', url: `${config.baseUrl}/p/${p.slug}` })) }] : []),
{ '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: HOME_FAQ.map((f) => ({ '@type': 'Question', name: f.q, acceptedAnswer: { '@type': 'Answer', text: f.a } })) },
],
-- styles: [showcase.STYLESHEET],
++ // The kit's controls (kit.css) and its cards and rails (room.css, after it); kit-web.js comes with community.js.
++ styles: [showcase.STYLESHEET, 'kit.css', 'room.css'],
++ bodyClass: 'cm-home',
body,
});
}
ddiff --git a/test/pastes-from-streams.test.js b/test/pastes-from-streams.test.js
iindex a092194..1a16f13 100644
---- a/test/pastes-from-streams.test.js
++++ b/test/pastes-from-streams.test.js
@@@ -48,10 +48,10 @@ const check = async (name, fn) => { catalog.reset(); await fn(); passed++; conso
});
await check('the home has a From streams shelf; the browse page says whose pastes and keeps it in its links', async () => {
-- const home = pages.homePage({ latest: [], trending: [], fromStreams: [rows[0]], languages: [] });
++ const home = pages.homePage({ latest: [], fromStreams: [rows[0]], languages: [] });
assert.match(home, /id="from-streams"/);
assert.match(home, /href="\/pastes\?from=streams"/);
-- assert.doesNotMatch(pages.homePage({ latest: [], trending: [], languages: [] }), /id="from-streams"/, 'no shelf without any');
++ assert.doesNotMatch(pages.homePage({ latest: [], languages: [] }), /id="from-streams"/, 'no shelf without any');
const out = await catalog.browse({ from: 'streams' });
const page = pages.browsePage({ ...out, pages: 3 }, []);
assert.match(page, /from streams, written up by OpenVibe\.AI/);
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex a5e1ff9..6f75ef0 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -10,16 +10,16 @@ const { spawn } = require('child_process');
const { measure, check, format } = require('openvibe-shared/perf-budget');
const BUDGETS = {
-- htmlRawKB: 42, // 41.6 with the agent layer's two discovery <link> tags (Shared 4.34.0, 2026-10-29); was 41 at 41.0
++ htmlRawKB: 41, // 39.4 the short home (2026-10-29: rails, a launcher and a fold in place of three grids, What lives here, Part of OpenVibe and the closing call), lowered back from 42; 41.6 with the agent layer's two discovery <link> tags (Shared 4.34.0, 2026-10-29); was 41 at 41.0
// 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, // 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: 318, // 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: 80, // 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)
-- cssFiles: 3, // 2
-- cssRawKB: 59, // 54.9 openvibe-shared 4.26.1 (hero visual); 47.2 with Shared 4.13.0 (2026-10-26); 40.6 on 10-22 (the home shelves' styles in community.css); was 40 at 36.5 with the showcase home
-- cssBrotliKB: 11.5, // 9.5 with Shared 4.13.0 (2026-10-26); 7.7 // raised 11 -> 12 for the openvibe-shared Kit 2 pin (kit.css grew) // back to the pre-Kit 2 value: Kit 2 has its own files (kit-data.css) since openvibe-shared v4.37.0, so kit.css weighs what it did // back to the pre-Kit 2 value: Kit 2 has its own files (kit-data.css) since openvibe-shared v4.37.0, so kit.css weighs what it did
++ cssFiles: 5, // 5 the short home: + kit.css (numbers, lists, chips, the fold) and room.css (cards and rails), the shared kit's look; 3
++ cssRawKB: 100, // 97.3 the short home (2026-10-29): kit.css 25.6 KB + room.css 15.7 KB, while community.css sheds the old shelves' rules and the page 10 of its 15 phone screens; 54.9 openvibe-shared 4.26.1 (hero visual); 47.2 with Shared 4.13.0 (2026-10-26); 40.6 on 10-22 (the home shelves' styles in community.css); was 40 at 36.5 with the showcase home
++ cssBrotliKB: 21, // 20.4 the short home (kit.css + room.css, as above; was 11.5 once Kit 2 had its own files in openvibe-shared v4.37.0); 9.5 with Shared 4.13.0 (2026-10-26); 7.7 // raised 11 -> 12 for the openvibe-shared Kit 2 pin (kit.css grew)
externalFiles: 2, // 1
};
ddiff --git a/test/shipped-pages.test.js b/test/shipped-pages.test.js
iindex b52c6fd..289473b 100644
---- a/test/shipped-pages.test.js
++++ b/test/shipped-pages.test.js
@@@ -5,7 +5,7 @@
const assert = require('assert');
const pages = require('../server/render/pages');
--const home = pages.homePage({ latest: [], trending: [], languages: [], user: null });
++const home = pages.homePage({ latest: [], languages: [], user: null });
assert.ok(home.includes('data-ov-shipped="latest" data-service="community" href="/updates"'), 'home pill');
assert.ok(home.includes('data-ov-shipped="list" data-service="community"'), 'home recent list');
assert.ok(home.includes('"logoutUrl":"/auth/logout?next={path}"'));
ddiff --git a/test/ssr-community.test.js b/test/ssr-community.test.js
iindex 878c1e7..4c13829 100644
---- a/test/ssr-community.test.js
++++ b/test/ssr-community.test.js
@@@ -28,9 +28,11 @@ const { boot, check, done } = require('./helpers/app');
const r = await t.get('/');
assert.strictEqual(r.status, 200);
has(r.text, `href="/p/${js}"`);
-- const trendingAt = r.text.indexOf('id="trending"');
-- assert.ok(r.text.indexOf(nginx, trendingAt) < r.text.indexOf(js, trendingAt), 'most viewed first');
-- assert.ok(!r.text.includes(unl) && !r.text.includes(priv));
++ // The most viewed are the home's quick filter (/pastes?sort=views), read from the same store.
++ has(r.text, 'href="/pastes?sort=views"');
++ const views = await t.get('/pastes?sort=views');
++ assert.ok(views.text.indexOf(nginx) > 0 && views.text.indexOf(nginx) < views.text.indexOf(js), 'most viewed first');
++ assert.ok(![r.text, views.text].some((html) => html.includes(unl) || html.includes(priv)));
assert.strictEqual(t.live.calls.filter((c) => c.path.startsWith('/api/pastes')).length, 0);
});
ddiff --git a/test/ssr.test.js b/test/ssr.test.js
iindex 2c2da17..a1f6327 100644
---- a/test/ssr.test.js
++++ b/test/ssr.test.js
@@@ -19,7 +19,7 @@ const { boot, check, done } = require('./helpers/app');
await ins.run('fair-moon-23', sam.subject_id, 'screenshot', 'Desktop shot', 'my desktop', 'text', 'public', 'https://openvibe.media/o/med_FAIRMOON', 40, 0, '2026-09-11 08:00:00', '2026-09-11 08:00:00');
await ins.run('grim-vale-61', alex.subject_id, 'paste', 'private thing', 'private', 'text', 'private', null, 0, 0, '2026-09-10 08:00:00', '2026-09-10 08:00:00');
-- await check('home renders hero, latest + trending pastes, CTA and roadmap with full SEO head', async () => {
++ await check('home renders hero, the latest pastes as a rail, the forum, roadmap and questions with full SEO head', async () => {
const r = await t.get('/');
assert.strictEqual(r.status, 200);
has(r.text, '<html lang="en"');
@@@ -33,16 +33,27 @@ const { boot, check, done } = require('./helpers/app');
has(r.text, '/shared/footer.js?v=');
has(r.text, '<h1>Share it with a link.<span class="sc-accent"> Talk about it here.</span></h1>');
has(r.text, '/shared/showcase.css?v=');
-- has(r.text, 'id="api"');
-- has(r.text, '<h2>Latest pastes</h2>');
-- has(r.text, '<h2>Most viewed</h2>');
-- has(r.text, 'id="spaces"');
-- has(r.text, 'href="/p/amber-fox-42"');
-- // Most-viewed puts the 900-view nginx paste first.
-- const trendingAt = r.text.indexOf('id="trending"');
-- assert.ok(r.text.indexOf('blue-lake-17', trendingAt) < r.text.indexOf('amber-fox-42', trendingAt));
++ // The front page is a taste and a door (2026-10-29): at most two hero actions; the newest pastes as a rail of the
++ // shared kit's cards (kit.css + room.css, kit-web.js through community.js) with See all; the kinds as quick
++ // filters, the most viewed among them; the API folded; the forum's places as a launcher.
++ const heroAt = r.text.indexOf('<section class="sc-hero');
++ const hero = r.text.slice(heroAt, r.text.indexOf('</section>', heroAt));
++ assert.strictEqual((hero.match(/class="sc-btn/g) || []).length, 2, 'two hero actions');
++ has(r.text, '/shared/kit.css?v=');
++ has(r.text, '/shared/room.css?v=');
++ has(r.text, '<meta name="ov-kit" content="/shared/kit-web.js?v=');
++ has(r.text, '<section class="ovk-rail ovk-rail--m" id="latest" aria-labelledby="latest-h">');
++ has(r.text, '<h2 id="latest-h">Latest pastes</h2>');
++ has(r.text, '<a class="ovk-rail-more" href="/pastes">See all');
++ assert.ok(/<article class="ovk-card ovk-card--post"[^>]*>[\s\S]*?href="\/p\/amber-fox-42"/.test(r.text), 'a paste is a kit card linking its page');
++ has(r.text, 'href="/pastes?sort=views"', 'the most viewed are a quick filter');
++ has(r.text, '<details class="ovk-fold" id="api">');
++ assert.ok(!/<h2>Most viewed<\/h2>|id="trending"|id="spaces"|class="sc-cta"|class="sc-sec sc-network"/.test(r.text), 'no shelf repeats another (most viewed, What lives here, the closing call, Part of OpenVibe)');
++ // Most viewed (/pastes?sort=views) puts the 900-view nginx paste first.
++ const views = await t.get('/pastes?sort=views');
++ assert.ok(views.text.indexOf('blue-lake-17') > 0 && views.text.indexOf('blue-lake-17') < views.text.indexOf('amber-fox-42'), 'most viewed first');
// Unlisted/private fixtures never show on shared pages.
-- assert.ok(!r.text.includes('dark-owl-55') && !r.text.includes('grim-vale-61'));
++ assert.ok(![r.text, views.text].some((html) => html.includes('dark-owl-55') || html.includes('grim-vale-61')));
// Navbar/footer init payload matches the shared-chrome contract.
const cfg = JSON.parse(r.text.match(/window\.__OV_PAGE = (.*);\n/)[1]);
assert.strictEqual(cfg.navbar.service, 'community');
@@@ -58,8 +69,8 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(cfg.footer.variant, 'full');
// The home's other shelves: the forum's places and the roadmap (both seeded at boot), and questions whose
// FAQPage JSON-LD is exactly what the page shows.
-- has(r.text, 'id="h-forum"');
-- has(r.text, 'href="/s/general"');
++ const forum = (/<section class="sc-sec sc-apps-sec" id="forum"[\s\S]*?<\/section>/.exec(r.text) || [''])[0];
++ assert.ok(forum.includes('<ul class="sc-apps">') && forum.includes('<a href="/s/general">') && forum.includes('href="/s">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, "'"));
ddiff --git a/test/wear-items.test.js b/test/wear-items.test.js
iindex a5a2298..a063479 100644
---- a/test/wear-items.test.js
++++ b/test/wear-items.test.js
@@@ -46,7 +46,9 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString
const src = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'community.js'), 'utf8');
assert.match(src, /querySelector\('\[data-ov-subject\]'\)/);
assert.match(src, /OpenVibeItems\.decorate\(document\)/);
-- assert.match(src, /ov:boost:load', function \(\) \{ wirePage\(\); wearItems\(\); \}/);
++ assert.match(src, /ov:boost:load', function \(\) \{ wirePage\(\); wearItems\(\); kitWeb\(\); \}/);
++ // kit-web.js the same way: only on a page that draws the kit's pieces (the home's rails, menus, copy buttons).
++ assert.match(src, /querySelector\('\.ovk-rail, \.ovk-more, \[data-ovk-copy\]'\)/);
});
await t.close();