openvibers/openvibe.quest Commits

The daily quest is one card on both pages, and quest icons are drawn from the set that has them (#254)

OP OpenVibers committed 3 days ago · 8d15beb560 · 7 files, +150 −41

* The daily quest is a card, and quest icons are drawn from the set that has them

Icons: Quest wrote every icon as a site mark (openvibe-shared/icons, 88 names), but most of its names (wrench, heart,
camera, globe, pencil…) are glyphs (openvibe-shared/glyphs). A mark that does not exist draws the bare "V", so "Use a
tool" and the daily card's own icon were a V on production. A glyph is now inline SVG; a site mark stays a site mark;
an unknown name is the quest mark.

The daily card: a real card (it was an outline with a floated icon box), the day in words, each step one row to press
with a tick when done, one sign-in button, and how many people finished today. "Build a step type" moves to How it
works. Earlier days are a counted fold of rows; they no longer link to JSON.

* On a phone a folded section is one row

A closed fold sat inside its section's full top and bottom room: How it works and Questions each floated in a third
of a screen of nothing.

* One daily card on the home and the catalog

The catalog drew its own card of the same quest, with other markup, other words and an ISO date. Both pages call one
renderer now; signing in from it comes back to the page you were on.

* Size budgets: the daily card's styles and inline glyphs, measured

Changes

ddiff --git a/public/css/app.css b/public/css/app.css
iindex 98938c0..ebc1601 100644
---- a/public/css/app.css
++++ b/public/css/app.css
@@@ -196,15 +196,57 @@ p.lede { font-size: 1.05rem; color: var(--text-secondary, #96a7c2); max-width: 7
.table-wrap > table:has(thead th:nth-child(3)):has(td[data-label]) td:empty { display: none; }
}
--/* One shared daily quest, before the catalog. */
--.q-daily { border: 1px solid var(--ov-border, #8886); border-radius: 18px; padding: clamp(20px, 4vw, 36px); margin-block: 20px; background: var(--ov-surface, transparent); }
--.q-daily-icon { float: right; display: grid; place-items: center; width: 52px; height: 52px; border: 1px solid var(--ov-border, #8886); border-radius: 15px; color: var(--accent-light, #8ab8ff); background: color-mix(in srgb, var(--accent, #678cff) 18%, transparent); }
++/* One shared daily quest, before the catalog: a card. Head (the quest's icon, "Today's quest", the day in words),
++ the title, the steps as rows to press, and a foot (where you stand, how many people finished today). */
++.q-daily { margin: 12px 0 18px; padding: clamp(16px, 3.4vw, 30px); border: 1px solid var(--border, #1f2d47); border-radius: 20px;
++ background: radial-gradient(120% 90% at 100% 0%, color-mix(in srgb, var(--accent, #678cff) 16%, transparent), transparent 60%), var(--bg-card, #131c2e); }
++.q-daily-head { display: flex; align-items: center; gap: 12px; }
++.q-daily-icon { flex: none; display: grid; place-items: center; width: 46px; height: 46px; border-radius: 14px; color: var(--accent-light, #8ab8ff); background: color-mix(in srgb, var(--accent, #678cff) 18%, transparent); }
.q-reviewed-glyph { display: inline-block; flex: none; object-fit: contain; }
--.q-daily-kicker { font-size: .75rem; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }
--.q-daily h1 { margin: .2em 0; font-size: clamp(1.5rem, 4vw, 2.4rem); }
--.q-daily-steps { list-style: none; margin: 20px 0; padding: 0; display: grid; gap: 8px; }
--.q-daily-steps li { display: flex; align-items: center; gap: 12px; min-height: 52px; padding: 8px 12px; border: 1px solid var(--ov-border, #8886); border-radius: 12px; }
--.q-daily-steps small { display: block; opacity: .7; }
--.q-daily-past { margin: 12px auto 28px; max-width: 1200px; padding: 12px 20px; }
--.q-daily-past summary { cursor: pointer; min-height: 44px; }
++.q-glyph { display: inline-grid; place-items: center; flex: none; line-height: 0; }
++.q-glyph svg { display: block; }
++.q-daily-kicker { display: grid; gap: 1px; margin: 0; min-width: 0; }
++.q-daily-kicker b { font-size: .74rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; color: var(--accent-light, #8ab8ff); }
++.q-daily-kicker time { color: var(--text-muted, #7386a3); font-size: .9rem; }
++.q-daily .q-daily-title { margin: 14px 0 6px; font-size: clamp(1.45rem, 4vw, 2.2rem); line-height: 1.15; letter-spacing: -.015em; }
++.q-daily-story { margin: 0; color: var(--text-secondary, #96a7c2); max-width: 62ch; }
++.q-daily-steps { list-style: none; margin: 16px 0 0; padding: 0; display: grid; gap: 8px; }
++.q-daily-steps a { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 12px; min-height: 56px; padding: 8px 12px; border: 1px solid var(--border, #1f2d47); border-radius: 14px;
++ background: color-mix(in srgb, var(--bg-primary, #0a0f1c) 55%, transparent); color: var(--text-primary, #e6edf7); text-decoration: none; transition: border-color var(--ov-dur-2, .2s) var(--ov-ease-out, ease-out); }
++.q-daily-steps a:hover, .q-daily-steps a:focus-visible { border-color: var(--accent, #678cff); outline: none; }
++.q-daily-ic { display: grid; place-items: center; width: 38px; height: 38px; border-radius: 50%; color: var(--accent-light, #8ab8ff); background: color-mix(in srgb, var(--accent, #678cff) 14%, transparent); }
++.q-daily-ic .ov-icon { --ovi-size: 28px !important; }
++.q-daily-t { display: grid; gap: 1px; min-width: 0; }
++.q-daily-t b { font-weight: 650; line-height: 1.3; }
++.q-daily-t small { color: var(--text-muted, #7386a3); font-size: .82rem; }
++.q-daily-go { width: 8px; height: 8px; margin-right: 4px; border-right: 2px solid var(--text-muted, #7386a3); border-bottom: 2px solid var(--text-muted, #7386a3); transform: rotate(-45deg); }
++.q-daily-steps .is-done .q-daily-ic { color: var(--success, #22c55e); background: color-mix(in srgb, var(--success, #22c55e) 16%, transparent); }
++.q-daily-steps .is-done b { color: var(--text-secondary, #96a7c2); }
++.q-daily-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px; margin: 16px 0 0; color: var(--text-secondary, #96a7c2); font-size: .92rem; }
++.q-daily-foot .ovk-btn { min-height: 44px; }
++.q-daily-foot b { color: var(--text-primary, #e6edf7); }
++.q-daily-dots { display: inline-flex; gap: 5px; }
++.q-daily-dots i { width: 10px; height: 10px; border-radius: 50%; background: color-mix(in srgb, var(--text-muted, #7386a3) 35%, transparent); }
++.q-daily-dots i.is-on { background: var(--success, #22c55e); }
++.q-daily-n { margin-left: auto; }
++@media (max-width: 520px) { .q-daily-n { margin-left: 0; flex-basis: 100%; } .q-daily-foot .ovk-btn { flex: 1 1 auto; justify-content: center; } }
++@media (prefers-reduced-motion: reduce) { .q-daily-steps a { transition: none; } }
++/* Earlier days: a counted fold, the day in words and the quest's name. */
++.q-daily-past { margin: 0 0 22px; border: 1px solid var(--border, #1f2d47); border-radius: 14px; background: var(--bg-card, #131c2e); }
++.q-daily-past summary { display: flex; align-items: center; gap: 10px; min-height: 48px; padding: 0 14px; font-weight: 650; cursor: pointer; list-style: none; }
++.q-daily-past summary::-webkit-details-marker { display: none; }
++.q-daily-past summary::after { content: ""; margin-left: auto; width: 8px; height: 8px; border-right: 2px solid currentColor; border-bottom: 2px solid currentColor; transform: rotate(45deg); opacity: .6; }
++.q-daily-past[open] summary::after { transform: rotate(225deg); }
++.q-daily-past-n { padding: 1px 8px; border-radius: 999px; background: color-mix(in srgb, var(--text-muted, #7386a3) 18%, transparent); color: var(--text-secondary, #96a7c2); font-size: .8rem; font-weight: 700; }
++.q-daily-past ul { list-style: none; margin: 0; padding: 0 14px 10px; }
++.q-daily-past li { display: grid; grid-template-columns: minmax(0, 11em) minmax(0, 1fr); gap: 12px; padding: 8px 0; border-top: 1px solid var(--border, #1f2d47); }
++.q-daily-past time { color: var(--text-muted, #7386a3); }
.q-doc { max-width: 780px; margin-inline: auto; }
++
++/* On a phone a folded section is one row: the section's own top and bottom room (made to hold a heading and its
++ content) left a closed fold floating in a third of a screen of nothing. */
++@media (max-width: 640px) {
++ .page .sc-sec:has(> .hm-fold-block) { margin-block: 0; padding-block: 2px; }
++ .page .sc-sec:has(> .hm-fold-block) > .hm-fold-block > summary { min-height: 56px; }
++ .page .sc-sec:has(> .hm-fold-block[open]) { padding-bottom: 18px; }
++}
ddiff --git a/public/css/quest-visual.css b/public/css/quest-visual.css
iindex 195aa16..4d134a8 100644
---- a/public/css/quest-visual.css
++++ b/public/css/quest-visual.css
@@@ -2,8 +2,8 @@
.q-eyebrow { margin: 0 0 .45rem; color: var(--accent-light, #8ab8ff); font-size: .75rem; font-weight: 800; letter-spacing: .14em; text-transform: uppercase; }
.q-reviewed-glyph { display: inline-block; flex: none; object-fit: contain; }
.q-log { --q-line: color-mix(in srgb, var(--border-light, #354363) 72%, transparent); }
--.q-log-hero, .q-index-daily { position: relative; isolation: isolate; overflow: hidden; border: 1px solid color-mix(in srgb, var(--accent, #678cff) 30%, var(--border, #263450)); border-radius: 26px; background: linear-gradient(135deg, color-mix(in srgb, var(--accent, #556cff) 16%, var(--bg-card, #141c2d)), var(--bg-card, #141c2d) 65%); box-shadow: 0 20px 55px -42px var(--accent, #678cff); }
--.q-log-hero::before, .q-index-daily::before { content: ''; position: absolute; z-index: -1; width: 310px; height: 310px; right: -100px; top: -155px; border-radius: 50%; background: radial-gradient(circle, color-mix(in srgb, var(--accent, #678cff) 32%, transparent), transparent 68%); pointer-events: none; }
++.q-log-hero { position: relative; isolation: isolate; overflow: hidden; border: 1px solid color-mix(in srgb, var(--accent, #678cff) 30%, var(--border, #263450)); border-radius: 26px; background: linear-gradient(135deg, color-mix(in srgb, var(--accent, #556cff) 16%, var(--bg-card, #141c2d)), var(--bg-card, #141c2d) 65%); box-shadow: 0 20px 55px -42px var(--accent, #678cff); }
++.q-log-hero::before { content: ''; position: absolute; z-index: -1; width: 310px; height: 310px; right: -100px; top: -155px; border-radius: 50%; background: radial-gradient(circle, color-mix(in srgb, var(--accent, #678cff) 32%, transparent), transparent 68%); pointer-events: none; }
.q-log-hero { display: grid; gap: 22px; padding: clamp(20px, 4vw, 34px); margin: 18px 0 24px; }
.q-log-hero h1 { margin: 0 0 .35rem; font-size: clamp(2rem, 5vw, 3.45rem); letter-spacing: -.045em; }
.q-log-hero p:not(.q-eyebrow) { max-width: 52ch; margin: 0; color: var(--text-secondary, #adbbd2); }
@@@ -79,17 +79,6 @@
.q-locked-fold > summary { color: var(--text-secondary, #b7c3d6); }
.q-badge-privacy { margin-top: 17px !important; font-size: .75rem !important; }
.q-log-foot { margin-top: 22px; padding-top: 14px; border-top: 1px solid var(--q-line); }
--.q-index-daily { display: grid; gap: 15px; padding: clamp(18px, 3vw, 27px); margin: 20px 0 25px; }
--.q-index-daily h2 { margin: 0; font-size: clamp(1.45rem, 3.4vw, 2.25rem); letter-spacing: -.035em; }
--.q-index-daily-icon { display: inline-grid; place-items: center; width: 50px; height: 50px; margin-bottom: 11px; border: 1px solid var(--q-line, #43516c); border-radius: 15px; color: var(--accent-light, #b2c1ff); background: color-mix(in srgb, var(--accent, #678cff) 18%, transparent); }
--.q-index-daily-copy > p:not(.q-eyebrow) { max-width: 50ch; color: var(--text-secondary, #b7c3d6); }
--.q-index-daily-count { display: inline-block; padding: 4px 10px; border: 1px solid var(--q-line, #43516c); border-radius: 100px; font-size: .76rem; font-weight: 750; }
--.q-index-daily ol { display: grid; gap: 6px; margin: 0; padding: 0; list-style: none; }
--.q-index-daily li { display: flex; align-items: center; gap: 10px; min-height: 49px; padding: 7px 10px; border: 1px solid var(--q-line, #43516c); border-radius: 12px; background: color-mix(in srgb, var(--bg-primary, #090f1c) 40%, transparent); }
--.q-index-daily li > span:nth-child(2) { min-width: 0; flex: 1; }
--.q-index-daily li a { display: inline-flex; align-items: center; min-height: 44px; color: var(--text-primary, #fff); font-size: .85rem; font-weight: 700; text-decoration: none; }
--.q-index-daily li small { display: block; color: var(--text-secondary, #b7c3d6); font-size: .75rem; }
--.q-index-daily .q-step-check { color: var(--success, #49cf98); font-weight: 800; }
.site-group h3 > .ov-icon { flex: none; }
.quest-list .quest { box-shadow: 0 12px 28px -26px #0009; }
.quest-list .quest .medal .ov-icon { --ovi-size: 28px !important; }
@@@ -108,7 +97,6 @@
.q-badge > summary { min-height: 49px; }
.q-badge-medal { width: 42px; height: 42px; }
.q-badge-sheet { left: auto; right: max(16px, calc((100vw - 1080px) / 2)); bottom: auto; top: 25%; width: 340px; }
-- .q-index-daily { grid-template-columns: minmax(0, 1fr) minmax(330px, .85fr); align-items: center; }
}
@media (max-width: 350px) { .q-badge-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } .q-log-stats > div { min-height: 99px; } }
@media (prefers-reduced-motion: reduce) {
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex fb361ea..8ebdbf9 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -142,16 +142,34 @@ function createPageRoutes(ctx) {
return html;
}
++ // Today's quest: one card. Its head is the day in words and the quest's own icon; each step is one row to press
++ // (the whole row is the link), with a tick when it is done; the foot says where the person stands and how many
++ // people finished today. The developer's link ("Build a step type") is with the other developer links, in How it
++ // works: it is not a step of today's quest.
++ const dayWords = (iso) => { const d = new Date(`${iso}T12:00:00Z`); return Number.isNaN(d.getTime()) ? iso : d.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', timeZone: 'UTC' }); };
++ // One renderer for the home (where its title is the page's h1) and the catalog (an h2): they were two cards
++ // with different markup, wording and dates for the same quest.
++ function dailyCardOf(req, daily, approvedGlyphs, tag = 'h1') {
++ const stepCount = daily.steps.length;
++ const doneCount = daily.steps.filter((x) => x.done).length;
++ const finishedToday = Number(daily.finished_today) || 0;
++ return html`<section class="q-daily" aria-labelledby="daily-title">
++<header class="q-daily-head"><span class="q-daily-icon">${icons.glyph('spark', 28, approvedGlyphs.get(nameForDaily(daily.date)))}</span><p class="q-daily-kicker"><b>Today's quest</b><time datetime="${daily.date}">${dayWords(daily.date)}</time></p></header>
++${raw(`<${tag} id="daily-title" class="q-daily-title">`)}${daily.title}${raw(`</${tag}>`)}<p class="q-daily-story">${daily.story}</p>
++<ol class="q-daily-steps">${daily.steps.map((step) => html`<li${step.done ? raw(' class="is-done"') : ''}><a href="${step.href}"><span class="q-daily-ic">${icons.glyph(step.done ? 'check' : icons.topic(step.event_type, step.site), 22)}</span><span class="q-daily-t"><b>${step.sentence}</b><small>${step.done ? 'Done · ' : ''}${step.site}</small></span><span class="q-daily-go" aria-hidden="true"></span></a></li>`)}</ol>
++<p class="q-daily-foot">${signedIn(req)
++ ? html`<span class="q-daily-dots" role="img" aria-label="${doneCount} of ${stepCount} steps done">${daily.steps.map((x) => html`<i${x.done ? raw(' class="is-on"') : ''}></i>`)}</span><b>${doneCount} of ${stepCount}</b>${Number(daily.streak) > 1 ? html`<span>${daily.streak}-day streak</span>` : ''}`
++ : html`<a class="ovk-btn ovk-btn--primary" href="/auth/login?next=${encodeURIComponent(req.path || '/')}">Sign in to track it</a>`}${finishedToday ? html`<span class="q-daily-n">${finishedToday.toLocaleString('en-US')} ${finishedToday === 1 ? 'person' : 'people'} finished today</span>` : ''}</p></section>`;
++ }
++
r.get('/', async (req, res) => {
const [appList, p, approvedGlyphs] = await Promise.all([ctx.appQuests ? ctx.appQuests.listed(12) : [], pulse(), ctx.glyphs.approved()]);
const daily = signedIn(req) ? await ctx.daily.progress(req.viewer.subject) : await ctx.daily.publicQuest();
const past = await s.db.many('SELECT day,title FROM daily_quests WHERE day < $1 ORDER BY day DESC LIMIT 14', [daily.date]);
-- const dailyCard = html`<section class="sc-sec q-daily" aria-labelledby="daily-title"><span class="q-daily-icon">${icons.glyph('spark', 32, approvedGlyphs.get(nameForDaily(daily.date)))}</span><p class="q-daily-kicker">TODAY'S QUEST · ${daily.date}</p>
--<h1 id="daily-title">${daily.title}</h1><p>${daily.story}</p>
--<ol class="q-daily-steps">${daily.steps.map((step) => html`<li>${icons.glyph(icons.topic(step.event_type, step.site), 32)}<span>${step.done ? '✓ ' : ''}<a href="${step.href}">${step.sentence}</a><small>${step.site}</small></span></li>`)}</ol>
--<p>${signedIn(req) ? html`${daily.progress} of 3 · ${daily.streak}-day streak` : html`Sign in to track your progress.`} · ${daily.finished_today || 0} finished today</p>
--<a href="/build-a-step-type">Build a step type</a></section>`;
-- const pastFold = html`<details class="q-daily-past"><summary>Past daily quests (${past.length})</summary><ul>${past.map((x) => html`<li><a href="/api/v1/daily/${x.day}">${x.day}: ${x.title}</a></li>`)}</ul></details>`;
++ const dailyCard = dailyCardOf(req, daily, approvedGlyphs, 'h1');
++ // Earlier days, as a counted fold. A row is the day and the quest's name; it links nowhere (the only address a
++ // past day has is its JSON, which is for programs, and is listed on How it works).
++ const pastFold = past.length ? html`<details class="q-daily-past"><summary><span>Earlier daily quests</span><span class="q-daily-past-n">${past.length}</span></summary><ul>${past.map((x) => html`<li><time datetime="${x.day}">${dayWords(x.day)}</time><span>${x.title}</span></li>`)}</ul></details>` : '';
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.
@@@ -219,7 +237,7 @@ ${fold({ id: 'rewards', title: 'Badges, items and OpenCoins', body: html`<p>One
<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>` })}
++<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. Building an app? <a href="/build-a-step-type">Suggest a step for the daily quest</a>.</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>
@@@ -275,7 +293,7 @@ ${raw(showcase.cta({
localStyles: ['css/quest-visual.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>
--${daily ? html`<section class="q-index-daily" aria-labelledby="q-index-daily-title"><div class="q-index-daily-copy"><span class="q-index-daily-icon">${icons.glyph('spark', 32, approvedGlyphs.get(nameForDaily(daily.date)))}</span><p class="q-eyebrow">TODAY'S SHARED QUEST · ${daily.date}</p><h2 id="q-index-daily-title">${daily.title}</h2><p>${daily.story}</p><span class="q-index-daily-count">${signedIn(req) ? `${daily.progress} of 3 complete` : `${daily.finished_today || 0} finished today`}</span></div><ol>${daily.steps.map((step) => html`<li>${icons.glyph(icons.topic(step.event_type, step.site), 24)}<span><a href="${step.href}">${step.sentence}</a><small>${quests.shortSite(step.site)}</small></span>${step.done ? html`<span class="q-step-check">✓</span>` : ''}</li>`)}</ol></section>` : ''}
++${daily ? dailyCardOf(req, daily, approvedGlyphs, 'h2') : ''}
${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, seen: mine ? seen : null, completed: mine ? completed : null })}
${appList.length ? html`<section class="site-group" id="app-quests" aria-labelledby="app-quests-h"><h2 id="app-quests-h">Made by apps</h2>
ddiff --git a/server/render/quest-icons.js b/server/render/quest-icons.js
iindex 5e6fa1f..6945f96 100644
---- a/server/render/quest-icons.js
++++ b/server/render/quest-icons.js
@@@ -1,10 +1,14 @@
'use strict';
--const { NAMES } = require('openvibe-shared/glyphs');
++const glyphs = require('openvibe-shared/glyphs');
++const siteMarks = require('openvibe-shared/icons');
const catalog = require('../quests/catalog');
--const { html } = require('./html');
++const { html, raw } = require('./html');
--const available = new Set(NAMES);
++// Two icon sets (see glyph() below): a name is usable when either has it.
++const GLYPHS = new Set(glyphs.NAMES);
++const MARKS = new Set(typeof siteMarks.names === 'function' ? siteMarks.names() : siteMarks.names);
++const available = new Set([...GLYPHS, ...MARKS]);
const TOPICS = Object.freeze({
'live.stream.started': 'live', 'network.follow.created': 'heart',
'community.thread.created': 'chat', 'community.post.created': 'reply',
@@@ -31,8 +35,12 @@ function glyph(name, size = 24, approvedSvg = null) {
const doc = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#8fa4dd" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${approvedSvg}</svg>`;
return html`<img class="q-reviewed-glyph" src="data:image/svg+xml,${encodeURIComponent(doc)}" width="${size}" height="${size}" alt="">`;
}
-- return html`<span class="ov-icon" data-icon="${safe(name)}" data-size="${size}" aria-hidden="true"></span>`;
++ // A site's own mark (live, chat, quest…) is drawn in the page by ov-icons.js; every other name is a glyph, drawn
++ // here as inline SVG. Writing a glyph's name as a site mark drew the bare "V" (most quest rows had one).
++ const n = safe(name);
++ if (!MARKS.has(n) && GLYPHS.has(n)) return html`<span class="q-glyph" data-glyph="${n}" aria-hidden="true">${raw(glyphs.svg(n, size))}</span>`;
++ return html`<span class="ov-icon" data-icon="${n}" data-size="${size}" aria-hidden="true"></span>`;
}
const needsDraft = (eventType, domain) => !TOPICS[eventType] && !SITES[domain];
--module.exports = { TOPICS, SITES, safe, topic, site, quest, badge, glyph, needsDraft };
++module.exports = { TOPICS, SITES, GLYPHS, MARKS, safe, topic, site, quest, badge, glyph, needsDraft };
ddiff --git a/test/daily-card.test.js b/test/daily-card.test.js
nnew file mode 100644
iindex 0000000..450aca3
---- /dev/null
++++ b/test/daily-card.test.js
@@@ -0,0 +1,52 @@
++'use strict';
++/**
++ * The home's daily quest card, and the icons on it.
++ *
++ * Two icon sets exist: a site's own mark (openvibe-shared/icons: live, chat, quest…, drawn in the page) and the glyphs
++ * (openvibe-shared/glyphs: wrench, heart, camera…). Quest wrote every icon as a site mark, and a mark that does not
++ * exist draws the bare "V": on production "Use a tool" and the card's own icon were a V. A glyph is now drawn here, as
++ * inline SVG, and nothing on the page asks for a mark that does not exist.
++ */
++const assert = require('assert');
++const { boot } = require('./helpers/boot');
++const icons = require('../server/render/quest-icons');
++
++(async () => {
++ const t = await boot();
++ try {
++ // Every name the icon tables use is drawable by one set or the other.
++ const names = [...Object.values(icons.TOPICS), ...Object.values(icons.SITES)];
++ names.forEach((n) => assert.ok(icons.GLYPHS.has(n) || icons.MARKS.has(n), `${n} is in neither icon set`));
++ assert.match(String(icons.glyph('wrench', 22)), /^<span class="q-glyph" data-glyph="wrench" aria-hidden="true"><svg /, 'a glyph is inline SVG');
++ assert.match(String(icons.glyph('live', 22)), /^<span class="ov-icon" data-icon="live"/, 'a site mark stays a site mark');
++ assert.match(String(icons.glyph('no-such-icon', 22)), /data-icon="quest"/, 'an unknown name is the quest mark, which exists');
++
++ const home = await t.get('/');
++ assert.strictEqual(home.status, 200);
++ const card = (home.text.match(/<section class="q-daily"[\s\S]*?<\/section>/) || [''])[0];
++ assert.ok(card, 'the daily card is on the home');
++ assert.match(card, /<p class="q-daily-kicker"><b>Today's quest<\/b><time datetime="\d{4}-\d\d-\d\d">[A-Z][a-z]+day, [A-Z][a-z]+ \d{1,2}<\/time><\/p>/, 'the day is said in words');
++ const rows = Array.from(card.matchAll(/<li[^>]*><a href="([^"]+)"><span class="q-daily-ic">([\s\S]*?)<\/span><span class="q-daily-t"><b>([^<]+)<\/b><small>([^<]+)<\/small><\/span>/g));
++ assert.strictEqual(rows.length, 3, 'three steps, each one row to press');
++ rows.forEach((m) => assert.match(m[1], /^https:\/\/[a-z0-9.-]+\//, 'a step links to where it is done'));
++ assert.match(card, /<a class="ovk-btn ovk-btn--primary" href="\/auth\/login\?next=%2F">Sign in to track it<\/a>/, 'signed out: one button');
++ assert.match(card, /<h1 id="daily-title" class="q-daily-title">/, 'on the home its title is the page\'s h1');
++ assert.ok(!card.includes('Build a step type') && !card.includes('/build-a-step-type'), 'the developer link is not in today\'s card');
++ assert.match(home.text, /Building an app\? <a href="\/build-a-step-type">Suggest a step for the daily quest<\/a>/, 'it is with How it works');
++
++ // The catalog shows the same card (its title an h2), not a second one of its own.
++ const catalog = await t.get('/quests');
++ assert.match(catalog.text, /<section class="q-daily"[^>]*>[\s\S]*?<h2 id="daily-title" class="q-daily-title">/);
++ assert.match(catalog.text, /href="\/auth\/login\?next=%2Fquests">Sign in to track it<\/a>/, 'signing in comes back to the page you were on');
++
++ // Nothing on the page asks for a site mark that does not exist (that is what drew the "V").
++ const asked = Array.from(home.text.matchAll(/class="ov-icon" data-icon="([^"]+)"/g)).map((m) => m[1]);
++ const missing = [...new Set(asked.filter((n) => !icons.MARKS.has(n)))];
++ assert.deepStrictEqual(missing, [], `site marks that do not exist: ${missing.join(', ')}`);
++ // A past day is a row, not a link to its JSON.
++ assert.ok(!/<a href="\/api\/v1\/daily\//.test(home.text), 'no page link goes to an API address');
++ console.log('daily-card: the card, its three rows, the day in words, one sign-in button; every icon on the home exists');
++ } finally {
++ await t.close();
++ }
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex 29cbc8d..3de7f91 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -21,14 +21,14 @@ const BUDGETS = {
// Raised 2026-10-22 with the rich home: figures under the hero, the most-earned badges, each card's "earned by",
// the badges nobody holds yet, the sites Quest follows, a FAQ and the quests as ItemList + FAQPage JSON-LD.
// Measured 54.3 KB raw / 10.6 KB brotli on a fresh database.
-- htmlRawKB: 83, // 82.6 with the daily quest hero (2026-11-09); 81.3 with openvibe-shared 4.93.2: content labels, glyph fallback, footer counts (2026-11-01); 80.6 with 2026-11-01: the hero orbit (15 sites, inline icons) (2026-11-01); 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)
++ htmlRawKB: 83.5, // 83.2 with the daily quest as a card of three rows and its glyphs drawn inline (2026-11-10); before: 82.6 with the daily quest hero (2026-11-09); 81.3 with openvibe-shared 4.93.2: content labels, glyph fallback, footer counts (2026-11-01); 80.6 with 2026-11-01: the hero orbit (15 sites, inline icons) (2026-11-01); 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: 16.8, // 16.8: measured 16.3 with openvibe-shared 4.163.0, plus room for 4.162.0–4.163.0, 2026-11-09
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)
jsRawKB: 376.7, // 376.7: measured 368.7 with openvibe-shared 4.163.0, plus room for 4.162.0–4.163.0, 2026-11-09
jsBrotliKB: 89.5, // 89.5: measured 87.5 with openvibe-shared 4.163.0, plus room for 4.162.0–4.163.0, 2026-11-09
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: 122.2, // 122.2: measured 117.2 with openvibe-shared 4.163.0, plus room for 4.162.0–4.163.0, 2026-11-09
-- cssBrotliKB: 26.6, // 26.6: measured 25.4 with openvibe-shared 4.163.0, plus room for 4.162.0–4.163.0, 2026-11-09
++ cssRawKB: 126.2, // 125.9 with the daily card and the phone folds (2026-11-10); before: 122.2: measured 117.2 with openvibe-shared 4.163.0, plus room for 4.162.0–4.163.0, 2026-11-09
++ cssBrotliKB: 27.3, // 27.1 with the daily card and the phone folds (2026-11-10); before: 26.6: measured 25.4 with openvibe-shared 4.163.0, plus room for 4.162.0–4.163.0, 2026-11-09
externalFiles: 0, // 0
};
ddiff --git a/test/quest-visual.test.js b/test/quest-visual.test.js
iindex a19527d..c1d2845 100644
---- a/test/quest-visual.test.js
++++ b/test/quest-visual.test.js
@@@ -24,7 +24,8 @@ const { createGlyphDraftClient, nameForBadge, nameForDaily } = require('../serve
assert.ok(me.text.includes('/css/quest-visual.css') && me.text.includes('/js/quest-visual.js'));
const index = await t.get('/quests', { as: person });
assert.equal(index.status, 200);
-- assert.ok(index.text.includes('q-index-daily') && index.text.includes('TODAY\'S SHARED QUEST'));
++ assert.ok(/<section class="q-daily"[^>]*>[\s\S]*?<h2 id="daily-title" class="q-daily-title">/.test(index.text), 'the catalog shows the same daily card as the home, its title an h2');
++ assert.ok(!index.text.includes('q-index-daily'), 'one card, not two');
assert.ok(index.text.includes('Quests by site') && index.text.includes('q-site-chip'));
assert.ok(requests.some((x) => /^daily-\d{4}-\d{2}-\d{2}$/.test(x.name)), 'a unique daily drawing joins review');
});