openvibers/openvibe.quest Commits

Quest: quest cards with a badge medal, steps as a checklist, reward chips; site groups with counts; a shorter hero; no API paths in the copy

OP OpenVibers committed 8 Oct 2026 · 9614c556c9 · 4 files, +56 −18


Changes

ddiff --git a/public/css/app.css b/public/css/app.css
iindex 3cfc09d..0cc3991 100644
---- a/public/css/app.css
++++ b/public/css/app.css
@@@ -48,12 +48,32 @@ button.sc-btn.sc-primary { border: 0; }
p.lede { font-size: 1.05rem; color: var(--text-secondary, #96a7c2); max-width: 780px; }
.site-group { margin: 1.2rem 0; }
.site-group small { color: var(--text-muted, #7386a3); font-weight: 400; }
--.quest-list { list-style: none; margin: 0; padding: 0; display: grid; gap: .7rem; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
--.quest { border: 1px solid var(--border, #1f2d47); border-radius: 10px; padding: .7rem .9rem; background: var(--bg-card, #131c2e); }
--.quest-title { font-weight: 650; }
--.quest-why { margin: .25rem 0; color: var(--text-secondary, #96a7c2); font-size: .93rem; }
--.quest-steps, .quest-reward { margin: .3rem 0; font-size: .87rem; }
--.quest-steps .step { display: inline-block; margin-right: .6rem; }
++.site-group { margin: 22px 0 0; }
++.site-group h3 { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; margin: 0 0 10px; font-size: 1.1rem; }
++.site-group h3 a { color: var(--text-primary, #e6edf7); text-decoration: none; }
++.site-group h3 a:hover { color: var(--accent-light, #60a5fa); }
++.site-group h3 small { flex-basis: 100%; font-weight: 400; font-size: .86rem; color: var(--text-muted, #7386a3); }
++.site-count { font-size: .72rem; font-weight: 700; padding: 1px 8px; border-radius: 999px; border: 1px solid var(--border-light, #2c3d5c); color: var(--text-muted, #7386a3); }
++.quest-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); }
++.quest { display: grid; grid-template-columns: 56px minmax(0, 1fr); gap: 14px; padding: 16px; border-radius: 16px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); transition: border-color .15s, transform .15s; }
++.quest:hover { border-color: color-mix(in srgb, var(--accent, #3b82f6) 55%, var(--border, #1f2d47)); transform: translateY(-1px); }
++.medal { width: 56px; height: 56px; border-radius: 50%; display: grid; place-items: center; font-weight: 800; font-size: 1rem; letter-spacing: .02em; color: hsl(var(--h) 85% 82%); background: radial-gradient(circle at 35% 30%, hsl(var(--h) 55% 34%), hsl(var(--h) 50% 18%) 70%); box-shadow: inset 0 0 0 3px hsl(var(--h) 55% 42% / .55), 0 6px 16px -8px hsl(var(--h) 70% 40% / .8); }
++.medal.earned { box-shadow: inset 0 0 0 3px hsl(var(--h) 80% 62%), 0 0 0 3px hsl(var(--h) 80% 62% / .25), 0 8px 20px -8px hsl(var(--h) 80% 50%); }
++.quest-main { min-width: 0; }
++.quest-title { font-weight: 700; font-size: 1.02rem; color: var(--text-primary, #e6edf7); text-decoration: none; }
++.quest-title:hover { color: var(--accent-light, #60a5fa); text-decoration: underline; text-underline-offset: 3px; }
++.quest-why { margin: 4px 0 8px; color: var(--text-secondary, #96a7c2); font-size: .92rem; line-height: 1.45; }
++.quest-steps { list-style: none; margin: 0 0 10px; padding: 0; display: grid; gap: 4px; font-size: .9rem; }
++.quest-steps li { position: relative; padding-left: 22px; color: var(--text-primary, #e6edf7); }
++.quest-steps li::before { content: ''; position: absolute; left: 0; top: .28em; width: 13px; height: 13px; border-radius: 4px; border: 1.5px solid var(--border-light, #2c3d5c); }
++.step-count { font-size: .78rem; font-weight: 700; color: var(--text-muted, #7386a3); }
++.quest-reward { margin: 0; }
++.reward { display: flex; flex-wrap: wrap; gap: 6px; }
++.reward-badge, .reward-coins { font-size: .78rem; padding: 3px 10px; border-radius: 999px; border: 1px solid var(--border-light, #2c3d5c); color: var(--text-secondary, #96a7c2); }
++.reward-badge strong { color: var(--text-primary, #e6edf7); }
++.reward-coins { color: var(--warning, #f59e0b); border-color: color-mix(in srgb, var(--warning, #f59e0b) 40%, transparent); }
++.reward-coins.soon { color: var(--text-muted, #7386a3); border-style: dashed; border-color: var(--border-light, #2c3d5c); }
++@media (prefers-reduced-motion: reduce) { .quest { transition: none; } }
.steps { margin: .4rem 0; }
.done { color: var(--success, #22c55e); font-weight: 700; }
.progress { display: grid; grid-template-columns: minmax(8rem, 1fr) 2fr auto; gap: .5rem; align-items: center; margin: .3rem 0; font-size: .9rem; }
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 47277f8..4dc6848 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -52,8 +52,8 @@ function createPageRoutes(ctx) {
r.get('/', (req, res) => {
const hero = showcase.hero({
eyebrow: `${SITE_NAME} · ${TAGLINE}`,
-- title: SITE_NAME,
-- accent: TAGLINE,
++ title: 'Quests across OpenVibe.',
++ accent: 'Badges for what you really do.',
lede: `${SITE_NAME} is the network's shared quest log: one place that knows what you have really done on every OpenVibe site, and pays you for it in badges and OpenCoins. Quest runs no activities of its own — it listens to what the sites already report, and nothing else.`,
actions: signedIn(req)
? [{ label: 'Your quest log', href: '/me', primary: true }, { label: 'How it works', href: '/how-it-works' }]
@@@ -70,7 +70,7 @@ ${raw(showcase.features({
lede: 'One quest log across every OpenVibe site, built on what the sites already tell the network.',
items: [
{ icon: 'ov:page', title: 'One log, every site', 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: 'A quest pays a text badge on your profile (GET /api/v1/profiles/:subject/badges) and, when OpenCoins are on, OpenCoins.' },
++ { icon: 'ov:account', title: 'Rewards are badges', text: '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 communities next', text: 'Quests a streamer or a community writes themselves need moderation, which this release does not have yet. The tables and the API already key everything by quest id.' },
],
ddiff --git a/server/render/quests.js b/server/render/quests.js
iindex 81f18c1..5c074b7 100644
---- a/server/render/quests.js
++++ b/server/render/quests.js
@@@ -14,18 +14,36 @@ const { html } = require('./html');
*/
function reward(quest, { coinsEnabled = false } = {}) {
const badge = quest.reward.badge.name;
-- if (quest.reward.coins <= 0) return html`<span class="reward">Badge: <strong>${badge}</strong></span>`;
-- if (!coinsEnabled) return html`<span class="reward">Badge: <strong>${badge}</strong> · <span class="muted">OpenCoins rewards start soon</span></span>`;
-- return html`<span class="reward">Badge: <strong>${badge}</strong> · <strong>${quest.reward.coins}</strong> OpenCoins</span>`;
++ const badgeChip = html`<span class="reward-badge">Badge: <strong>${badge}</strong></span>`;
++ if (quest.reward.coins <= 0) return html`<span class="reward">${badgeChip}</span>`;
++ if (!coinsEnabled) return html`<span class="reward">${badgeChip}<span class="reward-coins soon">OpenCoins rewards start soon</span></span>`;
++ return html`<span class="reward">${badgeChip}<span class="reward-coins"><strong>${quest.reward.coins}</strong> OpenCoins</span></span>`;
}
--/** One quest in a list: what it is, why, what it pays, and the events it counts. */
++/** A badge's colour, fixed by its quest id, so a badge looks the same wherever it is shown. */
++function hueOf(id) {
++ let h = 0;
++ for (const ch of String(id)) h = (h * 33 + ch.codePointAt(0)) % 360;
++ return h;
++}
++
++/** 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>`;
++}
++
++/** One quest in a list: the badge it pays, what it is, why, the steps as a checklist, and the reward. */
function card(quest, { coinsEnabled = false } = {}) {
return html`<li class="quest">
++ ${medal(quest)}
++ <div class="quest-main">
<a class="quest-title" href="/quests/${quest.id}">${quest.title}</a>
<p class="quest-why">${quest.why}</p>
-- <p class="quest-steps">${quest.steps.map((s) => html`<span class="step">${s.label}${s.count > 1 ? html` ×${s.count}` : ''} <code>${s.event_type}</code></span>`)}</p>
++ <ul class="quest-steps">${quest.steps.map((s) => html`<li>${s.label}${s.count > 1 ? html` <span class="step-count">×${s.count}</span>` : ''}</li>`)}</ul>
<p class="quest-reward">${reward(quest, { coinsEnabled })}</p>
++ </div>
</li>`;
}
@@@ -34,7 +52,7 @@ function list(groups = catalog.grouped(), { coinsEnabled = false } = {}) {
// The heading id is the domain with its dots replaced: an id with a dot is legal but awkward to target.
const anchor = (site) => `site-${String(site).replace(/[^a-z0-9-]/gi, '-')}`;
return html`${groups.map((g) => html`<section class="site-group" aria-labelledby="${anchor(g.site)}">
-- <h3 id="${anchor(g.site)}"><a href="https://${g.site}">${g.name}</a>${g.what ? html` <small>${g.what}</small>` : ''}</h3>
++ <h3 id="${anchor(g.site)}"><a href="https://${g.site}">${String(g.name).replace(/^OpenVibe\./, '')}</a><span class="site-count">${g.quests.length} quest${g.quests.length === 1 ? '' : 's'}</span>${g.what ? html` <small>${g.what}</small>` : ''}</h3>
<ul class="quest-list">${g.quests.map((q) => card(q, { coinsEnabled }))}</ul>
</section>`)}`;
}
@@@ -58,4 +76,4 @@ ${dailyCap} OpenCoins per person per day and ${globalDailyCap} for everyone toge
never money — and never Vibes.</p>`;
}
--module.exports = { reward, card, list, progress, coinsNote };
++module.exports = { medal, hueOf, reward, card, list, progress, coinsNote };
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex 220dccb..7102609 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -22,8 +22,8 @@ const BUDGETS = {
jsRawKB: 264, // 239.8
jsBrotliKB: 62.5, // 56.5
cssFiles: 2, // 2 (app.css + the cached /shared/showcase.css)
-- cssRawKB: 16.5, // 15.0
-- cssBrotliKB: 4.1, // 3.7
++ cssRawKB: 21.0, // 19.1 with the quest medals and cards
++ cssBrotliKB: 5.1, // 4.6
externalFiles: 0, // 0
};