openvibers/openvibe.quest Commits

Quest: the home is a taste and a door (17 phone screens to 4) (#127)

OP OpenVibers committed 15 days ago · 9a50884340 · 8 files, +289 −127

* Quest: the home is a taste and a door (17 phone screens to 4.4)

The front page carried the whole catalog: every quest as a full card, one per row (6,639 px at 390), the 15 sites
Quest follows with their events (1,903 px), badge lists and feature cards that repeated the hero and the FAQ.

It is now built from the shared kit:
- the numbers as one kit.stats strip;
- the quests as two rails of kit cards (most earned, then nobody has these yet), each card's picture the badge it
  pays (render/badge-art.js, served at /quests/:id/badge.svg, /series/:id/badge.svg, /apps/quests/:id/badge.svg);
- See all 39 and Browse quests open the new index /quests (every quest card, by site, the site as kit.tabs);
- the sites as quick filters (/quests?site=…);
- how it works, rewards, the sites Quest follows and the questions as kit.folds.

Rails swipe on a phone and wrap into a grid from 760 px, so no kit-web.js. The page's own tap targets are 44 px on
phones (Shared 4.33's rail link and pills are smaller; raised in app.css). CSS budget 39 -> 82 KB raw (measured 73.9:
kit.css + room.css), stated in the budget's note; HTML budget lowered 69 -> 65 (measured 58.6).

* Quest: the new /quests index has a Markdown twin like the other public pages

Changes

ddiff --git a/public/css/app.css b/public/css/app.css
iindex dc2213c..144071b 100644
---- a/public/css/app.css
++++ b/public/css/app.css
@@@ -90,35 +90,34 @@ p.lede { font-size: 1.05rem; color: var(--text-secondary, #96a7c2); max-width: 7
.badges li { border: 1px solid var(--border-light, #2c3d5c); border-radius: 999px; padding: .2rem .8rem; }
.badges small { color: var(--text-muted, #7386a3); }
.quest-block { margin: .8rem 0; }
--/* The home's live parts (server/http/pages.js): figures, the most-earned badges, open badges, sites followed, questions. */
--.q-stats { list-style: none; margin: 4px 0 8px; padding: 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fit, minmax(min(120px, 100%), 1fr)); }
--.q-stats li { display: flex; flex-direction: column; gap: 2px; padding: 14px 16px; border-radius: 14px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); }
--.q-stats b { font-size: 1.6rem; font-weight: 800; line-height: 1.1; }
--.q-stats span { font-size: .86rem; color: var(--text-secondary, #96a7c2); }
++/* The home (server/http/pages.js): the shared kit's numbers, rails of cards, site filters and folds (kit.css, room.css),
++ with only the spacing and the touch sizes set here. */
.quest-count { margin: 6px 0 0; font-size: .84rem; font-weight: 600; color: var(--success-text, var(--success, #34d399)); }
--.q-top { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(min(320px, 100%), 1fr)); }
--.q-top li { display: grid; grid-template-columns: 56px minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 12px 14px; border-radius: 16px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); }
--.q-top-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; overflow-wrap: anywhere; }
--.q-top-main span { font-size: .88rem; color: var(--text-secondary, #96a7c2); }
--.q-top-n { font-weight: 800; white-space: nowrap; }
--@media (max-width: 520px) {
-- .q-top li { grid-template-columns: 44px minmax(0, 1fr); }
-- .q-top .medal { width: 44px; height: 44px; font-size: .85rem; }
-- .q-top-n { grid-column: 2; font-size: .9rem; }
-- .q-stats b { font-size: 1.3rem; }
++.page > .ovk-stats { margin-top: 4px; }
++.q-rails .ovk-rail:first-child { margin-top: 0; }
++.q-rails .ovk-tabs { margin-top: 0; }
++.q-kinds-h { margin: 4px 0 6px; font-size: .78rem; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
++/* Phones swipe the rails; from 760 px they wrap into a grid, so a mouse needs no arrows (and no kit-web.js). */
++@media (min-width: 760px) {
++ .q-rails .ovk-rail-track { grid-auto-flow: row; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); grid-auto-columns: auto; overflow: visible; margin: 0; padding: 2px 0 10px; }
++ .q-rails .ovk-rail-btn { display: none; }
++ .q-rails .ovk-tabs { flex-wrap: wrap; margin: 0; padding: 0 0 8px; overflow: visible; }
}
--.q-open { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 1fr)); }
--.q-open li { display: grid; grid-template-columns: 44px minmax(0, 1fr); align-items: center; gap: 12px; padding: 10px 12px; border-radius: 14px; border: 1px dashed var(--border, #1f2d47); }
--.q-open .medal { width: 44px; height: 44px; font-size: .85rem; }
--.q-open span { display: flex; flex-direction: column; gap: 2px; min-width: 0; overflow-wrap: anywhere; }
--.q-open small { color: var(--text-muted, #7386a3); }
--.q-sites { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 1fr)); }
--.q-sites li { display: flex; flex-direction: column; gap: 4px; padding: 12px 14px; border-radius: 14px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); min-width: 0; }
++/* 44 px to the touch (the kit's rule): its See all link is 32 px and its filter pills 36 px (openvibe-shared 4.37.0). */
++@media (pointer: coarse), (max-width: 759px) {
++ .q-rails .ovk-rail-more, .page .ovk-tabs a { min-height: 44px; }
++ .q-sites a, .site-group h3 a { display: inline-flex; align-items: center; min-height: 44px; }
++}
++.q-folds .ovk-fold-body p, .q-steps li { color: var(--text-secondary, #96a7c2); line-height: 1.55; }
++.q-folds .ovk-fold-body p { margin: 0 0 10px; }
++.q-folds .ovk-fold-body > :last-child { margin-bottom: 0; }
++.q-steps { margin: 0; padding-left: 1.3rem; display: grid; gap: 8px; }
++.q-steps strong { color: var(--text-primary, #e6edf7); }
++.q-sites { list-style: none; margin: 0 0 10px; padding: 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 1fr)); }
++.q-sites li { display: flex; flex-direction: column; gap: 4px; padding: 12px 14px; border-radius: 14px; border: 1px solid var(--border, #1f2d47); background: var(--bg-secondary, #0f1729); min-width: 0; }
.q-sites a { font-weight: 700; }
.q-sites small { color: var(--text-muted, #7386a3); }
.q-sites span { display: flex; flex-wrap: wrap; gap: 4px; }
.q-sites code { font-size: .78rem; overflow-wrap: anywhere; }
--.q-faq { display: flex; flex-direction: column; gap: 10px; max-width: 820px; }
--.q-faq details { border: 1px solid var(--border, #1f2d47); border-radius: 14px; background: var(--bg-card, #131c2e); padding: 0 16px; }
--.q-faq summary { cursor: pointer; padding: 14px 0; font-weight: 700; }
--.q-faq details p { margin: 0 0 14px; color: var(--text-secondary, #96a7c2); line-height: 1.6; }
++/* The full index (/quests): its filters sit under the lede. */
++.page > .ovk-tabs { margin-top: 6px; margin-bottom: 4px; }
ddiff --git a/server/agent-pages.js b/server/agent-pages.js
iindex 17b639f..9b8e517 100644
---- a/server/agent-pages.js
++++ b/server/agent-pages.js
@@@ -5,7 +5,7 @@
// without a session or a cookie, so a twin cannot drift from the page a browser gets and never carries a
// signed-in person's view. A page that is not public (a person's own page, a missing page) answers 404
// and has no twin.
--const TWIN_PATH_RE = /^\/(?:how-it-works|updates|quests\/[^/]+|series\/[^/]+|apps\/quests\/[^/]+)?$/;
++const TWIN_PATH_RE = /^\/(?:how-it-works|updates|quests|quests\/[^/]+|series\/[^/]+|apps\/quests\/[^/]+)?$/;
/** A request as an anonymous browser makes it: no session, no cookies. */
function anonymousRequest(path, query, host) {
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex 89f2314..0c23d12 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -20,7 +20,8 @@ const DISALLOW = ['/auth/', '/api/', '/me'];
// One line per public page for llms-full.txt. /me is not here: it is a person's own log, never crawled.
const PAGE_TEXT = {
-- '/': ['OpenVibe.Quest home', 'OpenVibe.Quest: Quests across the whole network. The quests, grouped by the site whose events each one counts.'],
++ '/': ['OpenVibe.Quest home', 'OpenVibe.Quest: Quests across the whole network. The badges people earn most, the ones nobody holds yet, and how quests work.'],
++ '/quests': ['All quests', 'Every OpenVibe quest, grouped by the site whose events each one counts, with the badge it pays and how many people hold it.'],
'/how-it-works': ['How quests work', 'What OpenVibe.Quest counts, what it stores, and what its rewards are — badges and OpenCoins, never money and never Vibes.'],
'/updates': ['What shipped on OpenVibe.Quest', 'This site\'s update log, from the network changelog feed.'],
...Object.fromEntries(Object.values(catalog.SERIES).map((x) => [`/series/${x.id}`, [x.title, `${x.why} On ${(catalog.SITES[x.site] || {}).name || x.site}.`]])),
@@@ -46,6 +47,7 @@ function homeJsonLd(config) {
const publicPages = () => [
{ path: '/', changefreq: 'weekly', priority: 1.0 },
++ { path: '/quests', changefreq: 'weekly', priority: 0.8 },
{ path: '/how-it-works', changefreq: 'monthly', priority: 0.6 },
...Object.values(catalog.SERIES).map((x) => ({ path: `/series/${x.id}`, changefreq: 'monthly', priority: 0.7 })),
...catalog.all().map((q) => ({ path: `/quests/${q.id}`, changefreq: 'monthly', priority: 0.7 })),
@@@ -73,6 +75,7 @@ function createDiscoveryRoutes(ctx) {
sections: [
{ title: 'Start here', links: [
{ title: 'OpenVibe.Quest', url: abs('/'), note: 'Quests across the whole network.' },
++ { title: 'All quests', url: abs('/quests'), note: 'every quest, grouped by site' },
{ title: 'How quests work', url: abs('/how-it-works'), note: 'what is counted, what is stored, and the rewards' },
{ title: 'The catalog (JSON)', url: abs('/api/v1/quests'), note: 'every quest, grouped by site' },
{ title: 'What shipped on OpenVibe.Quest', url: abs('/updates') },
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex b5dbdcf..b35f1b2 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -12,6 +12,9 @@
const ovServe = require('openvibe-shared/serve');
const frame = require('openvibe-shared/frame');
const showcase = require('openvibe-shared/showcase');
++const kit = require('openvibe-shared/kit');
++// The home's stylesheets: the showcase (hero, sections), then the kit's controls and its cards and rails (room.css).
++const KIT_STYLES = [showcase.STYLESHEET, 'kit.css', 'room.css'];
// A person's stuff across the network (openvibe-shared 4.13 profile-panel: items, badges, achievements), shown once the
// pinned openvibe-shared serves it; before that the section is simply not there.
const HAS_PROFILE_PANEL = require('openvibe-shared/files').isBrowserFile('profile-panel.js');
@@@ -47,7 +50,7 @@ function createPageRoutes(ctx) {
const r = asyncRouter();
const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 300 });
// The pages with a Markdown twin (openvibe-shared/agent-layer): the public ones only.
-- const TWIN_PATH_RE = /^\/(?:how-it-works|updates|quests\/[^/]+|series\/[^/]+|apps\/quests\/[^/]+)?$/;
++ const TWIN_PATH_RE = /^\/(?:how-it-works|updates|quests|quests\/[^/]+|series\/[^/]+|apps\/quests\/[^/]+)?$/;
const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, agentLayer: TWIN_PATH_RE.test(String(req.path || '/').split('?')[0]), llms: '/llms.txt', ...o });
const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
const coinsUi = { coinsEnabled: coins.enabled, dailyCap: config.coins.dailyCap, globalDailyCap: config.coins.globalDailyCap };
@@@ -79,50 +82,39 @@ function createPageRoutes(ctx) {
return value;
};
const siteCount = Object.keys(catalog.SITES).length;
-- const countText = (n) => n.toLocaleString('en-US');
++ const siteKey = (domain) => quests.shortSite(domain).toLowerCase();
-- /** The figures under the hero: each one only when it has something to say. */
-- // The numbers under the hero: the shared strip (three across on a phone), each with its icon.
-- const statsStrip = (p) => raw(showcase.stats({
-- title: `${SITE_NAME} in numbers`,
-- items: [
-- { value: countText(catalog.all().length), label: `quests on ${siteCount} sites`, icon: 'ov:quest' },
-- p.completions > 0 && { value: countText(p.completions), label: p.completions === 1 ? 'quest finished' : 'quests finished', icon: 'ov:check' },
-- p.people > 0 && { value: countText(p.people), label: p.people === 1 ? 'person with badges' : 'people with badges', icon: 'ov:account' },
-- p.recent > 0 && { value: countText(p.recent), label: 'finished this week', icon: 'ov:history' },
-- coinsUi.coinsEnabled && p.coins > 0 && { value: countText(p.coins), label: 'OpenCoins given', icon: 'ov:tips' },
-- ].filter(Boolean),
-- }));
--
-- /** The badges people hold most (all time), with the quest that pays each. */
-- const topBadges = (p) => {
-- const top = catalog.all().map((q) => ({ q, n: p.byQuest.get(q.id) || 0 })).filter((x) => x.n > 0)
-- .sort((a, b) => b.n - a.n || a.q.title.localeCompare(b.q.title)).slice(0, 6);
-- if (!top.length) return '';
-- return html`<section class="sc-sec" id="most-earned" aria-labelledby="most-earned-h"><h2 id="most-earned-h">Badges people earn most</h2>
--<p class="sc-lede">How many people hold each badge. Nobody is named: Quest shows counts, never who.</p>
--<ol class="q-top">${top.map(({ q, n }) => html`<li>${quests.medal(q, { earned: true })}<span class="q-top-main"><strong>${q.reward.badge.name}</strong><span>for <a href="/quests/${q.id}">${q.title}</a> on ${siteName(q.site)}</span></span><span class="q-top-n">${countText(n)} ${n === 1 ? 'person' : 'people'}</span></li>`)}</ol></section>`;
-- };
++ // The numbers under the hero: one strip of the shared kit (kit.stats; it scrolls sideways on a phone instead of
++ // stacking tiles), each only when it has something to say (a zero is left out).
++ const statsStrip = (p) => raw(kit.stats([
++ [catalog.all().length, `quests on ${siteCount} sites`],
++ [Number(p.completions) || 0, Number(p.completions) === 1 ? 'quest finished' : 'quests finished'],
++ [Number(p.people) || 0, Number(p.people) === 1 ? 'person with badges' : 'people with badges'],
++ [Number(p.recent) || 0, 'finished this week'],
++ coinsUi.coinsEnabled ? [Number(p.coins) || 0, 'OpenCoins given'] : null,
++ ].filter(Boolean), { label: `${SITE_NAME} in numbers` }));
-- /** Quests nobody has finished yet (outside a series): the badge is there for the first person. */
-- const firstToFinish = (p) => {
-- const open = catalog.all().filter((q) => !q.series && !(p.byQuest.get(q.id) > 0)).slice(0, 6);
-- if (!open.length) return '';
-- return html`<section class="sc-sec" id="be-first" aria-labelledby="be-first-h"><h2 id="be-first-h">Nobody has these badges yet</h2>
--<p class="sc-lede">Quests no one has finished so far. Be the first to hold the badge.</p>
--<ul class="q-open">${open.map((q) => html`<li>${quests.medal(q)}<span><a href="/quests/${q.id}">${q.title}</a><small>${siteName(q.site)} · Badge: ${q.reward.badge.name}</small></span></li>`)}</ul></section>`;
-- };
++ /**
++ * What the home offers as cards: every quest outside a series and each series as one, in the catalog's site order,
++ * with how many hold its badge (a series: the badges earned across it). Counts only, never who.
++ */
++ const homeEntries = (p) => catalog.grouped({ fold: true }).flatMap((g) => [
++ ...g.quests.map((q) => ({ q, n: p.byQuest.get(q.id) || 0 })),
++ ...(g.series || []).map((x) => ({ x, n: x.quests.reduce((sum, q) => sum + (p.byQuest.get(q.id) || 0), 0) })),
++ ]);
++ const entryCard = (e) => (e.x ? quests.seriesKitCard(e.x, { earned: e.n }) : quests.kitCard(e.q, { coinsEnabled: coinsUi.coinsEnabled, earned: e.n }));
/** Which sites Quest follows and the events each reports: the whole of what moves a quest. */
-- const sitesFollowed = () => {
++ const followed = () => {
const bySite = new Map();
for (const e of catalog.EVENTS) {
if (e.counted === false) continue;
if (!bySite.has(e.site)) bySite.set(e.site, []);
bySite.get(e.site).push(e.event_type);
}
-- return html`<ul class="q-sites">${[...bySite].map(([site, events]) => html`<li><a href="https://${site}">${siteName(site)}</a><small>${(catalog.SITES[site] || {}).what || ''}</small><span>${events.map((ev, i) => html`${i ? ' ' : ''}<code>${ev}</code>`)}</span></li>`)}</ul>`;
++ return bySite;
};
++ const sitesFollowed = () => html`<ul class="q-sites">${[...followed()].map(([site, events]) => html`<li><a href="https://${site}">${siteName(site)}</a><small>${(catalog.SITES[site] || {}).what || ''}</small><span>${events.map((ev, i) => html`${i ? ' ' : ''}<code>${ev}</code>`)}</span></li>`)}</ul>`;
const FAQ = [
{ q: 'What is OpenVibe.Quest?', a: `One quest log for the OpenVibe sites. It counts what you really do on ${siteCount} of them, like going live, posting in the forum or publishing a world, and pays a badge for each quest you finish.` },
@@@ -133,9 +125,13 @@ function createPageRoutes(ctx) {
{ q: 'Can developers add quests?', a: 'Yes. A developer app defines quests on its own events with an app token through the OpenVibe SDK. They pay a badge named for the app, never OpenCoins.' },
];
++ // The home is a taste and a door (owner 2026-10-28: "act more like an app, not a huge long web page"): the hero,
++ // one strip of numbers, the quests as rails of cards with See all → /quests and the sites as quick filters, and
++ // the parts read once (how it works, what is counted, the questions) as folds. The whole catalog is /quests.
r.get('/', async (req, res) => {
-- const [appList, p] = await Promise.all([ctx.appQuests ? ctx.appQuests.listed(24) : [], pulse()]);
++ const [appList, p] = await Promise.all([ctx.appQuests ? ctx.appQuests.listed(12) : [], pulse()]);
const site = String(config.baseUrl).replace(/\/+$/, '');
++ const total = catalog.all().length;
// The navbar already says OpenVibe.Quest: the hero says what you get, in plain words, and where to start.
const hero = showcase.hero({
eyebrow: { icon: 'ov:quest', text: 'Free, nothing to install' },
@@@ -143,10 +139,18 @@ function createPageRoutes(ctx) {
accent: 'for what you already do on OpenVibe.',
lede: `Go live, post in the forum, publish a world: every quest you finish on any of ${siteCount} OpenVibe sites earns a badge${coinsUi.coinsEnabled ? ' and OpenCoins' : ''}. One quest log follows you everywhere.`,
actions: signedIn(req)
-- ? [{ label: 'Your quest log', href: '/me', primary: true, icon: 'ov:quest' }, { label: 'Browse quests', href: '#the-quests', icon: 'ov:search' }]
-- : [{ label: 'Start earning', href: '/auth/login?next=%2Fme', primary: true, icon: 'ov:quest' }, { label: 'Browse quests', href: '#the-quests', icon: 'ov:search' }],
++ ? [{ label: 'Your quest log', href: '/me', primary: true, icon: 'ov:quest' }, { label: 'Browse quests', href: '/quests', icon: 'ov:search' }]
++ : [{ label: 'Start earning', href: '/auth/login?next=%2Fme', primary: true, icon: 'ov:quest' }, { label: 'Browse quests', href: '/quests', icon: 'ov:search' }],
note: 'Badges, items and OpenCoins: loyalty points, never money. Open source (AGPL-3.0).',
});
++ // The first rail: what people finish most (the easy starts), then the rest in the catalog's order; the second:
++ // the quests nobody has finished yet that the first one left out. Twelve each at most; /quests has them all.
++ const entries = homeEntries(p);
++ const first = entries.map((e, i) => ({ e, i })).sort((a, b) => b.e.n - a.e.n || a.i - b.i).slice(0, 12).map(({ e }) => e);
++ const open = entries.filter((e) => e.q && e.n === 0 && !first.includes(e)).slice(0, 12);
++ const kinds = catalog.grouped().map((g) => ({ key: siteKey(g.site), label: quests.shortSite(g.site), count: g.quests.length + (g.series || []).reduce((n, x) => n + x.quests.length, 0) }));
++ const events = [...followed()].reduce((n, [, list]) => n + list.length, 0);
++ const fold = (o) => raw(kit.fold({ ...o, body: String(o.body) }));
page(req, res, {
index: true, cache: signedIn(req) ? null : PUBLIC_CACHE,
title: 'Earn badges for what you do on OpenVibe',
@@@ -159,46 +163,36 @@ function createPageRoutes(ctx) {
], { url: `${site}/` }),
seo.jsonLd.faq(FAQ),
],
-- styles: [showcase.STYLESHEET],
++ // The kit's controls (numbers, filters, folds) and its cards and rails; no kit-web.js: the cards carry no
++ // menu, and from 760 px the rails wrap into a grid instead of scrolling (public/css/app.css).
++ styles: KIT_STYLES,
body: html`${raw(hero)}
--${yourStuff(req)}
${statsStrip(p)}
--${topBadges(p)}
--${raw(showcase.features({
-- title: 'What Quest is',
-- lede: `One quest log across ${siteCount} OpenVibe sites, built on what the sites already tell the network.`,
-- items: [
-- { icon: 'ov:page', title: 'One log, many sites', text: 'A stream on OpenVibe.Live, a thread on OpenVibe.Community, a message in OpenVibe.Chat: the same log, grouped by the site the event came from.' },
-- { icon: 'ov:account', title: 'Rewards are badges', text: coinsUi.coinsEnabled ? 'A quest pays a badge that shows on your profile across the network, and OpenCoins into your OpenVibe.Network wallet.' : 'A quest pays a badge that shows on your profile across the network and, once they are switched on, OpenCoins.' },
-- { icon: 'ov:tools', title: 'OpenCoins, not Vibes', text: 'OpenCoins are loyalty points credited through OpenVibe.Network\'s wallet. They are never money, they are not Vibes, and Vibes are never touched here.' },
-- { icon: 'ov:games', title: 'Made by apps', text: 'A developer app defines quests on its own events with an app token (the SDK\'s quests): they pay a badge named for the app, never OpenCoins.' },
-- ],
-- }))}
--<section class="sc-sec" id="the-quests" aria-labelledby="the-quests-h"><h2 id="the-quests-h">The quests</h2>
--<p class="sc-lede">Every quest below counts something a person really did on an OpenVibe site, grouped by the site whose events it counts.</p>
--${quests.list(catalog.grouped({ fold: true }), { ...coinsUi, counts: p.byQuest })}
--${quests.coinsNote(coinsUi)}</section>
--${firstToFinish(p)}
--${appList.length ? html`<section class="sc-sec" id="app-quests" aria-labelledby="app-quests-h"><h2 id="app-quests-h">Made by apps</h2>
--<p class="sc-lede">Quests that developer apps on OpenVibe define on their own events. Each pays a badge named for the app, never OpenCoins.</p>
--<ul class="quest-list">${appList.map((q) => html`<li class="quest-card"><h3><a href="/apps/quests/${q.id}">${q.title}</a></h3>${q.why ? html`<p>${q.why}</p>` : ''}<p class="muted">Badge: ${q.reward.badge.name}${q.url ? html` · <a href="${q.url}" rel="nofollow noopener">Where to do it</a>` : ''}</p></li>`)}</ul></section>` : ''}
--${raw(showcase.steps({
-- id: 'how-progress-works',
-- title: 'How quests work across sites',
-- lede: 'Quest does not watch you and runs no activities of its own. It counts what the sites already report.',
-- items: [
-- { title: 'You do something on a site', text: 'Go live on OpenVibe.Live, reply in a forum thread, publish a creator world on OpenVibe.Games.' },
-- { title: 'The site reports it', text: 'The site sends the event it already emits to OpenVibe.Events, with the person named by the event\'s own schema.' },
-- { title: 'Quest counts it', text: 'One count per real action. When every step of a quest reaches its count, the quest is complete, once.' },
-- { title: 'You get the reward', text: 'The badge goes on your profile, an item goes to your inventory where the quest gives one, and OpenCoins go to your wallet.' },
-- ],
-- }))}
--<section class="sc-sec" aria-labelledby="sites-followed-h"><h2 id="sites-followed-h">The sites Quest follows</h2>
--<p class="sc-lede">These events are the whole of what moves a quest. Nothing else is tracked: no page views, no clicks, no watch time and no content you wrote.</p>
--${sitesFollowed()}
--<p class="sc-note">What each event means is on <a href="/how-it-works">How it works</a>, with what is stored and what is public.</p></section>
--<section class="sc-sec" id="questions" aria-labelledby="questions-h"><h2 id="questions-h">Questions</h2>
--<div class="q-faq">${FAQ.map((f) => html`<details><summary>${f.q}</summary><p>${f.a}</p></details>`)}</div></section>
++${yourStuff(req)}
++<section class="sc-sec q-rails">
++${raw(kit.rail({ id: 'the-quests', title: first.some((e) => e.n > 0) ? 'Badges people earn most' : 'Quests to start with', more: { href: '/quests', label: `See all ${total}` }, items: first.map(entryCard) }))}
++<p class="q-kinds-h" aria-hidden="true">Quests by site</p>
++${raw(kit.tabs({ base: '/quests', param: 'site', label: 'Quests by site', items: kinds }))}
++${raw(kit.rail({ id: 'be-first', title: 'Nobody has these badges yet', items: open.map(entryCard) }))}
++${raw(kit.rail({ id: 'app-quests', title: 'Made by apps', more: { href: '/quests#app-quests', label: 'See all' }, items: appList.map(quests.appKitCard) }))}
++</section>
++<section class="sc-sec q-folds" id="how-it-works" aria-labelledby="how-it-works-h"><h2 id="how-it-works-h">How it works</h2>
++<p class="sc-lede">Quest runs no activities of its own: it counts what the sites already report, one count per real action. Nothing else is tracked: no page views, no clicks, no watch time and no content you wrote.</p>
++${fold({ id: 'how-progress-works', title: 'From what you do to a badge', meta: '4 steps', body: html`<ol class="q-steps">
++<li><strong>You do something on a site.</strong> Go live on OpenVibe.Live, reply in a forum thread, publish a creator world on OpenVibe.Games.</li>
++<li><strong>The site reports it.</strong> The site sends the event it already emits to OpenVibe.Events, with the person named by the event's own schema.</li>
++<li><strong>Quest counts it.</strong> One count per real action. When every step of a quest reaches its count, the quest is complete, once.</li>
++<li><strong>You get the reward.</strong> The badge goes on your profile, an item goes to your inventory where the quest gives one, and OpenCoins go to your wallet.</li>
++</ol>` })}
++${fold({ id: 'rewards', title: 'Badges, items and OpenCoins', body: html`<p>One log, many sites: a stream on OpenVibe.Live, a thread on OpenVibe.Community, a message in OpenVibe.Chat, grouped by the site the event came from.</p>
++<p>${coinsUi.coinsEnabled ? 'A quest pays a badge that shows on your profile across the network, and OpenCoins into your OpenVibe.Network wallet.' : 'A quest pays a badge that shows on your profile across the network and, once they are switched on, OpenCoins.'} Some quests also give an item for your OpenVibe inventory, worn in chat and on your profile.</p>
++<p>OpenCoins are loyalty points credited through OpenVibe.Network's wallet. They are never money, they are not Vibes, and Vibes are never touched here. A quest a developer app defines on its own events pays a badge named for the app, never OpenCoins.</p>
++${quests.coinsNote(coinsUi)}` })}
++${fold({ id: 'sites-followed', title: 'The sites Quest follows', meta: `${followed().size} sites, ${events} events`, body: html`${sitesFollowed()}
++<p class="sc-note">What each event means is on <a href="/how-it-works">How it works</a>, with what is stored and what is public.</p>` })}
++</section>
++<section class="sc-sec q-folds" id="questions" aria-labelledby="questions-h"><h2 id="questions-h">Questions</h2>
++${FAQ.map((f) => fold({ title: f.q, body: html`<p>${f.a}</p>` }))}</section>
${raw(showcase.network({ service: 'quest' }))}
${raw(showcase.cta({
title: signedIn(req) ? 'Your quest log is waiting' : 'Start your quest log',
@@@ -208,12 +202,51 @@ ${raw(showcase.cta({
});
});
++ // ── Every quest: the index the home's See all opens, one site at a time (?site=), as the kit's tabs ──
++ r.get('/quests', async (req, res) => {
++ const groups = catalog.grouped({ fold: true });
++ const keys = ['all', ...groups.map((g) => siteKey(g.site))];
++ const active = kit.tabOf(req.query, keys, 'site');
++ const shown = active === 'all' ? groups : groups.filter((g) => siteKey(g.site) === active);
++ const [appList, p] = await Promise.all([active === 'all' && ctx.appQuests ? ctx.appQuests.listed(100) : [], pulse()]);
++ const count = (g) => g.quests.length + (g.series || []).reduce((n, x) => n + x.quests.length, 0);
++ const total = catalog.all().length;
++ const one = active === 'all' ? null : shown[0];
++ page(req, res, {
++ index: true, cache: signedIn(req) ? null : PUBLIC_CACHE,
++ title: one ? `Quests on ${one.name}` : 'All quests',
++ description: one
++ ? `The ${count(one)} quest${count(one) === 1 ? '' : 's'} on ${one.name}: what each one counts and the badge it pays.`
++ : `All ${total} OpenVibe quests on ${siteCount} sites, grouped by site: what each one counts, the badge it pays and how many people hold it.`,
++ crumbs: [{ label: 'Quests', href: '/' }, { label: one ? one.name : 'All quests' }],
++ styles: ['kit.css'],
++ body: html`<h1>${one ? html`Quests on ${one.name}` : 'All quests'}</h1>
++<p class="lede">${one ? html`${count(one)} quest${count(one) === 1 ? '' : 's'} on <a href="https://${one.site}">${one.name}</a>${one.what ? html`, ${one.what}` : ''}.` : html`${total} quests on ${siteCount} OpenVibe sites, grouped by the site whose events each one counts.`} Each pays a badge; how many people hold it is a count, never who.</p>
++${raw(kit.tabs({ base: '/quests', param: 'site', label: 'Quests by site', active, items: [{ key: 'all', label: 'All', count: total, href: '/quests' }, ...groups.map((g) => ({ key: siteKey(g.site), label: quests.shortSite(g.site), count: count(g) }))] }))}
++${quests.list(shown, { ...coinsUi, counts: p.byQuest })}
++${appList.length ? html`<section class="site-group" id="app-quests" aria-labelledby="app-quests-h"><h2 id="app-quests-h">Made by apps</h2>
++<p class="muted">Quests that developer apps on OpenVibe define on their own events. Each pays a badge named for the app, never OpenCoins.</p>
++<ul class="quest-list">${appList.map((q) => quests.appCard(q))}</ul></section>` : ''}
++${quests.coinsNote(coinsUi)}
++<p><a href="/">Quests home</a> · <a href="/how-it-works">How it works</a></p>`,
++ });
++ });
++
++ // ── A badge's picture (render/badge-art.js): the home's cards show the badge each quest pays ──
++ const sendSvg = (req, res, body) => res.status(200).type('image/svg+xml').set('Cache-Control', cache.assetHeaders(req.path, { maxAge: 86400 })).send(body);
++ r.get('/quests/:id/badge.svg', (req, res, next) => { const q = catalog.byId(req.params.id); return q ? sendSvg(req, res, quests.badgeSvg(q)) : next(); });
++ r.get('/series/:id/badge.svg', (req, res, next) => { const x = catalog.seriesById(req.params.id); return x ? sendSvg(req, res, quests.seriesSvg(x)) : next(); });
++ r.get('/apps/quests/:id/badge.svg', async (req, res, next) => {
++ const q = ctx.appQuests ? await ctx.appQuests.byId(req.params.id) : null;
++ return q && q.env === 'production' && q.status === 'active' ? sendSvg(req, res, quests.badgeSvg(q)) : next();
++ });
++
// ── An app quest (server/quests/app-quests.js) ──────────
r.get('/apps/quests/:id', async (req, res) => {
const quest = ctx.appQuests ? await ctx.appQuests.byId(req.params.id) : null;
if (!quest || quest.env !== 'production') return page(req, res, {
title: 'No such quest',
-- body: html`<h1>No such quest</h1><p>There is no app quest at this address. <a href="/#app-quests">Quests made by apps</a> lists them.</p>`,
++ body: html`<h1>No such quest</h1><p>There is no app quest at this address. <a href="/quests#app-quests">Quests made by apps</a> lists them.</p>`,
}, 404);
const mine = signedIn(req) && SUBJECT_RE.test(req.viewer.subject);
const [rows, completion, holders] = await Promise.all([
@@@ -225,7 +258,7 @@ ${raw(showcase.cta({
return page(req, res, {
index: quest.status === 'active', cache: mine ? null : PUBLIC_CACHE,
title: quest.title, description: quest.why || `${quest.title}: a quest an OpenVibe app made.`,
-- crumbs: [{ label: 'Quests', href: '/' }, { label: 'Made by apps', href: '/#app-quests' }, { label: quest.title }],
++ crumbs: [{ label: 'Quests', href: '/' }, { label: 'Made by apps', href: '/quests#app-quests' }, { label: quest.title }],
body: html`<h1>${quest.title}</h1>
${quest.why ? html`<p class="lede">${quest.why}</p>` : ''}
<p class="muted">Made by an OpenVibe developer app (project <code>${quest.project_id}</code>)${quest.url ? html` · <a href="${quest.url}" rel="nofollow noopener">Where to do it</a>` : ''}.${quest.status === 'retired' ? ' Retired: it no longer counts.' : ''}</p>
@@@ -235,7 +268,7 @@ ${quest.why ? html`<p class="lede">${quest.why}</p>` : ''}
<p>The badge <strong>${quest.reward.badge.name}</strong>. App quests never pay OpenCoins.</p>
<p class="muted">${holders ? html`${holders} ${holders === 1 ? 'person has' : 'people have'} this badge.` : 'A badge is awarded once and kept.'}</p>
${completion ? html`<div class="notice" role="status">You completed this quest on <strong>${String(completion.completed_at).slice(0, 10)}</strong>.</div>` : ''}
--<p><a href="/">All quests</a></p>`,
++<p><a href="/quests">All quests</a></p>`,
});
});
@@@ -244,7 +277,7 @@ ${completion ? html`<div class="notice" role="status">You completed this quest o
const quest = catalog.byId(req.params.id);
if (!quest) return page(req, res, {
title: 'No such quest',
-- body: html`<h1>No such quest</h1><p>There is no quest at this address. <a href="/">The quest list</a> has all ${catalog.all().length} of them.</p>`,
++ body: html`<h1>No such quest</h1><p>There is no quest at this address. <a href="/quests">The quest list</a> has all ${catalog.all().length} of them.</p>`,
}, 404);
const viewer = req.viewer;
@@@ -273,7 +306,7 @@ ${completion ? html`<div class="notice" role="status">You completed this quest o
<p class="muted">${state && state.holders ? html`${state.holders} ${state.holders === 1 ? 'person has' : 'people have'} this badge.` : html`A badge is awarded once and kept.`}</p>
${state && state.completion ? html`<div class="notice" role="status">You completed this quest on <strong>${String(state.completion.completed_at).slice(0, 10)}</strong>${coinsUi.coinsEnabled && state.completion.coins_state === 'credited' ? html`, and its OpenCoins are in your wallet` : ''}.</div>` : html`<p><a href="/me">Your quest log</a> keeps your progress.</p>`}
${quests.coinsNote(coinsUi)}
--<p><a href="/">All quests</a></p>`,
++<p><a href="/quests">All quests</a></p>`,
}));
});
@@@ -282,7 +315,7 @@ ${quests.coinsNote(coinsUi)}
const series = catalog.seriesById(req.params.id);
if (!series) return page(req, res, {
title: 'No such series',
-- body: html`<h1>No such series</h1><p>There is no quest series at this address. <a href="/">The quest list</a> has every quest.</p>`,
++ body: html`<h1>No such series</h1><p>There is no quest series at this address. <a href="/quests">The quest list</a> has every quest.</p>`,
}, 404);
const mine = signedIn(req) && SUBJECT_RE.test(req.viewer.subject);
const done = new Set(mine ? (await store.completionsFor(s.db, req.viewer.subject)).map((c) => c.quest_id) : []);
@@@ -298,7 +331,7 @@ ${quests.coinsNote(coinsUi)}
${mine ? quests.progress('Your progress', finished, series.quests.length) : ''}
<ul class="quest-list">${series.quests.map((q) => quests.card(q, { ...coinsUi, earned: done.has(q.id) }))}</ul>
${quests.coinsNote(coinsUi)}
--<p><a href="/">All quests</a>${mine ? html` · <a href="/me">Your quest log</a>` : ''}</p>`,
++<p><a href="/quests">All quests</a>${mine ? html` · <a href="/me">Your quest log</a>` : ''}</p>`,
});
});
@@@ -322,7 +355,7 @@ ${quests.coinsNote(coinsUi)}
each quest still needs, and the badges you have earned.</p>
<p><a href="/auth/login?next=%2Fme">Sign in with OpenVibe</a></p>
<p class="muted">Your progress is read from this site's database only, and only for you. Nobody else's log is shown here, and yours is not shown on anyone else's.</p>
--<p><a href="/how-it-works">How progress works</a> · <a href="/">The quests</a></p>`,
++<p><a href="/how-it-works">How progress works</a> · <a href="/quests">The quests</a></p>`,
});
const subject = req.viewer.subject;
@@@ -353,7 +386,7 @@ ${yourStuff(req)}
<h2>In progress</h2>
${started.length ? started.map((q) => html`<section class="quest-block"><h3><a href="/quests/${q.id}">${q.title}</a> <small>on ${siteName(q.site)}</small></h3>
${q.steps.map((step, i) => quests.progress(step.label, (seen.get(q.id) || new Map()).get(i) || 0, step.count))}
--<p class="quest-reward">${quests.reward(q, coinsUi)}</p></section>`) : html`<p class="muted">Nothing started yet. <a href="/">Pick a quest</a> — any of the sites you already use counts.</p>`}
++<p class="quest-reward">${quests.reward(q, coinsUi)}</p></section>`) : html`<p class="muted">Nothing started yet. <a href="/quests">Pick a quest</a> — any of the sites you already use counts.</p>`}
<h2>Completed</h2>
${finished.length ? html`<ul class="quest-list">${finished.map((q) => {
const c = done.get(q.id);
@@@ -424,7 +457,7 @@ them (ADR-033).</li>
<li>When two of your OpenVibe accounts are merged, everything here moves to the account you keep: each step keeps the
higher count, a quest both finished counts once, and each badge keeps its first award (ADR-029).</li>
</ul>
--<p><a href="/">The quests</a> · <a href="/updates">What shipped</a></p>`,
++<p><a href="/quests">The quests</a> · <a href="/updates">What shipped</a></p>`,
}));
// ── The update log ───────────────────────────────────────
ddiff --git a/server/render/badge-art.js b/server/render/badge-art.js
nnew file mode 100644
iindex 0000000..53b71cf
---- /dev/null
++++ b/server/render/badge-art.js
@@@ -0,0 +1,40 @@
++'use strict';
++
++/**
++ * A badge's picture: its medal (the initials on a ring of its colour, the same as render/quests.js medal()) on a wash of
++ * that colour, with the badge's name under it. A 16:9 SVG, so a quest is a picture in the shared kit's cards
++ * (openvibe-shared kit.card) like a stream or a clip is. Served at /quests/:id/badge.svg, /series/:id/badge.svg and
++ * /apps/quests/:id/badge.svg (http/pages.js); the text is the catalog's or an app's, escaped here.
++ */
++const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
++
++/** Up to two letters: the first of the first two words, or the first two of a single word ("Toolsmith" → "TO"). */
++function initials(name) {
++ const words = String(name || '').split(/\s+/).filter(Boolean);
++ const s = words.length > 1 ? [...words[0]][0] + [...words[1]][0] : [...(words[0] || '?')].slice(0, 2).join('');
++ return s.toUpperCase();
++}
++
++/**
++ * svg({ hue, mark, name }): mark is the medal's face (the initials, or a series' count), name the line under it (cut to
++ * 26 characters). The lowest 40 units stay clear for the card's corner (an item, a price). Decorative in a card, whose
++ * alt text names the badge.
++ */
++function svg({ hue = 210, mark = '?', name = '' } = {}) {
++ const h = Math.abs(Math.round(Number(hue) || 0)) % 360;
++ const line = [...String(name)].length > 26 ? `${[...String(name)].slice(0, 25).join('').trimEnd()}…` : String(name);
++ const face = [...String(mark)].slice(0, 3).join('');
++ const font = 'system-ui,-apple-system,Segoe UI,Roboto,sans-serif';
++ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 180" width="320" height="180">`
++ + `<defs><radialGradient id="w" cx="50%" cy="40%" r="80%"><stop offset="0" stop-color="hsl(${h},48%,26%)"/><stop offset="1" stop-color="hsl(${h},42%,9%)"/></radialGradient>`
++ + `<radialGradient id="m" cx="35%" cy="30%" r="80%"><stop offset="0" stop-color="hsl(${h},55%,38%)"/><stop offset=".75" stop-color="hsl(${h},50%,17%)"/></radialGradient></defs>`
++ + `<rect width="320" height="180" fill="url(#w)"/>`
++ + `<g fill="none" stroke="hsl(${h},70%,72%)" stroke-opacity=".16"><circle cx="160" cy="62" r="58"/><circle cx="160" cy="62" r="82"/><circle cx="160" cy="62" r="108"/></g>`
++ + `<path d="M141 88 133 116 145 110 151 121 158 93Z M179 88 187 116 175 110 169 121 162 93Z" fill="hsl(${h},55%,34%)"/>`
++ + `<circle cx="160" cy="62" r="38" fill="url(#m)" stroke="hsl(${h},62%,58%)" stroke-width="5"/>`
++ + `<text x="160" y="62" dy=".35em" text-anchor="middle" font-family="${font}" font-weight="800" font-size="${face.length > 2 ? 21 : 26}" letter-spacing="1" fill="hsl(${h},85%,85%)">${esc(face)}</text>`
++ + (line ? `<text x="160" y="137" text-anchor="middle" font-family="${font}" font-weight="700" font-size="15" fill="hsl(${h},60%,88%)">${esc(line)}</text>` : '')
++ + '</svg>';
++}
++
++module.exports = { svg, initials };
ddiff --git a/server/render/quests.js b/server/render/quests.js
iindex b0f9928..790d4de 100644
---- a/server/render/quests.js
++++ b/server/render/quests.js
@@@ -5,7 +5,9 @@
* site, a progress bar and the OpenCoins note. Plain HTML, no JavaScript, every value through the html`` template
* (escaping); the catalog is code, but nothing here assumes its text is harmless.
*/
++const kit = require('openvibe-shared/kit');
const catalog = require('../quests/catalog');
++const art = require('./badge-art');
const { html } = require('./html');
/**
@@@ -32,11 +34,49 @@ function hueOf(id) {
/** The badge as a medal: its initials on a ring of its colour (decorative; the name is always written beside it). */
function medal(quest, { earned = false } = {}) {
-- const words = String(quest.reward.badge.name || quest.title).split(/\s+/).filter(Boolean);
-- const initials = (words.length > 1 ? words[0][0] + words[1][0] : (words[0] || '?').slice(0, 2)).toUpperCase();
-- return html`<span class="medal${earned ? ' earned' : ''}" style="--h:${hueOf(quest.id)}" aria-hidden="true">${initials}</span>`;
++ return html`<span class="medal${earned ? ' earned' : ''}" style="--h:${hueOf(quest.id)}" aria-hidden="true">${art.initials(quest.reward.badge.name || quest.title)}</span>`;
}
++// ── The home's cards: a quest, a series or an app's quest as the shared kit's card (openvibe-shared kit.card), its
++// picture the badge it pays (render/badge-art.js). The card is one link; nothing in it needs kit-web.js (no ⋯ menu).
++const shortSite = (domain) => String((catalog.SITES[domain] || {}).name || domain).replace(/^OpenVibe\./, '');
++
++/** A catalog quest: the site it happens on, its item or OpenCoins in the corner, how many hold its badge. */
++function kitCard(quest, { coinsEnabled = false, earned = 0 } = {}) {
++ const it = quest.reward.item;
++ return kit.card({
++ href: `/quests/${quest.id}`, title: quest.title, ratio: '16/9', share: false,
++ image: `/quests/${encodeURIComponent(quest.id)}/badge.svg`, alt: `The ${quest.reward.badge.name} badge`,
++ badge: shortSite(quest.site),
++ corner: it ? `+ ${it.name}` : coinsEnabled && quest.reward.coins > 0 ? `${quest.reward.coins} OpenCoins` : '',
++ peek: quest.why,
++ count: earned > 0 ? [earned, 'earned'] : null,
++ });
++}
++
++/** A series: one card for all its quests, opening the series' page. */
++function seriesKitCard(series, { earned = 0 } = {}) {
++ return kit.card({
++ href: `/series/${series.id}`, title: series.title, ratio: '16/9', share: false,
++ image: `/series/${encodeURIComponent(series.id)}/badge.svg`, alt: `${series.quests.length} badges, one for each quest`,
++ badge: shortSite(series.site), peek: series.why,
++ count: earned > 0 ? [earned, earned === 1 ? 'badge earned' : 'badges earned'] : null,
++ });
++}
++
++/** A quest a developer app made (server/quests/app-quests.js): badges only, never OpenCoins. */
++function appKitCard(q) {
++ return kit.card({
++ href: `/apps/quests/${q.id}`, title: q.title, ratio: '16/9', share: false,
++ image: `/apps/quests/${encodeURIComponent(q.id)}/badge.svg`, alt: `The ${q.reward.badge.name} badge`,
++ badge: 'App', peek: q.why || '',
++ });
++}
++
++/** The SVG for a badge card (render/badge-art.js): a quest's, a series' (its count on the medal) or an app quest's. */
++const badgeSvg = (quest) => art.svg({ hue: hueOf(quest.id), mark: art.initials(quest.reward.badge.name || quest.title), name: quest.reward.badge.name });
++const seriesSvg = (series) => art.svg({ hue: hueOf(series.id), mark: String(series.quests.length), name: `${series.quests.length} quests, a badge each` });
++
/** "Earned by 3 people": how many have a quest's badge (a count, never who), or nothing at 0. */
function earnedBy(n) {
return n > 0 ? html`<p class="quest-count">Earned by ${n} ${n === 1 ? 'person' : 'people'}</p>` : '';
@@@ -72,6 +112,19 @@ function seriesCard(series, { coinsEnabled = false, counts = null } = {}) {
</li>`;
}
++/** A quest a developer app made, in a list (the full index, /quests): its badge, what it is, and where to do it. */
++function appCard(q) {
++ return html`<li class="quest">
++ ${medal(q)}
++ <div class="quest-main">
++ <a class="quest-title" href="/apps/quests/${q.id}">${q.title}</a>
++ ${q.why ? html`<p class="quest-why">${q.why}</p>` : ''}
++ <p class="quest-reward"><span class="reward"><span class="reward-badge">Badge: <strong>${q.reward.badge.name}</strong></span></span></p>
++ ${q.url ? html`<p class="quest-count"><a href="${q.url}" rel="nofollow noopener">Where to do it</a></p>` : ''}
++ </div>
++</li>`;
++}
++
/** The catalog grouped by the site whose events each quest counts (a folded catalog shows each series as one card). */
function list(groups = catalog.grouped(), { coinsEnabled = false, counts = null } = {}) {
// The heading id is the domain with its dots replaced: an id with a dot is legal but awkward to target.
@@@ -102,4 +155,4 @@ ${dailyCap} OpenCoins per person per day and ${globalDailyCap} for everyone toge
never money — and never Vibes.</p>`;
}
--module.exports = { medal, hueOf, reward, card, seriesCard, list, progress, coinsNote };
++module.exports = { medal, hueOf, reward, card, seriesCard, appCard, list, progress, coinsNote, kitCard, seriesKitCard, appKitCard, badgeSvg, seriesSvg, shortSite };
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex abd2015..b930cd0 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -21,14 +21,14 @@ const BUDGETS = {
// Raised 2026-10-22 with the rich home: figures under the hero, the most-earned badges, each card's "earned by",
// the badges nobody holds yet, the sites Quest follows, a FAQ and the quests as ItemList + FAQPage JSON-LD.
// Measured 54.3 KB raw / 10.6 KB brotli on a fresh database.
-- htmlRawKB: 69, // 67.5 the hero's and the numbers' icons (inline ring icons, 2026-10-28); 61.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; measured 54.3 (the home page: hero, figures, badges, features, the quests, sites, FAQ, cta, JSON-LD)
++ htmlRawKB: 65, // 58.6 the home a taste and a door (2026-10-29): rails of kit cards and folds instead of every quest card and the sites list; 67.5 the hero's and the numbers' icons (inline ring icons, 2026-10-28); 61.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; measured 54.3 (the home page: hero, figures, badges, features, the quests, sites, FAQ, cta, JSON-LD)
htmlBrotliKB: 13, // 11.8 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 10.6
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)
jsRawKB: 305, // 300.5 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; 293 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 287.2 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 273.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 264.3 with Shared 4.13.0 (2026-10-26: account links); 239.8
jsBrotliKB: 76, // 74.2 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; 70.5 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 67.9 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 65.3 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 56.5
-- cssFiles: 2, // 2 (app.css + the cached /shared/showcase.css)
-- cssRawKB: 39, // 34.4 openvibe-shared 4.26.1 (hero visual); 28.9 with Shared 4.13.0 (2026-10-26: account links); 22.6 with the quest medals and cards, and the home's figures, badges, sites and FAQ (2026-10-22)
-- cssBrotliKB: 8, // 6.2 with Shared 4.13.0 (2026-10-26: account links); 4.6
++ cssFiles: 4, // 4 (2026-10-29: + /shared/kit.css and /shared/room.css, the kit's numbers, filters, folds, cards and rails); 2 (app.css + the cached /shared/showcase.css)
++ cssRawKB: 82, // 74.6 the home on the shared kit (2026-10-29, openvibe-shared 4.37.0): kit.css 25.3 KB (numbers strip, site filters, folds) and room.css 15.7 KB (the quests as rails of cards), app.css down 1.5 KB; the page shed 13 of its 17 phone screens; 34.4 openvibe-shared 4.26.1 (hero visual); 28.9 with Shared 4.13.0 (2026-10-26: account links); 22.6 with the quest medals and cards, and the home's figures, badges, sites and FAQ (2026-10-22)
++ cssBrotliKB: 18, // 16.5 kit.css and room.css on the home (2026-10-29, see cssRawKB); 6.2 with Shared 4.13.0 (2026-10-26: account links); 4.6
externalFiles: 0, // 0
};
ddiff --git a/test/quests-api.test.js b/test/quests-api.test.js
iindex b9aa830..be4fac7 100644
---- a/test/quests-api.test.js
++++ b/test/quests-api.test.js
@@@ -114,7 +114,7 @@ const { render } = require('../server/render/html');
});
await check('every page is server-rendered, complete without JavaScript, and answers 200', async () => {
-- for (const p of ['/', '/how-it-works', '/quests/go-live', '/updates']) {
++ for (const p of ['/', '/quests', '/quests?site=chat', '/how-it-works', '/quests/go-live', '/updates']) {
const r = await t.get(p);
assert.strictEqual(r.status, 200, p);
assert.match(r.headers.get('content-type'), /^text\/html/, p);
@@@ -127,17 +127,46 @@ const { render } = require('../server/render/html');
assert.ok(quest.text.includes('First Broadcast'), 'its badge');
assert.ok(quest.text.includes('OpenCoins rewards start soon'), 'coins are honestly off');
const home = await t.get('/');
-- assert.ok(home.text.includes('OpenVibe.Live'), 'the quests are grouped by site');
-- assert.ok(home.text.includes('Say hello in chat'), 'a chat quest is listed');
-- assert.ok(home.text.includes('chat.room.message.created'), 'the events are listed');
++ // The home is a taste and a door: the quests as rails of the shared kit's cards (each with its badge's
++ // picture), See all → the index, the sites as quick filters, and the parts read once as folds.
++ assert.ok(home.text.includes('class="ovk-rail') && home.text.includes('class="ovk-card'), 'the quests as a rail of cards');
++ assert.ok(home.text.includes(`href="/quests">See all ${catalog.all().length}`), 'See all opens the index');
++ assert.ok(home.text.includes('src="/quests/go-live/badge.svg"'), 'a card shows the badge it pays');
++ assert.ok(home.text.includes('href="/quests?site=chat"') && home.text.includes('href="/quests?site=live"'), 'the sites as quick filters');
++ assert.ok(/\/shared\/kit\.css/.test(home.text) && /\/shared\/room\.css/.test(home.text) && !/\/shared\/kit-web\.js/.test(home.text), 'the kit\'s look (kit.css, room.css), no kit-web.js');
++ assert.ok(!home.text.includes('class="quest-steps"'), 'no full quest cards on the home: they are the index\'s');
++ for (const id of ['how-progress-works', 'rewards', 'sites-followed']) assert.ok(home.text.includes(`<details class="ovk-fold" id="${id}">`), `${id} is a fold`);
++ assert.ok(home.text.includes('OpenVibe.Live') && home.text.includes('chat.room.message.created'), 'the sites Quest follows and their events, in a fold');
// The live parts are counts, never people: kim's completion shows as a number and nothing about her.
assert.ok(home.text.includes('Badges people earn most'), 'the most-earned badges');
-- assert.ok(home.text.includes('Earned by 1 person'), 'a quest card says how many hold its badge');
-- assert.ok(/<dt>quest finished<\/dt><dd><span class="sc-stat-ic">[\s\S]*?<\/span>1<\/dd>/.test(home.text), 'the figures under the hero, each with its icon');
++ assert.ok(/<dt>quest finished<\/dt><dd>1<\/dd>/.test(home.text) && home.text.includes('class="ovk-stats"'), 'the figures under the hero, as one strip');
++ assert.ok(home.text.includes('<span>1 earned</span>'), 'a card says how many hold its badge');
assert.ok(home.text.includes('Nobody has these badges yet'), 'the quests nobody has finished');
assert.ok(home.text.includes('"@type":"FAQPage"') && home.text.includes('Can other people see my progress?'), 'the FAQ and its JSON-LD');
assert.ok(home.text.includes('"@type":"ItemList"'), 'the quests as an ItemList');
assert.ok(!home.text.includes(kim.subject) && !home.text.includes(kim.username), 'nobody is named');
++ // The index: every quest outside a series and each series as one card, grouped by site, one site per tab.
++ const index = await t.get('/quests');
++ for (const q of catalog.all().filter((x) => !x.series)) assert.ok(index.text.includes(`href="/quests/${q.id}"`), `the index lists ${q.id}`);
++ assert.ok(index.text.includes('href="/series/live-setup"'), 'and the series');
++ assert.ok(index.text.includes('Say hello in chat') && index.text.includes('Earned by 1 person'), 'each card with how many hold its badge');
++ assert.ok(index.text.includes('aria-current="page"><span>All</span>'), 'the All tab is open');
++ assert.ok(!index.text.includes(kim.subject) && !index.text.includes(kim.username), 'nobody is named on the index');
++ const chat = await t.get('/quests?site=chat');
++ assert.ok(chat.text.includes('Say hello in chat') && !chat.text.includes('href="/quests/go-live"'), 'a site tab shows that site\'s quests only');
++ assert.ok(chat.text.includes('<link rel="canonical" href="https://openvibe.quest/quests">'), 'one canonical for every tab');
++ const twin = await t.get('/quests.md');
++ assert.strictEqual(twin.status, 200);
++ assert.match(twin.headers.get('content-type'), /^text\/markdown/);
++ assert.ok(twin.text.includes('(/quests/go-live)'), 'the index has a Markdown twin like the other public pages');
++ // A badge's picture: an SVG for a quest, a series and nothing else.
++ const svg = await t.get('/quests/go-live/badge.svg');
++ assert.strictEqual(svg.status, 200);
++ assert.match(svg.headers.get('content-type'), /^image\/svg\+xml/);
++ assert.match(svg.headers.get('cache-control') || '', /^public, max-age=86400/);
++ assert.ok(svg.text.startsWith('<svg') && svg.text.includes('>FB<') && svg.text.includes('First Broadcast'), 'the medal and the badge name');
++ assert.strictEqual((await t.get('/series/live-setup/badge.svg')).status, 200);
++ assert.strictEqual((await t.get('/quests/no-such-quest/badge.svg')).status, 404);
const how = await t.get('/how-it-works');
assert.ok(how.text.includes('never money'), 'the rules say what rewards are');
assert.ok(how.text.includes('Vibes'), 'and that they are never Vibes');
@@@ -168,6 +197,7 @@ const { render } = require('../server/render/html');
const sitemap = await t.get('/sitemap.xml');
for (const q of catalog.all()) assert.ok(sitemap.text.includes(`<loc>https://openvibe.quest/quests/${q.id}</loc>`), `sitemap misses ${q.id}`);
assert.ok(sitemap.text.includes('<loc>https://openvibe.quest/how-it-works</loc>'));
++ assert.ok(sitemap.text.includes('<loc>https://openvibe.quest/quests</loc>'), 'the index the home\'s See all opens');
const robots = await t.get('/robots.txt');
assert.ok(robots.text.includes('Disallow: /me'), 'the private log is not crawled');
const llms = await t.get('/llms.txt');
@@@ -186,6 +216,10 @@ const { render } = require('../server/render/html');
assert.ok(!cardHtml.includes('<script>') && !cardHtml.includes('<img') && !cardHtml.includes('<b>go</b>'), 'no raw markup from data');
assert.ok(cardHtml.includes('&lt;script&gt;alert(1)&lt;/script&gt;'), 'escaped, not stripped');
assert.ok(cardHtml.includes('&lt;i&gt;Badge&lt;/i&gt;'), 'the badge label is escaped too');
++ const kitHtml = renderQuests.kitCard(hostile, { coinsEnabled: true, earned: 2 });
++ assert.ok(!kitHtml.includes('<script>') && kitHtml.includes('&lt;script&gt;alert(1)&lt;/script&gt;'), 'the home\'s card escapes it');
++ const art = renderQuests.badgeSvg(hostile);
++ assert.ok(!art.includes('<i>') && art.includes('&lt;i&gt;Badge&lt;/i&gt;'), 'so does the badge\'s picture');
const page = await t.get('/quests/%3Cscript%3Ealert(1)%3C%2Fscript%3E');
assert.strictEqual(page.status, 404);