dorfteich/apps/web/e2e/theme.spec.ts
Claude Fable 5 b799ad180b
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 4m40s
CI / Build container images (pull_request) Successful in 4m1s
CI / Auth e2e pack (pull_request) Successful in 8m30s
CI / Import/export fidelity gate (pull_request) Successful in 54s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
CI / Lint, typecheck, test (push) Has been cancelled
#182: top-bar theme toggle — cycle light/dark/system without a menu
An IconButton between the notifications bell and the user menu cycles
the theme mode in radio order (sun/moon/monitor mirror the CURRENT
choice). New useThemeMode() hook is the single write path (persist +
apply + same-document event), so the settings radios and the toggle
stay in sync; AppearanceSection now uses it too. Also rendered for
signed-out visitors — the mode is a device-local preference. i18n de+en;
unit tests for cycle/setter, theme.spec covers cycling, radio sync,
persistence, and the signed-out top bar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QRtCnB3uLdQtFmvp9HXcRX
2026-07-29 08:43:42 +02:00

117 lines
4.3 KiB
TypeScript

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();
});
test('the top-bar toggle cycles the mode and stays in sync with the radios', 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();
const toggle = page.locator('.topbar__theme');
// Default System → ein Klick zykelt in Radio-Reihenfolge weiter zu Hell,
// dann Dunkel, dann zurück zu System; die Radios folgen (issue #182).
await toggle.click();
await expect(radio(page, 'light')).toBeChecked();
expect(await effectiveTheme(page)).toBe('light');
await toggle.click();
await expect(radio(page, 'dark')).toBeChecked();
expect(await effectiveTheme(page)).toBe('dark');
await toggle.click();
await expect(radio(page, 'system')).toBeChecked();
expect(await effectiveTheme(page)).toBe('light'); // helles OS
// Auch andersherum: eine Radio-Wahl versetzt den Zyklus des Toggles.
await radio(page, 'dark').check();
await toggle.click();
await expect(radio(page, 'system')).toBeChecked();
// Persistenz wie bei den Radios (gleicher localStorage-Key).
await toggle.click(); // → light
await page.reload();
await page.locator('.settings-fieldset').waitFor();
await expect(radio(page, 'light')).toBeChecked();
expect(await effectiveTheme(page)).toBe('light');
await context.close();
});
test('the theme toggle works for signed-out visitors', async ({ page }) => {
await page.emulateMedia({ colorScheme: 'light' });
await page.goto(`${BASE}/login`);
const toggle = page.locator('.topbar__theme');
await toggle.waitFor();
await toggle.click(); // System → Hell
await toggle.click(); // Hell → Dunkel
expect(await effectiveTheme(page)).toBe('dark');
});