From 2ec5833f29f06814ce6fa27f56cbbdb9668be0f5 Mon Sep 17 00:00:00 2001 From: lobo Date: Thu, 1 Oct 2026 15:09:25 +0200 Subject: [PATCH] feat(settings): reduce animations switch --- public/i18n/de.json | 2 ++ public/i18n/en.json | 2 ++ src/components/AppearanceCard.jsx | 8 +++++ src/components/Confetti.jsx | 5 ++- src/components/Confetti.test.jsx | 9 +++++ src/hooks/useSaveAppearance.test.jsx | 6 ++-- src/pages/SettingsPage.test.jsx | 11 ++++++- src/utils/appearance.js | 8 +++++ src/utils/appearance.test.js | 49 +++++++++++++++++++++++++--- 9 files changed, 88 insertions(+), 12 deletions(-) diff --git a/public/i18n/de.json b/public/i18n/de.json index 8e8e6a3..37c5e40 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -89,6 +89,8 @@ }, "WIDE_LAYOUT": "Breites Layout", "WIDE_LAYOUT_HINT": "Nutzt mehr Platz auf breiten Bildschirmen.", + "REDUCE_MOTION": "Animationen reduzieren", + "REDUCE_MOTION_HINT": "Shop-Effekte und Konfetti stehen still, auch auf fremden Profilen.", "BACKGROUND": "Hintergrund", "BACKGROUND_HINT": "Öffne ein Spiel in deiner Bibliothek und wähle „Als Hintergrund“.", "BACKGROUND_REMOVE": "Hintergrund entfernen", diff --git a/public/i18n/en.json b/public/i18n/en.json index b3da899..cfc7af6 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -89,6 +89,8 @@ }, "WIDE_LAYOUT": "Wide layout", "WIDE_LAYOUT_HINT": "Uses more of a wide screen.", + "REDUCE_MOTION": "Reduce animations", + "REDUCE_MOTION_HINT": "Shop effects and confetti stand still, also on other profiles.", "BACKGROUND": "Background", "BACKGROUND_HINT": "Open a game in your library and choose \"Use as background\".", "BACKGROUND_REMOVE": "Remove background", diff --git a/src/components/AppearanceCard.jsx b/src/components/AppearanceCard.jsx index 238bae3..d50426b 100644 --- a/src/components/AppearanceCard.jsx +++ b/src/components/AppearanceCard.jsx @@ -31,6 +31,14 @@ export default function AppearanceCard() { saveAppearance({wideLayout})}/> +
+
+ +

{t('SETTINGS.REDUCE_MOTION_HINT')}

