openvibers/openvibe.quest Commits
Quest: the home is a taste and a door (17 phone screens to 4) (#127)
Changes
@@@ -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; }@@@ -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) {@@@ -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') },@@@ -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 oconst 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 thehigher 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 ───────────────────────────────────────nnew file mode 100644@@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');++++/** 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 };@@@ -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 togenever 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 };@@@ -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.6jsFiles: 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.8jsBrotliKB: 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.6externalFiles: 0, // 0};@@@ -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('<script>alert(1)</script>'), 'escaped, not stripped');assert.ok(cardHtml.includes('<i>Badge</i>'), 'the badge label is escaped too');++ const kitHtml = renderQuests.kitCard(hostile, { coinsEnabled: true, earned: 2 });++ assert.ok(!kitHtml.includes('<script>') && kitHtml.includes('<script>alert(1)</script>'), 'the home\'s card escapes it');++ const art = renderQuests.badgeSvg(hostile);++ assert.ok(!art.includes('<i>') && art.includes('<i>Badge</i>'), 'so does the badge\'s picture');const page = await t.get('/quests/%3Cscript%3Ealert(1)%3C%2Fscript%3E');assert.strictEqual(page.status, 404);
@@ -90,35 +90,34 @@ p.lede { font-size: 1.05rem; color: var(--text-secondary, #96a7c2); max-width: 7
@@ -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 li { border: 1px solid var(--border-light, #2c3d5c); border-radius: 999px; padding: .2rem .8rem; }
.badges small { color: var(--text-muted, #7386a3); }
.badges small { color: var(--text-muted, #7386a3); }
.quest-block { margin: .8rem 0; }
.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)); }
.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 a { font-weight: 700; }
.q-sites small { color: var(--text-muted, #7386a3); }
.q-sites small { color: var(--text-muted, #7386a3); }
.q-sites span { display: flex; flex-wrap: wrap; gap: 4px; }
.q-sites span { display: flex; flex-wrap: wrap; gap: 4px; }
.q-sites code { font-size: .78rem; overflow-wrap: anywhere; }
.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; }
@@ -5,7 +5,7 @@
@@ -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
// 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
// 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.
// 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. */
/** A request as an anonymous browser makes it: no session, no cookies. */
function anonymousRequest(path, query, host) {
function anonymousRequest(path, query, host) {
@@ -20,7 +20,8 @@ const DISALLOW = ['/auth/', '/api/', '/me'];
@@ -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.
// 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 = {
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.'],
'/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.'],
'/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}.`]])),
...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) {
@@ -46,6 +47,7 @@ function homeJsonLd(config) {
const publicPages = () => [
const publicPages = () => [
{ path: '/', changefreq: 'weekly', priority: 1.0 },
{ path: '/', changefreq: 'weekly', priority: 1.0 },
+ { path: '/quests', changefreq: 'weekly', priority: 0.8 },
{ path: '/how-it-works', changefreq: 'monthly', priority: 0.6 },
{ path: '/how-it-works', changefreq: 'monthly', priority: 0.6 },
...Object.values(catalog.SERIES).map((x) => ({ path: `/series/${x.id}`, changefreq: 'monthly', priority: 0.7 })),
...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 })),
...catalog.all().map((q) => ({ path: `/quests/${q.id}`, changefreq: 'monthly', priority: 0.7 })),
@@ -73,6 +75,7 @@ function createDiscoveryRoutes(ctx) {
@@ -73,6 +75,7 @@ function createDiscoveryRoutes(ctx) {
sections: [
sections: [
{ title: 'Start here', links: [
{ title: 'Start here', links: [
{ title: 'OpenVibe.Quest', url: abs('/'), note: 'Quests across the whole network.' },
{ 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: '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: 'The catalog (JSON)', url: abs('/api/v1/quests'), note: 'every quest, grouped by site' },
{ title: 'What shipped on OpenVibe.Quest', url: abs('/updates') },
{ title: 'What shipped on OpenVibe.Quest', url: abs('/updates') },
@@ -12,6 +12,9 @@
@@ -12,6 +12,9 @@
const ovServe = require('openvibe-shared/serve');
const ovServe = require('openvibe-shared/serve');
const frame = require('openvibe-shared/frame');
const frame = require('openvibe-shared/frame');
const showcase = require('openvibe-shared/showcase');
const showcase = require('openvibe-shared/showcase');
+const kit = require('openvibe-shared/kit');
+// 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
// 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.
// 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');
const HAS_PROFILE_PANEL = require('openvibe-shared/files').isBrowserFile('profile-panel.js');
@@ -47,7 +50,7 @@ function createPageRoutes(ctx) {
@@ -47,7 +50,7 @@ function createPageRoutes(ctx) {
const r = asyncRouter();
const r = asyncRouter();
const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 300 });
const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 300 });
// The pages with a Markdown twin (openvibe-shared/agent-layer): the public ones only.
// 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 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 signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
const coinsUi = { coinsEnabled: coins.enabled, dailyCap: config.coins.dailyCap, globalDailyCap: config.coins.globalDailyCap };
const coinsUi = { coinsEnabled: coins.enabled, dailyCap: config.coins.dailyCap, globalDailyCap: config.coins.globalDailyCap };
@@ -79,50 +82,39 @@ function createPageRoutes(ctx) {
@@ -79,50 +82,39 @@ function createPageRoutes(ctx) {
return value;
return value;
};
};
const siteCount = Object.keys(catalog.SITES).length;
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. */
/** Which sites Quest follows and the events each reports: the whole of what moves a quest. */
- const sitesFollowed = () => {
+ const followed = () => {
const bySite = new Map();
const bySite = new Map();
for (const e of catalog.EVENTS) {
for (const e of catalog.EVENTS) {
if (e.counted === false) continue;
if (e.counted === false) continue;
if (!bySite.has(e.site)) bySite.set(e.site, []);
if (!bySite.has(e.site)) bySite.set(e.site, []);
bySite.get(e.site).push(e.event_type);
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 = [
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.` },
{ 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) {
@@ -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.' },
{ 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) => {
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 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.
// The navbar already says OpenVibe.Quest: the hero says what you get, in plain words, and where to start.
const hero = showcase.hero({
const hero = showcase.hero({
eyebrow: { icon: 'ov:quest', text: 'Free, nothing to install' },
eyebrow: { icon: 'ov:quest', text: 'Free, nothing to install' },
@@ -143,10 +139,18 @@ function createPageRoutes(ctx) {
@@ -143,10 +139,18 @@ function createPageRoutes(ctx) {
accent: 'for what you already do on OpenVibe.',
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.`,
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)
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).',
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, {
page(req, res, {
index: true, cache: signedIn(req) ? null : PUBLIC_CACHE,
index: true, cache: signedIn(req) ? null : PUBLIC_CACHE,
title: 'Earn badges for what you do on OpenVibe',
title: 'Earn badges for what you do on OpenVibe',
@@ -159,46 +163,36 @@ function createPageRoutes(ctx) {
@@ -159,46 +163,36 @@ function createPageRoutes(ctx) {
], { url: `${site}/` }),
], { url: `${site}/` }),
seo.jsonLd.faq(FAQ),
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)}
body: html`${raw(hero)}
-${yourStuff(req)}
${statsStrip(p)}
${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.network({ service: 'quest' }))}
${raw(showcase.cta({
${raw(showcase.cta({
title: signedIn(req) ? 'Your quest log is waiting' : 'Start your quest log',
title: signedIn(req) ? 'Your quest log is waiting' : 'Start your quest log',
@@ -208,12 +202,51 @@ ${raw(showcase.cta({
@@ -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) ──────────
// ── An app quest (server/quests/app-quests.js) ──────────
r.get('/apps/quests/:id', async (req, res) => {
r.get('/apps/quests/:id', async (req, res) => {
const quest = ctx.appQuests ? await ctx.appQuests.byId(req.params.id) : null;
const quest = ctx.appQuests ? await ctx.appQuests.byId(req.params.id) : null;
if (!quest || quest.env !== 'production') return page(req, res, {
if (!quest || quest.env !== 'production') return page(req, res, {
title: 'No such quest',
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);
}, 404);
const mine = signedIn(req) && SUBJECT_RE.test(req.viewer.subject);
const mine = signedIn(req) && SUBJECT_RE.test(req.viewer.subject);
const [rows, completion, holders] = await Promise.all([
const [rows, completion, holders] = await Promise.all([
@@ -225,7 +258,7 @@ ${raw(showcase.cta({
@@ -225,7 +258,7 @@ ${raw(showcase.cta({
return page(req, res, {
return page(req, res, {
index: quest.status === 'active', cache: mine ? null : PUBLIC_CACHE,
index: quest.status === 'active', cache: mine ? null : PUBLIC_CACHE,
title: quest.title, description: quest.why || `${quest.title}: a quest an OpenVibe app made.`,
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>
body: html`<h1>${quest.title}</h1>
${quest.why ? html`<p class="lede">${quest.why}</p>` : ''}
${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>
<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>` : ''}
@@ -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>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>
<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>` : ''}
${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
@@ -244,7 +277,7 @@ ${completion ? html`<div class="notice" role="status">You completed this quest o
const quest = catalog.byId(req.params.id);
const quest = catalog.byId(req.params.id);
if (!quest) return page(req, res, {
if (!quest) return page(req, res, {
title: 'No such quest',
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);
}, 404);
const viewer = req.viewer;
const viewer = req.viewer;
@@ -273,7 +306,7 @@ ${completion ? html`<div class="notice" role="status">You completed this quest o
@@ -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>
<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>`}
${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)}
${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)}
@@ -282,7 +315,7 @@ ${quests.coinsNote(coinsUi)}
const series = catalog.seriesById(req.params.id);
const series = catalog.seriesById(req.params.id);
if (!series) return page(req, res, {
if (!series) return page(req, res, {
title: 'No such series',
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);
}, 404);
const mine = signedIn(req) && SUBJECT_RE.test(req.viewer.subject);
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) : []);
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)}
@@ -298,7 +331,7 @@ ${quests.coinsNote(coinsUi)}
${mine ? quests.progress('Your progress', finished, series.quests.length) : ''}
${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>
<ul class="quest-list">${series.quests.map((q) => quests.card(q, { ...coinsUi, earned: done.has(q.id) }))}</ul>
${quests.coinsNote(coinsUi)}
${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)}
@@ -322,7 +355,7 @@ ${quests.coinsNote(coinsUi)}
each quest still needs, and the badges you have earned.</p>
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><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 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;
const subject = req.viewer.subject;
@@ -353,7 +386,7 @@ ${yourStuff(req)}
@@ -353,7 +386,7 @@ ${yourStuff(req)}
<h2>In progress</h2>
<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>
${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))}
${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>
<h2>Completed</h2>
${finished.length ? html`<ul class="quest-list">${finished.map((q) => {
${finished.length ? html`<ul class="quest-list">${finished.map((q) => {
const c = done.get(q.id);
const c = done.get(q.id);
@@ -424,7 +457,7 @@ them (ADR-033).</li>
@@ -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
<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>
higher count, a quest both finished counts once, and each badge keeps its first award (ADR-029).</li>
</ul>
</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 ───────────────────────────────────────
// ── The update log ───────────────────────────────────────
new file mode 100644
new file mode 100644
@@ -0,0 +1,40 @@
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
+
+/** 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 };
@@ -5,7 +5,9 @@
@@ -5,7 +5,9 @@
* site, a progress bar and the OpenCoins note. Plain HTML, no JavaScript, every value through the html`` template
* 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.
* (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 catalog = require('../quests/catalog');
+const art = require('./badge-art');
const { html } = require('./html');
const { html } = require('./html');
/**
/**
@@ -32,11 +34,49 @@ function hueOf(id) {
@@ -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). */
/** 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 } = {}) {
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. */
/** "Earned by 3 people": how many have a quest's badge (a count, never who), or nothing at 0. */
function earnedBy(n) {
function earnedBy(n) {
return n > 0 ? html`<p class="quest-count">Earned by ${n} ${n === 1 ? 'person' : 'people'}</p>` : '';
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 } = {}) {
@@ -72,6 +112,19 @@ function seriesCard(series, { coinsEnabled = false, counts = null } = {}) {
</li>`;
</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). */
/** 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 } = {}) {
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.
// 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
@@ -102,4 +155,4 @@ ${dailyCap} OpenCoins per person per day and ${globalDailyCap} for everyone toge
never money — and never Vibes.</p>`;
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 };
@@ -21,14 +21,14 @@ const BUDGETS = {
@@ -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",
// 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.
// 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.
// 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
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)
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
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
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
externalFiles: 0, // 0
};
};
@@ -114,7 +114,7 @@ const { render } = require('../server/render/html');
@@ -114,7 +114,7 @@ const { render } = require('../server/render/html');
});
});
await check('every page is server-rendered, complete without JavaScript, and answers 200', async () => {
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);
const r = await t.get(p);
assert.strictEqual(r.status, 200, p);
assert.strictEqual(r.status, 200, p);
assert.match(r.headers.get('content-type'), /^text\/html/, p);
assert.match(r.headers.get('content-type'), /^text\/html/, p);
@@ -127,17 +127,46 @@ const { render } = require('../server/render/html');
@@ -127,17 +127,46 @@ const { render } = require('../server/render/html');
assert.ok(quest.text.includes('First Broadcast'), 'its badge');
assert.ok(quest.text.includes('First Broadcast'), 'its badge');
assert.ok(quest.text.includes('OpenCoins rewards start soon'), 'coins are honestly off');
assert.ok(quest.text.includes('OpenCoins rewards start soon'), 'coins are honestly off');
const home = await t.get('/');
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.
// 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('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('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":"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('"@type":"ItemList"'), 'the quests as an ItemList');
assert.ok(!home.text.includes(kim.subject) && !home.text.includes(kim.username), 'nobody is named');
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');
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('never money'), 'the rules say what rewards are');
assert.ok(how.text.includes('Vibes'), 'and that they are never Vibes');
assert.ok(how.text.includes('Vibes'), 'and that they are never Vibes');
@@ -168,6 +197,7 @@ const { render } = require('../server/render/html');
@@ -168,6 +197,7 @@ const { render } = require('../server/render/html');
const sitemap = await t.get('/sitemap.xml');
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}`);
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/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');
const robots = await t.get('/robots.txt');
assert.ok(robots.text.includes('Disallow: /me'), 'the private log is not crawled');
assert.ok(robots.text.includes('Disallow: /me'), 'the private log is not crawled');
const llms = await t.get('/llms.txt');
const llms = await t.get('/llms.txt');
@@ -186,6 +216,10 @@ const { render } = require('../server/render/html');
@@ -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('<script>') && !cardHtml.includes('<img') && !cardHtml.includes('<b>go</b>'), 'no raw markup from data');
assert.ok(cardHtml.includes('<script>alert(1)</script>'), 'escaped, not stripped');
assert.ok(cardHtml.includes('<script>alert(1)</script>'), 'escaped, not stripped');
assert.ok(cardHtml.includes('<i>Badge</i>'), 'the badge label is escaped too');
assert.ok(cardHtml.includes('<i>Badge</i>'), 'the badge label is escaped too');
+ const kitHtml = renderQuests.kitCard(hostile, { coinsEnabled: true, earned: 2 });
+ assert.ok(!kitHtml.includes('<script>') && kitHtml.includes('<script>alert(1)</script>'), 'the home\'s card escapes it');
+ const art = renderQuests.badgeSvg(hostile);
+ assert.ok(!art.includes('<i>') && art.includes('<i>Badge</i>'), 'so does the badge\'s picture');
const page = await t.get('/quests/%3Cscript%3Ealert(1)%3C%2Fscript%3E');
const page = await t.get('/quests/%3Cscript%3Ealert(1)%3C%2Fscript%3E');
assert.strictEqual(page.status, 404);
assert.strictEqual(page.status, 404);