From 60e1ab210c3abab4e92a0fe5b4eade8bc4eac574 Mon Sep 17 00:00:00 2001 From: "Jan C. Borchardt" <925062+jancborchardt@users.noreply.github.com> Date: Sat, 27 Jun 2026 01:27:37 +0200 Subject: [PATCH] feat: Add global support for prefers-reduced-motion Assisted-by: ClaudeCode:claude-opus-4-8 Signed-off-by: Jan C. Borchardt <925062+jancborchardt@users.noreply.github.com> --- apps/theming/css/default.css | 6 ++++++ apps/theming/lib/Controller/ThemingController.php | 12 ++++++++++-- apps/theming/lib/Themes/DefaultTheme.php | 4 +++- apps/theming/tests/Themes/DefaultThemeTest.php | 6 +++++- 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/apps/theming/css/default.css b/apps/theming/css/default.css index 7e66c54449f2c..b2bbca1904c42 100644 --- a/apps/theming/css/default.css +++ b/apps/theming/css/default.css @@ -142,3 +142,9 @@ --color-background-plain-text: #ffffff; --image-background: url('/apps/theming/img/background/jo-myoung-hee-fluid.webp'); } +@media (prefers-reduced-motion: reduce) { + :root { + --animation-quick: 1ms; + --animation-slow: 1ms; + } +} diff --git a/apps/theming/lib/Controller/ThemingController.php b/apps/theming/lib/Controller/ThemingController.php index 227163d38f0d3..32324c5b04127 100644 --- a/apps/theming/lib/Controller/ThemingController.php +++ b/apps/theming/lib/Controller/ThemingController.php @@ -415,9 +415,17 @@ public function getThemeStylesheet(string $themeId, bool $plain = false, bool $w $variables .= "$variable:$value; "; }; + // Collapse the animation timing variables for users who requested reduced + // motion. Emitted right after the variables on the same selector so it + // overrides them in the cascade. We use 1ms rather than 0 so values read + // via `parseInt(...) || fallback` in JavaScript stay truthy and transition + // events still fire. + $reducedMotion = static fn (string $selector): string + => "@media (prefers-reduced-motion: reduce) { $selector { --animation-quick: 1ms; --animation-slow: 1ms; } } "; + // If plain is set, the browser decides of the css priority if ($plain) { - $css = ":root { $variables } " . $customCss; + $css = ":root { $variables } " . $reducedMotion(':root') . $customCss; } else { // If not set, we'll rely on the body class // We need to separate @-rules from normal selectors, as they can't be nested @@ -430,7 +438,7 @@ public function getThemeStylesheet(string $themeId, bool $plain = false, bool $w $atRulesCss = implode('', $atRules[0]); $scopedCss = preg_replace('/(@[^{]+{(?:[^{}]*|(?R))*})/', '', $customCssWithoutComments); - $css = "$atRulesCss [data-theme-$themeId] { $variables $scopedCss }"; + $css = "$atRulesCss [data-theme-$themeId] { $variables $scopedCss } " . $reducedMotion("[data-theme-$themeId]"); } try { diff --git a/apps/theming/lib/Themes/DefaultTheme.php b/apps/theming/lib/Themes/DefaultTheme.php index fa8ef65094456..7d22a7b395b53 100644 --- a/apps/theming/lib/Themes/DefaultTheme.php +++ b/apps/theming/lib/Themes/DefaultTheme.php @@ -212,7 +212,9 @@ public function getCSSVariables(): array { // 1.5 * font-size for accessibility '--default-line-height' => '1.5', - // TODO: support "(prefers-reduced-motion)" + // A "(prefers-reduced-motion)" media query collapses these to 1ms; + // see ThemingController::getThemeStylesheet() and the static fallback + // in apps/theming/css/default.css. '--animation-quick' => '100ms', '--animation-slow' => '300ms', diff --git a/apps/theming/tests/Themes/DefaultThemeTest.php b/apps/theming/tests/Themes/DefaultThemeTest.php index 415600a979fd4..c87536d50f62a 100644 --- a/apps/theming/tests/Themes/DefaultThemeTest.php +++ b/apps/theming/tests/Themes/DefaultThemeTest.php @@ -149,9 +149,13 @@ public function testThemindDisabledFallbackCss(): void { $fallbackCss = file_get_contents(__DIR__ . '/../../css/default.css'); // Remove comments $fallbackCss = preg_replace('/\s*\/\*[\s\S]*?\*\//m', '', $fallbackCss); + // The fallback also carries a prefers-reduced-motion override that zeroes + // the animation variables; it is not part of the theme variables, so drop + // any @media at-rules before comparing. + $fallbackCss = preg_replace('/@media[^{]*\{(?:[^{}]|\{[^{}]*\})*\}/', '', $fallbackCss); // Remove blank lines $fallbackCss = preg_replace('/\s*\n\n/', "\n", $fallbackCss); - $this->assertEquals($css, $fallbackCss); + $this->assertEquals(rtrim($css), rtrim($fallbackCss)); } }