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)

OP OpenVibers committed 19 days ago · efd6ec6037 · 7 files, +331 −0

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 027af50..fd5a548 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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:
ddiff --git a/README.md b/README.md
iindex 1b67f85..3666467 100644
---- a/README.md
++++ b/README.md
@@@ -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
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
```bash
ddiff --git a/files.js b/files.js
iindex 37419d3..fd2a03c 100644
---- a/files.js
++++ b/files.js
@@@ -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));
ddiff --git a/package.json b/package.json
iindex e8a89cf..aae8d0d 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/profile-panel.css b/profile-panel.css
nnew file mode 100644
iindex 0000000..aa10a33
---- /dev/null
++++ b/profile-panel.css
@@@ -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); }
ddiff --git a/profile-panel.js b/profile-panel.js
nnew file mode 100644
iindex 0000000..83f30f2
---- /dev/null
++++ b/profile-panel.js
@@@ -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);
ddiff --git a/test/profile-panel.test.js b/test/profile-panel.test.js
nnew file mode 100644
iindex 0000000..29f536a
---- /dev/null
++++ b/test/profile-panel.test.js
@@@ -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); });