openvibers/openvibe.community Commits
Home: a taste and a door, not the index (15.4 -> 4.6 screens at 390 px) (#179)
Changes
@@@ -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 -->@@@ -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",@@@ -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",@@@ -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": {@@@ -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; }@@@ -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(); });})();@@@ -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_idWHERE 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_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, 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) {@@@ -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'),@@@ -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,});}@@@ -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/);@@@ -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.0htmlBrotliKB: 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.7jsFiles: 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};@@@ -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}"'));@@@ -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);});@@@ -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(/'/g, "'"));@@@ -46,7 +46,9 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toStringconst 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();
@@ -42,7 +42,7 @@ little progressive JavaScript for pagination, copy buttons and the upload path.
@@ -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)
(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.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-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
## How it fits the network
@@ -846,5 +846,5 @@ release after paste comments moved onto the typed comment threads. [Submissions]
@@ -846,5 +846,5 @@ release after paste comments moved onto the typed comment threads. [Submissions]
<!-- versions:start -->
<!-- versions:start -->
- openvibe-contracts: v0.222.0
- openvibe-contracts: v0.222.0
- openvibe-sdk: v0.67.0
- openvibe-sdk: v0.67.0
-- openvibe-shared: v4.37.0
+- openvibe-shared: v4.38.0
<!-- versions:end -->
<!-- versions:end -->
@@ -9,7 +9,7 @@
@@ -9,7 +9,7 @@
"domain": "openvibe.community",
"domain": "openvibe.community",
"contracts": "openvibe-contracts v0.222.0",
"contracts": "openvibe-contracts v0.222.0",
"sdk": "openvibe-sdk v0.67.0",
"sdk": "openvibe-sdk v0.67.0",
- "shared": "openvibe-shared v4.37.0",
+ "shared": "openvibe-shared v4.38.0",
"capabilities": [
"capabilities": [
"community.paste.create",
"community.paste.create",
"community.paste.write",
"community.paste.write",
@@ -19,7 +19,7 @@
@@ -19,7 +19,7 @@
"multer": "^2.4.0",
"multer": "^2.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.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-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"
"pg": "^8.23.0"
},
},
"devDependencies": {
"devDependencies": {
@@ -1236,9 +1236,9 @@
@@ -1236,9 +1236,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"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",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -30,7 +30,7 @@
@@ -30,7 +30,7 @@
"multer": "^2.4.0",
"multer": "^2.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.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-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"
"pg": "^8.23.0"
},
},
"engines": {
"engines": {
@@ -51,13 +51,7 @@ img { max-width: 100%; height: auto; }
@@ -51,13 +51,7 @@ img { max-width: 100%; height: auto; }
.section { padding: 28px 0 8px; }
.section { padding: 28px 0 8px; }
.section-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
.section-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
.section-head h2 { margin: 0; }
.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; }
.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 */
/* Cards */
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 14px; }
.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); }
@@ -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 { position: sticky; top: 8px; z-index: 5; display: flex; justify-content: center; margin: 0 0 10px; }
.pulse-new[hidden] { display: none; }
.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 { 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 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 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; }
.cm-faq p { margin: 0; padding: 12px 16px 14px; color: var(--text-secondary); line-height: 1.6; }
@@ -194,7 +194,21 @@
@@ -194,7 +194,21 @@
document.head.appendChild(s);
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();
wirePage();
wearItems();
wearItems();
- document.addEventListener('ov:boost:load', function () { wirePage(); wearItems(); });
+ kitWeb();
+ document.addEventListener('ov:boost:load', function () { wirePage(); wearItems(); kitWeb(); });
})();
})();
@@ -323,8 +323,8 @@ async function createApp(opts = {}) {
@@ -323,8 +323,8 @@ async function createApp(opts = {}) {
// Every page response below (redirects and error pages too) that sets no policy of its own.
// Every page response below (redirects and error pages too) that sets no policy of its own.
app.use(cache.applyHtml({ private: true }));
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.
// that fails or is empty is left out of the page.
let homeExtras = { at: 0, value: null };
let homeExtras = { at: 0, value: null };
async function readHomeExtras() {
async function readHomeExtras() {
@@ -334,10 +334,10 @@ async function createApp(opts = {}) {
@@ -334,10 +334,10 @@ async function createApp(opts = {}) {
quiet(require('./forum/store').listSpaces(db, ['public']), []),
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
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
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
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, 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,
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(), {}),
]);
]);
@@ -358,9 +358,10 @@ async function createApp(opts = {}) {
@@ -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
// 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.
// signed-in person's view, count a page view or consume a burn-after-read paste.
async function homeView(user) {
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) {
async function browseView(query) {
@@ -146,6 +146,9 @@ function renderPage(o) {
@@ -146,6 +146,9 @@ function renderPage(o) {
`<script src="${asset('js/community.js')}" defer></script>`,
`<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.
// 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'))}">`,
`<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)}">`,
`<meta name="ov-boost" content="community@${escapeHtml(RELEASE)}">`,
`<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,
`<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,
].filter(Boolean).join('\n'),
].filter(Boolean).join('\n'),
@@ -11,6 +11,7 @@ const ld = require('./jsonld');
@@ -11,6 +11,7 @@ const ld = require('./jsonld');
const { renderPage, SITE_NAME, DEFAULT_OG_IMAGE } = require('./layout');
const { renderPage, SITE_NAME, DEFAULT_OG_IMAGE } = require('./layout');
const frame = require('openvibe-shared/frame');
const frame = require('openvibe-shared/frame');
const showcase = require('openvibe-shared/showcase');
const showcase = require('openvibe-shared/showcase');
+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');
@@ -122,8 +123,11 @@ function cardGrid(pastes, empty = 'Nothing here yet.') {
@@ -122,8 +123,11 @@ function cardGrid(pastes, empty = 'Nothing here yet.') {
}
}
// ── Home ─────────────────────────────────────────────────────
// ── 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' \\
const TERMINAL = `$ curl -F 'content=<build.log' \\
https://openvibe.community/api/pastes
https://openvibe.community/api/pastes
{ "slug": "amber-fox-42",
{ "slug": "amber-fox-42",
@@ -140,115 +144,134 @@ const HOME_FAQ = [
@@ -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_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' };
+// 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 = {}) {
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) {
function forumShelf(spaces) {
if (!spaces || !spaces.length) return '';
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) {
function roadmapShelf(items) {
if (!items || !items.length) return '';
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) {
function pulseShelf(items) {
if (!items || !items.length) return '';
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() {
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>
<div class="cm-faq">${HOME_FAQ.map((f) => `<details><summary>${esc(f.q)}</summary><p>${esc(f.a)}</p></details>`).join('')}</div>
</section>`;
</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({
const body = showcase.hero({
- eyebrow: 'OpenVibe.Community',
title: 'Share it with a link.', accent: 'Talk about it here.',
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: [
actions: [
{ label: 'Start a paste', href: '/new', primary: true, icon: 'fa-plus' },
{ 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>` },
aside: { html: `<pre class="cm-hero-code" aria-label="Paste a file from a terminal"><code>${esc(TERMINAL)}</code></pre>` },
}) + homeStats(counts) + `
}) + 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)}
${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>`;
<section class="section">${frame.shipped({ service: 'community', title: 'Recently shipped on OpenVibe.Community' })}</section>`;
return renderPage({
return renderPage({
title: 'OpenVibe.Community: pastes, forums and the OpenVibe roadmap',
title: 'OpenVibe.Community: pastes, forums and the OpenVibe roadmap',
@@ -261,7 +284,9 @@ const { url } = await res.json(); // "/p/<slug>"` },
@@ -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}` })) }] : []),
...(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 } })) },
{ '@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,
body,
});
});
}
}
@@ -48,10 +48,10 @@ const check = async (name, fn) => { catalog.reset(); await fn(); passed++; conso
@@ -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 () => {
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, /id="from-streams"/);
assert.match(home, /href="\/pastes\?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 out = await catalog.browse({ from: 'streams' });
const page = pages.browsePage({ ...out, pages: 3 }, []);
const page = pages.browsePage({ ...out, pages: 3 }, []);
assert.match(page, /from streams, written up by OpenVibe\.AI/);
assert.match(page, /from streams, written up by OpenVibe\.AI/);
@@ -10,16 +10,16 @@ const { spawn } = require('child_process');
@@ -10,16 +10,16 @@ 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: 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
// 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, // 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, // 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: 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)
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)
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
externalFiles: 2, // 1
};
};
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
const assert = require('assert');
const assert = require('assert');
const pages = require('../server/render/pages');
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="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('data-ov-shipped="list" data-service="community"'), 'home recent list');
assert.ok(home.includes('"logoutUrl":"/auth/logout?next={path}"'));
assert.ok(home.includes('"logoutUrl":"/auth/logout?next={path}"'));
@@ -28,9 +28,11 @@ const { boot, check, done } = require('./helpers/app');
@@ -28,9 +28,11 @@ const { boot, check, done } = require('./helpers/app');
const r = await t.get('/');
const r = await t.get('/');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
has(r.text, `href="/p/${js}"`);
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);
assert.strictEqual(t.live.calls.filter((c) => c.path.startsWith('/api/pastes')).length, 0);
});
});
@@ -19,7 +19,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -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('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 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('/');
const r = await t.get('/');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
has(r.text, '<html lang="en"');
has(r.text, '<html lang="en"');
@@ -33,16 +33,27 @@ const { boot, check, done } = require('./helpers/app');
@@ -33,16 +33,27 @@ const { boot, check, done } = require('./helpers/app');
has(r.text, '/shared/footer.js?v=');
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, '<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, '/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.
// 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.
// Navbar/footer init payload matches the shared-chrome contract.
const cfg = JSON.parse(r.text.match(/window\.__OV_PAGE = (.*);\n/)[1]);
const cfg = JSON.parse(r.text.match(/window\.__OV_PAGE = (.*);\n/)[1]);
assert.strictEqual(cfg.navbar.service, 'community');
assert.strictEqual(cfg.navbar.service, 'community');
@@ -58,8 +69,8 @@ const { boot, check, done } = require('./helpers/app');
@@ -58,8 +69,8 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(cfg.footer.variant, 'full');
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
// 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.
// 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');
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(/<summary>([^<]*)<\/summary>/g)].map((m) => m[1].replace(/'/g, "'"));
@@ -46,7 +46,9 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString
@@ -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');
const src = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'community.js'), 'utf8');
assert.match(src, /querySelector\('\[data-ov-subject\]'\)/);
assert.match(src, /querySelector\('\[data-ov-subject\]'\)/);
assert.match(src, /OpenVibeItems\.decorate\(document\)/);
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();
await t.close();