openvibers/openvibe.shared Commits

Shared 2.18.0: items.js draws hats (before the name) and particle bursts (on point or focus) from OpenVibe.Inventory, with Live's motions and colours (#39)

OP OpenVibers committed 9 Oct 2026 · 8782e6a2e2 · 7 files, +169 −26

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 045e86c..1b54907 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,15 @@ 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.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
++ float/pulse/warp motion. **Particles** (`live.particle.css@1`): a short burst of the item's characters when you point
++ at or focus the name, never on its own. Hats and particles take their emoji, motion and characters from Live's
++ definitions: one public read per page, only when someone on it wears one. Elements are made from the name's own
++ document. items.css carries Live's hat motions and particle colours. Reduced motion and the Calm setting stop the
++ hats and hide the bursts.
++
## 2.17.0 — 2026-10-09
- `items.js` + `items.css`: what people wear in OpenVibe.Inventory, on any site (plan T21 "equip everywhere"). A name
ddiff --git a/README.md b/README.md
iindex 70f53ec..e089876 100644
---- a/README.md
++++ b/README.md
@@@ -666,7 +666,9 @@ its owner's subject and load both files:
`OpenVibeItems.decorate(node)` reads Inventory's public `GET /api/v1/equipped?subjects=…` (100 people per request,
no credentials, each answer kept 60 s) and gives each name its renderers' classes; call it again after adding names
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`). A server that already knows the token writes the class itself. The
++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
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 d3b28a1..793101d 100644
---- a/items.css
++++ b/items.css
@@@ -3,6 +3,8 @@
*
* The renderers OpenVibe.Inventory's kinds name (each kind's `surfaces[].renderer`):
* 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
* 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
@@@ -178,6 +180,34 @@
50% { background-position: 300% 50%; }
}
++/* 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; }
++.ov-hat-pulse { animation: ovHatPulse 1.5s ease-in-out infinite; }
++.ov-hat-warp { animation: ovHatWarp 2s ease-in-out infinite; }
++@keyframes ovHatFloat { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-3px); } }
++@keyframes ovHatPulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.2); } }
++@keyframes ovHatWarp { 0%, 100% { transform: scale(1) rotate(0deg); filter: hue-rotate(0deg); } 50% { transform: scale(1.1) rotate(5deg); filter: hue-rotate(180deg); } }
++
++/* Particles (live.particle.css@1): characters that float out of the name and fade, Live's colours. They reset the
++ name effect's text fill, so a gradient name still shows them. */
++.ov-px { position: relative; display: inline-block; }
++.ov-px-p {
++ position: absolute; top: 0; pointer-events: none; font-size: 10px; line-height: 1;
++ background: none; -webkit-text-fill-color: currentColor; opacity: 0;
++ animation: ovPxFloat 1.8s ease-out forwards;
++}
++@keyframes ovPxFloat {
++ 0% { opacity: 1; transform: translate(0, 0) scale(1); }
++ 100% { opacity: 0; transform: translate(var(--px-dx, 10px), var(--px-dy, -20px)) scale(0.4); }
++}
++.px-sparkle .ov-px-p { color: #ffd700; }
++.px-hearts .ov-px-p { color: #ff6b9d; }
++.px-flames .ov-px-p { color: #ff6600; }
++.px-stars .ov-px-p { color: #fbbf24; }
++.px-void .ov-px-p { color: #9900ff; }
++
@media (prefers-reduced-motion: reduce) {
-- .ov-fx[class*="name-fx-"] { animation: none !important; }
++ .ov-fx[class*="name-fx-"], .ov-hat { animation: none !important; }
++ .ov-px-p { display: none; }
}
ddiff --git a/items.js b/items.js
iindex 8fe949c..8fb4358 100644
---- a/items.js
++++ b/items.js
@@@ -7,26 +7,36 @@
* OpenVibeItems.decorate(listEl) again after new names are added (only the new ones are read)
*
* It reads OpenVibe.Inventory's public equipped sets (GET /api/v1/equipped?subjects=…, 100 people per request, no
-- * credentials) and keeps each answer 60 s, so a page full of names costs one or two requests. Each name gets the
-- * classes its items' renderers ask for:
-- * live.name_effect.css@1 `ov-fx` + the token (name-fx-rainbow, …): items.css draws it
-- * Anything else is left alone. Inventory unreachable: names stay as they are. Only tokens of the known shape are ever
-- * used as class names.
++ * credentials) and keeps each answer 60 s, so a page full of names costs one or two requests. Each name gets what its
++ * items' renderers ask for:
++ * 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`)
++ * 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
++ * runs by itself, and never under reduced motion or the network's Calm setting.
*/
(function (root) {
'use strict';
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 HAT_MOTION = ['float', 'pulse', 'warp'];
const BATCH = 100;
const TTL_MS = 60 * 1000;
const cache = new Map(); // subject → { at, slots }
const waiting = new Map(); // subject → Promise
++ let defsPromise = null; // Map(definition id → { art, attributes })
const api = () => String((root.OpenVibeItemsConfig && root.OpenVibeItemsConfig.api) || 'https://inventory.openvibe.network').replace(/\/+$/, '');
++ const getJson = async (url) => {
++ const r = await fetch(url, { credentials: 'omit', headers: { Accept: 'application/json' } });
++ if (!r.ok) throw new Error(`inventory answered ${r.status}`);
++ return r.json();
++ };
async function fetchSets(subjects) {
-- const r = await fetch(`${api()}/api/v1/equipped?subjects=${subjects.map(encodeURIComponent).join(',')}`, { credentials: 'omit', headers: { Accept: 'application/json' } });
-- if (!r.ok) throw new Error(`inventory answered ${r.status}`);
-- const body = await r.json();
++ const body = await getJson(`${api()}/api/v1/equipped?subjects=${subjects.map(encodeURIComponent).join(',')}`);
const now = Date.now();
const got = new Map();
for (const set of (body && body.equipped) || []) if (set && SUBJECT.test(set.subject)) got.set(set.subject, set.slots || {});
@@@ -48,11 +58,78 @@
return new Map(want.map((s) => [s, (cache.get(s) || { slots: {} }).slots]));
}
-- /** Give one element the classes of a set of slots (removing an earlier name effect). */
-- function apply(el, slots) {
-- for (const c of [...el.classList]) if (c === 'ov-fx' || NAME_FX.test(c)) el.classList.remove(c);
-- for (const [key, v] of Object.entries(slots || {})) {
-- if (key.split(':')[0] === 'live.name_effect' && v && NAME_FX.test(String(v.token || ''))) el.classList.add('ov-fx', v.token);
++ /** Live's item definitions (hats and particles carry their emoji, animation and characters there), once a page. */
++ function definitions() {
++ if (!defsPromise) {
++ defsPromise = getJson(`${api()}/api/v1/definitions?issuer=${encodeURIComponent('service:live')}&limit=500`)
++ .then((b) => new Map(((b && b.definitions) || []).map((d) => [d.id, { art: d.art || {}, attributes: d.attributes || {} }])))
++ .catch(() => { defsPromise = null; return new Map(); });
++ }
++ return defsPromise;
++ }
++
++ const slotOf = (slots, kind) => { for (const [key, v] of Object.entries(slots || {})) if (key.split(':')[0] === kind && v) return v; return null; };
++ const calm = () => {
++ const d = root.document && root.document.documentElement;
++ if (d && d.getAttribute && d.getAttribute('data-ov-motion') === 'reduced') return true;
++ return !!(root.matchMedia && root.matchMedia('(prefers-reduced-motion: reduce)').matches);
++ };
++
++ /** A short burst of characters out of the name (pointer or keyboard focus); never under reduced motion. */
++ function burst(el) {
++ if (calm() || !el.isConnected || el.dataset.ovPxBusy) return;
++ const chars = Array.from(el.dataset.ovPxChars || '').slice(0, 6);
++ if (!chars.length) return;
++ el.dataset.ovPxBusy = '1';
++ for (let i = 0; i < 6; i++) {
++ const p = el.ownerDocument.createElement('span');
++ p.className = 'ov-px-p';
++ p.setAttribute('aria-hidden', 'true');
++ p.textContent = chars[i % chars.length];
++ const angle = (Math.PI * 2 * i) / 6 + Math.random() * 0.6;
++ p.style.setProperty('--px-dx', `${Math.round(Math.cos(angle) * 22)}px`);
++ p.style.setProperty('--px-dy', `${Math.round(Math.sin(angle) * 16 - 10)}px`);
++ p.style.left = `${Math.round(30 + Math.random() * 40)}%`;
++ el.appendChild(p);
++ setTimeout(() => p.remove(), 1900);
++ }
++ setTimeout(() => { delete el.dataset.ovPxBusy; }, 1200);
++ }
++ function onBurst(e) { burst(e.currentTarget); }
++
++ /** 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();
++ el.removeEventListener('mouseenter', onBurst);
++ el.removeEventListener('focusin', onBurst);
++ delete el.dataset.ovPxChars;
++
++ const fx = slotOf(slots, 'live.name_effect');
++ if (fx && NAME_FX.test(String(fx.token || ''))) el.classList.add('ov-fx', fx.token);
++
++ 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);
++ if (glyph && el.parentNode) {
++ const h = el.ownerDocument.createElement('span');
++ const motion = String(hatDef.attributes.animated || '');
++ h.className = `ov-hat${HAT_MOTION.includes(motion) ? ` ov-hat-${motion}` : ''}`;
++ h.setAttribute('aria-hidden', 'true');
++ h.textContent = glyph;
++ el.parentNode.insertBefore(h, el);
++ }
++
++ const px = slotOf(slots, 'live.particle');
++ const pxDef = px && defs && defs.get(px.definition_id);
++ const token = String((px && px.token) || (pxDef && pxDef.art.token) || '');
++ const chars = pxDef && String(pxDef.attributes.chars || '').slice(0, 24);
++ if (PX.test(token) && chars) {
++ el.classList.add('ov-px', token);
++ el.dataset.ovPxChars = chars;
++ el.addEventListener('mouseenter', onBurst);
++ el.addEventListener('focusin', onBurst);
}
}
@@@ -64,9 +141,11 @@
for (const el of els) el.dataset.ovItems = '1';
let sets;
try { sets = await equipped(els.map((el) => el.dataset.ovSubject)); } catch { return 0; }
-- for (const el of els) apply(el, sets.get(el.dataset.ovSubject));
++ const needsDefs = [...sets.values()].some((slots) => slotOf(slots, 'live.hat') || slotOf(slots, 'live.particle'));
++ const defs = needsDefs ? await definitions() : new Map();
++ for (const el of els) apply(el, sets.get(el.dataset.ovSubject), defs);
return els.length;
}
-- root.OpenVibeItems = { decorate, equipped, apply, _cache: cache };
++ root.OpenVibeItems = { decorate, equipped, definitions, apply, burst, _cache: cache };
})(typeof window !== 'undefined' ? window : globalThis);
ddiff --git a/package-lock.json b/package-lock.json
iindex 3047a4a..66c4c03 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "2.17.0",
++ "version": "2.18.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "2.17.0",
++ "version": "2.18.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/package.json b/package.json
iindex 7c52b1c..c9611da 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "2.17.0",
++ "version": "2.18.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 25faf0a..1348d04 100644
---- a/test/items.test.js
++++ b/test/items.test.js
@@@ -11,6 +11,11 @@ const { parseHTML } = require('linkedom');
const A = 'usr_01JZ0000000000000000000AAA';
const B = 'usr_01JZ0000000000000000000BBB';
++const C = 'usr_01JZ0000000000000000000CCC';
++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: '✦✧⋆' } },
++];
const subjectOf = (i) => `usr_01JZ${String(i).padStart(22, '0')}`;
(async () => {
@@@ -19,6 +24,7 @@ const subjectOf = (i) => `usr_01JZ${String(i).padStart(22, '0')}`;
global.fetch = async (url, opts) => {
calls.push({ url: String(url), opts });
if (down) throw new Error('offline');
++ if (String(url).includes('/api/v1/definitions?')) return { ok: true, json: async () => ({ definitions: DEFS }) };
const subjects = decodeURIComponent(String(url).split('subjects=')[1] || '').split(',');
return {
ok: true,
@@@ -26,7 +32,8 @@ const subjectOf = (i) => `usr_01JZ${String(i).padStart(22, '0')}`;
equipped: subjects.map((s) => ({
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 === 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' } } : {},
})),
}),
};
@@@ -35,13 +42,25 @@ 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>
++ 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>
${Array.from({ length: 150 }, (_, i) => `<span class="many" data-ov-subject="${subjectOf(i)}">p${i}</span>`).join('')}</main>`);
-- assert.strictEqual(await items.decorate(document), 152, 'every valid subject, the invalid one skipped');
-- assert.strictEqual(calls.length, 2, '152 people in two requests (100 + 52)');
-- assert.ok(calls.every((c) => c.url.startsWith('https://inventory.openvibe.network/api/v1/equipped?subjects=') && c.opts.credentials === 'omit'));
++ assert.strictEqual(await items.decorate(document), 153, '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(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');
++ const hat = document.getElementById('a').previousElementSibling;
++ assert.ok(hat && hat.className === 'ov-hat ov-hat-pulse' && hat.textContent === '👑' && hat.getAttribute('aria-hidden') === 'true', 'and her crown sits just before her name');
++ const cleo = document.getElementById('p');
++ assert.deepStrictEqual([...cleo.classList].sort(), ['ov-px', 'px-sparkle'], 'Cleo wears particles');
++ assert.strictEqual(cleo.dataset.ovPxChars, '✦✧⋆');
++ 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)));
++ items.burst(cleo);
++ assert.strictEqual(cleo.querySelectorAll('.ov-px-p').length, 6, 'one burst at a time');
assert.deepStrictEqual([...document.getElementById('b').classList], [], 'a token of the wrong shape never becomes a class');
assert.deepStrictEqual([...document.getElementById('c').classList], []);
@@@ -51,12 +70,13 @@ const subjectOf = (i) => `usr_01JZ${String(i).padStart(22, '0')}`;
more.setAttribute('data-ov-subject', A);
document.querySelector('main').appendChild(more);
assert.strictEqual(await items.decorate(document), 1);
-- assert.strictEqual(calls.length, 2, 'a new name of a known person is drawn from the cache');
++ assert.strictEqual(calls.length, 3, 'a new name of a known person is drawn from the cache');
assert.ok(more.classList.contains('name-fx-rainbow'));
// apply() replaces an earlier effect.
items.apply(more, { 'live.name_effect:name_effect': { token: 'name-fx-fire' } });
assert.deepStrictEqual([...more.classList].sort(), ['name-fx-fire', 'ov-fx']);
++ assert.ok(!(more.previousElementSibling && more.previousElementSibling.classList.contains('ov-hat')), 'the earlier hat is gone');
items.apply(more, {});
assert.deepStrictEqual([...more.classList], []);
@@@ -73,5 +93,8 @@ const subjectOf = (i) => `usr_01JZ${String(i).padStart(22, '0')}`;
assert.ok(fx.includes('name-fx-rainbow') && fx.includes('name-fx-divine'));
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, /\.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');
})().catch((err) => { console.error(err); process.exit(1); });