feat(settings): reduce animations switch

This commit is contained in:
lobo committed 2026-10-01 15:09:25 +02:00
1 parent fa0857f55d
commit 2ec5833f29
9 files changed
+88 -12

No files matched your search

+2
View File
@@ -89,6 +89,8 @@
}, },
"WIDE_LAYOUT": "Breites Layout", "WIDE_LAYOUT": "Breites Layout",
"WIDE_LAYOUT_HINT": "Nutzt mehr Platz auf breiten Bildschirmen.", "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": "Hintergrund",
"BACKGROUND_HINT": "Öffne ein Spiel in deiner Bibliothek und wähle „Als Hintergrund“.", "BACKGROUND_HINT": "Öffne ein Spiel in deiner Bibliothek und wähle „Als Hintergrund“.",
"BACKGROUND_REMOVE": "Hintergrund entfernen", "BACKGROUND_REMOVE": "Hintergrund entfernen",
+2
View File
@@ -89,6 +89,8 @@
}, },
"WIDE_LAYOUT": "Wide layout", "WIDE_LAYOUT": "Wide layout",
"WIDE_LAYOUT_HINT": "Uses more of a wide screen.", "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": "Background",
"BACKGROUND_HINT": "Open a game in your library and choose \"Use as background\".", "BACKGROUND_HINT": "Open a game in your library and choose \"Use as background\".",
"BACKGROUND_REMOVE": "Remove background", "BACKGROUND_REMOVE": "Remove background",
+8
View File
@@ -31,6 +31,14 @@ export default function AppearanceCard() {
<Switch id="wideLayout" checked={appearance.wideLayout} <Switch id="wideLayout" checked={appearance.wideLayout}
onCheckedChange={(wideLayout) => saveAppearance({wideLayout})}/> onCheckedChange={(wideLayout) => saveAppearance({wideLayout})}/>
</div> </div>
<div className="flex items-center justify-between gap-4">
<div className="space-y-1">
<Label htmlFor="reduceMotion">{t('SETTINGS.REDUCE_MOTION')}</Label>
<p className="text-xs text-muted-foreground">{t('SETTINGS.REDUCE_MOTION_HINT')}</p>
</div>
<Switch id="reduceMotion" checked={appearance.reduceMotion}
onCheckedChange={(reduceMotion) => saveAppearance({reduceMotion})}/>
</div>
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div className="space-y-1"> <div className="space-y-1">
<Label id="background-label">{t('SETTINGS.BACKGROUND')}</Label> <Label id="background-label">{t('SETTINGS.BACKGROUND')}</Label>
+2 -3
View File
@@ -1,6 +1,7 @@
import {useEffect, useRef, useState} from 'react'; import {useEffect, useRef, useState} from 'react';
import {LOGO_COLORS} from './Logo.jsx'; import {LOGO_COLORS} from './Logo.jsx';
import {reduceMotion} from '../utils/appearance.js';
const DURATION_MS = 2800; const DURATION_MS = 2800;
const PIECES = {small: 70, big: 240}; 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. // 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 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. // The logo colors of the chosen accent and the accent itself.
function colors() { function colors() {
const style = getComputedStyle(document.documentElement); const style = getComputedStyle(document.documentElement);
@@ -48,7 +47,7 @@ function pieces(size, width, height) {
*/ */
export default function Confetti({size, onDone}) { export default function Confetti({size, onDone}) {
const canvasRef = useRef(null); 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. // 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); const onDoneRef = useRef(onDone);
onDoneRef.current = onDone; onDoneRef.current = onDone;
+9
View File
@@ -34,4 +34,13 @@ describe('Confetti', () => {
expect(container.querySelector('canvas')).toBeNull(); 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(<Confetti size="small"/>);
document.documentElement.removeAttribute('data-reduce-motion');
expect(container.querySelector('canvas')).toBeNull();
});
}); });
+3 -3
View File
@@ -54,7 +54,7 @@ describe('useSaveAppearance', () => {
const [url, request] = fetch.mock.calls[0]; const [url, request] = fetch.mock.calls[0];
expect(url).toContain('/api/users/settings/appearance'); expect(url).toContain('/api/users/settings/appearance');
expect(request.method).toBe('PUT'); 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 () => { 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 () => { 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; let answerMe;
fetchRoutes({ fetchRoutes({
[PUT]: json(204), [PUT]: json(204),
@@ -206,7 +206,7 @@ describe('useSaveAppearance', () => {
}); });
expect(result.current.preferences.appearance.corners).toBe('round'); 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 () => { it('sends nothing for a change that waited for the load when a reset came first', async () => {
+10 -1
View File
@@ -187,11 +187,20 @@ describe('SettingsPage appearance', () => {
fireEvent.click(appearanceCard().getByRole('button', {name: 'SETTINGS.CORNER_OPTIONS.ROUND'})); fireEvent.click(appearanceCard().getByRole('button', {name: 'SETTINGS.CORNER_OPTIONS.ROUND'}));
await waitFor(() => expect(appearancePuts()).toHaveLength(2)); 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.GREEN'}).getAttribute('aria-pressed')).toBe('true');
expect(appearanceCard().getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.VIOLET'}).getAttribute('aria-pressed')).toBe('false'); 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 () => { it('removes the background', async () => {
localStorage.setItem('tbp_appearance', JSON.stringify({backgroundAppid: 620})); localStorage.setItem('tbp_appearance', JSON.stringify({backgroundAppid: 620}));
renderPage(profile()); renderPage(profile());
+8
View File
@@ -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. // bannerAppid null: the dashboard banner shows the game played most in the last two weeks.
export const DEFAULT_APPEARANCE = { export const DEFAULT_APPEARANCE = {
accent: 'violet', corners: 'default', wideLayout: false, backgroundAppid: null, bannerAppid: null, showBanner: true, 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); export const appid = (value) => (Number.isInteger(value) && value > 0 ? value : null);
@@ -22,6 +23,7 @@ export const normalizeAppearance = (value) => ({
backgroundAppid: appid(value?.backgroundAppid), backgroundAppid: appid(value?.backgroundAppid),
bannerAppid: appid(value?.bannerAppid), bannerAppid: appid(value?.bannerAppid),
showBanner: value?.showBanner !== false, 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) => { export const applyAppearance = (appearance, accentOverride = null, root = document.documentElement) => {
root.dataset.accent = accentOverride ?? appearance.accent; root.dataset.accent = accentOverride ?? appearance.accent;
root.dataset.corners = appearance.corners; 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. * PUT /api/users/settings/appearance on Auth. The caller handles the response status.
*/ */
+44 -5
View File
@@ -1,22 +1,23 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import {afterEach, describe, expect, it} from 'vitest'; import {afterEach, describe, expect, it, vi} from 'vitest';
import { import {
applyAppearance, applyAppearance,
cacheAppearance, cacheAppearance,
DEFAULT_APPEARANCE, DEFAULT_APPEARANCE,
normalizeAppearance, normalizeAppearance,
readCachedAppearance, readCachedAppearance,
reduceMotion,
} from './appearance.js'; } from './appearance.js';
describe('normalizeAppearance', () => { describe('normalizeAppearance', () => {
it('keeps known values', () => { 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); expect(normalizeAppearance(appearance)).toEqual(appearance);
}); });
it('replaces unknown or missing values with the defaults', () => { 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); .toEqual(DEFAULT_APPEARANCE);
expect(normalizeAppearance(null)).toEqual(DEFAULT_APPEARANCE); expect(normalizeAppearance(null)).toEqual(DEFAULT_APPEARANCE);
expect(normalizeAppearance({backgroundAppid: 1.5})).toEqual(DEFAULT_APPEARANCE); expect(normalizeAppearance({backgroundAppid: 1.5})).toEqual(DEFAULT_APPEARANCE);
@@ -27,9 +28,9 @@ describe('appearance cache', () => {
afterEach(() => localStorage.clear()); afterEach(() => localStorage.clear());
it('reads back what was cached', () => { 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', () => { it('falls back to the defaults for a broken or empty cache', () => {
@@ -58,3 +59,41 @@ describe('applyAppearance', () => {
expect(root.dataset.accent).toBe('gold'); 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);
});
});