#180: Dark mode - Light/Dark/System setting with token-based dark palette #181

Merged
stwaidele merged 4 commits from feat/180-dark-mode into main 2026-07-29 07:56:30 +02:00
17 changed files with 693 additions and 57 deletions

View File

@ -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 \

View File

@ -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<void> {
).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();
});
});
}

View File

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

View File

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

View File

@ -3,7 +3,17 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Applies at parse time, before any script or stylesheet: the UA
canvas is already dark for system-dark users, killing the white
flash. theme-init.js then narrows it to the resolved theme. -->
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#2f6f4f" />
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#10161d" />
<title>Dorfteich</title>
<!-- Classic (non-module) script: executes during head parsing, before
first paint and before the deferred module bundle. External file
because the prod CSP forbids inline scripts (issue #180). -->
<script src="/theme-init.js"></script>
<!-- Self-hosted catalog @font-face rules (ADR 0016), baked into the image
by deploy/fonts/build-fonts.mjs. Absent in a plain dev build → the app
falls back to system fonts; never references a third-party origin. -->

View File

@ -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 <head> 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. */
}
})();

View File

@ -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: {

View File

@ -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 {
<WatchesSection />
<ApiTokensSection />
<FeedTokensSection />
<AppearanceSection />
<InteractionSection />
<DataExportSection />
</SettingsLayout>
@ -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<ThemeMode>(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 (
<section className="settings-section">
<h2>{t('settings:appearance.title')}</h2>
<fieldset className="settings-fieldset">
<legend>{t('settings:appearance.legend')}</legend>
{options.map((option) => (
<label key={option.value} className="settings-checkbox">
<input
type="radio"
name="theme-mode"
value={option.value}
checked={mode === option.value}
onChange={() => choose(option.value)}
/>
{option.label}
</label>
))}
</fieldset>
<p className="field__hint">{t('settings:appearance.hint')}</p>
</section>
);
}
/** 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 {

View File

@ -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). */

View File

@ -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
* <html> 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);
}

View File

@ -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<string, string> {
const vars: Record<string, string> = {};
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();
}
});
});

View File

@ -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<string, string>();
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');
});
});

View File

@ -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<EffectiveTheme, string> = {
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 <html> 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 <html> 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<EffectiveTheme>(read);
useEffect(() => {
const observer = new MutationObserver(() => setTheme(read()));
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme'],
});
return () => observer.disconnect();
}, []);
return theme;
}

View File

@ -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 `<html>` (`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 `<head>` parsing, before first
paint and the module bundle, and already injects the (future)
`ui.theme.css` cache. A parse-time `<meta name="color-scheme">` 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).

View File

@ -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.

View File

@ -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",

View File

@ -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 devices light/dark mode. Applies to this device."
},
"interaction": {
"title": "Interaction",
"disableSingleKey": "Disable single-key shortcuts",