Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 49 additions & 35 deletions web/src/app.css
Original file line number Diff line number Diff line change
@@ -1,26 +1,34 @@
@import 'tailwindcss';

/*
* Bind the `dark:` utility variant to a `.dark` class on <html> instead of the
* OS media query. This is the universal Tailwind v4 convention and keeps the
* variant in lockstep with the CSS-variable swap below (both react to the same
* class), so manual light/dark toggling never desyncs from `dark:` utilities.
*/
@custom-variant dark (&:is(.dark *));

@theme inline {
/* Dark mode (default) */
--color-background: oklch(0.145 0.015 265);
--color-foreground: oklch(0.985 0 0);
--color-card: oklch(0.18 0.015 265);
--color-card-foreground: oklch(0.985 0 0);
--color-popover: oklch(0.18 0.015 265);
--color-popover-foreground: oklch(0.985 0 0);
--color-primary: oklch(0.55 0.2 250);
/* Light mode = default token values */
--color-background: oklch(0.985 0 0);
--color-foreground: oklch(0.145 0.015 265);
--color-card: oklch(0.985 0 0);
--color-card-foreground: oklch(0.145 0.015 265);
--color-popover: oklch(0.985 0 0);
--color-popover-foreground: oklch(0.145 0.015 265);
--color-primary: oklch(0.5 0.2 250);
--color-primary-foreground: oklch(0.985 0 0);
--color-secondary: oklch(0.27 0.015 265);
--color-secondary-foreground: oklch(0.985 0 0);
--color-muted: oklch(0.27 0.015 265);
--color-muted-foreground: oklch(0.7 0.015 265);
--color-accent: oklch(0.27 0.015 265);
--color-accent-foreground: oklch(0.985 0 0);
--color-secondary: oklch(0.96 0.005 265);
--color-secondary-foreground: oklch(0.25 0.015 265);
--color-muted: oklch(0.96 0.005 265);
--color-muted-foreground: oklch(0.55 0.015 265);
--color-accent: oklch(0.96 0.005 265);
--color-accent-foreground: oklch(0.25 0.015 265);
--color-destructive: oklch(0.55 0.22 25);
--color-destructive-foreground: oklch(0.985 0 0);
--color-border: oklch(0.35 0.015 265);
--color-input: oklch(0.35 0.015 265);
--color-ring: oklch(0.6 0.15 250);
--color-border: oklch(0.87 0.005 265);
--color-input: oklch(0.87 0.005 265);
--color-ring: oklch(0.45 0.15 250);
--radius-sm: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
Expand All @@ -38,26 +46,32 @@
font-family: system-ui, -apple-system, sans-serif;
}

