openvibers/openvibe.design Commits

Motion and Brand under reduced motion: the real effects play on request (#232)

OP OpenVibers committed 3 days ago · ebcd524c36 · 12 files, +131 −57

* Motion and Brand under reduced motion: the real effects play on request; Your look says what Motion is

On a phone that asks to reduce motion, /motion was completely still and its "Play animations here" played a stand-in
bounce, not the effects the page describes. The line above the demos now says why nothing moves, and its button plays
the real effects on this page only, inside a demo scope (openvibe-shared 4.180.0, data-fx-demo). The Motion setting is
never touched. Your look's Motion choice reads "On" and "Calm", with a line when the device itself asks to move less.
Pins openvibe-shared v4.180.0.

* Merge remote-tracking branch 'origin/main' into design/motion-brand-phone

# Conflicts:
#	README.md
#	STATUS.json
#	package-lock.json
#	package.json

Changes

ddiff --git a/README.md b/README.md
iindex 73c1a3f..2b810aa 100644
---- a/README.md
++++ b/README.md
@@@ -234,6 +234,6 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by
<!-- versions:start -->
- openvibe-contracts: v0.384.0
- openvibe-sdk: v0.133.0
--- openvibe-shared: v4.179.0
++- openvibe-shared: v4.180.0
- openvibe-publishing: v1.5.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 84fe558..5852a9f 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -10,12 +10,12 @@
"domain": "openvibe.design",
"contracts": "openvibe-contracts v0.384.0",
"sdk": "openvibe-sdk v0.133.0",
-- "shared": "openvibe-shared v4.179.0",
++ "shared": "openvibe-shared v4.180.0",
"packages": [
"openvibe-contracts v0.384.0",
"openvibe-publishing v1.5.0",
"openvibe-sdk v0.133.0",
-- "openvibe-shared v4.179.0"
++ "openvibe-shared v4.180.0"
],
"summary": "The service skeleton for openvibe.design: Network sign-in as OAuth client `design` (PKCE S256, httpOnly design_at/design_rt cookies) and a theme API proxy to OpenVibe.Network's /api/themes. The proxy serves the public catalogue (cached 5 minutes), one theme, the person's active theme and custom variables, saving a theme with source 'studio', its history and a restore by id, and a submission to the gallery. Writes need this site's own Origin; Network 4xx answers pass through and a Network failure is 502. The studio is designed: a gallery, previews on a Live channel, a Chat room and a shop's grid (with colour-vision views), a six-colour editor that derives the full token set, WCAG checks that gate Use, sharing to the gallery, and History with Restore. The Arena queues one prompt for three models through OpenVibe.AI (client credentials, ai.run.create), stores sanitized HTML with reported model/provider/cost/time/tokens, renders it only in sandboxed CSP-capped iframes, draws blind pairs, records one vote per person per round, and keeps Elo ratings per category plus median cost and time. The prompts, outputs and votes are an open JSONL dataset; rounds and votes follow ADR-033 export and deletion.",
"works": [
ddiff --git a/package-lock.json b/package-lock.json
iindex 1775c03..e74b272 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -19,7 +19,7 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.384.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.5.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.133.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.179.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.180.0",
"pg": "^8.23.1",
"subset-font": "^2.9.0",
"wawoff2": "^2.0.1"
@@@ -1395,9 +1395,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "4.179.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.179.0",
-- "integrity": "sha512-Fjffjq3gLINhiXTqQJwsKQT/QbV4TYvsmz6GTp5hBJ8xA6KUQgu0wDgCQ3BRxCZ0ehmn0HGVAHUI+5m0LtPQCQ==",
++ "version": "4.180.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.180.0",
++ "integrity": "sha512-VZBOvI2D+ONBudakLG141Go1PaD0NkycldUxSYuQLzG3ZebewlRhGinzKI7Qj+RmrmFy2b4g7Wjb71XkDo2KCg==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/package.json b/package.json
iindex 1b643fd..8fae8b8 100644
---- a/package.json
++++ b/package.json
@@@ -25,7 +25,7 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.384.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.5.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.133.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.179.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.180.0",
"pg": "^8.23.1",
"subset-font": "^2.9.0",
"wawoff2": "^2.0.1"
ddiff --git a/public/css/brand.css b/public/css/brand.css
iindex f65980e..7a87cec 100644
---- a/public/css/brand.css
++++ b/public/css/brand.css
@@@ -156,7 +156,7 @@
.br-play-stage{min-height:220px}
.br-sizes{gap:18px 22px}
}
--@media (prefers-reduced-motion:reduce){.br-chip,.br-files a{transition:none}.br .br-files a:hover{transform:none}}
++@media (prefers-reduced-motion:reduce){.br-chip:not([data-fx-demo] *),.br-files a:not([data-fx-demo] *){transition:none}.br:not([data-fx-demo]) .br-files a:hover{transform:none}}
.br-h3-note{margin-left:6px;color:var(--text-muted);font-weight:600;font-size:12px}
/* A phone: the motion files are a sideways rail (the owner, 10-31: short pages by rails and folds, never by hiding). */
ddiff --git a/public/css/demo-motion-controls.css b/public/css/demo-motion-controls.css
iindex 68bd24b..ef2cb92 100644
---- a/public/css/demo-motion-controls.css
++++ b/public/css/demo-motion-controls.css
@@@ -1,5 +1,9 @@
--.ov-demo-motion-notice { display: grid; gap: 10px; margin: 12px auto 20px; width: min(100%, 1080px); padding: 12px 14px; border: 1px solid var(--ovk-line, var(--border)); border-radius: 14px; background: var(--ovk-panel, var(--bg-card)); color: var(--ovk-text, var(--text-primary)); }
++/* The line above a page of motion demos when motion is reduced (public/js/demo-motion-controls.js): why nothing moves,
++ and the button that plays the demos on this page only. The words and the buttons share one row where there is room. */
++.ov-demo-motion-notice { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px 16px; margin: 0 auto 20px; width: min(100%, 1080px); padding: 12px 14px; border: 1px solid var(--ovk-line, var(--border)); border-left: 4px solid var(--warning, #f59e0b); border-radius: 14px; background: var(--ovk-panel, var(--bg-card)); color: var(--ovk-text, var(--text-primary)); }
.ov-demo-motion-notice[hidden] { display: none; }
--.ov-demo-motion-notice > span { font-weight: 700; }
++.ov-demo-motion-notice.is-playing { border-left-color: var(--success, #22c55e); }
++.ov-demo-motion-notice > p { flex: 1 1 260px; margin: 0; font-weight: 650; line-height: 1.4; }
.ov-demo-motion-notice > div { display: flex; flex-wrap: wrap; gap: 8px; }
.ov-demo-motion-notice button { min-height: 44px; }
++.ov-demo-motion-notice button[hidden] { display: none; }
ddiff --git a/public/css/motion.css b/public/css/motion.css
iindex 96a3d19..f4f6e00 100644
---- a/public/css/motion.css
++++ b/public/css/motion.css
@@@ -93,5 +93,6 @@
.mo-durs li{grid-template-columns:96px minmax(0,1fr) 56px}
.mo-durs small{display:none}
}
--@media (prefers-reduced-motion:reduce){.mo-race.is-run i{transition:none}}
--html[data-ov-motion=reduced] .mo-race.is-run i{transition:none}
++/* Still under reduced motion, except while the person plays the demos on this page ([data-fx-demo], demo-motion-controls.js). */
++@media (prefers-reduced-motion:reduce){.mo-race.is-run:not([data-fx-demo] *) i{transition:none}}
++html[data-ov-motion=reduced] .mo-race.is-run:not([data-fx-demo] *) i{transition:none}
ddiff --git a/public/css/studio-look.css b/public/css/studio-look.css
iindex ed8b349..66f4a3d 100644
---- a/public/css/studio-look.css
++++ b/public/css/studio-look.css
@@@ -108,3 +108,6 @@
/* The theme's colours as four separate dots: two overlapping ones read as a switch. */
.lk-theme .tk-dots { gap: 5px; }
.lk-theme .tk-dots .tk-dot + .tk-dot { margin-left: 0; }
++/* Under the Motion choice, when the device itself asks for less motion. */
++.lk-motion-note { margin: -4px 14px 12px; color: var(--ovk-muted); font-size: .86rem; line-height: 1.4; }
++.lk-motion-note[hidden] { display: none; }
ddiff --git a/public/js/demo-motion-controls.js b/public/js/demo-motion-controls.js
iindex ed43cfe..198c928 100644
---- a/public/js/demo-motion-controls.js
++++ b/public/js/demo-motion-controls.js
@@@ -1,3 +1,12 @@
++/**
++ * The pages that exist to show motion (/brand, /motion, an icon's page) under reduced motion.
++ *
++ * When the device asks for less motion, or the person's Motion setting is Calm, nothing on these pages moves, and a
++ * page of still demos looks broken. So the page says why, in a line above the demos, and offers to play them here:
++ * that sets data-fx-demo on this page's own container (openvibe-shared 4.180.0: inside it ov-fx and the mark play in
++ * full), and nothing else. The saved setting is untouched, the rest of the page chrome stays calm, and Stop takes it
++ * back. What plays is the real effect, the one every site uses; this file never stands in an animation of its own.
++ */
(function () {
'use strict';
const brand = document.querySelector('.br');
@@@ -11,60 +20,59 @@
const media = window.matchMedia('(prefers-reduced-motion: reduce)');
const calm = () => Boolean(loader && loader.display && loader.display.get().motion === 'reduced');
const reduced = () => calm() || media.matches;
-- let localPlay = false;
++ const playing = () => page.hasAttribute('data-fx-demo');
const notice = document.createElement('div');
notice.className = 'ov-demo-motion-notice';
notice.hidden = true;
-- notice.innerHTML = '<span></span><div><button type="button" class="ovk-btn ovk-btn--quiet" data-ov-demo-play>Play animations here</button><button type="button" class="ovk-btn ovk-btn--quiet" data-ov-demo-auto>Turn animations on</button></div>';
++ notice.setAttribute('role', 'status');
++ const words = document.createElement('p');
++ const acts = document.createElement('div');
++ const button = (label, kind) => { const b = document.createElement('button'); b.type = 'button'; b.className = `ovk-btn ovk-btn--${kind}`; b.textContent = label; acts.appendChild(b); return b; };
++ const playBtn = button('Play them on this page', 'primary');
++ const stopBtn = button('Stop', 'quiet');
++ const calmBtn = button('Turn Calm off everywhere', 'quiet');
++ notice.append(words, acts);
anchor.before(notice);
-- const animate = (target) => {
-- if (!target || !target.animate) return;
-- target.animate([{ transform: 'translateY(10px) scale(.92)', opacity: .55 }, { transform: 'translateY(0) scale(1)', opacity: 1 }], {
-- duration: 520, easing: 'cubic-bezier(.34,1.56,.64,1)',
-- });
-- };
-- const refresh = () => {
-- const off = reduced() && !localPlay;
-- notice.hidden = !off;
-- notice.querySelector('span').textContent = calm()
-- ? 'Animations are off: your Motion setting is Calm.'
-- : 'Animations are off: your device asks for less motion.';
-- document.querySelectorAll('.br-motion-video').forEach((video) => { if (off) video.pause(); });
-- };
-- const play = () => {
-- const target = page.querySelector('.ov-mark, .mv-tile svg');
-- if (window.OVMark && window.OVMark.intro) page.querySelectorAll('.ov-mark, [data-br-hero], [data-mo-hero]').forEach((mark) => window.OVMark.intro(mark));
-- document.querySelectorAll('[data-br-replay], [data-mo-replay]').forEach((button) => { if (!button.hidden) button.click(); });
++ function refresh() {
++ const on = playing();
++ notice.hidden = !(reduced() || on);
++ notice.classList.toggle('is-playing', on);
++ words.textContent = on
++ ? 'Playing on this page only. Your setting has not changed.'
++ : calm()
++ ? 'Calm is on, so nothing on this page moves. That is the setting working.'
++ : 'This device asks for less motion, so nothing on this page moves. That is the setting working.';
++ playBtn.hidden = on;
++ stopBtn.hidden = !on;
++ calmBtn.hidden = on || !calm();
++ if (!on && reduced()) document.querySelectorAll('.br-motion-video').forEach((video) => video.pause());
++ }
++
++ // The demos were wired while motion was reduced, so a reveal or a count-up skipped itself. Making each stage's
++ // markup again gives the kit fresh elements to wire, now inside the demo scope.
++ function rewire() {
++ page.querySelectorAll('.mo-fx-stage').forEach((stage) => { const html = stage.innerHTML; stage.innerHTML = html; });
++ if (window.OVFx && window.OVFx.scan) window.OVFx.scan(page);
++ }
++ function play() {
++ page.setAttribute('data-fx-demo', '');
++ rewire();
++ if (window.OVMark && window.OVMark.intro) page.querySelectorAll('[data-br-hero], [data-mo-hero]').forEach((mark) => window.OVMark.intro(mark));
const run = page.querySelector('[data-mo-run]');
if (run && !run.hidden) run.click();
-- const tile = page.querySelector('[data-mv]');
-- if (tile) tile.click();
document.querySelectorAll('.br-motion-video').forEach((video) => video.play().catch(() => {}));
-- animate(target);
-- page.querySelectorAll('.br-chip, .mo-btn, .mo-card').forEach((control, i) => {
-- if (i < 6) setTimeout(() => animate(control.querySelector('.ov-mark, svg') || control), i * 90);
-- });
-- };
-- notice.querySelector('[data-ov-demo-play]').addEventListener('click', () => {
-- localPlay = true;
-- // This page only: the saved Motion setting is untouched. Removing the attribute lets the shared tokens and
-- // the page's own CSS run again; the notice stays away until the next visit.
-- document.documentElement.removeAttribute('data-ov-motion');
-- document.documentElement.classList.add('ov-demo-play');
refresh();
-- play();
-- });
-- notice.querySelector('[data-ov-demo-auto]').addEventListener('click', () => {
-- if (loader && loader.display) loader.display.set({ motion: 'auto' });
++ }
++ function stop() {
++ page.removeAttribute('data-fx-demo');
++ rewire();
refresh();
-- });
-- page.addEventListener('click', (event) => {
-- if (!localPlay) return;
-- const control = event.target.closest('.br-chip, .br-replay, .mo-replay, .mo-btn, .mo-card, .mv-tile');
-- if (control) animate(control.querySelector('.ov-mark, svg') || control);
-- });
++ }
++ playBtn.addEventListener('click', play);
++ stopBtn.addEventListener('click', stop);
++ calmBtn.addEventListener('click', () => { if (loader && loader.display) loader.display.set({ motion: 'auto' }); refresh(); });
media.addEventListener('change', refresh);
new MutationObserver(refresh).observe(document.documentElement, { attributes: true, attributeFilter: ['data-ov-motion'] });
refresh();
ddiff --git a/public/js/look.js b/public/js/look.js
iindex b18dc48..f8a2e2a 100644
---- a/public/js/look.js
++++ b/public/js/look.js
@@@ -49,8 +49,13 @@
function check(name, value) {
root.querySelectorAll(`input[name="${name}"]`).forEach((i) => { if (i.type === 'radio') i.checked = i.value === value; });
}
++ // The device's own setting wins over "On": say so, where the choice is made.
++ const motionNote = $('#lk-motion-note');
++ let lessMotion = null;
++ try { lessMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); lessMotion.addEventListener('change', () => showSettings()); } catch (e) { lessMotion = null; }
function showSettings() {
const d = L.display.get();
++ if (motionNote) motionNote.hidden = !(lessMotion && lessMotion.matches);
check('scheme', L.scheme.get());
check('text', d.text);
check('motion', d.motion);
ddiff --git a/server/views/look.js b/server/views/look.js
iindex 2812cf5..49beeb8 100644
---- a/server/views/look.js
++++ b/server/views/look.js
@@@ -50,7 +50,10 @@ function lookPage({ themes = [], me = {} } = {}) {
note: signedIn ? 'Each change applies at once and is saved to your account, so every OpenVibe site follows.' : 'Each change applies at once on this device. Sign in and it follows you to every OpenVibe site.',
body: kit.seg({ name: 'scheme', label: 'Light or dark', options: [['light', 'Light'], ['dark', 'Dark'], ['auto', 'Automatic']], value: 'auto' })
+ kit.seg({ name: 'text', label: 'Text size', options: [['100', 'Standard'], ['112', 'Larger'], ['125', 'Largest']], value: '100' })
-- + kit.seg({ name: 'motion', label: 'Motion', options: [['auto', 'Full'], ['reduced', 'Reduced']], value: 'auto' })
++ // "On" is what the device allows (theme-loader's 'auto'), so the line under it says when the device itself
++ // asks for less motion: "Full" claimed motion that a phone set to reduce it never showed.
++ + kit.seg({ name: 'motion', label: 'Motion', options: [['auto', 'On'], ['reduced', 'Calm']], value: 'auto' })
++ + '<p class="lk-motion-note" id="lk-motion-note" hidden>This device asks for less motion, so OpenVibe keeps still on it whichever you choose.</p>'
// Icons (openvibe-shared 4.71.0, plan L56; the owner, 2026-11-01: "a setting to prefer a certain icon type…
// configurable for users in most cases"): the sample under it is ordinary page markup, drawn by ov-glyphs.js, so
// it changes as the choice does.
ddiff --git a/test/demo-motion.test.js b/test/demo-motion.test.js
nnew file mode 100644
iindex 0000000..8d1fb09
---- /dev/null
++++ b/test/demo-motion.test.js
@@@ -0,0 +1,50 @@
++'use strict';
++/**
++ * The pages that show motion, when motion is reduced (public/js/demo-motion-controls.js).
++ *
++ * A phone that asks for less motion showed /motion completely still, and "Play animations here" played a stand-in
++ * bounce made by this site, not the effects the page describes. The rule now: the page says why nothing moves, and
++ * its button plays the REAL effects inside a demo scope (openvibe-shared 4.180.0, data-fx-demo) on this page only.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const { boot } = require('./helpers/boot');
++
++const read = (f) => fs.readFileSync(path.join(__dirname, '..', f), 'utf8');
++
++(async () => {
++ const t = await boot();
++ try {
++ const js = read('public/js/demo-motion-controls.js');
++ assert.ok(js.includes("setAttribute('data-fx-demo', '')"), 'Play sets the demo scope on the page');
++ assert.ok(js.includes("removeAttribute('data-fx-demo')"), 'Stop takes it back');
++ assert.ok(!/\.animate\(/.test(js), 'no stand-in animation: what plays is the shared effect itself');
++ assert.ok(!js.includes("removeAttribute('data-ov-motion')"), 'the person\'s Motion setting is never touched to play a demo');
++ assert.ok(js.includes('This device asks for less motion') && js.includes('Calm is on'), 'both reasons are said in words');
++
++ // The pages' own reduced-motion rules leave the scope alone.
++ assert.ok(read('public/css/motion.css').includes('.mo-race.is-run:not([data-fx-demo] *) i{transition:none}'));
++ assert.ok(read('public/css/brand.css').includes('.br-chip:not([data-fx-demo] *)'));
++
++ // The shared code that honours the scope is the pinned one.
++ const fx = fs.readFileSync(path.join(path.dirname(require.resolve('openvibe-shared/package.json')), 'ov-fx.js'), 'utf8');
++ assert.ok(fx.includes('[data-fx-demo]'), 'openvibe-shared is 4.180.0 or newer (the demo scope)');
++
++ const pages = ['/motion', '/brand'];
++ const answers = await Promise.all(pages.map((p) => t.get(p)));
++ answers.forEach((r, i) => {
++ assert.strictEqual(r.status, 200, pages[i]);
++ assert.match(r.text, /\/js\/demo-motion-controls\.js\?v=[0-9a-f]{10}/, `${pages[i]} loads the controls`);
++ });
++
++ // Your look: the Motion choice says what it is, and has the line for a device that asks for less motion.
++ const look = await t.get('/look');
++ assert.match(look.text, /name="motion" value="auto"[^>]*>\s*<span>On<\/span>/);
++ assert.match(look.text, /name="motion" value="reduced"[^>]*>\s*<span>Calm<\/span>/);
++ assert.match(look.text, /<p class="lk-motion-note" id="lk-motion-note" hidden>This device asks for less motion/);
++ console.log('demo-motion: the real effects play in a demo scope; the reasons are said; Your look is honest about the device');
++ } finally {
++ await t.close();
++ }
++})().catch((e) => { console.error(e); process.exit(1); });