/** * Calm Reduced Motion defaults + Reduced Effects decorative-rail quieting. * Related to #1614; correctness only (not GPU %). */ import { test, expect, type Page, type Locator } from '@playwright/test'; import { loginAs, waitForStacksLoaded } from './helpers'; const STORAGE_KEY = 'sencho.appearance.theme'; const LEGACY_KEY = 'sencho-theme'; const SIGNATURE_STATE = { theme: 'dim', accent: 'cyan', borderBoost: 0, glow: 0.16, contrast: 0, uiFont: 'Geist', monoFont: 'Geist Mono', typeScale: 1, visualStyle: 'signature', headingStyle: 'signature', chartStyle: 'signature', reducedEffects: false, reducedMotion: false, readability: false, } as const; async function seedAppearance(page: Page, state: Record | null) { await page.addInitScript( ({ key, legacy, payload, skipKey }) => { // Tests that mutate appearance mid-run set this so reload is not overwritten. if (sessionStorage.getItem(skipKey) === '1') return; if (payload === null) { localStorage.removeItem(key); localStorage.removeItem(legacy); } else { localStorage.setItem(key, JSON.stringify(payload)); localStorage.removeItem(legacy); } }, { key: STORAGE_KEY, legacy: LEGACY_KEY, payload: state, skipKey: 'sencho.e2e.skipThemeSeed' }, ); } async function openAppearance(page: Page) { await page.getByRole('button', { name: /profile/i }).click(); await page.getByRole('button', { name: 'Settings', exact: true }).click(); await page.getByRole('button', { name: 'Appearance', exact: true }).click(); await expect(page.getByText('Motion & effects')).toBeVisible(); } async function railAnim(locator: Locator): Promise<{ duration: string; iterations: string; name: string }> { return locator.evaluate((el) => { const cs = getComputedStyle(el); return { duration: cs.animationDuration, iterations: cs.animationIterationCount, name: cs.animationName, }; }); } function expectRailsStatic(info: { duration: string; iterations: string; name: string }) { const stopped = info.name === 'none' || info.duration === '0s' || info.duration === '0.01ms' || info.iterations === '1'; expect(stopped).toBeTruthy(); } function expectRailsRunning(info: { duration: string; iterations: string; name: string }) { expect(info.name === 'none').toBeFalsy(); expect(info.duration === '0s' || info.duration === '0.01ms').toBeFalsy(); } async function assertRails(page: Page, mode: 'static' | 'running') { const glow = page.locator('.masthead-rail-glow'); const shimmer = page.locator('.masthead-rail-shimmer'); const glowCount = await glow.count(); const shimmerCount = await shimmer.count(); expect(glowCount + shimmerCount).toBeGreaterThan(0); for (let i = 0; i < glowCount; i++) { const info = await railAnim(glow.nth(i)); if (mode === 'static') expectRailsStatic(info); else expectRailsRunning(info); } for (let i = 0; i < shimmerCount; i++) { const info = await railAnim(shimmer.nth(i)); if (mode === 'static') expectRailsStatic(info); else expectRailsRunning(info); } } test.describe('Calm reduced motion defaults', () => { test('fresh load applies data-motion by DOMContentLoaded', async ({ page }) => { await seedAppearance(page, null); await page.addInitScript(() => { document.addEventListener( 'DOMContentLoaded', () => { (window as unknown as { __snMotionAtDCL?: string | null }).__snMotionAtDCL = document.documentElement.getAttribute('data-motion'); }, { once: true }, ); }); await page.goto('/'); const atDcl = await page.evaluate( () => (window as unknown as { __snMotionAtDCL?: string | null }).__snMotionAtDCL ?? null, ); expect(atDcl).toBe('reduced'); }); test('returning Signature object missing reducedMotion has no data-motion', async ({ page }) => { const { reducedMotion: _omit, ...withoutMotion } = SIGNATURE_STATE; await seedAppearance(page, withoutMotion); await loginAs(page); await waitForStacksLoaded(page); await expect(page.locator('html')).not.toHaveAttribute('data-motion', 'reduced'); }); test('stored reducedMotion true and false survive reload', async ({ page }) => { await seedAppearance(page, { ...SIGNATURE_STATE, reducedMotion: true }); await loginAs(page); await waitForStacksLoaded(page); await expect(page.locator('html')).toHaveAttribute('data-motion', 'reduced'); await page.reload(); await waitForStacksLoaded(page); await expect(page.locator('html')).toHaveAttribute('data-motion', 'reduced'); // Stop init-script reseeding so the false write is what theme-init reads next. await page.evaluate((key) => { sessionStorage.setItem('sencho.e2e.skipThemeSeed', '1'); const raw = localStorage.getItem(key); const parsed = raw ? JSON.parse(raw) : {}; parsed.reducedMotion = false; localStorage.setItem(key, JSON.stringify(parsed)); }, STORAGE_KEY); await page.reload(); await waitForStacksLoaded(page); await expect(page.locator('html')).not.toHaveAttribute('data-motion', 'reduced'); }); test('Signature to Calm and back writes both effects and motion', async ({ page }) => { await seedAppearance(page, SIGNATURE_STATE); await loginAs(page); await waitForStacksLoaded(page); await openAppearance(page); await page.getByRole('button', { name: /readable default|Calm/i }).click(); await expect(page.locator('html')).toHaveAttribute('data-effects', 'reduced'); await expect(page.locator('html')).toHaveAttribute('data-motion', 'reduced'); await page.getByRole('button', { name: /Today's look|Signature/i }).click(); await expect(page.locator('html')).not.toHaveAttribute('data-effects', 'reduced'); await expect(page.locator('html')).not.toHaveAttribute('data-motion', 'reduced'); }); test('Calm card stays selected when Motion is toggled off; rails stay static', async ({ page }) => { await seedAppearance(page, SIGNATURE_STATE); await loginAs(page); await waitForStacksLoaded(page); await openAppearance(page); await page.getByRole('button', { name: /readable default|Calm/i }).click(); await page.getByRole('switch', { name: 'Reduced motion' }).click(); await expect(page.locator('html')).not.toHaveAttribute('data-motion', 'reduced'); await expect(page.getByRole('button', { name: /readable default/i })).toHaveAttribute('aria-pressed', 'true'); await assertRails(page, 'static'); }); test('Signature full effects with Motion off keeps rails running', async ({ page }) => { await page.emulateMedia({ reducedMotion: 'no-preference' }); await seedAppearance(page, SIGNATURE_STATE); await loginAs(page); await waitForStacksLoaded(page); await openAppearance(page); await expect(page.locator('html')).not.toHaveAttribute('data-effects', 'reduced'); await expect(page.locator('html')).not.toHaveAttribute('data-motion', 'reduced'); await assertRails(page, 'running'); }); test('manual Reduced effects under custom style staticizes rails without enabling Motion', async ({ page }) => { await page.emulateMedia({ reducedMotion: 'no-preference' }); await seedAppearance(page, SIGNATURE_STATE); await loginAs(page); await waitForStacksLoaded(page); await openAppearance(page); await page.getByRole('radio', { name: 'Heat' }).click(); await page.getByRole('switch', { name: 'Reduced effects' }).click(); await expect(page.locator('html')).toHaveAttribute('data-effects', 'reduced'); await expect(page.locator('html')).not.toHaveAttribute('data-motion', 'reduced'); await assertRails(page, 'static'); }); test('Readability with Motion off staticizes rails and leaves Motion off', async ({ page }) => { await seedAppearance(page, SIGNATURE_STATE); await loginAs(page); await waitForStacksLoaded(page); await openAppearance(page); await page.getByRole('switch', { name: 'Readability mode' }).click(); await expect(page.locator('html')).toHaveAttribute('data-effects', 'reduced'); await expect(page.locator('html')).not.toHaveAttribute('data-motion', 'reduced'); await assertRails(page, 'static'); }); });