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)
Changes
@@@ -4,6 +4,13 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semvebreaking 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@@@ -651,6 +651,25 @@ Re-renders (each openvibe-shared/boost page move re-renders the bar) end theDropdowns 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@@@ -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));nnew file mode 100644@@@ -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; }++}nnew file mode 100644@@@ -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);@@@ -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"@@@ -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",nnew file mode 100644@@@ -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); });
@@ -4,6 +4,13 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -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.
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).
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
## 2.16.0 — 2026-10-09
- The account menu links your public profile: **My Profile** → `https://openvibe.network/@<username>` (plan T21: the
- The account menu links your public profile: **My Profile** → `https://openvibe.network/@<username>` (plan T21: the
@@ -651,6 +651,25 @@ Re-renders (each openvibe-shared/boost page move re-renders the bar) end the
@@ -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.
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
## Development
```bash
```bash
@@ -31,6 +31,7 @@ const BROWSER = Object.freeze([
@@ -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
'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)
'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
'tooltip.js',
'tooltip.js',
'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
'release-update.js', // plans a new release and applies style/content in place; release-watch loads it on demand
'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([
@@ -38,9 +39,10 @@ const BROWSER = Object.freeze([
'openvibe-sw.js', // Web Push service worker
'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([
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));
const served = (name) => BROWSER.includes(String(name)) || STYLES.includes(String(name));
new file mode 100644
new file mode 100644
@@ -0,0 +1,183 @@
@@ -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; }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,72 @@
@@ -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);
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "2.16.0",
+ "version": "2.17.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "2.16.0",
+ "version": "2.17.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"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",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -35,6 +35,7 @@
@@ -35,6 +35,7 @@
"./icons": "./ov-icons.js",
"./icons": "./ov-icons.js",
"./indexnow": "./indexnow.js",
"./indexnow": "./indexnow.js",
"./island": "./island.js",
"./island": "./island.js",
+ "./items": "./items.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,77 @@
@@ -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); });