diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 47d93a3..7131f63 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -521,18 +521,26 @@ jobs: sleep 2 done - # Two logins per run → reset first (see note above). + # Six logins per run since #180 doubled the scans (3 contexts × light/ + # dark, limit is 10/min) → reset first (see note above). - name: Reset login rate limit before a11y pack run: | echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL" - # WCAG-A/AA-Regressionsschutz (issue #171): axe-Scan der Kernscreens. + # WCAG-A/AA-Regressionsschutz (issue #171): axe-Scan der Kernscreens, + # seit #180 in beiden Farbschemata. - name: Run a11y pack run: | E2E_BASE_URL=http://localhost:5173 \ pnpm --filter @dorfteich/web exec playwright test e2e/a11y.spec.ts + # Hell/Dunkel/System-Umschalter (issue #180). + - name: Run theme pack + run: | + E2E_BASE_URL=http://localhost:5173 \ + pnpm --filter @dorfteich/web exec playwright test e2e/theme.spec.ts + - name: Run setup wizard pack run: | E2E_BASE_URL=http://localhost:5175 E2E_SETUP=1 \ diff --git a/apps/web/e2e/a11y.spec.ts b/apps/web/e2e/a11y.spec.ts index 7b232d4..97f4e7c 100644 --- a/apps/web/e2e/a11y.spec.ts +++ b/apps/web/e2e/a11y.spec.ts @@ -10,10 +10,15 @@ import { contextForUser } from './helpers'; * der Issues #162–#170 verletzungsfrei; jede neue Verletzung bricht den * Build. Best-Practice-Regeln (axe-Tag best-practice) prüfen wir hier * bewusst NICHT, nur normative WCAG-Kriterien. + * + * Seit issue #180 läuft jeder Scan in BEIDEN Farbschemata: emulateMedia + * setzt prefers-color-scheme, theme-init.js löst den Default „System“ zum + * konkreten data-theme auf, axe misst dann die echten Dark-Token-Farben. */ const BASE = process.env.E2E_BASE_URL ?? 'http://localhost:5173'; const TAGS = ['wcag2a', 'wcag21a', 'wcag2aa', 'wcag21aa']; +const SCHEMES = ['light', 'dark'] as const; /** Bewusst tolerierte Regel-IDs — nur mit Begründung ergänzen. */ const ALLOWED_RULES: string[] = []; @@ -32,42 +37,52 @@ async function expectClean(page: Page, label: string): Promise { ).toEqual([]); } -test('login page passes the axe WCAG A/AA scan', async ({ page }) => { - await page.goto('/login'); - await page.waitForLoadState('networkidle'); - await expectClean(page, '/login'); -}); +for (const scheme of SCHEMES) { + test.describe(`${scheme} scheme`, () => { + test(`login page passes the axe WCAG A/AA scan (${scheme})`, async ({ page }) => { + await page.emulateMedia({ colorScheme: scheme }); + await page.goto('/login'); + await page.waitForLoadState('networkidle'); + await expectClean(page, `/login (${scheme})`); + }); -test('reading and editing a page passes the axe WCAG A/AA scan', async ({ browser }) => { - const context = await contextForUser(browser, BASE, 'fixture-user'); - const page = await context.newPage(); - await page.goto('/p/content-fixtures/every-element'); - await page.waitForLoadState('networkidle'); - await expectClean(page, 'Lesemodus every-element'); + test(`reading and editing a page passes the axe WCAG A/AA scan (${scheme})`, async ({ + browser, + }) => { + const context = await contextForUser(browser, BASE, 'fixture-user'); + const page = await context.newPage(); + await page.emulateMedia({ colorScheme: scheme }); + await page.goto('/p/content-fixtures/every-element'); + await page.waitForLoadState('networkidle'); + await expectClean(page, `Lesemodus every-element (${scheme})`); - await page.locator('.editor-page__mode-toggle').click(); - await page.locator('.ProseMirror[contenteditable="true"]').waitFor({ timeout: 10_000 }); - await page.waitForTimeout(500); - await expectClean(page, 'Editor every-element'); - await context.close(); -}); + await page.locator('.editor-page__mode-toggle').click(); + await page.locator('.ProseMirror[contenteditable="true"]').waitFor({ timeout: 10_000 }); + await page.waitForTimeout(500); + await expectClean(page, `Editor every-element (${scheme})`); + await context.close(); + }); -test('user settings pass the axe WCAG A/AA scan', async ({ browser }) => { - const context = await contextForUser(browser, BASE, 'fixture-user'); - const page = await context.newPage(); - await page.goto('/settings'); - await page.waitForLoadState('networkidle'); - await expectClean(page, '/settings'); - await context.close(); -}); + test(`user settings pass the axe WCAG A/AA scan (${scheme})`, async ({ browser }) => { + const context = await contextForUser(browser, BASE, 'fixture-user'); + const page = await context.newPage(); + await page.emulateMedia({ colorScheme: scheme }); + await page.goto('/settings'); + await page.waitForLoadState('networkidle'); + await expectClean(page, `/settings (${scheme})`); + await context.close(); + }); -test('admin area passes the axe WCAG A/AA scan', async ({ browser }) => { - const context = await contextForUser(browser, BASE, 'fixture-admin'); - const page = await context.newPage(); - await page.goto('/admin'); - await page.waitForLoadState('networkidle'); - // Personenliste sichtbar, inkl. der Icon-Aktionen (issue #175). - await page.locator('.user-manager__table .user-row').first().waitFor(); - await expectClean(page, '/admin'); - await context.close(); -}); + test(`admin area passes the axe WCAG A/AA scan (${scheme})`, async ({ browser }) => { + const context = await contextForUser(browser, BASE, 'fixture-admin'); + const page = await context.newPage(); + await page.emulateMedia({ colorScheme: scheme }); + await page.goto('/admin'); + await page.waitForLoadState('networkidle'); + // Personenliste sichtbar, inkl. der Icon-Aktionen (issue #175). + await page.locator('.user-manager__table .user-row').first().waitFor(); + await expectClean(page, `/admin (${scheme})`); + await context.close(); + }); + }); +} diff --git a/apps/web/e2e/theme.spec.ts b/apps/web/e2e/theme.spec.ts new file mode 100644 index 0000000..0dc4e9e --- /dev/null +++ b/apps/web/e2e/theme.spec.ts @@ -0,0 +1,66 @@ +import { expect, test, type Page } from '@playwright/test'; + +import { contextForUser } from './helpers'; + +/** + * Theme-Umschalter (issue #180): die Hell/Dunkel/System-Wahl in den + * Einstellungen wirkt sofort, überlebt den Reload (localStorage) und folgt + * im System-Modus Live-Änderungen des OS-Schemas. Klassen-Hooks statt + * lokalisierter Texte (UI-Sprache folgt dem Profil-Locale). + */ + +const BASE = process.env.E2E_BASE_URL ?? 'http://localhost:5173'; + +const radio = (page: Page, value: string) => + page.locator(`.settings-fieldset input[value="${value}"]`); + +const effectiveTheme = (page: Page) => page.evaluate(() => document.documentElement.dataset.theme); + +test('theme choice applies instantly, persists, and system mode follows the OS', 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('.settings-fieldset').waitFor(); + + // Default: System, auf einem hellen OS also light. + await expect(radio(page, 'system')).toBeChecked(); + expect(await effectiveTheme(page)).toBe('light'); + + // Explizit Dunkel: wirkt sofort, ohne Reload, trotz hellem OS. + await radio(page, 'dark').check(); + expect(await effectiveTheme(page)).toBe('dark'); + await expect(page.locator('meta[name="theme-color"]').first()).toHaveAttribute( + 'content', + '#10161d', + ); + + // 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 expect(radio(page, 'dark')).toBeChecked(); + expect(await effectiveTheme(page)).toBe('dark'); + expect(await page.evaluate(() => window.localStorage.getItem('ui.theme.mode'))).toBe('"dark"'); + + // Zurück auf System: folgt dem hellen OS … + await radio(page, 'system').check(); + expect(await effectiveTheme(page)).toBe('light'); + + // … und Live-Wechseln des OS-Schemas ohne Interaktion. + await page.emulateMedia({ colorScheme: 'dark' }); + await expect + .poll(async () => effectiveTheme(page), { message: 'system mode follows OS change' }) + .toBe('dark'); + await page.emulateMedia({ colorScheme: 'light' }); + await expect.poll(async () => effectiveTheme(page)).toBe('light'); + + // Explizite Wahl gewinnt gegen das OS-Schema. + await page.emulateMedia({ colorScheme: 'dark' }); + await radio(page, 'light').check(); + expect(await effectiveTheme(page)).toBe('light'); + + await context.close(); +}); diff --git a/apps/web/src/theme/theme-contrast.test.ts b/apps/web/src/theme/theme-contrast.test.ts new file mode 100644 index 0000000..3236e24 --- /dev/null +++ b/apps/web/src/theme/theme-contrast.test.ts @@ -0,0 +1,100 @@ +import { readFileSync } from 'node:fs'; + +import { describe, expect, it } from 'vitest'; + +/** + * Mechanical contrast fence for BOTH token palettes (issue #180, ADR 0018): + * parses tokens.css, so any future palette tweak is re-checked against the + * WCAG 2.1 AA thresholds of ADR 0017 — text ≥ 4.5:1, non-text UI ≥ 3:1 — + * without anyone having to remember the ratios. Pattern follows + * user-color.test.ts. + */ + +const css = readFileSync(new URL('../styles/tokens.css', import.meta.url), 'utf8'); + +function parseVars(block: string): Record { + const vars: Record = {}; + for (const match of block.matchAll(/(--color-[\w-]+):\s*([^;]+);/g)) { + vars[match[1]!] = match[2]!.trim(); + } + return vars; +} + +function blockFor(selectorStart: string): string { + const start = css.indexOf(selectorStart); + expect(start, `selector ${selectorStart} present in tokens.css`).toBeGreaterThanOrEqual(0); + return css.slice(start, css.indexOf('}', start)); +} + +const lightVars = parseVars(blockFor(':root {')); +const darkVars = { ...lightVars, ...parseVars(blockFor(":root[data-theme='dark']")) }; + +/** WCAG relative luminance of an sRGB hex colour. */ +function luminance(hex: string): number { + expect(hex, `hex colour, got "${hex}"`).toMatch(/^#[0-9a-f]{6}$/i); + const channels = [1, 3, 5].map((i) => { + const c = parseInt(hex.slice(i, i + 2), 16) / 255; + return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * channels[0]! + 0.7152 * channels[1]! + 0.0722 * channels[2]!; +} + +/** Contrast ratio between two colours (WCAG 2.x). */ +function contrast(a: string, b: string): number { + const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x); + return (hi! + 0.05) / (lo! + 0.05); +} + +/** [foreground, background, minimum] — only pairings the UI really renders. */ +const PAIRS: [string, string, number][] = [ + // Body text on every background surface. + ['--color-text', '--color-bg', 4.5], + ['--color-text', '--color-bg-subtle', 4.5], + ['--color-text', '--color-surface', 4.5], + ['--color-text', '--color-surface-muted', 4.5], + // Muted text (hints, metadata) — not used on surface-muted. + ['--color-text-muted', '--color-bg', 4.5], + ['--color-text-muted', '--color-bg-subtle', 4.5], + ['--color-text-muted', '--color-surface', 4.5], + // Accent doubles as link/text colour. + ['--color-accent', '--color-bg', 4.5], + ['--color-accent', '--color-bg-subtle', 4.5], + ['--color-accent', '--color-surface', 4.5], + ['--color-accent-contrast', '--color-accent', 4.5], + // Danger as text (overdue dates, error notes) and on danger buttons. + ['--color-danger', '--color-bg', 4.5], + ['--color-danger', '--color-bg-subtle', 4.5], + ['--color-danger', '--color-surface', 4.5], + ['--color-danger-contrast', '--color-danger', 4.5], + ['--color-danger-contrast', '--color-danger-strong', 4.5], + // Success text. + ['--color-ok', '--color-bg', 4.5], + ['--color-ok', '--color-surface', 4.5], + // Status badges (admin system page). + ['--color-badge-ok-text', '--color-badge-ok-bg', 4.5], + ['--color-badge-error-text', '--color-badge-error-bg', 4.5], + ['--color-badge-warn-text', '--color-badge-warn-bg', 4.5], + // Non-text UI (WCAG 1.4.11): input borders, favorite icons. + ['--color-border-input', '--color-bg', 3], + ['--color-border-input', '--color-surface', 3], + ['--color-favorite', '--color-bg', 3], +]; + +describe.each([ + ['light', lightVars], + ['dark', darkVars], +] as const)('%s palette', (name, vars) => { + it.each(PAIRS)('%s on %s clears %s:1', (fg, bg, min) => { + const ratio = contrast(vars[fg]!, vars[bg]!); + expect(ratio, `${fg} (${vars[fg]}) on ${bg} (${vars[bg]}) in ${name}`).toBeGreaterThanOrEqual( + min, + ); + }); + + it('defines every token the pair list references', () => { + for (const [fg, bg] of PAIRS) { + expect(vars[fg], fg).toBeDefined(); + expect(vars[bg], bg).toBeDefined(); + } + }); +}); diff --git a/apps/web/src/theme/theme.test.ts b/apps/web/src/theme/theme.test.ts new file mode 100644 index 0000000..e1ae55d --- /dev/null +++ b/apps/web/src/theme/theme.test.ts @@ -0,0 +1,94 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { applyTheme, readStoredThemeMode, resolveTheme, THEME_MODE_KEY } from './theme'; + +/** Node ≥ 22 ships its own (unconfigured, undefined) localStorage global + * that shadows jsdom's — give the tests a real in-memory one. */ +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(), + }, + }); +} + +/** jsdom has no matchMedia — stub the single query the module uses. */ +function stubMatchMedia(prefersDark: boolean): void { + vi.stubGlobal( + 'matchMedia', + (query: string) => + ({ + matches: query.includes('dark') && prefersDark, + addEventListener: () => undefined, + }) as unknown as MediaQueryList, + ); +} + +beforeEach(() => { + stubLocalStorage(); + stubMatchMedia(false); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + delete document.documentElement.dataset.theme; +}); + +describe('readStoredThemeMode', () => { + it('defaults to system when nothing is stored', () => { + expect(readStoredThemeMode()).toBe('system'); + }); + + it('reads the JSON-encoded value usePersistentState writes', () => { + window.localStorage.setItem(THEME_MODE_KEY, JSON.stringify('dark')); + expect(readStoredThemeMode()).toBe('dark'); + }); + + it('falls back to system on broken JSON and unknown values', () => { + window.localStorage.setItem(THEME_MODE_KEY, 'not json'); + expect(readStoredThemeMode()).toBe('system'); + window.localStorage.setItem(THEME_MODE_KEY, JSON.stringify('purple')); + expect(readStoredThemeMode()).toBe('system'); + }); +}); + +describe('resolveTheme', () => { + it('returns explicit choices untouched', () => { + expect(resolveTheme('light')).toBe('light'); + expect(resolveTheme('dark')).toBe('dark'); + }); + + it('resolves system via prefers-color-scheme', () => { + expect(resolveTheme('system')).toBe('light'); + stubMatchMedia(true); + expect(resolveTheme('system')).toBe('dark'); + }); +}); + +describe('applyTheme', () => { + it('sets a concrete data-theme, color-scheme, and both theme-color metas', () => { + for (const media of ['(prefers-color-scheme: light)', '(prefers-color-scheme: dark)']) { + const meta = document.createElement('meta'); + meta.setAttribute('name', 'theme-color'); + meta.setAttribute('media', media); + document.head.appendChild(meta); + } + + applyTheme('dark'); + expect(document.documentElement.dataset.theme).toBe('dark'); + expect(document.documentElement.style.colorScheme).toBe('dark'); + const metas = [...document.querySelectorAll('meta[name="theme-color"]')]; + expect(metas).toHaveLength(2); + for (const meta of metas) expect(meta.getAttribute('content')).toBe('#10161d'); + + applyTheme('system'); // stubbed OS is light + expect(document.documentElement.dataset.theme).toBe('light'); + for (const meta of metas) expect(meta.getAttribute('content')).toBe('#2f6f4f'); + }); +}); diff --git a/docs/architecture/adr/0018-color-theming.md b/docs/architecture/adr/0018-color-theming.md new file mode 100644 index 0000000..4734250 --- /dev/null +++ b/docs/architecture/adr/0018-color-theming.md @@ -0,0 +1,77 @@ +# ADR 0018: Color theming — modes now, accent themes by derivation later + +- Status: accepted +- Date: 2026-07-28 + +## Context + +Until issue #180 the SPA was light-only; the no-JS public shell carried a +minimal dark block as a contrast fix (#167). ADR 0016 established the +token pattern for fonts (`--font-*` slots, pond-scoped overrides via +custom properties); ADR 0017 makes WCAG 2.1 AA contrast a hard +requirement. The product direction is a staged theming vision: dark mode +first, then user-selectable accent themes, then pond-level accents. + +This ADR fixes the model for the whole roadmap so the later stages do not +need new architecture decisions. + +## Decision + +**Model: theme = mode × accent.** The _mode_ (light/dark) selects one of +two complete neutral palettes; the _accent_ (later stages) recolors only +the accent token family. The two axes are orthogonal. + +1. **One token palette per mode, in `tokens.css` only.** Light values + live on `:root`, dark values in a single `:root[data-theme='dark']` + block. There is deliberately **no `@media` duplicate**: JS always + resolves the user's Light/Dark/System choice to a _concrete_ + `data-theme` on `` (`theme/theme.ts`; System via `matchMedia` + plus a live change listener). This single selector is the stable + contract later stages mirror. +2. **Every color is a token pair.** New colors MUST be added to both + blocks (or deliberately inherit) and their real UI pairings MUST pass + the mechanical contrast fence `theme-contrast.test.ts` (text ≥ 4.5:1, + non-text UI ≥ 3:1). `color-scheme` flips with the palette so native + controls and scrollbars follow. +3. **The mode preference is device-local.** `ui.theme.mode` in + localStorage (like the single-key-shortcut toggle, #170) — never + server-persisted. The `ui.theme.*` namespace is reserved for the + accent stage (`ui.theme.accent`, `ui.theme.css` pre-derived CSS + cache). +4. **Flash prevention is a classic external script.** The prod CSP + (`script-src 'self'`) forbids inline scripts, so + `public/theme-init.js` runs during `` parsing, before first + paint and the module bundle, and already injects the (future) + `ui.theme.css` cache. A parse-time `` plus + paired `theme-color` metas cover the interval before it runs; + `applyTheme()` rewrites both metas so an explicit override beats the + OS scheme. +5. **Accent stages derive, they do not validate.** A future "color + theme" is ONE accent hex (presets = curated hexes, custom = free + pick); `deriveAccentTokens(hex, mode)` computes accent tokens whose + contrast is guaranteed _by construction_ against the canonical mode + backgrounds. Full-palette themes (recoloring neutrals) are explicitly + out of scope: the contrast surface explodes, and plugin CSS + (`section-styles-basic`) relies on near-neutral backgrounds without + knowing host tokens. Pond theming recolors the accent family only, as + a scoped wrapper like `PondFontScope` (ADR 0016), with precedence + pond > user > default via the natural custom-property cascade; + `useEffectiveTheme()` exists for that stage. +6. **The no-JS public shell stays self-contained.** `html-shell.ts` + keeps its own inline light/dark CSS via `@media` (no JS there) with + colors matching the SPA dark tokens; it is deliberately not + user/pond-themed for now. + +## Consequences + +- Adding a color means adding a _pair_ plus a fence entry — forgetting + the dark value or an inadequate ratio fails unit tests, not review. +- The a11y e2e pack scans every core screen in both schemes; axe measures + real computed dark colors. +- Accepted trade-offs: `USER_COLORS` (presence/carets) stay + theme-independent — their accessible signal is the white text on them; + the PWA manifest splash stays light (manifests cannot media-query); + the mode preference does not roam across devices. +- `--color-primary` no longer exists; the accent family is + `--color-accent`/`--color-accent-contrast` (plus the danger family for + destructive surfaces).