diff --git a/frontend/src/__tests__/components/Layout.test.tsx b/frontend/src/__tests__/components/Layout.test.tsx
index 611e55a86..8d23cb52c 100644
--- a/frontend/src/__tests__/components/Layout.test.tsx
+++ b/frontend/src/__tests__/components/Layout.test.tsx
@@ -125,6 +125,47 @@ describe('Layout', () => {
expect(buttons.length).toBeGreaterThan(0);
});
});
+
+ it('cycles through dark → light → system → dark', async () => {
+ localStorage.setItem('theme-mode', 'dark');
+ render();
+
+ await waitFor(() => {
+ // In dark mode, title should say "Switch to light mode"
+ const btn = document.querySelector('button[title="Switch to light mode"]');
+ expect(btn).toBeInTheDocument();
+ });
+
+ // Click to go from dark → light
+ const lightBtn = document.querySelector('button[title="Switch to light mode"]')!;
+ lightBtn.click();
+
+ await waitFor(() => {
+ // In light mode, title should say "Switch to system mode"
+ const btn = document.querySelector('button[title="Switch to system mode"]');
+ expect(btn).toBeInTheDocument();
+ });
+
+ // Click to go from light → system
+ const systemBtn = document.querySelector('button[title="Switch to system mode"]')!;
+ systemBtn.click();
+
+ await waitFor(() => {
+ // In system mode, title should say "Switch to dark mode"
+ const btn = document.querySelector('button[title="Switch to dark mode"]');
+ expect(btn).toBeInTheDocument();
+ });
+
+ // Click to go from system → dark
+ const darkBtn = document.querySelector('button[title="Switch to dark mode"]')!;
+ darkBtn.click();
+
+ await waitFor(() => {
+ // Back to dark mode
+ const btn = document.querySelector('button[title="Switch to light mode"]');
+ expect(btn).toBeInTheDocument();
+ });
+ });
});
describe('plate detection alert modal', () => {
diff --git a/frontend/src/__tests__/contexts/ThemeContext.test.tsx b/frontend/src/__tests__/contexts/ThemeContext.test.tsx
new file mode 100644
index 000000000..adb092da4
--- /dev/null
+++ b/frontend/src/__tests__/contexts/ThemeContext.test.tsx
@@ -0,0 +1,181 @@
+/**
+ * Tests for the ThemeContext system theme detection feature.
+ */
+
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import { ThemeProvider, useTheme } from '../../contexts/ThemeContext';
+import type { ReactNode } from 'react';
+
+// Helper to create a controllable matchMedia mock for individual tests
+function mockMatchMedia(prefersDark: boolean) {
+ let listener: ((e: MediaQueryListEvent) => void) | null = null;
+
+ const mql = {
+ matches: prefersDark,
+ media: '(prefers-color-scheme: dark)',
+ onchange: null,
+ addListener: () => {},
+ removeListener: () => {},
+ addEventListener: (_event: string, cb: (e: MediaQueryListEvent) => void) => {
+ listener = cb;
+ },
+ removeEventListener: () => {},
+ dispatchEvent: () => true,
+ };
+
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: () => mql,
+ });
+
+ return {
+ /** Simulate an OS theme change event */
+ fireChange: (dark: boolean) => {
+ mql.matches = dark;
+ if (listener) {
+ listener({ matches: dark } as MediaQueryListEvent);
+ }
+ },
+ };
+}
+
+function wrapper({ children }: { children: ReactNode }) {
+ return {children};
+}
+
+describe('ThemeContext', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ document.documentElement.className = '';
+ });
+
+ describe('systemPreference initialization', () => {
+ it('initializes systemPreference as dark when OS prefers dark', () => {
+ mockMatchMedia(true);
+
+ const { result } = renderHook(() => useTheme(), { wrapper });
+
+ // When mode is system, resolvedMode should follow OS preference
+ act(() => result.current.setMode('system'));
+ expect(result.current.resolvedMode).toBe('dark');
+ });
+
+ it('initializes systemPreference as light when OS prefers light', () => {
+ mockMatchMedia(false);
+
+ const { result } = renderHook(() => useTheme(), { wrapper });
+
+ act(() => result.current.setMode('system'));
+ expect(result.current.resolvedMode).toBe('light');
+ });
+ });
+
+ describe('matchMedia change event', () => {
+ it('updates systemPreference when OS theme changes', () => {
+ const { fireChange } = mockMatchMedia(false);
+
+ const { result } = renderHook(() => useTheme(), { wrapper });
+ act(() => result.current.setMode('system'));
+
+ expect(result.current.resolvedMode).toBe('light');
+
+ act(() => fireChange(true));
+ expect(result.current.resolvedMode).toBe('dark');
+
+ act(() => fireChange(false));
+ expect(result.current.resolvedMode).toBe('light');
+ });
+ });
+
+ describe('resolvedMode', () => {
+ it('follows explicit mode when mode is dark', () => {
+ mockMatchMedia(false);
+
+ const { result } = renderHook(() => useTheme(), { wrapper });
+ act(() => result.current.setMode('dark'));
+
+ expect(result.current.resolvedMode).toBe('dark');
+ });
+
+ it('follows explicit mode when mode is light', () => {
+ mockMatchMedia(true);
+
+ const { result } = renderHook(() => useTheme(), { wrapper });
+ act(() => result.current.setMode('light'));
+
+ expect(result.current.resolvedMode).toBe('light');
+ });
+
+ it('follows systemPreference when mode is system', () => {
+ const { fireChange } = mockMatchMedia(true);
+
+ const { result } = renderHook(() => useTheme(), { wrapper });
+ act(() => result.current.setMode('system'));
+
+ expect(result.current.resolvedMode).toBe('dark');
+
+ act(() => fireChange(false));
+ expect(result.current.resolvedMode).toBe('light');
+ });
+
+ it('ignores OS changes when mode is explicit', () => {
+ const { fireChange } = mockMatchMedia(false);
+
+ const { result } = renderHook(() => useTheme(), { wrapper });
+ act(() => result.current.setMode('dark'));
+
+ act(() => fireChange(true));
+ expect(result.current.resolvedMode).toBe('dark');
+ });
+ });
+
+ describe('document root dark class', () => {
+ it('adds dark class when mode is system and OS prefers dark', () => {
+ mockMatchMedia(true);
+
+ const { result } = renderHook(() => useTheme(), { wrapper });
+ act(() => result.current.setMode('system'));
+
+ expect(document.documentElement.classList.contains('dark')).toBe(true);
+ });
+
+ it('removes dark class when mode is system and OS prefers light', () => {
+ mockMatchMedia(false);
+
+ const { result } = renderHook(() => useTheme(), { wrapper });
+ act(() => result.current.setMode('system'));
+
+ expect(document.documentElement.classList.contains('dark')).toBe(false);
+ });
+
+ it('adds dark class when mode is explicitly dark', () => {
+ mockMatchMedia(false);
+
+ const { result } = renderHook(() => useTheme(), { wrapper });
+ act(() => result.current.setMode('dark'));
+
+ expect(document.documentElement.classList.contains('dark')).toBe(true);
+ });
+ });
+
+ describe('toggleMode', () => {
+ it('cycles dark → light → system → dark', () => {
+ mockMatchMedia(false);
+ localStorage.setItem('theme-mode', 'dark');
+
+ const { result } = renderHook(() => useTheme(), { wrapper });
+
+ expect(result.current.mode).toBe('dark');
+
+ act(() => result.current.toggleMode());
+ expect(result.current.mode).toBe('light');
+
+ act(() => result.current.toggleMode());
+ expect(result.current.mode).toBe('system');
+
+ act(() => result.current.toggleMode());
+ expect(result.current.mode).toBe('dark');
+ });
+ });
+});
diff --git a/frontend/src/__tests__/pages/SettingsPage.test.tsx b/frontend/src/__tests__/pages/SettingsPage.test.tsx
index 812e0bccb..305326c3a 100644
--- a/frontend/src/__tests__/pages/SettingsPage.test.tsx
+++ b/frontend/src/__tests__/pages/SettingsPage.test.tsx
@@ -927,4 +927,65 @@ describe('SettingsPage', () => {
15_000,
);
});
+
+ describe('theme mode buttons', () => {
+ it('renders Dark, Light, and System buttons', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: 'Dark' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Light' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'System' })).toBeInTheDocument();
+ });
+ });
+
+ it('highlights the active mode button with green border', async () => {
+ render();
+ const user = userEvent.setup();
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: 'System' })).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByRole('button', { name: 'System' }));
+
+ await waitFor(() => {
+ const systemBtn = screen.getByRole('button', { name: 'System' });
+ expect(systemBtn.className).toContain('border-bambu-green');
+ });
+ });
+
+ it('clicking a theme button switches mode', async () => {
+ localStorage.setItem('theme-mode', 'dark');
+ render();
+ const user = userEvent.setup();
+
+ await waitFor(() => {
+ const darkBtn = screen.getByRole('button', { name: 'Dark' });
+ expect(darkBtn.className).toContain('border-bambu-green');
+ });
+
+ const lightBtn = screen.getByRole('button', { name: 'Light' });
+ await user.click(lightBtn);
+
+ await waitFor(() => {
+ expect(lightBtn.className).toContain('border-bambu-green');
+ });
+ });
+
+ it('shows a toast when theme button is clicked', async () => {
+ render();
+ const user = userEvent.setup();
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: 'System' })).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByRole('button', { name: 'System' }));
+
+ await waitFor(() => {
+ expect(screen.getByText('Settings saved')).toBeInTheDocument();
+ });
+ });
+ });
});
diff --git a/frontend/src/__tests__/setup.ts b/frontend/src/__tests__/setup.ts
index 83ab24145..31fd6670d 100644
--- a/frontend/src/__tests__/setup.ts
+++ b/frontend/src/__tests__/setup.ts
@@ -26,18 +26,19 @@ afterEach(() => {
afterAll(() => server.close());
// Mock window.matchMedia for responsive components
+// Uses a plain function (not vi.fn) so vi.restoreAllMocks() in tests can't wipe it
Object.defineProperty(window, 'matchMedia', {
writable: true,
- value: vi.fn().mockImplementation((query: string) => ({
+ value: (query: string) => ({
matches: false,
media: query,
onchange: null,
- addListener: vi.fn(),
- removeListener: vi.fn(),
- addEventListener: vi.fn(),
- removeEventListener: vi.fn(),
- dispatchEvent: vi.fn(),
- })),
+ addListener: () => {},
+ removeListener: () => {},
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ dispatchEvent: () => true,
+ }),
});
// Mock ResizeObserver
diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx
index ab4165fd1..bde7a53c5 100644
--- a/frontend/src/components/Layout.tsx
+++ b/frontend/src/components/Layout.tsx
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
-import { Printer, Archive, ListOrdered, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, ShieldAlert, Bell, Globe, type LucideIcon } from 'lucide-react';
+import { Printer, Archive, ListOrdered, BarChart3, Cloud, Settings, Sun, Moon, Monitor, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, ShieldAlert, Bell, Globe, type LucideIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useTheme } from '../contexts/ThemeContext';
import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
@@ -78,9 +78,14 @@ export function setDefaultView(path: string) {
export function Layout() {
const navigate = useNavigate();
const location = useLocation();
- const { mode, toggleMode } = useTheme();
+ const { mode, resolvedMode, toggleMode } = useTheme();
const { t } = useTranslation();
const isSidebarCompact = useIsSidebarCompact();
+
+ // Theme toggle: mode → icon and tooltip
+ const ThemeIcon = { dark: Sun, light: Monitor, system: Moon }[mode];
+ const themeSwitchTitle = t({ dark: 'nav.switchToLight', light: 'nav.switchToSystem', system: 'nav.switchToDark' }[mode]);
+
// Re-render Layout (and the page rendered inside ) whenever the
// backend color catalog is (re)populated, so pages that mounted before the
// catalog fetched — and cached HSL-fallback color names during their first
@@ -499,7 +504,7 @@ export function Layout() {
@@ -525,7 +530,7 @@ export function Layout() {
{/* Logo */}

@@ -753,9 +758,9 @@ export function Layout() {
{authEnabled && user && (
<>
@@ -858,9 +863,9 @@ export function Layout() {
{authEnabled && user && (
<>
diff --git a/frontend/src/contexts/ThemeContext.tsx b/frontend/src/contexts/ThemeContext.tsx
index 17b7dc6cb..f8032e193 100644
--- a/frontend/src/contexts/ThemeContext.tsx
+++ b/frontend/src/contexts/ThemeContext.tsx
@@ -1,7 +1,7 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
import { api } from '../api/client';
-type ThemeMode = 'light' | 'dark';
+type ThemeMode = 'light' | 'dark' | 'system';
type ThemeStyle = 'classic' | 'glow' | 'vibrant';
type DarkBackground = 'neutral' | 'warm' | 'cool' | 'oled' | 'slate' | 'forest';
type LightBackground = 'neutral' | 'warm' | 'cool';
@@ -9,6 +9,7 @@ type ThemeAccent = 'green' | 'teal' | 'blue' | 'orange' | 'purple' | 'red';
interface ThemeContextType {
mode: ThemeMode;
+ resolvedMode: 'light' | 'dark';
// Dark mode settings
darkStyle: ThemeStyle;
darkBackground: DarkBackground;
@@ -38,6 +39,23 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
return stored || legacy || 'dark';
});
+ // System preference detection
+ const [systemPreference, setSystemPreference] = useState<'light' | 'dark'>(() => {
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
+ });
+
+ useEffect(() => {
+ const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
+ const handler = (e: MediaQueryListEvent) => {
+ setSystemPreference(e.matches ? 'dark' : 'light');
+ };
+ mediaQuery.addEventListener('change', handler);
+ return () => mediaQuery.removeEventListener('change', handler);
+ }, []);
+
+ // Resolved mode: what's actually applied (always 'light' or 'dark')
+ const resolvedMode: 'light' | 'dark' = mode === 'system' ? systemPreference : mode;
+
// Dark mode settings
const [darkStyle, setDarkStyleState] = useState
(() => {
return (localStorage.getItem('dark-style') as ThemeStyle) || 'vibrant';
@@ -104,8 +122,8 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
'accent-green', 'accent-teal', 'accent-blue', 'accent-orange', 'accent-purple', 'accent-red'
);
- // Apply based on current mode
- if (mode === 'dark') {
+ // Apply based on resolved mode
+ if (resolvedMode === 'dark') {
root.classList.add('dark');
root.classList.add(`style-${darkStyle}`);
root.classList.add(`bg-${darkBackground}`);
@@ -118,9 +136,13 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
localStorage.setItem('theme-mode', mode);
localStorage.removeItem('theme');
- }, [mode, darkStyle, darkBackground, darkAccent, lightStyle, lightBackground, lightAccent]);
+ }, [mode, resolvedMode, darkStyle, darkBackground, darkAccent, lightStyle, lightBackground, lightAccent]);
- const toggleMode = () => setModeState(prev => prev === 'dark' ? 'light' : 'dark');
+ const toggleMode = () => setModeState(prev => {
+ if (prev === 'dark') return 'light';
+ if (prev === 'light') return 'system';
+ return 'dark';
+ });
const setMode = (m: ThemeMode) => setModeState(m);
// Dark setters
@@ -160,6 +182,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
return (
+ {/* Theme Mode Selector */}
+
+
+
+ {([
+ { id: 'dark', label: t('settings.themeDark') },
+ { id: 'light', label: t('settings.themeLight') },
+ { id: 'system', label: t('settings.themeSystem') },
+ ] as const).map(({ id, label }) => (
+
+ ))}
+
+
+
{/* Dark Mode Settings */}
-
+
{t('settings.darkMode')}
- {mode === 'dark' && {t('settings.active')}}
+ {resolvedMode === 'dark' && {t('settings.active')}}
@@ -1691,10 +1712,10 @@ export function SettingsPage() {
{/* Light Mode Settings */}
-
+
{t('settings.lightMode')}
- {mode === 'light' && {t('settings.active')}}
+ {resolvedMode === 'light' && {t('settings.active')}}