dorfteich/apps/web/e2e/theme-accent.spec.ts
Claude Fable 5 83a2fe470e
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 4m41s
CI / Build container images (pull_request) Successful in 4m4s
CI / Auth e2e pack (pull_request) Successful in 11m40s
CI / Import/export fidelity gate (pull_request) Successful in 52s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
CI / Lint, typecheck, test (push) Has been cancelled
#184: user accent theming — presets and free color as one mechanism
apply-theme.ts derives BOTH modes' accent tokens from the stored choice
(ui.theme.accent: preset id or {custom:'#hex'}) and writes them as
<style id="user-theme"> with :root:root + :root:root[data-theme='dark']
blocks — the doubled :root beats tokens.css regardless of document
order, since theme-init.js injects the ui.theme.css cache during <head>
parsing, before the bundle styles. The default preset means NO override
(hand-tuned tokens.css values stay). main.tsx re-derives from the
choice at startup, healing stale caches after app updates.

Settings: accent radiogroup inside the Appearance section (visible
names, color never the only cue) with per-mode preview swatches on
each mode's canonical background, plus a custom color input; i18n
de+en. The second fieldset made bare .settings-fieldset locators
ambiguous — theme specs now scope via input[name] (fence stays).

Tests: apply-theme unit pack, BASE_PALETTE<->tokens.css drift fence in
theme-contrast.test.ts, e2e theme-accent.spec (instant apply, pre-paint
persistence, default removes override, axe smoke with garish yellow in
both modes). ADR 0018 amendment documents the stage-B details.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QRtCnB3uLdQtFmvp9HXcRX
2026-07-29 08:59:27 +02:00

98 lines
4.0 KiB
TypeScript

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();
});