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)
Changes
@@@ -4,6 +4,15 @@ 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.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@@@ -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 namesand 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. TheCalm setting and `prefers-reduced-motion` stop the animation. `window.OpenVibeItemsConfig = { api }` points it atanother Inventory (development).@@@ -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; }}@@@ -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);@@@ -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"@@@ -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": {@@@ -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); });
@@ -4,6 +4,15 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -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.
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.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
## 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
- `items.js` + `items.css`: what people wear in OpenVibe.Inventory, on any site (plan T21 "equip everywhere"). A name
@@ -666,7 +666,9 @@ its owner's subject and load both files:
@@ -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,
`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
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
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
Calm setting and `prefers-reduced-motion` stop the animation. `window.OpenVibeItemsConfig = { api }` points it at
another Inventory (development).
another Inventory (development).
@@ -3,6 +3,8 @@
@@ -3,6 +3,8 @@
*
*
* The renderers OpenVibe.Inventory's kinds name (each kind's `surfaces[].renderer`):
* 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.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
* 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),
* 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
* unchanged, so a name looks the same in Live's chat and everywhere else. The network's Calm setting
@@ -178,6 +180,34 @@
@@ -178,6 +180,34 @@
50% { background-position: 300% 50%; }
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) {
@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; }
}
}
@@ -7,26 +7,36 @@
@@ -7,26 +7,36 @@
* OpenVibeItems.decorate(listEl) again after new names are added (only the new ones are read)
* 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
* 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) {
(function (root) {
'use strict';
'use strict';
const SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const NAME_FX = /^name-fx-[a-z]{2,24}$/;
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 BATCH = 100;
const TTL_MS = 60 * 1000;
const TTL_MS = 60 * 1000;
const cache = new Map(); // subject → { at, slots }
const cache = new Map(); // subject → { at, slots }
const waiting = new Map(); // subject → Promise
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 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) {
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 now = Date.now();
const got = new Map();
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 set of (body && body.equipped) || []) if (set && SUBJECT.test(set.subject)) got.set(set.subject, set.slots || {});
@@ -48,11 +58,78 @@
@@ -48,11 +58,78 @@
return new Map(want.map((s) => [s, (cache.get(s) || { slots: {} }).slots]));
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 @@
@@ -64,9 +141,11 @@
for (const el of els) el.dataset.ovItems = '1';
for (const el of els) el.dataset.ovItems = '1';
let sets;
let sets;
try { sets = await equipped(els.map((el) => el.dataset.ovSubject)); } catch { return 0; }
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;
return els.length;
}
}
- root.OpenVibeItems = { decorate, equipped, apply, _cache: cache };
+ root.OpenVibeItems = { decorate, equipped, definitions, apply, burst, _cache: cache };
})(typeof window !== 'undefined' ? window : globalThis);
})(typeof window !== 'undefined' ? window : globalThis);
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "2.17.0",
+ "version": "2.18.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "2.17.0",
+ "version": "2.18.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.17.0",
+ "version": "2.18.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": {
@@ -11,6 +11,11 @@ const { parseHTML } = require('linkedom');
@@ -11,6 +11,11 @@ const { parseHTML } = require('linkedom');
const A = 'usr_01JZ0000000000000000000AAA';
const A = 'usr_01JZ0000000000000000000AAA';
const B = 'usr_01JZ0000000000000000000BBB';
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')}`;
const subjectOf = (i) => `usr_01JZ${String(i).padStart(22, '0')}`;
(async () => {
(async () => {
@@ -19,6 +24,7 @@ const subjectOf = (i) => `usr_01JZ${String(i).padStart(22, '0')}`;
@@ -19,6 +24,7 @@ const subjectOf = (i) => `usr_01JZ${String(i).padStart(22, '0')}`;
global.fetch = async (url, opts) => {
global.fetch = async (url, opts) => {
calls.push({ url: String(url), opts });
calls.push({ url: String(url), opts });
if (down) throw new Error('offline');
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(',');
const subjects = decodeURIComponent(String(url).split('subjects=')[1] || '').split(',');
return {
return {
ok: true,
ok: true,
@@ -26,7 +32,8 @@ const subjectOf = (i) => `usr_01JZ${String(i).padStart(22, '0')}`;
@@ -26,7 +32,8 @@ const subjectOf = (i) => `usr_01JZ${String(i).padStart(22, '0')}`;
equipped: subjects.map((s) => ({
equipped: subjects.map((s) => ({
subject: 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' } }
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')}`;
@@ -35,13 +42,25 @@ const subjectOf = (i) => `usr_01JZ${String(i).padStart(22, '0')}`;
const items = global.OpenVibeItems;
const items = global.OpenVibeItems;
assert.ok(items && typeof items.decorate === 'function');
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>`);
${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');
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('b').classList], [], 'a token of the wrong shape never becomes a class');
assert.deepStrictEqual([...document.getElementById('c').classList], []);
assert.deepStrictEqual([...document.getElementById('c').classList], []);
@@ -51,12 +70,13 @@ const subjectOf = (i) => `usr_01JZ${String(i).padStart(22, '0')}`;
@@ -51,12 +70,13 @@ const subjectOf = (i) => `usr_01JZ${String(i).padStart(22, '0')}`;
more.setAttribute('data-ov-subject', A);
more.setAttribute('data-ov-subject', A);
document.querySelector('main').appendChild(more);
document.querySelector('main').appendChild(more);
assert.strictEqual(await items.decorate(document), 1);
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'));
assert.ok(more.classList.contains('name-fx-rainbow'));
// apply() replaces an earlier effect.
// apply() replaces an earlier effect.
items.apply(more, { 'live.name_effect:name_effect': { token: 'name-fx-fire' } });
items.apply(more, { 'live.name_effect:name_effect': { token: 'name-fx-fire' } });
assert.deepStrictEqual([...more.classList].sort(), ['name-fx-fire', 'ov-fx']);
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, {});
items.apply(more, {});
assert.deepStrictEqual([...more.classList], []);
assert.deepStrictEqual([...more.classList], []);
@@ -73,5 +93,8 @@ const subjectOf = (i) => `usr_01JZ${String(i).padStart(22, '0')}`;
@@ -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.ok(fx.includes('name-fx-rainbow') && fx.includes('name-fx-divine'));
assert.match(css, /\.ov-fx \{ display: inline-block; \}/);
assert.match(css, /\.ov-fx \{ display: inline-block; \}/);
assert.match(css, /prefers-reduced-motion: reduce[\s\S]*animation: none !important/);
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');
console.log('items: all checks passed');
})().catch((err) => { console.error(err); process.exit(1); });
})().catch((err) => { console.error(err); process.exit(1); });