openvibers/openvibe.quest Commits
Redesign quest log and badge collection (recovered from #233) (#251)
Changes
@@@ -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.networkOV_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:4700OV_OAUTH_CLIENT_ID=quest@@@ -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, eachwith its steps, the events that count them, its badge, its OpenCoins and any item reward, plus the list of eventsQuest listens to. A quest may not count an event the catalog does not document; that fails at load.nnew file mode 100644BBinary files /dev/null and b/docs/screenshots/quest-visual/badge-sheet-1280-dark.jpg differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/quest-visual/badge-sheet-390-light.jpg differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/quest-visual/me-1280-dark.jpg differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/quest-visual/me-1280-light.jpg differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/quest-visual/me-390-dark.jpg differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/quest-visual/me-390-light.jpg differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/quest-visual/quests-1280-dark.jpg differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/quest-visual/quests-1280-light.jpg differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/quest-visual/quests-390-dark.jpg differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/quest-visual/quests-390-light.jpg differ@@@ -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; }nnew file mode 100644@@@ -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; }nnew file mode 100644@@@ -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;++ });++})();@@@ -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@@@ -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',@@@ -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>`,});});@@@ -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]) };},@@@ -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())) {nnew file mode 100644@@@ -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 };@@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');++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>';}@@@ -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)}nnew file mode 100644@@@ -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 };@@@ -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 && !donefunction 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, seenreturn 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,@@@ -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}`;nnew file mode 100644@@@ -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); });@@@ -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');
@@ -19,6 +19,8 @@ VALKEY_PREFIX=ov:quest:
@@ -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.
# 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_URL=https://openvibe.network
OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
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).
# Daily quest generation through OpenVibe.AI (ai.complete, with a hand-written fallback).
OV_AI_INTERNAL_URL=http://127.0.0.1:4700
OV_AI_INTERNAL_URL=http://127.0.0.1:4700
OV_OAUTH_CLIENT_ID=quest
OV_OAUTH_CLIENT_ID=quest
@@ -27,6 +27,8 @@ OpenVibe.Quest owns the quest log and the catalog that describes it.
@@ -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.
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
- 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
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.
Quest listens to. A quest may not count an event the catalog does not document; that fails at load.
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/quest-visual/badge-sheet-1280-dark.jpg differ
Binary files /dev/null and b/docs/screenshots/quest-visual/badge-sheet-1280-dark.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/quest-visual/badge-sheet-390-light.jpg differ
Binary files /dev/null and b/docs/screenshots/quest-visual/badge-sheet-390-light.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/quest-visual/me-1280-dark.jpg differ
Binary files /dev/null and b/docs/screenshots/quest-visual/me-1280-dark.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/quest-visual/me-1280-light.jpg differ
Binary files /dev/null and b/docs/screenshots/quest-visual/me-1280-light.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/quest-visual/me-390-dark.jpg differ
Binary files /dev/null and b/docs/screenshots/quest-visual/me-390-dark.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/quest-visual/me-390-light.jpg differ
Binary files /dev/null and b/docs/screenshots/quest-visual/me-390-light.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/quest-visual/quests-1280-dark.jpg differ
Binary files /dev/null and b/docs/screenshots/quest-visual/quests-1280-dark.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/quest-visual/quests-1280-light.jpg differ
Binary files /dev/null and b/docs/screenshots/quest-visual/quests-1280-light.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/quest-visual/quests-390-dark.jpg differ
Binary files /dev/null and b/docs/screenshots/quest-visual/quests-390-dark.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/quest-visual/quests-390-light.jpg differ
Binary files /dev/null and b/docs/screenshots/quest-visual/quests-390-light.jpg differ
@@ -198,6 +198,8 @@ p.lede { font-size: 1.05rem; color: var(--text-secondary, #96a7c2); max-width: 7
@@ -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. */
/* 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 { 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-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 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 { list-style: none; margin: 20px 0; padding: 0; display: grid; gap: 8px; }
new file mode 100644
new file mode 100644
@@ -0,0 +1,129 @@
@@ -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; }
new file mode 100644
new file mode 100644
@@ -0,0 +1,37 @@
@@ -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;
+ });
+})();
@@ -32,6 +32,7 @@ const { createPageRoutes } = require('./http/pages');
@@ -32,6 +32,7 @@ const { createPageRoutes } = require('./http/pages');
const { createCoins } = require('./quests/coins');
const { createCoins } = require('./quests/coins');
const { createItems } = require('./quests/items');
const { createItems } = require('./quests/items');
const { createDaily } = require('./quests/daily');
const { createDaily } = require('./quests/daily');
+const { createGlyphDraftClient } = require('./quests/glyph-drafts');
const { createEventsConsumer } = require('./events-consumer');
const { createEventsConsumer } = require('./events-consumer');
const accountDataLib = require('./identity/account-data');
const accountDataLib = require('./identity/account-data');
const { createNetworkSender } = require('openvibe-sdk/account-data');
const { createNetworkSender } = require('openvibe-sdk/account-data');
@@ -70,12 +71,13 @@ async function createApp(opts = {}) {
@@ -70,12 +71,13 @@ async function createApp(opts = {}) {
? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
? 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'); });
: 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.
// 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 });
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.
// 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 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();
const app = express();
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
@@ -41,6 +41,8 @@ function load(env = process.env) {
@@ -41,6 +41,8 @@ function load(env = process.env) {
// OpenVibe.Network: SSO (OAuth2 authorization server with PKCE) and its JWKS.
// OpenVibe.Network: SSO (OAuth2 authorization server with PKCE) and its JWKS.
networkUrl,
networkUrl,
networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
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),
networkIssuer: trim(env.OV_NETWORK_ISSUER || networkUrl),
// The audience this service's app, agent and service tokens carry.
// The audience this service's app, agent and service tokens carry.
audience: env.QUEST_AUDIENCE || 'openvibe.quest',
audience: env.QUEST_AUDIENCE || 'openvibe.quest',
@@ -26,6 +26,8 @@ const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
@@ -26,6 +26,8 @@ const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
const { html, raw, table } = require('../render/html');
const { html, raw, table } = require('../render/html');
const { send } = require('../render/layout');
const { send } = require('../render/layout');
const quests = require('../render/quests');
const quests = require('../render/quests');
+const icons = require('../render/quest-icons');
+const { nameForBadge, nameForDaily } = require('../quests/glyph-drafts');
const S = require('../render/short');
const S = require('../render/short');
const catalog = require('../quests/catalog');
const catalog = require('../quests/catalog');
const store = require('../quests/store');
const store = require('../quests/store');
@@ -141,12 +143,12 @@ function createPageRoutes(ctx) {
@@ -141,12 +143,12 @@ function createPageRoutes(ctx) {
}
}
r.get('/', async (req, res) => {
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 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"><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>
<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>
<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>`;
<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 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({
@@ -244,11 +246,12 @@ ${raw(showcase.cta({
const active = kit.tabOf(req.query, keys, 'site');
const active = kit.tabOf(req.query, keys, 'site');
const shown = active === 'all' ? groups : groups.filter((g) => siteKey(g.site) === active);
const shown = active === 'all' ? groups : groups.filter((g) => siteKey(g.site) === active);
const mine = signedIn(req) && SUBJECT_RE.test(req.viewer.subject);
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) : [],
active === 'all' && ctx.appQuests ? ctx.appQuests.listed(100) : [],
pulse(),
pulse(),
mine ? store.progressFor(s.db, req.viewer.subject) : [],
mine ? store.progressFor(s.db, req.viewer.subject) : [],
mine ? store.completionsFor(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 completed = new Map(completions.map((c) => [c.quest_id, c.completed_at]));
const seen = new Map();
const seen = new Map();
@@ -260,6 +263,7 @@ ${raw(showcase.cta({
@@ -260,6 +263,7 @@ ${raw(showcase.cta({
const count = (g) => g.quests.length + (g.series || []).reduce((n, x) => n + x.quests.length, 0);
const count = (g) => g.quests.length + (g.series || []).reduce((n, x) => n + x.quests.length, 0);
const total = catalog.all().length;
const total = catalog.all().length;
const one = active === 'all' ? null : shown[0];
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, {
page(req, res, {
index: true, cache: signedIn(req) ? null : PUBLIC_CACHE,
index: true, cache: signedIn(req) ? null : PUBLIC_CACHE,
title: one ? `Quests on ${one.name}` : 'All quests',
title: one ? `Quests on ${one.name}` : 'All quests',
@@ -268,8 +272,10 @@ ${raw(showcase.cta({
@@ -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.`,
: `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' }],
crumbs: [{ label: 'Quests', href: '/' }, { label: one ? one.name : 'All quests' }],
styles: ['kit.css'],
styles: ['kit.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>` : ''}
${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>
@@ -428,11 +434,18 @@ each quest still needs, and the badges you have earned.</p>
@@ -428,11 +434,18 @@ each quest still needs, and the badges you have earned.</p>
});
});
const subject = req.viewer.subject;
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.progressFor(s.db, subject),
store.completionsFor(s.db, subject),
store.completionsFor(s.db, subject),
store.badgesFor(s.db, subject),
store.badgesFor(s.db, subject),
store.creditedTo(s.db, subject, store.dayOf(s.now())),
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();
const seen = new Map();
for (const row of progress) {
for (const row of progress) {
@@ -445,19 +458,30 @@ each quest still needs, and the badges you have earned.</p>
@@ -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 started = catalog.all().filter((q) => questState(q) === 'started');
const finished = catalog.all().filter((q) => questState(q) === 'done');
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 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, {
return page(req, res, {
title: 'Your quest log',
title: 'Your quest log',
crumbs: [{ label: 'Quests', href: '/' }, { label: '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 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 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.`) : '';
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) => {
@@ -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>
<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>` : ''}
${item || coins ? html`<p class="quest-why">${item}${coins}</p>` : ''}
</div></li>`;
</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>`,
});
});
});
});
@@ -38,7 +38,7 @@ function asQuest(row) {
@@ -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();
const badgeNames = new Map();
let subscribe = initialSubscribe;
let subscribe = initialSubscribe;
const iso = () => new Date(s.now()).toISOString();
const iso = () => new Date(s.now()).toISOString();
@@ -84,6 +84,9 @@ function createAppQuests({ s, log = console, subscribe: initialSubscribe = null
@@ -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]);
[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);
badgeNames.set(id, body.badge.name);
if (env === 'production' && status === 'active') await ensureSubscribed(key);
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]) };
return { status: existed ? 200 : 201, row: await s.db.maybe('SELECT * FROM app_quests WHERE id = $1', [id]) };
},
},
@@ -66,7 +66,7 @@ function wireType(t) {
@@ -66,7 +66,7 @@ function wireType(t) {
status: t.status, source: t.source || t.project_id };
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 db = s.db;
const token = config.oauth.clientSecret ? serviceAuth.createTokenClient({
const token = config.oauth.clientSecret ? serviceAuth.createTokenClient({
tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId,
tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId,
@@ -127,7 +127,10 @@ function createDaily({ s, config, coins, fetchImpl = globalThis.fetch, aiGenerat
@@ -127,7 +127,10 @@ function createDaily({ s, config, coins, fetchImpl = globalThis.fetch, aiGenerat
}
}
const pending = new Map();
const pending = new Map();
async function ensure(day = utcDay(s.now())) {
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);
return pending.get(day);
}
}
async function publicQuest(day = utcDay(s.now())) {
async function publicQuest(day = utcDay(s.now())) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,56 @@
@@ -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 };
@@ -1,12 +1,13 @@
@@ -1,12 +1,13 @@
'use strict';
'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
* 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
* (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.
* /apps/quests/:id/badge.svg (http/pages.js); the text is the catalog's or an app's, escaped here.
*/
*/
const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
+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"). */
/** Up to two letters: the first of the first two words, or the first two of a single word ("Toolsmith" → "TO"). */
function initials(name) {
function initials(name) {
@@ -20,7 +21,7 @@ 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
* 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.
* 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 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 line = [...String(name)].length > 26 ? `${[...String(name)].slice(0, 25).join('').trimEnd()}…` : String(name);
const face = [...String(mark)].slice(0, 3).join('');
const face = [...String(mark)].slice(0, 3).join('');
@@ -32,7 +33,9 @@ function svg({ hue = 210, mark = '?', name = '' } = {}) {
@@ -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>`
+ `<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%)"/>`
+ `<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"/>`
+ `<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>` : '')
+ (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>';
+ '</svg>';
}
}
@@ -95,6 +95,7 @@ function renderPage(o) {
@@ -95,6 +95,7 @@ function renderPage(o) {
head: `<meta name="referrer" content="${o.noReferrer ? 'no-referrer' : 'strict-origin-when-cross-origin'}">
head: `<meta name="referrer" content="${o.noReferrer ? 'no-referrer' : 'strict-origin-when-cross-origin'}">
${appIcon.headTags({ site: 'quest' })}
${appIcon.headTags({ site: 'quest' })}
<link rel="stylesheet" href="${asset('css/app.css')}">
<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. -->
<!-- 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')}
${[...new Set(['showcase.css', ...(o.styles || [])])].map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`).join('\n')}
${render(scripts)}
${render(scripts)}
new file mode 100644
new file mode 100644
@@ -0,0 +1,38 @@
@@ -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 };
@@ -8,6 +8,7 @@
@@ -8,6 +8,7 @@
const kit = require('openvibe-shared/kit');
const kit = require('openvibe-shared/kit');
const catalog = require('../quests/catalog');
const catalog = require('../quests/catalog');
const art = require('./badge-art');
const art = require('./badge-art');
+const icons = require('./quest-icons');
const { html } = require('./html');
const { html } = require('./html');
/**
/**
@@ -74,7 +75,7 @@ function hueOf(id) {
@@ -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). */
/** The badge as a medal: its initials on a ring of its colour (decorative; the name is always written beside it). */
function medal(quest, { earned = false } = {}) {
function medal(quest, { earned = false } = {}) {
- 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
// ── 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) {
@@ -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. */
/** 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. */
/** "Earned by 3 people": how many have a quest's badge (a count, never who), or nothing at 0. */
function earnedBy(n) {
function earnedBy(n) {
@@ -139,11 +140,11 @@ const stateChip = (done, locked = false) => (locked && !done
@@ -139,11 +140,11 @@ const stateChip = (done, locked = false) => (locked && !done
function stepLine(step, i, seen) {
function stepLine(step, i, seen) {
const unit = unitOf(step);
const unit = unitOf(step);
if (!seen) {
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 value = Math.min(seen.get(i) || 0, step.count);
const text = fraction(value, step.count, unit);
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
@@ -159,7 +160,7 @@ function card(quest, { coinsEnabled = false, earned = false, counts = null, seen
return html`<li class="quest${done ? ' is-completed' : ''}">
return html`<li class="quest${done ? ' is-completed' : ''}">
${medal(quest, { earned: done })}
${medal(quest, { earned: done })}
<div class="quest-main">
<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>
<p class="quest-why">${quest.why}</p>
<ul class="quest-steps">${quest.steps.map((s, i) => stepLine(s, i, stepSeen))}</ul>
<ul class="quest-steps">${quest.steps.map((s, i) => stepLine(s, i, stepSeen))}</ul>
<p class="quest-reward">${reward(quest, { coinsEnabled })}</p>
<p class="quest-reward">${reward(quest, { coinsEnabled })}</p>
@@ -214,7 +215,7 @@ function list(groups = catalog.grouped(), { coinsEnabled = false, counts = null,
@@ -214,7 +215,7 @@ function list(groups = catalog.grouped(), { coinsEnabled = false, counts = null,
completedAt: completed ? (completed.get(q.id) || null) : null,
completedAt: completed ? (completed.get(q.id) || null) : null,
});
});
return html`${groups.map((g) => html`<section class="site-group" aria-labelledby="${anchor(g.site)}">
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>
<ul class="quest-list">${g.quests.map(cardFor)}${(g.series || []).map((x) => seriesCard(x, { coinsEnabled, counts }))}</ul>
</section>`)}`;
</section>`)}`;
}
}
@@ -249,7 +250,7 @@ function terms() {
@@ -249,7 +250,7 @@ function terms() {
function coinsNote({ coinsEnabled, dailyCap, globalDailyCap }) {
function coinsNote({ coinsEnabled, dailyCap, globalDailyCap }) {
if (!coinsEnabled) {
if (!coinsEnabled) {
return html`<p class="muted">OpenCoins rewards start soon. A completion is recorded now, and no OpenCoins move
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
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,
${dailyCap} OpenCoins per person per day and ${globalDailyCap} for everyone together. OpenCoins are loyalty points,
@@ -38,7 +38,7 @@ async function boot(opts = {}) {
@@ -38,7 +38,7 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
const { createStore } = require('../../server/db');
const testdb = await require('./db').testDb();
const testdb = await require('./db').testDb();
const store = createStore(testdb.db, { now: opts.now });
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();
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 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}`;
const base = `http://127.0.0.1:${server.address().port}`;
new file mode 100644
new file mode 100644
@@ -0,0 +1,65 @@
@@ -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); });
@@ -172,7 +172,7 @@ const { render } = require('../server/render/html');
@@ -172,7 +172,7 @@ const { render } = require('../server/render/html');
assert.strictEqual(svg.status, 200);
assert.strictEqual(svg.status, 200);
assert.match(svg.headers.get('content-type'), /^image\/svg\+xml/);
assert.match(svg.headers.get('content-type'), /^image\/svg\+xml/);
assert.match(svg.headers.get('cache-control') || '', /^public, max-age=86400/);
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('/series/live-setup/badge.svg')).status, 200);
assert.strictEqual((await t.get('/quests/no-such-quest/badge.svg')).status, 404);
assert.strictEqual((await t.get('/quests/no-such-quest/badge.svg')).status, 404);
const how = await t.get('/how-it-works');
const how = await t.get('/how-it-works');