openvibers/openvibe.quest Commits

Redesign quest log and badge collection (recovered from #233) (#251)

OP OpenVibers committed 4 days ago · a9342e7de3 · 28 files, +410 −43

* Redesign quest log and badge collection

* Quest log review: the ring holds its number, badges say their names, plain words

The today ring's 0/3 sat on its own label; an earned badge was an icon with no name; the page printed a raw API
path and a config flag's name. The ring wraps only its number, every badge shows its name under its medal on a grid
that fits whole words, the badge list is a link, and the OpenCoins note says rewards are not switched on yet.

Changes

ddiff --git a/.env.example b/.env.example
iindex c0fa03f..e1bb832 100644
---- a/.env.example
++++ b/.env.example
@@@ -19,6 +19,8 @@ VALKEY_PREFIX=ov:quest:
# OpenVibe.Network: SSO (OAuth client `quest`, PKCE S256) and its JWKS; app tokens for audience openvibe.quest.
OV_NETWORK_URL=https://openvibe.network
OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
++OV_DESIGN_INTERNAL_URL=http://127.0.0.1:5090
++# QUEST_DESIGN_DRAFTS=off
# Daily quest generation through OpenVibe.AI (ai.complete, with a hand-written fallback).
OV_AI_INTERNAL_URL=http://127.0.0.1:4700
OV_OAUTH_CLIENT_ID=quest
ddiff --git a/README.md b/README.md
iindex 07cbce8..2318ba8 100644
---- a/README.md
++++ b/README.md
@@@ -27,6 +27,8 @@ OpenVibe.Quest owns the quest log and the catalog that describes it.
The home page leads with one shared daily quest. A due-based job chooses three verified Events step types across at least two sites, asks OpenVibe.AI to favor recent network changes, and rotates a hand-written set when AI is unavailable. GET `/api/v1/daily` is public; GET `/api/v1/me/daily` returns the caller's private checklist and streak. PUT `/api/v1/me/daily/time-zone` stores an IANA time zone for local-day counting. Apps with `quest.steps.contribute` can POST `/api/v1/step-types`; staff must approve a proposal before the generator can use it.
++`/me` shows progress, a counted Done fold and earned and locked glyph medallions; `/quests` leads with today's quest before the site groups. Both pages render their words and links on the server. Daily quest titles and app badges request a missing glyph from OpenVibe.Design's service draft API with `design.glyph.draft`; pending drawings stay in staff review and the bundled glyph remains visible. Once approved, Quest uses Design's validated SVG image. Set `OV_DESIGN_INTERNAL_URL` for the internal Design service or `QUEST_DESIGN_DRAFTS=off` to disable requests during a rollout. Network must grant the Quest service principal `design.glyph.draft` before enabling draft requests.
++
- The **quest catalog** in code ([server/quests/catalog.js](server/quests/catalog.js)): 12 first-party quests, each
with its steps, the events that count them, its badge, its OpenCoins and any item reward, plus the list of events
Quest listens to. A quest may not count an event the catalog does not document; that fails at load.
ddiff --git a/docs/screenshots/quest-visual/badge-sheet-1280-dark.jpg b/docs/screenshots/quest-visual/badge-sheet-1280-dark.jpg
nnew file mode 100644
iindex 0000000..b8a9ee0
BBinary files /dev/null and b/docs/screenshots/quest-visual/badge-sheet-1280-dark.jpg differ
ddiff --git a/docs/screenshots/quest-visual/badge-sheet-390-light.jpg b/docs/screenshots/quest-visual/badge-sheet-390-light.jpg
nnew file mode 100644
iindex 0000000..0e87ea8
BBinary files /dev/null and b/docs/screenshots/quest-visual/badge-sheet-390-light.jpg differ
ddiff --git a/docs/screenshots/quest-visual/me-1280-dark.jpg b/docs/screenshots/quest-visual/me-1280-dark.jpg
nnew file mode 100644
iindex 0000000..f5cf9fd
BBinary files /dev/null and b/docs/screenshots/quest-visual/me-1280-dark.jpg differ
ddiff --git a/docs/screenshots/quest-visual/me-1280-light.jpg b/docs/screenshots/quest-visual/me-1280-light.jpg
nnew file mode 100644
iindex 0000000..4e0ab85
BBinary files /dev/null and b/docs/screenshots/quest-visual/me-1280-light.jpg differ
ddiff --git a/docs/screenshots/quest-visual/me-390-dark.jpg b/docs/screenshots/quest-visual/me-390-dark.jpg
nnew file mode 100644
iindex 0000000..cf4ea1e
BBinary files /dev/null and b/docs/screenshots/quest-visual/me-390-dark.jpg differ
ddiff --git a/docs/screenshots/quest-visual/me-390-light.jpg b/docs/screenshots/quest-visual/me-390-light.jpg
nnew file mode 100644
iindex 0000000..e25aafe
BBinary files /dev/null and b/docs/screenshots/quest-visual/me-390-light.jpg differ
ddiff --git a/docs/screenshots/quest-visual/quests-1280-dark.jpg b/docs/screenshots/quest-visual/quests-1280-dark.jpg
nnew file mode 100644
iindex 0000000..adae20a
BBinary files /dev/null and b/docs/screenshots/quest-visual/quests-1280-dark.jpg differ
ddiff --git a/docs/screenshots/quest-visual/quests-1280-light.jpg b/docs/screenshots/quest-visual/quests-1280-light.jpg
nnew file mode 100644
iindex 0000000..3578758
BBinary files /dev/null and b/docs/screenshots/quest-visual/quests-1280-light.jpg differ
ddiff --git a/docs/screenshots/quest-visual/quests-390-dark.jpg b/docs/screenshots/quest-visual/quests-390-dark.jpg
nnew file mode 100644
iindex 0000000..b41d3e3
BBinary files /dev/null and b/docs/screenshots/quest-visual/quests-390-dark.jpg differ
ddiff --git a/docs/screenshots/quest-visual/quests-390-light.jpg b/docs/screenshots/quest-visual/quests-390-light.jpg
nnew file mode 100644
iindex 0000000..15d70e3
BBinary files /dev/null and b/docs/screenshots/quest-visual/quests-390-light.jpg differ
ddiff --git a/public/css/app.css b/public/css/app.css
iindex b852840..98938c0 100644
---- a/public/css/app.css
++++ b/public/css/app.css
@@@ -198,6 +198,8 @@ p.lede { font-size: 1.05rem; color: var(--text-secondary, #96a7c2); max-width: 7
/* 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); }
++.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; }
ddiff --git a/public/css/quest-visual.css b/public/css/quest-visual.css
nnew file mode 100644
iindex 0000000..195aa16
---- /dev/null
++++ b/public/css/quest-visual.css
@@@ -0,0 +1,129 @@
++/* Quest log and index: local page styles, kept off the home page's first paint. */
++.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 { 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); }
++.q-log-stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
++.q-log-stats > div { min-width: 0; min-height: 106px; padding: 13px 8px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1px; text-align: center; border: 1px solid var(--q-line); border-radius: 18px; background: color-mix(in srgb, var(--bg-primary, #090f1c) 45%, transparent); }
++.q-log-stats strong { color: var(--text-primary, #f4f7ff); font-size: 1.52rem; font-variant-numeric: tabular-nums; line-height: 1.15; }
++.q-log-stats span:last-child { color: var(--text-secondary, #b5c2d7); font-size: .75rem; line-height: 1.2; }
++.q-flame { display: block; font-size: clamp(1.1rem, calc(1.1rem + var(--streak) * .025rem), 1.8rem); line-height: 1.05; filter: drop-shadow(0 3px 8px #f2873966); }
++.q-today-ring::before { content: ''; position: absolute; width: 60px; height: 60px; border-radius: 50%; background: conic-gradient(var(--accent, #748bff) calc(var(--progress) * 33.333%), var(--q-line) 0); mask: radial-gradient(circle, transparent 58%, #000 61%); opacity: .9; }
++.q-today-ring { position: relative; }
++.q-today-ring strong { margin-top: 10px; }
++.q-today-ring small { font-size: .75rem; color: var(--text-secondary, #b5c2d7); }
++.q-week-coins .ov-icon { color: var(--warning, #f4b45b); }
++.q-log-columns { display: grid; gap: 26px; align-items: start; }
++.q-log-work, .q-log-badges { min-width: 0; }
++.q-section-head { display: flex; align-items: end; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
++.q-section-head h2 { margin: 0; font-size: 1.38rem; letter-spacing: -.025em; }
++.q-section-head > span { flex: none; padding: 4px 9px; border: 1px solid var(--q-line); border-radius: 100px; color: var(--text-secondary, #afbdd2); font-size: .75rem; }
++.q-progress-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; }
++.q-progress-card { padding: 17px; border: 1px solid var(--q-line); border-radius: 21px; background: var(--bg-card, #141c2d); box-shadow: 0 14px 30px -28px #000b; transition: transform .22s ease, border-color .22s ease, box-shadow .22s ease; }
++.q-progress-card:hover { transform: translateY(-3px); border-color: var(--accent, #7890ff); box-shadow: 0 19px 36px -28px var(--accent, #7890ff); }
++.q-progress-head { display: flex; align-items: center; gap: 11px; }
++.q-progress-head > div { flex: 1; min-width: 0; }
++.q-progress-head .quest-state { align-self: flex-start; }
++.q-quest-tile { flex: none; display: grid; place-items: center; width: 53px; height: 53px; border-radius: 16px; color: hsl(var(--h, 220) 85% 78%); background: linear-gradient(145deg, hsl(var(--h, 220) 75% 53% / .28), hsl(var(--h, 220) 55% 25% / .15)); border: 1px solid hsl(var(--h, 220) 75% 60% / .35); }
++.q-progress-title { display: flex; align-items: center; min-height: 44px; width: fit-content; max-width: 100%; color: var(--text-primary, #f4f7ff); font-weight: 750; font-size: 1.04rem; line-height: 1.3; text-decoration: none; }
++.q-progress-title:hover { color: var(--accent-light, #a4bbff); }
++.q-site-chip { display: inline-flex; align-items: center; gap: 4px; width: fit-content; max-width: 100%; padding: 2px 8px; border-radius: 100px; background: color-mix(in srgb, var(--accent, #678cff) 13%, transparent); color: var(--text-secondary, #bfcbdd); font-size: .75rem; font-weight: 650; vertical-align: middle; }
++.q-progress-head .q-site-chip { margin-top: 5px; }
++.q-progress-card > p { margin: 11px 0; color: var(--text-secondary, #b4c1d5); font-size: .88rem; }
++.q-progress-steps { display: grid; gap: 7px; list-style: none; margin: 0; padding: 0; }
++.q-progress-steps li { display: flex; align-items: center; gap: 8px; color: var(--text-secondary, #c2cce0); font-size: .82rem; }
++.q-progress-steps li > span:nth-child(2) { flex: 1; min-width: 0; line-height: 1.25; }
++.q-progress-steps b { flex: none; color: var(--accent-light, #a4bbff); font-variant-numeric: tabular-nums; }
++.q-progress-steps progress { display: block; width: 100%; height: 4px; margin-top: 5px; accent-color: var(--accent, #788fff); }
++.q-progress-reward { display: flex; gap: 8px; align-items: center; margin-top: 13px; padding-top: 12px; border-top: 1px solid var(--q-line); color: var(--text-secondary, #b5c2d7); font-size: .77rem; }
++.q-progress-reward .medal { width: 27px; height: 27px; }
++.q-progress-reward .medal .ov-icon { --ovi-size: 16px !important; }
++.q-progress-reward strong { color: var(--text-primary, #f4f7ff); }
++.q-empty { display: flex; align-items: center; gap: 14px; border: 1px dashed var(--q-line); border-radius: 20px; padding: 17px; }
++.q-empty p { margin: 0; }
++.q-completed-fold, .q-locked-fold { margin-top: 18px; border: 1px solid var(--q-line); border-radius: 17px; background: var(--bg-card, #141c2d); }
++.q-completed-fold > summary, .q-locked-fold > summary { display: flex; align-items: center; gap: 8px; min-height: 48px; padding: 10px 15px; cursor: pointer; font-weight: 700; }
++.q-completed-fold > summary strong { padding: 1px 8px; border-radius: 100px; background: color-mix(in srgb, var(--accent, #678cff) 18%, transparent); }
++.q-completed-fold > summary small { margin-left: auto; color: var(--text-muted, #9aabc4); font-weight: 500; }
++.q-completed-fold .quest-list { padding: 0 12px 12px; grid-template-columns: 1fr; }
++.q-completed-fold > p { padding: 0 15px 12px; }
++.q-more-quests a { display: inline-flex; align-items: center; gap: 7px; min-height: 44px; font-weight: 700; text-decoration: none; }
++.q-log-badges > p { margin: -4px 0 14px; font-size: .88rem; color: var(--text-secondary, #b7c3d6); }
++.q-badge-grid { list-style: none; display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 8px; margin: 0; padding: 0; }
++.q-badge { position: relative; }
++.q-badge > summary { display: grid; place-items: center; min-height: 56px; cursor: pointer; list-style: none; }
++.q-badge > summary::-webkit-details-marker { display: none; }
++.q-badge-medal { display: grid; place-items: center; width: 48px; height: 48px; border-radius: 50%; color: hsl(var(--h) 86% 83%); background: radial-gradient(circle at 36% 26%, hsl(var(--h) 72% 45% / .43), hsl(var(--h) 50% 18% / .5) 67%); box-shadow: inset 0 0 0 2px hsl(var(--h) 72% 65% / .82), inset 0 0 0 5px hsl(var(--h) 72% 65% / .17), 0 8px 18px -10px hsl(var(--h) 72% 50% / .8); transition: transform .22s ease, box-shadow .22s ease; }
++.q-badge:hover .q-badge-medal, .q-badge:focus-within .q-badge-medal { transform: translateY(-4px) scale(1.06); box-shadow: inset 0 0 0 2px hsl(var(--h) 72% 65%), 0 0 20px hsl(var(--h) 78% 58% / .28); }
++.q-badge.is-locked .q-badge-medal { color: var(--text-muted, #9aabc4); background: transparent; box-shadow: inset 0 0 0 1px var(--q-line); opacity: .6; }
++.q-badge-tip { display: none; position: absolute; bottom: 100%; left: 50%; z-index: 5; transform: translateX(-50%); min-width: 125px; padding: 7px 9px; border: 1px solid var(--q-line); border-radius: 9px; background: var(--bg-secondary, #0b1323); box-shadow: 0 8px 20px #0005; text-align: center; pointer-events: none; }
++.q-badge-tip strong, .q-badge-tip small { display: block; line-height: 1.2; }
++.q-badge-tip strong { font-size: .75rem; }
++.q-badge-tip small { color: var(--text-secondary, #b7c3d6); font-size: .75rem; margin-top: 3px; }
++.q-badge:hover:not([open]) .q-badge-tip { display: block; }
++.q-badge[open]::before { content: ''; position: fixed; inset: 0; z-index: 9998; background: #050a14ad; backdrop-filter: blur(5px); }
++.q-badge-sheet { position: fixed; left: 12px; right: 12px; bottom: 12px; z-index: 9999; max-width: 420px; max-height: calc(100dvh - 24px); overflow: auto; margin: 0 auto; padding: 22px; border: 1px solid var(--q-line); border-radius: 24px; background: var(--bg-card, #141c2d); box-shadow: 0 24px 90px #0009; animation: q-sheet-in .22s ease both; }
++.q-badge-sheet .q-badge-medal--large { width: 72px; height: 72px; margin: 10px 0; }
++.q-badge-sheet h3 { margin: 6px 0; font-size: 1.32rem; }
++.q-badge-sheet p { color: var(--text-secondary, #b7c3d6); margin: 6px 0; }
++.q-badge-steps { margin: 7px 0 10px; padding-left: 18px; color: var(--text-secondary, #b7c3d6); font-size: .83rem; }
++.q-badge-sheet .q-eyebrow { color: var(--accent-light, #a4bbff); }
++.q-badge-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 20px; }
++.q-badge-actions a, .q-badge-actions button { display: inline-flex; align-items: center; justify-content: center; min-height: 44px; padding: 8px 14px; border: 1px solid var(--q-line); border-radius: 11px; background: color-mix(in srgb, var(--accent, #678cff) 14%, var(--bg-card, #141c2d)); color: var(--text-primary, #fff); font: inherit; font-size: .85rem; font-weight: 700; text-decoration: none; cursor: pointer; }
++.q-badge-actions a { border-color: var(--accent, #678cff); }
++.q-locked-fold { padding: 0 8px 10px; }
++.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; }
++.quest-list .quest-steps li { padding-left: 0; }
++.quest-list .quest-steps li::before { display: none; }
++.quest-list .quest-steps li > .ov-icon { flex: none; color: var(--accent-light, #a4bbff); }
++@keyframes q-sheet-in { from { opacity: 0; transform: translateY(15px); } to { opacity: 1; transform: translateY(0); } }
++@keyframes q-badge-reveal { 0% { transform: scale(.65) rotate(-12deg); opacity: .2; } 65% { transform: scale(1.15) rotate(3deg); opacity: 1; } 100% { transform: scale(1) rotate(0); } }
++@keyframes q-ring-draw { from { box-shadow: inset 0 0 0 0 hsl(var(--h) 72% 65%); } to { box-shadow: inset 0 0 0 2px hsl(var(--h) 72% 65%), 0 0 20px hsl(var(--h) 78% 58% / .28); } }
++@keyframes q-badge-fade { from { opacity: .25; } to { opacity: 1; } }
++.q-badge.is-new .q-badge-medal { animation: q-badge-reveal .55s cubic-bezier(.2,.75,.2,1) both, q-ring-draw .55s ease both; }
++@media (min-width: 880px) {
++ .q-log-hero { grid-template-columns: minmax(0, 1fr) minmax(360px, 46%); align-items: center; }
++ .q-log-columns { grid-template-columns: minmax(0, 1.06fr) minmax(375px, .94fr); }
++ .q-badge-grid { grid-template-columns: repeat(8, minmax(0, 1fr)); gap: 5px; }
++ .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) {
++ .q-progress-card, .q-badge-medal { transition: none; }
++ .q-badge-sheet { animation: none; }
++ .q-badge.is-new .q-badge-medal { animation: q-badge-fade .3s ease both; }
++}
++
++/* Review, 2026-11-09 (Opus): the ring holds only its number, a badge always says its name, and words stay plain. */
++.q-today-ring::before { top: 12px; }
++.q-today-ring strong { display: grid; place-items: center; width: 60px; height: 60px; margin: 0 0 4px; font-size: 1.25rem; }
++.q-today-ring strong small { display: inline; }
++.q-badge > summary { grid-template-rows: auto auto; gap: 6px; align-content: start; padding: 4px 2px; }
++.q-badge-name { max-width: 100%; color: var(--text-secondary, #b5c2d7); font-size: .72rem; line-height: 1.2; text-align: center; overflow-wrap: anywhere; }
++.q-badge.is-locked .q-badge-name { color: var(--text-muted, #8b98ad); }
++.q-today-ring strong { display: flex; align-items: baseline; justify-content: center; padding-top: 18px; box-sizing: border-box; }
++.q-badge-grid { grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); gap: 10px 6px; }
++.q-badge-name { overflow-wrap: break-word; }
ddiff --git a/public/js/quest-visual.js b/public/js/quest-visual.js
nnew file mode 100644
iindex 0000000..7570478
---- /dev/null
++++ b/public/js/quest-visual.js
@@@ -0,0 +1,37 @@
++'use strict';
++
++(() => {
++ const badges = [...document.querySelectorAll('.q-badge')];
++ const newest = badges.find((el) => el.dataset.awarded);
++ if (newest) {
++ const key = `quest-badge-seen:${newest.dataset.badgeId}:${newest.dataset.awarded}`;
++ try {
++ if (!sessionStorage.getItem(key)) {
++ newest.classList.add('is-new');
++ sessionStorage.setItem(key, '1');
++ }
++ } catch { /* animation is optional */ }
++ }
++ document.addEventListener('click', async (event) => {
++ const close = event.target.closest('.q-badge-close');
++ if (close) { close.closest('details').open = false; return; }
++ const share = event.target.closest('.q-share');
++ if (!share) return;
++ const url = new URL(share.dataset.share, location.origin).href;
++ const title = share.dataset.title;
++ try {
++ if (navigator.share) await navigator.share({ title, url });
++ else if (navigator.clipboard) {
++ await navigator.clipboard.writeText(url);
++ share.textContent = 'Link copied';
++ }
++ } catch { /* The View quest link remains available. */ }
++ });
++ document.addEventListener('toggle', (event) => {
++ if (!event.target.matches?.('.q-badge') || !event.target.open) return;
++ for (const badge of badges) if (badge !== event.target) badge.open = false;
++ }, true);
++ document.addEventListener('keydown', (event) => {
++ if (event.key === 'Escape') for (const badge of badges) badge.open = false;
++ });
++})();
ddiff --git a/server/app.js b/server/app.js
iindex 09a4529..cc4c4e1 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -32,6 +32,7 @@ const { createPageRoutes } = require('./http/pages');
const { createCoins } = require('./quests/coins');
const { createItems } = require('./quests/items');
const { createDaily } = require('./quests/daily');
++const { createGlyphDraftClient } = require('./quests/glyph-drafts');
const { createEventsConsumer } = require('./events-consumer');
const accountDataLib = require('./identity/account-data');
const { createNetworkSender } = require('openvibe-sdk/account-data');
@@@ -70,12 +71,13 @@ async function createApp(opts = {}) {
? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Quest cannot answer account events'); });
// App quests (server/quests/app-quests.js): quests developer apps define on their own events.
-- const appQuests = require('./quests/app-quests').createAppQuests({ s, log });
-- const daily = createDaily({ s, config, coins, fetchImpl, aiGenerate: opts.aiGenerate, log });
++ const glyphs = opts.glyphs || createGlyphDraftClient({ config, fetchImpl, log, now: s.now });
++ const appQuests = require('./quests/app-quests').createAppQuests({ s, glyphs, log });
++ const daily = createDaily({ s, config, coins, glyphs, fetchImpl, aiGenerate: opts.aiGenerate, log });
const eventsConsumer = createEventsConsumer({ db: s.db, s, secrets: config.events.secrets, coins, items, accountData, accountSend, appQuests, daily, now: s.now, log });
// The quests in OpenVibe.Search (./search-index.js), swept on its own timer once server/index.js starts it.
const searchIndex = opts.searchIndex || createSearchIndex({ config, s, log });
-- const ctx = { config, s, keys, sso, principal, coins, items, daily, eventsConsumer, accountData, accountSend, searchIndex, appQuests, log };
++ const ctx = { config, s, keys, sso, principal, coins, items, daily, glyphs, eventsConsumer, accountData, accountSend, searchIndex, appQuests, log };
const app = express();
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
ddiff --git a/server/config.js b/server/config.js
iindex 16c84a7..0b02dd0 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -41,6 +41,8 @@ function load(env = process.env) {
// OpenVibe.Network: SSO (OAuth2 authorization server with PKCE) and its JWKS.
networkUrl,
networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
++ designInternalUrl: trim(env.OV_DESIGN_INTERNAL_URL || 'http://127.0.0.1:5090'),
++ designDrafts: isProduction && env.QUEST_DESIGN_DRAFTS !== 'off',
networkIssuer: trim(env.OV_NETWORK_ISSUER || networkUrl),
// The audience this service's app, agent and service tokens carry.
audience: env.QUEST_AUDIENCE || 'openvibe.quest',
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 1b973ef..fb361ea 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -26,6 +26,8 @@ const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
const { html, raw, table } = require('../render/html');
const { send } = require('../render/layout');
const quests = require('../render/quests');
++const icons = require('../render/quest-icons');
++const { nameForBadge, nameForDaily } = require('../quests/glyph-drafts');
const S = require('../render/short');
const catalog = require('../quests/catalog');
const store = require('../quests/store');
@@@ -141,12 +143,12 @@ function createPageRoutes(ctx) {
}
r.get('/', async (req, res) => {
-- const [appList, p] = await Promise.all([ctx.appQuests ? ctx.appQuests.listed(12) : [], pulse()]);
++ 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"><p class="q-daily-kicker">TODAY'S QUEST · ${daily.date}</p>
++ 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><span class="ov-icon" data-icon="${step.site.split('.')[1] || 'quest'}" data-size="32" aria-hidden="true"></span><span>${step.done ? '✓ ' : ''}<a href="${step.href}">${step.sentence}</a><small>${step.site}</small></span></li>`)}</ol>
++<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>`;
@@@ -244,11 +246,12 @@ ${raw(showcase.cta({
const active = kit.tabOf(req.query, keys, 'site');
const shown = active === 'all' ? groups : groups.filter((g) => siteKey(g.site) === active);
const mine = signedIn(req) && SUBJECT_RE.test(req.viewer.subject);
-- const [appList, p, rows, completions] = await Promise.all([
++ const [appList, p, rows, completions, approvedGlyphs] = await Promise.all([
active === 'all' && ctx.appQuests ? ctx.appQuests.listed(100) : [],
pulse(),
mine ? store.progressFor(s.db, req.viewer.subject) : [],
mine ? store.completionsFor(s.db, req.viewer.subject) : [],
++ ctx.glyphs.approved(),
]);
const completed = new Map(completions.map((c) => [c.quest_id, c.completed_at]));
const seen = new Map();
@@@ -260,6 +263,7 @@ ${raw(showcase.cta({
const count = (g) => g.quests.length + (g.series || []).reduce((n, x) => n + x.quests.length, 0);
const total = catalog.all().length;
const one = active === 'all' ? null : shown[0];
++ const daily = signedIn(req) ? await ctx.daily.progress(req.viewer.subject) : await ctx.daily.publicQuest();
page(req, res, {
index: true, cache: signedIn(req) ? null : PUBLIC_CACHE,
title: one ? `Quests on ${one.name}` : 'All quests',
@@@ -268,8 +272,10 @@ ${raw(showcase.cta({
: `All ${total} OpenVibe quests on ${siteCount} sites, grouped by site: what each one counts, the badge it pays and how many people hold it.`,
crumbs: [{ label: 'Quests', href: '/' }, { label: one ? one.name : 'All quests' }],
styles: ['kit.css'],
++ 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>` : ''}
${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>
@@@ -428,11 +434,18 @@ each quest still needs, and the badges you have earned.</p>
});
const subject = req.viewer.subject;
-- const [progress, completions, badges, creditedToday] = await Promise.all([
++ const week = new Date(s.now());
++ week.setUTCDate(week.getUTCDate() - (week.getUTCDay() + 6) % 7);
++ week.setUTCHours(0, 0, 0, 0);
++ const weekStart = week.toISOString();
++ const [progress, completions, badges, creditedToday, creditedWeek, daily, approvedGlyphs] = await Promise.all([
store.progressFor(s.db, subject),
store.completionsFor(s.db, subject),
store.badgesFor(s.db, subject),
store.creditedTo(s.db, subject, store.dayOf(s.now())),
++ s.db.value("SELECT COALESCE(SUM(coins),0) FROM quest_completions WHERE subject=$1 AND coins_state='credited' AND coins_at >= $2", [subject, weekStart]),
++ ctx.daily.progress(subject),
++ ctx.glyphs.approved(),
]);
const seen = new Map();
for (const row of progress) {
@@@ -445,19 +458,30 @@ each quest still needs, and the badges you have earned.</p>
const started = catalog.all().filter((q) => questState(q) === 'started');
const finished = catalog.all().filter((q) => questState(q) === 'done');
const badge = (id) => catalog.badgeById(id) || { id, name: (ctx.appQuests && ctx.appQuests.badgeName(id)) || id };
++ const earnedIds = new Set(badges.map((b) => b.badge_id));
++ const locked = catalog.all().filter((q) => !earnedIds.has(q.reward.badge.id));
++ const appBadgeQuests = new Map((await Promise.all(badges.filter((b) => !catalog.byId(b.quest_id) && !String(b.quest_id).startsWith('daily-'))
++ .map(async (b) => [b.quest_id, await ctx.appQuests.byId(b.quest_id)]))).filter(([, q]) => q));
++ const badgeTile = (b, isLocked = false) => {
++ const id = isLocked ? b.reward.badge.id : b.badge_id;
++ const info = badge(id);
++ const q = isLocked ? b : catalog.byId(info.quest_id || b.quest_id) || appBadgeQuests.get(b.quest_id);
++ const title = info.name;
++ const date = isLocked ? 'Not earned yet' : String(b.awarded_at).slice(0, 10);
++ const href = q ? (String(q.id).startsWith('app.') ? `/apps/quests/${encodeURIComponent(q.id)}` : `/quests/${q.id}`) : b.quest_id?.startsWith('daily-') ? '/' : `/apps/quests/${encodeURIComponent(b.quest_id || '')}`;
++ const why = q ? q.why : isLocked ? 'Complete this quest to earn the badge.' : 'Earned by completing a quest across OpenVibe.';
++ const approved = isLocked ? null : approvedGlyphs.get(nameForBadge(id));
++ return html`<li><details class="q-badge${isLocked ? ' is-locked' : ''}" data-badge-id="${id}" data-awarded="${isLocked ? '' : date}"><summary aria-label="${title}, ${date}"><span class="q-badge-medal" style="--h:${quests.hueOf(id)}">${icons.glyph(icons.badge(id, q), 26, approved)}</span><span class="q-badge-name">${title}</span><span class="q-badge-tip"><strong>${title}</strong><small>${date}</small></span></summary><div class="q-badge-sheet"><p class="q-eyebrow">${isLocked ? 'STILL TO EARN' : 'BADGE EARNED'}</p><span class="q-badge-medal q-badge-medal--large" style="--h:${quests.hueOf(id)}">${icons.glyph(icons.badge(id, q), 36, approved)}</span><h3>${title}</h3><p>${why}</p>${q ? html`<p>From <strong>${q.title}</strong></p><ul class="q-badge-steps">${q.steps.map((step) => html`<li>${step.label}</li>`)}</ul>` : ''}<p class="q-badge-date">${isLocked ? 'Complete the quest to unlock it.' : html`Earned <time datetime="${b.awarded_at}">${date}</time>`}</p><div class="q-badge-actions"><a href="${href}">View quest</a>${isLocked ? '' : html`<button type="button" class="q-share" data-share="${href}" data-title="${title}">Share</button>`}<button type="button" class="q-badge-close">Close</button></div></div></details></li>`;
++ };
return page(req, res, {
title: 'Your quest log',
crumbs: [{ label: 'Quests', href: '/' }, { label: 'Your quest log' }],
-- body: html`<h1>Your quest log</h1>
--<p class="muted">Signed in as <strong>${req.viewer.displayName || req.viewer.username || 'you'}</strong>. One log across every OpenVibe site.</p>
--${yourStuff(req)}
--<h2>In progress</h2>
--${started.length ? started.map((q) => html`<section class="quest-block"><h3><a href="/quests/${q.id}">${q.title}</a> <small>on ${siteName(q.site)}</small> ${quests.stateChip(false)}</h3>
--${q.steps.map((step, i) => quests.progress(step.label, (seen.get(q.id) || new Map()).get(i) || 0, step.count, quests.unitOf(step)))}
--<p class="quest-reward">${quests.reward(q, coinsUi)}</p></section>`) : html`<p class="muted">Nothing started yet. <a href="/quests">Pick a quest</a> — any of the sites you already use counts.</p>`}
--<h2>Completed</h2>
--${finished.length ? html`<ul class="quest-list">${finished.map((q) => {
++ scripts: ['js/quest-visual.js'], localStyles: ['css/quest-visual.css'],
++ body: html`<div class="q-log"><header class="q-log-hero"><div><p class="q-eyebrow">YOUR JOURNEY ACROSS OPENVIBE</p><h1>Your quest log</h1><p>Signed in as <strong>${req.viewer.displayName || req.viewer.username || 'you'}</strong>. One log across every OpenVibe site, showing what you have done and what each quest still needs.</p></div><div class="q-log-stats"><div class="q-streak"><span class="q-flame" style="--streak:${Math.min(daily.streak || 0, 30)}" aria-hidden="true">🔥</span><strong>${daily.streak || 0}</strong><span>day streak</span></div><div class="q-today-ring" style="--progress:${daily.progress || 0}"><strong>${daily.progress || 0}<small>/3</small></strong><span>today's quest</span></div><div class="q-week-coins">${icons.glyph('coin', 24)}<strong>${Number(creditedWeek) || 0}</strong><span>OpenCoins this week</span></div></div></header>
++<div class="q-log-columns"><section class="q-log-work" aria-labelledby="q-in-progress"><div class="q-section-head"><div><p class="q-eyebrow">KEEP GOING</p><h2 id="q-in-progress">In progress</h2></div><span>${started.length} active</span></div>
++${started.length ? html`<ul class="q-progress-list">${started.map((q) => html`<li class="q-progress-card"><div class="q-progress-head"><span class="q-quest-tile" style="--h:${quests.hueOf(q.id)}">${icons.glyph(icons.quest(q), 30)}</span><div><a href="/quests/${q.id}" class="q-progress-title">${q.title}</a><span class="q-site-chip">${icons.glyph(icons.site(q.site), 16)}${siteName(q.site)}</span></div>${quests.stateChip(false)}</div><p>${q.why}</p><ul class="q-progress-steps">${q.steps.map((step, i) => { const value = Math.min((seen.get(q.id) || new Map()).get(i) || 0, step.count); return html`<li>${icons.glyph(icons.topic(step.event_type, q.site), 18)}<span>${step.label}<progress max="${step.count}" value="${value}" role="progressbar" aria-label="${step.label}" aria-valuemin="0" aria-valuemax="${step.count}" aria-valuenow="${value}" aria-valuetext="${value}/${step.count}${quests.unitOf(step) ? ` ${quests.unitOf(step)}` : ''}">${value}/${step.count}${quests.unitOf(step) ? ` ${quests.unitOf(step)}` : ''}</progress></span><b>${value >= step.count ? '✓' : `${value}/${step.count}${quests.unitOf(step) ? ` ${quests.unitOf(step)}` : ''}`}</b></li>`; })}</ul><div class="q-progress-reward">${quests.medal(q)}<span>Earn the <strong>${q.reward.badge.name}</strong> badge${q.reward.coins > 0 ? html` · ${q.reward.coins} OpenCoins${coinsUi.coinsEnabled ? '' : ' when enabled'}` : ''}</span></div></li>`)}</ul>` : html`<div class="q-empty"><span class="q-quest-tile">${icons.glyph('spark', 30)}</span><p>Nothing started yet. <a href="/quests">Pick a quest</a> — any of the sites you already use counts.</p></div>`}
++<details class="q-completed-fold"><summary><span>Done</span><strong>${finished.length}</strong><small>completed quests</small></summary>${finished.length ? html`<ul class="quest-list">${finished.map((q) => {
const c = done.get(q.id);
const item = q.reward.item ? (c.item_state === 'granted' ? html`${q.reward.item.emoji} <strong>${q.reward.item.name}</strong> is in <a href="https://inventory.openvibe.network/me">your inventory</a>. ` : c.item_state === 'failed' ? html`${q.reward.item.name} could not be given yet — this is recorded and retried. ` : html`${q.reward.item.name} is on its way to your inventory. `) : '';
const coins = c.coins > 0 ? (c.coins_state === 'credited' ? html`<strong>${c.coins}</strong> OpenCoins credited.` : c.coins_state === 'skipped' ? html`${c.coins} OpenCoins ${coinsUi.coinsEnabled ? html`were not credited: ${c.coins_error || 'the daily cap was reached'}` : html`start soon`}.` : html`${c.coins} OpenCoins could not be credited yet — this is recorded and retried.`) : '';
@@@ -468,16 +492,13 @@ ${finished.length ? html`<ul class="quest-list">${finished.map((q) => {
<p class="quest-earned">Completed <time datetime="${c.completed_at}">${String(c.completed_at).slice(0, 10)}</time> · Badge: <strong>${q.reward.badge.name}</strong></p>
${item || coins ? html`<p class="quest-why">${item}${coins}</p>` : ''}
</div></li>`;
-- })}</ul>` : html`<p class="muted">No quests completed yet.</p>`}
--<h2>Your badges</h2>
--${badges.length ? html`<ul class="badges">${badges.map((b) => html`<li><strong>${badge(b.badge_id).name}</strong> <small>${String(b.awarded_at).slice(0, 10)}</small></li>`)}</ul>
--<p class="muted">Anyone can see these three facts at <code>/api/v1/profiles/${subject}/badges</code>: the badge's id, its name and the date. Nothing else about you is ever in that answer.</p>` : html`<p class="muted">No badges yet.</p>`}
--${quests.coinsNote(coinsUi)}
--${coinsUi.coinsEnabled ? html`<p class="muted">Credited today: <strong>${creditedToday}</strong> of ${coinsUi.dailyCap} OpenCoins.</p>` : ''}
--<h2>Not started</h2>
--${catalog.all().filter((q) => questState(q) === 'new').length
-- ? html`<p>${catalog.all().filter((q) => questState(q) === 'new').map((q, i) => html`${i ? ', ' : ''}<a href="/quests/${q.id}">${q.title}</a>`)}</p>`
-- : html`<p class="muted">Every quest is started or done.</p>`}`,
++ })}</ul>` : html`<p class="muted">No quests completed yet.</p>`}</details>
++<p class="q-more-quests"><a href="/quests">Explore quests you have not started ${icons.glyph('arrow-right', 16)}</a></p>
++</section><aside class="q-log-badges" aria-labelledby="q-your-badges"><div class="q-section-head"><div><p class="q-eyebrow">THE COLLECTION</p><h2 id="q-your-badges">Your badges</h2></div><span>${badges.length} earned</span></div><p>Each badge marks a quest you finished. Tap one for its story and a link to share it.</p>
++${badges.length ? html`<ul class="q-badge-grid">${badges.map((b) => badgeTile(b))}</ul>` : html`<p class="muted">No badges yet. Finish a quest to earn your first.</p>`}
++<details class="q-locked-fold"><summary>${locked.length} badges still to earn</summary><ul class="q-badge-grid">${locked.map((q) => badgeTile(q, true))}</ul></details>
++<p class="q-badge-privacy">Anyone can see which badges you earned and when (<a href="/api/v1/profiles/${subject}/badges">your public badge list</a>). Your progress stays private.</p></aside></div>
++<div class="q-log-foot">${quests.coinsNote(coinsUi)}${coinsUi.coinsEnabled ? html`<p class="muted">Credited today: <strong>${creditedToday}</strong> of ${coinsUi.dailyCap} OpenCoins.</p>` : ''}</div>${yourStuff(req)}</div>`,
});
});
ddiff --git a/server/quests/app-quests.js b/server/quests/app-quests.js
iindex 0627b9f..79d241e 100644
---- a/server/quests/app-quests.js
++++ b/server/quests/app-quests.js
@@@ -38,7 +38,7 @@ function asQuest(row) {
};
}
--function createAppQuests({ s, log = console, subscribe: initialSubscribe = null }) {
++function createAppQuests({ s, glyphs, log = console, subscribe: initialSubscribe = null }) {
const badgeNames = new Map();
let subscribe = initialSubscribe;
const iso = () => new Date(s.now()).toISOString();
@@@ -84,6 +84,9 @@ function createAppQuests({ s, log = console, subscribe: initialSubscribe = null
[id, projectId, key, principal.requester, env, slug, body.title, body.why || null, JSON.stringify(body.steps), body.badge.name, body.url || null, status, at]);
badgeNames.set(id, body.badge.name);
if (env === 'production' && status === 'active') await ensureSubscribed(key);
++ if (env === 'production' && status === 'active' && glyphs) {
++ void glyphs.request(glyphs.nameForBadge(id), `${body.badge.name}: ${body.title}. ${body.why || 'A quest made by an OpenVibe app.'}`);
++ }
return { status: existed ? 200 : 201, row: await s.db.maybe('SELECT * FROM app_quests WHERE id = $1', [id]) };
},
ddiff --git a/server/quests/daily.js b/server/quests/daily.js
iindex f8a7f6d..ce2d150 100644
---- a/server/quests/daily.js
++++ b/server/quests/daily.js
@@@ -66,7 +66,7 @@ function wireType(t) {
status: t.status, source: t.source || t.project_id };
}
--function createDaily({ s, config, coins, fetchImpl = globalThis.fetch, aiGenerate, log = console }) {
++function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, aiGenerate, log = console }) {
const db = s.db;
const token = config.oauth.clientSecret ? serviceAuth.createTokenClient({
tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId,
@@@ -127,7 +127,10 @@ function createDaily({ s, config, coins, fetchImpl = globalThis.fetch, aiGenerat
}
const pending = new Map();
async function ensure(day = utcDay(s.now())) {
-- if (!pending.has(day)) pending.set(day, create(day).finally(() => pending.delete(day)));
++ if (!pending.has(day)) pending.set(day, create(day).then((q) => {
++ if (q && glyphs) void glyphs.request(glyphs.nameForDaily(day), `${q.title}. ${q.story}`);
++ return q;
++ }).finally(() => pending.delete(day)));
return pending.get(day);
}
async function publicQuest(day = utcDay(s.now())) {
ddiff --git a/server/quests/glyph-drafts.js b/server/quests/glyph-drafts.js
nnew file mode 100644
iindex 0000000..ca8685d
---- /dev/null
++++ b/server/quests/glyph-drafts.js
@@@ -0,0 +1,56 @@
++'use strict';
++
++/** Reviewed Design glyphs; pending candidates never leave Design. Every page has a bundled glyph fallback. */
++const crypto = require('node:crypto');
++const { serviceAuth } = require('openvibe-contracts');
++
++const nameForBadge = (id) => `badge-${crypto.createHash('sha256').update(String(id)).digest('hex').slice(0, 18)}`;
++const nameForDaily = (day) => `daily-${day}`;
++function createGlyphDraftClient({ config, fetchImpl = globalThis.fetch, log = console, now = Date.now }) {
++ const base = String(config.designInternalUrl || 'http://127.0.0.1:5090').replace(/\/+$/, '');
++ const tokens = config.oauth.clientSecret ? serviceAuth.createTokenClient({
++ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId,
++ clientSecret: config.oauth.clientSecret, audience: 'openvibe.design',
++ scope: 'design.glyph.draft', fetchImpl,
++ }) : null;
++ let last = 0;
++ let approvedCache = new Map();
++ let pending = null;
++ const requested = new Set();
++ async function request(name, sentence) {
++ if (!config.designDrafts || !tokens || !/^[a-z][a-z0-9-]{1,31}$/.test(name) || requested.has(name)) return false;
++ requested.add(name);
++ try {
++ const res = await fetchImpl(`${base}/api/v1/glyphs/drafts`, {
++ method: 'POST', headers: { ...(await tokens.authHeaders()), 'Content-Type': 'application/json' },
++ body: JSON.stringify({ name, sentence: String(sentence || '').slice(0, 200) }),
++ signal: AbortSignal.timeout(30000),
++ });
++ if (!res.ok) { requested.delete(name); log.warn(`[Quest glyphs] Design draft request failed (${res.status})`); return false; }
++ return true;
++ } catch { requested.delete(name); log.warn('[Quest glyphs] Design draft request unavailable'); return false; }
++ }
++ async function approved() {
++ if (!config.designDrafts) return approvedCache;
++ if (now() - last < 300000) return approvedCache;
++ if (pending) return pending;
++ pending = (async () => {
++ try {
++ const res = await fetchImpl(`${base}/api/v1/glyphs/drafts?status=approved`, { signal: AbortSignal.timeout(2500) });
++ if (res.ok) {
++ const body = await res.json();
++ const rows = Array.isArray(body.drafts) ? body.drafts : [];
++ const next = new Map();
++ for (const row of rows) if (row.status === 'approved' && /^[a-z][a-z0-9-]{1,31}$/.test(row.name) && typeof row.svg === 'string' && row.svg.length <= 4000 && !next.has(row.name)) next.set(row.name, row.svg);
++ approvedCache = next;
++ }
++ } catch { /* bundled glyphs stay visible */ }
++ last = now();
++ pending = null;
++ return approvedCache;
++ })();
++ return pending;
++ }
++ return { request, approved, nameForBadge, nameForDaily };
++}
++module.exports = { createGlyphDraftClient, nameForBadge, nameForDaily };
ddiff --git a/server/render/badge-art.js b/server/render/badge-art.js
iindex 53b71cf..65fe27f 100644
---- a/server/render/badge-art.js
++++ b/server/render/badge-art.js
@@@ -1,12 +1,13 @@
'use strict';
/**
-- * A badge's picture: its medal (the initials on a ring of its colour, the same as render/quests.js medal()) on a wash of
++ * A badge's picture: its medal (a shared glyph when one fits, otherwise initials) on a wash of
* that colour, with the badge's name under it. A 16:9 SVG, so a quest is a picture in the shared kit's cards
* (openvibe-shared kit.card) like a stream or a clip is. Served at /quests/:id/badge.svg, /series/:id/badge.svg and
* /apps/quests/:id/badge.svg (http/pages.js); the text is the catalog's or an app's, escaped here.
*/
const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
++const glyphs = require('openvibe-shared/glyphs');
/** Up to two letters: the first of the first two words, or the first two of a single word ("Toolsmith" → "TO"). */
function initials(name) {
@@@ -20,7 +21,7 @@ function initials(name) {
* 26 characters). The lowest 40 units stay clear for the card's corner (an item, a price). Decorative in a card, whose
* alt text names the badge.
*/
--function svg({ hue = 210, mark = '?', name = '' } = {}) {
++function svg({ hue = 210, mark = '?', name = '', icon = null } = {}) {
const h = Math.abs(Math.round(Number(hue) || 0)) % 360;
const line = [...String(name)].length > 26 ? `${[...String(name)].slice(0, 25).join('').trimEnd()}…` : String(name);
const face = [...String(mark)].slice(0, 3).join('');
@@@ -32,7 +33,9 @@ function svg({ hue = 210, mark = '?', name = '' } = {}) {
+ `<g fill="none" stroke="hsl(${h},70%,72%)" stroke-opacity=".16"><circle cx="160" cy="62" r="58"/><circle cx="160" cy="62" r="82"/><circle cx="160" cy="62" r="108"/></g>`
+ `<path d="M141 88 133 116 145 110 151 121 158 93Z M179 88 187 116 175 110 169 121 162 93Z" fill="hsl(${h},55%,34%)"/>`
+ `<circle cx="160" cy="62" r="38" fill="url(#m)" stroke="hsl(${h},62%,58%)" stroke-width="5"/>`
-- + `<text x="160" y="62" dy=".35em" text-anchor="middle" font-family="${font}" font-weight="800" font-size="${face.length > 2 ? 21 : 26}" letter-spacing="1" fill="hsl(${h},85%,85%)">${esc(face)}</text>`
++ + (icon && glyphs.NAMES.includes(icon)
++ ? `<g transform="translate(136 38) scale(2)" fill="none" stroke="hsl(${h},85%,85%)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${glyphs.path(icon)}</g>`
++ : `<text x="160" y="62" dy=".35em" text-anchor="middle" font-family="${font}" font-weight="800" font-size="${face.length > 2 ? 21 : 26}" letter-spacing="1" fill="hsl(${h},85%,85%)">${esc(face)}</text>`)
+ (line ? `<text x="160" y="137" text-anchor="middle" font-family="${font}" font-weight="700" font-size="15" fill="hsl(${h},60%,88%)">${esc(line)}</text>` : '')
+ '</svg>';
}
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 501f611..36ba57f 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -95,6 +95,7 @@ function renderPage(o) {
head: `<meta name="referrer" content="${o.noReferrer ? 'no-referrer' : 'strict-origin-when-cross-origin'}">
${appIcon.headTags({ site: 'quest' })}
<link rel="stylesheet" href="${asset('css/app.css')}">
++${(o.localStyles || []).map((rel) => `<link rel="stylesheet" href="${esc(asset(rel))}">`).join('\n')}
<!-- The shared showcase stylesheet on every page, not only the home: inner pages use its buttons (sc-btn), and without it a sign-in button drew as a bare link (phone sweep, 2026-11-01). It is scoped to its own sc- classes. -->
${[...new Set(['showcase.css', ...(o.styles || [])])].map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`).join('\n')}
${render(scripts)}
ddiff --git a/server/render/quest-icons.js b/server/render/quest-icons.js
nnew file mode 100644
iindex 0000000..5e6fa1f
---- /dev/null
++++ b/server/render/quest-icons.js
@@@ -0,0 +1,38 @@
++'use strict';
++
++const { NAMES } = require('openvibe-shared/glyphs');
++const catalog = require('../quests/catalog');
++const { html } = require('./html');
++
++const available = new Set(NAMES);
++const TOPICS = Object.freeze({
++ 'live.stream.started': 'live', 'network.follow.created': 'heart',
++ 'community.thread.created': 'chat', 'community.post.created': 'reply',
++ 'community.comment.created': 'quote', 'chat.room.message.created': 'chat',
++ 'wiki.revision.created': 'book-open', 'tools.job.created': 'wrench',
++ 'mediahub.video.published': 'clapper', 'mediahub.picture.uploaded': 'camera',
++ 'deals.vote.changed': 'thumbs-up', 'blog.post.published': 'pencil',
++ 'games.world.published': 'globe',
++});
++const SITES = Object.freeze({
++ 'openvibe.live': 'live', 'openvibe.network': 'globe', 'openvibe.community': 'chat',
++ 'openvibe.chat': 'chat', 'openvibe.wiki': 'book-open', 'openvibe.tools': 'wrench',
++ 'openvibe.video': 'clapper', 'openvibe.pics': 'camera', 'openvibe.deals': 'tag',
++ 'openvibe.blog': 'pencil', 'openvibe.games': 'game',
++});
++
++const safe = (name, fallback = 'quest') => available.has(name) ? name : fallback;
++const topic = (eventType, site) => safe(TOPICS[eventType] || SITES[site]);
++const site = (domain) => safe(SITES[domain] || String(domain || '').replace(/^openvibe\./, ''));
++const quest = (q) => topic(q.steps?.[0]?.event_type, q.site);
++const badge = (id, q) => safe(catalog.BADGE_ICONS[id] || (q && quest(q)));
++function glyph(name, size = 24, approvedSvg = null) {
++ if (approvedSvg) {
++ 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>`;
++}
++const needsDraft = (eventType, domain) => !TOPICS[eventType] && !SITES[domain];
++
++module.exports = { TOPICS, SITES, safe, topic, site, quest, badge, glyph, needsDraft };
ddiff --git a/server/render/quests.js b/server/render/quests.js
iindex 454c5fb..2b1bb16 100644
---- a/server/render/quests.js
++++ b/server/render/quests.js
@@@ -8,6 +8,7 @@
const kit = require('openvibe-shared/kit');
const catalog = require('../quests/catalog');
const art = require('./badge-art');
++const icons = require('./quest-icons');
const { html } = require('./html');
/**
@@@ -74,7 +75,7 @@ function hueOf(id) {
/** The badge as a medal: its initials on a ring of its colour (decorative; the name is always written beside it). */
function medal(quest, { earned = false } = {}) {
-- return html`<span class="medal${earned ? ' earned' : ''}" style="--h:${hueOf(quest.id)}" aria-hidden="true">${art.initials(quest.reward.badge.name || quest.title)}</span>`;
++ return html`<span class="medal${earned ? ' earned' : ''}" style="--h:${hueOf(quest.id)}" aria-hidden="true">${icons.glyph(icons.badge(quest.reward.badge.id, quest), 28)}</span>`;
}
// ── The home's cards: a quest, a series or an app's quest as the shared kit's card (openvibe-shared kit.card), its
@@@ -114,8 +115,8 @@ function appKitCard(q) {
}
/** The SVG for a badge card (render/badge-art.js): a quest's, a series' (its count on the medal) or an app quest's. */
--const badgeSvg = (quest) => art.svg({ hue: hueOf(quest.id), mark: art.initials(quest.reward.badge.name || quest.title), name: quest.reward.badge.name });
--const seriesSvg = (series) => art.svg({ hue: hueOf(series.id), mark: String(series.quests.length), name: `${series.quests.length} quests, a badge each` });
++const badgeSvg = (quest) => art.svg({ hue: hueOf(quest.id), mark: art.initials(quest.reward.badge.name || quest.title), icon: icons.badge(quest.reward.badge.id, quest), name: quest.reward.badge.name });
++const seriesSvg = (series) => art.svg({ hue: hueOf(series.id), mark: String(series.quests.length), icon: 'layers', name: `${series.quests.length} quests, a badge each` });
/** "Earned by 3 people": how many have a quest's badge (a count, never who), or nothing at 0. */
function earnedBy(n) {
@@@ -139,11 +140,11 @@ const stateChip = (done, locked = false) => (locked && !done
function stepLine(step, i, seen) {
const unit = unitOf(step);
if (!seen) {
-- return html`<li>${step.label}${step.count > 1 ? html` <span class="step-count">×${step.count}</span>` : ''}</li>`;
++ return html`<li>${icons.glyph(icons.topic(step.event_type, step.site), 18)}<span>${step.label}</span>${step.count > 1 ? html` <span class="step-count">×${step.count}</span>` : ''}</li>`;
}
const value = Math.min(seen.get(i) || 0, step.count);
const text = fraction(value, step.count, unit);
-- return html`<li>${step.label}<span class="step-progress">${bar(value, step.count, text, step.label)}<span class="progress-count">${text}</span></span></li>`;
++ return html`<li>${icons.glyph(icons.topic(step.event_type, step.site), 18)}<span>${step.label}</span><span class="step-progress">${bar(value, step.count, text, step.label)}<span class="progress-count">${text}</span></span></li>`;
}
/**
@@@ -159,7 +160,7 @@ function card(quest, { coinsEnabled = false, earned = false, counts = null, seen
return html`<li class="quest${done ? ' is-completed' : ''}">
${medal(quest, { earned: done })}
<div class="quest-main">
-- <a class="quest-title" href="/quests/${quest.id}">${quest.title}</a> ${stateChip(done)}
++ <a class="quest-title" href="/quests/${quest.id}">${quest.title}</a> <span class="q-site-chip">${icons.glyph(icons.site(quest.site), 16)}${shortSite(quest.site)}</span> ${stateChip(done)}
<p class="quest-why">${quest.why}</p>
<ul class="quest-steps">${quest.steps.map((s, i) => stepLine(s, i, stepSeen))}</ul>
<p class="quest-reward">${reward(quest, { coinsEnabled })}</p>
@@@ -214,7 +215,7 @@ function list(groups = catalog.grouped(), { coinsEnabled = false, counts = null,
completedAt: completed ? (completed.get(q.id) || null) : null,
});
return html`${groups.map((g) => html`<section class="site-group" aria-labelledby="${anchor(g.site)}">
-- <h3 id="${anchor(g.site)}"><a href="https://${g.site}">${String(g.name).replace(/^OpenVibe\./, '')}</a><span class="site-count">${count(g)} quest${count(g) === 1 ? '' : 's'}</span>${g.what ? html` <small>${g.what}</small>` : ''}</h3>
++ <h3 id="${anchor(g.site)}">${icons.glyph(icons.site(g.site), 22)}<a href="https://${g.site}">${String(g.name).replace(/^OpenVibe\./, '')}</a><span class="site-count">${count(g)} quest${count(g) === 1 ? '' : 's'}</span>${g.what ? html` <small>${g.what}</small>` : ''}</h3>
<ul class="quest-list">${g.quests.map(cardFor)}${(g.series || []).map((x) => seriesCard(x, { coinsEnabled, counts }))}</ul>
</section>`)}`;
}
@@@ -249,7 +250,7 @@ function terms() {
function coinsNote({ coinsEnabled, dailyCap, globalDailyCap }) {
if (!coinsEnabled) {
return html`<p class="muted">OpenCoins rewards start soon. A completion is recorded now, and no OpenCoins move
--while <code>QUEST_COINS</code> is off. OpenCoins are loyalty points, never money — and never Vibes.</p>`;
++until rewards are switched on. OpenCoins are loyalty points, never money, and never Vibes.</p>`;
}
return html`<p class="muted">Completing a quest credits OpenCoins through OpenVibe.Network's wallet, within
${dailyCap} OpenCoins per person per day and ${globalDailyCap} for everyone together. OpenCoins are loyalty points,
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex fdcaf4b..5de3740 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -38,7 +38,7 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
const testdb = await require('./db').testDb();
const store = createStore(testdb.db, { now: opts.now });
-- const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, accountSend: opts.accountSend, aiGenerate: opts.aiGenerate });
++ const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, accountSend: opts.accountSend, aiGenerate: opts.aiGenerate, glyphs: opts.glyphs });
await built.ctx.keys.ensure();
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;
ddiff --git a/test/quest-visual.test.js b/test/quest-visual.test.js
nnew file mode 100644
iindex 0000000..a19527d
---- /dev/null
++++ b/test/quest-visual.test.js
@@@ -0,0 +1,65 @@
++'use strict';
++
++const assert = require('node:assert/strict');
++const { boot, check, done } = require('./helpers/boot');
++const { createGlyphDraftClient, nameForBadge, nameForDaily } = require('../server/quests/glyph-drafts');
++
++(async () => {
++ const requests = [];
++ const approved = new Map();
++ const glyphs = { nameForBadge, nameForDaily, approved: async () => approved,
++ request: async (name, sentence) => { requests.push({ name, sentence }); return true; } };
++ const t = await boot({ glyphs });
++ const person = t.network.addUser('mira');
++ try {
++ await t.ctx.s.db.query('INSERT INTO quest_progress (subject, quest_id, step, count, updated_at) VALUES ($1,$2,0,2,$3)',
++ [person.subject, 'stream-three-times', new Date().toISOString()]);
++ await t.ctx.s.db.query('INSERT INTO quest_badges (subject,badge_id,quest_id,awarded_at) VALUES ($1,$2,$3,$4)',
++ [person.subject, 'first-broadcast', 'go-live', new Date().toISOString()]);
++ await check('quest log and index stay server rendered with distinct phone and desktop structures', async () => {
++ const me = await t.get('/me', { as: person });
++ assert.equal(me.status, 200);
++ for (const word of ['q-log-hero', 'q-log-columns', 'q-progress-card', 'q-completed-fold', 'q-badge-grid',
++ 'q-badge-sheet', 'First Broadcast', 'role="progressbar"', 'Your quest log']) assert.ok(me.text.includes(word), word);
++ 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(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');
++ });
++ await check('only approved drawings replace the bundled glyph fallback', async () => {
++ let me = await t.get('/me', { as: person });
++ assert.ok(me.text.includes('data-icon="live"'), 'a bundled icon appears before review');
++ approved.set(nameForBadge('first-broadcast'), '<path d="M5 12h14"/>');
++ me = await t.get('/me', { as: person });
++ assert.ok(me.text.includes('data:image/svg+xml,'), 'an approved drawing is shown');
++ assert.ok(me.text.includes('q-badge-close') && me.text.includes('data-share='));
++ });
++ } finally { await t.close(); }
++
++ await check('the Design client sends a service token, caches approved glyphs and never returns pending ones', async () => {
++ const calls = [];
++ let now = Date.now();
++ const fetchImpl = async (url, options = {}) => {
++ calls.push({ url: String(url), method: options.method || 'GET', authorization: options.headers?.Authorization });
++ if (String(url).endsWith('/oauth/token')) return new Response(JSON.stringify({ access_token: 'opaque-test', token_type: 'Bearer', expires_in: 300 }), { status: 200 });
++ if (options.method === 'POST') return new Response(JSON.stringify({ name: 'daily-2026-11-09', status: 'pending', draft_ids: [], created: true }), { status: 201 });
++ return new Response(JSON.stringify({ drafts: [
++ { name: 'daily-2026-11-09', status: 'approved', svg: '<path d="M5 12h14"/>' },
++ { name: 'pending', status: 'draft', svg: '<path d="M5 12h14"/>' },
++ ] }), { status: 200 });
++ };
++ const client = createGlyphDraftClient({ config: { designDrafts: true, designInternalUrl: 'http://design.test',
++ networkInternalUrl: 'http://network.test', oauth: { clientId: 'quest', clientSecret: 'test' } },
++ fetchImpl, now: () => now });
++ assert.equal(await client.request('daily-2026-11-09', 'A shared quest across sites'), true);
++ assert.equal(calls.find((c) => c.url.includes('/api/v1/glyphs/drafts') && c.method === 'POST').authorization, 'Bearer opaque-test');
++ const first = await client.approved();
++ assert.ok(first.has('daily-2026-11-09'));
++ assert.ok(!first.has('pending'));
++ await client.approved();
++ assert.equal(calls.filter((c) => c.method === 'GET').length, 1, 'approved list cached for five minutes');
++ });
++ done();
++})().catch((err) => { process.stderr.write(`${err.stack || err}\n`); process.exit(1); });
ddiff --git a/test/quests-api.test.js b/test/quests-api.test.js
iindex 84b9712..54f20ab 100644
---- a/test/quests-api.test.js
++++ b/test/quests-api.test.js
@@@ -172,7 +172,7 @@ const { render } = require('../server/render/html');
assert.strictEqual(svg.status, 200);
assert.match(svg.headers.get('content-type'), /^image\/svg\+xml/);
assert.match(svg.headers.get('cache-control') || '', /^public, max-age=86400/);
-- assert.ok(svg.text.startsWith('<svg') && svg.text.includes('>FB<') && svg.text.includes('First Broadcast'), 'the medal and the badge name');
++ assert.ok(svg.text.startsWith('<svg') && svg.text.includes(require('openvibe-shared/glyphs').path('live')) && svg.text.includes('First Broadcast'), 'the glyph medal and the badge name');
assert.strictEqual((await t.get('/series/live-setup/badge.svg')).status, 200);
assert.strictEqual((await t.get('/quests/no-such-quest/badge.svg')).status, 404);
const how = await t.get('/how-it-works');