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')}

+ ); } +/** Per-mode preview of a derived accent. Decorative only — the visible + * preset/custom NAME carries the meaning, color is never the only cue + * (ADR 0017). The rings show each mode's canonical background. */ +function AccentSwatches({ light, dark }: { light: string; dark: string }): React.JSX.Element { + return ( + + + + + ); +} + +/** Akzentfarbe (issue #184, ADR 0018 stage B): presets and a free color as + * ONE mechanism — both run through deriveAccentTokens, so any pick stays + * readable by construction. Same section as the mode radios (the jump-nav + * fence pins the section count). */ +function AccentFieldset(): React.JSX.Element { + const { t } = useTranslation(); + const [choice, setChoice] = useAccentChoice(); + const isCustom = typeof choice === 'object'; + // The color input keeps the last custom pick while a preset is selected, + // so re-selecting "custom" restores it instead of jumping to a default. + const [customHex, setCustomHex] = useState(isCustom ? choice.custom : '#2f6f4f'); + + const derivedPair = (accent: string): { light: string; dark: string } => ({ + light: deriveAccentTokens(accent, 'light').accent, + dark: deriveAccentTokens(accent, 'dark').accent, + }); + + return ( +
+ {t('settings:appearance.accentLegend')} + {THEME_PRESETS.map((preset) => ( + + ))} +
+ setChoice({ custom: customHex })} + /> + + { + setCustomHex(event.target.value); + setChoice({ custom: event.target.value }); + }} + /> + {isCustom && } +
+

{t('settings:appearance.accentHint')}

