From 59955708e1c5bc0596055c882371338c08f63239 Mon Sep 17 00:00:00 2001 From: "Fredrik Liljegren (Claude Code Claude Opus 5)" Date: Fri, 21 Aug 2026 10:46:06 +0200 Subject: [PATCH] feat(ui): default the theme to the system preference The theme fell back to light when neither a stored choice nor --dark was present, so readers on a dark desktop got a light diff on every fresh session. It now falls back to prefers-color-scheme, and follows changes to it for as long as the reader has not picked a theme themselves. Precedence is unchanged where it was already meaningful: a stored choice wins over --dark, which wins over the system. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018PkYQzbsnMihHesafWvXKs --- packages/ui/src/hooks/use-theme.ts | 31 +++++++++++++++++++++++++++++- packages/ui/tests/theme.test.ts | 23 ++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 packages/ui/tests/theme.test.ts diff --git a/packages/ui/src/hooks/use-theme.ts b/packages/ui/src/hooks/use-theme.ts index 1c859fb..30080bc 100644 --- a/packages/ui/src/hooks/use-theme.ts +++ b/packages/ui/src/hooks/use-theme.ts @@ -13,15 +13,44 @@ export function getTheme(): Theme { return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light'; } +const DARK_QUERY = '(prefers-color-scheme: dark)'; + +function prefersDark(): boolean { + return typeof window !== 'undefined' && !!window.matchMedia?.(DARK_QUERY).matches; +} + +export function resolveInitialTheme( + stored: Theme | null, + initial: Theme | null | undefined, + systemPrefersDark: boolean, +): Theme { + return stored ?? initial ?? (systemPrefersDark ? 'dark' : 'light'); +} + export function useTheme(initialTheme?: Theme | null) { const [theme, setTheme] = useState( - () => getStoredTheme() || initialTheme || 'light' + () => resolveInitialTheme(getStoredTheme(), initialTheme, prefersDark()) ); useLayoutEffect(() => { document.documentElement.setAttribute('data-theme', theme); }, [theme]); + // Keep following the system until the reader picks a theme themselves. + useEffect(() => { + if (getStoredTheme() || initialTheme || typeof window === 'undefined' || !window.matchMedia) { + return; + } + + const query = window.matchMedia(DARK_QUERY); + const onChange = (event: MediaQueryListEvent): void => { + setTheme(event.matches ? 'dark' : 'light'); + }; + + query.addEventListener('change', onChange); + return () => query.removeEventListener('change', onChange); + }, [initialTheme]); + const toggleTheme = useCallback(() => { setTheme(prev => { const next = prev === 'light' ? 'dark' : 'light'; diff --git a/packages/ui/tests/theme.test.ts b/packages/ui/tests/theme.test.ts new file mode 100644 index 0000000..96e2b41 --- /dev/null +++ b/packages/ui/tests/theme.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest'; +import { resolveInitialTheme } from '../src/hooks/use-theme'; + +describe('resolveInitialTheme', () => { + it('follows the system when nothing else was chosen', () => { + expect(resolveInitialTheme(null, null, true)).toBe('dark'); + expect(resolveInitialTheme(null, null, false)).toBe('light'); + }); + + it('prefers an explicit flag over the system preference', () => { + expect(resolveInitialTheme(null, 'light', true)).toBe('light'); + expect(resolveInitialTheme(null, 'dark', false)).toBe('dark'); + }); + + it('prefers the reader\'s stored choice over everything else', () => { + expect(resolveInitialTheme('light', 'dark', true)).toBe('light'); + expect(resolveInitialTheme('dark', 'light', false)).toBe('dark'); + }); + + it('treats a missing initial theme the same as a null one', () => { + expect(resolveInitialTheme(null, undefined, true)).toBe('dark'); + }); +});