openvibers/openvibe.help Commits
Help: the catalog lists product sites only; questions from each site in turn; site cards on the home page
Changes
@@@ -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; } }@@@ -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`. */@@@ -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>@@@ -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');@@@ -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.3jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)jsRawKB: 264, // 239.8jsBrotliKB: 62.5, // 56.5cssFiles: 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.6externalFiles: 0, // 0};
@@ -72,3 +72,19 @@ mark { background: var(--warning, #f59e0b); color: #10151f; border-radius: 3px;
@@ -72,3 +72,19 @@ mark { background: var(--warning, #f59e0b); color: #10151f; border-radius: 3px;
.msg.staff { border-left-color: var(--accent, #3b82f6); }
.msg.staff { border-left-color: var(--accent, #3b82f6); }
.msg-meta { margin: 0 0 4px; }
.msg-meta { margin: 0 0 4px; }
.state-form label.radio { margin-right: 16px; }
.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; } }
@@ -168,12 +168,20 @@ function buildSites() {
@@ -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 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)));
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 state = live.has(e.domain) ? 'live' : 'coming';
const manifests = e.manifests || [e.manifest];
const manifests = e.manifests || [e.manifest];
return {
return {
...e,
...e,
id: e.domain,
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,
state,
live: state === 'live',
live: state === 'live',
// A product manifest has no `what` of its own: its tagline is the one line, or the description's first sentence.
// 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) {
@@ -219,8 +227,16 @@ function buildArticles(sites) {
* not a ranking claim, and the page says where they come from.
* not a ranking claim, and the page says where they come from.
*/
*/
function popularQuestions(sites, articles, limit = 8) {
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`. */
/** Build the catalog once per process (and per test boot), at `now`. */
@@ -102,11 +102,11 @@ function createPageRoutes(ctx) {
@@ -102,11 +102,11 @@ function createPageRoutes(ctx) {
${searchForm()}</section>
${searchForm()}</section>
<section class="sc-sec" aria-labelledby="h-popular"><h2 id="h-popular">Popular questions</h2>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
${contactBlock(req)}</section>
@@ -22,8 +22,18 @@ const WITH_FAQ = [
@@ -22,8 +22,18 @@ const WITH_FAQ = [
const t = await boot();
const t = await boot();
const catalog = t.ctx.catalog;
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);
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([...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');
assert.deepStrictEqual([...got].sort(), [...expected].sort(), 'the catalog and the manifests disagree');
@@ -19,14 +19,14 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
@@ -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
// 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.
// 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 = {
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)
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)
jsRawKB: 264, // 239.8
jsRawKB: 264, // 239.8
jsBrotliKB: 62.5, // 56.5
jsBrotliKB: 62.5, // 56.5
cssFiles: 2, // 2 (app.css + the cached /shared/showcase.css)
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
externalFiles: 0, // 0
};
};