diff --git a/apps/web/e2e/theme-accent.spec.ts b/apps/web/e2e/theme-accent.spec.ts new file mode 100644 index 0000000..f1a06cc --- /dev/null +++ b/apps/web/e2e/theme-accent.spec.ts @@ -0,0 +1,97 @@ +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(); +}); diff --git a/apps/web/e2e/theme.spec.ts b/apps/web/e2e/theme.spec.ts index 44beb5b..c786cf6 100644 --- a/apps/web/e2e/theme.spec.ts +++ b/apps/web/e2e/theme.spec.ts @@ -23,7 +23,7 @@ test('theme choice applies instantly, persists, and system mode follows the OS', const page = await context.newPage(); await page.emulateMedia({ colorScheme: 'light' }); await page.goto('/settings'); - await page.locator('.settings-fieldset').waitFor(); + await page.locator('input[name="theme-mode"]').first().waitFor(); // Default: System, auf einem hellen OS also light. await expect(radio(page, 'system')).toBeChecked(); @@ -40,7 +40,7 @@ test('theme choice applies instantly, persists, and system mode follows the OS', // Persistenz: Wahl und Theme überleben den Reload (theme-init.js liest // denselben localStorage-Key vor dem ersten Paint). await page.reload(); - await page.locator('.settings-fieldset').waitFor(); + await page.locator('input[name="theme-mode"]').first().waitFor(); await expect(radio(page, 'dark')).toBeChecked(); expect(await effectiveTheme(page)).toBe('dark'); expect(await page.evaluate(() => window.localStorage.getItem('ui.theme.mode'))).toBe('"dark"'); @@ -72,7 +72,7 @@ test('the top-bar toggle cycles the mode and stays in sync with the radios', asy const page = await context.newPage(); await page.emulateMedia({ colorScheme: 'light' }); await page.goto('/settings'); - await page.locator('.settings-fieldset').waitFor(); + await page.locator('input[name="theme-mode"]').first().waitFor(); const toggle = page.locator('.topbar__theme'); // Default System → ein Klick zykelt in Radio-Reihenfolge weiter zu Hell, @@ -97,7 +97,7 @@ test('the top-bar toggle cycles the mode and stays in sync with the radios', asy // Persistenz wie bei den Radios (gleicher localStorage-Key). await toggle.click(); // → light await page.reload(); - await page.locator('.settings-fieldset').waitFor(); + await page.locator('input[name="theme-mode"]').first().waitFor(); await expect(radio(page, 'light')).toBeChecked(); expect(await effectiveTheme(page)).toBe('light'); diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index e8aa9d1..9ec6b27 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -8,6 +8,7 @@ import { AuthProvider } from './auth/auth-context'; import { ToastProvider } from './components/Toast'; import './i18n'; import { ApiError } from './lib/api'; +import { initUserTheme } from './theme/apply-theme'; import { applyTheme, initSystemThemeListener, readStoredThemeMode } from './theme/theme'; import './styles/tokens.css'; import './styles/base.css'; @@ -17,6 +18,9 @@ import './styles/base.css'; // listener keeps 'system' users in sync with live OS scheme changes. applyTheme(readStoredThemeMode()); initSystemThemeListener(); +// Accent choice (#184): re-derive from the stored choice — heals a stale +// ui.theme.css cache after app updates; theme-init.js only bridges paint. +initUserTheme(); const queryClient = new QueryClient({ defaultOptions: { diff --git a/apps/web/src/pages/SettingsPage.tsx b/apps/web/src/pages/SettingsPage.tsx index 5f89205..6a29ddb 100644 --- a/apps/web/src/pages/SettingsPage.tsx +++ b/apps/web/src/pages/SettingsPage.tsx @@ -1,5 +1,12 @@ import { zodResolver } from '@hookform/resolvers/zod'; -import { changePasswordInputSchema, updateProfileInputSchema } from '@dorfteich/shared'; +import { + BASE_PALETTE, + changePasswordInputSchema, + DEFAULT_THEME_PRESET_ID, + deriveAccentTokens, + THEME_PRESETS, + updateProfileInputSchema, +} from '@dorfteich/shared'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useState } from 'react'; import { useForm } from 'react-hook-form'; @@ -17,6 +24,7 @@ import { WatchesSection } from '../watches/WatchesSection'; import { useDocumentTitle } from '../lib/use-document-title'; import { SINGLE_KEY_SHORTCUTS_KEY } from '../lib/single-key-shortcuts'; import { usePersistentState } from '../lib/use-persistent-state'; +import { useAccentChoice } from '../theme/apply-theme'; import { useThemeMode, type ThemeMode } from '../theme/theme'; interface SessionView { id: string; @@ -78,10 +86,95 @@ function AppearanceSection(): React.JSX.Element { ))}
{t('settings:appearance.hint')}
+