openvibers/openvibe.help Commits

Help: the catalog lists product sites only; questions from each site in turn; site cards on the home page

OP OpenVibers committed 8 Oct 2026 · fb21c14d56 · 5 files, +53 −11


Changes

ddiff --git a/public/css/app.css b/public/css/app.css
iindex 426bd01..74cf875 100644
---- a/public/css/app.css
++++ b/public/css/app.css
@@@ -72,3 +72,19 @@ mark { background: var(--warning, #f59e0b); color: #10151f; border-radius: 3px;
.msg.staff { border-left-color: var(--accent, #3b82f6); }
.msg-meta { margin: 0 0 4px; }
.state-form label.radio { margin-right: 16px; }
++
++/* ── Home: popular questions and the site cards ── */
++.q-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); }
++.q-list li { display: flex; flex-direction: column; gap: 4px; padding: 12px 14px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); }
++.q-list li a { font-weight: 600; text-decoration: none; color: var(--text-primary, #e6edf7); }
++.q-list li a:hover { color: var(--accent-light, #60a5fa); }
++.q-site { font-size: .78rem; font-weight: 700; letter-spacing: .4px; text-transform: uppercase; color: var(--text-muted, #7386a3); }
++.site-cards { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); }
++.site-card { display: flex; flex-direction: column; gap: 4px; height: 100%; padding: 12px 14px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-secondary, #101828); color: inherit; text-decoration: none; transition: border-color .15s, transform .15s; }
++.site-card:hover { border-color: var(--accent, #3b82f6); transform: translateY(-1px); }
++.site-card.coming { opacity: .72; }
++.site-card-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
++.site-state { font-size: .7rem; font-weight: 700; padding: 1px 8px; border-radius: 999px; border: 1px solid var(--border-light, #2c3d5c); color: var(--text-muted, #7386a3); }
++.site-card:not(.coming) .site-state { color: var(--success, #22c55e); border-color: color-mix(in srgb, var(--success, #22c55e) 45%, transparent); }
++.site-card-line { font-size: .86rem; line-height: 1.4; color: var(--text-secondary, #96a7c2); }
++@media (prefers-reduced-motion: reduce) { .site-card { transition: none; } }
ddiff --git a/server/catalog.js b/server/catalog.js
iindex 9e0d81e..7538d22 100644
---- a/server/catalog.js
++++ b/server/catalog.js
@@@ -168,12 +168,20 @@ function buildSites() {
for (const m of services) if (m.site) byDomain.set(siteDomain(m), fromService(m, m.exposure ? m.exposure.state : null));
for (const p of contracts.products.manifests) byDomain.set(p.domain, merge(byDomain.get(p.domain), fromProduct(p)));
-- const sites = [...byDomain.values()].map((e) => {
++ // The help centre is about products: a site is an apex domain (openvibe.live, openre.stream) or a live service's own
++ // site on a subdomain (ai.openvibe.services). The network's operational hosts (admin., auth., api., status., themes.,
++ // realtime. on openvibe.network) and moved addresses are not products and stay out of the catalog.
++ const serviceSites = new Set(services.filter((m) => m.site).map(siteDomain));
++ const isProduct = (e) => String(e.domain).split('.').length === 2 || (serviceSites.has(e.domain) && live.has(e.domain));
++
++ const sites = [...byDomain.values()].filter(isProduct).map((e) => {
const state = live.has(e.domain) ? 'live' : 'coming';
const manifests = e.manifests || [e.manifest];
return {
...e,
id: e.domain,
++ // "OpenVibe.Actor" in a list of OpenVibe sites reads as "Actor"; OpenRe.Stream keeps its own name.
++ shortName: String(e.name || e.domain).replace(/^OpenVibe\./, ''),
state,
live: state === 'live',
// A product manifest has no `what` of its own: its tagline is the one line, or the description's first sentence.
@@@ -219,8 +227,16 @@ function buildArticles(sites) {
* not a ranking claim, and the page says where they come from.
*/
function popularQuestions(sites, articles, limit = 8) {
-- const order = new Map(sites.map((s, i) => [s.domain, i]));
-- return [...articles].sort((a, b) => order.get(a.site) - order.get(b.site)).slice(0, limit);
++ // One question from each site in turn (live sites first), so the list shows the breadth of the network rather than
++ // every question of the first site in the catalog.
++ const bySite = new Map();
++ for (const a of articles) { if (!bySite.has(a.site)) bySite.set(a.site, []); bySite.get(a.site).push(a); }
++ const queues = sites.map((s) => bySite.get(s.domain) || []).filter((q) => q.length);
++ const out = [];
++ for (let round = 0; out.length < limit && queues.some((q) => q.length > round); round++) {
++ for (const q of queues) { if (q[round] && out.length < limit) out.push(q[round]); }
++ }
++ return out;
}
/** Build the catalog once per process (and per test boot), at `now`. */
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 214d33d..983414d 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -102,11 +102,11 @@ function createPageRoutes(ctx) {
${searchForm()}</section>
<section class="sc-sec" aria-labelledby="h-popular"><h2 id="h-popular">Popular questions</h2>
<p class="sc-lede">Every line below is a question an OpenVibe product answers about itself in its own manifest.</p>
--<ul class="q-list">${catalog.popular.map((a) => html`<li><a href="${a.path}">${a.question}</a> <span class="muted small">${a.siteName}</span></li>`)}</ul>
++<ul class="q-list">${catalog.popular.map((a) => html`<li><a href="${a.path}">${a.question}</a><span class="q-site">${String(a.siteName).replace(/^OpenVibe\./, '')}</span></li>`)}</ul>
<p class="small"><a href="/sites">All ${catalog.sites.length} sites and their questions</a></p></section>
<section class="sc-sec" aria-labelledby="h-sites"><h2 id="h-sites">Browse by site</h2>
<p class="sc-lede">Live sites first; each name opens that site's page, with its own words and its own questions. <a href="/sites">The full list, with what each one is</a>.</p>
--<ul class="site-grid">${catalog.sites.map((x) => html`<li><a href="/sites/${x.id}">${x.name}</a></li>`)}</ul></section>
++<ul class="site-cards">${catalog.sites.map((x) => html`<li><a class="site-card${x.live ? '' : ' coming'}" href="/sites/${x.id}"><span class="site-card-top"><b>${x.shortName}</b><span class="site-state">${x.live ? 'live' : 'coming'}</span></span><span class="site-card-line">${x.oneLine}</span></a></li>`)}</ul></section>
<section class="sc-sec" aria-labelledby="h-contact"><h2 id="h-contact">Contact support</h2>
<p class="sc-lede">Nothing here answered it? Open a ticket: you and the people who run OpenVibe, in one thread.</p>
${contactBlock(req)}</section>
ddiff --git a/test/help-articles.test.js b/test/help-articles.test.js
iindex cb4f9ac..2c4c7aa 100644
---- a/test/help-articles.test.js
++++ b/test/help-articles.test.js
@@@ -22,8 +22,18 @@ const WITH_FAQ = [
const t = await boot();
const catalog = t.ctx.catalog;
-- await check('the catalog is exactly the manifests: every product domain and every service site, once each', () => {
-- const expected = new Set([...PRODUCTS.map((p) => p.domain), ...SERVICE_SITES.map(siteDomain)]);
++ await check('the catalog is exactly the product sites in the manifests: apex domains and live service sites, once each', () => {
++ const live = new Set();
++ for (const m of contracts.services.manifests) {
++ if (!m.exposure || m.exposure.state !== 'live') continue;
++ for (const d of m.domains || []) live.add(d);
++ if (m.site && m.site.tld) live.add(siteDomain(m));
++ }
++ const product = (d) => d.split('.').length === 2 || (SERVICE_SITES.map(siteDomain).includes(d) && live.has(d));
++ const expected = new Set([...PRODUCTS.map((p) => p.domain), ...SERVICE_SITES.map(siteDomain)].filter(product));
++ for (const host of ['admin.openvibe.network', 'auth.openvibe.network', 'api.openvibe.network', 'status.openvibe.network', 'themes.openvibe.network']) {
++ assert.ok(!catalog.sites.some((s) => s.id === host), `${host} is an operational host, not a product`);
++ }
const got = catalog.sites.map((s) => s.id);
assert.deepStrictEqual([...new Set(got)].length, got.length, 'a domain appears twice in the catalog');
assert.deepStrictEqual([...got].sort(), [...expected].sort(), 'the catalog and the manifests disagree');
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex 320c0ed..dc90e9a 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -19,14 +19,14 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
// against 11.6 KB of the shared showcase.css. Raised 2026-10-08 from 16.5/4.1 to 18.5/4.5 for a measured
// 17.5/4.3. The HTML budget did NOT move: those pages fit in the skeleton home page's 24.9 KB budget with room.
const BUDGETS = {
-- htmlRawKB: 27.5, // measured 25.3 (the home page: hero, search, popular questions, the site grid, contact)
-- htmlBrotliKB: 6.9, // 6.0
++ htmlRawKB: 36, // measured 32.6 (the home page: hero, search, popular questions, the site cards with one line each, contact)
++ htmlBrotliKB: 8.2, // 7.3
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)
jsRawKB: 264, // 239.8
jsBrotliKB: 62.5, // 56.5
cssFiles: 2, // 2 (app.css + the cached /shared/showcase.css)
-- cssRawKB: 18.5, // 17.5
-- cssBrotliKB: 4.5, // 4.3
++ cssRawKB: 21.5, // 19.3
++ cssBrotliKB: 5.2, // 4.6
externalFiles: 0, // 0
};