openvibers/openvibe.shared Commits

4.180.0: a demo scope for motion (data-fx-demo) (#400)

OP OpenVibers committed 3 days ago · b0ffea391c · 11 files, +64 −25

* 4.180.0: a demo scope for motion (data-fx-demo)

Inside a container with data-fx-demo the effects and the mark play even when motion is reduced, by the device or by
Calm. 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. Outside the container the
setting holds.

* The demo scope keeps its lengths under the device's own setting too

kit.css zeroes --ov-dur-* under prefers-reduced-motion; the scope sets its own, in the look's feel.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex ec1d576..dc51e77 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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
ddiff --git a/README.md b/README.md
iindex 952289f..5c0e689 100644
---- a/README.md
++++ b/README.md
@@@ -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 deployed
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
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex 1d11bd8..a9c5c24 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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)",
ddiff --git a/navbar.js b/navbar.js
iindex 200a74f..13e59dc 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -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">.
ddiff --git a/ov-fx.js b/ov-fx.js
iindex 36e909f..6a79b92 100644
---- a/ov-fx.js
++++ b/ov-fx.js
@@@ -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 see
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`); });
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); }
}
ddiff --git a/ov-mark.js b/ov-mark.js
iindex 917b87e..77787cd 100644
---- a/ov-mark.js
++++ b/ov-mark.js
@@@ -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 ──
ddiff --git a/package-lock.json b/package-lock.json
iindex be54279..1ad8fb1 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 536b8c0..9e4ef86 100644
---- a/package.json
++++ b/package.json
@@@ -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": {
ddiff --git a/test/fx.test.js b/test/fx.test.js
iindex 0365639..54d56dc 100644
---- a/test/fx.test.js
++++ b/test/fx.test.js
@@@ -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();
ddiff --git a/test/ov-mark.test.js b/test/ov-mark.test.js
iindex ec94def..385d989 100644
---- a/test/ov-mark.test.js
++++ b/test/ov-mark.test.js
@@@ -220,6 +220,13 @@ function setup({ reduce = false, session = memoryStorage(), start = 1000000, roo
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)');
++ // 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, roo
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-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.
ddiff --git a/theme-loader.js b/theme-loader.js
iindex 3b31040..87c6f84 100644
---- a/theme-loader.js
++++ b/theme-loader.js
@@@ -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) { /* */ }