openvibers/openvibe.shared Commits

Shared 2.17.0: items.js + items.css draw what people wear in OpenVibe.Inventory (name effects) on any site (#38)

OP OpenVibers committed 9 Oct 2026 · 77314c042e · 8 files, +366 −5

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 75408a4..045e86c 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.17.0 — 2026-10-09
++
++- `items.js` + `items.css`: what people wear in OpenVibe.Inventory, on any site (plan T21 "equip everywhere"). A name
++ marked `data-ov-subject="usr_…"` gets its owner's name effect (`live.name_effect.css@1`: `ov-fx` + the token) from
++ Inventory's public batch read, 100 people per request with a 60 s cache. items.css holds Live's 13 name effects
++ unchanged, so a name looks the same in Live's chat and everywhere else; reduced motion stops the animation.
++
## 2.16.0 — 2026-10-09
- The account menu links your public profile: **My Profile** → `https://openvibe.network/@<username>` (plan T21: the
ddiff --git a/README.md b/README.md
iindex 9e76e72..70f53ec 100644
---- a/README.md
++++ b/README.md
@@@ -651,6 +651,25 @@ Re-renders (each openvibe-shared/boost page move re-renders the bar) end the
Dropdowns under a top link (`children`) are fixed-positioned under their link when they show (hover, keyboard focus, a tap), because the links row scrolls sideways and would clip them.
++### What people wear (`items.js`, `items.css`)
++
++The items a person has equipped in OpenVibe.Inventory, drawn the same on every site (plan T21). Mark each name with
++its owner's subject and load both files:
++
++```html
++<link rel="stylesheet" href="/shared/items.css">
++<a href="https://openvibe.network/@ana" data-ov-subject="usr_01…">Ana</a>
++<script src="/shared/items.js" defer></script>
++<script>addEventListener('DOMContentLoaded', () => OpenVibeItems.decorate(document));</script>
++```
++
++`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
++Calm setting and `prefers-reduced-motion` stop the animation. `window.OpenVibeItemsConfig = { api }` points it at
++another Inventory (development).
++
## Development
```bash
ddiff --git a/files.js b/files.js
iindex c6920d3..4c041b3 100644
---- a/files.js
++++ b/files.js
@@@ -31,6 +31,7 @@ const BROWSER = Object.freeze([
'route-transition.js', // page-move bar, loading line and fade-in; web-runtime.js fetches it on the first move
'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
'tooltip.js',
'release-watch.js', // keeps open tabs on a supported release (ADR-016), loaded after first paint
'release-update.js', // plans a new release and applies style/content in place; release-watch loads it on demand
@@@ -38,9 +39,10 @@ const BROWSER = Object.freeze([
'openvibe-sw.js', // Web Push service worker
]);
--// Stylesheets sites serve at /shared/<file> beside the scripts (generated from their module, never edited by hand).
++// Stylesheets sites serve at /shared/<file> beside the scripts.
const STYLES = Object.freeze([
-- 'showcase.css', // openvibe-shared/showcase's sections + the ring icons (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)
]);
const served = (name) => BROWSER.includes(String(name)) || STYLES.includes(String(name));
ddiff --git a/items.css b/items.css
nnew file mode 100644
iindex 0000000..d3b28a1
---- /dev/null
++++ b/items.css
@@@ -0,0 +1,183 @@
++/*
++ * openvibe-shared/items.css — what people wear, drawn the same on every OpenVibe site (plan T21 "equip everywhere").
++ *
++ * 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>
++ * 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
++ * (html[data-ov-motion=reduced], theme-loader.js) and prefers-reduced-motion stop the animation and keep the colour.
++ */
++.ov-fx { display: inline-block; }
++
++/* Rainbow — cycling hue */
++.name-fx-rainbow {
++ background: linear-gradient(90deg, #ff0000, #ff8800, #ffff00, #00ff00, #0088ff, #8800ff, #ff0088);
++ background-size: 400% 100%;
++ -webkit-background-clip: text;
++ -webkit-text-fill-color: transparent;
++ background-clip: text;
++ animation: nameFxRainbow 3s linear infinite;
++}
++@keyframes nameFxRainbow {
++ 0% { background-position: 0% 50%; }
++ 100% { background-position: 400% 50%; }
++}
++
++/* Fire — warm pulse with glow */
++.name-fx-fire {
++ color: #ff6600 !important;
++ text-shadow: 0 0 6px #ff4400, 0 0 12px #ff220088, 0 0 20px #cc110044;
++ animation: nameFxFire 1.5s ease-in-out infinite alternate;
++}
++@keyframes nameFxFire {
++ 0% { text-shadow: 0 0 6px #ff4400, 0 0 12px #ff220088; color: #ff6600; }
++ 100% { text-shadow: 0 0 10px #ff8800, 0 0 20px #ff440088, 0 0 30px #cc220044; color: #ffaa00; }
++}
++
++/* Ice — cool blue shimmer */
++.name-fx-ice {
++ color: #88ddff !important;
++ text-shadow: 0 0 6px #44bbff88, 0 0 12px #2288cc44;
++ animation: nameFxIce 2s ease-in-out infinite alternate;
++}
++@keyframes nameFxIce {
++ 0% { text-shadow: 0 0 4px #44bbff88; color: #88ddff; }
++ 100% { text-shadow: 0 0 10px #66eeff, 0 0 20px #44bbff88; color: #bbffff; }
++}
++
++/* Golden — shimmering gold */
++.name-fx-golden {
++ background: linear-gradient(90deg, #ffd700, #ffec80, #ffd700, #ffb800);
++ background-size: 300% 100%;
++ -webkit-background-clip: text;
++ -webkit-text-fill-color: transparent;
++ background-clip: text;
++ animation: nameFxGolden 2s ease-in-out infinite;
++ filter: drop-shadow(0 0 4px #ffd70066);
++}
++@keyframes nameFxGolden {
++ 0%, 100% { background-position: 0% 50%; }
++ 50% { background-position: 100% 50%; }
++}
++
++/* Neon — glowing pulse */
++.name-fx-neon {
++ color: #00ff88 !important;
++ text-shadow: 0 0 8px #00ff88, 0 0 16px #00ff8888, 0 0 24px #00cc6644;
++ animation: nameFxNeon 1.2s ease-in-out infinite alternate;
++}
++@keyframes nameFxNeon {
++ 0% { text-shadow: 0 0 4px #00ff88, 0 0 8px #00ff8866; }
++ 100% { text-shadow: 0 0 12px #00ff88, 0 0 24px #00ff88aa, 0 0 36px #00cc6666; }
++}
++
++/* Galaxy — cosmic swirl */
++.name-fx-galaxy {
++ background: linear-gradient(90deg, #8b5cf6, #ec4899, #3b82f6, #8b5cf6);
++ background-size: 400% 100%;
++ -webkit-background-clip: text;
++ -webkit-text-fill-color: transparent;
++ background-clip: text;
++ animation: nameFxGalaxy 4s linear infinite;
++ filter: drop-shadow(0 0 6px #8b5cf644);
++}
++@keyframes nameFxGalaxy {
++ 0% { background-position: 0% 50%; }
++ 100% { background-position: 400% 50%; }
++}
++
++/* Void — dark matter warp */
++.name-fx-void {
++ color: #9900ff !important;
++ text-shadow: 0 0 8px #6600cc, 0 0 16px #33006688, 0 0 24px #11003344;
++ animation: nameFxVoid 2s ease-in-out infinite;
++}
++@keyframes nameFxVoid {
++ 0%, 100% { text-shadow: 0 0 8px #6600cc, 0 0 16px #33006688; transform: scale(1); }
++ 50% { text-shadow: 0 0 12px #9900ff, 0 0 24px #6600ccaa, 0 0 36px #33006666; transform: scale(1.03); }
++}
++
++/* Toxic — green pulse */
++.name-fx-toxic {
++ background: linear-gradient(90deg, #00ff44, #88ff00, #ccff00, #00ff88);
++ background-size: 200% 100%;
++ -webkit-background-clip: text;
++ -webkit-text-fill-color: transparent;
++ background-clip: text;
++ animation: nameFxToxic 1.8s ease-in-out infinite alternate;
++}
++@keyframes nameFxToxic {
++ 0% { background-position: 0% 50%; filter: brightness(1); }
++ 100% { background-position: 200% 50%; filter: brightness(1.3); }
++}
++
++/* Blood — crimson drip */
++.name-fx-blood {
++ background: linear-gradient(180deg, #ff0000, #880000, #ff2222);
++ background-size: 100% 200%;
++ -webkit-background-clip: text;
++ -webkit-text-fill-color: transparent;
++ background-clip: text;
++ animation: nameFxBlood 2s ease-in-out infinite;
++}
++@keyframes nameFxBlood {
++ 0%, 100% { background-position: 50% 0%; }
++ 50% { background-position: 50% 200%; }
++}
++
++/* Shadow — dark mist */
++.name-fx-shadow {
++ color: #aa88cc !important;
++ animation: nameFxShadow 2s ease-in-out infinite;
++}
++@keyframes nameFxShadow {
++ 0%, 100% { text-shadow: 0 0 6px rgba(0,0,0,0.8), 0 0 12px rgba(80,0,120,0.6), 2px 2px 4px rgba(0,0,0,0.9); }
++ 50% { text-shadow: 0 0 12px rgba(0,0,0,0.9), 0 0 24px rgba(80,0,120,0.8), 4px 4px 8px rgba(0,0,0,1); }
++}
++
++/* Glitch — jittery digital */
++.name-fx-glitch {
++ color: #00ff88 !important;
++ animation: nameFxGlitch 0.15s infinite;
++ text-shadow: 2px 0 #ff0044, -2px 0 #00ccff;
++}
++@keyframes nameFxGlitch {
++ 0% { transform: translate(0); }
++ 20% { transform: translate(-1px, 1px); }
++ 40% { transform: translate(1px, -1px); }
++ 60% { transform: translate(-1px, 0); }
++ 80% { transform: translate(1px, 1px); }
++ 100% { transform: translate(0); }
++}
++
++/* Hologram — flickering translucent */
++.name-fx-hologram {
++ background: linear-gradient(90deg, #00ffcc, #00ccff, #8888ff, #00ffaa);
++ background-size: 300% 100%;
++ -webkit-background-clip: text;
++ -webkit-text-fill-color: transparent;
++ background-clip: text;
++ animation: nameFxHoloFlicker 0.12s infinite, nameFxHoloShift 3s linear infinite;
++}
++@keyframes nameFxHoloFlicker { 0%,45%,55%,100% { opacity: 1; } 50% { opacity: 0.6; } }
++@keyframes nameFxHoloShift { 0% { background-position: 0% 50%; } 100% { background-position: 300% 50%; } }
++
++/* Divine — holy radiance */
++.name-fx-divine {
++ background: linear-gradient(90deg, #ffffff, #ffe066, #ffffff, #ffcc00, #ffffff);
++ background-size: 300% 100%;
++ -webkit-background-clip: text;
++ -webkit-text-fill-color: transparent;
++ background-clip: text;
++ animation: nameFxDivine 2s ease-in-out infinite;
++ filter: drop-shadow(0 0 8px #ffe06688);
++}
++@keyframes nameFxDivine {
++ 0%, 100% { background-position: 0% 50%; }
++ 50% { background-position: 300% 50%; }
++}
++
++@media (prefers-reduced-motion: reduce) {
++ .ov-fx[class*="name-fx-"] { animation: none !important; }
++}
ddiff --git a/items.js b/items.js
nnew file mode 100644
iindex 0000000..8fe949c
---- /dev/null
++++ b/items.js
@@@ -0,0 +1,72 @@
++/**
++ * openvibe-shared/items.js — what people wear, on any OpenVibe site (plan T21 "equip everywhere"). Browser global
++ * `OpenVibeItems`; draw with /shared/items.css.
++ *
++ * <a href="…" data-ov-subject="usr_01…">Ana</a>
++ * OpenVibeItems.decorate(document) every [data-ov-subject] under the node, once per element
++ * 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.
++ */
++(function (root) {
++ 'use strict';
++ const SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++ const NAME_FX = /^name-fx-[a-z]{2,24}$/;
++ const BATCH = 100;
++ const TTL_MS = 60 * 1000;
++ const cache = new Map(); // subject → { at, slots }
++ const waiting = new Map(); // subject → Promise
++ const api = () => String((root.OpenVibeItemsConfig && root.OpenVibeItemsConfig.api) || 'https://inventory.openvibe.network').replace(/\/+$/, '');
++
++ 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 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 || {});
++ for (const s of subjects) cache.set(s, { at: now, slots: got.get(s) || {} });
++ }
++
++ /** Equipped slots for each subject: Map(subject → { 'kind:slot': { token, definition_id, … } }). */
++ async function equipped(subjects) {
++ const want = [...new Set(subjects)].filter((s) => SUBJECT.test(s));
++ const now = Date.now();
++ const missing = want.filter((s) => { const h = cache.get(s); return (!h || now - h.at > TTL_MS) && !waiting.has(s); });
++ for (let i = 0; i < missing.length; i += BATCH) {
++ const chunk = missing.slice(i, i + BATCH);
++ const p = fetchSets(chunk).catch(() => { for (const s of chunk) if (!cache.has(s)) cache.set(s, { at: Date.now(), slots: {} }); });
++ for (const s of chunk) waiting.set(s, p);
++ p.then(() => { for (const s of chunk) waiting.delete(s); });
++ }
++ await Promise.all(want.map((s) => waiting.get(s)).filter(Boolean));
++ 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);
++ }
++ }
++
++ async function decorate(node) {
++ const scope = node || (root.document && root.document);
++ if (!scope || !scope.querySelectorAll) return 0;
++ const els = [...scope.querySelectorAll('[data-ov-subject]')].filter((el) => !el.dataset.ovItems && SUBJECT.test(el.dataset.ovSubject || ''));
++ if (!els.length) return 0;
++ 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));
++ return els.length;
++ }
++
++ root.OpenVibeItems = { decorate, equipped, apply, _cache: cache };
++})(typeof window !== 'undefined' ? window : globalThis);
ddiff --git a/package-lock.json b/package-lock.json
iindex 5a62fa0..3047a4a 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "2.16.0",
++ "version": "2.17.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "2.16.0",
++ "version": "2.17.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/package.json b/package.json
iindex 8f82c25..7c52b1c 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "2.16.0",
++ "version": "2.17.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
@@@ -35,6 +35,7 @@
"./icons": "./ov-icons.js",
"./indexnow": "./indexnow.js",
"./island": "./island.js",
++ "./items": "./items.js",
"./legal": "./legal.js",
"./express-async": "./express-async.js",
"./metrics": "./metrics.js",
ddiff --git a/test/items.test.js b/test/items.test.js
nnew file mode 100644
iindex 0000000..25faf0a
---- /dev/null
++++ b/test/items.test.js
@@@ -0,0 +1,77 @@
++'use strict';
++// items.js + items.css (plan T21 "equip everywhere"): names marked with data-ov-subject get the name effect their
++// owner wears, from OpenVibe.Inventory's public batch read (100 per request, no credentials, 60 s cache); unknown
++// tokens never become classes; an unreachable Inventory leaves names alone; items.css carries Live's 13 effects and
++// stops them for reduced motion.
++// node test/items.test.js
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const { parseHTML } = require('linkedom');
++
++const A = 'usr_01JZ0000000000000000000AAA';
++const B = 'usr_01JZ0000000000000000000BBB';
++const subjectOf = (i) => `usr_01JZ${String(i).padStart(22, '0')}`;
++
++(async () => {
++ const calls = [];
++ let down = false;
++ global.fetch = async (url, opts) => {
++ calls.push({ url: String(url), opts });
++ if (down) throw new Error('offline');
++ const subjects = decodeURIComponent(String(url).split('subjects=')[1] || '').split(',');
++ return {
++ ok: true,
++ json: async () => ({
++ 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' } } : {},
++ })),
++ }),
++ };
++ };
++ require('../items.js');
++ 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>
++ ${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.deepStrictEqual([...document.getElementById('a').classList].sort(), ['name-fx-rainbow', 'ov-fx'], 'Ana wears her rainbow');
++ assert.deepStrictEqual([...document.getElementById('b').classList], [], 'a token of the wrong shape never becomes a class');
++ assert.deepStrictEqual([...document.getElementById('c').classList], []);
++
++ // Decorating again reads nothing: the elements are done, and the sets are cached.
++ assert.strictEqual(await items.decorate(document), 0);
++ const more = document.createElement('span');
++ 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.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']);
++ items.apply(more, {});
++ assert.deepStrictEqual([...more.classList], []);
++
++ // Inventory unreachable: names stay as they are, nothing throws.
++ down = true;
++ const { document: d2 } = parseHTML(`<p><span id="x" data-ov-subject="${subjectOf(999)}">X</span></p>`);
++ assert.strictEqual(await items.decorate(d2), 1);
++ assert.deepStrictEqual([...d2.getElementById('x').classList], []);
++
++ // The stylesheet: Live's 13 name effects, an inline-block wrapper, and reduced motion.
++ const css = fs.readFileSync(path.join(__dirname, '..', 'items.css'), 'utf8');
++ const fx = [...css.matchAll(/^\.(name-fx-[a-z]+) \{/gm)].map((m) => m[1]);
++ assert.strictEqual(fx.length, 13, `13 name effects (${fx.join(', ')})`);
++ 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/);
++ console.log('items: all checks passed');
++})().catch((err) => { console.error(err); process.exit(1); });