/* Light mode — applied via .light class on <html> */
:root.light {
--color-background: oklch(0.985 0 0);
--color-foreground: oklch(0.145 0.015 265);
--color-card: oklch(0.985 0 0);
--color-card-foreground: oklch(0.145 0.015 265);
--color-popover: oklch(0.985 0 0);
--color-popover-foreground: oklch(0.145 0.015 265);
--color-primary: oklch(0.5 0.2 250);
/* Native UI (scrollbars, <select>, autofill, form controls) follows the theme. */
:root {
color-scheme: light;
}

/* Dark mode — applied via .dark class on <html> */
.dark {
color-scheme: dark;
--color-background: oklch(0.145 0.015 265);
--color-foreground: oklch(0.985 0 0);
--color-card: oklch(0.18 0.015 265);
--color-card-foreground: oklch(0.985 0 0);
--color-popover: oklch(0.18 0.015 265);
--color-popover-foreground: oklch(0.985 0 0);
--color-primary: oklch(0.55 0.2 250);
--color-primary-foreground: oklch(0.985 0 0);
--color-secondary: oklch(0.96 0.005 265);
--color-secondary-foreground: oklch(0.25 0.015 265);
--color-muted: oklch(0.96 0.005 265);
--color-muted-foreground: oklch(0.55 0.015 265);
--color-accent: oklch(0.96 0.005 265);
--color-accent-foreground: oklch(0.25 0.015 265);
--color-secondary: oklch(0.27 0.015 265);
--color-secondary-foreground: oklch(0.985 0 0);
--color-muted: oklch(0.27 0.015 265);
--color-muted-foreground: oklch(0.7 0.015 265);
--color-accent: oklch(0.27 0.015 265);
--color-accent-foreground: oklch(0.985 0 0);
--color-destructive: oklch(0.55 0.22 25);
--color-destructive-foreground: oklch(0.985 0 0);
--color-border: oklch(0.87 0.005 265);
--color-input: oklch(0.87 0.005 265);
--color-ring: oklch(0.45 0.15 250);
--color-border: oklch(0.35 0.015 265);
--color-input: oklch(0.35 0.015 265);
--color-ring: oklch(0.6 0.15 250);
}
}
18 changes: 12 additions & 6 deletions web/src/app.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,23 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E🛡️%3C/text%3E%3C/svg%3E" />
<script>
// Prevent flash of wrong theme — runs before page render
// Prevent flash of wrong theme — runs before page render.
// Mirrors the logic in src/lib/theme.ts: 'system' resolves via the
// OS preference, so we must NOT treat a stored 'system' value as a
// concrete dark choice (that caused a dark→light flash for
// system + light-OS users).
(function () {
try {
var stored = localStorage.getItem('rungu-theme');
var prefersLight = window.matchMedia('(prefers-color-scheme: light)').matches;
var isLight = stored ? stored === 'light' : prefersLight;
if (isLight) {
document.documentElement.classList.add('light');
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var isDark =
stored === 'dark' ||
((!stored || stored === 'system') && prefersDark);
if (isDark) {
document.documentElement.classList.add('dark');
}
} catch (e) {
// localStorage unavailable (incognito, disabled) — default to dark
// localStorage unavailable (incognito, disabled) — follow OS only.
}
})();
</script>
Expand Down
59 changes: 22 additions & 37 deletions web/src/lib/components/ThemeToggle.svelte
Original file line number Diff line number Diff line change
@@ -1,49 +1,34 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { browser } from '$app/environment';

let {
theme = $bindable(
browser
? (() => {
try {
const saved = localStorage.getItem('rungu-theme');
if (saved === 'light' || saved === 'dark' || saved === 'system') return saved;
} catch {}
return 'system' as const;
})()
: 'system'
),
}: { theme?: 'light' | 'dark' | 'system' } = $props();
import {
getStoredTheme,
applyTheme,
setTheme,
cycleTheme,
watchSystemTheme,
type Theme,
} from '$lib/theme';

// Read the persisted theme once on the client; 'system' during SSR so the
// server-rendered markup matches the pre-hydration FOUC script output.
let theme = $state<Theme>(browser ? getStoredTheme() : 'system');

function cycle() {
const order: Array<'light' | 'dark' | 'system'> = ['light', 'dark', 'system'];
const idx = order.indexOf(theme);
theme = order[(idx + 1) % order.length];
applyTheme();
theme = cycleTheme(theme);
setTheme(theme);
}

function applyTheme() {
const root = document.documentElement;
const prefersLight = window.matchMedia('(prefers-color-scheme: light)').matches;

const isLight = theme === 'light' || (theme === 'system' && prefersLight);

if (isLight) {
root.classList.add('light');
} else {
root.classList.remove('light');
}

try {
localStorage.setItem('rungu-theme', theme);
} catch {
// localStorage unavailable
}
}
// Apply on mount and whenever the user changes modes.
$effect(() => {
applyTheme(theme);
});

// When following the OS, react to live changes without a reload.
$effect(() => {
applyTheme();
if (theme !== 'system') return;
const stop = watchSystemTheme(() => applyTheme('system'));
return stop;
});
</script>

Expand Down
192 changes: 192 additions & 0 deletions web/src/lib/theme.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import {
getStoredTheme,
resolveTheme,
cycleTheme,
applyTheme,
setTheme,
persistTheme,
watchSystemTheme,
THEME_STORAGE_KEY,
DARK_CLASS,
type Theme,
} from '$lib/theme';

// ── jsdom does not implement matchMedia; provide a controllable mock ──

let prefersDark = false;

function installMatchMedia() {
const listeners = new Set<(e: MediaQueryListEvent) => void>();
const mql = {
get matches() {
return prefersDark;
},
media: '(prefers-color-scheme: dark)',
onchange: null,
addEventListener: (_ev: string, cb: (e: MediaQueryListEvent) => void) =>
listeners.add(cb),
removeEventListener: (_ev: string, cb: (e: MediaQueryListEvent) => void) =>
listeners.delete(cb),
addListener: (cb: (e: MediaQueryListEvent) => void) => listeners.add(cb),
removeListener: (cb: (e: MediaQueryListEvent) => void) => listeners.delete(cb),
dispatchEvent: () => false,
};
vi.stubGlobal('matchMedia', () => mql);
return {
mql,
setPrefersDark(v: boolean) {
prefersDark = v;
},
emit() {
for (const cb of listeners) {
cb({ matches: prefersDark } as MediaQueryListEvent);
}
},
clear() {
listeners.clear();
},
};
}

describe('getStoredTheme', () => {
beforeEach(() => {
localStorage.clear();
prefersDark = false;
installMatchMedia();
});

it('defaults to system when nothing is stored', () => {
expect(getStoredTheme()).toBe('system');
});

it('returns a valid stored value', () => {
localStorage.setItem(THEME_STORAGE_KEY, 'dark');
expect(getStoredTheme()).toBe('dark');
});

it('falls back to system for an invalid value', () => {
localStorage.setItem(THEME_STORAGE_KEY, 'hot-pink');
expect(getStoredTheme()).toBe('system');
});
});

describe('resolveTheme', () => {
beforeEach(() => {
prefersDark = false;
installMatchMedia();
});

it('resolves explicit modes directly', () => {
expect(resolveTheme('light')).toBe('light');
expect(resolveTheme('dark')).toBe('dark');
});

it('resolves system via the OS preference (light)', () => {
prefersDark = false;
expect(resolveTheme('system')).toBe('light');
});

it('resolves system via the OS preference (dark)', () => {
prefersDark = true;
expect(resolveTheme('system')).toBe('dark');
});
});

describe('cycleTheme', () => {
it('cycles light → dark → system → light', () => {
expect(cycleTheme('light')).toBe('dark');
expect(cycleTheme('dark')).toBe('system');
expect(cycleTheme('system')).toBe('light');
});
});

describe('applyTheme / setTheme', () => {
beforeEach(() => {
document.documentElement.classList.remove(DARK_CLASS);
localStorage.clear();
prefersDark = false;
installMatchMedia();
});

it('adds the dark class for dark', () => {
applyTheme('dark');
expect(document.documentElement.classList.contains(DARK_CLASS)).toBe(true);
});

it('removes the dark class for light', () => {
document.documentElement.classList.add(DARK_CLASS);
applyTheme('light');
expect(document.documentElement.classList.contains(DARK_CLASS)).toBe(false);
});

it('resolves system against the OS preference', () => {
prefersDark = true;
applyTheme('system');
expect(document.documentElement.classList.contains(DARK_CLASS)).toBe(true);

prefersDark = false;
applyTheme('system');
expect(document.documentElement.classList.contains(DARK_CLASS)).toBe(false);
});

it('setTheme applies and persists', () => {
setTheme('dark');
expect(document.documentElement.classList.contains(DARK_CLASS)).toBe(true);
expect(localStorage.getItem(THEME_STORAGE_KEY)).toBe('dark');
});

it('persistTheme writes to localStorage', () => {
persistTheme('light');
expect(localStorage.getItem(THEME_STORAGE_KEY)).toBe('light');
});
});

describe('watchSystemTheme', () => {
let mock: ReturnType<typeof installMatchMedia>;

beforeEach(() => {
prefersDark = false;
mock = installMatchMedia();
});

afterEach(() => {
mock.clear();
vi.unstubAllGlobals();
});

it('invokes the callback with the resolved theme on OS change', () => {
const cb = vi.fn();
const stop = watchSystemTheme(cb);

prefersDark = true;
mock.emit();
expect(cb).toHaveBeenCalledWith('dark');

prefersDark = false;
mock.emit();
expect(cb).toHaveBeenCalledWith('light');

stop();
});

it('unsubscribe stops further callbacks', () => {
const cb = vi.fn();
const stop = watchSystemTheme(cb);
stop();
mock.emit();
expect(cb).not.toHaveBeenCalled();
});
});

describe('Theme type contract', () => {
it('exposes the expected storage key and class name', () => {
expect(THEME_STORAGE_KEY).toBe('rungu-theme');
expect(DARK_CLASS).toBe('dark');
});

it('Theme covers exactly the three modes', () => {
const themes: Theme[] = ['light', 'dark', 'system'];
expect(themes).toHaveLength(3);
});
});
Loading
Loading