openvibers/openvibe.network Commits
Profiles: each badge wears its site's icon; item names break between words (#508)
Changes
@@@ -20,6 +20,8 @@ const express = require('express');const seo = require('openvibe-shared/seo');const cache = require('openvibe-shared/cache-policy');const usernames = require('../identity/usernames');++const icons = require('openvibe-shared/icons');++const { siteForHost, fullName } = require('../frame/sites');const esc = seo.esc;const SITE = 'https://openvibe.network';@@@ -93,7 +95,11 @@ function createPublicProfiles({ db, selfToken = () => null, inventoryUrl = 'httpconst body = await r.json();const badges = (Array.isArray(body.badges) ? body.badges : []).filter((b) => b && typeof b.id === 'string' && typeof b.name === 'string')-- .map((b) => ({ id: b.id.slice(0, 64), name: b.name.slice(0, 60), awarded_at: typeof b.awarded_at === 'string' ? b.awarded_at : null }))++ .map((b) => ({++ id: b.id.slice(0, 64), name: b.name.slice(0, 60), awarded_at: typeof b.awarded_at === 'string' ? b.awarded_at : null,++ // The site whose quest awards it (Quest #173): the badge shows that site's icon.++ site: typeof b.site === 'string' && /^[a-z0-9][a-z0-9.-]{2,60}$/.test(b.site) ? b.site : null,++ })).sort((a, b) => String(b.awarded_at || '').localeCompare(String(a.awarded_at || '')));value = { badges };} catch (err) {@@@ -273,7 +279,14 @@ function showcaseHtml(inv, name) {function badgesHtml(inv) {if (!inv.badges || !inv.badges.length) return '';return `<section class="pf-section" aria-labelledby="pf-badges"><h2 id="pf-badges">Badges <span class="pf-count">${inv.badges.length}</span></h2>--<ul class="pf-badges">${inv.badges.map((b) => `<li><a class="pf-badge" href="${QUEST_SITE}/" title="${esc(b.awarded_at ? `Earned ${monthOf(b.awarded_at) || ''} on OpenVibe.Quest` : 'Earned on OpenVibe.Quest')}"><span class="pf-badge-ic" aria-hidden="true">🏅</span><span>${esc(b.name)}</span>${b.awarded_at && monthOf(b.awarded_at) ? `<small>${esc(monthOf(b.awarded_at))}</small>` : ''}</a></li>`).join('')}</ul></section>`;++<ul class="pf-badges">${inv.badges.map((b) => {++ // Each badge wears the icon of the site whose quest awards it; the medal only when the site is not known.++ const site = b.site ? siteForHost(b.site) : null;++ const when = b.awarded_at && monthOf(b.awarded_at) ? monthOf(b.awarded_at) : '';++ const title = `${when ? `Earned ${when}` : 'Earned'} on OpenVibe.Quest${site ? `: an ${fullName(site)} quest` : ''}`;++ const ic = site && site.icon ? `<span class="pf-badge-ic is-site" aria-hidden="true">${icons.svg(site.icon)}</span>` : '<span class="pf-badge-ic" aria-hidden="true">🏅</span>';++ return `<li><a class="pf-badge" href="${QUEST_SITE}/" title="${esc(title)}">${ic}<span>${esc(b.name)}</span>${when ? `<small>${esc(when)}</small>` : ''}</a></li>`;++ }).join('')}</ul></section>`;}/** Games: each game the person played with its achievements (newest first), Steam-style; links to the game on OpenVibe.Play. */@@@ -285,6 +298,9 @@ function gamesHtml(inv) {<ul class="pf-ach">${g.recent.map((x) => `<li title="${esc(x.description || x.name)}"><span aria-hidden="true">🏆</span> ${esc(x.name)}${x.unlocked_at && monthOf(x.unlocked_at) ? ` <small>${esc(monthOf(x.unlocked_at))}</small>` : ''}</li>`).join('')}</ul></li>`).join('')}</ul></section>`;}++/** Break opportunities in a run-together name (CrackheadCarl → Crackhead<wbr>Carl): on escaped text, letters only. */++const softBreaks = (escaped) => escaped.replace(/([a-z])([A-Z])/g, '$1<wbr>$2');++function itemsHtml(inv, u) {const name = esc(u.display_name || u.username);if (inv.unavailable) return `<section class="pf-section" aria-labelledby="pf-items"><h2 id="pf-items">Items</h2><p class="pf-empty">The items could not be loaded just now. They are safe on <a href="${INVENTORY_SITE}/">OpenVibe.Inventory</a>; try again in a minute.</p></section>`;@@@ -301,7 +317,7 @@ function itemsHtml(inv, u) {${sorted.map((g) => {g.list.sort((a, b) => rarityRank(b.rarity) - rarityRank(a.rarity) || a.name.localeCompare(b.name));return `<h3 class="pf-kind-head">${esc(g.name)} <span class="pf-count">${g.list.length}</span></h3>--<ul class="pf-grid">${g.list.map((i) => `<li class="pf-card r-${esc(i.rarity)}${worn.has(i.instance_id) ? ' is-worn' : ''}"><a href="${esc(i.url)}" title="${esc(`${i.name} · ${RARITY_LABEL[i.rarity]}`)}">${art(i)}<span class="pf-card-name">${esc(i.name)}</span>${rarityBadge(i.rarity)}${worn.has(i.instance_id) ? '<span class="pf-worn-tag">Worn</span>' : ''}</a></li>`).join('')}</ul>`;++<ul class="pf-grid">${g.list.map((i) => `<li class="pf-card r-${esc(i.rarity)}${worn.has(i.instance_id) ? ' is-worn' : ''}"><a href="${esc(i.url)}" title="${esc(`${i.name} · ${RARITY_LABEL[i.rarity]}`)}">${art(i)}<span class="pf-card-name">${softBreaks(esc(i.name))}</span>${rarityBadge(i.rarity)}${worn.has(i.instance_id) ? '<span class="pf-worn-tag">Worn</span>' : ''}</a></li>`).join('')}</ul>`;}).join('\n')}<p class="pf-note"><a href="${esc(all)}">${inv.more ? 'Every item' : 'This inventory'} on OpenVibe.Inventory</a> · <a href="${INVENTORY_SITE}/items">Every item there is</a></p></section>`;}@@@ -404,7 +420,7 @@ a{color:var(--accent)}.pf-badge{display:inline-flex;align-items:center;gap:8px;padding:7px 14px 7px 10px;border-radius:999px;border:1px solid color-mix(in srgb,var(--r-legendary) 40%,var(--border));background:color-mix(in srgb,var(--r-legendary) 10%,var(--bg-secondary));color:var(--text-primary);text-decoration:none;font-weight:600;font-size:.88rem}.pf-badge:hover,.pf-badge:focus-visible{border-color:var(--r-legendary)}.pf-badge small{font-weight:500;color:var(--text-muted);font-size:.75rem}--.pf-badge-ic{font-size:1.05rem}++.pf-badge-ic{font-size:1.05rem}.pf-badge-ic.is-site{display:inline-grid;place-items:center;width:20px;height:20px;color:var(--accent)}.pf-badge-ic.is-site svg{width:16px;height:16px}.pf-games{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(auto-fill,minmax(min(300px,100%),1fr));gap:12px}.pf-game{border:1px solid var(--border);border-radius:14px;background:var(--bg-secondary);padding:12px 14px;min-width:0}.pf-game-head{display:flex;justify-content:space-between;gap:10px;align-items:baseline;text-decoration:none;color:inherit;margin-bottom:8px}@@@ -64,7 +64,7 @@ const INSTANCES = [if (u.pathname === `/api/v1/people/${A}/equipped`) return json(200, { subject: A, slots: { 'live.hat:hat': { instance_id: INSTANCES[0].id, definition_id: INSTANCES[0].definition_id }, 'live.name_effect:name_effect': { instance_id: INSTANCES[1].id, definition_id: INSTANCES[1].definition_id, token: 'name-fx-rainbow' }, 'network.badge:badge': { instance_id: INSTANCES[3].id, definition_id: INSTANCES[3].definition_id, media_id: 'med_01JZ00000000000000000000B1' } } });if (u.pathname === `/api/v1/people/${A}/items`) return json(200, { subject: A, instances: INSTANCES, definitions: DEFS, next_cursor: null });if (u.pathname === `/api/v1/people/${A}/achievements`) return json(200, { subject: A, total: 3, titles: [{ title: 'salvage-rush', name: 'Salvage <Rush>', achievements: [{ id: 'big-haul', name: 'Big Haul', description: 'Bring back 50 parts', unlocked_at: '2026-10-20T10:00:00Z' }, { id: 'first-haul', name: 'First Haul', description: '', unlocked_at: '2026-10-19T10:00:00Z' }] }, { title: 'vibe-heroes', name: 'Vibe Heroes', achievements: [{ id: 'hello', name: 'Hello', unlocked_at: '2026-10-01T10:00:00Z' }] }] });-- if (u.pathname === `/api/v1/profiles/${A}/badges`) return json(200, { subject: A, badges: [{ id: 'first-follow', name: 'First Follow', awarded_at: '2026-10-02T10:00:00Z' }, { id: 'thread-starter', name: 'Thread <Starter>', awarded_at: '2026-10-05T10:00:00Z' }] });++ if (u.pathname === `/api/v1/profiles/${A}/badges`) return json(200, { subject: A, badges: [{ id: 'first-follow', name: 'First Follow', awarded_at: '2026-10-02T10:00:00Z', site: 'openvibe.live' }, { id: 'thread-starter', name: 'Thread <Starter>', awarded_at: '2026-10-05T10:00:00Z' }] });return json(404, { code: 'inventory.unknown_subject' });});await new Promise((r) => inv.listen(0, '127.0.0.1', r));@@@ -105,6 +105,11 @@ const INSTANCES = [assert.ok(/<span class="pf-stat-k">Achievements<\/span><span class="pf-stat-v">3<\/span>/.test(r.text));assert.strictEqual(calls.find((c) => c.path.endsWith('/badges')).auth, 'Bearer tok:openvibe.quest:quest.badge.read', 'and for Quest');assert.ok(r.text.includes('id="pf-badges"') && /Thread <Starter>[\s\S]*First Follow/.test(r.text), 'badges, newest first, escaped');++ // A badge wears the icon of the site whose quest awards it (Quest #173); one with no site keeps the medal.++ const follow = r.text.slice(r.text.lastIndexOf('<li>', r.text.indexOf('<span>First Follow</span>')), r.text.indexOf('<span>First Follow</span>'));++ assert.ok(follow.includes('class="pf-badge-ic is-site"') && follow.includes('<svg') && follow.includes('an OpenVibe.Live quest'), 'First Follow shows Live\'s icon');++ const thread = r.text.slice(r.text.lastIndexOf('<li>', r.text.indexOf('Thread <Starter>')), r.text.indexOf('Thread <Starter>'));++ assert.ok(thread.includes('🏅') && !thread.includes('is-site'), 'a badge with no site keeps the medal');// The worn badge's image rule (.85em square) once shared the chips' class and squeezed every chip into it.const css = (sel) => (r.text.match(new RegExp(`\\n${sel.replace('.', '\\.')}\\{([^}]*)\\}`)) || [])[1] || '';assert.ok(/padding:7px 14px/.test(css('.pf-badge')) && !/width/.test(css('.pf-badge')), 'a badge chip sizes to its name');
@@ -20,6 +20,8 @@ const express = require('express');
@@ -20,6 +20,8 @@ const express = require('express');
const seo = require('openvibe-shared/seo');
const seo = require('openvibe-shared/seo');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
const usernames = require('../identity/usernames');
const usernames = require('../identity/usernames');
+const icons = require('openvibe-shared/icons');
+const { siteForHost, fullName } = require('../frame/sites');
const esc = seo.esc;
const esc = seo.esc;
const SITE = 'https://openvibe.network';
const SITE = 'https://openvibe.network';
@@ -93,7 +95,11 @@ function createPublicProfiles({ db, selfToken = () => null, inventoryUrl = 'http
@@ -93,7 +95,11 @@ function createPublicProfiles({ db, selfToken = () => null, inventoryUrl = 'http
const body = await r.json();
const body = await r.json();
const badges = (Array.isArray(body.badges) ? body.badges : [])
const badges = (Array.isArray(body.badges) ? body.badges : [])
.filter((b) => b && typeof b.id === 'string' && typeof b.name === 'string')
.filter((b) => b && typeof b.id === 'string' && typeof b.name === 'string')
- .map((b) => ({ id: b.id.slice(0, 64), name: b.name.slice(0, 60), awarded_at: typeof b.awarded_at === 'string' ? b.awarded_at : null }))
+ .map((b) => ({
+ id: b.id.slice(0, 64), name: b.name.slice(0, 60), awarded_at: typeof b.awarded_at === 'string' ? b.awarded_at : null,
+ // The site whose quest awards it (Quest #173): the badge shows that site's icon.
+ site: typeof b.site === 'string' && /^[a-z0-9][a-z0-9.-]{2,60}$/.test(b.site) ? b.site : null,
+ }))
.sort((a, b) => String(b.awarded_at || '').localeCompare(String(a.awarded_at || '')));
.sort((a, b) => String(b.awarded_at || '').localeCompare(String(a.awarded_at || '')));
value = { badges };
value = { badges };
} catch (err) {
} catch (err) {
@@ -273,7 +279,14 @@ function showcaseHtml(inv, name) {
@@ -273,7 +279,14 @@ function showcaseHtml(inv, name) {
function badgesHtml(inv) {
function badgesHtml(inv) {
if (!inv.badges || !inv.badges.length) return '';
if (!inv.badges || !inv.badges.length) return '';
return `<section class="pf-section" aria-labelledby="pf-badges"><h2 id="pf-badges">Badges <span class="pf-count">${inv.badges.length}</span></h2>
return `<section class="pf-section" aria-labelledby="pf-badges"><h2 id="pf-badges">Badges <span class="pf-count">${inv.badges.length}</span></h2>
-<ul class="pf-badges">${inv.badges.map((b) => `<li><a class="pf-badge" href="${QUEST_SITE}/" title="${esc(b.awarded_at ? `Earned ${monthOf(b.awarded_at) || ''} on OpenVibe.Quest` : 'Earned on OpenVibe.Quest')}"><span class="pf-badge-ic" aria-hidden="true">🏅</span><span>${esc(b.name)}</span>${b.awarded_at && monthOf(b.awarded_at) ? `<small>${esc(monthOf(b.awarded_at))}</small>` : ''}</a></li>`).join('')}</ul></section>`;
+<ul class="pf-badges">${inv.badges.map((b) => {
+ // Each badge wears the icon of the site whose quest awards it; the medal only when the site is not known.
+ const site = b.site ? siteForHost(b.site) : null;
+ const when = b.awarded_at && monthOf(b.awarded_at) ? monthOf(b.awarded_at) : '';
+ const title = `${when ? `Earned ${when}` : 'Earned'} on OpenVibe.Quest${site ? `: an ${fullName(site)} quest` : ''}`;
+ const ic = site && site.icon ? `<span class="pf-badge-ic is-site" aria-hidden="true">${icons.svg(site.icon)}</span>` : '<span class="pf-badge-ic" aria-hidden="true">🏅</span>';
+ return `<li><a class="pf-badge" href="${QUEST_SITE}/" title="${esc(title)}">${ic}<span>${esc(b.name)}</span>${when ? `<small>${esc(when)}</small>` : ''}</a></li>`;
+ }).join('')}</ul></section>`;
}
}
/** Games: each game the person played with its achievements (newest first), Steam-style; links to the game on OpenVibe.Play. */
/** Games: each game the person played with its achievements (newest first), Steam-style; links to the game on OpenVibe.Play. */
@@ -285,6 +298,9 @@ function gamesHtml(inv) {
@@ -285,6 +298,9 @@ function gamesHtml(inv) {
<ul class="pf-ach">${g.recent.map((x) => `<li title="${esc(x.description || x.name)}"><span aria-hidden="true">🏆</span> ${esc(x.name)}${x.unlocked_at && monthOf(x.unlocked_at) ? ` <small>${esc(monthOf(x.unlocked_at))}</small>` : ''}</li>`).join('')}</ul></li>`).join('')}</ul></section>`;
<ul class="pf-ach">${g.recent.map((x) => `<li title="${esc(x.description || x.name)}"><span aria-hidden="true">🏆</span> ${esc(x.name)}${x.unlocked_at && monthOf(x.unlocked_at) ? ` <small>${esc(monthOf(x.unlocked_at))}</small>` : ''}</li>`).join('')}</ul></li>`).join('')}</ul></section>`;
}
}
+/** Break opportunities in a run-together name (CrackheadCarl → Crackhead<wbr>Carl): on escaped text, letters only. */
+const softBreaks = (escaped) => escaped.replace(/([a-z])([A-Z])/g, '$1<wbr>$2');
+
function itemsHtml(inv, u) {
function itemsHtml(inv, u) {
const name = esc(u.display_name || u.username);
const name = esc(u.display_name || u.username);
if (inv.unavailable) return `<section class="pf-section" aria-labelledby="pf-items"><h2 id="pf-items">Items</h2><p class="pf-empty">The items could not be loaded just now. They are safe on <a href="${INVENTORY_SITE}/">OpenVibe.Inventory</a>; try again in a minute.</p></section>`;
if (inv.unavailable) return `<section class="pf-section" aria-labelledby="pf-items"><h2 id="pf-items">Items</h2><p class="pf-empty">The items could not be loaded just now. They are safe on <a href="${INVENTORY_SITE}/">OpenVibe.Inventory</a>; try again in a minute.</p></section>`;
@@ -301,7 +317,7 @@ function itemsHtml(inv, u) {
@@ -301,7 +317,7 @@ function itemsHtml(inv, u) {
${sorted.map((g) => {
${sorted.map((g) => {
g.list.sort((a, b) => rarityRank(b.rarity) - rarityRank(a.rarity) || a.name.localeCompare(b.name));
g.list.sort((a, b) => rarityRank(b.rarity) - rarityRank(a.rarity) || a.name.localeCompare(b.name));
return `<h3 class="pf-kind-head">${esc(g.name)} <span class="pf-count">${g.list.length}</span></h3>
return `<h3 class="pf-kind-head">${esc(g.name)} <span class="pf-count">${g.list.length}</span></h3>
-<ul class="pf-grid">${g.list.map((i) => `<li class="pf-card r-${esc(i.rarity)}${worn.has(i.instance_id) ? ' is-worn' : ''}"><a href="${esc(i.url)}" title="${esc(`${i.name} · ${RARITY_LABEL[i.rarity]}`)}">${art(i)}<span class="pf-card-name">${esc(i.name)}</span>${rarityBadge(i.rarity)}${worn.has(i.instance_id) ? '<span class="pf-worn-tag">Worn</span>' : ''}</a></li>`).join('')}</ul>`;
+<ul class="pf-grid">${g.list.map((i) => `<li class="pf-card r-${esc(i.rarity)}${worn.has(i.instance_id) ? ' is-worn' : ''}"><a href="${esc(i.url)}" title="${esc(`${i.name} · ${RARITY_LABEL[i.rarity]}`)}">${art(i)}<span class="pf-card-name">${softBreaks(esc(i.name))}</span>${rarityBadge(i.rarity)}${worn.has(i.instance_id) ? '<span class="pf-worn-tag">Worn</span>' : ''}</a></li>`).join('')}</ul>`;
}).join('\n')}
}).join('\n')}
<p class="pf-note"><a href="${esc(all)}">${inv.more ? 'Every item' : 'This inventory'} on OpenVibe.Inventory</a> · <a href="${INVENTORY_SITE}/items">Every item there is</a></p></section>`;
<p class="pf-note"><a href="${esc(all)}">${inv.more ? 'Every item' : 'This inventory'} on OpenVibe.Inventory</a> · <a href="${INVENTORY_SITE}/items">Every item there is</a></p></section>`;
}
}
@@ -404,7 +420,7 @@ a{color:var(--accent)}
@@ -404,7 +420,7 @@ a{color:var(--accent)}
.pf-badge{display:inline-flex;align-items:center;gap:8px;padding:7px 14px 7px 10px;border-radius:999px;border:1px solid color-mix(in srgb,var(--r-legendary) 40%,var(--border));background:color-mix(in srgb,var(--r-legendary) 10%,var(--bg-secondary));color:var(--text-primary);text-decoration:none;font-weight:600;font-size:.88rem}
.pf-badge{display:inline-flex;align-items:center;gap:8px;padding:7px 14px 7px 10px;border-radius:999px;border:1px solid color-mix(in srgb,var(--r-legendary) 40%,var(--border));background:color-mix(in srgb,var(--r-legendary) 10%,var(--bg-secondary));color:var(--text-primary);text-decoration:none;font-weight:600;font-size:.88rem}
.pf-badge:hover,.pf-badge:focus-visible{border-color:var(--r-legendary)}
.pf-badge:hover,.pf-badge:focus-visible{border-color:var(--r-legendary)}
.pf-badge small{font-weight:500;color:var(--text-muted);font-size:.75rem}
.pf-badge small{font-weight:500;color:var(--text-muted);font-size:.75rem}
-.pf-badge-ic{font-size:1.05rem}
+.pf-badge-ic{font-size:1.05rem}.pf-badge-ic.is-site{display:inline-grid;place-items:center;width:20px;height:20px;color:var(--accent)}.pf-badge-ic.is-site svg{width:16px;height:16px}
.pf-games{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(auto-fill,minmax(min(300px,100%),1fr));gap:12px}
.pf-games{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(auto-fill,minmax(min(300px,100%),1fr));gap:12px}
.pf-game{border:1px solid var(--border);border-radius:14px;background:var(--bg-secondary);padding:12px 14px;min-width:0}
.pf-game{border:1px solid var(--border);border-radius:14px;background:var(--bg-secondary);padding:12px 14px;min-width:0}
.pf-game-head{display:flex;justify-content:space-between;gap:10px;align-items:baseline;text-decoration:none;color:inherit;margin-bottom:8px}
.pf-game-head{display:flex;justify-content:space-between;gap:10px;align-items:baseline;text-decoration:none;color:inherit;margin-bottom:8px}
@@ -64,7 +64,7 @@ const INSTANCES = [
@@ -64,7 +64,7 @@ const INSTANCES = [
if (u.pathname === `/api/v1/people/${A}/equipped`) return json(200, { subject: A, slots: { 'live.hat:hat': { instance_id: INSTANCES[0].id, definition_id: INSTANCES[0].definition_id }, 'live.name_effect:name_effect': { instance_id: INSTANCES[1].id, definition_id: INSTANCES[1].definition_id, token: 'name-fx-rainbow' }, 'network.badge:badge': { instance_id: INSTANCES[3].id, definition_id: INSTANCES[3].definition_id, media_id: 'med_01JZ00000000000000000000B1' } } });
if (u.pathname === `/api/v1/people/${A}/equipped`) return json(200, { subject: A, slots: { 'live.hat:hat': { instance_id: INSTANCES[0].id, definition_id: INSTANCES[0].definition_id }, 'live.name_effect:name_effect': { instance_id: INSTANCES[1].id, definition_id: INSTANCES[1].definition_id, token: 'name-fx-rainbow' }, 'network.badge:badge': { instance_id: INSTANCES[3].id, definition_id: INSTANCES[3].definition_id, media_id: 'med_01JZ00000000000000000000B1' } } });
if (u.pathname === `/api/v1/people/${A}/items`) return json(200, { subject: A, instances: INSTANCES, definitions: DEFS, next_cursor: null });
if (u.pathname === `/api/v1/people/${A}/items`) return json(200, { subject: A, instances: INSTANCES, definitions: DEFS, next_cursor: null });
if (u.pathname === `/api/v1/people/${A}/achievements`) return json(200, { subject: A, total: 3, titles: [{ title: 'salvage-rush', name: 'Salvage <Rush>', achievements: [{ id: 'big-haul', name: 'Big Haul', description: 'Bring back 50 parts', unlocked_at: '2026-10-20T10:00:00Z' }, { id: 'first-haul', name: 'First Haul', description: '', unlocked_at: '2026-10-19T10:00:00Z' }] }, { title: 'vibe-heroes', name: 'Vibe Heroes', achievements: [{ id: 'hello', name: 'Hello', unlocked_at: '2026-10-01T10:00:00Z' }] }] });
if (u.pathname === `/api/v1/people/${A}/achievements`) return json(200, { subject: A, total: 3, titles: [{ title: 'salvage-rush', name: 'Salvage <Rush>', achievements: [{ id: 'big-haul', name: 'Big Haul', description: 'Bring back 50 parts', unlocked_at: '2026-10-20T10:00:00Z' }, { id: 'first-haul', name: 'First Haul', description: '', unlocked_at: '2026-10-19T10:00:00Z' }] }, { title: 'vibe-heroes', name: 'Vibe Heroes', achievements: [{ id: 'hello', name: 'Hello', unlocked_at: '2026-10-01T10:00:00Z' }] }] });
- if (u.pathname === `/api/v1/profiles/${A}/badges`) return json(200, { subject: A, badges: [{ id: 'first-follow', name: 'First Follow', awarded_at: '2026-10-02T10:00:00Z' }, { id: 'thread-starter', name: 'Thread <Starter>', awarded_at: '2026-10-05T10:00:00Z' }] });
+ if (u.pathname === `/api/v1/profiles/${A}/badges`) return json(200, { subject: A, badges: [{ id: 'first-follow', name: 'First Follow', awarded_at: '2026-10-02T10:00:00Z', site: 'openvibe.live' }, { id: 'thread-starter', name: 'Thread <Starter>', awarded_at: '2026-10-05T10:00:00Z' }] });
return json(404, { code: 'inventory.unknown_subject' });
return json(404, { code: 'inventory.unknown_subject' });
});
});
await new Promise((r) => inv.listen(0, '127.0.0.1', r));
await new Promise((r) => inv.listen(0, '127.0.0.1', r));
@@ -105,6 +105,11 @@ const INSTANCES = [
@@ -105,6 +105,11 @@ const INSTANCES = [
assert.ok(/<span class="pf-stat-k">Achievements<\/span><span class="pf-stat-v">3<\/span>/.test(r.text));
assert.ok(/<span class="pf-stat-k">Achievements<\/span><span class="pf-stat-v">3<\/span>/.test(r.text));
assert.strictEqual(calls.find((c) => c.path.endsWith('/badges')).auth, 'Bearer tok:openvibe.quest:quest.badge.read', 'and for Quest');
assert.strictEqual(calls.find((c) => c.path.endsWith('/badges')).auth, 'Bearer tok:openvibe.quest:quest.badge.read', 'and for Quest');
assert.ok(r.text.includes('id="pf-badges"') && /Thread <Starter>[\s\S]*First Follow/.test(r.text), 'badges, newest first, escaped');
assert.ok(r.text.includes('id="pf-badges"') && /Thread <Starter>[\s\S]*First Follow/.test(r.text), 'badges, newest first, escaped');
+ // A badge wears the icon of the site whose quest awards it (Quest #173); one with no site keeps the medal.
+ const follow = r.text.slice(r.text.lastIndexOf('<li>', r.text.indexOf('<span>First Follow</span>')), r.text.indexOf('<span>First Follow</span>'));
+ assert.ok(follow.includes('class="pf-badge-ic is-site"') && follow.includes('<svg') && follow.includes('an OpenVibe.Live quest'), 'First Follow shows Live\'s icon');
+ const thread = r.text.slice(r.text.lastIndexOf('<li>', r.text.indexOf('Thread <Starter>')), r.text.indexOf('Thread <Starter>'));
+ assert.ok(thread.includes('🏅') && !thread.includes('is-site'), 'a badge with no site keeps the medal');
// The worn badge's image rule (.85em square) once shared the chips' class and squeezed every chip into it.
// The worn badge's image rule (.85em square) once shared the chips' class and squeezed every chip into it.
const css = (sel) => (r.text.match(new RegExp(`\\n${sel.replace('.', '\\.')}\\{([^}]*)\\}`)) || [])[1] || '';
const css = (sel) => (r.text.match(new RegExp(`\\n${sel.replace('.', '\\.')}\\{([^}]*)\\}`)) || [])[1] || '';
assert.ok(/padding:7px 14px/.test(css('.pf-badge')) && !/width/.test(css('.pf-badge')), 'a badge chip sizes to its name');
assert.ok(/padding:7px 14px/.test(css('.pf-badge')) && !/width/.test(css('.pf-badge')), 'a badge chip sizes to its name');