#180: dark-mode test fence, both-scheme a11y pack, theme e2e, ADR 0018
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 4m41s
CI / Build container images (pull_request) Successful in 3m57s
CI / Auth e2e pack (pull_request) Failing after 5m26s
CI / Import/export fidelity gate (pull_request) Has been skipped

theme-contrast.test.ts parses tokens.css and asserts every real UI colour
pairing (4.5:1 text, 3:1 UI) for BOTH palettes, so palette drift fails
unit tests instead of review. theme.test.ts covers resolve/apply logic
(Node >= 22 ships a shadowing undefined localStorage global — the test
brings its own in-memory storage). The a11y pack now runs its four scans
in light AND dark via emulateMedia; the new theme pack exercises the
three-way switch end to end (instant apply, reload persistence, live OS
follow in system mode, override beats OS). ADR 0018 records the theming
model broadly: modes now, accent themes by derivation later.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QRtCnB3uLdQtFmvp9HXcRX
This commit is contained in:
Claude Fable 5 2026-07-28 20:46:25 +02:00
parent 2c571f9f5e
commit 4e0ad82220
6 changed files with 397 additions and 37 deletions

View File

@ -521,18 +521,26 @@ jobs:
sleep 2 sleep 2
done 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 - name: Reset login rate limit before a11y pack
run: | run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL" 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 - name: Run a11y pack
run: | run: |
E2E_BASE_URL=http://localhost:5173 \ E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/a11y.spec.ts 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 - name: Run setup wizard pack
run: | run: |
E2E_BASE_URL=http://localhost:5175 E2E_SETUP=1 \ 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 * der Issues #162#170 verletzungsfrei; jede neue Verletzung bricht den
* Build. Best-Practice-Regeln (axe-Tag best-practice) prüfen wir hier * Build. Best-Practice-Regeln (axe-Tag best-practice) prüfen wir hier
* bewusst NICHT, nur normative WCAG-Kriterien. * 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 BASE = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
const TAGS = ['wcag2a', 'wcag21a', 'wcag2aa', 'wcag21aa']; const TAGS = ['wcag2a', 'wcag21a', 'wcag2aa', 'wcag21aa'];
const SCHEMES = ['light', 'dark'] as const;
/** Bewusst tolerierte Regel-IDs — nur mit Begründung ergänzen. */ /** Bewusst tolerierte Regel-IDs — nur mit Begründung ergänzen. */
const ALLOWED_RULES: string[] = []; const ALLOWED_RULES: string[] = [];
@ -32,42 +37,52 @@ async function expectClean(page: Page, label: string): Promise<void> {
).toEqual([]); ).toEqual([]);
} }
test('login page passes the axe WCAG A/AA scan', async ({ page }) => { 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.goto('/login');
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
await expectClean(page, '/login'); await expectClean(page, `/login (${scheme})`);
}); });
test('reading and editing a page passes the axe WCAG A/AA scan', async ({ browser }) => { 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 context = await contextForUser(browser, BASE, 'fixture-user');
const page = await context.newPage(); const page = await context.newPage();
await page.emulateMedia({ colorScheme: scheme });
await page.goto('/p/content-fixtures/every-element'); await page.goto('/p/content-fixtures/every-element');
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
await expectClean(page, 'Lesemodus every-element'); await expectClean(page, `Lesemodus every-element (${scheme})`);
await page.locator('.editor-page__mode-toggle').click(); await page.locator('.editor-page__mode-toggle').click();
await page.locator('.ProseMirror[contenteditable="true"]').waitFor({ timeout: 10_000 }); await page.locator('.ProseMirror[contenteditable="true"]').waitFor({ timeout: 10_000 });
await page.waitForTimeout(500); await page.waitForTimeout(500);
await expectClean(page, 'Editor every-element'); await expectClean(page, `Editor every-element (${scheme})`);
await context.close(); await context.close();
}); });
test('user settings pass the axe WCAG A/AA scan', async ({ browser }) => { test(`user settings pass the axe WCAG A/AA scan (${scheme})`, async ({ browser }) => {
const context = await contextForUser(browser, BASE, 'fixture-user'); const context = await contextForUser(browser, BASE, 'fixture-user');
const page = await context.newPage(); const page = await context.newPage();
await page.emulateMedia({ colorScheme: scheme });
await page.goto('/settings'); await page.goto('/settings');
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
await expectClean(page, '/settings'); await expectClean(page, `/settings (${scheme})`);
await context.close(); await context.close();
}); });
test('admin area passes the axe WCAG A/AA scan', async ({ browser }) => { test(`admin area passes the axe WCAG A/AA scan (${scheme})`, async ({ browser }) => {
const context = await contextForUser(browser, BASE, 'fixture-admin'); const context = await contextForUser(browser, BASE, 'fixture-admin');
const page = await context.newPage(); const page = await context.newPage();
await page.emulateMedia({ colorScheme: scheme });
await page.goto('/admin'); await page.goto('/admin');
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
// Personenliste sichtbar, inkl. der Icon-Aktionen (issue #175). // Personenliste sichtbar, inkl. der Icon-Aktionen (issue #175).
await page.locator('.user-manager__table .user-row').first().waitFor(); await page.locator('.user-manager__table .user-row').first().waitFor();
await expectClean(page, '/admin'); await expectClean(page, `/admin (${scheme})`);
await context.close(); await context.close();
}); });
});
}

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

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