diff --git a/frontend/e2e/theme.spec.ts b/frontend/e2e/theme.spec.ts new file mode 100644 index 00000000..95b87392 --- /dev/null +++ b/frontend/e2e/theme.spec.ts @@ -0,0 +1,73 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Theme toggle', () => { + test.beforeEach(async ({ page }) => { + // Clear any saved theme preference before each test + await page.goto('/'); + await page.evaluate(() => localStorage.removeItem('theme')); + }); + + test('toggle button is visible in the header', async ({ page }) => { + await page.goto('/'); + await expect( + page.getByRole('banner').getByRole('button', { name: /dark mode|light mode/i }) + ).toBeVisible(); + }); + + test('clicking the toggle switches to dark mode', async ({ page }) => { + await page.goto('/'); + await page.waitForSelector('[data-hydrated]'); + + const html = page.locator('html'); + await expect(html).not.toHaveAttribute('data-theme', 'dark'); + + await page + .getByRole('banner') + .getByRole('button', { name: /dark mode/i }) + .click(); + + await expect(html).toHaveAttribute('data-theme', 'dark'); + }); + + test('clicking the toggle again switches back to light mode', async ({ page }) => { + await page.goto('/'); + await page.waitForSelector('[data-hydrated]'); + + await page + .getByRole('banner') + .getByRole('button', { name: /dark mode/i }) + .click(); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); + + await page + .getByRole('banner') + .getByRole('button', { name: /light mode/i }) + .click(); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'light'); + }); + + test('theme persists after page reload', async ({ page }) => { + await page.goto('/'); + await page.waitForSelector('[data-hydrated]'); + + await page + .getByRole('banner') + .getByRole('button', { name: /dark mode/i }) + .click(); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); + + await page.reload(); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); + }); + + test('saved theme is applied before first paint (no flash)', async ({ page }) => { + // Set dark theme in localStorage before navigating + await page.goto('/'); + await page.evaluate(() => localStorage.setItem('theme', 'dark')); + + // Intercept the initial HTML to verify data-theme is set immediately + await page.goto('/'); + const theme = await page.evaluate(() => document.documentElement.getAttribute('data-theme')); + expect(theme).toBe('dark'); + }); +}); diff --git a/frontend/src/app.html b/frontend/src/app.html index 50bd0b52..69f27222 100644 --- a/frontend/src/app.html +++ b/frontend/src/app.html @@ -3,6 +3,12 @@
+ %sveltekit.head% diff --git a/frontend/src/lib/components/ThemeToggle.svelte b/frontend/src/lib/components/ThemeToggle.svelte new file mode 100644 index 00000000..de85adb1 --- /dev/null +++ b/frontend/src/lib/components/ThemeToggle.svelte @@ -0,0 +1,69 @@ + + + diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 1c1de04f..c0d4a3b5 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -5,6 +5,7 @@ import { page } from '$app/state'; import { onMount } from 'svelte'; import { m } from '$lib/paraglide/messages.js'; import { setLocale, getLocale } from '$lib/paraglide/runtime'; +import ThemeToggle from '$lib/components/ThemeToggle.svelte'; let { children, data } = $props(); @@ -125,6 +126,9 @@ function clickOutside(node: HTMLElement) { {/each} + +