import AxeBuilder from '@axe-core/playwright'; import { expect, test, type Page } from '@playwright/test'; import { contextForUser } from './helpers'; /** * Akzent-Theming (issue #184, ADR 0018 Stufe B): Presets und freie Farbe * laufen durch dieselbe Ableitung; die Wahl wirkt sofort, überlebt den * Reload (ui.theme.css-Cache via theme-init.js) und bleibt per Konstruktion * lesbar — der axe-Smoke prüft das exemplarisch mit einer grellen freien * Farbe in beiden Modi. Klassen-Hooks statt lokalisierter Texte. */ const BASE = process.env.E2E_BASE_URL ?? 'http://localhost:5173'; const accentRadio = (page: Page, value: string) => page.locator(`input[name="theme-accent"][value="${value}"]`); const effectiveAccent = (page: Page) => page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--color-accent').trim(), ); test('accent choice applies instantly, persists, and default removes the override', async ({ browser, }) => { const context = await contextForUser(browser, BASE, 'fixture-user'); const page = await context.newPage(); await page.emulateMedia({ colorScheme: 'light' }); await page.goto('/settings'); await page.locator('input[name="theme-accent"]').first().waitFor(); // Default: Teichgrün, kein Override-Style. await expect(accentRadio(page, 'pond-green')).toBeChecked(); expect(await effectiveAccent(page)).toBe('#2f6f4f'); expect(await page.locator('#user-theme').count()).toBe(0); // Preset: wirkt sofort über das user-theme-Style-Element. await accentRadio(page, 'lake-blue').check(); expect(await effectiveAccent(page)).toBe('#2b5f8f'); expect(await page.locator('#user-theme').count()).toBe(1); // Persistenz: theme-init.js injiziert den ui.theme.css-Cache pre-paint. await page.reload(); await page.locator('input[name="theme-accent"]').first().waitFor(); await expect(accentRadio(page, 'lake-blue')).toBeChecked(); expect(await effectiveAccent(page)).toBe('#2b5f8f'); // Der Akzent gilt je Modus abgeleitet — Dunkelmodus bekommt einen // helleren Wert als das Light-Preset (nie denselben Hex). await page.locator('.topbar__theme').click(); // system -> light await page.locator('.topbar__theme').click(); // light -> dark const darkAccent = await effectiveAccent(page); expect(darkAccent).not.toBe('#2b5f8f'); expect(darkAccent).toMatch(/^#[0-9a-f]{6}$/); // Zurück auf Standard: Override und Cache verschwinden. await page.locator('.topbar__theme').click(); // dark -> system await accentRadio(page, 'pond-green').check(); expect(await page.locator('#user-theme').count()).toBe(0); expect(await page.evaluate(() => window.localStorage.getItem('ui.theme.css'))).toBeNull(); await context.close(); }); test('a garish custom accent is derived readable — axe passes in both modes', async ({ browser, }) => { const context = await contextForUser(browser, BASE, 'fixture-user'); const page = await context.newPage(); await page.emulateMedia({ colorScheme: 'light' }); await page.goto('/settings'); await page.locator('input[name="theme-accent"]').first().waitFor(); // Grelles Gelb als freie Farbe: die Ableitung muss hell einen deutlich // dunkleren Wert liefern — nie den Roh-Hex. await page.locator('.accent-color-input').fill('#ffff00'); await expect(accentRadio(page, 'custom')).toBeChecked(); const lightAccent = await effectiveAccent(page); expect(lightAccent).not.toBe('#ffff00'); const TAGS = ['wcag2a', 'wcag21a', 'wcag2aa', 'wcag21aa']; for (const scheme of ['light', 'dark'] as const) { await page.emulateMedia({ colorScheme: scheme }); // Modus konkret machen: System folgt emulateMedia (theme.ts-Listener). await expect .poll(() => page.evaluate(() => document.documentElement.dataset.theme)) .toBe(scheme); const results = await new AxeBuilder({ page }).withTags(TAGS).analyze(); expect( results.violations.map((v) => ({ rule: v.id, help: v.help })), `axe mit grellem Akzent (${scheme})`, ).toEqual([]); } await context.close(); });