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)
Changes
@@@ -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; }++}@@@ -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) {@@@ -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>@@@ -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 };nnew file mode 100644@@@ -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); });@@@ -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-09jsFiles: 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-09jsBrotliKB: 89.5, // 89.5: measured 87.5 with openvibe-shared 4.163.0, plus room for 4.162.0–4.163.0, 2026-11-09cssFiles: 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-09externalFiles: 0, // 0};@@@ -24,7 +24,8 @@ const { createGlyphDraftClient, nameForBadge, nameForDaily } = require('../serveassert.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');});
@@ -196,15 +196,57 @@ p.lede { font-size: 1.05rem; color: var(--text-secondary, #96a7c2); max-width: 7
@@ -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; }
.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-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; }
.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; }
+}
@@ -2,8 +2,8 @@
@@ -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-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-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 { --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 { 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 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); }
.q-log-hero p:not(.q-eyebrow) { max-width: 52ch; margin: 0; color: var(--text-secondary, #adbbd2); }
@@ -79,17 +79,6 @@
@@ -79,17 +79,6 @@
.q-locked-fold > summary { color: var(--text-secondary, #b7c3d6); }
.q-locked-fold > summary { color: var(--text-secondary, #b7c3d6); }
.q-badge-privacy { margin-top: 17px !important; font-size: .75rem !important; }
.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-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; }
.site-group h3 > .ov-icon { flex: none; }
.quest-list .quest { box-shadow: 0 12px 28px -26px #0009; }
.quest-list .quest { box-shadow: 0 12px 28px -26px #0009; }
.quest-list .quest .medal .ov-icon { --ovi-size: 28px !important; }
.quest-list .quest .medal .ov-icon { --ovi-size: 28px !important; }
@@ -108,7 +97,6 @@
@@ -108,7 +97,6 @@
.q-badge > summary { min-height: 49px; }
.q-badge > summary { min-height: 49px; }
.q-badge-medal { width: 42px; height: 42px; }
.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-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 (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) {
@media (prefers-reduced-motion: reduce) {
@@ -142,16 +142,34 @@ function createPageRoutes(ctx) {
@@ -142,16 +142,34 @@ function createPageRoutes(ctx) {
return html;
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) => {
r.get('/', async (req, res) => {
const [appList, p, approvedGlyphs] = await Promise.all([ctx.appQuests ? ctx.appQuests.listed(12) : [], pulse(), ctx.glyphs.approved()]);
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 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 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 site = String(config.baseUrl).replace(/\/+$/, '');
const total = catalog.all().length;
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.
@@ -219,7 +237,7 @@ ${fold({ id: 'rewards', title: 'Badges, items and OpenCoins', body: html`<p>One
@@ -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>
<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)}` })}
${quests.coinsNote(coinsUi)}` })}
${fold({ id: 'sites-followed', title: 'The sites Quest follows', meta: `${followed().size} sites, ${events} events`, body: html`${sitesFollowed()}
${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>
<section class="sc-sec q-folds" id="questions" aria-labelledby="questions-h"><h2 id="questions-h">Questions</h2>
<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>
${FAQ.map((f) => fold({ title: f.q, body: html`<p>${f.a}</p>` }))}</section>
@@ -275,7 +293,7 @@ ${raw(showcase.cta({
@@ -275,7 +293,7 @@ ${raw(showcase.cta({
localStyles: ['css/quest-visual.css'],
localStyles: ['css/quest-visual.css'],
body: html`<h1>${one ? html`Quests on ${one.name}` : 'All quests'}</h1>
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>
<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) }))] }))}
${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 })}
${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>
${appList.length ? html`<section class="site-group" id="app-quests" aria-labelledby="app-quests-h"><h2 id="app-quests-h">Made by apps</h2>
@@ -1,10 +1,14 @@
@@ -1,10 +1,14 @@
'use strict';
'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 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({
const TOPICS = Object.freeze({
'live.stream.started': 'live', 'network.follow.created': 'heart',
'live.stream.started': 'live', 'network.follow.created': 'heart',
'community.thread.created': 'chat', 'community.post.created': 'reply',
'community.thread.created': 'chat', 'community.post.created': 'reply',
@@ -31,8 +35,12 @@ function glyph(name, size = 24, approvedSvg = null) {
@@ -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>`;
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`<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];
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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,52 @@
@@ -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); });
@@ -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: 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
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)
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
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
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)
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
externalFiles: 0, // 0
};
};
@@ -24,7 +24,8 @@ const { createGlyphDraftClient, nameForBadge, nameForDaily } = require('../serve
@@ -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'));
assert.ok(me.text.includes('/css/quest-visual.css') && me.text.includes('/js/quest-visual.js'));
const index = await t.get('/quests', { as: person });
const index = await t.get('/quests', { as: person });
assert.equal(index.status, 200);
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(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');
assert.ok(requests.some((x) => /^daily-\d{4}-\d{2}-\d{2}$/.test(x.name)), 'a unique daily drawing joins review');
});
});