feat: add theme utility with applyTheme and isDarkMode

Adds src/lib/utils/theme.svelte.ts with applyTheme() (sets data-theme
attribute and meta theme-color) and getIsDarkMode(). Uses a plain
variable instead of $state since Vitest's node environment does not
compile Svelte 5 runes in .svelte.ts files. Adds jsdom dev dependency
and $app/environment alias in vitest config to support DOM-level unit
tests; fixes preferences.test.ts to include the theme default added
in Task 2.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Frederic Cabassut
2026-03-21 08:47:20 +01:00
committed by Fred Cabassut
co-authored by Claude Sonnet 4.6
parent 35cd888547
commit 80d18478da
7 changed files with 435 additions and 6 deletions
+5
View File
@@ -0,0 +1,5 @@
// Mock for $app/environment used in Vitest unit tests
export const browser = true;
export const dev = false;
export const building = false;
export const version = 'test';
+1
View File
@@ -4,6 +4,7 @@ import { DEFAULT_PREFERENCES, BOOLEAN_PREF_KEYS } from '$lib/types/preferences.j
describe('preferences defaults', () => {
it('should have correct default values', () => {
expect(DEFAULT_PREFERENCES).toEqual({
theme: 'system',
defaultNoteMode: 'richtext',
defaultNoteColor: 'default',
hideFooter: false,
+42
View File
@@ -0,0 +1,42 @@
import { browser } from '$app/environment';
const LIGHT_THEME_COLOR = '#f0e6d3';
const DARK_THEME_COLOR = '#1a1715';
let darkMode = false;
export function getIsDarkMode(): boolean {
return darkMode;
}
export function applyTheme(theme: 'system' | 'light' | 'dark'): void {
if (!browser) return;
// Validate input
if (theme !== 'system' && theme !== 'light' && theme !== 'dark') {
theme = 'system';
}
// Resolve system preference
let resolved = theme;
if (theme === 'system') {
const prefersDark =
typeof window.matchMedia === 'function' &&
window.matchMedia('(prefers-color-scheme: dark)').matches;
resolved = prefersDark ? 'dark' : 'light';
}
const isDark = resolved === 'dark';
darkMode = isDark;
if (isDark) {
document.documentElement.setAttribute('data-theme', 'dark');
} else {
document.documentElement.removeAttribute('data-theme');
}
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) {
meta.setAttribute('content', isDark ? DARK_THEME_COLOR : LIGHT_THEME_COLOR);
}
}
+70
View File
@@ -0,0 +1,70 @@
// @vitest-environment jsdom
import { describe, it, expect, beforeEach, vi } from 'vitest';
// Mock $app/environment
vi.mock('$app/environment', () => ({ browser: true }));
describe('applyTheme', () => {
let applyTheme: (theme: 'system' | 'light' | 'dark') => void;
beforeEach(async () => {
// Reset DOM
document.documentElement.removeAttribute('data-theme');
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) meta.remove();
const metaEl = document.createElement('meta');
metaEl.setAttribute('name', 'theme-color');
metaEl.setAttribute('content', '#f0e6d3');
document.head.appendChild(metaEl);
// Reset module state
vi.resetModules();
const mod = await import('$lib/utils/theme.svelte.js');
applyTheme = mod.applyTheme;
});
it('sets data-theme="dark" for dark mode', () => {
applyTheme('dark');
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
});
it('removes data-theme for light mode', () => {
document.documentElement.setAttribute('data-theme', 'dark');
applyTheme('light');
expect(document.documentElement.hasAttribute('data-theme')).toBe(false);
});
it('updates meta theme-color for dark mode', () => {
applyTheme('dark');
const meta = document.querySelector('meta[name="theme-color"]');
expect(meta?.getAttribute('content')).toBe('#1a1715');
});
it('updates meta theme-color for light mode', () => {
applyTheme('dark');
applyTheme('light');
const meta = document.querySelector('meta[name="theme-color"]');
expect(meta?.getAttribute('content')).toBe('#f0e6d3');
});
it('falls back to system for unknown values', () => {
// matchMedia returns false by default in jsdom → resolves to light
applyTheme('invalid' as 'system' | 'light' | 'dark');
expect(document.documentElement.hasAttribute('data-theme')).toBe(false);
});
it('resolves system preference to dark when prefers-color-scheme is dark', () => {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: query === '(prefers-color-scheme: dark)',
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn()
}))
});
applyTheme('system');
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
});
});