openvibers/openvibe.shared Commits
A person's stuff on any site (profile-panel.js): worn items, counts, achievements by game and badges from the Network profile, Steam-style (#114)
Changes
@@@ -6,6 +6,11 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).## 4.13.0 — unreleased++- **A person's stuff on any site** (`profile-panel.js`, `profile-panel.css`, new). One tag (`data-ov-profile="name"`)++ draws someone's worn items, their counts of items, badges, achievements and games, their latest unlock per game and++ links to their profile and inventory, from OpenVibe.Network's public profile (which gathers Inventory, Quest and++ Play since Network#333 and Play#38). Steam-style, so Games, Quest and any site can show it (the owner, 2026-10-25).++ `test/profile-panel.test.js`.- **One lit row in the menus on phones** (`navbar.js`). A tap left `:hover` on the row it touched, so the "all sites"launcher showed two highlighted rows: the site you are on and the last one your finger crossed (the owner, 2026-10-25,on openre.stream). The launcher's and the phone drawer's hover backgrounds now apply only under `@media (hover:@@@ -697,6 +697,25 @@ community badge's image (`img.ov-badge`) before the name, from the equipped readCalm setting and `prefers-reduced-motion` stop the animation. `window.OpenVibeItemsConfig = { api }` points it atanother Inventory (development).++### A person's stuff on any site (`profile-panel.js`, `profile-panel.css`)++++Steam-style: what someone wears, how many items, badges and achievements they have, their games with the latest++unlock, and links to their full profile and inventory, on any OpenVibe site (the owner, 2026-10-25: "you should be++able to see your inventory anywhere"). Put a fallback link in an element and load both files:++++```html++<link rel="stylesheet" href="/shared/profile-panel.css">++<div data-ov-profile="ana"><a href="https://openvibe.network/@ana">Ana's profile</a></div>++<script src="/shared/profile-panel.js" defer></script>++```++++Every `[data-ov-profile]` on the page is drawn once loaded; `OpenVibeProfilePanel.render(el, username)` draws one added++later. It reads OpenVibe.Network's public profile (`GET https://openvibe.network/api/v1/profiles/:username`, CORS++open, no credentials), which gathers OpenVibe.Inventory, OpenVibe.Quest and OpenVibe.Play. Values are text, links only++to OpenVibe's own https hosts, pictures only from openvibe.media and openvibe.network; a private profile shows its name++and picture; no answer leaves the fallback link. `window.OpenVibeProfilePanelConfig = { api }` points it at another++Network (development).++## Development```bash@@@ -32,6 +32,7 @@ const BROWSER = Object.freeze(['boost.js', // smooth same-site page moves for a server-rendered site (OVBoost; plan T11)'island.js', // activity island'items.js', // what people wear (OpenVibe.Inventory name effects, plan T21); draw with items.css++ 'profile-panel.js', // a person's stuff on any site (worn items, badges, achievements), from the Network profile'tooltip.js','player.js', // the network's one video player look: enhances video[data-ov-player]; draw with player.css'release-watch.js', // keeps open tabs on a supported release (ADR-016), loaded after first paint@@@ -44,6 +45,7 @@ const BROWSER = Object.freeze([const STYLES = Object.freeze(['showcase.css', // openvibe-shared/showcase's sections + the ring icons (generated: scripts/build-styles.js)'items.css', // Live's name effects for items.js (live.name_effect.css@1)++ 'profile-panel.css', // the panel profile-panel.js draws'player.css', // player.js's controls]);const served = (name) => BROWSER.includes(String(name)) || STYLES.includes(String(name));@@@ -34,6 +34,7 @@"./indexnow": "./indexnow.js","./island": "./island.js","./items": "./items.js",++ "./profile-panel": "./profile-panel.js","./legal": "./legal.js","./express-async": "./express-async.js","./metrics": "./metrics.js",nnew file mode 100644@@@ -0,0 +1,39 @@++/*++ * openvibe-shared/profile-panel.css — the panel profile-panel.js draws: a person's stuff on any OpenVibe site.++ * Theme tokens only, so it follows the site's theme and light or dark mode; every list can shrink to a phone.++ */++.ovpp { --ovpp-r-common: #94a3b8; --ovpp-r-uncommon: #22c55e; --ovpp-r-rare: #3b82f6; --ovpp-r-epic: #a855f7; --ovpp-r-legendary: #f59e0b;++ box-sizing: border-box; max-width: 520px; padding: 14px 16px; border: 1px solid var(--border, #1f2d47); border-radius: 16px;++ background: var(--bg-card, var(--bg-secondary, #101828)); color: var(--text-primary, #e6edf7); font-size: .92rem; line-height: 1.4; }++.ovpp *, .ovpp *::before, .ovpp *::after { box-sizing: border-box; }++.ovpp a { color: inherit; }++.ovpp-head { display: flex; align-items: center; gap: 12px; text-decoration: none; min-width: 0; }++.ovpp-pic { width: 48px; height: 48px; border-radius: 50%; object-fit: cover; flex: none; background: var(--bg-secondary, #101828); }++.ovpp-who { display: flex; flex-direction: column; min-width: 0; }++.ovpp-who b { font-size: 1.05rem; overflow-wrap: anywhere; }++.ovpp-who small { color: var(--text-secondary, #96a7c2); }++.ovpp-stats { list-style: none; margin: 12px 0 0; padding: 0; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; }++.ovpp-stats li { display: flex; flex-direction: column; align-items: center; padding: 6px 4px; border-radius: 10px; background: var(--bg-secondary, #101828); min-width: 0; }++.ovpp-stats b { font-size: 1.05rem; }++.ovpp-stats span { font-size: .72rem; color: var(--text-muted, #7386a3); }++.ovpp-sec { margin-top: 12px; }++.ovpp-h { margin: 0 0 6px; font-size: .72rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #7386a3); }++.ovpp-chips { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px; }++.ovpp-chip, .ovpp-chip a { display: inline-flex; align-items: center; gap: 6px; min-width: 0; max-width: 100%; }++.ovpp-chip { padding: 4px 10px 4px 4px; border-radius: 999px; border: 1px solid color-mix(in srgb, var(--rc, var(--ovpp-r-common)) 45%, var(--border, #1f2d47)); background: var(--bg-secondary, #101828); }++.ovpp-chip a { text-decoration: none; }++.ovpp-chip span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.ovpp-badge { padding: 4px 10px; --rc: var(--ovpp-r-legendary); }++.ovpp-art { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: 50%; flex: none; overflow: hidden;++ background: color-mix(in srgb, var(--rc, var(--ovpp-r-common)) 22%, transparent); font-size: 16px; }++.ovpp-art img { width: 100%; height: 100%; object-fit: cover; }++.ovpp .r-common { --rc: var(--ovpp-r-common); } .ovpp .r-uncommon { --rc: var(--ovpp-r-uncommon); } .ovpp .r-rare { --rc: var(--ovpp-r-rare); }++.ovpp .r-epic { --rc: var(--ovpp-r-epic); } .ovpp .r-legendary { --rc: var(--ovpp-r-legendary); }++.ovpp-list { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: minmax(0, 1fr); gap: 6px; }++.ovpp-list li { overflow-wrap: anywhere; }++.ovpp-game { font-weight: 700; text-decoration: none; }++.ovpp-list small { color: var(--text-muted, #7386a3); }++.ovpp-latest { display: block; color: var(--text-secondary, #96a7c2); font-size: .85rem; }++.ovpp-note { margin: 10px 0 0; color: var(--text-secondary, #96a7c2); }++.ovpp-links { margin: 12px 0 0; font-size: .85rem; color: var(--text-muted, #7386a3); }++.ovpp-links a { color: var(--accent, #3b82f6); }nnew file mode 100644@@@ -0,0 +1,173 @@++/**++ * openvibe-shared/profile-panel.js — a person's stuff on any OpenVibe site, Steam-style: what they wear, their item++ * count, their Quest badges and their achievements in every game, with links to the full profile and inventory.++ * Browser global `OpenVibeProfilePanel`; draw with /shared/profile-panel.css.++ *++ * <div data-ov-profile="ana"><a href="https://openvibe.network/@ana">Ana's profile</a></div>++ * <script src="/shared/profile-panel.js" defer></script>++ * OpenVibeProfilePanel.render(el, 'ana') or every [data-ov-profile] on the page, by itself, once loaded++ *++ * It reads OpenVibe.Network's public profile (GET https://openvibe.network/api/v1/profiles/:username: profile@1, CORS++ * open, no credentials, cached 60 s by the Network), which gathers Inventory, Quest and Play. The panel replaces the++ * element's content (a plain link is the no-JavaScript fallback). Every value is set as text, never markup; pictures++ * load only from openvibe.media and openvibe.network. A private profile shows its name and picture only; an answer++ * that does not come leaves the fallback as it was.++ */++(function (root) {++ 'use strict';++ const NAME = /^[A-Za-z0-9_]{1,32}$/;++ const MEDIA_ID = /^[A-Za-z0-9_-]{1,80}$/;++ const RARITY = ['common', 'uncommon', 'rare', 'epic', 'legendary'];++ const SAFE_HOST = /^(?:[a-z0-9-]+\.)*(?:openvibe\.[a-z]+|openre\.stream)$/;++ const api = () => String((root.OpenVibeProfilePanelConfig && root.OpenVibeProfilePanelConfig.api) || 'https://openvibe.network').replace(/\/+$/, '');++ const seen = new Map(); // username \u2192 Promise(profile | null), once a page++++ function load(username) {++ if (!seen.has(username)) {++ seen.set(username, fetch(`${api()}/api/v1/profiles/${encodeURIComponent(username)}`, { credentials: 'omit', headers: { Accept: 'application/json' } })++ .then((r) => (r.ok ? r.json() : null)).then((b) => (b && b.profile) || null).catch(() => null));++ }++ return seen.get(username);++ }++++ function el(tag, cls, text) {++ const n = root.document.createElement(tag);++ if (cls) n.className = cls;++ if (text != null) n.textContent = String(text);++ return n;++ }++ /** Only https links on the network's own hosts (openvibe.* and its subdomains, openre.stream) become hrefs. */++ function safeHref(href) {++ try {++ const u = new URL(String(href || ''));++ return u.protocol === 'https:' && SAFE_HOST.test(u.hostname) ? u.href : null;++ } catch { return null; }++ }++ function link(href, cls, text) {++ const a = el('a', cls, text);++ const h = safeHref(href);++ if (h) a.href = h;++ return a;++ }++ function stat(n, label) {++ const li = el('li');++ li.appendChild(el('b', null, Number(n) || 0));++ li.appendChild(el('span', null, label));++ return li;++ }++ function section(title, cls) {++ const s = el('div', `ovpp-sec ${cls}`);++ s.appendChild(el('h3', 'ovpp-h', title));++ return s;++ }++ function itemArt(art) {++ const a = art || {};++ const box = el('span', 'ovpp-art');++ if (a.media_id && MEDIA_ID.test(String(a.media_id))) {++ const img = el('img');++ img.src = `https://openvibe.media/o/${a.media_id}`;++ img.alt = '';++ img.loading = 'lazy';++ img.width = 28; img.height = 28;++ box.appendChild(img);++ } else box.textContent = String(a.emoji || '\u25c6').slice(0, 8);++ box.setAttribute('aria-hidden', 'true');++ return box;++ }++++ function draw(target, p) {++ const card = el('section', 'ovpp');++ card.setAttribute('aria-label', `${p.display_name || p.username} on OpenVibe`);++ const head = link(p.profile_url, 'ovpp-head');++ const pic = el('img', 'ovpp-pic');++ pic.src = `${api()}/avatar/${encodeURIComponent(p.username)}?s=96`;++ pic.alt = ''; pic.width = 48; pic.height = 48;++ head.appendChild(pic);++ const who = el('span', 'ovpp-who');++ who.appendChild(el('b', null, p.display_name || p.username));++ who.appendChild(el('small', null, `@${p.username}`));++ head.appendChild(who);++ card.appendChild(head);++ if (p.private) {++ card.appendChild(el('p', 'ovpp-note', 'This profile is private.'));++ target.replaceChildren(card);++ return card;++ }++ const ach = p.achievements || { total: 0, games: [] };++ const stats = el('ul', 'ovpp-stats');++ stats.appendChild(stat((p.items && p.items.count) || 0, 'items'));++ stats.appendChild(stat((p.badges || []).length, 'badges'));++ stats.appendChild(stat(ach.total || 0, 'achievements'));++ stats.appendChild(stat((ach.games || []).length, 'games'));++ card.appendChild(stats);++++ if ((p.showcase || []).length) {++ const s = section('Wearing', 'ovpp-wear');++ const ul = el('ul', 'ovpp-chips');++ p.showcase.slice(0, 6).forEach((i) => {++ const li = el('li', `ovpp-chip r-${RARITY.includes(i.rarity) ? i.rarity : 'common'}`);++ const a = link(i.url, null);++ a.title = `${i.kind_name || ''} \u00b7 ${i.name}`;++ a.appendChild(itemArt(i.art));++ a.appendChild(el('span', null, i.name));++ li.appendChild(a);++ ul.appendChild(li);++ });++ s.appendChild(ul);++ card.appendChild(s);++ }++ if ((ach.games || []).length) {++ const s = section('Achievements', 'ovpp-ach');++ const ul = el('ul', 'ovpp-list');++ ach.games.slice(0, 3).forEach((g) => {++ const li = el('li');++ const a = link(g.url, 'ovpp-game', g.name);++ li.appendChild(a);++ li.appendChild(el('small', null, ` \u00b7 ${g.count} unlocked`));++ const latest = (g.recent || [])[0];++ if (latest) li.appendChild(el('span', 'ovpp-latest', `\u{1F3C6} ${latest.name}`));++ ul.appendChild(li);++ });++ s.appendChild(ul);++ card.appendChild(s);++ }++ if ((p.badges || []).length) {++ const s = section('Badges', 'ovpp-badges');++ const ul = el('ul', 'ovpp-chips');++ p.badges.slice(0, 8).forEach((b) => ul.appendChild(el('li', 'ovpp-chip ovpp-badge', `\u{1F3C5} ${b.name}`)));++ s.appendChild(ul);++ card.appendChild(s);++ }++ const foot = el('p', 'ovpp-links');++ foot.appendChild(link(p.profile_url, null, 'Full profile'));++ if (p.inventory_url) { foot.appendChild(root.document.createTextNode(' \u00b7 ')); foot.appendChild(link(p.inventory_url, null, 'Inventory')); }++ card.appendChild(foot);++ target.replaceChildren(card);++ return card;++ }++++ /** Draw `username`'s panel into `target`. → the panel, or null (the fallback stays) when there is no answer. */++ async function render(target, username) {++ const name = String(username || '');++ if (!target || !NAME.test(name)) return null;++ const p = await load(name);++ return p ? draw(target, p) : null;++ }++++ function renderAll(node) {++ const scope = node || root.document;++ scope.querySelectorAll('[data-ov-profile]').forEach((n) => {++ if (n.dataset.ovProfileDone) return;++ n.dataset.ovProfileDone = '1';++ render(n, n.getAttribute('data-ov-profile'));++ });++ }++++ const out = { render, renderAll, _draw: draw };++ root.OpenVibeProfilePanel = out;++ if (typeof module !== 'undefined' && module.exports) module.exports = out;++ if (root.document && root.document.addEventListener) {++ if (root.document.readyState === 'loading') root.document.addEventListener('DOMContentLoaded', () => renderAll());++ else renderAll();++ }++})(typeof window !== 'undefined' ? window : globalThis);nnew file mode 100644@@@ -0,0 +1,92 @@++'use strict';++// profile-panel.js in a real headless Chrome at phone width: [data-ov-profile] elements become a person's stuff from++// the Network's public profile JSON (worn items, counts, achievements by game, badges, links); every value is text++// (a name with markup stays text), only https links on OpenVibe's own hosts become hrefs, a private profile shows its++// name only, a missing one leaves the fallback link, and nothing on the panel widens the page past the phone.++// Skipped (exit 0) without Chrome or with OV_SKIP_BROWSER=1.++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const h = require('../browser-harness');++++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {++ console.log('profile-panel: skipped (no Chrome; set CHROME_BIN)');++ process.exit(0);++}++++const ROOT = path.join(__dirname, '..');++const ANA = {++ username: 'ana', display_name: 'Ana <img src=x onerror=alert(1)>', profile_url: 'https://openvibe.network/@ana', private: false,++ showcase: [++ { kind: 'live.hat', kind_name: 'Hat', name: 'Royal Crown', rarity: 'legendary', art: { emoji: '👑' }, url: 'https://inventory.openvibe.network/items/itd_1' },++ { kind: 'live.name_effect', kind_name: 'Name effect', name: 'Rainbow', rarity: 'epic', art: { emoji: '🌈' }, url: 'javascript:alert(1)' },++ ],++ items: { count: 12 }, inventory_url: 'https://inventory.openvibe.network/u/usr_1',++ badges: [{ id: 'first-follow', name: 'First Follow' }],++ achievements: { total: 3, games: [{ title: 'salvage-rush', name: 'Salvage Rush', count: 2, url: 'https://play.openvibe.network/t/salvage-rush', recent: [{ name: 'Big Haul' }] }, { title: 'x', name: 'Evil', count: 1, url: 'https://evil.example/t/x', recent: [] }] },++};++const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Panel</title><link rel="icon" href="data:,">++<link rel="stylesheet" href="/shared/profile-panel.css"></head>++<body style="margin:0;padding:16px"><div id="ana" data-ov-profile="ana"><a href="https://openvibe.network/@ana">Ana</a></div>++<div id="priv" data-ov-profile="hidden"><a href="https://openvibe.network/@hidden">Hidden</a></div>++<div id="none" data-ov-profile="nobody"><a href="https://openvibe.network/@nobody">Nobody</a></div>++<script>window.OpenVibeProfilePanelConfig = { api: location.origin };</script>++<script src="/shared/profile-panel.js" defer></script></body></html>`;++const server = http.createServer((req, res) => {++ const u = new URL(req.url, 'http://x');++ const json = (s, o) => { res.writeHead(s, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); };++ if (u.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(PAGE); }++ if (u.pathname === '/api/v1/profiles/ana') return json(200, { profile: ANA });++ if (u.pathname === '/api/v1/profiles/hidden') return json(200, { profile: { username: 'hidden', display_name: 'Hidden', profile_url: 'https://openvibe.network/@hidden', private: true } });++ if (u.pathname.startsWith('/api/v1/profiles/')) return json(404, { error: 'not_found' });++ if (u.pathname.startsWith('/avatar/')) { res.writeHead(204); return res.end(); }++ const m = /^\/shared\/([a-z0-9-]+\.(?:js|css))$/.exec(u.pathname);++ if (m && fs.existsSync(path.join(ROOT, m[1]))) { res.writeHead(200, { 'content-type': m[1].endsWith('.css') ? 'text/css' : 'application/javascript' }); return res.end(fs.readFileSync(path.join(ROOT, m[1]))); }++ res.writeHead(404); return res.end();++});++++(async () => {++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));++ const base = `http://127.0.0.1:${server.address().port}`;++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-profile-panel-'));++ const browser = await h.launch({ tmpDir });++ try {++ const context = await browser.newContext();++ const p = await h.openPage(browser, context, { width: 360, height: 900 });++ await p.goto(`${base}/`, { maxMs: 5000 });++ const got = JSON.parse(await p.evaluate(`new Promise((r) => { const t = setInterval(() => { if (document.querySelector('#ana .ovpp') && document.querySelector('#priv .ovpp')) { clearInterval(t); setTimeout(() => {++ const ana = document.querySelector('#ana .ovpp');++ r(JSON.stringify({++ name: ana.querySelector('.ovpp-who b').textContent,++ imgs: ana.querySelectorAll('img').length,++ stats: [...ana.querySelectorAll('.ovpp-stats li')].map((li) => li.textContent),++ worn: [...ana.querySelectorAll('.ovpp-wear a')].map((a) => [a.textContent, a.getAttribute('href')]),++ games: [...ana.querySelectorAll('.ovpp-ach a')].map((a) => [a.textContent, a.getAttribute('href')]),++ latest: ana.querySelector('.ovpp-latest') && ana.querySelector('.ovpp-latest').textContent,++ badges: [...ana.querySelectorAll('.ovpp-badges li')].map((li) => li.textContent),++ links: [...ana.querySelectorAll('.ovpp-links a')].map((a) => a.getAttribute('href')),++ priv: document.querySelector('#priv .ovpp').textContent,++ none: document.getElementById('none').innerHTML,++ wide: document.documentElement.scrollWidth,++ }));++ }, 100); } }, 30); })`));++ assert.strictEqual(got.name, 'Ana <img src=x onerror=alert(1)>', 'a name with markup stays text');++ assert.strictEqual(got.imgs, 1, 'only the picture is an image (no injected img)');++ assert.deepStrictEqual(got.stats, ['12items', '1badges', '3achievements', '2games']);++ assert.deepStrictEqual(got.worn, [['👑Royal Crown', 'https://inventory.openvibe.network/items/itd_1'], ['🌈Rainbow', null]], 'a javascript: link never becomes an href');++ assert.deepStrictEqual(got.games, [['Salvage Rush', 'https://play.openvibe.network/t/salvage-rush'], ['Evil', null]], 'only OpenVibe hosts are linked');++ assert.strictEqual(got.latest, '🏆 Big Haul');++ assert.deepStrictEqual(got.badges, ['🏅 First Follow']);++ assert.deepStrictEqual(got.links, ['https://openvibe.network/@ana', 'https://inventory.openvibe.network/u/usr_1']);++ assert.match(got.priv, /Hidden[\s\S]*This profile is private\./);++ assert.match(got.none, /href="https:\/\/openvibe\.network\/@nobody"/, 'no profile: the fallback link stays');++ assert.ok(got.wide <= 360, `fits a 360 px phone (${got.wide})`);++ console.log('profile-panel: ok');++ } finally {++ await browser.close();++ server.close();++ fs.rmSync(tmpDir, { recursive: true, force: true });++ }++})().catch((e) => { console.error(e); process.exit(1); });
@@ -6,6 +6,11 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
@@ -6,6 +6,11 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## 4.13.0 — unreleased
## 4.13.0 — unreleased
+- **A person's stuff on any site** (`profile-panel.js`, `profile-panel.css`, new). One tag (`data-ov-profile="name"`)
+ draws someone's worn items, their counts of items, badges, achievements and games, their latest unlock per game and
+ links to their profile and inventory, from OpenVibe.Network's public profile (which gathers Inventory, Quest and
+ Play since Network#333 and Play#38). Steam-style, so Games, Quest and any site can show it (the owner, 2026-10-25).
+ `test/profile-panel.test.js`.
- **One lit row in the menus on phones** (`navbar.js`). A tap left `:hover` on the row it touched, so the "all sites"
- **One lit row in the menus on phones** (`navbar.js`). A tap left `:hover` on the row it touched, so the "all sites"
launcher showed two highlighted rows: the site you are on and the last one your finger crossed (the owner, 2026-10-25,
launcher showed two highlighted rows: the site you are on and the last one your finger crossed (the owner, 2026-10-25,
on openre.stream). The launcher's and the phone drawer's hover backgrounds now apply only under `@media (hover:
on openre.stream). The launcher's and the phone drawer's hover backgrounds now apply only under `@media (hover:
@@ -697,6 +697,25 @@ community badge's image (`img.ov-badge`) before the name, from the equipped read
@@ -697,6 +697,25 @@ community badge's image (`img.ov-badge`) before the name, from the equipped read
Calm setting and `prefers-reduced-motion` stop the animation. `window.OpenVibeItemsConfig = { api }` points it at
Calm setting and `prefers-reduced-motion` stop the animation. `window.OpenVibeItemsConfig = { api }` points it at
another Inventory (development).
another Inventory (development).
+### A person's stuff on any site (`profile-panel.js`, `profile-panel.css`)
+
+Steam-style: what someone wears, how many items, badges and achievements they have, their games with the latest
+unlock, and links to their full profile and inventory, on any OpenVibe site (the owner, 2026-10-25: "you should be
+able to see your inventory anywhere"). Put a fallback link in an element and load both files:
+
+```html
+<link rel="stylesheet" href="/shared/profile-panel.css">
+<div data-ov-profile="ana"><a href="https://openvibe.network/@ana">Ana's profile</a></div>
+<script src="/shared/profile-panel.js" defer></script>
+```
+
+Every `[data-ov-profile]` on the page is drawn once loaded; `OpenVibeProfilePanel.render(el, username)` draws one added
+later. It reads OpenVibe.Network's public profile (`GET https://openvibe.network/api/v1/profiles/:username`, CORS
+open, no credentials), which gathers OpenVibe.Inventory, OpenVibe.Quest and OpenVibe.Play. Values are text, links only
+to OpenVibe's own https hosts, pictures only from openvibe.media and openvibe.network; a private profile shows its name
+and picture; no answer leaves the fallback link. `window.OpenVibeProfilePanelConfig = { api }` points it at another
+Network (development).
+
## Development
## Development
```bash
```bash
@@ -32,6 +32,7 @@ const BROWSER = Object.freeze([
@@ -32,6 +32,7 @@ const BROWSER = Object.freeze([
'boost.js', // smooth same-site page moves for a server-rendered site (OVBoost; plan T11)
'boost.js', // smooth same-site page moves for a server-rendered site (OVBoost; plan T11)
'island.js', // activity island
'island.js', // activity island
'items.js', // what people wear (OpenVibe.Inventory name effects, plan T21); draw with items.css
'items.js', // what people wear (OpenVibe.Inventory name effects, plan T21); draw with items.css
+ 'profile-panel.js', // a person's stuff on any site (worn items, badges, achievements), from the Network profile
'tooltip.js',
'tooltip.js',
'player.js', // the network's one video player look: enhances video[data-ov-player]; draw with player.css
'player.js', // the network's one video player look: enhances video[data-ov-player]; draw with player.css
'release-watch.js', // keeps open tabs on a supported release (ADR-016), loaded after first paint
'release-watch.js', // keeps open tabs on a supported release (ADR-016), loaded after first paint
@@ -44,6 +45,7 @@ const BROWSER = Object.freeze([
@@ -44,6 +45,7 @@ const BROWSER = Object.freeze([
const STYLES = Object.freeze([
const STYLES = Object.freeze([
'showcase.css', // openvibe-shared/showcase's sections + the ring icons (generated: scripts/build-styles.js)
'showcase.css', // openvibe-shared/showcase's sections + the ring icons (generated: scripts/build-styles.js)
'items.css', // Live's name effects for items.js (live.name_effect.css@1)
'items.css', // Live's name effects for items.js (live.name_effect.css@1)
+ 'profile-panel.css', // the panel profile-panel.js draws
'player.css', // player.js's controls
'player.css', // player.js's controls
]);
]);
const served = (name) => BROWSER.includes(String(name)) || STYLES.includes(String(name));
const served = (name) => BROWSER.includes(String(name)) || STYLES.includes(String(name));
@@ -34,6 +34,7 @@
@@ -34,6 +34,7 @@
"./indexnow": "./indexnow.js",
"./indexnow": "./indexnow.js",
"./island": "./island.js",
"./island": "./island.js",
"./items": "./items.js",
"./items": "./items.js",
+ "./profile-panel": "./profile-panel.js",
"./legal": "./legal.js",
"./legal": "./legal.js",
"./express-async": "./express-async.js",
"./express-async": "./express-async.js",
"./metrics": "./metrics.js",
"./metrics": "./metrics.js",
new file mode 100644
new file mode 100644
@@ -0,0 +1,39 @@
@@ -0,0 +1,39 @@
+/*
+ * openvibe-shared/profile-panel.css — the panel profile-panel.js draws: a person's stuff on any OpenVibe site.
+ * Theme tokens only, so it follows the site's theme and light or dark mode; every list can shrink to a phone.
+ */
+.ovpp { --ovpp-r-common: #94a3b8; --ovpp-r-uncommon: #22c55e; --ovpp-r-rare: #3b82f6; --ovpp-r-epic: #a855f7; --ovpp-r-legendary: #f59e0b;
+ box-sizing: border-box; max-width: 520px; padding: 14px 16px; border: 1px solid var(--border, #1f2d47); border-radius: 16px;
+ background: var(--bg-card, var(--bg-secondary, #101828)); color: var(--text-primary, #e6edf7); font-size: .92rem; line-height: 1.4; }
+.ovpp *, .ovpp *::before, .ovpp *::after { box-sizing: border-box; }
+.ovpp a { color: inherit; }
+.ovpp-head { display: flex; align-items: center; gap: 12px; text-decoration: none; min-width: 0; }
+.ovpp-pic { width: 48px; height: 48px; border-radius: 50%; object-fit: cover; flex: none; background: var(--bg-secondary, #101828); }
+.ovpp-who { display: flex; flex-direction: column; min-width: 0; }
+.ovpp-who b { font-size: 1.05rem; overflow-wrap: anywhere; }
+.ovpp-who small { color: var(--text-secondary, #96a7c2); }
+.ovpp-stats { list-style: none; margin: 12px 0 0; padding: 0; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; }
+.ovpp-stats li { display: flex; flex-direction: column; align-items: center; padding: 6px 4px; border-radius: 10px; background: var(--bg-secondary, #101828); min-width: 0; }
+.ovpp-stats b { font-size: 1.05rem; }
+.ovpp-stats span { font-size: .72rem; color: var(--text-muted, #7386a3); }
+.ovpp-sec { margin-top: 12px; }
+.ovpp-h { margin: 0 0 6px; font-size: .72rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
+.ovpp-chips { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px; }
+.ovpp-chip, .ovpp-chip a { display: inline-flex; align-items: center; gap: 6px; min-width: 0; max-width: 100%; }
+.ovpp-chip { padding: 4px 10px 4px 4px; border-radius: 999px; border: 1px solid color-mix(in srgb, var(--rc, var(--ovpp-r-common)) 45%, var(--border, #1f2d47)); background: var(--bg-secondary, #101828); }
+.ovpp-chip a { text-decoration: none; }
+.ovpp-chip span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ovpp-badge { padding: 4px 10px; --rc: var(--ovpp-r-legendary); }
+.ovpp-art { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: 50%; flex: none; overflow: hidden;
+ background: color-mix(in srgb, var(--rc, var(--ovpp-r-common)) 22%, transparent); font-size: 16px; }
+.ovpp-art img { width: 100%; height: 100%; object-fit: cover; }
+.ovpp .r-common { --rc: var(--ovpp-r-common); } .ovpp .r-uncommon { --rc: var(--ovpp-r-uncommon); } .ovpp .r-rare { --rc: var(--ovpp-r-rare); }
+.ovpp .r-epic { --rc: var(--ovpp-r-epic); } .ovpp .r-legendary { --rc: var(--ovpp-r-legendary); }
+.ovpp-list { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: minmax(0, 1fr); gap: 6px; }
+.ovpp-list li { overflow-wrap: anywhere; }
+.ovpp-game { font-weight: 700; text-decoration: none; }
+.ovpp-list small { color: var(--text-muted, #7386a3); }
+.ovpp-latest { display: block; color: var(--text-secondary, #96a7c2); font-size: .85rem; }
+.ovpp-note { margin: 10px 0 0; color: var(--text-secondary, #96a7c2); }
+.ovpp-links { margin: 12px 0 0; font-size: .85rem; color: var(--text-muted, #7386a3); }
+.ovpp-links a { color: var(--accent, #3b82f6); }
new file mode 100644
new file mode 100644
@@ -0,0 +1,173 @@
@@ -0,0 +1,173 @@
+/**
+ * openvibe-shared/profile-panel.js — a person's stuff on any OpenVibe site, Steam-style: what they wear, their item
+ * count, their Quest badges and their achievements in every game, with links to the full profile and inventory.
+ * Browser global `OpenVibeProfilePanel`; draw with /shared/profile-panel.css.
+ *
+ * <div data-ov-profile="ana"><a href="https://openvibe.network/@ana">Ana's profile</a></div>
+ * <script src="/shared/profile-panel.js" defer></script>
+ * OpenVibeProfilePanel.render(el, 'ana') or every [data-ov-profile] on the page, by itself, once loaded
+ *
+ * It reads OpenVibe.Network's public profile (GET https://openvibe.network/api/v1/profiles/:username: profile@1, CORS
+ * open, no credentials, cached 60 s by the Network), which gathers Inventory, Quest and Play. The panel replaces the
+ * element's content (a plain link is the no-JavaScript fallback). Every value is set as text, never markup; pictures
+ * load only from openvibe.media and openvibe.network. A private profile shows its name and picture only; an answer
+ * that does not come leaves the fallback as it was.
+ */
+(function (root) {
+ 'use strict';
+ const NAME = /^[A-Za-z0-9_]{1,32}$/;
+ const MEDIA_ID = /^[A-Za-z0-9_-]{1,80}$/;
+ const RARITY = ['common', 'uncommon', 'rare', 'epic', 'legendary'];
+ const SAFE_HOST = /^(?:[a-z0-9-]+\.)*(?:openvibe\.[a-z]+|openre\.stream)$/;
+ const api = () => String((root.OpenVibeProfilePanelConfig && root.OpenVibeProfilePanelConfig.api) || 'https://openvibe.network').replace(/\/+$/, '');
+ const seen = new Map(); // username \u2192 Promise(profile | null), once a page
+
+ function load(username) {
+ if (!seen.has(username)) {
+ seen.set(username, fetch(`${api()}/api/v1/profiles/${encodeURIComponent(username)}`, { credentials: 'omit', headers: { Accept: 'application/json' } })
+ .then((r) => (r.ok ? r.json() : null)).then((b) => (b && b.profile) || null).catch(() => null));
+ }
+ return seen.get(username);
+ }
+
+ function el(tag, cls, text) {
+ const n = root.document.createElement(tag);
+ if (cls) n.className = cls;
+ if (text != null) n.textContent = String(text);
+ return n;
+ }
+ /** Only https links on the network's own hosts (openvibe.* and its subdomains, openre.stream) become hrefs. */
+ function safeHref(href) {
+ try {
+ const u = new URL(String(href || ''));
+ return u.protocol === 'https:' && SAFE_HOST.test(u.hostname) ? u.href : null;
+ } catch { return null; }
+ }
+ function link(href, cls, text) {
+ const a = el('a', cls, text);
+ const h = safeHref(href);
+ if (h) a.href = h;
+ return a;
+ }
+ function stat(n, label) {
+ const li = el('li');
+ li.appendChild(el('b', null, Number(n) || 0));
+ li.appendChild(el('span', null, label));
+ return li;
+ }
+ function section(title, cls) {
+ const s = el('div', `ovpp-sec ${cls}`);
+ s.appendChild(el('h3', 'ovpp-h', title));
+ return s;
+ }
+ function itemArt(art) {
+ const a = art || {};
+ const box = el('span', 'ovpp-art');
+ if (a.media_id && MEDIA_ID.test(String(a.media_id))) {
+ const img = el('img');
+ img.src = `https://openvibe.media/o/${a.media_id}`;
+ img.alt = '';
+ img.loading = 'lazy';
+ img.width = 28; img.height = 28;
+ box.appendChild(img);
+ } else box.textContent = String(a.emoji || '\u25c6').slice(0, 8);
+ box.setAttribute('aria-hidden', 'true');
+ return box;
+ }
+
+ function draw(target, p) {
+ const card = el('section', 'ovpp');
+ card.setAttribute('aria-label', `${p.display_name || p.username} on OpenVibe`);
+ const head = link(p.profile_url, 'ovpp-head');
+ const pic = el('img', 'ovpp-pic');
+ pic.src = `${api()}/avatar/${encodeURIComponent(p.username)}?s=96`;
+ pic.alt = ''; pic.width = 48; pic.height = 48;
+ head.appendChild(pic);
+ const who = el('span', 'ovpp-who');
+ who.appendChild(el('b', null, p.display_name || p.username));
+ who.appendChild(el('small', null, `@${p.username}`));
+ head.appendChild(who);
+ card.appendChild(head);
+ if (p.private) {
+ card.appendChild(el('p', 'ovpp-note', 'This profile is private.'));
+ target.replaceChildren(card);
+ return card;
+ }
+ const ach = p.achievements || { total: 0, games: [] };
+ const stats = el('ul', 'ovpp-stats');
+ stats.appendChild(stat((p.items && p.items.count) || 0, 'items'));
+ stats.appendChild(stat((p.badges || []).length, 'badges'));
+ stats.appendChild(stat(ach.total || 0, 'achievements'));
+ stats.appendChild(stat((ach.games || []).length, 'games'));
+ card.appendChild(stats);
+
+ if ((p.showcase || []).length) {
+ const s = section('Wearing', 'ovpp-wear');
+ const ul = el('ul', 'ovpp-chips');
+ p.showcase.slice(0, 6).forEach((i) => {
+ const li = el('li', `ovpp-chip r-${RARITY.includes(i.rarity) ? i.rarity : 'common'}`);
+ const a = link(i.url, null);
+ a.title = `${i.kind_name || ''} \u00b7 ${i.name}`;
+ a.appendChild(itemArt(i.art));
+ a.appendChild(el('span', null, i.name));
+ li.appendChild(a);
+ ul.appendChild(li);
+ });
+ s.appendChild(ul);
+ card.appendChild(s);
+ }
+ if ((ach.games || []).length) {
+ const s = section('Achievements', 'ovpp-ach');
+ const ul = el('ul', 'ovpp-list');
+ ach.games.slice(0, 3).forEach((g) => {
+ const li = el('li');
+ const a = link(g.url, 'ovpp-game', g.name);
+ li.appendChild(a);
+ li.appendChild(el('small', null, ` \u00b7 ${g.count} unlocked`));
+ const latest = (g.recent || [])[0];
+ if (latest) li.appendChild(el('span', 'ovpp-latest', `\u{1F3C6} ${latest.name}`));
+ ul.appendChild(li);
+ });
+ s.appendChild(ul);
+ card.appendChild(s);
+ }
+ if ((p.badges || []).length) {
+ const s = section('Badges', 'ovpp-badges');
+ const ul = el('ul', 'ovpp-chips');
+ p.badges.slice(0, 8).forEach((b) => ul.appendChild(el('li', 'ovpp-chip ovpp-badge', `\u{1F3C5} ${b.name}`)));
+ s.appendChild(ul);
+ card.appendChild(s);
+ }
+ const foot = el('p', 'ovpp-links');
+ foot.appendChild(link(p.profile_url, null, 'Full profile'));
+ if (p.inventory_url) { foot.appendChild(root.document.createTextNode(' \u00b7 ')); foot.appendChild(link(p.inventory_url, null, 'Inventory')); }
+ card.appendChild(foot);
+ target.replaceChildren(card);
+ return card;
+ }
+
+ /** Draw `username`'s panel into `target`. → the panel, or null (the fallback stays) when there is no answer. */
+ async function render(target, username) {
+ const name = String(username || '');
+ if (!target || !NAME.test(name)) return null;
+ const p = await load(name);
+ return p ? draw(target, p) : null;
+ }
+
+ function renderAll(node) {
+ const scope = node || root.document;
+ scope.querySelectorAll('[data-ov-profile]').forEach((n) => {
+ if (n.dataset.ovProfileDone) return;
+ n.dataset.ovProfileDone = '1';
+ render(n, n.getAttribute('data-ov-profile'));
+ });
+ }
+
+ const out = { render, renderAll, _draw: draw };
+ root.OpenVibeProfilePanel = out;
+ if (typeof module !== 'undefined' && module.exports) module.exports = out;
+ if (root.document && root.document.addEventListener) {
+ if (root.document.readyState === 'loading') root.document.addEventListener('DOMContentLoaded', () => renderAll());
+ else renderAll();
+ }
+})(typeof window !== 'undefined' ? window : globalThis);
new file mode 100644
new file mode 100644
@@ -0,0 +1,92 @@
@@ -0,0 +1,92 @@
+'use strict';
+// profile-panel.js in a real headless Chrome at phone width: [data-ov-profile] elements become a person's stuff from
+// the Network's public profile JSON (worn items, counts, achievements by game, badges, links); every value is text
+// (a name with markup stays text), only https links on OpenVibe's own hosts become hrefs, a private profile shows its
+// name only, a missing one leaves the fallback link, and nothing on the panel widens the page past the phone.
+// Skipped (exit 0) without Chrome or with OV_SKIP_BROWSER=1.
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const h = require('../browser-harness');
+
+if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {
+ console.log('profile-panel: skipped (no Chrome; set CHROME_BIN)');
+ process.exit(0);
+}
+
+const ROOT = path.join(__dirname, '..');
+const ANA = {
+ username: 'ana', display_name: 'Ana <img src=x onerror=alert(1)>', profile_url: 'https://openvibe.network/@ana', private: false,
+ showcase: [
+ { kind: 'live.hat', kind_name: 'Hat', name: 'Royal Crown', rarity: 'legendary', art: { emoji: '👑' }, url: 'https://inventory.openvibe.network/items/itd_1' },
+ { kind: 'live.name_effect', kind_name: 'Name effect', name: 'Rainbow', rarity: 'epic', art: { emoji: '🌈' }, url: 'javascript:alert(1)' },
+ ],
+ items: { count: 12 }, inventory_url: 'https://inventory.openvibe.network/u/usr_1',
+ badges: [{ id: 'first-follow', name: 'First Follow' }],
+ achievements: { total: 3, games: [{ title: 'salvage-rush', name: 'Salvage Rush', count: 2, url: 'https://play.openvibe.network/t/salvage-rush', recent: [{ name: 'Big Haul' }] }, { title: 'x', name: 'Evil', count: 1, url: 'https://evil.example/t/x', recent: [] }] },
+};
+const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Panel</title><link rel="icon" href="data:,">
+<link rel="stylesheet" href="/shared/profile-panel.css"></head>
+<body style="margin:0;padding:16px"><div id="ana" data-ov-profile="ana"><a href="https://openvibe.network/@ana">Ana</a></div>
+<div id="priv" data-ov-profile="hidden"><a href="https://openvibe.network/@hidden">Hidden</a></div>
+<div id="none" data-ov-profile="nobody"><a href="https://openvibe.network/@nobody">Nobody</a></div>
+<script>window.OpenVibeProfilePanelConfig = { api: location.origin };</script>
+<script src="/shared/profile-panel.js" defer></script></body></html>`;
+const server = http.createServer((req, res) => {
+ const u = new URL(req.url, 'http://x');
+ const json = (s, o) => { res.writeHead(s, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); };
+ if (u.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(PAGE); }
+ if (u.pathname === '/api/v1/profiles/ana') return json(200, { profile: ANA });
+ if (u.pathname === '/api/v1/profiles/hidden') return json(200, { profile: { username: 'hidden', display_name: 'Hidden', profile_url: 'https://openvibe.network/@hidden', private: true } });
+ if (u.pathname.startsWith('/api/v1/profiles/')) return json(404, { error: 'not_found' });
+ if (u.pathname.startsWith('/avatar/')) { res.writeHead(204); return res.end(); }
+ const m = /^\/shared\/([a-z0-9-]+\.(?:js|css))$/.exec(u.pathname);
+ if (m && fs.existsSync(path.join(ROOT, m[1]))) { res.writeHead(200, { 'content-type': m[1].endsWith('.css') ? 'text/css' : 'application/javascript' }); return res.end(fs.readFileSync(path.join(ROOT, m[1]))); }
+ res.writeHead(404); return res.end();
+});
+
+(async () => {
+ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-profile-panel-'));
+ const browser = await h.launch({ tmpDir });
+ try {
+ const context = await browser.newContext();
+ const p = await h.openPage(browser, context, { width: 360, height: 900 });
+ await p.goto(`${base}/`, { maxMs: 5000 });
+ const got = JSON.parse(await p.evaluate(`new Promise((r) => { const t = setInterval(() => { if (document.querySelector('#ana .ovpp') && document.querySelector('#priv .ovpp')) { clearInterval(t); setTimeout(() => {
+ const ana = document.querySelector('#ana .ovpp');
+ r(JSON.stringify({
+ name: ana.querySelector('.ovpp-who b').textContent,
+ imgs: ana.querySelectorAll('img').length,
+ stats: [...ana.querySelectorAll('.ovpp-stats li')].map((li) => li.textContent),
+ worn: [...ana.querySelectorAll('.ovpp-wear a')].map((a) => [a.textContent, a.getAttribute('href')]),
+ games: [...ana.querySelectorAll('.ovpp-ach a')].map((a) => [a.textContent, a.getAttribute('href')]),
+ latest: ana.querySelector('.ovpp-latest') && ana.querySelector('.ovpp-latest').textContent,
+ badges: [...ana.querySelectorAll('.ovpp-badges li')].map((li) => li.textContent),
+ links: [...ana.querySelectorAll('.ovpp-links a')].map((a) => a.getAttribute('href')),
+ priv: document.querySelector('#priv .ovpp').textContent,
+ none: document.getElementById('none').innerHTML,
+ wide: document.documentElement.scrollWidth,
+ }));
+ }, 100); } }, 30); })`));
+ assert.strictEqual(got.name, 'Ana <img src=x onerror=alert(1)>', 'a name with markup stays text');
+ assert.strictEqual(got.imgs, 1, 'only the picture is an image (no injected img)');
+ assert.deepStrictEqual(got.stats, ['12items', '1badges', '3achievements', '2games']);
+ assert.deepStrictEqual(got.worn, [['👑Royal Crown', 'https://inventory.openvibe.network/items/itd_1'], ['🌈Rainbow', null]], 'a javascript: link never becomes an href');
+ assert.deepStrictEqual(got.games, [['Salvage Rush', 'https://play.openvibe.network/t/salvage-rush'], ['Evil', null]], 'only OpenVibe hosts are linked');
+ assert.strictEqual(got.latest, '🏆 Big Haul');
+ assert.deepStrictEqual(got.badges, ['🏅 First Follow']);
+ assert.deepStrictEqual(got.links, ['https://openvibe.network/@ana', 'https://inventory.openvibe.network/u/usr_1']);
+ assert.match(got.priv, /Hidden[\s\S]*This profile is private\./);
+ assert.match(got.none, /href="https:\/\/openvibe\.network\/@nobody"/, 'no profile: the fallback link stays');
+ assert.ok(got.wide <= 360, `fits a 360 px phone (${got.wide})`);
+ console.log('profile-panel: ok');
+ } finally {
+ await browser.close();
+ server.close();
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ }
+})().catch((e) => { console.error(e); process.exit(1); });