feat(nav): add ThemeToggle component with moon/sun icons and no-flash script
- Inline <script> in app.html applies saved localStorage theme before first paint to prevent flash of wrong theme - ThemeToggle.svelte: moon/sun button, localStorage persistence, sets data-theme on <html>, defaults to system preference on first visit - Placed in +layout.svelte between language selector and user menu - E2E tests cover visibility, toggle, reverse toggle, persistence, and no-flash behaviour — all 6 passing Refs #64 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
73
frontend/e2e/theme.spec.ts
Normal file
73
frontend/e2e/theme.spec.ts
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,12 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var t = localStorage.getItem('theme');
|
||||||
|
if (t === 'dark' || t === 'light') document.documentElement.setAttribute('data-theme', t);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
|||||||
69
frontend/src/lib/components/ThemeToggle.svelte
Normal file
69
frontend/src/lib/components/ThemeToggle.svelte
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
type Theme = 'light' | 'dark';
|
||||||
|
|
||||||
|
function systemPrefersDark(): boolean {
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveInitialTheme(): Theme {
|
||||||
|
const saved = localStorage.getItem('theme');
|
||||||
|
if (saved === 'light' || saved === 'dark') return saved;
|
||||||
|
return systemPrefersDark() ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
let theme = $state<Theme>('light');
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
theme = resolveInitialTheme();
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
theme = theme === 'dark' ? 'light' : 'dark';
|
||||||
|
localStorage.setItem('theme', theme);
|
||||||
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={toggle}
|
||||||
|
aria-label={theme === 'dark' ? 'light mode' : 'dark mode'}
|
||||||
|
title={theme === 'dark' ? 'light mode' : 'dark mode'}
|
||||||
|
class="rounded p-1.5 text-ink-2 transition-colors hover:bg-muted hover:text-ink"
|
||||||
|
>
|
||||||
|
{#if theme === 'dark'}
|
||||||
|
<!-- Sun icon — click to go light -->
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="4" />
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
d="M12 2v2M12 20v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M2 12h2M20 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<!-- Moon icon — click to go dark -->
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
@@ -5,6 +5,7 @@ import { page } from '$app/state';
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { setLocale, getLocale } from '$lib/paraglide/runtime';
|
import { setLocale, getLocale } from '$lib/paraglide/runtime';
|
||||||
|
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
||||||
|
|
||||||
let { children, data } = $props();
|
let { children, data } = $props();
|
||||||
|
|
||||||
@@ -125,6 +126,9 @@ function clickOutside(node: HTMLElement) {
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Theme toggle -->
|
||||||
|
<ThemeToggle />
|
||||||
|
|
||||||
<!-- User menu -->
|
<!-- User menu -->
|
||||||
<div
|
<div
|
||||||
class="relative"
|
class="relative"
|
||||||
|
|||||||
Reference in New Issue
Block a user