openvibers/openvibe.shared Commits
4.180.0: a demo scope for motion (data-fx-demo) (#400)
Changes
@@@ -1,5 +1,17 @@# Changelog++## 4.180.0 — 2026-11-10++++- **A demo scope for motion: `data-fx-demo`** (`ov-fx.js`, `ov-mark.js`, `theme-loader.js`). Inside a container++ with this attribute the effects and the mark play even when motion is reduced, by the device or by Calm. It is for++ a page that exists to show motion, and only after the person presses a button that says so: openvibe.design's++ Motion page was completely still on a phone that asks for less motion, and never said why. Outside the container++ nothing changes: the setting holds for the rest of the page and for every other page.++ - `OVFx.confetti({ from: el })` names the element it comes from, so confetti inside a demo scope plays; without++ `from` it follows the setting as before.++ - Calm's blanket rule, the effects' reduced-motion rules and the mark's now exclude `[data-fx-demo] *`, and a demo++ scope gets its `--ov-dur-*` lengths back under Calm.++## 4.179.0 — 2026-11-09**Share to OpenVibe Community.** The share sheet (`ov-share.js`) has a Community tile beside OpenVibe Chat: it opens@@@ -2,7 +2,7 @@> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.--**Status:** alpha, v4.179.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.180.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployedconsumer installs a tagged release and none keeps a vendored copy([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` namesthe tag it pins.@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.179.0",++ "version": "4.180.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)","deployed": false,"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",@@@ -572,7 +572,7 @@const NAV_ICONS_REV = '1091daee9c7e'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)// END generated// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.-- const SIBLING_REV = { 'ov-mark.js': 'd7e87def8fff', 'ov-glyphs.js': '6f8c8dd2a10d', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2d3a47d3535c', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '20f4ef41d127', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': '44a863d7a267', 'ov-share.css': '45b92b654148', 'search.js': 'bbae7542436f', 'dock.js': '7ff462f31951', 'presence.js': '1b675c37d888', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': 'e06f1388b696', 'history.js': '1e6598f78ba4', 'site-switcher.js': '292bea111aa8', 'appbar.js': '1edf7532f228', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'ba3981246bc0', 'sso-client.js': '52439265b980', 'overlay.js': 'd18308ab8a63', 'ov-home.js': '631efe800b13', 'nav-slots.js': 'a5af310d62e0', 'ov-wallet.js': '5c4d7fdb448d', 'ov-wallet.css': 'e46f653fa59e' };++ const SIBLING_REV = { 'ov-mark.js': 'ced6e6ecf191', 'ov-glyphs.js': '6f8c8dd2a10d', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2d3a47d3535c', 'ov-fx.js': 'f0dc83b9c816', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '20f4ef41d127', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': '44a863d7a267', 'ov-share.css': '45b92b654148', 'search.js': 'bbae7542436f', 'dock.js': '7ff462f31951', 'presence.js': '1b675c37d888', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': 'e06f1388b696', 'history.js': '1e6598f78ba4', 'site-switcher.js': '292bea111aa8', 'appbar.js': '1edf7532f228', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'ba3981246bc0', 'sso-client.js': '52439265b980', 'overlay.js': 'd18308ab8a63', 'ov-home.js': '631efe800b13', 'nav-slots.js': 'a5af310d62e0', 'ov-wallet.js': '5c4d7fdb448d', 'ov-wallet.css': 'e46f653fa59e' };// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.@@@ -31,6 +31,11 @@+ `html:is(${CALM}){--ov-dur-1:.15s;--ov-dur-2:.25s;--ov-dur-3:.4s;--ov-dur-4:.62s;--ov-ease-spring:var(--ov-ease-out)}`+ `html:is(${SNAPPY}){--ov-dur-1:.09s;--ov-dur-2:.15s;--ov-dur-3:.24s;--ov-dur-4:.42s;--ov-ease-spring:cubic-bezier(.3,1.8,.6,1)}`+ 'html[data-ov-motion=reduced]{--ov-dur-1:0s;--ov-dur-2:0s;--ov-dur-3:0s;--ov-dur-4:0s}'++ // A demo scope keeps its lengths whatever reduced them (Calm above, or kit.css under the device's own setting),++ // in the look's own feel.++ + '[data-fx-demo]{--ov-dur-1:.12s;--ov-dur-2:.2s;--ov-dur-3:.32s;--ov-dur-4:.52s}'++ + `html:is(${CALM}) [data-fx-demo]{--ov-dur-1:.15s;--ov-dur-2:.25s;--ov-dur-3:.4s;--ov-dur-4:.62s}`++ + `html:is(${SNAPPY}) [data-fx-demo]{--ov-dur-1:.09s;--ov-dur-2:.15s;--ov-dur-3:.24s;--ov-dur-4:.42s}`+ '.ovfx-glint{position:relative;overflow:hidden;isolation:isolate}'+ '.ovfx-glint::after{content:"";position:absolute;inset:-2px;pointer-events:none;background:linear-gradient(105deg,transparent 35%,rgba(255,255,255,.28) 50%,transparent 65%);transform:translateX(-120%);z-index:1}'+ '.ovfx-glint:hover::after,.ovfx-glint:focus-visible::after{transform:translateX(120%);transition:transform .75s var(--ov-ease-in-out)}'@@@ -50,10 +55,13 @@+ '.ovfx-shimmer{background:linear-gradient(100deg,color-mix(in srgb,var(--text-muted,#8b93ad) 12%,transparent) 30%,color-mix(in srgb,var(--text-muted,#8b93ad) 22%,transparent) 50%,color-mix(in srgb,var(--text-muted,#8b93ad) 12%,transparent) 70%) 0 0/300% 100%;animation:ovfxShim 1.4s linear infinite;color:transparent!important;border-radius:8px}'+ '@keyframes ovfxShim{from{background-position:100% 0}to{background-position:0 0}}'+ '.ovfx-confetti{position:fixed;inset:0;pointer-events:none;z-index:2147483000}'-- + '@media (prefers-reduced-motion:reduce){.ovfx-glint::after{display:none}.ovfx-lift,.ovfx-press,.ovfx-tilt,.ovfx-in{transition:none!important}.ovfx-shimmer{animation:none}}'-- + 'html[data-ov-motion=reduced] .ovfx-glint::after{display:none}html[data-ov-motion=reduced] .ovfx-shimmer{animation:none}html[data-ov-motion=reduced] .ovfx-in{transition:none}';++ + '@media (prefers-reduced-motion:reduce){.ovfx-glint:not([data-fx-demo] *)::after{display:none}.ovfx-lift:not([data-fx-demo] *),.ovfx-press:not([data-fx-demo] *),.ovfx-tilt:not([data-fx-demo] *),.ovfx-in:not([data-fx-demo] *){transition:none!important}.ovfx-shimmer:not([data-fx-demo] *){animation:none}}'++ + 'html[data-ov-motion=reduced] .ovfx-glint:not([data-fx-demo] *)::after{display:none}html[data-ov-motion=reduced] .ovfx-shimmer:not([data-fx-demo] *){animation:none}html[data-ov-motion=reduced] .ovfx-in:not([data-fx-demo] *){transition:none}';-- const reduced = () => (root.matchMedia && root.matchMedia('(prefers-reduced-motion: reduce)').matches) || doc.documentElement.getAttribute('data-ov-motion') === 'reduced';++ // The demo scope (4.180.0): inside [data-fx-demo] the effects play even when motion is reduced. A page that exists++ // to show motion sets it on one container, only after the person presses a button that says so; nothing else may.++ const demo = (el) => !!(el && el.closest && el.closest('[data-fx-demo]'));++ const reduced = (el) => !demo(el) && ((root.matchMedia && root.matchMedia('(prefers-reduced-motion: reduce)').matches) || doc.documentElement.getAttribute('data-ov-motion') === 'reduced');const fine = () => !!(root.matchMedia && root.matchMedia('(hover: hover) and (pointer: fine)').matches);const done = new WeakSet();let io = null;@@@ -103,7 +111,7 @@// ── Pointer effects ──function ripple(el, ev) {-- if (reduced()) return;++ if (reduced(el)) return;const r = el.getBoundingClientRect(), d = Math.max(r.width, r.height) * 2.2;const s = doc.createElement('span'); s.className = 'ovfx-rip';s.style.cssText = `width:${d}px;height:${d}px;left:${(ev.clientX || r.left + r.width / 2) - r.left - d / 2}px;top:${(ev.clientY || r.top + r.height / 2) - r.top - d / 2}px`;@@@ -112,7 +120,7 @@function tilt(el) {let raf = 0;el.addEventListener('pointermove', (ev) => {-- if (ev.pointerType !== 'mouse' || reduced() || raf) return;++ if (ev.pointerType !== 'mouse' || reduced(el) || raf) return;raf = root.requestAnimationFrame(() => {raf = 0;const r = el.getBoundingClientRect(), x = (ev.clientX - r.left) / r.width - 0.5, y = (ev.clientY - r.top) / r.height - 0.5;@@@ -124,7 +132,7 @@// ── Confetti: one canvas, about 1.6 s, then gone ──function confetti(opts) {-- if (reduced() || !doc.body) return false;++ if (reduced(opts && opts.from) || !doc.body) return false;const o = opts || {}, cs = root.getComputedStyle(doc.documentElement);const accent = (cs.getPropertyValue('--accent') || '').trim() || '#4f7cff';const colors = o.colors || [accent, '#4f7cff', '#8b5cf6', '#22c55e', '#facc15', '#f472b6'];@@@ -163,15 +171,15 @@if (n === 'glint' || n === 'glow' || n === 'lift' || n === 'press' || n === 'ripple' || n === 'shimmer') el.classList.add(`ovfx-${n}`);if (n === 'ripple') el.addEventListener('pointerdown', (ev) => ripple(el, ev));if (n === 'tilt' && fine()) { el.classList.add('ovfx-tilt'); tilt(el); }-- if (n === 'confetti') el.addEventListener('click', () => { const r = el.getBoundingClientRect(); confetti({ x: r.left + r.width / 2, y: r.top + r.height / 2, count: 70 }); });-- if (n === 'reveal' && !reduced()) {++ if (n === 'confetti') el.addEventListener('click', () => { const r = el.getBoundingClientRect(); confetti({ x: r.left + r.width / 2, y: r.top + r.height / 2, count: 70, from: el }); });++ if (n === 'reveal' && !reduced(el)) {const kids = el.children.length && el.getAttribute('data-fx-self') == null ? Array.from(el.children) : [el];if (!below(el)) continue; // already on screen when wired: never hide what someone can seeconst gap = Math.max(0, Math.min(200, Number(el.getAttribute('data-fx-stagger')) || 50));kids.forEach((k, i) => { k.classList.add('ovfx-hide'); k.style.setProperty('--ovfx-d', `${Math.min(i, 12) * gap}ms`); });el.__ovfxReveal = kids; observe(el);}-- if (n === 'count' && !reduced()) {++ if (n === 'count' && !reduced(el)) {const c = countOf(el);if (c && below(el)) { el.__ovfxCount = c; observe(el); } else if (c) { el.__ovfxCount = c; seen(el); }}@@@ -186,8 +186,8 @@ html[data-ov-season-mark=winter] .ovm-fall i{width:6px;height:6px;border-radius:@keyframes ovmWiggle{0%,100%{transform:none}25%{transform:rotate(-18deg)}60%{transform:rotate(12deg)}}@keyframes ovmTwinkle{0%,100%{opacity:1}50%{opacity:.3}}@keyframes ovmFall{0%{opacity:0;transform:none}12%{opacity:1}72%{opacity:1;transform:translate(var(--x),var(--y)) rotate(var(--r))}100%{opacity:0;transform:translate(var(--x),var(--y)) rotate(var(--r))}}--@media (prefers-reduced-motion:reduce){.ov-mark *{animation-iteration-count:1!important}.ov-mark::before{transition:none!important}.ovm-fall{display:none}}--html[data-ov-motion=reduced] .ov-mark *{animation-iteration-count:1!important}++@media (prefers-reduced-motion:reduce){.ov-mark:not([data-fx-demo] *) *{animation-iteration-count:1!important}.ov-mark:not([data-fx-demo] *)::before{transition:none!important}.ovm-fall{display:none}}++html[data-ov-motion=reduced] .ov-mark:not([data-fx-demo] *) *{animation-iteration-count:1!important}.ov-busy{cursor:progress;pointer-events:none}.ov-busy-mark{display:inline-block;vertical-align:-.2em;margin-right:.45em;flex:none}`;@@@ -251,7 +251,9 @@ html[data-ov-motion=reduced] .ov-mark *{animation-iteration-count:1!important}if (!end || end > now + INTRO_MS) { end = now + INTRO_MS; try { sessionStorage.setItem(INTRO_KEY, String(end)); } catch { /* */ } }return end;}-- function reduced() {++ // Inside [data-fx-demo] (ov-fx.js: a motion page's own container, set when the person asks) a mark plays in full.++ function reduced(el) {++ if (el && el.closest && el.closest('[data-fx-demo]')) return false;return !!(rm && rm.matches) || document.documentElement.getAttribute('data-ov-motion') === 'reduced';}function update(el) {@@@ -261,12 +263,12 @@ html[data-ov-motion=reduced] .ov-mark *{animation-iteration-count:1!important}const now = Date.now();// Calm (the Motion setting, or the device asking for less) is gentle, not still: a state still animates, once.const may = !el.hasAttribute('data-static') && !document.hidden && st.onScreen === true;-- if (may && !reduced() && !st.introEnd) { st.introEnd = introDeadline(now); if (season()) setTimeout(() => fallOnce(el), 900); }++ if (may && !reduced(el) && !st.introEnd) { st.introEnd = introDeadline(now); if (season()) setTimeout(() => fallOnce(el), 900); }const s = el.getAttribute('data-state');// Anything the mark is showing keeps it moving: a state, a page on its way, a known progress.const showing = (s && s !== 'idle') || el.hasAttribute('data-nav') || el.hasAttribute('data-progress');const intro = now < st.introEnd;-- const run = may && (showing || (!reduced() && (intro || st.hover || st.focus || now < st.lingerEnd)));++ const run = may && (showing || (!reduced(el) && (intro || st.hover || st.focus || now < st.lingerEnd)));if (run !== el.hasAttribute('data-ovm-run')) { if (run) el.setAttribute('data-ovm-run', ''); else el.removeAttribute('data-ovm-run'); }// The intro's pop and growth are on only inside the intro (paused with the rest while the mark cannot move).if ((may && intro) !== el.hasAttribute('data-ovm-intro') && (may || !intro)) { if (may && intro) el.setAttribute('data-ovm-intro', ''); else el.removeAttribute('data-ovm-intro'); }@@@ -381,7 +383,7 @@ html[data-ov-motion=reduced] .ov-mark *{animation-iteration-count:1!important}new MutationObserver((records) => {let added = false;for (const r of records) {-- if (r.type === 'attributes') { if (r.target === document.documentElement) { updateAll(); if (r.attributeName === 'data-ov-season-mark') scheduleFlourish(); } else if (state.has(r.target)) update(r.target); continue; }++ if (r.type === 'attributes') { if (r.attributeName === 'data-fx-demo') { updateAll(); continue; } if (r.target === document.documentElement) { updateAll(); if (r.attributeName === 'data-ov-season-mark') scheduleFlourish(); } else if (state.has(r.target)) update(r.target); continue; }if (r.addedNodes.length) added = true;r.removedNodes.forEach((node) => {if (node.nodeType !== 1) return;@@@ -390,7 +392,7 @@ html[data-ov-motion=reduced] .ov-mark *{animation-iteration-count:1!important}});}if (added) mount();-- }).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-state', 'data-static', 'data-nav', 'data-progress', 'data-ov-motion', 'data-ov-season-mark'] });++ }).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-state', 'data-static', 'data-nav', 'data-progress', 'data-ov-motion', 'data-ov-season-mark', 'data-fx-demo'] });} catch { /* */ }}// ── A page on its way: the navbar's mark draws its ring round as the next page loads ──@@@ -1,12 +1,12 @@{"name": "openvibe-shared",-- "version": "4.179.0",++ "version": "4.180.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "4.179.0",++ "version": "4.180.0","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0",@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.179.0",++ "version": "4.180.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -41,7 +41,9 @@ function setup({ reduce = false, calm = false, html = '' } = {}) {const css = t.document.getElementById('ov-fx-css').textContent;assert.match(css, /--ov-dur-1:\.12s;--ov-dur-2:\.2s;--ov-dur-3:\.32s;--ov-dur-4:\.52s/, 'the motion tokens');assert.match(css, /html\[data-ov-motion=reduced\]\{--ov-dur-1:0s/, 'Calm zeroes them');-- assert.match(css, /@media \(prefers-reduced-motion:reduce\)\{\.ovfx-glint::after\{display:none\}/);++ assert.ok(css.includes('@media (prefers-reduced-motion:reduce){.ovfx-glint:not([data-fx-demo] *)::after{display:none}'), 'the device asking for less motion stops the glint, outside a demo scope');++ assert.ok(css.includes('[data-fx-demo]{--ov-dur-1:.12s;--ov-dur-2:.2s'), 'a demo scope sets its own lengths, so neither Calm nor the device setting (kit.css) reaches inside it');++ assert.ok(/html:is\([^)]*neon-tokyo[^)]*\) \[data-fx-demo\]\{--ov-dur-1:\.09s/.test(css), 'in the look\'s own feel');assert.ok(!/infinite/.test(css.replace(/\.ovfx-shimmer\{[^}]*\}/, '')), 'nothing loops but a placeholder\'s shimmer');assert.strictEqual(typeof t.window.OVFx.confetti, 'function');// A feel per look (4.67.0): quiet themes move calmly (no overshoot), neon ones snap; Calm still wins.@@@ -86,6 +88,14 @@ function setup({ reduce = false, calm = false, html = '' } = {}) {assert.strictEqual(t.window.OVFx.confetti({ x: 1, y: 1 }), false, 'no confetti');}++ // ── the demo scope: a page that shows motion may play it inside [data-fx-demo], when the person asks ──++ for (const opts of [{ reduce: true }, { calm: true }]) {++ const t = setup({ ...opts, html: '<div data-fx-demo><ul id="in" data-fx="reveal" data-top="1400"><li>a</li></ul></div><ul id="out" data-fx="reveal" data-top="1400"><li>b</li></ul>' });++ assert.strictEqual(t.document.querySelectorAll('#in .ovfx-hide').length, 1, 'inside the scope the reveal waits to play');++ assert.strictEqual(t.document.querySelectorAll('#out .ovfx-hide').length, 0, 'outside it nothing is hidden: the setting holds for the rest of the page');++ assert.strictEqual(t.window.OVFx.confetti({ x: 1, y: 1 }), false, 'confetti with no source element stays off');++ }++// ── markup added later is wired ──{const t = setup();@@@ -220,6 +220,13 @@ function setup({ reduce = false, session = memoryStorage(), start = 1000000, rooassert.strictEqual(t.running(), false, 'prefers-reduced-motion');assert.strictEqual(t.smilPaused(), true, 'SMIL stopped too (the V sweep used to run under reduced motion)');++ // The demo scope: inside [data-fx-demo] (a motion page's own container, set when the person asks) it plays.++ const d = setup({ reduce: true });++ d.document.body.setAttribute('data-fx-demo', '');++ d.io.fire(d.mark, true);++ d.mark.closest('a').dispatchEvent(new d.Event('pointerenter'));++ assert.strictEqual(d.running(), true, 'a mark inside a demo scope plays under prefers-reduced-motion');++const u = setup();u.io.fire(u.mark, true);u.document.documentElement.setAttribute('data-ov-motion', 'reduced'); await u.flush();@@@ -340,7 +347,7 @@ function setup({ reduce = false, session = memoryStorage(), start = 1000000, rooassert.match(css, /\.ov-mark:hover \.gr\{animation:ovmGlint|\.ov-mark:hover \.gr,/, 'and crosses on hover');assert.match(css, /\.ov-mark\[data-glow\]::before\{opacity:\.42/, 'data-glow keeps the glow on');assert.match(css, /\.ov-mark\[data-tone\]::before,\.ov-mark\[data-state=sleep\]::before\{display:none\}/);-- assert.ok(css.includes('.ov-mark::before{transition:none!important}'), 'reduced motion: the glow appears without moving');++ assert.ok(css.includes('.ov-mark:not([data-fx-demo] *)::before{transition:none!important}'), 'reduced motion: the glow appears without moving (outside a demo scope)');assert.match(css, /\.ov-mark:active \.m\{transform:scale\(\.9\)/, 'a press gives a little');assert.ok(!/ovmPop/.test(css), 'the old pop is gone');// The glint's copy of the V follows the V into its shapes.@@@ -380,7 +380,7 @@for (const k in COMFORT_ATTRS) { const [attr, off] = COMFORT_ATTRS[k]; if (d[k] && d[k] !== off) el.setAttribute(attr, d[k]); else el.removeAttribute(attr); }if (!document.getElementById('ov-display-css') && document.head) {const st = document.createElement('style'); st.id = 'ov-display-css';-- st.textContent = 'html[data-ov-text]{font-size:calc(100% * var(--ov-text-scale,1))}html[data-ov-motion=reduced] *,html[data-ov-motion=reduced] *::before,html[data-ov-motion=reduced] *::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}html[data-ov-motion=reduced] .ov-icon .ovi-comet{display:none}' + ALIAS_CSS + COMFORT_CSS;++ st.textContent = 'html[data-ov-text]{font-size:calc(100% * var(--ov-text-scale,1))}html[data-ov-motion=reduced] *:not([data-fx-demo] *),html[data-ov-motion=reduced] *:not([data-fx-demo] *)::before,html[data-ov-motion=reduced] *:not([data-fx-demo] *)::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}html[data-ov-motion=reduced] .ov-icon .ovi-comet{display:none}' + ALIAS_CSS + COMFORT_CSS;document.head.appendChild(st);}try { window.dispatchEvent(new CustomEvent('ov:display', { detail: d })); } catch (e) { /* */ }
@@ -1,5 +1,17 @@
@@ -1,5 +1,17 @@
# Changelog
# Changelog
+## 4.180.0 — 2026-11-10
+
+- **A demo scope for motion: `data-fx-demo`** (`ov-fx.js`, `ov-mark.js`, `theme-loader.js`). Inside a container
+ with this attribute the effects and the mark play even when motion is reduced, by the device or by Calm. It is for
+ a page that exists to show motion, and only after the person presses a button that says so: openvibe.design's
+ Motion page was completely still on a phone that asks for less motion, and never said why. Outside the container
+ nothing changes: the setting holds for the rest of the page and for every other page.
+ - `OVFx.confetti({ from: el })` names the element it comes from, so confetti inside a demo scope plays; without
+ `from` it follows the setting as before.
+ - Calm's blanket rule, the effects' reduced-motion rules and the mark's now exclude `[data-fx-demo] *`, and a demo
+ scope gets its `--ov-dur-*` lengths back under Calm.
+
## 4.179.0 — 2026-11-09
## 4.179.0 — 2026-11-09
**Share to OpenVibe Community.** The share sheet (`ov-share.js`) has a Community tile beside OpenVibe Chat: it opens
**Share to OpenVibe Community.** The share sheet (`ov-share.js`) has a Community tile beside OpenVibe Chat: it opens
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
-**Status:** alpha, v4.179.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.180.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.179.0",
+ "version": "4.180.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
@@ -572,7 +572,7 @@
@@ -572,7 +572,7 @@
const NAV_ICONS_REV = '1091daee9c7e'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
const NAV_ICONS_REV = '1091daee9c7e'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
// END generated
// END generated
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
- const SIBLING_REV = { 'ov-mark.js': 'd7e87def8fff', 'ov-glyphs.js': '6f8c8dd2a10d', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2d3a47d3535c', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '20f4ef41d127', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': '44a863d7a267', 'ov-share.css': '45b92b654148', 'search.js': 'bbae7542436f', 'dock.js': '7ff462f31951', 'presence.js': '1b675c37d888', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': 'e06f1388b696', 'history.js': '1e6598f78ba4', 'site-switcher.js': '292bea111aa8', 'appbar.js': '1edf7532f228', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'ba3981246bc0', 'sso-client.js': '52439265b980', 'overlay.js': 'd18308ab8a63', 'ov-home.js': '631efe800b13', 'nav-slots.js': 'a5af310d62e0', 'ov-wallet.js': '5c4d7fdb448d', 'ov-wallet.css': 'e46f653fa59e' };
+ const SIBLING_REV = { 'ov-mark.js': 'ced6e6ecf191', 'ov-glyphs.js': '6f8c8dd2a10d', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2d3a47d3535c', 'ov-fx.js': 'f0dc83b9c816', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '20f4ef41d127', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': '44a863d7a267', 'ov-share.css': '45b92b654148', 'search.js': 'bbae7542436f', 'dock.js': '7ff462f31951', 'presence.js': '1b675c37d888', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': 'e06f1388b696', 'history.js': '1e6598f78ba4', 'site-switcher.js': '292bea111aa8', 'appbar.js': '1edf7532f228', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'ba3981246bc0', 'sso-client.js': '52439265b980', 'overlay.js': 'd18308ab8a63', 'ov-home.js': '631efe800b13', 'nav-slots.js': 'a5af310d62e0', 'ov-wallet.js': '5c4d7fdb448d', 'ov-wallet.css': 'e46f653fa59e' };
// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest
// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest
// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their
// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
@@ -31,6 +31,11 @@
@@ -31,6 +31,11 @@
+ `html:is(${CALM}){--ov-dur-1:.15s;--ov-dur-2:.25s;--ov-dur-3:.4s;--ov-dur-4:.62s;--ov-ease-spring:var(--ov-ease-out)}`
+ `html:is(${CALM}){--ov-dur-1:.15s;--ov-dur-2:.25s;--ov-dur-3:.4s;--ov-dur-4:.62s;--ov-ease-spring:var(--ov-ease-out)}`
+ `html:is(${SNAPPY}){--ov-dur-1:.09s;--ov-dur-2:.15s;--ov-dur-3:.24s;--ov-dur-4:.42s;--ov-ease-spring:cubic-bezier(.3,1.8,.6,1)}`
+ `html:is(${SNAPPY}){--ov-dur-1:.09s;--ov-dur-2:.15s;--ov-dur-3:.24s;--ov-dur-4:.42s;--ov-ease-spring:cubic-bezier(.3,1.8,.6,1)}`
+ 'html[data-ov-motion=reduced]{--ov-dur-1:0s;--ov-dur-2:0s;--ov-dur-3:0s;--ov-dur-4:0s}'
+ 'html[data-ov-motion=reduced]{--ov-dur-1:0s;--ov-dur-2:0s;--ov-dur-3:0s;--ov-dur-4:0s}'
+ // A demo scope keeps its lengths whatever reduced them (Calm above, or kit.css under the device's own setting),
+ // in the look's own feel.
+ + '[data-fx-demo]{--ov-dur-1:.12s;--ov-dur-2:.2s;--ov-dur-3:.32s;--ov-dur-4:.52s}'
+ + `html:is(${CALM}) [data-fx-demo]{--ov-dur-1:.15s;--ov-dur-2:.25s;--ov-dur-3:.4s;--ov-dur-4:.62s}`
+ + `html:is(${SNAPPY}) [data-fx-demo]{--ov-dur-1:.09s;--ov-dur-2:.15s;--ov-dur-3:.24s;--ov-dur-4:.42s}`
+ '.ovfx-glint{position:relative;overflow:hidden;isolation:isolate}'
+ '.ovfx-glint{position:relative;overflow:hidden;isolation:isolate}'
+ '.ovfx-glint::after{content:"";position:absolute;inset:-2px;pointer-events:none;background:linear-gradient(105deg,transparent 35%,rgba(255,255,255,.28) 50%,transparent 65%);transform:translateX(-120%);z-index:1}'
+ '.ovfx-glint::after{content:"";position:absolute;inset:-2px;pointer-events:none;background:linear-gradient(105deg,transparent 35%,rgba(255,255,255,.28) 50%,transparent 65%);transform:translateX(-120%);z-index:1}'
+ '.ovfx-glint:hover::after,.ovfx-glint:focus-visible::after{transform:translateX(120%);transition:transform .75s var(--ov-ease-in-out)}'
+ '.ovfx-glint:hover::after,.ovfx-glint:focus-visible::after{transform:translateX(120%);transition:transform .75s var(--ov-ease-in-out)}'
@@ -50,10 +55,13 @@
@@ -50,10 +55,13 @@
+ '.ovfx-shimmer{background:linear-gradient(100deg,color-mix(in srgb,var(--text-muted,#8b93ad) 12%,transparent) 30%,color-mix(in srgb,var(--text-muted,#8b93ad) 22%,transparent) 50%,color-mix(in srgb,var(--text-muted,#8b93ad) 12%,transparent) 70%) 0 0/300% 100%;animation:ovfxShim 1.4s linear infinite;color:transparent!important;border-radius:8px}'
+ '.ovfx-shimmer{background:linear-gradient(100deg,color-mix(in srgb,var(--text-muted,#8b93ad) 12%,transparent) 30%,color-mix(in srgb,var(--text-muted,#8b93ad) 22%,transparent) 50%,color-mix(in srgb,var(--text-muted,#8b93ad) 12%,transparent) 70%) 0 0/300% 100%;animation:ovfxShim 1.4s linear infinite;color:transparent!important;border-radius:8px}'
+ '@keyframes ovfxShim{from{background-position:100% 0}to{background-position:0 0}}'
+ '@keyframes ovfxShim{from{background-position:100% 0}to{background-position:0 0}}'
+ '.ovfx-confetti{position:fixed;inset:0;pointer-events:none;z-index:2147483000}'
+ '.ovfx-confetti{position:fixed;inset:0;pointer-events:none;z-index:2147483000}'
- + '@media (prefers-reduced-motion:reduce){.ovfx-glint::after{display:none}.ovfx-lift,.ovfx-press,.ovfx-tilt,.ovfx-in{transition:none!important}.ovfx-shimmer{animation:none}}'
- + 'html[data-ov-motion=reduced] .ovfx-glint::after{display:none}html[data-ov-motion=reduced] .ovfx-shimmer{animation:none}html[data-ov-motion=reduced] .ovfx-in{transition:none}';
+ + '@media (prefers-reduced-motion:reduce){.ovfx-glint:not([data-fx-demo] *)::after{display:none}.ovfx-lift:not([data-fx-demo] *),.ovfx-press:not([data-fx-demo] *),.ovfx-tilt:not([data-fx-demo] *),.ovfx-in:not([data-fx-demo] *){transition:none!important}.ovfx-shimmer:not([data-fx-demo] *){animation:none}}'
+ + 'html[data-ov-motion=reduced] .ovfx-glint:not([data-fx-demo] *)::after{display:none}html[data-ov-motion=reduced] .ovfx-shimmer:not([data-fx-demo] *){animation:none}html[data-ov-motion=reduced] .ovfx-in:not([data-fx-demo] *){transition:none}';
- const reduced = () => (root.matchMedia && root.matchMedia('(prefers-reduced-motion: reduce)').matches) || doc.documentElement.getAttribute('data-ov-motion') === 'reduced';
+ // The demo scope (4.180.0): inside [data-fx-demo] the effects play even when motion is reduced. A page that exists
+ // to show motion sets it on one container, only after the person presses a button that says so; nothing else may.
+ const demo = (el) => !!(el && el.closest && el.closest('[data-fx-demo]'));
+ const reduced = (el) => !demo(el) && ((root.matchMedia && root.matchMedia('(prefers-reduced-motion: reduce)').matches) || doc.documentElement.getAttribute('data-ov-motion') === 'reduced');
const fine = () => !!(root.matchMedia && root.matchMedia('(hover: hover) and (pointer: fine)').matches);
const fine = () => !!(root.matchMedia && root.matchMedia('(hover: hover) and (pointer: fine)').matches);
const done = new WeakSet();
const done = new WeakSet();
let io = null;
let io = null;
@@ -103,7 +111,7 @@
@@ -103,7 +111,7 @@
// ── Pointer effects ──
// ── Pointer effects ──
function ripple(el, ev) {
function ripple(el, ev) {
- if (reduced()) return;
+ if (reduced(el)) return;
const r = el.getBoundingClientRect(), d = Math.max(r.width, r.height) * 2.2;
const r = el.getBoundingClientRect(), d = Math.max(r.width, r.height) * 2.2;
const s = doc.createElement('span'); s.className = 'ovfx-rip';
const s = doc.createElement('span'); s.className = 'ovfx-rip';
s.style.cssText = `width:${d}px;height:${d}px;left:${(ev.clientX || r.left + r.width / 2) - r.left - d / 2}px;top:${(ev.clientY || r.top + r.height / 2) - r.top - d / 2}px`;
s.style.cssText = `width:${d}px;height:${d}px;left:${(ev.clientX || r.left + r.width / 2) - r.left - d / 2}px;top:${(ev.clientY || r.top + r.height / 2) - r.top - d / 2}px`;
@@ -112,7 +120,7 @@
@@ -112,7 +120,7 @@
function tilt(el) {
function tilt(el) {
let raf = 0;
let raf = 0;
el.addEventListener('pointermove', (ev) => {
el.addEventListener('pointermove', (ev) => {
- if (ev.pointerType !== 'mouse' || reduced() || raf) return;
+ if (ev.pointerType !== 'mouse' || reduced(el) || raf) return;
raf = root.requestAnimationFrame(() => {
raf = root.requestAnimationFrame(() => {
raf = 0;
raf = 0;
const r = el.getBoundingClientRect(), x = (ev.clientX - r.left) / r.width - 0.5, y = (ev.clientY - r.top) / r.height - 0.5;
const r = el.getBoundingClientRect(), x = (ev.clientX - r.left) / r.width - 0.5, y = (ev.clientY - r.top) / r.height - 0.5;
@@ -124,7 +132,7 @@
@@ -124,7 +132,7 @@
// ── Confetti: one canvas, about 1.6 s, then gone ──
// ── Confetti: one canvas, about 1.6 s, then gone ──
function confetti(opts) {
function confetti(opts) {
- if (reduced() || !doc.body) return false;
+ if (reduced(opts && opts.from) || !doc.body) return false;
const o = opts || {}, cs = root.getComputedStyle(doc.documentElement);
const o = opts || {}, cs = root.getComputedStyle(doc.documentElement);
const accent = (cs.getPropertyValue('--accent') || '').trim() || '#4f7cff';
const accent = (cs.getPropertyValue('--accent') || '').trim() || '#4f7cff';
const colors = o.colors || [accent, '#4f7cff', '#8b5cf6', '#22c55e', '#facc15', '#f472b6'];
const colors = o.colors || [accent, '#4f7cff', '#8b5cf6', '#22c55e', '#facc15', '#f472b6'];
@@ -163,15 +171,15 @@
@@ -163,15 +171,15 @@
if (n === 'glint' || n === 'glow' || n === 'lift' || n === 'press' || n === 'ripple' || n === 'shimmer') el.classList.add(`ovfx-${n}`);
if (n === 'glint' || n === 'glow' || n === 'lift' || n === 'press' || n === 'ripple' || n === 'shimmer') el.classList.add(`ovfx-${n}`);
if (n === 'ripple') el.addEventListener('pointerdown', (ev) => ripple(el, ev));
if (n === 'ripple') el.addEventListener('pointerdown', (ev) => ripple(el, ev));
if (n === 'tilt' && fine()) { el.classList.add('ovfx-tilt'); tilt(el); }
if (n === 'tilt' && fine()) { el.classList.add('ovfx-tilt'); tilt(el); }
- if (n === 'confetti') el.addEventListener('click', () => { const r = el.getBoundingClientRect(); confetti({ x: r.left + r.width / 2, y: r.top + r.height / 2, count: 70 }); });
- if (n === 'reveal' && !reduced()) {
+ if (n === 'confetti') el.addEventListener('click', () => { const r = el.getBoundingClientRect(); confetti({ x: r.left + r.width / 2, y: r.top + r.height / 2, count: 70, from: el }); });
+ if (n === 'reveal' && !reduced(el)) {
const kids = el.children.length && el.getAttribute('data-fx-self') == null ? Array.from(el.children) : [el];
const kids = el.children.length && el.getAttribute('data-fx-self') == null ? Array.from(el.children) : [el];
if (!below(el)) continue; // already on screen when wired: never hide what someone can see
if (!below(el)) continue; // already on screen when wired: never hide what someone can see
const gap = Math.max(0, Math.min(200, Number(el.getAttribute('data-fx-stagger')) || 50));
const gap = Math.max(0, Math.min(200, Number(el.getAttribute('data-fx-stagger')) || 50));
kids.forEach((k, i) => { k.classList.add('ovfx-hide'); k.style.setProperty('--ovfx-d', `${Math.min(i, 12) * gap}ms`); });
kids.forEach((k, i) => { k.classList.add('ovfx-hide'); k.style.setProperty('--ovfx-d', `${Math.min(i, 12) * gap}ms`); });
el.__ovfxReveal = kids; observe(el);
el.__ovfxReveal = kids; observe(el);
}
}
- if (n === 'count' && !reduced()) {
+ if (n === 'count' && !reduced(el)) {
const c = countOf(el);
const c = countOf(el);
if (c && below(el)) { el.__ovfxCount = c; observe(el); } else if (c) { el.__ovfxCount = c; seen(el); }
if (c && below(el)) { el.__ovfxCount = c; observe(el); } else if (c) { el.__ovfxCount = c; seen(el); }
}
}
@@ -186,8 +186,8 @@ html[data-ov-season-mark=winter] .ovm-fall i{width:6px;height:6px;border-radius:
@@ -186,8 +186,8 @@ html[data-ov-season-mark=winter] .ovm-fall i{width:6px;height:6px;border-radius:
@keyframes ovmWiggle{0%,100%{transform:none}25%{transform:rotate(-18deg)}60%{transform:rotate(12deg)}}
@keyframes ovmWiggle{0%,100%{transform:none}25%{transform:rotate(-18deg)}60%{transform:rotate(12deg)}}
@keyframes ovmTwinkle{0%,100%{opacity:1}50%{opacity:.3}}
@keyframes ovmTwinkle{0%,100%{opacity:1}50%{opacity:.3}}
@keyframes ovmFall{0%{opacity:0;transform:none}12%{opacity:1}72%{opacity:1;transform:translate(var(--x),var(--y)) rotate(var(--r))}100%{opacity:0;transform:translate(var(--x),var(--y)) rotate(var(--r))}}
@keyframes ovmFall{0%{opacity:0;transform:none}12%{opacity:1}72%{opacity:1;transform:translate(var(--x),var(--y)) rotate(var(--r))}100%{opacity:0;transform:translate(var(--x),var(--y)) rotate(var(--r))}}
-@media (prefers-reduced-motion:reduce){.ov-mark *{animation-iteration-count:1!important}.ov-mark::before{transition:none!important}.ovm-fall{display:none}}
-html[data-ov-motion=reduced] .ov-mark *{animation-iteration-count:1!important}
+@media (prefers-reduced-motion:reduce){.ov-mark:not([data-fx-demo] *) *{animation-iteration-count:1!important}.ov-mark:not([data-fx-demo] *)::before{transition:none!important}.ovm-fall{display:none}}
+html[data-ov-motion=reduced] .ov-mark:not([data-fx-demo] *) *{animation-iteration-count:1!important}
.ov-busy{cursor:progress;pointer-events:none}
.ov-busy{cursor:progress;pointer-events:none}
.ov-busy-mark{display:inline-block;vertical-align:-.2em;margin-right:.45em;flex:none}
.ov-busy-mark{display:inline-block;vertical-align:-.2em;margin-right:.45em;flex:none}
`;
`;
@@ -251,7 +251,9 @@ html[data-ov-motion=reduced] .ov-mark *{animation-iteration-count:1!important}
@@ -251,7 +251,9 @@ html[data-ov-motion=reduced] .ov-mark *{animation-iteration-count:1!important}
if (!end || end > now + INTRO_MS) { end = now + INTRO_MS; try { sessionStorage.setItem(INTRO_KEY, String(end)); } catch { /* */ } }
if (!end || end > now + INTRO_MS) { end = now + INTRO_MS; try { sessionStorage.setItem(INTRO_KEY, String(end)); } catch { /* */ } }
return end;
return end;
}
}
- function reduced() {
+ // Inside [data-fx-demo] (ov-fx.js: a motion page's own container, set when the person asks) a mark plays in full.
+ function reduced(el) {
+ if (el && el.closest && el.closest('[data-fx-demo]')) return false;
return !!(rm && rm.matches) || document.documentElement.getAttribute('data-ov-motion') === 'reduced';
return !!(rm && rm.matches) || document.documentElement.getAttribute('data-ov-motion') === 'reduced';
}
}
function update(el) {
function update(el) {
@@ -261,12 +263,12 @@ html[data-ov-motion=reduced] .ov-mark *{animation-iteration-count:1!important}
@@ -261,12 +263,12 @@ html[data-ov-motion=reduced] .ov-mark *{animation-iteration-count:1!important}
const now = Date.now();
const now = Date.now();
// Calm (the Motion setting, or the device asking for less) is gentle, not still: a state still animates, once.
// Calm (the Motion setting, or the device asking for less) is gentle, not still: a state still animates, once.
const may = !el.hasAttribute('data-static') && !document.hidden && st.onScreen === true;
const may = !el.hasAttribute('data-static') && !document.hidden && st.onScreen === true;
- if (may && !reduced() && !st.introEnd) { st.introEnd = introDeadline(now); if (season()) setTimeout(() => fallOnce(el), 900); }
+ if (may && !reduced(el) && !st.introEnd) { st.introEnd = introDeadline(now); if (season()) setTimeout(() => fallOnce(el), 900); }
const s = el.getAttribute('data-state');
const s = el.getAttribute('data-state');
// Anything the mark is showing keeps it moving: a state, a page on its way, a known progress.
// Anything the mark is showing keeps it moving: a state, a page on its way, a known progress.
const showing = (s && s !== 'idle') || el.hasAttribute('data-nav') || el.hasAttribute('data-progress');
const showing = (s && s !== 'idle') || el.hasAttribute('data-nav') || el.hasAttribute('data-progress');
const intro = now < st.introEnd;
const intro = now < st.introEnd;
- const run = may && (showing || (!reduced() && (intro || st.hover || st.focus || now < st.lingerEnd)));
+ const run = may && (showing || (!reduced(el) && (intro || st.hover || st.focus || now < st.lingerEnd)));
if (run !== el.hasAttribute('data-ovm-run')) { if (run) el.setAttribute('data-ovm-run', ''); else el.removeAttribute('data-ovm-run'); }
if (run !== el.hasAttribute('data-ovm-run')) { if (run) el.setAttribute('data-ovm-run', ''); else el.removeAttribute('data-ovm-run'); }
// The intro's pop and growth are on only inside the intro (paused with the rest while the mark cannot move).
// The intro's pop and growth are on only inside the intro (paused with the rest while the mark cannot move).
if ((may && intro) !== el.hasAttribute('data-ovm-intro') && (may || !intro)) { if (may && intro) el.setAttribute('data-ovm-intro', ''); else el.removeAttribute('data-ovm-intro'); }
if ((may && intro) !== el.hasAttribute('data-ovm-intro') && (may || !intro)) { if (may && intro) el.setAttribute('data-ovm-intro', ''); else el.removeAttribute('data-ovm-intro'); }
@@ -381,7 +383,7 @@ html[data-ov-motion=reduced] .ov-mark *{animation-iteration-count:1!important}
@@ -381,7 +383,7 @@ html[data-ov-motion=reduced] .ov-mark *{animation-iteration-count:1!important}
new MutationObserver((records) => {
new MutationObserver((records) => {
let added = false;
let added = false;
for (const r of records) {
for (const r of records) {
- if (r.type === 'attributes') { if (r.target === document.documentElement) { updateAll(); if (r.attributeName === 'data-ov-season-mark') scheduleFlourish(); } else if (state.has(r.target)) update(r.target); continue; }
+ if (r.type === 'attributes') { if (r.attributeName === 'data-fx-demo') { updateAll(); continue; } if (r.target === document.documentElement) { updateAll(); if (r.attributeName === 'data-ov-season-mark') scheduleFlourish(); } else if (state.has(r.target)) update(r.target); continue; }
if (r.addedNodes.length) added = true;
if (r.addedNodes.length) added = true;
r.removedNodes.forEach((node) => {
r.removedNodes.forEach((node) => {
if (node.nodeType !== 1) return;
if (node.nodeType !== 1) return;
@@ -390,7 +392,7 @@ html[data-ov-motion=reduced] .ov-mark *{animation-iteration-count:1!important}
@@ -390,7 +392,7 @@ html[data-ov-motion=reduced] .ov-mark *{animation-iteration-count:1!important}
});
});
}
}
if (added) mount();
if (added) mount();
- }).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-state', 'data-static', 'data-nav', 'data-progress', 'data-ov-motion', 'data-ov-season-mark'] });
+ }).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-state', 'data-static', 'data-nav', 'data-progress', 'data-ov-motion', 'data-ov-season-mark', 'data-fx-demo'] });
} catch { /* */ }
} catch { /* */ }
}
}
// ── A page on its way: the navbar's mark draws its ring round as the next page loads ──
// ── A page on its way: the navbar's mark draws its ring round as the next page loads ──
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.179.0",
+ "version": "4.180.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.179.0",
+ "version": "4.180.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": "4.179.0",
+ "version": "4.180.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": {
@@ -41,7 +41,9 @@ function setup({ reduce = false, calm = false, html = '' } = {}) {
@@ -41,7 +41,9 @@ function setup({ reduce = false, calm = false, html = '' } = {}) {
const css = t.document.getElementById('ov-fx-css').textContent;
const css = t.document.getElementById('ov-fx-css').textContent;
assert.match(css, /--ov-dur-1:\.12s;--ov-dur-2:\.2s;--ov-dur-3:\.32s;--ov-dur-4:\.52s/, 'the motion tokens');
assert.match(css, /--ov-dur-1:\.12s;--ov-dur-2:\.2s;--ov-dur-3:\.32s;--ov-dur-4:\.52s/, 'the motion tokens');
assert.match(css, /html\[data-ov-motion=reduced\]\{--ov-dur-1:0s/, 'Calm zeroes them');
assert.match(css, /html\[data-ov-motion=reduced\]\{--ov-dur-1:0s/, 'Calm zeroes them');
- assert.match(css, /@media \(prefers-reduced-motion:reduce\)\{\.ovfx-glint::after\{display:none\}/);
+ assert.ok(css.includes('@media (prefers-reduced-motion:reduce){.ovfx-glint:not([data-fx-demo] *)::after{display:none}'), 'the device asking for less motion stops the glint, outside a demo scope');
+ assert.ok(css.includes('[data-fx-demo]{--ov-dur-1:.12s;--ov-dur-2:.2s'), 'a demo scope sets its own lengths, so neither Calm nor the device setting (kit.css) reaches inside it');
+ assert.ok(/html:is\([^)]*neon-tokyo[^)]*\) \[data-fx-demo\]\{--ov-dur-1:\.09s/.test(css), 'in the look\'s own feel');
assert.ok(!/infinite/.test(css.replace(/\.ovfx-shimmer\{[^}]*\}/, '')), 'nothing loops but a placeholder\'s shimmer');
assert.ok(!/infinite/.test(css.replace(/\.ovfx-shimmer\{[^}]*\}/, '')), 'nothing loops but a placeholder\'s shimmer');
assert.strictEqual(typeof t.window.OVFx.confetti, 'function');
assert.strictEqual(typeof t.window.OVFx.confetti, 'function');
// A feel per look (4.67.0): quiet themes move calmly (no overshoot), neon ones snap; Calm still wins.
// A feel per look (4.67.0): quiet themes move calmly (no overshoot), neon ones snap; Calm still wins.
@@ -86,6 +88,14 @@ function setup({ reduce = false, calm = false, html = '' } = {}) {
@@ -86,6 +88,14 @@ function setup({ reduce = false, calm = false, html = '' } = {}) {
assert.strictEqual(t.window.OVFx.confetti({ x: 1, y: 1 }), false, 'no confetti');
assert.strictEqual(t.window.OVFx.confetti({ x: 1, y: 1 }), false, 'no confetti');
}
}
+ // ── the demo scope: a page that shows motion may play it inside [data-fx-demo], when the person asks ──
+ for (const opts of [{ reduce: true }, { calm: true }]) {
+ const t = setup({ ...opts, html: '<div data-fx-demo><ul id="in" data-fx="reveal" data-top="1400"><li>a</li></ul></div><ul id="out" data-fx="reveal" data-top="1400"><li>b</li></ul>' });
+ assert.strictEqual(t.document.querySelectorAll('#in .ovfx-hide').length, 1, 'inside the scope the reveal waits to play');
+ assert.strictEqual(t.document.querySelectorAll('#out .ovfx-hide').length, 0, 'outside it nothing is hidden: the setting holds for the rest of the page');
+ assert.strictEqual(t.window.OVFx.confetti({ x: 1, y: 1 }), false, 'confetti with no source element stays off');
+ }
+
// ── markup added later is wired ──
// ── markup added later is wired ──
{
{
const t = setup();
const t = setup();
@@ -220,6 +220,13 @@ function setup({ reduce = false, session = memoryStorage(), start = 1000000, roo
@@ -220,6 +220,13 @@ function setup({ reduce = false, session = memoryStorage(), start = 1000000, roo
assert.strictEqual(t.running(), false, 'prefers-reduced-motion');
assert.strictEqual(t.running(), false, 'prefers-reduced-motion');
assert.strictEqual(t.smilPaused(), true, 'SMIL stopped too (the V sweep used to run under reduced motion)');
assert.strictEqual(t.smilPaused(), true, 'SMIL stopped too (the V sweep used to run under reduced motion)');
+ // The demo scope: inside [data-fx-demo] (a motion page's own container, set when the person asks) it plays.
+ const d = setup({ reduce: true });
+ d.document.body.setAttribute('data-fx-demo', '');
+ d.io.fire(d.mark, true);
+ d.mark.closest('a').dispatchEvent(new d.Event('pointerenter'));
+ assert.strictEqual(d.running(), true, 'a mark inside a demo scope plays under prefers-reduced-motion');
+
const u = setup();
const u = setup();
u.io.fire(u.mark, true);
u.io.fire(u.mark, true);
u.document.documentElement.setAttribute('data-ov-motion', 'reduced'); await u.flush();
u.document.documentElement.setAttribute('data-ov-motion', 'reduced'); await u.flush();
@@ -340,7 +347,7 @@ function setup({ reduce = false, session = memoryStorage(), start = 1000000, roo
@@ -340,7 +347,7 @@ function setup({ reduce = false, session = memoryStorage(), start = 1000000, roo
assert.match(css, /\.ov-mark:hover \.gr\{animation:ovmGlint|\.ov-mark:hover \.gr,/, 'and crosses on hover');
assert.match(css, /\.ov-mark:hover \.gr\{animation:ovmGlint|\.ov-mark:hover \.gr,/, 'and crosses on hover');
assert.match(css, /\.ov-mark\[data-glow\]::before\{opacity:\.42/, 'data-glow keeps the glow on');
assert.match(css, /\.ov-mark\[data-glow\]::before\{opacity:\.42/, 'data-glow keeps the glow on');
assert.match(css, /\.ov-mark\[data-tone\]::before,\.ov-mark\[data-state=sleep\]::before\{display:none\}/);
assert.match(css, /\.ov-mark\[data-tone\]::before,\.ov-mark\[data-state=sleep\]::before\{display:none\}/);
- assert.ok(css.includes('.ov-mark::before{transition:none!important}'), 'reduced motion: the glow appears without moving');
+ assert.ok(css.includes('.ov-mark:not([data-fx-demo] *)::before{transition:none!important}'), 'reduced motion: the glow appears without moving (outside a demo scope)');
assert.match(css, /\.ov-mark:active \.m\{transform:scale\(\.9\)/, 'a press gives a little');
assert.match(css, /\.ov-mark:active \.m\{transform:scale\(\.9\)/, 'a press gives a little');
assert.ok(!/ovmPop/.test(css), 'the old pop is gone');
assert.ok(!/ovmPop/.test(css), 'the old pop is gone');
// The glint's copy of the V follows the V into its shapes.
// The glint's copy of the V follows the V into its shapes.
@@ -380,7 +380,7 @@
@@ -380,7 +380,7 @@
for (const k in COMFORT_ATTRS) { const [attr, off] = COMFORT_ATTRS[k]; if (d[k] && d[k] !== off) el.setAttribute(attr, d[k]); else el.removeAttribute(attr); }
for (const k in COMFORT_ATTRS) { const [attr, off] = COMFORT_ATTRS[k]; if (d[k] && d[k] !== off) el.setAttribute(attr, d[k]); else el.removeAttribute(attr); }
if (!document.getElementById('ov-display-css') && document.head) {
if (!document.getElementById('ov-display-css') && document.head) {
const st = document.createElement('style'); st.id = 'ov-display-css';
const st = document.createElement('style'); st.id = 'ov-display-css';
- st.textContent = 'html[data-ov-text]{font-size:calc(100% * var(--ov-text-scale,1))}html[data-ov-motion=reduced] *,html[data-ov-motion=reduced] *::before,html[data-ov-motion=reduced] *::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}html[data-ov-motion=reduced] .ov-icon .ovi-comet{display:none}' + ALIAS_CSS + COMFORT_CSS;
+ st.textContent = 'html[data-ov-text]{font-size:calc(100% * var(--ov-text-scale,1))}html[data-ov-motion=reduced] *:not([data-fx-demo] *),html[data-ov-motion=reduced] *:not([data-fx-demo] *)::before,html[data-ov-motion=reduced] *:not([data-fx-demo] *)::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}html[data-ov-motion=reduced] .ov-icon .ovi-comet{display:none}' + ALIAS_CSS + COMFORT_CSS;
document.head.appendChild(st);
document.head.appendChild(st);
}
}
try { window.dispatchEvent(new CustomEvent('ov:display', { detail: d })); } catch (e) { /* */ }
try { window.dispatchEvent(new CustomEvent('ov:display', { detail: d })); } catch (e) { /* */ }