+
+ saveAppearance({reduceMotion})}/> +
diff --git a/src/components/Confetti.jsx b/src/components/Confetti.jsx index 90ddc7b..a8c4344 100644 --- a/src/components/Confetti.jsx +++ b/src/components/Confetti.jsx @@ -1,6 +1,7 @@ import {useEffect, useRef, useState} from 'react'; import {LOGO_COLORS} from './Logo.jsx'; +import {reduceMotion} from '../utils/appearance.js'; const DURATION_MS = 2800; const PIECES = {small: 70, big: 240}; @@ -8,8 +9,6 @@ const GRAVITY = 0.12; // The speeds are per frame at 60 Hz; a faster screen moves the same distance in the same time. const FRAME_MS = 1000 / 60; -const reducedMotion = () => window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false; - // The logo colors of the chosen accent and the accent itself. function colors() { const style = getComputedStyle(document.documentElement); @@ -48,7 +47,7 @@ function pieces(size, width, height) { */ export default function Confetti({size, onDone}) { const canvasRef = useRef(null); - const [reduce] = useState(reducedMotion); + const [reduce] = useState(reduceMotion); // In a ref: a new function from a re-render must not restart the animation, and the latest one runs at the end. const onDoneRef = useRef(onDone); onDoneRef.current = onDone; diff --git a/src/components/Confetti.test.jsx b/src/components/Confetti.test.jsx index 3c5787a..af9d7b9 100644 --- a/src/components/Confetti.test.jsx +++ b/src/components/Confetti.test.jsx @@ -34,4 +34,13 @@ describe('Confetti', () => { expect(container.querySelector('canvas')).toBeNull(); }); + + it('draws nothing when the reduce animations setting is on', () => { + reducedMotion(false); + document.documentElement.setAttribute('data-reduce-motion', ''); + const {container} = render(); + document.documentElement.removeAttribute('data-reduce-motion'); + + expect(container.querySelector('canvas')).toBeNull(); + }); }); diff --git a/src/hooks/useSaveAppearance.test.jsx b/src/hooks/useSaveAppearance.test.jsx index 552730e..7ea60f7 100644 --- a/src/hooks/useSaveAppearance.test.jsx +++ b/src/hooks/useSaveAppearance.test.jsx @@ -54,7 +54,7 @@ describe('useSaveAppearance', () => { const [url, request] = fetch.mock.calls[0]; expect(url).toContain('/api/users/settings/appearance'); expect(request.method).toBe('PUT'); - expect(JSON.parse(request.body)).toEqual({accent: 'blue', corners: 'default', wideLayout: false, backgroundAppid: null, bannerAppid: null, showBanner: true}); + expect(JSON.parse(request.body)).toEqual({accent: 'blue', corners: 'default', wideLayout: false, backgroundAppid: null, bannerAppid: null, showBanner: true, reduceMotion: false}); }); it('puts the old value back when Auth refuses', async () => { @@ -119,7 +119,7 @@ describe('useSaveAppearance', () => { }); it('waits for a running load from Auth, then keeps its values for the fields not changed here', async () => { - const fromServer = {accent: 'green', corners: 'default', wideLayout: false, backgroundAppid: null, bannerAppid: 620, showBanner: false}; + const fromServer = {accent: 'green', corners: 'default', wideLayout: false, backgroundAppid: null, bannerAppid: 620, showBanner: false, reduceMotion: false}; let answerMe; fetchRoutes({ [PUT]: json(204), @@ -206,7 +206,7 @@ describe('useSaveAppearance', () => { }); expect(result.current.preferences.appearance.corners).toBe('round'); - expect(puts()[2]).toEqual({accent: 'green', corners: 'round', wideLayout: true, backgroundAppid: null, bannerAppid: null, showBanner: true}); + expect(puts()[2]).toEqual({accent: 'green', corners: 'round', wideLayout: true, backgroundAppid: null, bannerAppid: null, showBanner: true, reduceMotion: false}); }); it('sends nothing for a change that waited for the load when a reset came first', async () => { diff --git a/src/pages/SettingsPage.test.jsx b/src/pages/SettingsPage.test.jsx index 88c5139..95d5d7a 100644 --- a/src/pages/SettingsPage.test.jsx +++ b/src/pages/SettingsPage.test.jsx @@ -187,11 +187,20 @@ describe('SettingsPage appearance', () => { fireEvent.click(appearanceCard().getByRole('button', {name: 'SETTINGS.CORNER_OPTIONS.ROUND'})); await waitFor(() => expect(appearancePuts()).toHaveLength(2)); - expect(appearancePuts()[1]).toEqual({accent: 'green', corners: 'round', wideLayout: false, backgroundAppid: null, bannerAppid: null, showBanner: true}); + expect(appearancePuts()[1]).toEqual({accent: 'green', corners: 'round', wideLayout: false, backgroundAppid: null, bannerAppid: null, showBanner: true, reduceMotion: false}); expect(appearanceCard().getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.GREEN'}).getAttribute('aria-pressed')).toBe('true'); expect(appearanceCard().getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.VIOLET'}).getAttribute('aria-pressed')).toBe('false'); }); + it('saves the reduce animations switch', async () => { + renderPage(profile()); + + fireEvent.click(await screen.findByRole('switch', {name: 'SETTINGS.REDUCE_MOTION'})); + + await waitFor(() => expect(appearancePuts()).toHaveLength(1)); + expect(appearancePuts()[0].reduceMotion).toBe(true); + }); + it('removes the background', async () => { localStorage.setItem('tbp_appearance', JSON.stringify({backgroundAppid: 620})); renderPage(profile()); diff --git a/src/utils/appearance.js b/src/utils/appearance.js index 7a5170d..37b50d0 100644 --- a/src/utils/appearance.js +++ b/src/utils/appearance.js @@ -9,6 +9,7 @@ export const CORNERS = ['sharp', 'default', 'round']; // bannerAppid null: the dashboard banner shows the game played most in the last two weeks. export const DEFAULT_APPEARANCE = { accent: 'violet', corners: 'default', wideLayout: false, backgroundAppid: null, bannerAppid: null, showBanner: true, + reduceMotion: false, }; export const appid = (value) => (Number.isInteger(value) && value > 0 ? value : null); @@ -22,6 +23,7 @@ export const normalizeAppearance = (value) => ({ backgroundAppid: appid(value?.backgroundAppid), bannerAppid: appid(value?.bannerAppid), showBanner: value?.showBanner !== false, + reduceMotion: value?.reduceMotion === true, }); /** @@ -41,8 +43,14 @@ export const cacheAppearance = (appearance) => storage.set(STORAGE_KEY, JSON.str export const applyAppearance = (appearance, accentOverride = null, root = document.documentElement) => { root.dataset.accent = accentOverride ?? appearance.accent; root.dataset.corners = appearance.corners; + root.toggleAttribute('data-reduce-motion', appearance.reduceMotion === true); }; +/** True when the system asks for reduced motion or the "Reduce animations" setting is on. */ +export const reduceMotion = () => + (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false) + || document.documentElement.hasAttribute('data-reduce-motion'); + /** * PUT /api/users/settings/appearance on Auth. The caller handles the response status. */ diff --git a/src/utils/appearance.test.js b/src/utils/appearance.test.js index cfa6020..e38c215 100644 --- a/src/utils/appearance.test.js +++ b/src/utils/appearance.test.js @@ -1,22 +1,23 @@ // @vitest-environment jsdom -import {afterEach, describe, expect, it} from 'vitest'; +import {afterEach, describe, expect, it, vi} from 'vitest'; import { applyAppearance, cacheAppearance, DEFAULT_APPEARANCE, normalizeAppearance, readCachedAppearance, + reduceMotion, } from './appearance.js'; describe('normalizeAppearance', () => { it('keeps known values', () => { - const appearance = {accent: 'blue', corners: 'round', wideLayout: true, backgroundAppid: 620, bannerAppid: 1086940, showBanner: false}; + const appearance = {accent: 'blue', corners: 'round', wideLayout: true, backgroundAppid: 620, bannerAppid: 1086940, showBanner: false, reduceMotion: true}; expect(normalizeAppearance(appearance)).toEqual(appearance); }); it('replaces unknown or missing values with the defaults', () => { - expect(normalizeAppearance({accent: 'red;x', corners: 'pointy', wideLayout: 'yes', backgroundAppid: -1, bannerAppid: '620', showBanner: 'no'})) + expect(normalizeAppearance({accent: 'red;x', corners: 'pointy', wideLayout: 'yes', backgroundAppid: -1, bannerAppid: '620', showBanner: 'no', reduceMotion: 'yes'})) .toEqual(DEFAULT_APPEARANCE); expect(normalizeAppearance(null)).toEqual(DEFAULT_APPEARANCE); expect(normalizeAppearance({backgroundAppid: 1.5})).toEqual(DEFAULT_APPEARANCE); @@ -27,9 +28,9 @@ describe('appearance cache', () => { afterEach(() => localStorage.clear()); it('reads back what was cached', () => { - cacheAppearance({accent: 'green', corners: 'sharp', wideLayout: false, backgroundAppid: null, bannerAppid: 620, showBanner: true}); + cacheAppearance({accent: 'green', corners: 'sharp', wideLayout: false, backgroundAppid: null, bannerAppid: 620, showBanner: true, reduceMotion: false}); - expect(readCachedAppearance()).toEqual({accent: 'green', corners: 'sharp', wideLayout: false, backgroundAppid: null, bannerAppid: 620, showBanner: true}); + expect(readCachedAppearance()).toEqual({accent: 'green', corners: 'sharp', wideLayout: false, backgroundAppid: null, bannerAppid: 620, showBanner: true, reduceMotion: false}); }); it('falls back to the defaults for a broken or empty cache', () => { @@ -58,3 +59,41 @@ describe('applyAppearance', () => { expect(root.dataset.accent).toBe('gold'); }); }); + +describe('reduce motion', () => { + afterEach(() => { + document.documentElement.removeAttribute('data-reduce-motion'); + vi.unstubAllGlobals(); + }); + + const mediaQuery = (matches) => vi.stubGlobal('matchMedia', () => ({matches})); + + it('is set on the root by applyAppearance and removed again', () => { + const root = document.createElement('html'); + + applyAppearance({accent: 'blue', corners: 'default', reduceMotion: true}, null, root); + expect(root.hasAttribute('data-reduce-motion')).toBe(true); + + applyAppearance({accent: 'blue', corners: 'default', reduceMotion: false}, null, root); + expect(root.hasAttribute('data-reduce-motion')).toBe(false); + }); + + it('is on for the system media query', () => { + mediaQuery(true); + + expect(reduceMotion()).toBe(true); + }); + + it('is on for the setting', () => { + mediaQuery(false); + document.documentElement.setAttribute('data-reduce-motion', ''); + + expect(reduceMotion()).toBe(true); + }); + + it('is off for neither', () => { + mediaQuery(false); + + expect(reduceMotion()).toBe(false); + }); +});