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() { Bambuddy @@ -525,7 +530,7 @@ export function Layout() { {/* Logo */}
Bambuddy @@ -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')}}