+
+ ); +} + /** Bedienungs-Einstellungen (issue #170, WCAG 2.1.4): Einzeltasten-Kürzel * abschaltbar machen — lokale Geräte-Einstellung, kein Server-Zustand. */ function InteractionSection(): React.JSX.Element { diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index 00b8407..ea01521 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -3690,6 +3690,31 @@ ul[data-type='task_list'] li p:last-of-type { font-weight: 600; } +/* Accent picker (issue #184): the two dots preview the DERIVED accent per + mode; their ring (inline box-shadow) shows each mode's canonical + background, so the auto-adjustment is visible before choosing. */ +.accent-swatches { + display: inline-flex; + gap: var(--space-3); + margin-left: var(--space-2); +} + +.accent-swatch { + width: 0.875rem; + height: 0.875rem; + border-radius: 50%; + border: 1px solid var(--color-chip-outline); +} + +.accent-color-input { + inline-size: 2.25rem; + block-size: 1.5rem; + padding: 0; + border: 1px solid var(--color-border-input); + border-radius: var(--radius); + background: none; +} + .pond-settings-page__header { display: flex; align-items: center; diff --git a/apps/web/src/theme/apply-theme.test.ts b/apps/web/src/theme/apply-theme.test.ts new file mode 100644 index 0000000..9406de6 --- /dev/null +++ b/apps/web/src/theme/apply-theme.test.ts @@ -0,0 +1,112 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + accentHexForChoice, + applyUserTheme, + buildUserThemeCss, + initUserTheme, + readStoredAccentChoice, + THEME_ACCENT_KEY, + THEME_CSS_KEY, +} from './apply-theme'; + +/** Node ≥ 22 ships its own (unconfigured, undefined) localStorage global + * that shadows jsdom's — give the tests a real in-memory one (pattern + * theme.test.ts). */ +function stubLocalStorage(): void { + const store = new Map(); + Object.defineProperty(window, 'localStorage', { + configurable: true, + value: { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => void store.set(key, String(value)), + removeItem: (key: string) => void store.delete(key), + clear: () => store.clear(), + }, + }); +} + +beforeEach(() => { + stubLocalStorage(); +}); + +afterEach(() => { + document.getElementById('user-theme')?.remove(); +}); + +describe('readStoredAccentChoice', () => { + it('defaults to the default preset on empty, broken, or unknown values', () => { + expect(readStoredAccentChoice()).toBe('pond-green'); + window.localStorage.setItem(THEME_ACCENT_KEY, 'not json'); + expect(readStoredAccentChoice()).toBe('pond-green'); + window.localStorage.setItem(THEME_ACCENT_KEY, JSON.stringify('no-such-preset')); + expect(readStoredAccentChoice()).toBe('pond-green'); + window.localStorage.setItem(THEME_ACCENT_KEY, JSON.stringify({ custom: 'teal' })); + expect(readStoredAccentChoice()).toBe('pond-green'); + }); + + it('accepts preset ids and normalized custom hexes', () => { + window.localStorage.setItem(THEME_ACCENT_KEY, JSON.stringify('lake-blue')); + expect(readStoredAccentChoice()).toBe('lake-blue'); + window.localStorage.setItem(THEME_ACCENT_KEY, JSON.stringify({ custom: '#AABBCC' })); + expect(readStoredAccentChoice()).toEqual({ custom: '#aabbcc' }); + }); +}); + +describe('accentHexForChoice', () => { + it('maps the default preset to null (= no override)', () => { + expect(accentHexForChoice('pond-green')).toBeNull(); + expect(accentHexForChoice('lake-blue')).toBe('#2b5f8f'); + expect(accentHexForChoice({ custom: '#A0B0C0' })).toBe('#a0b0c0'); + }); +}); + +describe('buildUserThemeCss', () => { + it('emits both mode blocks per the data-theme selector contract', () => { + const css = buildUserThemeCss('#2b5f8f'); + // Doubled :root beats tokens.css regardless of document order. + expect(css).toContain(':root:root {'); + expect(css).toContain(":root:root[data-theme='dark'] {"); + expect(css.match(/--color-accent:/g)).toHaveLength(2); + expect(css.match(/--color-accent-contrast:/g)).toHaveLength(2); + }); +}); + +describe('applyUserTheme', () => { + it('writes the style element and caches choice + css', () => { + applyUserTheme('lake-blue'); + const style = document.getElementById('user-theme'); + expect(style?.textContent).toContain('--color-accent:'); + expect(window.localStorage.getItem(THEME_ACCENT_KEY)).toBe('"lake-blue"'); + expect(JSON.parse(window.localStorage.getItem(THEME_CSS_KEY)!)).toBe(style?.textContent); + }); + + it('removes override and cache for the default preset', () => { + applyUserTheme({ custom: '#ff0000' }); + expect(document.getElementById('user-theme')).not.toBeNull(); + applyUserTheme('pond-green'); + expect(document.getElementById('user-theme')).toBeNull(); + expect(window.localStorage.getItem(THEME_CSS_KEY)).toBeNull(); + }); +}); + +describe('initUserTheme', () => { + it('re-derives from the stored choice, healing a stale css cache', () => { + window.localStorage.setItem(THEME_ACCENT_KEY, JSON.stringify('lake-blue')); + window.localStorage.setItem(THEME_CSS_KEY, JSON.stringify('/* stale */')); + initUserTheme(); + expect(document.getElementById('user-theme')?.textContent).toContain('--color-accent:'); + expect(JSON.parse(window.localStorage.getItem(THEME_CSS_KEY)!)).toContain('--color-accent:'); + }); + + it('drops a leftover style when the stored choice is the default', () => { + const stale = document.createElement('style'); + stale.id = 'user-theme'; + document.head.appendChild(stale); + window.localStorage.setItem(THEME_CSS_KEY, JSON.stringify('/* stale */')); + initUserTheme(); + expect(document.getElementById('user-theme')).toBeNull(); + expect(window.localStorage.getItem(THEME_CSS_KEY)).toBeNull(); + }); +}); diff --git a/apps/web/src/theme/apply-theme.ts b/apps/web/src/theme/apply-theme.ts new file mode 100644 index 0000000..db6b95a --- /dev/null +++ b/apps/web/src/theme/apply-theme.ts @@ -0,0 +1,140 @@ +import { + DEFAULT_THEME_PRESET_ID, + deriveAccentTokens, + isHexColor, + normalizeHexColor, + THEME_PRESETS, +} from '@dorfteich/shared'; +import { useEffect, useState } from 'react'; + +/** + * User accent theme (ADR 0018 stage B, issue #184). The choice — a preset id + * or a free hex — lives in localStorage like the mode (device-local, never + * server-side). Applying derives BOTH modes' accent tokens and writes them + * as a