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

+8
View File
@@ -31,6 +31,14 @@ export default function AppearanceCard() {
<Switch id="wideLayout" checked={appearance.wideLayout}
onCheckedChange={(wideLayout) => saveAppearance({wideLayout})}/>
</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="space-y-1">
<Label id="background-label">{t('SETTINGS.BACKGROUND')}</Label>
+2 -3
View File
@@ -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;
+9
View File
@@ -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(<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];
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 () => {
+10 -1
View File
@@ -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());
+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.
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.
*/
+44 -5
View File
@@ -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);
});
});