openvibers/openvibe.live Commits

Motion: Calm reduces, only Off removes (Shared 4.183.0) (#755)

OP OpenVibers committed 3 days ago · 17f9700564 · 56 files, +85 −257

* Motion: Calm reduces, only Off removes

The owner, 2026-11-10: "Reduce motion is too much reduction, it ruins a lot of stuff, so it should still have full
motion but actually just reduce it instead of getting rid of it."

With openvibe-shared 4.183.0, Calm (the Motion setting, or a device that asks) plays every effect smaller and slower
and only Off stops them. This removes every rule here that stopped an effect under reduced motion; what such a rule
did to leave a thing finished belongs to html[data-ov-motion=off]; script that skipped an effect when the device
asked skips it only with motion off.

* Pin openvibe-shared 4.183.0 (Calm reduces, only Off removes)

* The scenes check: its last line

* The glow asks the page safely; two checks follow the new rule

player-shape.js read documentElement without checking it is there. The home count-up and the player glow checks
relied on a device asking to skip an effect; they set motion off where they need stillness, and the glow check
holds that Calm keeps it.

Changes

ddiff --git a/README.md b/README.md
iindex 8b326ee5..92672dfa 100644
---- a/README.md
++++ b/README.md
@@@ -184,5 +184,5 @@ Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL
<!-- versions:start -->
- openvibe-contracts: v0.384.0
- openvibe-sdk: v0.125.0
--- openvibe-shared: v4.179.0
++- openvibe-shared: v4.183.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 905162aa..024ee940 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -7,7 +7,7 @@
"domain": "openvibe.live (www, ingest, whip)",
"contracts": "openvibe-contracts v0.384.0",
"sdk": "openvibe-sdk v0.125.0",
-- "shared": "openvibe-shared v4.179.0",
++ "shared": "openvibe-shared v4.183.0",
"capabilities": [
"live.chat_context.read",
"live.chat_effects.write",
ddiff --git a/package-lock.json b/package-lock.json
iindex b39c69e3..431b1703 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -26,7 +26,7 @@
"onnxruntime-node": "^1.20.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.384.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.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.183.0",
"pg": "^8.23.1",
"sanitize-html": "^2.17.7",
"sdp-transform": "^3.0.0",
@@@ -3342,9 +3342,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.183.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.183.0",
++ "integrity": "sha512-vp9gg++Dhyj3h0cwvBjujM6dSFYoeXdh1UXdZ6OXP/NjG672Fs7uRANV4MjT467JtFDN7r7qLI3OJAwGQxOuQw==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/package.json b/package.json
iindex c62ae702..f396c048 100644
---- a/package.json
++++ b/package.json
@@@ -45,7 +45,7 @@
"onnxruntime-node": "^1.20.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.384.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.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.183.0",
"pg": "^8.23.1",
"sanitize-html": "^2.17.7",
"sdp-transform": "^3.0.0",
ddiff --git a/public/banned.html b/public/banned.html
iindex e84f0871..a54940d0 100644
---- a/public/banned.html
++++ b/public/banned.html
@@@ -96,10 +96,7 @@
.fine { font-size: 0.82rem; color: #8a8480; margin-top: 18px; }
.logo { color: #a78bfa; font-weight: 800; text-decoration: none; }
-- @media (prefers-reduced-motion: reduce) {
-- .bg, .bg::after, .stamp, .card.shake, .rv, .msg, .msg.hot::before, .hurt.beat, .go { animation: none !important; }
-- .rv, .msg { opacity: 1; transform: none; }
-- }
++ html[data-ov-motion=off] .rv,html[data-ov-motion=off] .msg{opacity: 1;transform: none}
</style>
</head>
<body>
@@@ -152,7 +149,7 @@
ok = !!(res.ok && j.ok); name = j.name || null;
} catch (e) { ok = false; }
if (!ok) { btn.disabled = false; btn.innerHTML = 'Try again <span class="arrow">→</span>'; return; }
-- var reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
++ var reduced = (document.documentElement.getAttribute('data-ov-motion') === 'off');
if (name) document.getElementById('welcome-line').textContent = name + ', the ban is lifted. Taking you to the site…';
if (!reduced) {
var cx = window.innerWidth / 2, cy = window.innerHeight / 2;
ddiff --git a/public/css/broadcast.css b/public/css/broadcast.css
iindex bbe276b2..0d0d5be7 100644
---- a/public/css/broadcast.css
++++ b/public/css/broadcast.css
@@@ -284,7 +284,6 @@
align-items: center;
gap: 6px;
}
--@media (prefers-reduced-motion: reduce) { .bc-live-badge { animation: none; } }
.bc-live-badge i {
font-size: 0.6rem;
ddiff --git a/public/css/chat-shared.css b/public/css/chat-shared.css
iindex 478236ee..214d887b 100644
---- a/public/css/chat-shared.css
++++ b/public/css/chat-shared.css
@@@ -448,16 +448,12 @@
/* Cosmetic name/hat effects only animate while their row is on screen (IntersectionObserver). */
.chat-msg.has-fx:not(.fx-on) .chat-user, .chat-msg.has-fx:not(.fx-on) .chat-user *,
.chat-msg.has-fx:not(.fx-on) .chat-hat { animation-play-state: paused !important; }
--@media (prefers-reduced-motion: reduce) {
-- .chat-msg .chat-user, .chat-msg .chat-hat, .chat-particle { animation: none !important; }
--}
.chat-loading-history { padding: 14px; color: var(--text-muted); text-align: center; font-size: 0.85rem; }
/* New rows arrive with a short rise; history (rendered off-screen and swapped in) does not animate. */
@keyframes chatRowIn { from { opacity: 0; transform: translateY(6px); } }
.chat-msg.chat-msg-new { animation: chatRowIn var(--ov-dur-2, 180ms) var(--ov-ease-out, cubic-bezier(.2, .8, .3, 1)); }
--@media (prefers-reduced-motion: reduce) { .chat-msg.chat-msg-new { animation: none; } }
/* Friendly global chat: folded rows. */
.chat-friendly-group { margin: 2px 0; }
ddiff --git a/public/css/context-cards.css b/public/css/context-cards.css
iindex d854232d..8f88e9d3 100644
---- a/public/css/context-cards.css
++++ b/public/css/context-cards.css
@@@ -180,7 +180,4 @@
.dsc-toggle-text b { font-size: 0.83rem; }
.dsc-toggle-text small { font-size: 0.68rem; }
}
--@media (prefers-reduced-motion: reduce) {
-- .dsc-toggle-count, .dsc-toggle-chev, .dsc-toggle { animation: none !important; transition: none !important; }
--}
.rs-lite .dsc-toggle-count { animation: none !important; }
ddiff --git a/public/css/cosmetics.css b/public/css/cosmetics.css
iindex d3c45789..b638bd12 100644
---- a/public/css/cosmetics.css
++++ b/public/css/cosmetics.css
@@@ -243,10 +243,7 @@
}
.chat-msg.has-fx:not(.fx-on) .name-fx-vegas::before,
.chat-msg.has-fx:not(.fx-on) .name-fx-vegas::after { animation-play-state: paused !important; }
--@media (prefers-reduced-motion: reduce) {
-- .name-fx-vegas, .name-fx-vegas::before, .name-fx-vegas::after { animation: none !important; }
-- .name-fx-vegas { filter: drop-shadow(0 0 3px rgba(255, 207, 63, .9)); }
--}
++html[data-ov-motion=off] .name-fx-vegas{filter: drop-shadow(0 0 3px rgba(255, 207, 63, .9))}
/* 2fat Bass Drop (2026-10-23, one of a kind: 2fat, who brings the old classic bangers back): the name thumps on the beat,
a big drop then a smaller one, in club lights, with an equalizer dancing before it and a speaker after it. Only its
one holder wears it (fx_twofat is never in the catalog). The equalizer and the speaker are pseudo-elements, so they
@@@ -308,10 +305,7 @@
}
.chat-msg.has-fx:not(.fx-on) .name-fx-twofat::before,
.chat-msg.has-fx:not(.fx-on) .name-fx-twofat::after { animation-play-state: paused !important; }
--@media (prefers-reduced-motion: reduce) {
-- .name-fx-twofat, .name-fx-twofat::before, .name-fx-twofat::after { animation: none !important; }
-- .name-fx-twofat { filter: drop-shadow(0 0 3px rgba(255, 47, 214, .9)); }
--}
++html[data-ov-motion=off] .name-fx-twofat{filter: drop-shadow(0 0 3px rgba(255, 47, 214, .9))}
/* ══════════════════════════════════════════════════════════════
ddiff --git a/public/css/features/agent-stream.css b/public/css/features/agent-stream.css
iindex 18c18414..732e6cb0 100644
---- a/public/css/features/agent-stream.css
++++ b/public/css/features/agent-stream.css
@@@ -68,7 +68,6 @@
.agent-overlay-dot.is-failed { background: #ef4444; }
.agent-overlay-dot.is-skipped { background: #94a3b8; }
@keyframes agent-overlay-pulse { 50% { opacity: 0.45; } }
--@media (prefers-reduced-motion: reduce) { .agent-overlay-dot.is-started { animation: none; } }
.agent-overlay-ticks { display: flex; gap: 3px; align-items: center; margin-top: 3px; min-height: 10px; }
.agent-overlay-ticks[hidden] { display: none; }
.agent-overlay-tick { width: 12px; height: 8px; padding: 0; border: 0; border-radius: 2px; background: #4b5563; cursor: pointer; opacity: 0.85; }
ddiff --git a/public/css/features/broadcast.css b/public/css/features/broadcast.css
iindex 264676d8..8927da80 100644
---- a/public/css/features/broadcast.css
++++ b/public/css/features/broadcast.css
@@@ -535,7 +535,6 @@
#bc-stats-overlay .bc-stat > i { width: 16px; text-align: center; font-size: 0.72rem; color: rgba(255, 255, 255, 0.55); }
#bc-stats-overlay .bc-stat-label { color: rgba(255, 255, 255, 0.66); min-width: 0; }
#bc-stats-overlay .bc-stat-v { font-weight: 700; text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
--@media (prefers-reduced-motion: reduce) { #bc-stats-overlay.bc-stats-overlay { animation: none; } }
.bc-status-good { background: var(--success); color: #fff; }
.bc-status-warn { background: var(--warning); color: #000; }
@@@ -2505,7 +2504,6 @@
.bc-ws-quality[open] .bc-sum-chev { transform: rotate(180deg); color: var(--accent); }
.bc-ws-quality[open] > summary { border-bottom: 1px solid var(--border); }
.bc-ws-quality .bc-ws-quality-inner { margin: 0; padding: 12px; }
--@media (prefers-reduced-motion: reduce) { .bc-switch-thumb, .bc-sum-chev { transition: none; } }
/* Wide panels: details on the left, how you stream on the right, so fields are not a thousand pixels wide. */
.bc-ws-main { container-type: inline-size; container-name: bcws; }
@@@ -2522,7 +2520,6 @@
.bc-live-badge { animation: none; }
.bc-live-badge i { animation: pulse-live 1.5s infinite; }
.bc-rs-control .bc-restream-detail { flex-basis: 100%; min-width: 0; white-space: normal; overflow-wrap: anywhere; }
--@media (prefers-reduced-motion: reduce) { .bc-live-badge i { animation: none; } }
/* On a phone's full page the live controls are a grid of tiles (icon over a short label), not a column of thirteen
full-width bars; ending the stream keeps its own full-width row. */
@media (max-width: 768px) {
@@@ -2688,7 +2685,6 @@ body.bc-studio-on .toast-container { z-index: 1500; }
/* Chat's own reconnecting line sits with the chat; the site-wide pill would say it twice (and read as the stream). */
body.bc-studio-on .ov-conn-pill { display: none !important; }
body.bc-studio-on #bc-chat-messages .chat-conn-status { display: block !important; width: fit-content; background: rgba(180, 83, 9, 0.85); font-style: normal; }
--@media (prefers-reduced-motion: reduce) { .bcs, .bcs-state-spin { animation: none; } }
.bcs-end { height: 34px; padding: 0 16px; border: 0; border-radius: 999px; background: #e11d48; color: #fff; font-size: 0.9rem; font-weight: 800; }
.bcs-zoom {
position: absolute; top: 38%; left: 50%; transform: translate(-50%, -50%); padding: 8px 16px; border-radius: 999px;
@@@ -2800,7 +2796,6 @@ body.bc-studio-on #bc-chat-messages .chat-conn-status { display: block !importan
.bcs-switch-k { position: absolute; top: 3px; left: 3px; width: 24px; height: 24px; border-radius: 50%; background: #fff; transition: transform var(--ov-dur-2, 0.15s); }
.bcs-switch[aria-checked="true"] { background: #16a34a; border-color: transparent; }
.bcs-switch[aria-checked="true"] .bcs-switch-k { transform: translateX(20px); }
--@media (prefers-reduced-motion: reduce) { .bcs-switch-k { transition: none; } }
.bcs-confirm {
width: 100%; max-width: 360px; padding: 20px; border-radius: 18px; text-align: center;
background: var(--bg-secondary, #15151c); color: var(--text-primary, #f4f4f5); box-shadow: 0 20px 60px rgba(0,0,0,0.5);
@@@ -2827,10 +2822,6 @@ body.bc-studio-on #bc-chat-messages .chat-conn-status { display: block !importan
.bc-live-pill-dot { width: 8px; height: 8px; border-radius: 50%; background: #fff; animation: bcLivePillPulse 1.6s var(--ov-ease-in-out, ease-in-out) infinite; }
@keyframes bcLivePillPulse { 50% { opacity: 0.35; } }
.bc-live-pill:focus-visible { outline: 3px solid #93c5fd; outline-offset: 2px; }
--@media (prefers-reduced-motion: reduce) { .bc-live-pill-dot { animation: none; } }
--@media (prefers-reduced-motion: reduce) {
-- .bcs-live-dot, .bcs-sheet, .bcs-sheet-wrap { animation: none; }
--}
/* Setting up on a phone keeps the app's bottom bar, with Go Live lit, like every other page (the owner, 2026-11-01:
"the buttons at the bottom don't show like our mobile buttons"). Only the live studio (body.bc-studio-on, above)
@@@ -2911,10 +2902,7 @@ body.bc-golive-dock-on #page-broadcast { padding-bottom: calc(104px + max(var(--
}
.bc-clip-name.is-held .bc-clip-name-timer { display: none; }
@keyframes bc-clip-name-time { to { transform: scaleX(0); } }
--@media (prefers-reduced-motion: reduce) {
-- .bc-clip-name { animation: none; }
-- .bc-clip-name-timer { animation: none; display: none; }
--}
++html[data-ov-motion=off] .bc-clip-name-timer{display: none}
/* ── The camera chooser before going live (broadcast-devices.js _bcRenderCameraChooser) ──
Phones: Front | Back, and a chip per camera when a side has several. Computers: a card per camera. */
@@@ -2970,7 +2958,6 @@ body.bc-golive-dock-on #page-broadcast { padding-bottom: calc(104px + max(var(--
.bc-cam-side:focus-visible, .bc-cam-lens:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.bc-cam-sides:has(> :nth-child(3)) .bc-cam-side { padding: 0 11px; }
.bc-cam-sides:has(> :nth-child(3)) .bc-cam-side i { display: none; } /* Front · Back · USB: words only */
--@media (prefers-reduced-motion: reduce) { .bc-cam-side, .bc-cam-lens, .bc-setup-row { transition: none; } }
.bc-setup-note { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 4px 12px; margin: 8px 2px 0; font-size: 0.74rem; color: var(--text-muted); }
.bc-setup-link { font: inherit; font-weight: 600; color: var(--accent); background: none; border: 0; padding: 6px 0; cursor: pointer; }
.bc-setup-link:hover { text-decoration: underline; }
@@@ -3011,7 +2998,6 @@ body.bc-golive-dock-on #page-broadcast { padding-bottom: calc(104px + max(var(--
.gl-device-sheet-head button { width: 44px; height: 44px; border: 0; border-radius: 50%; background: var(--bg-primary); color: var(--text-primary); }
}
@media (max-width: 760px) { .gl-preview { min-height: min(46vh, 390px); max-height: min(58vh, 520px); } .gl-pv-chips { bottom: 62px; } }
--@media (prefers-reduced-motion: reduce) { .gl-allow-mark, .bcs-golive[data-mode="live"], .bcs-golive[data-mode="connecting"] { animation: none; } .bcs-golive-btn .fa-spin { animation-iteration-count: 1; } }
.bc-ws-golive-section .bc-perm-hint { flex: 1 1 200px; display: flex; align-items: flex-start; gap: 8px; margin: 0; font-size: 0.84rem; }
.bc-ws-golive-section .bc-perm-hint i { color: var(--accent); margin-top: 3px; }
.bc-ws-golive-section #bc-perm-btn { flex: 1 0 auto; justify-content: center; min-height: 44px; border-radius: 12px; font-weight: 650; }
@@@ -3136,7 +3122,6 @@ body.bc-golive-dock-on #page-broadcast { padding-bottom: calc(104px + max(var(--
.bcs-golive:not([hidden]) { animation: bcs-dock-in var(--ov-dur-2, 0.2s) var(--ov-ease-out, ease-out); }
.bcs-golive-t { margin-left: 8px; }
@keyframes bcs-dock-in { from { opacity: 0; transform: translateY(8px); } }
--@media (prefers-reduced-motion: reduce) { .bcs-golive:not([hidden]), .bc-live-card-dot { animation: none; } }
/* RobotStreamer, at every width: its state and Stop on one line, the details under them in full (on a phone the Stop
button stood between them as a tall tile and the details ran off the right; on a computer the state sat at the far
@@@ -3203,7 +3188,7 @@ body.bc-golive-dock-on #page-broadcast { padding-bottom: calc(104px + max(var(--
.bcs-gl-burst { position: absolute; left: 22px; top: 21px; width: 0; height: 0; pointer-events: none; }
.bcs-gl-burst i { position: absolute; width: 5px; height: 5px; margin: -2.5px; border-radius: 50%; background: hsl(var(--a), 90%, 66%); opacity: 0; animation: bcs-gl-spark .9s var(--ov-ease-out, cubic-bezier(.2,.8,.3,1)) var(--d) forwards; }
@keyframes bcs-gl-spark { 0% { opacity: 1; transform: rotate(var(--a)) translateX(0); } 100% { opacity: 0; transform: rotate(var(--a)) translateX(48px) scale(.4); } }
--@media (prefers-reduced-motion: reduce) { .bcs-livecard, .bcs-gl-dot::after, .bcs-gl-burst i { animation: none !important; } .bcs-gl-burst { display: none; } }
++html[data-ov-motion=off] .bcs-gl-burst{display: none}
/* A phone: the link on its own line, whole, and Copy the width of the card (an easy tap while holding the phone). */
@media (max-width: 480px) {
.bcs-livecard .bcs-gl-link { flex-wrap: wrap; padding: 10px; gap: 10px; }
@@@ -3314,7 +3299,6 @@ body.bc-golive-dock-on #page-broadcast { padding-bottom: calc(104px + max(var(--
.gl-loading span { width: 8px; height: 8px; border-radius: 50%; background: var(--text-muted); animation: gl-breathe 1.2s var(--ov-ease-in-out, ease-in-out) infinite; }
.gl-loading span:nth-child(2) { animation-delay: 0.2s; }
.gl-loading span:nth-child(3) { animation-delay: 0.4s; }
--@media (prefers-reduced-motion: reduce) { .gl-wait-dot, .gl-loading span { animation: none; } .gl-slot, .gl-chip, .gl-title { transition: none; } }
/* Video quality is a fold too: the same row as the others (its older rule had its own padding). */
.gl-fold.bc-ws-quality > summary { padding: 8px 14px; min-height: 52px; font-size: 0.9rem; }
.gl-fold.bc-ws-quality > summary > i:first-child { width: 18px; }
@@@ -3375,7 +3359,6 @@ body.bc-golive-dock-on #page-broadcast { padding-bottom: calc(104px + max(var(--
.gl-pv-off:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.gl-pv-off[aria-pressed="true"] i::before { content: "\f06e"; }
@media (pointer: coarse) { .gl-pv-off { width: 44px; height: 44px; } }
--@media (prefers-reduced-motion: reduce) { .gl-preview video, .gl-pv-lvl i { transition: none; } }
/* Test privately: the quiet button under Go live; while recording, a red dot and the seconds left; then the test plays
back over the preview with Done. */
@@@ -3398,7 +3381,6 @@ body.bc-golive-dock-on #page-broadcast { padding-bottom: calc(104px + max(var(--
background: var(--accent-strong, var(--accent)); color: var(--on-accent-strong, var(--on-accent, #fff)); }
.gl-test-done:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
@media (pointer: coarse) { .gl-test-done { min-height: 44px; } }
--@media (prefers-reduced-motion: reduce) { .gl-test-btn.is-rec i { animation: none; } }
/* Labels (L1) in the go-live card's fold: six toggles; the AI's suggestions dashed, to confirm. */
.gl-labels-why { margin: 0 0 10px; font-size: .82rem; line-height: 1.45; color: var(--text-secondary); }
@@@ -3415,7 +3397,6 @@ body.bc-golive-dock-on #page-broadcast { padding-bottom: calc(104px + max(var(--
.gl-label--adult.is-on { color: var(--warning-text, var(--warning)); border-color: color-mix(in srgb, var(--warning-text, var(--warning)) 55%, var(--border)); background: color-mix(in srgb, var(--warning-text, var(--warning)) 10%, transparent); }
.gl-label--ai { border-style: dashed; color: var(--accent); }
@media (pointer: coarse) { .gl-label { min-height: 44px; } }
--@media (prefers-reduced-motion: reduce) { .gl-label { transition: none; } }
/* One "Turn on camera" (11-09 review): on a phone the Go live dock at the thumb carries it, so the card keeps the words
and the other ways, not a second identical button. */
ddiff --git a/public/css/features/channel.css b/public/css/features/channel.css
iindex d9a95fdf..7567fda0 100644
---- a/public/css/features/channel.css
++++ b/public/css/features/channel.css
@@@ -1453,7 +1453,6 @@ button.viewer-badge:focus-visible { outline: 2px solid #93c5fd; outline-offset:
.ch-chat-reopen[hidden] { display: none; }
/* Below 1181px the chat is an overlay or a bottom sheet with its own open/close button, so no hide/show here. */
@media (max-width: 1180px) { .ch-chat-collapse, .ch-chat-reopen { display: none !important; } }
--@media (prefers-reduced-motion: reduce) { #page-channel.chat-revealing .stream-layout > .chat-sidebar { animation: none; } }
/* ── Resizing the chat (public/js/channel-chat-resize.js) ─────
One grip per chat: on its left edge beside the stream (width), on its top edge as a bottom sheet (height). */
@@@ -1515,7 +1514,6 @@ button.viewer-badge:focus-visible { outline: 2px solid #93c5fd; outline-offset:
.ch-about-card-title { font-size: 0.88rem; font-weight: 650; line-height: 1.3; color: var(--text-primary);
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.ch-about-card-meta { font-size: 0.75rem; color: var(--text-muted); }
--@media (prefers-reduced-motion: reduce) { .ch-about-card-thumb, .ch-about-more i { transition: none; } }
/* The channel page ends where its content ends: a full-viewport minimum left a screen of empty space above the footer
whenever the open tab was short. */
@@@ -1610,9 +1608,6 @@ button.viewer-badge:focus-visible { outline: 2px solid #93c5fd; outline-offset:
}
.player-shape-glow.is-on { opacity: 0.8; }
#page-channel .video-container:fullscreen { aspect-ratio: auto; max-height: none; width: 100%; height: 100%; }
--@media (prefers-reduced-motion: reduce) {
-- #page-channel .video-container { transition: none; }
--}
/* ── A phone on its side: the stream fills the screen (2026-10-25) ───────────────
Landscape, at most 540px tall, on a touch screen. Every rule above sizes the player for a portrait phone (half the
@@@ -1772,8 +1767,6 @@ html:has(.openvibe-navbar.ovnav-hide) #page-channel .ch-tabs-wrap { top: 0; padd
/* The phone's gutter is the container's 16 px plus the fold's own 8 px. */
#page-channel .ch-tabs-wrap { margin-inline: -24px; padding-inline: 24px; }
}
--@media (prefers-reduced-motion: reduce) { #page-channel .ch-tabs-wrap { transition: none; } }
--html[data-ov-motion="reduced"] #page-channel .ch-tabs-wrap { transition: none; }
#page-channel .ch-tabs-wrap #ch-tabs { margin: 0; scroll-padding-inline: 14px; }
#page-channel .ch-tabs-arrow { display: none; position: absolute; top: 50%; transform: translateY(-50%); z-index: 2; width: 32px; height: 32px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg-card); color: var(--text-primary); cursor: pointer; }
#page-channel .ch-tabs-arrow[data-dir="prev"] { left: 0; }
@@@ -1810,8 +1803,6 @@ html[data-ov-motion="reduced"] #page-channel .ch-tabs-wrap { transition: none; }
@media (max-width: 760px) { #page-channel .ch-tab-panel :is(.stream-grid, .pastes-grid) { grid-auto-columns: minmax(220px, 78%); } }
#page-channel .ch-rail-skeleton { min-height: 150px; border-radius: 12px; background: linear-gradient(110deg, var(--bg-card) 25%, var(--bg-hover) 50%, var(--bg-card) 75%); background-size: 200% 100%; animation: ch-shimmer 1.5s linear infinite; }
@keyframes ch-shimmer { to { background-position-x: -200%; } }
--@media (prefers-reduced-motion: reduce) { #page-channel .ch-rail-skeleton { animation: none; } }
--html[data-ov-motion="reduced"] #page-channel .ch-rail-skeleton { animation: none; }
.ch-list-page { background: var(--bg-primary); color: var(--text-primary); }
.ch-list-content { max-width: 960px; margin: 0 auto; padding: clamp(20px, 4vw, 48px); }
.ch-list-content > p:first-child a { color: var(--accent); text-decoration: none; }
@@@ -1840,7 +1831,7 @@ html[data-ov-motion="reduced"] #page-channel .ch-rail-skeleton { animation: none
#page-channel .ch-tab-panel .stream-grid > * { scroll-snap-align: start; min-width: 0; }
#page-channel .ch-tab-panel .stream-grid > p.muted { grid-column: auto; }
}
--@media (prefers-reduced-motion: reduce) { #page-channel .ch-tab-panel { scroll-margin-top: calc(var(--ovnav-h, 56px) + 56px); } }
++html[data-ov-motion=off] #page-channel .ch-tab-panel{scroll-margin-top: calc(var(--ovnav-h, 56px) + 56px)}
/* Shared channel identity, chips, actions, and More menu. */
#page-channel :is(.ch-info-bar-text, .ch-offline-header-info) { min-width: 0; }
@@@ -1969,7 +1960,6 @@ html[data-ov-motion="reduced"] #page-channel .ch-rail-skeleton { animation: none
body:has(#page-channel.active.ch-is-live) .ch-chat-reopen .fa-comments { color: inherit; }
}
@keyframes chChatRise { from { opacity: 0; transform: translateY(24px); } to { opacity: 1; transform: none; } }
--@media (prefers-reduced-motion: reduce) { #page-channel.chat-revealing .stream-layout > .chat-sidebar { animation: none !important; } }
/* Offline, with when (ask 57, 11-09): a quiet state line and the last stream one tap away (the header says "Last live" too). */
.ch-offline-state { display: inline-flex; align-items: center; gap: 8px; margin: 8px 0 0; padding: 6px 14px; border-radius: 999px; font-size: .9rem; font-weight: 600;
@@@ -1985,7 +1975,6 @@ html[data-ov-motion="reduced"] #page-channel .ch-rail-skeleton { animation: none
.ch-last-text { min-width: 0; display: grid; gap: 2px; }
.ch-last-text b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .95rem; color: var(--text-primary); }
.ch-last-text small { font-size: .78rem; color: var(--text-muted); }
--@media (prefers-reduced-motion: reduce) { .ch-last-stream { transition: none; } }
/* ── The player's bar, 11-09 (the owner: "the buttons and the icons… better sized and centred… better tags"). One button
size (40 px, 44 on a touch screen) with the icon centred at 18 px; LIVE is a red pill with a breathing dot (a grey
@@@ -2000,7 +1989,6 @@ html[data-ov-motion="reduced"] #page-channel .ch-rail-skeleton { animation: none
.video-controls .dvr-live-btn .live-dot { width: 7px; height: 7px; background: #fff; box-shadow: 0 0 0 0 rgba(255, 255, 255, .6); animation: vc-live-breathe 1.8s ease-in-out infinite; }
.video-controls .dvr-live-btn.dvr-behind { background: rgba(255, 255, 255, .16) !important; box-shadow: none; }
@keyframes vc-live-breathe { 0%, 100% { box-shadow: 0 0 0 0 rgba(255, 255, 255, .55); } 50% { box-shadow: 0 0 0 4px rgba(255, 255, 255, 0); } }
--html[data-ov-motion=reduced] .video-controls .dvr-live-btn .live-dot { animation-duration: 3.6s; }
.video-controls .viewer-badge, .video-controls .uptime-badge { height: 28px; min-height: 28px; padding: 0 10px; border-radius: 999px; gap: 6px; font-size: 12.5px !important; font-weight: 600;
background: rgba(255, 255, 255, .12); -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); color: #fff; }
.video-controls .viewer-badge i, .video-controls .uptime-badge i { font-size: 12px; color: rgba(255, 255, 255, .75); }
ddiff --git a/public/css/features/chatpage.css b/public/css/features/chatpage.css
iindex 74f4fa45..135fc0b0 100644
---- a/public/css/features/chatpage.css
++++ b/public/css/features/chatpage.css
@@@ -360,7 +360,6 @@
.vc-participant-row.speaking .vc-avatar-mini { box-shadow: 0 0 0 2px rgba(74, 222, 128, 0.7); }
.vc-connected-panel.is-open { animation: vcPanelIn var(--ov-dur-2, 220ms) var(--ov-ease-out, cubic-bezier(.2, .9, .3, 1)); }
@keyframes vcPanelIn { from { opacity: 0; transform: translateY(6px); } }
--@media (prefers-reduced-motion: reduce) { .vc-channel-item.is-entering, .vc-channel-item.is-leaving, .vc-connected-panel.is-open { animation: none; } }
/* Without JavaScript (the server inlines this fragment for a direct visit): what needs it, and where to go instead. */
.chat-noscript { max-width: 720px; margin: 24px auto; padding: 0 16px; line-height: 1.6; }
ddiff --git a/public/css/features/content.css b/public/css/features/content.css
iindex 1179fc76..f73df578 100644
---- a/public/css/features/content.css
++++ b/public/css/features/content.css
@@@ -498,11 +498,6 @@ a.cf-recap-band:hover .cf-recap-sub { text-decoration: underline; text-underline
.cf-chips::-webkit-scrollbar { display: none; }
.cf-chip { flex: none; min-height: 34px; padding: 0 12px; font-size: 0.84rem; }
}
--@media (prefers-reduced-motion: reduce) {
-- .cf-card { animation: none; }
-- .cf-skel-media::after, .cf-skel-line::after { animation: none; }
-- .cf-media img.cf-thumb, .cf-play { transition: none; }
--}
/* A card with no picture shows who made it: their face, round, over a blur of the same picture. */
.cf-thumb--who { overflow: hidden; }
ddiff --git a/public/css/features/dashboard.css b/public/css/features/dashboard.css
iindex ba227765..77bf311a 100644
---- a/public/css/features/dashboard.css
++++ b/public/css/features/dashboard.css
@@@ -7,7 +7,6 @@
.pc-status { font-size: 0.92rem; display: flex; align-items: center; gap: 8px; min-height: 22px; }
.pc-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; display: inline-block; }
.pc-dot-on { background: #53fc18; box-shadow: 0 0 6px rgba(83,252,24,0.6); animation: pcDotPulse 2.2s var(--ov-ease-in-out, ease-in-out) infinite; }
--@media (prefers-reduced-motion: reduce) { .pc-dot-on { animation: none; } }
.pc-kind { font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; padding: 1px 7px; border-radius: 6px; margin-left: 4px; vertical-align: middle; }
.pc-kind-app { background: rgba(83,252,24,0.16); color: #6ee34a; }
.pc-kind-test { background: color-mix(in srgb, var(--accent) 18%, transparent); color: var(--accent-light, #e1b77d); }
ddiff --git a/public/css/features/featured.css b/public/css/features/featured.css
iindex 7d4528f6..2a85c827 100644
---- a/public/css/features/featured.css
++++ b/public/css/features/featured.css
@@@ -88,10 +88,6 @@
.hf-panel { padding: 14px 14px 14px; }
.hf-overview { display: none; }
}
--@media (prefers-reduced-motion: reduce) {
-- .hf, .hf-live i { animation: none; }
-- .hf-media video, .hf-media canvas, .hf-media img.hf-frame, .hf-now { transition: none; }
--}
/* Touch pointers: the unmute pill answers a finger at 44 px (2026-11-05 phone sweep: 35.6 px tall). It sits on the
video overlay, so it takes the height itself rather than a hit area over the frame's own link. */
ddiff --git a/public/css/features/home.css b/public/css/features/home.css
iindex a16f4012..83ede74d 100644
---- a/public/css/features/home.css
++++ b/public/css/features/home.css
@@@ -108,7 +108,6 @@
.sg-live-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: #ef4444; margin-right: 2px; vertical-align: middle; animation: sg-pulse 1.6s var(--ov-ease-in-out, ease-in-out) infinite; }
@keyframes sg-pulse { 50% { opacity: .35; } }
.streamer-group-stream.is-live .streamer-group-stream-thumb { box-shadow: 0 0 0 2px #ef4444; border-radius: 8px; }
--@media (prefers-reduced-motion: reduce) { .sg-live-dot { animation: none; } }
.streamer-group-stream-title { font-size: 0.88rem; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.streamer-group-stream-time { font-size: 0.75rem; }
.hero-rotate {
@@@ -133,9 +132,6 @@
transform: scale(1.07); filter: none; opacity: 1;
box-shadow: 0 26px 60px -10px rgba(0, 0, 0, 0.85), 0 0 0 1px var(--accent) inset;
}
--@media (prefers-reduced-motion: reduce) {
-- .hero-bg-layer { transition: opacity var(--ov-dur-4, 0.6s) var(--ov-ease-out, ease); }
--}
.hero-quip {
height: 2.8em; margin: 2px auto 18px; max-width: 640px; overflow: hidden;
display: grid; place-items: center; text-align: center; text-wrap: balance;
@@@ -296,12 +292,7 @@
.hero-stat-label { white-space: normal; line-height: 1.1; }
.hero-float { border-radius: 10px; }
}
--@media (prefers-reduced-motion: reduce) {
-- .hero-float { animation: heroFloatIn var(--ov-dur-1, 0.01s) forwards; opacity: 0.5; }
-- .hero-float::after { animation: none; display: none; }
-- .hero-collage { transition: none; transform: none; }
-- .hero-quip { transition: none; }
--}
++html[data-ov-motion=off] .hero-float{opacity: 0.5}html[data-ov-motion=off] .hero-float::after{display: none}html[data-ov-motion=off] .hero-collage{transform: none}
.stream-card-ago {
margin-left: auto;
font-size: 0.75rem;
@@@ -372,9 +363,7 @@
.home-cta-features { grid-template-columns: 1fr; }
.cta-hero { font-size: 1.7rem; }
}
--@media (prefers-reduced-motion: reduce) {
-- .cta-hero-grad { -webkit-text-fill-color: var(--accent); color: var(--accent); }
--}
++html[data-ov-motion=off] .cta-hero-grad{-webkit-text-fill-color: var(--accent);color: var(--accent)}
.home-pastes-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
@@@ -476,10 +465,6 @@
.stream-card-uptime, .stream-card-vcount { display: inline-flex; align-items: center; white-space: nowrap; }
.stream-card-appear { animation: streamCardAppear var(--ov-dur-4, 0.6s) var(--ov-ease-out, ease); }
.stream-card-leave { opacity: 0; transform: scale(0.94); transition: opacity var(--ov-dur-3, 0.4s) var(--ov-ease-out, ease), transform var(--ov-dur-3, 0.4s) var(--ov-ease-out, ease); pointer-events: none; }
--@media (prefers-reduced-motion: reduce) {
-- .stream-card-appear { animation: none; }
-- .stream-card-leave { transition: none; }
--}
.hero-aurora::before, .hero-aurora::after {
content: ''; position: absolute; pointer-events: none; border-radius: 50%;
mix-blend-mode: lighten; filter: blur(44px);
@@@ -569,9 +554,6 @@
@media (max-width: 640px) {
.hero-stats { min-height: 182px; }
}
--@media (prefers-reduced-motion: reduce) {
-- .hero-stats.is-swapping { transition: none; }
--}
.hero-rotate-wrap { display: inline; }
.hero-tagline { text-wrap: balance; overflow-wrap: break-word; }
@media (max-width: 760px) {
@@@ -647,7 +629,6 @@
.home-load-more:active { transform: translateY(0) scale(0.99); }
.home-load-more:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.home-load-more:hover i { transform: translateY(2px); }
--@media (prefers-reduced-motion: reduce) { .home-load-more, .home-load-more i { transition: none; } }
.hero-stat:hover { border-color: color-mix(in srgb, var(--accent) 38%, transparent); }
.hero-stat--clickable:hover { transform: translateY(-1px); }
.hero-stat--live > i { color: var(--live-red-text, var(--live-red)); background: color-mix(in srgb, var(--live-red) 15%, transparent); border-color: color-mix(in srgb, var(--live-red) 34%, transparent); }
@@@ -748,7 +729,6 @@
animation-delay: calc(var(--ov-enter-i, 0) * 45ms);
will-change: transform, opacity;
}
--@media (prefers-reduced-motion: reduce) { .ov-enter { animation: none !important; } }
.cta-btn-short { display: none; }
.cta-method { display: flex; flex-direction: column; }
@@@ -826,11 +806,7 @@
.cta-section-label { white-space: nowrap; }
}
--@media (prefers-reduced-motion: reduce) {
-- .cta-method:hover::before, .home-cta-feature:hover::after { animation: none; opacity: 0; }
-- .home-cta-banner.about-opening * { animation: none !important; }
-- .cta-method:hover .cta-method-icon, .home-cta-feature:hover > i:first-child { transform: none; }
--}
++html[data-ov-motion=off] .cta-method:hover::before,html[data-ov-motion=off] .home-cta-feature:hover::after{opacity: 0}html[data-ov-motion=off] .cta-method:hover .cta-method-icon,html[data-ov-motion=off] .home-cta-feature:hover > i:first-child{transform: none}
/* ── from i18n-star.css ── */
/* ═══ Star of OpenVibe (decoration and compact grid: css/i18n-star.css) ═══
@@@ -893,9 +869,7 @@
.home-star-section { margin-bottom: 34px; }
.star-live-card { max-width: 420px; width: 100%; margin: 0 auto; }
}
--@media (prefers-reduced-motion: reduce) {
-- .star-petals { display: none; }
--}
++html[data-ov-motion=off] .star-petals{display: none}
.rs-lite .star-card-border::before { animation: none; background: linear-gradient(135deg, var(--star-gold), #f472b6, var(--accent)); inset: 0; }
.rs-lite .star-avatar-glow, .rs-lite .star-petals { display: none; }
.rs-lite .star-avatar-ring { animation: none; }
@@@ -1044,12 +1018,6 @@
position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap;
}
}
--@media (prefers-reduced-motion: reduce) {
-- .star-more.is-entering { animation: none; }
-- .star-card.is-swapping { transition: none; }
-- .star-card.is-compact .star-avatar-ring { animation: none; }
-- .star-act, .star-act > i { transition: none; }
--}
/* The pitch banner: what kind of place this is, before the headline says it. */
.cta-chips { display: flex; flex-wrap: wrap; justify-content: center; gap: 6px; margin: 0 0 14px; }
@@@ -1256,7 +1224,6 @@
.hero-stat-all-sites > i:last-child { font-size: 0.7rem; color: var(--text-muted); transition: transform var(--ov-dur-2, 0.18s); }
.hero-stat-all-sites:hover, .hero-stat-all-sites:focus-visible { border-color: color-mix(in srgb, var(--accent) 60%, transparent); background: color-mix(in srgb, var(--accent) 16%, transparent); outline: none; }
.hero-stat-all-sites:hover > i:last-child { transform: translateX(3px); }
--@media (prefers-reduced-motion: reduce) { .hero-stat-all-sites, .hero-stat-all-sites > i { transition: none; } }
/* Recently Online: a stream that has a recording opens its replay (openvibe.video, the chat in time beside it), and
its picture says so with a play mark (2026-10-27). */
@@@ -1272,7 +1239,6 @@
}
.streamer-group-stream.has-replay:hover .sg-play i, .streamer-group-stream.has-replay:focus-visible .sg-play i { transform: scale(1.08); background: var(--accent); border-color: transparent; }
.sg-replay { color: var(--accent-light, var(--accent)); font-weight: 700; }
--@media (prefers-reduced-motion: reduce) { .sg-play i { transition: none; } }
/* Touch screens (2026-10-29, tap-target audit): every control on the home reaches 44 px where it has room, and an
invisible hit area (::after) where growing it would move the layout. Nothing changes on a mouse. */
@@@ -1337,7 +1303,6 @@
}
.home-rail-tail { display: none; }
@keyframes homeRailPulse { from { opacity: 1; } to { opacity: .55; } }
--@media (prefers-reduced-motion: reduce) { .home-rail-skel { animation: none; } }
/* 2026-10-31, the phone home, shorter again: the featured stream's card leaves the Live Now grid on a phone (the
featured block above already shows it), and when it was the only stream there the grid's header goes too; the
@@@ -1517,12 +1482,7 @@
/* Light themes: the surface follows the page, the aurora softens. */
@media (prefers-color-scheme: light) { :root:not([data-theme]) .ovnet::before { opacity: .35; } }
[data-theme-mode="light"] .ovnet::before, html.light .ovnet::before { opacity: .35; }
--@media (prefers-reduced-motion: reduce) {
-- .ovnet::before, .ovnet-sky i, .ovnet-dot { animation: none; }
-- .ovnet-sky i { opacity: .45; }
-- .ovnet.is-armed .ovnet-site { opacity: 1; --lift: 0px; }
-- .ovnet-site, .ovnet-jump a, .ovnet-cta { transition: none; }
--}
++html[data-ov-motion=off] .ovnet-sky i{opacity: .45}html[data-ov-motion=off] .ovnet.is-armed .ovnet-site{opacity: 1;--lift: 0px}
/* The menu hint carries the menu's own orb (11-09: "Jump between sites… from the OV mark" pointed at the wrong button). */
.ovnet-switch .ovnet-orb { flex: none; color: var(--accent-light, #60a5fa); }
ddiff --git a/public/css/features/rundown.css b/public/css/features/rundown.css
iindex 37aaffdd..d2e729a2 100644
---- a/public/css/features/rundown.css
++++ b/public/css/features/rundown.css
@@@ -238,7 +238,6 @@ a.rd-use:hover b { color: var(--accent); }
.rd-more > summary::before { content: ''; width: 8px; height: 8px; border-right: 2px solid currentColor; border-bottom: 2px solid currentColor; transform: translateY(-2px) rotate(-45deg); transition: transform var(--ov-dur-2, .18s) var(--ov-ease-out, ease); }
.rd-more[open] > summary::before { transform: translateY(-4px) rotate(45deg); }
.rd-more > .rd-topic-grid { padding: 4px 12px 14px; }
--@media (prefers-reduced-motion: reduce) { .rd-more > summary::before { transition: none; } }
/* ── The bar: search and filters (public/js/rundown.js) ──────────────────────────────────
Sticky under the navbar on every Rundown page. The phone gets the search box and one
@@@ -544,7 +543,6 @@ body.rd-sheet-open { overflow: hidden; }
#rundown-root .rd-take .rd-compact-top .rd-chip { position: relative; min-height: 28px; }
#rundown-root .rd-take .rd-compact-top .rd-chip::after { content: ''; position: absolute; inset: -8px; }
}
--@media (prefers-reduced-motion: reduce) { .rd-bar, #rundown-root .rd-react-btn, #rundown-root .rd-skeleton span { transition: none; animation: none; } }
/* ── The day, at the top of the front page (rundown.js _rdDayDraw): the day in short when the AI summary is on, then
today's moments as a row to swipe, in time order, each hung from one line by its dot. ── */
ddiff --git a/public/css/features/stats.css b/public/css/features/stats.css
iindex 2aff12a2..05053054 100644
---- a/public/css/features/stats.css
++++ b/public/css/features/stats.css
@@@ -123,9 +123,7 @@
.sn-tile:last-child:nth-child(odd) { grid-column: 1 / -1; }
.sn-swipe-hint { display: inline; }
}
--@media (prefers-reduced-motion: reduce) {
-- .sn-overlay, .sn-dialog, .sn-bar, .sn-marks .sn-line { animation: none !important; stroke-dashoffset: 0; }
--}
++html[data-ov-motion=off] .sn-overlay,html[data-ov-motion=off] .sn-dialog,html[data-ov-motion=off] .sn-bar,html[data-ov-motion=off] .sn-marks .sn-line{stroke-dashoffset: 0}
@media (forced-colors: active) {
.sn-bar, .sn-dot { fill: CanvasText; }
.sn-line { stroke: CanvasText; }
ddiff --git a/public/css/features/viewer-places.css b/public/css/features/viewer-places.css
iindex 6bc770b9..d32689ae 100644
---- a/public/css/features/viewer-places.css
++++ b/public/css/features/viewer-places.css
@@@ -27,7 +27,6 @@
.ovp-out { font-size: 0.68rem; opacity: 0.55; }
.ovp-n { margin-left: auto; padding-left: 12px; font-weight: 700; }
.ovp-n.is-live { font-size: 0.72rem; font-weight: 700; color: #fca5a5; text-transform: uppercase; letter-spacing: 0.05em; }
--@media (prefers-reduced-motion: reduce) { .ovp { animation: none; } }
/* A section under the places (the live studio's restreams): each with a state dot, and a way to manage them. */
.ovp-extra { margin-top: 4px; padding-top: 4px; border-top: 1px solid color-mix(in srgb, var(--text-primary, #fff) 10%, transparent); }
.ovp-extra[hidden] { display: none; }
ddiff --git a/public/css/home-fx.css b/public/css/home-fx.css
iindex 9905f07e..3eba0988 100644
---- a/public/css/home-fx.css
++++ b/public/css/home-fx.css
@@@ -1,7 +1,7 @@
/* ═══════════════════════════════════════════════════════════════
HOME FX — decorative effects layer for the home page (public/js/home-fx.js)
-- Additive only; everything degrades to the plain page without JS or under
-- prefers-reduced-motion.
++ Additive only; everything degrades to the plain page without JS or with
++ motion off (Calm plays it, smaller and slower).
═══════════════════════════════════════════════════════════════ */
#page-home .hero { position: relative; }
.hfx-sparks { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; z-index: 0; opacity: 0.9; }
@@@ -162,11 +162,7 @@
#page-home .section-header h2 .live-dot { animation: hfxLive 1.4s var(--ov-ease-in-out, ease-in-out) infinite; }
@keyframes hfxLive { 0%,100% { transform: scale(1); filter: drop-shadow(0 0 0 rgba(231,76,60,0)); } 50% { transform: scale(1.25); filter: drop-shadow(0 0 8px rgba(231,76,60,0.9)); } }
--@media (prefers-reduced-motion: reduce) {
-- .hfx-sparks, .hfx-spot { display: none; }
-- .hfx-shimmer, .hfx-glow, .hb::before, .hb::after, .hb-ico, #page-home .section-header h2 .live-dot { animation: none !important; }
-- .hfx-reveal { opacity: 1; transform: none; transition: none; }
--}
++html[data-ov-motion=off] .hfx-sparks,html[data-ov-motion=off] .hfx-spot{display: none}html[data-ov-motion=off] .hfx-reveal{opacity: 1;transform: none}
.rs-lite .hfx-sparks, .rs-lite .hfx-spot { display: none; }
.rs-lite .hfx-shimmer, .rs-lite .hfx-glow, .rs-lite .hb::after, .rs-lite #page-home .section-header h2 .live-dot { animation: none !important; text-shadow: none; }
@@@ -239,7 +235,6 @@
@media (max-width: 520px) { .hb-label small { display: none; } #page-home .hero-primary .hb-label small { display: inline; } }
/* The very narrowest phones: Go live alone, so the label never squeezes. */
@media (max-width: 340px) { #page-home .hero-primary .hb-label small { display: none; } }
--@media (prefers-reduced-motion: reduce) { .hb-vibe, .hb-vibe .hb-ico::after { animation: none !important; } }
/* Touch devices get no hover, so the one button on the row that has something extra to say needs
to advertise it. A small pulsing dot marks it as tappable-for-more; it is pointer-events:none
@@@ -267,7 +262,6 @@
100% { transform: scale(3.3); opacity: 0; }
}
}
--@media (hover: none) and (prefers-reduced-motion: reduce) { .hb[data-ovtip]::after { animation: none; } }
/* ═══════════════════════════════════════════════════════════════════════════
ddiff --git a/public/css/home-tour.css b/public/css/home-tour.css
iindex 9d41be77..d1160bc7 100644
---- a/public/css/home-tour.css
++++ b/public/css/home-tour.css
@@@ -143,7 +143,7 @@
overflow-x: auto; scrollbar-width: none; margin-left: -14px; margin-right: -14px; padding: 0 14px 2px; }
.ovg-join:not(.is-open) .ovg-join-perks::-webkit-scrollbar { display: none; }
}
--@media (prefers-reduced-motion: reduce) { .tour-wire, .tour-node-dot, .tour-core-ring, .tour-feat, .tour-core::before, .tour-core b, .tour-core-logo { animation: none; } .tour-feat { opacity: 1; transform: none; } }
++html[data-ov-motion=off] .tour-feat{opacity: 1;transform: none}
.rs-lite .tour::before, .rs-lite .tour-core-ring { display: none; }
/* Phones keep the glow but not the repainting gradients. */
.rs-lite .tour-core b { animation: none; -webkit-text-fill-color: var(--text-primary); background: none; }
@@@ -342,9 +342,6 @@
.ovg-join-expand:hover i, .ovg-join.is-open .ovg-join-expand i { animation: none !important; }
@keyframes joinNudgeSweep { 0%, 62% { transform: translateX(0); } 100% { transform: translateX(420%); } }
@keyframes joinNudge { 0%, 74%, 100% { transform: translateY(0); } 82% { transform: translateY(2.5px); } 90% { transform: translateY(0); } }
--@media (prefers-reduced-motion: reduce) {
-- .ovg-join-expand::after, .ovg-join-expand i { animation: none !important; }
--}
.tour-quest-slot.fx-live .ovg-join-perks li { animation: joinPerk var(--ov-dur-4, 0.6s) var(--ov-ease-out, cubic-bezier(.2, 1, .3, 1)) both;
animation-delay: calc(var(--i) * 85ms + 100ms); }
@keyframes joinPerk { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
@@@ -374,12 +371,6 @@
.ovg-join-cta .btn { width: 100%; justify-content: center; }
.ovg-join-tour { width: 100%; text-align: center; }
}
--@media (prefers-reduced-motion: reduce) {
-- .ovg-quest-wave, .ovg-quest-bub, .ovg-quest-sheen, .ovg-quest-ico,
-- .ovg-join-glow, .ovg-join-mark, .ovg-join-go::after { animation: none !important; }
-- .ovg-quest-liquid { transition: none; }
-- .ovg-join-perks li { animation: none !important; }
--}
/* ═══════════════════════════════════════════════════════════════
The launch button — what the quest slot becomes once the setup list is finished, and what the
@@@ -482,10 +473,6 @@
.ovg-launch-ico { width: 34px; height: 34px; }
.ovg-launch-text small { font-size: 0.7rem; }
}
--@media (prefers-reduced-motion: reduce) {
-- .ovg-launch-aura, .ovg-firstlive-ring, .ovg-launch-sheen, .ovg-firstlive-sheen { animation: none !important; }
-- .ovg-launch, .ovg-firstlive { transition: none; }
--}
.rs-lite .ovg-launch-aura, .rs-lite .ovg-firstlive-ring { animation: none !important; }
/* ── Where the wires meet the hub ────────────────────────────────────────────
ddiff --git a/public/css/i18n-star.css b/public/css/i18n-star.css
iindex ccd3ea0f..7effb4b7 100644
---- a/public/css/i18n-star.css
++++ b/public/css/i18n-star.css
@@@ -109,12 +109,7 @@
}
@keyframes starSheen { 0% { background-position: 120% 0; } 100% { background-position: -120% 0; } }
--@media (prefers-reduced-motion: reduce) {
-- .chat-translation, .chat-translate-hint, .ch-captions, .ch-caption, .ch-captions-dot, .ch-about-bio-translated,
-- .star-card-border, .star-card-border::before, .star-petals span, .star-avatar-ring, .star-avatar-glow, .star-live-pill,
-- .star-kicker i, .star-btn-watch::after { animation: none !important; }
-- .chat-translation, .ch-caption, .star-card-border { opacity: 1; transform: none; }
--}
++html[data-ov-motion=off] .chat-translation,html[data-ov-motion=off] .ch-caption,html[data-ov-motion=off] .star-card-border{opacity: 1;transform: none}
/* ── Chat: "translate to my language" hover button (sits left of the reply button) ── */
.chat-msg-hoverable .chat-translate-btn {
@@@ -199,9 +194,6 @@
.star-card.is-compact { padding: 13px 13px 14px; gap: 9px 11px; }
/* On a phone the three actions stack two-then-one cleanly rather than at random. */
}
--@media (prefers-reduced-motion: reduce) {
-- .star-expand, .star-expand i, .star-card.is-compact .star-avatar { transition: none; }
--}
/* ── Chat: "Translate chat" switch (shows once a line in another language arrives; chat.js _autoTrChip) ──
A compact pill on the right of the tools row, the height of the other tool buttons. The tools row's generic
ddiff --git a/public/css/media-player.css b/public/css/media-player.css
iindex e2e6907a..41bc184f 100644
---- a/public/css/media-player.css
++++ b/public/css/media-player.css
@@@ -82,7 +82,7 @@ code { background: rgba(255,255,255,.08); padding: 2px 6px; border-radius: 6px;
animation: mp-pulse 1.4s var(--ov-ease-out, ease-out) infinite; /* a ring that pulses out: nothing spins */
}
@keyframes mp-pulse { from { box-shadow: 0 0 0 0 color-mix(in srgb, var(--mp-accent) 55%, transparent); } to { box-shadow: 0 0 0 12px transparent; } }
--@media (prefers-reduced-motion: reduce) { .mp-spinner { animation: none; opacity: .7; } } /* a still ring while it loads */
++html[data-ov-motion=off] .mp-spinner{opacity: .7} /* a still ring while it loads */
/* ── Custom player controls ──────────────────────────────── */
.mp-controls {
ddiff --git a/public/css/messenger.css b/public/css/messenger.css
iindex c72c7384..fd61d4dd 100644
---- a/public/css/messenger.css
++++ b/public/css/messenger.css
@@@ -67,7 +67,6 @@
overflow: hidden;
animation: msgSlideUp var(--ov-dur-2, 0.2s) var(--ov-ease-out, ease-out);
}
--@media (prefers-reduced-motion: reduce) { .messenger-panel { animation: none; } }
.messenger-panel.open {
display: flex;
}
ddiff --git a/public/css/ov-guide.css b/public/css/ov-guide.css
iindex 191d717c..4350ad71 100644
---- a/public/css/ov-guide.css
++++ b/public/css/ov-guide.css
@@@ -110,7 +110,6 @@ body.ovg-lock { overflow: hidden; }
.ovg-kv { grid-template-columns: 1fr; } .ovg-kv span { font-size: 0.7rem; }
.ovg-foot { padding: 10px 14px calc(14px + env(safe-area-inset-bottom, 0px)); }
}
--@media (prefers-reduced-motion: reduce) { .ovg-card, .ovg-panel, .ovg-pick, .ovg-box, .ovg-sum-row, .ovg-big, .ovg-radar i, .ovg-live-badge { animation: none !important; } }
/* ── Engine extras: clickable steps, hub, rings, spotlight, mini-journey forms ── */
.ovg-steps { grid-template-columns: repeat(var(--n, 4), 1fr); }
ddiff --git a/public/css/style.css b/public/css/style.css
iindex a6fb2261..00cd0d18 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -156,11 +156,8 @@ html { -webkit-tap-highlight-color: transparent; }
:where(a, button, summary, [role="button"], [tabindex]:not([tabindex="-1"]), input[type="checkbox"], input[type="radio"], input[type="range"]):focus-visible {
outline: 2px solid var(--accent-light, var(--accent)); outline-offset: 2px;
}
--@media (prefers-reduced-motion: reduce) {
-- /* No movement: the press is a brief dim instead. */
-- :root { --press-scale: 1; --press-scale-card: 1; }
-- :where(button, [role="button"], .btn, .ov-press, a.badge, .hb, .section-action, .digest-chip, .digest-recap, a.stream-card, a.moment-card, a.pulse-card, a.home-paste-card, a.streamer-group-stream, .ov-press-card):not(:disabled):active { opacity: 0.82; }
--}
++/* Motion off: no movement, the press is a brief dim instead. */
++html[data-ov-motion=off]{--press-scale: 1;--press-scale-card: 1}html[data-ov-motion=off] :where(button, [role="button"], .btn, .ov-press, a.badge, .hb, .section-action, .digest-chip, .digest-recap, a.stream-card, a.moment-card, a.pulse-card, a.home-paste-card, a.streamer-group-stream, .ov-press-card):not(:disabled):active{opacity: 0.82}
/* ═══════════════════════════════════════════════════════════════
NAVBAR
@@@ -1092,9 +1089,7 @@ html[data-theme-mode="light"] .hero-bg { --hero-veil: rgba(255, 255, 255, 0.62);
from { transform: scale(1.06) translate(-1.4%, 1%); }
to { transform: scale(1.16) translate(1.8%, -1.8%); }
}
--@media (prefers-reduced-motion: reduce) {
-- .hero-bg-layer.active { animation: none; transform: scale(1.08); }
--}
++html[data-ov-motion=off] .hero-bg-layer.active{transform: scale(1.08)}
@keyframes heroFloatIn { to { opacity: var(--op, 0.6); } }
/* 3D drift: translate in x/y + slow z-tilt so cards feel like they float in space. */
@@@ -1195,7 +1190,7 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }
.ov-rail-bar > span { position: absolute; inset: 0 auto 0 0; border-radius: inherit; background: var(--rail-accent, var(--accent)); }
/* Touch screens swipe; buttons there would only cover the cards. */
@media (hover: none), (pointer: coarse) { .ov-rail-btn { display: none; } }
--@media (prefers-reduced-motion: reduce) { .ov-rail-track { scroll-behavior: auto; } }
++html[data-ov-motion=off] .ov-rail-track{scroll-behavior: auto}
/* ── AI Moments showcase row ── */
.moments-row { display: flex; gap: 12px; overflow-x: auto; margin-bottom: 22px; }
@@@ -1258,9 +1253,6 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }
itself off the chip now and the labels are short words, so both fit.) */
.hero-stat-num { font-size: 1.05rem; }
}
--@media (prefers-reduced-motion: reduce) {
-- .hero-stat--live > i { animation: none; }
--}
/* ═══════════════════════════════════════════════════════════════
STREAM CARD EXTRAS (duration, ago badge)
@@@ -1303,7 +1295,6 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }
transition: max-height var(--ov-dur-4, 0.6s) var(--ov-ease-out, cubic-bezier(0.22, 1, 0.36, 1)), opacity var(--ov-dur-3, 0.4s) var(--ov-ease-out, ease), margin var(--ov-dur-3, 0.4s) var(--ov-ease-out, ease);
}
/* Toggle label swaps with collapsed state. */
--@media (prefers-reduced-motion: reduce) { .home-about-body { transition: opacity var(--ov-dur-3, 0.3s) var(--ov-ease-out, ease); } }
/* Animated glowing border sweep */
@property --cta-angle { syntax: '<angle>'; initial-value: 0deg; inherits: false; }
@@@ -1433,12 +1424,6 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }
.cta-btn-glow { position: relative; isolation: isolate; box-shadow: 0 8px 22px color-mix(in srgb, var(--accent-dark) 35%, transparent); }
@keyframes ctaBtnGlow { 0%,100% { opacity: 0; } 50% { opacity: 1; } }
/* Respect reduced-motion */
--@media (prefers-reduced-motion: reduce) {
-- .home-cta-banner::before, .cta-ambient, .cta-embers span, .cta-live-dot, .cta-hero-grad,
-- .cta-btn-glow, .home-cta-feature {
-- animation: none !important;
-- }
--}
/* ═══════════════════════════════════════════════════════════════
FOUNDER STORY — Timeline Layout
@@@ -2127,7 +2112,6 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }
.muted-hint { z-index: 21; border: 0; cursor: pointer; -webkit-tap-highlight-color: transparent; }
.muted-hint:hover, .muted-hint:focus-visible { background: var(--accent-strong, var(--accent, #3b82f6)); color: var(--on-accent-strong, #fff); }
@keyframes unmute-pop { from { opacity: 0; transform: translateY(-4px) scale(.96); } }
--@media (prefers-reduced-motion: reduce) { .unmute-pill { animation: none; } }
/* ── DVR Progress Bar (Live Stream Seeking) ─────────────────── */
.dvr-progress-wrap {
@@@ -5398,10 +5382,6 @@ body.theme-light .btn-outline:hover {
.call-participant-tile.is-leaving { animation: callTileOut var(--ov-dur-2, 240ms) ease-in forwards; pointer-events: none; }
@keyframes callTileIn { from { opacity: 0; transform: scale(0.86); } }
@keyframes callTileOut { to { opacity: 0; transform: scale(0.86); } }
--@media (prefers-reduced-motion: reduce) {
-- .call-participant-tile.is-entering, .call-participant-tile.is-leaving, .call-participant-tile.is-connecting .call-avatar, .vc-connected-badge i { animation: none !important; }
-- .call-avatar::after { transition: none; }
--}
/* Video (camera on) */
.call-video {
@@@ -7284,7 +7264,7 @@ textarea.paste-textarea {
/* Phones, scrolling down: the floating buttons step aside (draggable-fab.js) and come back on the way up. */
body .floating-chat-fab, body .mobile-chat-toggle { transition: transform var(--ov-dur-2, 0.2s), opacity var(--ov-dur-2, 0.2s), background var(--ov-dur-2, 0.15s); }
body.fabs-tucked .floating-chat-fab, body.fabs-tucked .mobile-chat-toggle { opacity: 0; transform: scale(0.6); pointer-events: none; }
--@media (prefers-reduced-motion: reduce) { body.fabs-tucked .floating-chat-fab, body.fabs-tucked .mobile-chat-toggle { transform: none; } }
++html[data-ov-motion=off] body.fabs-tucked .floating-chat-fab,html[data-ov-motion=off] body.fabs-tucked .mobile-chat-toggle{transform: none}
/* ── Floating Chat Widget ─────────────────────────────────────── */
.floating-chat-fab {
@@@ -9016,9 +8996,6 @@ body.fabs-tucked .floating-chat-fab, body.fabs-tucked .mobile-chat-toggle { opac
/* On a light page the shimmer's pale gold sank into the white; a deep amber keeps the name readable through it. */
html[data-theme-mode="light"] .hero h1 { background-image: linear-gradient(90deg, var(--accent, var(--accent)), #b7791f, var(--accent, var(--accent))); }
@keyframes heroShimmer { to { background-position: 200% center; } }
--@media (prefers-reduced-motion: reduce) {
-- .hero-aurora, .hero-aurora::before, .hero-aurora::after, .hero h1 { animation: none; }
--}
/* Stream description on live cards (collapsible) */
@@@ -9038,7 +9015,6 @@ html[data-theme-mode="light"] .hero h1 { background-image: linear-gradient(90deg
0%,100% { box-shadow: 0 8px 24px rgba(0,0,0,.5), 0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent); }
50% { box-shadow: 0 8px 24px rgba(0,0,0,.5), 0 0 0 12px transparent; }
}
--@media (prefers-reduced-motion: reduce) { .cp-bonus-chest { animation: none; } }
/* Live-counting uptime tooltip */
.uptime-tooltip {
@@@ -9143,7 +9119,6 @@ html[data-theme-mode="light"] .hero h1 { background-image: linear-gradient(90deg
position: static; height: auto; background: none; padding: 4px 0 6px;
}
.cgw-more.expanded i { animation: none; }
--@media (prefers-reduced-motion: reduce) { .cgw-more i { animation: none; } }
.cgw-goal {
position: relative; height: 42px; border-radius: 9px; overflow: hidden; cursor: pointer;
background: var(--bg-card); border: 1px solid var(--border);
@@@ -9569,7 +9544,7 @@ label.staff-inline-toggle.aiv-field { flex-direction:row; align-items:center; }
@keyframes ovSpark3 { 0% { opacity: 0; transform: translate(0,10px) scale(0.6); } 10% { opacity: 1; } 60% { opacity: 0; transform: translate(3px,16px) scale(1.2); } 100% { opacity: 0; } }
@media (max-width: 600px) { .brand-mark { width: 26px; height: 26px; } .brand-mark svg { width: 25px; height: 25px; } .brand-mark--hero svg { width: 100%; height: 100%; } }
.rs-lite .ov-orbit, .rs-lite .ov-spark { filter: none; }
--@media (prefers-reduced-motion: reduce) { .brand-mark svg, .ov-glow, .ov-comet, .ov-v, .ov-dot, .ov-spark { animation: none !important; } .ov-orbit, .ov-spark { display: none; } .ov-comet { stroke-dasharray: none; opacity: 0.9; } }
++html[data-ov-motion=off] .ov-orbit,html[data-ov-motion=off] .ov-spark{display: none}html[data-ov-motion=off] .ov-comet{stroke-dasharray: none;opacity: 0.9}
@media (max-width: 600px) { .brand-mark { width: 26px; height: 26px; } .brand-mark svg { width: 25px; height: 25px; } }
.ov-mark--ph { opacity: 0.45; color: var(--text-muted); }
@@@ -9596,10 +9571,6 @@ label.staff-inline-toggle.aiv-field { flex-direction:row; align-items:center; }
Only the element that is arriving animates, so the one leaving doesn't flash on its way out. */
.hero-stat-full.is-entering, .hero-stat-strip.is-entering { animation: heroStatsOpen var(--ov-dur-3, 0.42s) var(--ov-ease-out, cubic-bezier(.2, 1, .3, 1)); }
@keyframes heroStatsOpen { from { opacity: 0; transform: translateY(-7px); } to { opacity: 1; transform: none; } }
--@media (prefers-reduced-motion: reduce) {
-- .hero-stat-full.is-entering, .hero-stat-strip.is-entering { animation: none; }
-- .hero-stat-more i { transition: none; }
--}
/* ── Hero polish ─────────────────────────────────────────────────────────────
The rotating tagline is a single unbreakable line, so on a narrow screen a long phrase ran off
@@@ -9852,9 +9823,6 @@ label.staff-inline-toggle.aiv-field { flex-direction:row; align-items:center; }
#page-home.page { padding-bottom: 10px; }
/* Route changes (the bar, the loading line, the fade-in) are openvibe-shared web-runtime enter() + route-transition.js. */
--@media (prefers-reduced-motion: reduce) {
-- .brand-mark.is-spin svg, .brand-mark.is-settling svg { animation: none !important; }
--}
/* ═══════════════════════════════════════════════════════════════
Off-screen sections animate nothing.
@@@ -9941,10 +9909,7 @@ label.staff-inline-toggle.aiv-field { flex-direction:row; align-items:center; }
@keyframes ovConnPulse { 50% { transform: scale(0.55); opacity: 0.5; } }
.chat-conn-status { font-style: italic; opacity: 0.85; }
.chat-conn-status[data-state="connected"] { color: var(--success-text, var(--success)); }
--@media (prefers-reduced-motion: reduce) {
-- .ov-conn-pill { transition: opacity var(--motion-fast) linear; transform: translate3d(-50%, 0, 0); }
-- .ov-conn-pill .ov-conn-dot { animation: none !important; }
--}
++html[data-ov-motion=off] .ov-conn-pill{transform: translate3d(-50%, 0, 0)}
body.ov-panel-open #floating-chat-fab { opacity: 0; pointer-events: none; transform: scale(.85); transition: opacity var(--ov-dur-2, .15s), transform var(--ov-dur-2, .15s); }
ddiff --git a/public/index.html b/public/index.html
iindex c2ba10d0..3cfad19b 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -60,7 +60,7 @@
// page's effects script (which only loads on the home route).
(function () {
try {
-- if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
++ if ((document.documentElement.getAttribute('data-ov-motion') === 'off')) return;
if (matchMedia('(pointer: coarse)').matches || matchMedia('(max-width: 820px)').matches || (navigator.deviceMemory && navigator.deviceMemory <= 4)) {
document.documentElement.classList.add('rs-lite');
}
@@@ -86,7 +86,6 @@
.ovsk-fade{animation:ovskFade var(--ov-dur-2, .25s) var(--ov-ease-out, ease) forwards}
@keyframes ovskFade{to{opacity:0}}
@media (max-width:760px){#stream-grid-live .ovsk-card:nth-of-type(n+3){display:none}}
-- @media (prefers-reduced-motion:reduce){.ovsk::after,.ovsk-bar::after{animation:none}}
/* A route's own markup is fetched on first visit; until it lands the page shows its outline. */
.ov-route-skel{display:grid;gap:14px;padding:24px 16px;max-width:1200px;margin:0 auto}
.ov-route-skel span{display:block;border-radius:14px;background:color-mix(in srgb,var(--text-muted,#8b93ad) 10%,transparent);animation:ovRouteSkel 1.4s var(--ov-ease-in-out, ease-in-out) infinite alternate}
@@@ -98,7 +97,6 @@
.ov-route-error>i{font-size:1.4rem;color:var(--danger-text, var(--danger,#ef4444))}
.ov-route-error>div{flex:1;min-width:180px;display:grid;gap:2px}
.ov-route-error span{color:var(--text-muted,#8b93ad);font-size:.9rem}
-- @media (prefers-reduced-motion:reduce){.ov-route-skel span{animation:none}}
/* Keep the cosmetics modal out of the page before its stylesheet lands. */
.cosmetics-modal-overlay:not(.active){display:none}
</style>
ddiff --git a/public/js/app-channel.js b/public/js/app-channel.js
iindex de302ce0..9e2db8c2 100644
---- a/public/js/app-channel.js
++++ b/public/js/app-channel.js
@@@ -2193,7 +2193,7 @@ function switchChannelTab(tab, btn, jump) {
setTimeout(_queueChannelSectionSpy, reduce ? 200 : 1450);
}
function _channelReduceMotion() {
-- return document.documentElement.dataset.ovMotion === 'reduced' || !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
++ return document.documentElement.dataset.ovMotion === 'off';
}
let _chJumpUntil = 0;
function _markChannelJump(tab, fromTap = false) {
ddiff --git a/public/js/app-home.js b/public/js/app-home.js
iindex 87c2c0c9..18c891a3 100644
---- a/public/js/app-home.js
++++ b/public/js/app-home.js
@@@ -126,7 +126,7 @@ function ovPutCards(container, html, append) {
added.forEach(el => el.classList.add('hfx-reveal', 'is-in'));
let reduce = false;
-- try { reduce = matchMedia('(prefers-reduced-motion: reduce)').matches; } catch { /* */ }
++ try { reduce = (document.documentElement.getAttribute('data-ov-motion') === 'off'); } catch { /* */ }
if (reduce) return;
added.forEach((el, i) => {
el.classList.add('ov-enter');
@@@ -209,7 +209,7 @@ function startHeroQuips(quips) {
*/
function animateHeightChange(wrap, mutate, opts = {}) {
const { duration = 420, entering = null } = opts;
-- const reduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
++ const reduced = window.matchMedia && (document.documentElement.getAttribute('data-ov-motion') === 'off');
if (!wrap || reduced) { try { mutate(); } catch { /* */ } return; }
const h0 = wrap.offsetHeight;
try { mutate(); } catch { /* */ }
@@@ -508,7 +508,7 @@ function _renderHeroStats(stats) {
* nothing is clipped once it settles and the board can still grow if a number gets longer.
*/
let swapping = 0;
-- const reduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
++ const reduced = window.matchMedia && (document.documentElement.getAttribute('data-ov-motion') === 'off');
const swap = () => {
const toOpen = full.hidden;
if (reduced) {
@@@ -752,7 +752,7 @@ function renderHeroBackground(media, moments) {
layers[0].classList.remove('active'); void layers[0].offsetWidth; layers[0].classList.add('active');
} else layers[0].classList.add('active');
layers[1].classList.remove('active');
-- if (frames.length < 2 || (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches && frames.length < 2)) return;
++ if (frames.length < 2 || (window.matchMedia && (document.documentElement.getAttribute('data-ov-motion') === 'off') && frames.length < 2)) return;
let idx = 0;
_heroBgTimer = setInterval(() => {
if (document.hidden) return;
@@@ -777,7 +777,7 @@ function renderHeroCollage(media) {
if (!wrap || !Array.isArray(media) || !media.length) return;
if (_heroCollageTimer) { clearInterval(_heroCollageTimer); _heroCollageTimer = null; }
wrap.innerHTML = '';
-- const reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
++ const reduce = window.matchMedia && (document.documentElement.getAttribute('data-ov-motion') === 'off');
const w = window.innerWidth || 1200;
const count = Math.min(media.length, w < 640 ? 5 : w < 1024 ? 8 : 12);
const positions = _heroCollagePositions(count);
@@@ -824,7 +824,7 @@ function _heroParallaxInit() {
const hero = document.querySelector('#page-home .hero');
const collage = document.getElementById('hero-collage');
if (!hero || !collage) return;
-- if (window.matchMedia && (window.matchMedia('(prefers-reduced-motion: reduce)').matches || window.matchMedia('(pointer: coarse)').matches)) return;
++ if (window.matchMedia && ((document.documentElement.getAttribute('data-ov-motion') === 'off') || window.matchMedia('(pointer: coarse)').matches)) return;
_heroParallaxBound = true;
// Parallax is a plain 2D shift of the whole collage, written straight to its transform. It used
// to write --tilt-x/--tilt-y custom properties; custom properties inherit, so every pointer move
@@@ -980,7 +980,7 @@ function _initHomeAbout() {
*/
function _aboutPlayOpening(banner) {
let reduce = false;
-- try { reduce = matchMedia('(prefers-reduced-motion: reduce)').matches; } catch { /* */ }
++ try { reduce = (document.documentElement.getAttribute('data-ov-motion') === 'off'); } catch { /* */ }
if (reduce) return;
const items = banner.querySelectorAll('.home-about-body .cta-section-label, .home-about-body .cta-method, .home-about-body .home-cta-feature, .home-about-body .home-cta-buttons, .home-about-body .cta-money-lede');
items.forEach((el, i) => el.style.setProperty('--about-i', String(Math.min(i, 16))));
@@@ -1197,7 +1197,7 @@ function _ovnetCount(root) {
const n = Number(root.querySelector('.ovnet-groups')?.dataset.sites || el?.dataset.count || 0);
if (!el || !n) return;
el.dataset.count = String(n);
-- if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) { el.textContent = String(n); return; }
++ if (document.documentElement.getAttribute('data-ov-motion') === 'off') { el.textContent = String(n); return; }
const clock = window.performance || Date;
const t0 = clock.now(), dur = 900;
const step = (t) => {
@@@ -1281,7 +1281,7 @@ function initHomeNetwork() {
_ovnetCount(root);
_ovnetIcons();
};
-- if ('IntersectionObserver' in window && !(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches)) {
++ if ('IntersectionObserver' in window && !(window.matchMedia && (document.documentElement.getAttribute('data-ov-motion') === 'off'))) {
root.classList.add('is-armed');
const io = new IntersectionObserver((entries) => {
if (entries.some((en) => en.isIntersecting)) { io.disconnect(); requestAnimationFrame(wake); }
ddiff --git a/public/js/app.js b/public/js/app.js
iindex 3bba8fe8..21299dd6 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -1372,7 +1372,7 @@ function ovWakeBrandMark() {
try { marks = document.querySelectorAll('.brand-mark'); } catch { return; }
if (!marks || !marks.length) return;
let reduce = false;
-- try { reduce = matchMedia('(prefers-reduced-motion: reduce)').matches; } catch { /* */ }
++ try { reduce = (document.documentElement.getAttribute('data-ov-motion') === 'off'); } catch { /* */ }
if (reduce) {
// CSS stops the loops under reduced motion but cannot reach SMIL — the gradient sweep on the
// V would otherwise run for the whole session. Hold everything still, once.
@@@ -1762,7 +1762,7 @@ function _ensureGoalCycleScheduler() {
}, GOAL_CYCLE_INTERVAL_MS);
}
function _playGoalCycle(el) {
-- if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
++ if (window.matchMedia && (document.documentElement.getAttribute('data-ov-motion') === 'off')) return;
const track = el && el.querySelector('.cgw-track-cycle');
if (!track || track.children.length < 2) return;
const n = parseInt(el.dataset.goalCount || '0', 10);
ddiff --git a/public/js/broadcast-workspace.js b/public/js/broadcast-workspace.js
iindex 5afa26c7..a1aa1232 100644
---- a/public/js/broadcast-workspace.js
++++ b/public/js/broadcast-workspace.js
@@@ -1050,7 +1050,7 @@ function _glOpenFold(id) {
const d = document.getElementById(id);
if (!d) return;
d.open = true;
-- d.scrollIntoView({ block: 'start', behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' });
++ d.scrollIntoView({ block: 'start', behavior: (document.documentElement.getAttribute('data-ov-motion') === 'off') ? 'auto' : 'smooth' });
}
function _glCopyLink() {
ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex e6cce9b2..56c9b6cc 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -2834,7 +2834,7 @@ function _chatEvCss() {
@container (max-width:240px){.chat-ev{grid-template-columns:auto minmax(0,1fr)}.chat-ev-acts{grid-column:2;justify-self:start}}
.chat-ev--here{grid-template-columns:auto minmax(0,1fr);padding:6px 10px}.chat-ev--here .chat-ev-icon{width:22px;height:22px}.chat-ev--here .chat-ev-icon::before{width:8px;height:8px}
@container (max-width:360px){@media (pointer:coarse){.chat-ev-cta{width:44px}}}
--@media (prefers-reduced-motion:reduce){.chat-ev.chat-msg-new{animation:none}.chat-ev--live .chat-ev-icon::before{animation:none}}`;
++`;
document.head.appendChild(st);
}
function _chatEvTime(at) {
ddiff --git a/public/js/content-feed.js b/public/js/content-feed.js
iindex 2e150d76..927a5f2d 100644
---- a/public/js/content-feed.js
++++ b/public/js/content-feed.js
@@@ -390,7 +390,7 @@
const ratios = new Map();
let active = null;
const still = (() => {
-- try { if (matchMedia('(prefers-reduced-motion: reduce)').matches) return true; } catch { /* */ }
++ try { if (/^(reduced|off)$/.test(document.documentElement.getAttribute('data-ov-motion') || '')) return true; } catch { /* */ }
try { if (navigator.connection && navigator.connection.saveData) return true; } catch { /* */ }
return false;
})();
ddiff --git a/public/js/context-cards.js b/public/js/context-cards.js
iindex 33d9b3bf..0f4a67fe 100644
---- a/public/js/context-cards.js
++++ b/public/js/context-cards.js
@@@ -107,7 +107,7 @@
localStorage.setItem(KEY, JSON.stringify([...openSet]));
} catch { /* */ }
-- const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
++ const reduce = (document.documentElement.getAttribute('data-ov-motion') === 'off');
if (reduce) { body.hidden = !opening; busy = false; return; }
// Animate the real height rather than a max-height guess: the board's height varies by
ddiff --git a/public/js/home-fx.js b/public/js/home-fx.js
iindex d8ab301b..c119e8bf 100644
---- a/public/js/home-fx.js
++++ b/public/js/home-fx.js
@@@ -7,12 +7,12 @@
* - scroll reveal: section headers, cards and grids slide/fade in as they enter the viewport
* - count-up: hero stat numbers tick up from 0 when first painted
* - card tilt: stream cards tilt slightly toward the pointer
-- * Everything is disabled under prefers-reduced-motion, paused when the tab is hidden, and
++ * Everything is disabled with motion off (Calm plays it), paused when the tab is hidden, and
* skips the canvas on coarse-pointer / narrow screens to keep phones cool.
*/
(function () {
'use strict';
-- const REDUCED = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
++ const REDUCED = window.matchMedia && (document.documentElement.getAttribute('data-ov-motion') === 'off');
const COARSE = window.matchMedia && window.matchMedia('(pointer: coarse)').matches;
if (REDUCED) return;
// Phones, coarse pointers and low-memory devices run in "lite" mode: no particle canvas, no
ddiff --git a/public/js/home-tour.js b/public/js/home-tour.js
iindex 10ea59ff..18474f8d 100644
---- a/public/js/home-tour.js
++++ b/public/js/home-tour.js
@@@ -131,7 +131,7 @@
svg.setAttribute('viewBox', `0 0 ${R.width} ${R.height}`);
svg.setAttribute('width', R.width); svg.setAttribute('height', R.height);
const vertical = getComputedStyle(stage).getPropertyValue('--tour-vertical').trim() === '1';
-- const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches || document.documentElement.classList.contains('rs-lite');
++ const reduce = (document.documentElement.getAttribute('data-ov-motion') === 'off') || document.documentElement.classList.contains('rs-lite');
const logo = stage.querySelector('.tour-core-logo') || stage.querySelector('[data-node="core"]');
if (!logo) return;
ddiff --git a/public/js/ov-density.js b/public/js/ov-density.js
iindex 5af9d817..ea7e7a4e 100644
---- a/public/js/ov-density.js
++++ b/public/js/ov-density.js
@@@ -55,7 +55,6 @@
.ovd-bar { display: block; width: 2.5px; border-radius: 1px; background: currentColor; height: 11px; }
.ovd-btn[data-n="1"] .ovd-bar { width: 9px; }
.ovd-btn[data-n="2"] .ovd-bar { width: 5px; }
--@media (prefers-reduced-motion: reduce) { .ovd-btn { transition: none; } }
/* The grid honours the chosen count; without a choice it keeps its own auto-fit behaviour. */
[data-ovd-cols] { grid-template-columns: repeat(var(--ovd-cols), minmax(0, 1fr)) !important; }
`;
ddiff --git a/public/js/ov-mini-player.js b/public/js/ov-mini-player.js
iindex 2e071fe6..0efb9bc6 100644
---- a/public/js/ov-mini-player.js
++++ b/public/js/ov-mini-player.js
@@@ -33,7 +33,7 @@
const KEY = 'ov-mini-player';
const st = { box: null, video: null, home: null, title: '', href: '', user: '', styleEl: null, chat: null, x: 0, y: 0, w: 0, corner: 'br', idle: 0, offRo: null };
-- const reduced = () => { try { return matchMedia('(prefers-reduced-motion: reduce)').matches; } catch { return false; } };
++ const reduced = () => { try { return (document.documentElement.getAttribute('data-ov-motion') === 'off'); } catch { return false; } };
const phone = () => { try { return matchMedia('(max-width: 759px)').matches; } catch { return false; } };
const fine = () => { try { return matchMedia('(hover: hover) and (pointer: fine)').matches; } catch { return false; } };
@@@ -106,7 +106,6 @@
.ov-mp-chat-h .ov-mp-b{background:transparent;color:inherit;width:30px;height:30px}
.ov-mp-chat-h .ov-mp-b:hover{background:rgba(127,127,127,.18)}
.ov-mp-chat iframe{flex:1;width:100%;border:0;background:transparent}
--@media (prefers-reduced-motion:reduce){.ov-mini-player.is-settling,.ov-mp-chat{transition:none}}
`;
(document.head || document.documentElement).appendChild(st.styleEl);
}
ddiff --git a/public/js/ov-odometer.js b/public/js/ov-odometer.js
iindex b5056439..fcaf534c 100644
---- a/public/js/ov-odometer.js
++++ b/public/js/ov-odometer.js
@@@ -36,7 +36,7 @@
.ovnum.is-down .ovnum-digit.changed{animation:ovnumDown .62s ease-out}
@keyframes ovnumUp{0%{filter:none}35%{filter:brightness(1.55)}100%{filter:none}}
@keyframes ovnumDown{0%{filter:none}35%{filter:brightness(.72)}100%{filter:none}}
--@media (prefers-reduced-motion:reduce){.ovnum-track{transition:none}.ovnum .ovnum-digit.changed{animation:none}}`;
++`;
function ensure() {
if (document.getElementById('ovnum-css')) return;
ddiff --git a/public/js/ov-rail.js b/public/js/ov-rail.js
iindex 3e96ed3d..49451a34 100644
---- a/public/js/ov-rail.js
++++ b/public/js/ov-rail.js
@@@ -13,7 +13,7 @@
if (typeof window === 'undefined' || window.OVRail) return;
let seq = 0;
-- const reducedMotion = () => !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
++ const reducedMotion = () => !!(window.matchMedia && (document.documentElement.getAttribute('data-ov-motion') === 'off'));
function button(dir, track, label) {
const b = document.createElement('button');
ddiff --git a/public/js/ov-skeleton.js b/public/js/ov-skeleton.js
iindex db07464c..8956cae3 100644
---- a/public/js/ov-skeleton.js
++++ b/public/js/ov-skeleton.js
@@@ -39,7 +39,7 @@
.ovsk-panel { padding: 14px; display: grid; gap: 10px; }
.ovsk-fade { animation: ovskFade .25s ease forwards; }
@keyframes ovskFade { to { opacity: 0; } }
--@media (prefers-reduced-motion: reduce) { .ovsk::after, .ovsk-bar::after { animation: none; } }`;
++`;
function ensure() {
if (document.getElementById('ovsk-css')) return;
ddiff --git a/public/js/ov-viewer-health.js b/public/js/ov-viewer-health.js
iindex 808ac1bc..abb264ad 100644
---- a/public/js/ov-viewer-health.js
++++ b/public/js/ov-viewer-health.js
@@@ -235,7 +235,6 @@
'.ov-freeze-notice button svg{width:16px;height:16px}',
'@keyframes ov-freeze-in{from{opacity:0;transform:translate(-50%,-6px) scale(.96)}}',
'@keyframes ov-freeze-turn{to{transform:rotate(360deg)}}',
-- 'html[data-ov-motion=reduced] .ov-freeze-notice::before{animation-duration:1.8s}',
].join('');
(document.head || document.documentElement).appendChild(styleEl);
}
ddiff --git a/public/js/player-shape.js b/public/js/player-shape.js
iindex 85aa7237..f8b54b83 100644
---- a/public/js/player-shape.js
++++ b/public/js/player-shape.js
@@@ -58,7 +58,8 @@
/** The blurred fill beside a picture narrower than its box. */
function glow(box, source) {
-- const reduce = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
++ // A slow colour fade moves nothing: Calm keeps the glow, only motion off drops it.
++ const reduce = !!(document.documentElement && document.documentElement.getAttribute('data-ov-motion') === 'off');
const saver = navigator.connection && navigator.connection.saveData;
const c = document.createElement('canvas');
const ctx = !reduce && !saver && c.getContext && c.getContext('2d');
ddiff --git a/public/kiosk.html b/public/kiosk.html
iindex a45317e5..29512155 100644
---- a/public/kiosk.html
++++ b/public/kiosk.html
@@@ -146,7 +146,6 @@ button{font:inherit}
box-shadow:0 0 12px var(--red);animation:pulse 1.6s infinite
}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.35}}
--@media (prefers-reduced-motion:reduce){.hero-side .live-pill .pulse{animation:none}}
.hero-side span.tag{
display:block;margin-top:.8vh;
font-size:.78vw;color:var(--muted);font-weight:850;
ddiff --git a/public/live-notify.js b/public/live-notify.js
iindex 34216d61..ff7b768c 100644
---- a/public/live-notify.js
++++ b/public/live-notify.js
@@@ -33,7 +33,7 @@
'.hln-x{position:absolute;top:5px;right:7px;color:#7a7a86;font-size:14px;line-height:1;padding:2px;cursor:pointer;background:none;border:none}' +
'.hln-x:hover{color:#fff}' +
'@keyframes hlnPulse{0%,100%{opacity:1}50%{opacity:.3}}' +
-- '@media (prefers-reduced-motion:reduce){.hln-card{transition:opacity .3s}}';
++ '';
(document.head || document.documentElement).appendChild(s);
}
ddiff --git a/public/obs/alerts.html b/public/obs/alerts.html
iindex e291eca0..58a37f2b 100644
---- a/public/obs/alerts.html
++++ b/public/obs/alerts.html
@@@ -43,7 +43,7 @@ body[data-pos=bottom] .al { animation-name: alInUp; }
.al-msg:empty { display: none; }
.al-status { position: fixed; left: 1vw; bottom: 1vw; font-size: .9vw; color: rgba(255,255,255,.55); text-shadow: 0 1px 2px #000; }
.al-status:empty { display: none; }
--@media (prefers-reduced-motion: reduce) { .al, .al.is-out { animation: none; } .al::after { display: none; } }
++html[data-ov-motion=off] .al::after{display: none}
</style>
<script src="/shared/theme-loader.js"></script>
<!-- The OpenVibe glyphs draw every fa- icon here (no Font Awesome since 2026-11-01). -->
ddiff --git a/public/obs/chat.html b/public/obs/chat.html
iindex adc45761..ac39f150 100644
---- a/public/obs/chat.html
++++ b/public/obs/chat.html
@@@ -264,10 +264,7 @@ body.no-shadow .msg { text-shadow: none; }
}
.chat-msg.has-fx:not(.fx-on) .name-fx-vegas::before,
.chat-msg.has-fx:not(.fx-on) .name-fx-vegas::after { animation-play-state: paused !important; }
--@media (prefers-reduced-motion: reduce) {
-- .name-fx-vegas, .name-fx-vegas::before, .name-fx-vegas::after { animation: none !important; }
-- .name-fx-vegas { filter: drop-shadow(0 0 3px rgba(255, 207, 63, .9)); }
--}
++html[data-ov-motion=off] .name-fx-vegas{filter: drop-shadow(0 0 3px rgba(255, 207, 63, .9))}
/* 2fat Bass Drop (2026-10-23, one of a kind: 2fat, who brings the old classic bangers back): the name thumps on the beat,
a big drop then a smaller one, in club lights, with an equalizer dancing before it and a speaker after it. Only its
one holder wears it (fx_twofat is never in the catalog). The equalizer and the speaker are pseudo-elements, so they
@@@ -329,10 +326,7 @@ body.no-shadow .msg { text-shadow: none; }
}
.chat-msg.has-fx:not(.fx-on) .name-fx-twofat::before,
.chat-msg.has-fx:not(.fx-on) .name-fx-twofat::after { animation-play-state: paused !important; }
--@media (prefers-reduced-motion: reduce) {
-- .name-fx-twofat, .name-fx-twofat::before, .name-fx-twofat::after { animation: none !important; }
-- .name-fx-twofat { filter: drop-shadow(0 0 3px rgba(255, 47, 214, .9)); }
--}
++html[data-ov-motion=off] .name-fx-twofat{filter: drop-shadow(0 0 3px rgba(255, 47, 214, .9))}
.name-fx-void {
color: #9900ff !important;
ddiff --git a/public/obs/scene.html b/public/obs/scene.html
iindex ef6c73b1..12793cfe 100644
---- a/public/obs/scene.html
++++ b/public/obs/scene.html
@@@ -56,7 +56,7 @@ body::after { content: ''; position: absolute; inset: 0; z-index: -1; pointer-ev
.sc > :nth-child(4) { animation-delay: 1.2s; }
.sc > :nth-child(5) { animation-delay: 1.35s; }
@keyframes scIn { to { opacity: 1; transform: none; } }
--@media (prefers-reduced-motion: reduce) { .sc-light { animation: none; } .sc > * { animation: none; opacity: 1; transform: none; } }
++html[data-ov-motion=off] .sc > *{opacity: 1;transform: none}
</style>
<script src="/shared/theme-loader.js"></script>
</head>
ddiff --git a/test/chat-event-cards.test.js b/test/chat-event-cards.test.js
iindex 7e7ebabf..1c8ddbca 100644
---- a/test/chat-event-cards.test.js
++++ b/test/chat-event-cards.test.js
@@@ -47,5 +47,5 @@ assert.ok(!/innerHTML\s*=\s*[^;]*ev\.(name|title)/.test(card), 'the name and tit
assert.ok(/who\.textContent = ev\.name/.test(card) && /title\.textContent = `“\$\{ev\.title\}”`/.test(card), 'they are text nodes');
assert.ok(/showTime = !!opts\.isGlobal \|\| !!chatSettings\.showTimestamps/.test(card), 'time: global always, elsewhere the viewer\'s setting');
assert.ok(/_CHAT_EV_FOLD_MS/.test(card) && /went live again/.test(card), 'the same person within 15 minutes updates the card');
--assert.ok(/prefers-reduced-motion:reduce/.test(src.slice(src.indexOf('function _chatEvCss('), src.indexOf('function _chatEvTime('))), 'motion stops for people who ask');
++assert.ok(!/prefers-reduced-motion/.test(src.slice(src.indexOf('function _chatEvCss('), src.indexOf('function _chatEvTime('))), 'Calm plays the cards (openvibe-shared 4.183.0); only motion off stops them, by the loader\'s one rule');
console.log('chat event cards: ok');
ddiff --git a/test/cosmetics-exclusive.test.js b/test/cosmetics-exclusive.test.js
iindex 2916207f..ee6949be 100644
---- a/test/cosmetics-exclusive.test.js
++++ b/test/cosmetics-exclusive.test.js
@@@ -44,11 +44,11 @@ const cosmetics = require('../server/monetization/cosmetics');
const css = fs.readFileSync(path.join(__dirname, '..', file), 'utf8');
assert.ok(css.includes('.name-fx-vegas {') && css.includes('@keyframes nameFxVegasReel') && css.includes('@keyframes nameFxVegasJackpot'), `${file} draws the effect`);
assert.ok(css.includes('.px-vegas .chat-particle'), `${file} colours the Vegas particles`);
-- assert.ok(css.includes('prefers-reduced-motion: reduce') && /\.name-fx-vegas::before, \.name-fx-vegas::after \{ animation: none/.test(css), `${file} stops the reels for reduced motion`);
-- // 2fat's set (2026-10-23): the bass drop, its equalizer and its particles, stopped for reduced motion too.
++ assert.ok(!css.includes('prefers-reduced-motion') && css.includes('html[data-ov-motion=off] .name-fx-vegas{filter:'), `${file}: Calm plays the reels; with motion off the glow stays`);
++ // 2fat's set (2026-10-23): the bass drop, its equalizer and its particles; at rest on its glow with motion off.
assert.ok(css.includes('.name-fx-twofat {') && css.includes('@keyframes nameFxTwofatThump') && css.includes('@keyframes nameFxTwofatEq'), `${file} draws 2fat's effect`);
assert.ok(css.includes('.px-twofat .chat-particle'), `${file} colours 2fat's particles`);
-- assert.ok(/\.name-fx-twofat, \.name-fx-twofat::before, \.name-fx-twofat::after \{ animation: none/.test(css), `${file} stops 2fat's effect for reduced motion`);
++ assert.ok(css.includes('html[data-ov-motion=off] .name-fx-twofat{filter:'), `${file}: 2fat's effect rests on its glow with motion off`);
}
// 2fat's set is one of a kind like Vegas, and fits Inventory's kinds.
const T = cosmetics.COSMETICS;
ddiff --git a/test/home-rail-autoload.test.js b/test/home-rail-autoload.test.js
iindex 0d714ef0..56cba3dd 100644
---- a/test/home-rail-autoload.test.js
++++ b/test/home-rail-autoload.test.js
@@@ -105,7 +105,7 @@ function page(overflowX) {
// control's column clamp never squeezes a rail's cards.
assert.match(css, /\.home-rail-tail \{ display: none; \}/);
assert.match(css, /#home-catchup \.stream-grid > \.home-rail-tail, #home-catchup \.home-pastes-list > \.home-rail-tail \{ display: flex; \}/);
-- assert.match(css, /@media \(prefers-reduced-motion: reduce\) \{ \.home-rail-skel \{ animation: none; \} \}/);
++ assert.doesNotMatch(css, /\.home-rail-skel \{ animation: none/, 'the placeholder shimmers under Calm too (slower, by the loader)');
assert.match(css, /#home-catchup \.stream-grid\[data-ovd-cols\], #home-catchup \.home-pastes-list\[data-ovd-cols\] \{ grid-template-columns: none !important; \}/);
// Recent Pastes rides the same rail on a phone (it was two cards a row with "Load 2 more", 2026-11-02).
assert.match(css, /#home-catchup \.stream-grid, #home-catchup \.home-pastes-list \{\n\s+display: grid; grid-auto-flow: column;/);
ddiff --git a/test/home-tabs.test.js b/test/home-tabs.test.js
iindex 91e5ec7c..52daf4e4 100644
---- a/test/home-tabs.test.js
++++ b/test/home-tabs.test.js
@@@ -65,6 +65,9 @@ function openHome({ views = 0 } = {}) {
});
if (views) store.set('ov_home_intro_seen', String(views));
window.matchMedia = () => ({ matches: true, addEventListener() { }, removeEventListener() { } });
++ // Motion off: the page writes its numbers at once (the frame stub below passes no time to count with). Since
++ // openvibe-shared 4.183.0 a device that asks for less motion still counts up; only Off does not.
++ window.document.documentElement.setAttribute('data-ov-motion', 'off');
window.requestAnimationFrame = (f) => setTimeout(f, 0);
window.cancelAnimationFrame = () => { };
window.scrollTo = () => { };
ddiff --git a/test/obs-alerts.test.js b/test/obs-alerts.test.js
iindex ccf135fe..9198d119 100644
---- a/test/obs-alerts.test.js
++++ b/test/obs-alerts.test.js
@@@ -20,7 +20,7 @@ assert.ok(re.test('/obs/alerts/goosely') && !re.test('/obs/alerts/<x>') && !re.t
// ── The page, statically ──
assert.ok(!/innerHTML/.test(script), 'a message is only ever text');
assert.match(SRC, /background: transparent !important/, 'a transparent ground over the stream');
--assert.match(SRC, /@media \(prefers-reduced-motion: reduce\)/);
++assert.doesNotMatch(SRC, /prefers-reduced-motion/, 'an alert plays whatever the streamer\'s own device asks');
assert.match(script, /m\.type === 'overlay-alert'/, 'only overlay-alert messages');
assert.match(script, /type: 'join', streamId: streamId, channel: user/, 'joins the channel\'s room, live or not');
ddiff --git a/test/obs-scenes.test.js b/test/obs-scenes.test.js
iindex cec8f3c7..25c2ffd1 100644
---- a/test/obs-scenes.test.js
++++ b/test/obs-scenes.test.js
@@@ -26,7 +26,7 @@ assert.match(SRC, /\/\^\[a-z0-9-\]\{1,40\}\$\/\.test\(q\.get\('theme'\)/, 'the t
assert.ok(!/innerHTML/.test(SRC), 'a query value is only ever text');
assert.match(SRC, /\(q\.get\('msg'\) \|\| ''\)\.slice\(0, 140\)/, 'a message of your own, short');
assert.match(SRC, /setAttribute\('data-state', 'busy'\)/, 'on Be right back the mark breathes');
--assert.match(SRC, /@media \(prefers-reduced-motion: reduce\) \{ \.sc-light \{ animation: none; \}/, 'still under reduced motion');
++assert.doesNotMatch(SRC, /prefers-reduced-motion/, 'a scene plays whatever the streamer\'s own device asks: the audience is who sees it');
assert.ok(!/spin|rotate\(/.test(SRC.replace(/<!--[\s\S]*?-->/g, '')), 'nothing spins');
// ── The dashboard: a Scenes card in the Chat tab, a URL for each scene in the theme picked ──
const frag = fs.readFileSync(path.join(__dirname, '..', 'public/fragments/dashboard.html'), 'utf8');
@@@ -36,4 +36,4 @@ assert.match(dash, /chatai: +\['dash-card-vibe', 'dash-aibot-card', 'dash-card-o
assert.match(dash, /\['', 'obs', 'scene', kind, encodeURIComponent\(currentUser\.username\)\]\.join\('\/'\)/, 'each scene\'s address');
assert.match(dash, /if \(theme && theme !== 'vibe'\) q\.set\('theme', theme\)/, 'the theme picked rides along');
assert.match(dash, /_call\('updateDashScenes'\)/, 'drawn when the tab opens');
--console.log('obs scenes: three scenes by route, the loader and the mark, any theme by name, the mark arriving, text only, still under reduced motion');
++console.log('obs scenes: three scenes by route, the loader and the mark, any theme by name, the mark arriving, text only, playing whatever the device asks');
ddiff --git a/test/player-shape.test.js b/test/player-shape.test.js
iindex 7bcc3857..42979a75 100644
---- a/test/player-shape.test.js
++++ b/test/player-shape.test.js
@@@ -38,11 +38,11 @@ function element(tag, props = {}) {
}, props);
}
--function load({ store = {}, reduce = false } = {}) {
++function load({ store = {}, reduce = false, motion = null } = {}) {
const observers = [];
const ctx = {
window: {}, console, Math, JSON, Number, Object, setInterval: () => 1, clearInterval() {},
-- document: { hidden: false, createElement: (t) => element(t) },
++ document: { hidden: false, createElement: (t) => element(t), documentElement: { getAttribute: (k) => (k === 'data-ov-motion' ? motion : null) } },
navigator: {},
matchMedia: () => ({ matches: reduce }),
localStorage: { getItem: (k) => (k in store ? store[k] : null), setItem: (k, v) => { store[k] = String(v); } },
@@@ -123,12 +123,17 @@ const near = (a, b) => Math.abs(a - b) < 0.001;
assert.ok(near(ratio(box), 4 / 3), 'the canvas drives the shape while it is the picture');
}
--// ── The glow is not made for reduced motion; the page wiring ──
++// ── The glow is not made with motion off (Calm and a device that asks for less keep it: a slow colour fade moves
++// nothing); the page wiring ──
{
-- const { api } = load({ reduce: true });
++ const { api } = load({ motion: 'off' });
const box = element('div');
api.watch(element('video'), box, { key: 'x' });
-- assert.strictEqual(box.children.length, 0, 'no glow canvas with reduced motion');
++ assert.strictEqual(box.children.length, 0, 'no glow canvas with motion off');
++ const calm = load({ reduce: true, motion: 'reduced' });
++ const calmBox = element('div');
++ calm.api.watch(element('video'), calmBox, { key: 'x' });
++ assert.ok(calmBox.children.length > 0, 'Calm keeps the glow');
const withGlow = element('div');
load().api.watch(element('video'), withGlow, { key: 'y' });
assert.strictEqual(withGlow.children[0] && withGlow.children[0].className, 'player-shape-glow');