openvibers/openvibe.shared Commits

Shared 2.19.0: items.js draws community badges (network.badge.image@1) — the Workshop badge's image before the name, straight from the equipped read (#42)

OP OpenVibers committed 9 Oct 2026 · 549a6cb0ee · 7 files, +44 −10

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 1b54907..f21106d 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,13 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
breaking change to any exported module, browser global or served file name is a new major.
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
++## 2.19.0 — 2026-10-09
++
++- `items.js` draws **community badges** (`network.badge.image@1`, OpenVibe.Inventory's Workshop): the badge's
++ reviewed image, from OpenVibe.Media, in an `img.ov-badge` just before the name. The image comes straight from the
++ equipped read's `media_id`, with no extra request, and only ids of the `med_…` shape become a URL. items.css sizes it
++ to the text.
++
## 2.18.0 — 2026-10-09
- `items.js` draws two more renderers. **Hats** (`live.hat.glyph@1`): the emoji just before the name, with Live's
ddiff --git a/README.md b/README.md
iindex 19b7fd0..956d450 100644
---- a/README.md
++++ b/README.md
@@@ -668,7 +668,8 @@ no credentials, each answer kept 60 s) and gives each name its renderers' classe
and only the new ones are read. Renderers: `live.name_effect.css@1` → `ov-fx` + the item's token (Live's 13 name
effects, `name-fx-rainbow` … `name-fx-divine`); `live.hat.glyph@1` → the hat's emoji just before the name
(`ov-hat`, with its motion); `live.particle.css@1` → `ov-px` + the token, and a burst of the item's characters when
--someone points at or focuses the name. Hats and particles read Live's definitions once per page. A server that already knows the token writes the class itself. The
++someone points at or focuses the name. Hats and particles read Live's definitions once per page. `network.badge.image@1` → a
++community badge's image (`img.ov-badge`) before the name, from the equipped read's `media_id`. A server that already knows the token writes the class itself. The
Calm setting and `prefers-reduced-motion` stop the animation. `window.OpenVibeItemsConfig = { api }` points it at
another Inventory (development).
ddiff --git a/items.css b/items.css
iindex 793101d..0ff8e2b 100644
---- a/items.css
++++ b/items.css
@@@ -5,6 +5,7 @@
* live.name_effect.css@1 the item's art.token is a class on the name: <span class="ov-fx name-fx-rainbow">Ana</span>
* live.hat.glyph@1 the hat's emoji before the name: <span class="ov-hat ov-hat-pulse">👑</span>
* live.particle.css@1 `ov-px` + the token on the name; items.js spawns `.ov-px-p` characters in a burst
++ * network.badge.image@1 <img class="ov-badge"> before the name (a Workshop badge, from OpenVibe.Media)
* items.js adds the classes from the person's equipped set; a page that already knows the token (a server-rendered
* profile) writes the class itself. These are Live's own name effects (OpenVibe.Live public/css/cosmetics.css),
* unchanged, so a name looks the same in Live's chat and everywhere else. The network's Calm setting
@@@ -180,6 +181,9 @@
50% { background-position: 300% 50%; }
}
++/* Community badges (network.badge.image@1): the reviewed image, a little larger than the text, just before the name. */
++.ov-badge { display: inline-block; width: 1.15em; height: 1.15em; margin-right: 3px; vertical-align: -0.2em; border-radius: 3px; object-fit: contain; }
++
/* Hats (live.hat.glyph@1): the emoji just before the name, Live's float/pulse/warp motions. */
.ov-hat { display: inline-block; margin-right: 2px; font-size: 0.9em; line-height: 1; vertical-align: -0.05em; }
.ov-hat-float { animation: ovHatFloat 2s ease-in-out infinite; }
ddiff --git a/items.js b/items.js
iindex 8fb4358..b59be52 100644
---- a/items.js
++++ b/items.js
@@@ -12,6 +12,8 @@
* live.name_effect.css@1 `ov-fx` + the token (name-fx-rainbow, …) on the name: items.css draws it
* live.hat.glyph@1 the hat's emoji just before the name (`ov-hat`, with its float/pulse/warp animation)
* live.particle.css@1 a short burst of the item's characters when you point at or focus the name (`ov-px`)
++ * network.badge.image@1 a community badge (the Workshop): its reviewed image just before the name (`ov-badge`),
++ * straight from the equipped read's media_id, served by OpenVibe.Media
* Hats and particles need their definitions (emoji, animation, characters): one public read of Live's definitions per
* page, only when someone on it wears one. Anything else is left alone. Inventory unreachable: names stay as they
* are. Only tokens of the known shape become class names; emoji and characters are text, never markup. A burst never
@@@ -22,6 +24,7 @@
const SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const NAME_FX = /^name-fx-[a-z]{2,24}$/;
const PX = /^px-[a-z]{2,24}$/;
++ const MEDIA_ID = /^med_[0-9A-HJKMNP-TV-Z]{26}$/;
const HAT_MOTION = ['float', 'pulse', 'warp'];
const BATCH = 100;
const TTL_MS = 60 * 1000;
@@@ -100,8 +103,7 @@
/** Give one element what a set of slots draws (removing what an earlier set drew). defs: Map from definitions(). */
function apply(el, slots, defs) {
for (const c of [...el.classList]) if (c === 'ov-fx' || NAME_FX.test(c) || c === 'ov-px' || PX.test(c)) el.classList.remove(c);
-- const prev = el.previousElementSibling;
-- if (prev && prev.classList && prev.classList.contains('ov-hat')) prev.remove();
++ for (let prev = el.previousElementSibling; prev && prev.classList && (prev.classList.contains('ov-hat') || prev.classList.contains('ov-badge')); prev = el.previousElementSibling) prev.remove();
el.removeEventListener('mouseenter', onBurst);
el.removeEventListener('focusin', onBurst);
delete el.dataset.ovPxChars;
@@@ -109,6 +111,18 @@
const fx = slotOf(slots, 'live.name_effect');
if (fx && NAME_FX.test(String(fx.token || ''))) el.classList.add('ov-fx', fx.token);
++ const badge = slotOf(slots, 'network.badge');
++ if (badge && MEDIA_ID.test(String(badge.media_id || '')) && el.parentNode) {
++ const b = el.ownerDocument.createElement('img');
++ b.className = 'ov-badge';
++ b.src = `${String((root.OpenVibeItemsConfig && root.OpenVibeItemsConfig.media) || 'https://openvibe.media').replace(/\/+$/, '')}/o/${badge.media_id}`;
++ b.alt = '';
++ b.width = 16; b.height = 16;
++ b.setAttribute('loading', 'lazy');
++ b.setAttribute('aria-hidden', 'true');
++ el.parentNode.insertBefore(b, el);
++ }
++
const hat = slotOf(slots, 'live.hat');
const hatDef = hat && defs && defs.get(hat.definition_id);
const glyph = hatDef && String(hatDef.art.emoji || hatDef.attributes.hat_char || '').slice(0, 8);
ddiff --git a/package-lock.json b/package-lock.json
iindex 603db82..7e3c0f0 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "2.18.0",
++ "version": "2.19.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "2.18.0",
++ "version": "2.19.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/package.json b/package.json
iindex 9ba8e37..bca147f 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "2.18.0",
++ "version": "2.19.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
ddiff --git a/test/items.test.js b/test/items.test.js
iindex 1348d04..b1596ba 100644
---- a/test/items.test.js
++++ b/test/items.test.js
@@@ -12,6 +12,7 @@ const { parseHTML } = require('linkedom');
const A = 'usr_01JZ0000000000000000000AAA';
const B = 'usr_01JZ0000000000000000000BBB';
const C = 'usr_01JZ0000000000000000000CCC';
++const D = 'usr_01JZ0000000000000000000DDD';
const DEFS = [
{ id: 'itd_2', kind: 'live.hat', art: { emoji: '👑' }, attributes: { hat_char: '👑', animated: 'pulse' } },
{ id: 'itd_4', kind: 'live.particle', art: { emoji: '✨', token: 'px-sparkle' }, attributes: { chars: '✦✧⋆' } },
@@@ -33,7 +34,8 @@ const subjectOf = (i) => `usr_01JZ${String(i).padStart(22, '0')}`;
subject: s,
slots: s === A ? { 'live.name_effect:name_effect': { instance_id: 'inv_1', definition_id: 'itd_1', token: 'name-fx-rainbow' }, 'live.hat:hat': { instance_id: 'inv_2', definition_id: 'itd_2', token: 'hat-crown' } }
: s === B ? { 'live.name_effect:name_effect': { instance_id: 'inv_3', definition_id: 'itd_3', token: 'evil" onload="x' } }
-- : s === C ? { 'live.particle:particle': { instance_id: 'inv_4', definition_id: 'itd_4', token: 'px-sparkle' } } : {},
++ : s === C ? { 'live.particle:particle': { instance_id: 'inv_4', definition_id: 'itd_4', token: 'px-sparkle' } }
++ : s === D ? { 'network.badge:badge': { instance_id: 'inv_5', definition_id: 'itd_5', media_id: 'med_01JZ00000000000000000000B1' } } : {},
})),
}),
};
@@@ -42,12 +44,12 @@ const subjectOf = (i) => `usr_01JZ${String(i).padStart(22, '0')}`;
const items = global.OpenVibeItems;
assert.ok(items && typeof items.decorate === 'function');
-- const { document } = parseHTML(`<main><a id="a" data-ov-subject="${A}">Ana</a><span id="b" data-ov-subject="${B}">Bob</span><span id="c" data-ov-subject="nope">C</span><b><span id="p" data-ov-subject="${C}">Cleo</span></b>
++ const { document } = parseHTML(`<main><a id="a" data-ov-subject="${A}">Ana</a><span id="b" data-ov-subject="${B}">Bob</span><span id="c" data-ov-subject="nope">C</span><b><span id="p" data-ov-subject="${C}">Cleo</span></b><i><span id="d" data-ov-subject="${D}">Dee</span></i>
${Array.from({ length: 150 }, (_, i) => `<span class="many" data-ov-subject="${subjectOf(i)}">p${i}</span>`).join('')}</main>`);
-- assert.strictEqual(await items.decorate(document), 153, 'every valid subject, the invalid one skipped');
++ assert.strictEqual(await items.decorate(document), 154, 'every valid subject, the invalid one skipped');
const sets = calls.filter((c) => c.url.includes('/equipped?'));
-- assert.strictEqual(sets.length, 2, '153 people in two requests (100 + 53)');
++ assert.strictEqual(sets.length, 2, '154 people in two requests (100 + 54)');
assert.strictEqual(calls.filter((c) => c.url.includes('/definitions?')).length, 1, 'definitions once, because someone wears a hat or particles');
assert.ok(calls.every((c) => c.url.startsWith('https://inventory.openvibe.network/api/v1/') && c.opts.credentials === 'omit'));
assert.deepStrictEqual([...document.getElementById('a').classList].sort(), ['name-fx-rainbow', 'ov-fx'], 'Ana wears her rainbow');
@@@ -56,6 +58,11 @@ const subjectOf = (i) => `usr_01JZ${String(i).padStart(22, '0')}`;
const cleo = document.getElementById('p');
assert.deepStrictEqual([...cleo.classList].sort(), ['ov-px', 'px-sparkle'], 'Cleo wears particles');
assert.strictEqual(cleo.dataset.ovPxChars, '✦✧⋆');
++ const dee = document.getElementById('d');
++ const b = dee.previousElementSibling;
++ assert.ok(b && b.tagName === 'IMG' && b.className === 'ov-badge' && b.getAttribute('src') === 'https://openvibe.media/o/med_01JZ00000000000000000000B1' && b.getAttribute('alt') === '', 'Dee\'s community badge before her name');
++ items.apply(dee, { 'network.badge:badge': { media_id: 'javascript:alert(1)' } });
++ assert.ok(!(dee.previousElementSibling && dee.previousElementSibling.classList.contains('ov-badge')), 'a bad media id draws nothing, and the old badge is gone');
items.burst(cleo);
assert.strictEqual(cleo.querySelectorAll('.ov-px-p').length, 6, 'a burst: six characters');
assert.ok([...cleo.querySelectorAll('.ov-px-p')].every((p) => '✦✧⋆'.includes(p.textContent)));
@@@ -94,6 +101,7 @@ const subjectOf = (i) => `usr_01JZ${String(i).padStart(22, '0')}`;
assert.match(css, /\.ov-fx \{ display: inline-block; \}/);
assert.match(css, /prefers-reduced-motion: reduce[\s\S]*animation: none !important/);
assert.match(css, /\.ov-hat-pulse \{ animation: ovHatPulse/);
++ assert.match(css, /\.ov-badge \{ display: inline-block;/);
assert.match(css, /\.px-sparkle \.ov-px-p \{ color: #ffd700; \}/);
assert.match(css, /prefers-reduced-motion: reduce[\s\S]*\.ov-px-p \{ display: none; \}/);
console.log('items: all checks passed');