openvibers/openvibe.design Commits
Motion and Brand under reduced motion: the real effects play on request (#232)
Changes
@@@ -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 -->@@@ -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": [@@@ -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",@@@ -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"@@@ -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). */@@@ -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; }@@@ -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}@@@ -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; }@@@ -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();@@@ -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);@@@ -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.nnew file mode 100644@@@ -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); });
@@ -234,6 +234,6 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by
@@ -234,6 +234,6 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by
<!-- versions:start -->
<!-- versions:start -->
- openvibe-contracts: v0.384.0
- openvibe-contracts: v0.384.0
- openvibe-sdk: v0.133.0
- openvibe-sdk: v0.133.0
-- openvibe-shared: v4.179.0
+- openvibe-shared: v4.180.0
- openvibe-publishing: v1.5.0
- openvibe-publishing: v1.5.0
<!-- versions:end -->
<!-- versions:end -->
@@ -10,12 +10,12 @@
@@ -10,12 +10,12 @@
"domain": "openvibe.design",
"domain": "openvibe.design",
"contracts": "openvibe-contracts v0.384.0",
"contracts": "openvibe-contracts v0.384.0",
"sdk": "openvibe-sdk v0.133.0",
"sdk": "openvibe-sdk v0.133.0",
- "shared": "openvibe-shared v4.179.0",
+ "shared": "openvibe-shared v4.180.0",
"packages": [
"packages": [
"openvibe-contracts v0.384.0",
"openvibe-contracts v0.384.0",
"openvibe-publishing v1.5.0",
"openvibe-publishing v1.5.0",
"openvibe-sdk v0.133.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.",
"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": [
"works": [
@@ -19,7 +19,7 @@
@@ -19,7 +19,7 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.384.0",
"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-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-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",
"pg": "^8.23.1",
"subset-font": "^2.9.0",
"subset-font": "^2.9.0",
"wawoff2": "^2.0.1"
"wawoff2": "^2.0.1"
@@ -1395,9 +1395,9 @@
@@ -1395,9 +1395,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"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",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -25,7 +25,7 @@
@@ -25,7 +25,7 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.384.0",
"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-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-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",
"pg": "^8.23.1",
"subset-font": "^2.9.0",
"subset-font": "^2.9.0",
"wawoff2": "^2.0.1"
"wawoff2": "^2.0.1"
@@ -156,7 +156,7 @@
@@ -156,7 +156,7 @@
.br-play-stage{min-height:220px}
.br-play-stage{min-height:220px}
.br-sizes{gap:18px 22px}
.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}
.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). */
/* A phone: the motion files are a sideways rail (the owner, 10-31: short pages by rails and folds, never by hiding). */
@@ -1,5 +1,9 @@
@@ -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[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 > div { display: flex; flex-wrap: wrap; gap: 8px; }
.ov-demo-motion-notice button { min-height: 44px; }
.ov-demo-motion-notice button { min-height: 44px; }
+.ov-demo-motion-notice button[hidden] { display: none; }
@@ -93,5 +93,6 @@
@@ -93,5 +93,6 @@
.mo-durs li{grid-template-columns:96px minmax(0,1fr) 56px}
.mo-durs li{grid-template-columns:96px minmax(0,1fr) 56px}
.mo-durs small{display:none}
.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}
@@ -108,3 +108,6 @@
@@ -108,3 +108,6 @@
/* The theme's colours as four separate dots: two overlapping ones read as a switch. */
/* 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 { gap: 5px; }
.lk-theme .tk-dots .tk-dot + .tk-dot { margin-left: 0; }
.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; }
@@ -1,3 +1,12 @@
@@ -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 () {
(function () {
'use strict';
'use strict';
const brand = document.querySelector('.br');
const brand = document.querySelector('.br');
@@ -11,60 +20,59 @@
@@ -11,60 +20,59 @@
const media = window.matchMedia('(prefers-reduced-motion: reduce)');
const media = window.matchMedia('(prefers-reduced-motion: reduce)');
const calm = () => Boolean(loader && loader.display && loader.display.get().motion === 'reduced');
const calm = () => Boolean(loader && loader.display && loader.display.get().motion === 'reduced');
const reduced = () => calm() || media.matches;
const reduced = () => calm() || media.matches;
- let localPlay = false;
+ const playing = () => page.hasAttribute('data-fx-demo');
const notice = document.createElement('div');
const notice = document.createElement('div');
notice.className = 'ov-demo-motion-notice';
notice.className = 'ov-demo-motion-notice';
notice.hidden = true;
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);
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]');
const run = page.querySelector('[data-mo-run]');
if (run && !run.hidden) run.click();
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(() => {}));
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();
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();
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);
media.addEventListener('change', refresh);
new MutationObserver(refresh).observe(document.documentElement, { attributes: true, attributeFilter: ['data-ov-motion'] });
new MutationObserver(refresh).observe(document.documentElement, { attributes: true, attributeFilter: ['data-ov-motion'] });
refresh();
refresh();
@@ -49,8 +49,13 @@
@@ -49,8 +49,13 @@
function check(name, value) {
function check(name, value) {
root.querySelectorAll(`input[name="${name}"]`).forEach((i) => { if (i.type === 'radio') i.checked = i.value === 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() {
function showSettings() {
const d = L.display.get();
const d = L.display.get();
+ if (motionNote) motionNote.hidden = !(lessMotion && lessMotion.matches);
check('scheme', L.scheme.get());
check('scheme', L.scheme.get());
check('text', d.text);
check('text', d.text);
check('motion', d.motion);
check('motion', d.motion);
@@ -50,7 +50,10 @@ function lookPage({ themes = [], me = {} } = {}) {
@@ -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.',
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' })
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: '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…
// 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
// 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.
// it changes as the choice does.
new file mode 100644
new file mode 100644
@@ -0,0 +1,50 @@
@@ -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); });