openvibers/openvibe.help Commits

Help's home answers on the page: twelve popular questions open to each site's own answer (and are FAQPage data), the hero shows real figures, and each site card counts its answers (#75)

OP OpenVibers committed 22 days ago · 1383bcb3b2 · 5 files, +45 −10

Changes

ddiff --git a/public/css/app.css b/public/css/app.css
iindex f1f1a0b..2d6b084 100644
---- a/public/css/app.css
++++ b/public/css/app.css
@@@ -79,7 +79,7 @@ mark { background: var(--warning, #f59e0b); color: #10151f; border-radius: 3px;
.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-cards { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(min(220px, 100%), 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); }
/* Not open yet: a dashed outline and no fill, never faded text (opacity took it below WCAG AA contrast). */
@@@ -89,3 +89,16 @@ mark { background: var(--warning, #f59e0b); color: #10151f; border-radius: 3px;
.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; } }
++
++/* The home: the figures beside the hero, the popular questions with their answers, each site's answer count. */
++.help-stats { margin: 0; display: grid; gap: 10px; }
++.help-stats div { border: 1px solid var(--border, #1f2d47); border-radius: 12px; padding: 12px 16px; background: var(--bg-card, #131c2e); }
++.help-stats dt { font-size: 1.25rem; font-weight: 700; color: var(--text-primary, #e6edf7); }
++.help-stats dd { margin: 2px 0 0; color: var(--text-secondary, #96a7c2); font-size: .92rem; }
++.q-faq { display: grid; gap: 8px; margin: 12px 0; }
++.q-faq details { border: 1px solid var(--border, #1f2d47); border-radius: 12px; padding: 4px 16px; background: var(--bg-card, #131c2e); }
++.q-faq details[open] { padding-bottom: 12px; }
++.q-faq summary { cursor: pointer; font-weight: 600; padding: 10px 0; display: flex; flex-wrap: wrap; justify-content: space-between; gap: 4px 12px; }
++.q-faq summary .q-site { font-weight: 400; }
++.q-faq details p { margin: 4px 0 0; color: var(--text-secondary, #96a7c2); overflow-wrap: anywhere; }
++.site-card-count { margin-top: auto; font-size: .8rem; color: var(--text-secondary, #96a7c2); }
ddiff --git a/server/catalog.js b/server/catalog.js
iindex fbabb1f..ce128ee 100644
---- a/server/catalog.js
++++ b/server/catalog.js
@@@ -227,7 +227,7 @@ function buildArticles(sites) {
* The questions the home page shows: real ones, live sites first, in catalog order. `limit` is a display choice,
* not a ranking claim, and the page says where they come from.
*/
--function popularQuestions(sites, articles, limit = 8) {
++function popularQuestions(sites, articles, limit = 12) {
// 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();
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 714c21f..af6ad1f 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -79,8 +79,18 @@ function createPageRoutes(ctx) {
<p><a class="sc-btn sc-primary" href="/tickets/new">Open a ticket</a> <a class="sc-btn" href="/tickets">Your tickets</a></p></div>`;
}
-- r.get('/', (req, res) => {
++ r.get('/', async (req, res) => {
const live = catalog.sites.filter((x) => x.live);
++ // Real figures only: the catalog's own counts, and tickets solved once there are any.
++ const answersBySite = new Map();
++ for (const a of catalog.articles) answersBySite.set(a.site, (answersBySite.get(a.site) || 0) + 1);
++ const features = catalog.sites.reduce((n, x) => n + ((x.pillars && x.pillars.length) || 0), 0);
++ let solved = 0;
++ try { solved = (await store.staffCounts(s)).solved; } catch { /* the figure is left out, never guessed */ }
++ const n = (v) => Number(v).toLocaleString('en-US');
++ const stats = [[`${n(live.length)} sites`, 'live, every one with its own page here'], [`${n(catalog.articles.length)} answers`, 'each in the site\'s own words, linked back']];
++ if (features) stats.push([`${n(features)} features`, 'described by the sites that run them']);
++ if (solved) stats.push([`${n(solved)} tickets`, 'solved by the people who run OpenVibe']);
const hero = showcase.hero({
eyebrow: `${SITE_NAME} · ${TAGLINE}`,
title: 'How can we help?',
@@@ -90,10 +100,13 @@ function createPageRoutes(ctx) {
? [{ label: 'Your tickets', href: '/tickets' }, { label: 'Open a ticket', href: '/tickets/new', primary: true }]
: [{ label: 'Search the answers', href: '#search', primary: true }, { label: 'Sign in to open a ticket', href: '/auth/login?next=%2Ftickets%2Fnew' }],
note: `Open source (AGPL-3.0). ${catalog.sites.length} sites in the catalog${live.length === catalog.sites.length ? ', every one live' : `: ${live.length} live, ${catalog.sites.length - live.length} still coming`}.`,
++ html: `<dl class="help-stats">${stats.map(([dt, dd]) => `<div><dt>${dt}</dt><dd>${dd}</dd></div>`).join('')}</dl>`,
});
++ // The questions shown with their answers are FAQPage data too: the same words, verbatim from the manifests.
++ const faqLd = catalog.popular.length ? { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: catalog.popular.map((a) => ({ '@type': 'Question', name: a.question, acceptedAnswer: { '@type': 'Answer', text: a.answer } })) } : null;
page(req, res, {
index: true, cache: signedIn(req) ? null : PUBLIC_CACHE,
-- jsonLd: homeJsonLd(config),
++ jsonLd: faqLd ? [...homeJsonLd(config), faqLd] : homeJsonLd(config),
description: `Search the answers of every OpenVibe site, browse them one by one, or open a support ticket that stays one conversation.`,
styles: [showcase.STYLESHEET],
body: html`${raw(hero)}
@@@ -101,12 +114,12 @@ function createPageRoutes(ctx) {
<p class="sc-lede">One box over every site's answers, and over the whole OpenVibe network.</p>
${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="q-site">${String(a.siteName).replace(/^OpenVibe\./, '')}</span></li>`)}</ul>
++<p class="sc-lede">One question from each site in turn, answered the way the site answers it in its own manifest.</p>
++<div class="q-faq">${catalog.popular.map((a) => html`<details><summary><span>${a.question}</span><span class="q-site">${String(a.siteName).replace(/^OpenVibe\./, '')}</span></summary><p>${a.answer}</p><p class="small"><a href="${a.path}">The answer's own page</a> · <a href="/sites/${a.site}">Every ${String(a.siteName).replace(/^OpenVibe\./, '')} question</a></p></details>`)}</div>
<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-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>
++<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>${answersBySite.get(x.domain) ? html`<span class="site-card-count">${String(answersBySite.get(x.domain))} ${answersBySite.get(x.domain) === 1 ? 'answer' : 'answers'}</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/layout.test.js b/test/layout.test.js
iindex 1a2425b..0ee2cd3 100644
---- a/test/layout.test.js
++++ b/test/layout.test.js
@@@ -42,7 +42,16 @@ const BOOST = /<script src="\/shared\/boost\.js\?v=[0-9a-f]{12}" data-main="#mai
assert.ok(head.includes('<link rel="canonical" href="https://openvibe.help/">'), 'canonical');
assert.ok(head.includes('<meta name="robots" content="index, follow">'), 'robots');
const ld = [...head.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1])['@type']);
-- assert.deepStrictEqual(ld, ['WebSite', 'WebApplication', 'WebPage'], 'the home JSON-LD');
++ assert.deepStrictEqual(ld, ['WebSite', 'WebApplication', 'WebPage', 'FAQPage'], 'the home JSON-LD: the site, the app, the page and the questions it shows');
++ const faq = [...head.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1])).find((j) => j['@type'] === 'FAQPage');
++ assert.ok(faq.mainEntity.length >= 8, 'a dozen questions, one per site in turn');
++ const page = r.text.replace(/&#39;/g, "'").replace(/&quot;/g, '"').replace(/&amp;/g, '&');
++ for (const q of faq.mainEntity) {
++ assert.ok(page.includes(`<span>${q.name}</span>`), `the page shows "${q.name}"`);
++ assert.ok(page.includes(q.acceptedAnswer.text), `and its answer, verbatim`);
++ }
++ assert.match(r.text, /<dl class="help-stats">[\s\S]*?answers<\/dt>/, 'the figures beside the hero');
++ assert.match(r.text, /class="site-card-count">\d+ answers?</, 'each site card counts its answers');
for (const tag of ['<meta property="og:title"', 'data-ov-icon="help"', '/css/app.css?v=', '/shared/theme-loader.js', '/shared/navbar.js', '/shared/footer.js', '<meta name="referrer"']) {
assert.ok(head.includes(tag), `head has ${tag}`);
}
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex d34041f..1d364e1 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -19,8 +19,8 @@ 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: 39, // measured 35.7 with 40 sites in the catalog (contracts 0.186.0, 2026-10-15); 32.6 on 2026-10-08 (the home page: hero, search, popular questions, the site cards with one line each, contact)
-- htmlBrotliKB: 9, // 8.3 with 40 sites (2026-10-15); 7.3
++ htmlRawKB: 50, // measured 45.7 on 2026-10-22: the 12 popular questions now carry their answers inline (and as FAQPage data), plus the hero's figures; 35.7 with 40 sites in the catalog (contracts 0.186.0, 2026-10-15); 32.6 on 2026-10-08 (the home page: hero, search, popular questions, the site cards with one line each, contact)
++ htmlBrotliKB: 10.7, // 9.7 on 2026-10-22 (answers inline); 8.3 with 40 sites (2026-10-15); 7.3
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)
jsRawKB: 264, // 239.8
jsBrotliKB: 62.5, // 56.5