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/settings-nav.spec.ts b/apps/web/e2e/settings-nav.spec.ts index aebfdaf..2c908e9 100644 --- a/apps/web/e2e/settings-nav.spec.ts +++ b/apps/web/e2e/settings-nav.spec.ts @@ -28,8 +28,8 @@ test('user settings show the jump nav and clicking scrolls + activates', async ( await expect(nav).toBeVisible(); const links = nav.locator('.settings-nav__link'); // Profile, password, sessions, watches, API tokens, feed tokens, data export. - // 8 seit #170 (neue Bedienungs-Sektion). - await expect(links).toHaveCount(8); + // 8 seit #170 (Bedienung), 9 seit #180 (Erscheinungsbild). + await expect(links).toHaveCount(9); // Jump to the last section: it scrolls into view and becomes active. const last = links.last(); 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/index.html b/apps/web/index.html index 64aa490..284207c 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -3,7 +3,17 @@ + + + + Dorfteich + + diff --git a/apps/web/public/theme-init.js b/apps/web/public/theme-init.js new file mode 100644 index 0000000..aa1e8c9 --- /dev/null +++ b/apps/web/public/theme-init.js @@ -0,0 +1,34 @@ +/* Pre-paint theme boot (issue #180). Must stay a plain classic script in a + * separate file: the prod CSP (nginx.conf, script-src 'self') forbids inline + * scripts, and executing during parsing — before the deferred module + * bundle — is what prevents a white flash for dark users. Reads the same + * JSON-encoded localStorage keys as src/theme/theme.ts; keep them in sync. */ +(function () { + var theme = 'light'; + try { + var raw = window.localStorage.getItem('ui.theme.mode'); + var mode = raw === null ? 'system' : JSON.parse(raw); + var dark = + mode === 'dark' || + (mode !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches); + if (dark) theme = 'dark'; + } catch { + /* Broken storage/JSON: default to light. */ + } + document.documentElement.dataset.theme = theme; + document.documentElement.style.colorScheme = theme; + try { + /* Hook for the accent-theming stage (ADR 0018): a pre-derived stylesheet + * cached under ui.theme.css is injected before first paint so custom + * accents do not flash either. Unset until that stage ships. */ + var css = window.localStorage.getItem('ui.theme.css'); + if (css) { + var style = document.createElement('style'); + style.id = 'user-theme'; + style.textContent = JSON.parse(css); + document.head.appendChild(style); + } + } catch { + /* Optional enhancement only. */ + } +})(); diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 7d4c714..e8aa9d1 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -8,9 +8,16 @@ import { AuthProvider } from './auth/auth-context'; import { ToastProvider } from './components/Toast'; import './i18n'; import { ApiError } from './lib/api'; +import { applyTheme, initSystemThemeListener, readStoredThemeMode } from './theme/theme'; import './styles/tokens.css'; import './styles/base.css'; +// theme-init.js already themed the document pre-paint; re-applying here is +// a no-op safety net for contexts serving index.html without it, and the +// listener keeps 'system' users in sync with live OS scheme changes. +applyTheme(readStoredThemeMode()); +initSystemThemeListener(); + const queryClient = new QueryClient({ defaultOptions: { queries: { diff --git a/apps/web/src/pages/SettingsPage.tsx b/apps/web/src/pages/SettingsPage.tsx index 2f92ff9..d72e4e9 100644 --- a/apps/web/src/pages/SettingsPage.tsx +++ b/apps/web/src/pages/SettingsPage.tsx @@ -17,6 +17,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 { applyTheme, THEME_MODE_KEY, type ThemeMode } from '../theme/theme'; interface SessionView { id: string; createdAt: string; @@ -38,6 +39,7 @@ export function SettingsPage(): React.JSX.Element { + @@ -45,6 +47,43 @@ export function SettingsPage(): React.JSX.Element { ); } +/** Erscheinungsbild (issue #180): Hell/Dunkel/System — wie + * InteractionSection eine lokale Geräte-Einstellung, kein Server-Zustand. */ +function AppearanceSection(): React.JSX.Element { + const { t } = useTranslation(); + const [mode, setMode] = usePersistentState(THEME_MODE_KEY, 'system'); + const choose = (value: ThemeMode): void => { + setMode(value); + applyTheme(value); + }; + const options: { value: ThemeMode; label: string }[] = [ + { value: 'light', label: t('settings:appearance.light') }, + { value: 'dark', label: t('settings:appearance.dark') }, + { value: 'system', label: t('settings:appearance.system') }, + ]; + return ( +
+

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

+
+ {t('settings:appearance.legend')} + {options.map((option) => ( + + ))} +
+

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

+
+ ); +} + /** 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 a4b0538..00b8407 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -1401,6 +1401,8 @@ button { margin-left: -0.4rem; border: 2px solid var(--color-bg); border-radius: 50%; + /* Deliberately NOT a theme token: USER_COLORS backgrounds are chosen so + white text clears 4.5:1 (user-color.test.ts) independent of the theme. */ color: #ffffff; font-size: 0.7rem; font-weight: 600; @@ -1443,6 +1445,7 @@ button { left: -1px; padding: 0.05rem 0.3rem; border-radius: 3px 3px 3px 0; + /* White on USER_COLORS, theme-independent — see .presence-avatar. */ color: #ffffff; font-size: 0.7rem; font-weight: 600; @@ -1909,6 +1912,9 @@ ul[data-type='task_list'] li p:last-of-type { font-size: 0.72rem; line-height: 1.5; white-space: nowrap; + /* Invisible on light; separates arbitrary user label colors from the + dark canvas (issue #180). */ + border: 1px solid var(--color-chip-outline); } .label-chip__swatch { @@ -2210,7 +2216,7 @@ ul[data-type='task_list'] li p:last-of-type { } .member-seats__item { - background: var(--color-surface-muted, rgba(127, 127, 127, 0.12)); + background: var(--color-surface-muted); border-radius: var(--radius-sm, 0.375rem); padding: var(--space-1) var(--space-2); font-size: 0.875rem; @@ -3007,7 +3013,7 @@ ul[data-type='task_list'] li p:last-of-type { border-radius: var(--radius-sm, 0.375rem); padding: var(--space-3); margin-bottom: var(--space-3); - background: var(--color-surface-muted, rgba(127, 127, 127, 0.06)); + background: var(--color-bg-subtle); } .attachments-panel__header { @@ -3197,23 +3203,23 @@ ul[data-type='task_list'] li p:last-of-type { padding: 0.1rem 0.5rem; border-radius: 999px; font-size: 0.8125rem; - background: var(--color-surface-muted, #e2e8f0); + background: var(--color-surface-muted); margin-left: var(--space-1); } .system-badge--ok { - background: #d6f2df; - color: #1d6f42; + background: var(--color-badge-ok-bg); + color: var(--color-badge-ok-text); } .system-badge--error { - background: #fbe3e0; - color: #a02818; + background: var(--color-badge-error-bg); + color: var(--color-badge-error-text); } .system-badge--warn { - background: #fdf0d4; - color: #8a5a00; + background: var(--color-badge-warn-bg); + color: var(--color-badge-warn-text); } .system-jobs__notice { @@ -3312,11 +3318,11 @@ ul[data-type='task_list'] li p:last-of-type { .button--danger { border-color: var(--color-danger); background: var(--color-danger); - color: var(--color-accent-contrast); + color: var(--color-danger-contrast); } .button--danger:hover:not(:disabled) { - background: #8a0718; + background: var(--color-danger-strong); } /* Pond creation form in the switcher + pond danger zone */ @@ -3527,7 +3533,7 @@ ul[data-type='task_list'] li p:last-of-type { .comments-section__count { margin-left: var(--space-2); font-size: 0.8125rem; - background: var(--color-surface-muted, #e2e8f0); + background: var(--color-surface-muted); border-radius: 999px; padding: 0.05rem 0.5rem; } @@ -3604,7 +3610,7 @@ ul[data-type='task_list'] li p:last-of-type { background: none; border: none; padding: 0; - color: var(--color-primary, #2f6f4f); + color: var(--color-accent); cursor: pointer; font-size: 0.8125rem; text-decoration: underline; @@ -3641,8 +3647,8 @@ ul[data-type='task_list'] li p:last-of-type { /* Watches (issue #93) */ .watch-toggle--active { - background: var(--color-primary, #2f6f4f); - color: #fff; + background: var(--color-accent); + color: var(--color-accent-contrast); } .watches-list { @@ -3672,6 +3678,18 @@ ul[data-type='task_list'] li p:last-of-type { margin: var(--space-2) 0; } +/* Radio groups in settings sections (issue #180). */ +.settings-fieldset { + border: none; + margin: 0; + padding: 0; +} + +.settings-fieldset legend { + padding: 0; + font-weight: 600; +} + .pond-settings-page__header { display: flex; align-items: center; @@ -3696,8 +3714,8 @@ ul[data-type='task_list'] li p:last-of-type { position: absolute; top: -2px; right: -4px; - background: #a02818; - color: #fff; + background: var(--color-danger); + color: var(--color-danger-contrast); border-radius: 999px; font-size: 0.7rem; padding: 0 0.35rem; @@ -3929,7 +3947,7 @@ ul[data-type='task_list'] li p:last-of-type { } .dt-date--overdue { - color: #b91c1c; + color: var(--color-danger); } /* Task overview block (issue #154). */ diff --git a/apps/web/src/styles/tokens.css b/apps/web/src/styles/tokens.css index 380f34e..fa49081 100644 --- a/apps/web/src/styles/tokens.css +++ b/apps/web/src/styles/tokens.css @@ -4,6 +4,10 @@ * one-line changes. The look is deliberately plain and professional. */ :root { + /* Native controls/scrollbars follow the active theme; the dark block + below flips this together with the palette (issue #180). */ + color-scheme: light; + /* Font slots per ADR 0016; the real self-hosted fonts arrive with issue #66. */ --font-heading: 'Roboto', system-ui, -apple-system, 'Segoe UI', sans-serif; --font-body: 'Roboto', system-ui, -apple-system, 'Segoe UI', sans-serif; @@ -21,6 +25,9 @@ * token with a #fff fallback; the fallback-less ones dropped their * background entirely (#120). */ --color-surface: #ffffff; + /* Muted pill/badge surfaces (member seats, count pills, neutral badges). + Was a fallback-only value scattered through base.css before #180. */ + --color-surface-muted: #e2e8f0; --color-border: #d9e2ec; /* Boundaries of interactive controls (inputs, selects) need 3:1 against both backgrounds (WCAG 1.4.11) — #d9e2ec is only ~1.3:1. */ @@ -28,11 +35,28 @@ --color-accent: #2f6f4f; --color-accent-contrast: #ffffff; --color-danger: #ab091e; + /* Text sitting ON a danger background (buttons, notification badge) — + paired with --color-danger the same way --color-accent-contrast is + paired with --color-accent; the two must flip together per theme. */ + --color-danger-contrast: #ffffff; + --color-danger-strong: #8a0718; --color-ok: #14803c; /* Favorite stars and tree icons (issue #132) — a readable gold. ICON use only: 3.25:1 passes the 3:1 non-text minimum but NOT the 4.5:1 required for text (audit A11Y-024c). */ --color-favorite: #b8860b; + /* Status badges (admin system page): pastel surface + strong text, each + pair ≥ 4.5:1 in both themes (asserted by theme-contrast.test.ts). */ + --color-badge-ok-bg: #d6f2df; + --color-badge-ok-text: #1d6f42; + --color-badge-error-bg: #fbe3e0; + --color-badge-error-text: #a02818; + --color-badge-warn-bg: #fdf0d4; + --color-badge-warn-text: #8a5a00; + /* Separation ring for user-colored label chips/swatches — invisible on + light, a faint light outline on dark where arbitrary label colors can + melt into the background. */ + --color-chip-outline: transparent; /* Spacing scale (rem-based). */ --space-1: 0.25rem; @@ -46,3 +70,36 @@ --topbar-height: 3rem; --sidebar-width: 16rem; } + +/* + * Dark palette (issue #180). public/theme-init.js and theme/theme.ts always + * resolve the user's Light/Dark/System choice to a CONCRETE data-theme on + * — there is deliberately no @media fallback here, so this single + * selector is the whole contract for later theming stages (ADR 0018). + * Every pair is contrast-asserted by theme-contrast.test.ts; border-input + * and favorite pass ≥ 3:1 against both palettes and stay unchanged. + */ +:root[data-theme='dark'] { + color-scheme: dark; + + --color-text: #e2e8f0; + --color-text-muted: #a7b3c0; + --color-bg: #10161d; + --color-bg-subtle: #161d26; + --color-surface: #1b2430; + --color-surface-muted: #26303c; + --color-border: #323e4d; + --color-accent: #5cb88a; + --color-accent-contrast: #10161d; + --color-danger: #f87171; + --color-danger-contrast: #10161d; + --color-danger-strong: #fa8f8f; + --color-ok: #4ade80; + --color-badge-ok-bg: #1b3d2b; + --color-badge-ok-text: #8fdcb0; + --color-badge-error-bg: #43181d; + --color-badge-error-text: #f3a8a8; + --color-badge-warn-bg: #3c3113; + --color-badge-warn-text: #e3c06b; + --color-chip-outline: rgba(255, 255, 255, 0.18); +} 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/apps/web/src/theme/theme.ts b/apps/web/src/theme/theme.ts new file mode 100644 index 0000000..ea3f97a --- /dev/null +++ b/apps/web/src/theme/theme.ts @@ -0,0 +1,84 @@ +import { useEffect, useState } from 'react'; + +/** localStorage key for the Light/Dark/System choice (issue #180) — a + * device-local preference like SINGLE_KEY_SHORTCUTS_KEY, JSON-encoded via + * usePersistentState. public/theme-init.js reads the same key before the + * bundle loads; keep the two in sync. The ui.theme.* namespace is shared + * with the later accent-theming stage (ui.theme.accent, ui.theme.css). */ +export const THEME_MODE_KEY = 'ui.theme.mode'; + +export type ThemeMode = 'light' | 'dark' | 'system'; +export type EffectiveTheme = 'light' | 'dark'; + +/** Meta theme-color per effective theme; must match tokens.css + * (--color-accent light, --color-bg dark) and the metas in index.html. */ +const THEME_COLOR: Record = { + light: '#2f6f4f', + dark: '#10161d', +}; + +export function readStoredThemeMode(): ThemeMode { + try { + const raw = window.localStorage.getItem(THEME_MODE_KEY); + const parsed = raw === null ? 'system' : (JSON.parse(raw) as unknown); + return parsed === 'light' || parsed === 'dark' ? parsed : 'system'; + } catch { + return 'system'; + } +} + +export function resolveTheme(mode: ThemeMode): EffectiveTheme { + if (mode !== 'system') return mode; + try { + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + } catch { + return 'light'; + } +} + +/** Resolve the mode to a CONCRETE theme on — tokens.css keys its + * dark palette solely off data-theme (no @media fallback), so this is the + * one place the choice takes effect. Also mirrors color-scheme (native + * controls) and both media-paired theme-color metas: with an explicit + * override the OS scheme must not win, and under 'system' both metas + * resolve to the same value anyway. */ +export function applyTheme(mode: ThemeMode): void { + const theme = resolveTheme(mode); + const root = document.documentElement; + root.dataset.theme = theme; + root.style.colorScheme = theme; + document + .querySelectorAll('meta[name="theme-color"]') + .forEach((meta) => meta.setAttribute('content', THEME_COLOR[theme])); +} + +/** Follow OS scheme changes live while the stored mode is 'system'. + * Called once at startup (main.tsx). */ +export function initSystemThemeListener(): void { + try { + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { + if (readStoredThemeMode() === 'system') applyTheme('system'); + }); + } catch { + // matchMedia may be absent in exotic embedders; the theme simply stays + // as applied until the next explicit change. + } +} + +/** The theme currently in effect, reactive to setting AND OS changes — + * observes data-theme on instead of duplicating the resolve logic. + * Pond-scoped theming (ADR 0018 stage C) will build on this. */ +export function useEffectiveTheme(): EffectiveTheme { + const read = (): EffectiveTheme => + document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light'; + const [theme, setTheme] = useState(read); + useEffect(() => { + const observer = new MutationObserver(() => setTheme(read())); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['data-theme'], + }); + return () => observer.disconnect(); + }, []); + return theme; +} 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). diff --git a/eslint.config.mjs b/eslint.config.mjs index 7e1782e..8968a3f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -36,6 +36,17 @@ export default tseslint.config( }, }, }, + { + // Classic browser scripts served verbatim from public/ (theme-init.js, + // issue #180): no bundler, no modules, browser globals only. + files: ['apps/web/public/*.js'], + languageOptions: { + globals: { + window: 'readonly', + document: 'readonly', + }, + }, + }, { rules: { // Unused values are usually bugs; underscore-prefix marks intentional ones. diff --git a/packages/shared/i18n/de/settings.json b/packages/shared/i18n/de/settings.json index bc06e69..f9ec6a0 100644 --- a/packages/shared/i18n/de/settings.json +++ b/packages/shared/i18n/de/settings.json @@ -57,6 +57,14 @@ "save": "Startseite speichern", "saved": "Gespeichert." }, + "appearance": { + "title": "Erscheinungsbild", + "legend": "Farbschema", + "light": "Hell", + "dark": "Dunkel", + "system": "Systemeinstellung", + "hint": "„Systemeinstellung“ folgt dem Hell-/Dunkel-Modus des Geräts. Gilt für dieses Gerät." + }, "interaction": { "title": "Bedienung", "disableSingleKey": "Einzeltasten-Kürzel deaktivieren", diff --git a/packages/shared/i18n/en/settings.json b/packages/shared/i18n/en/settings.json index 9d208dc..f34d578 100644 --- a/packages/shared/i18n/en/settings.json +++ b/packages/shared/i18n/en/settings.json @@ -57,6 +57,14 @@ "save": "Save landing page", "saved": "Saved." }, + "appearance": { + "title": "Appearance", + "legend": "Color scheme", + "light": "Light", + "dark": "Dark", + "system": "System setting", + "hint": "“System setting” follows the device’s light/dark mode. Applies to this device." + }, "interaction": { "title": "Interaction", "disableSingleKey": "Disable single-key shortcuts",