Browse Source

feat: add system theme detection (prefers-color-scheme) (#1501)

feat: add system theme detection (prefers-color-scheme)
Timothy Trowbridge 2 days ago
parent
commit
052e928107

+ 41 - 0
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(<Layout />);
+
+      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', () => {

+ 181 - 0
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 <ThemeProvider>{children}</ThemeProvider>;
+}
+
+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');
+    });
+  });
+});

+ 61 - 0
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(<SettingsPage />);
+
+      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(<SettingsPage />);
+      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(<SettingsPage />);
+      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(<SettingsPage />);
+      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();
+      });
+    });
+  });
 });

+ 8 - 7
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

+ 13 - 8
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 <Outlet />) 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() {
             <Menu className="w-6 h-6 text-white" />
           </button>
           <img
-            src={mode === 'dark' ? '/img/bambuddy_logo_dark_transparent.png' : '/img/bambuddy_logo_light.png'}
+            src={resolvedMode === 'dark' ? '/img/bambuddy_logo_dark_transparent.png' : '/img/bambuddy_logo_light.png'}
             alt="Bambuddy"
             className="h-8 ml-3"
           />
@@ -525,7 +530,7 @@ export function Layout() {
         {/* Logo */}
         <div className={`border-b border-bambu-dark-tertiary flex items-center justify-center ${isSidebarCompact || sidebarExpanded ? 'p-4' : 'p-2'}`}>
           <img
-            src={mode === 'dark' ? '/img/bambuddy_logo_dark_transparent.png' : '/img/bambuddy_logo_light.png'}
+            src={resolvedMode === 'dark' ? '/img/bambuddy_logo_dark_transparent.png' : '/img/bambuddy_logo_light.png'}
             alt="Bambuddy"
             className={isSidebarCompact || sidebarExpanded ? 'h-16 w-auto' : 'h-8 w-8 object-cover object-left'}
           />
@@ -753,9 +758,9 @@ export function Layout() {
                 <button
                   onClick={toggleMode}
                   className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
-                  title={mode === 'dark' ? t('nav.switchToLight') : t('nav.switchToDark')}
+                  title={themeSwitchTitle}
                 >
-                  {mode === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
+                  <ThemeIcon className="w-5 h-5" />
                 </button>
                 {authEnabled && user && (
                   <>
@@ -858,9 +863,9 @@ export function Layout() {
               <button
                 onClick={toggleMode}
                 className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
-                title={mode === 'dark' ? t('nav.switchToLight') : t('nav.switchToDark')}
+                title={themeSwitchTitle}
               >
-                {mode === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
+                <ThemeIcon className="w-5 h-5" />
               </button>
               {authEnabled && user && (
                 <>

+ 28 - 5
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<ThemeStyle>(() => {
     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 (
     <ThemeContext.Provider value={{
       mode,
+      resolvedMode,
       darkStyle, darkBackground, darkAccent,
       lightStyle, lightBackground, lightAccent,
       toggleMode, setMode,

+ 2 - 1
frontend/src/i18n/locales/de.ts

@@ -24,6 +24,7 @@ export default {
     keyboardShortcuts: 'Tastaturkürzel (?)',
     switchToLight: 'Zum hellen Modus wechseln',
     switchToDark: 'Zum dunklen Modus wechseln',
+    switchToSystem: 'Zum Systemmodus wechseln',
     smartSwitches: 'Smart Switches',
     logout: 'Abmelden',
     installApp: 'App installieren',
@@ -2141,7 +2142,7 @@ export default {
     styleClassic: 'Klassisch',
     styleGlow: 'Leuchtend',
     styleVibrant: 'Lebendig',
-    themeToggleHint: 'Zwischen Hell- und Dunkelmodus mit dem Sonnen-/Mondsymbol in der Seitenleiste wechseln.',
+    themeToggleHint: 'Zwischen Dunkel-, Hell- und Systemmodus mit dem Symbol in der Seitenleiste wechseln.',
     // Archive
     autoArchivePrints: 'Drucke automatisch archivieren',
     autoArchiveDescription: '3MF-Dateien automatisch speichern, wenn Drucke abgeschlossen sind',

+ 2 - 1
frontend/src/i18n/locales/en.ts

@@ -24,6 +24,7 @@ export default {
     keyboardShortcuts: 'Keyboard shortcuts (?)',
     switchToLight: 'Switch to light mode',
     switchToDark: 'Switch to dark mode',
+    switchToSystem: 'Switch to system mode',
     smartSwitches: 'Smart Switches',
     logout: 'Logout',
     installApp: 'Install app',
@@ -2144,7 +2145,7 @@ export default {
     styleClassic: 'Classic',
     styleGlow: 'Glow',
     styleVibrant: 'Vibrant',
-    themeToggleHint: 'Toggle between dark and light mode using the sun/moon icon in the sidebar.',
+    themeToggleHint: 'Toggle between dark, light, and system mode using the icon in the sidebar.',
     // Archive
     autoArchivePrints: 'Auto-archive prints',
     autoArchiveDescription: 'Automatically save 3MF files when prints complete',

+ 2 - 1
frontend/src/i18n/locales/es.ts

@@ -24,6 +24,7 @@ export default {
     keyboardShortcuts: 'Atajos de teclado (?)',
     switchToLight: 'Cambiar a modo claro',
     switchToDark: 'Cambiar a modo oscuro',
+    switchToSystem: 'Cambiar a modo del sistema',
     smartSwitches: 'Interruptores inteligentes',
     logout: 'Cerrar sesión',
     installApp: 'Instalar app',
@@ -2144,7 +2145,7 @@ export default {
     styleClassic: 'Clásico',
     styleGlow: 'Resplandor',
     styleVibrant: 'Vibrante',
-    themeToggleHint: 'Alterne entre el modo oscuro y claro con el icono del sol/luna en la barra lateral.',
+    themeToggleHint: 'Alterne entre modo oscuro, claro y sistema con el icono en la barra lateral.',
     // Archive
     autoArchivePrints: 'Archivar impresiones automáticamente',
     autoArchiveDescription: 'Guardar automáticamente los archivos 3MF cuando se completan las impresiones',

+ 2 - 1
frontend/src/i18n/locales/fr.ts

@@ -24,6 +24,7 @@ export default {
     keyboardShortcuts: 'Raccourcis clavier (?)',
     switchToLight: 'Passer au mode clair',
     switchToDark: 'Passer au mode sombre',
+    switchToSystem: 'Passer au mode système',
     smartSwitches: 'Interrupteurs intelligents',
     logout: 'Déconnexion',
     installApp: "Installer l'application",
@@ -2096,7 +2097,7 @@ export default {
     styleClassic: 'Classique',
     styleGlow: 'Lumineux',
     styleVibrant: 'Vif',
-    themeToggleHint: 'Basculer entre le mode sombre et clair avec l\'icône soleil/lune dans la barre latérale.',
+    themeToggleHint: 'Basculer entre le mode sombre, clair et système avec l\'icône dans la barre latérale.',
     autoArchivePrints: 'Archiver automatiquement les impressions',
     autoArchiveDescription: 'Sauvegarder automatiquement les fichiers 3MF à la fin des impressions',
     saveThumbnailsDescription: 'Extraire et sauvegarder les images d\'aperçu des fichiers 3MF',

+ 2 - 1
frontend/src/i18n/locales/it.ts

@@ -24,6 +24,7 @@ export default {
     keyboardShortcuts: 'Scorciatoie da tastiera (?)',
     switchToLight: 'Passa a tema chiaro',
     switchToDark: 'Passa a tema scuro',
+    switchToSystem: 'Passa a tema di sistema',
     smartSwitches: 'Interruttori Smart',
     logout: 'Esci',
     installApp: 'Installa app',
@@ -2095,7 +2096,7 @@ export default {
     styleClassic: 'Classico',
     styleGlow: 'Luminoso',
     styleVibrant: 'Vibrante',
-    themeToggleHint: 'Passa tra modalità scura e chiara con l\'icona sole/luna nella barra laterale.',
+    themeToggleHint: 'Passa tra modalità scura, chiara e sistema con l\'icona nella barra laterale.',
     autoArchivePrints: 'Archiviazione automatica stampe',
     autoArchiveDescription: 'Salva automaticamente i file 3MF al completamento delle stampe',
     saveThumbnailsDescription: 'Estrai e salva le immagini di anteprima dai file 3MF',

+ 2 - 1
frontend/src/i18n/locales/ja.ts

@@ -24,6 +24,7 @@ export default {
     keyboardShortcuts: 'キーボードショートカット (?)',
     switchToLight: 'ライトモードに切替',
     switchToDark: 'ダークモードに切替',
+    switchToSystem: 'システムモードに切替',
     smartSwitches: 'スマートスイッチ',
     logout: 'ログアウト',
     installApp: 'アプリをインストール',
@@ -2140,7 +2141,7 @@ export default {
     styleClassic: 'クラシック',
     styleGlow: 'グロー',
     styleVibrant: 'ビビッド',
-    themeToggleHint: 'サイドバーの太陽/月アイコンでダークモードとライトモードを切り替えます。',
+    themeToggleHint: 'サイドバーのアイコンでダーク、ライト、システムモードを切り替えます。',
     // Archive
     autoArchivePrints: '印刷を自動アーカイブ',
     autoArchiveDescription: '印刷完了時に3MFファイルを自動保存',

+ 2 - 1
frontend/src/i18n/locales/pt-BR.ts

@@ -24,6 +24,7 @@ export default {
     keyboardShortcuts: 'Atalhos de teclado (?)',
     switchToLight: 'Mudar para modo claro',
     switchToDark: 'Mudar para modo escuro',
+    switchToSystem: 'Mudar para modo do sistema',
     smartSwitches: 'Interruptores inteligentes',
     logout: 'Sair',
     installApp: 'Instalar app',
@@ -2095,7 +2096,7 @@ export default {
     styleClassic: 'Clássico',
     styleGlow: 'Brilhante',
     styleVibrant: 'Vibrante',
-    themeToggleHint: 'Alternar entre modo escuro e claro usando o ícone de sol/lua na barra lateral.',
+    themeToggleHint: 'Alternar entre modo escuro, claro e sistema usando o ícone na barra lateral.',
     autoArchivePrints: 'Arquivar impressões automaticamente',
     autoArchiveDescription: 'Salvar automaticamente arquivos 3MF quando impressões forem concluídas',
     saveThumbnailsDescription: 'Extrair e salvar imagens de pré-visualização dos arquivos 3MF',

+ 2 - 1
frontend/src/i18n/locales/zh-CN.ts

@@ -24,6 +24,7 @@ export default {
     keyboardShortcuts: '键盘快捷键 (?)',
     switchToLight: '切换到浅色模式',
     switchToDark: '切换到深色模式',
+    switchToSystem: '切换到系统模式',
     smartSwitches: '智能开关',
     logout: '退出登录',
     installApp: '安装应用',
@@ -2140,7 +2141,7 @@ export default {
     styleClassic: '经典',
     styleGlow: '发光',
     styleVibrant: '鲜艳',
-    themeToggleHint: '使用侧边栏中的太阳/月亮图标在深色和浅色模式之间切换。',
+    themeToggleHint: '使用侧边栏中的图标在深色、浅色和系统模式之间切换。',
     autoArchivePrints: '自动归档打印',
     autoArchiveDescription: '打印完成时自动保存3MF文件',
     saveThumbnailsDescription: '从3MF文件中提取并保存预览图像',

+ 2 - 1
frontend/src/i18n/locales/zh-TW.ts

@@ -24,6 +24,7 @@ export default {
     keyboardShortcuts: '鍵盤快捷鍵 (?)',
     switchToLight: '切換到淺色模式',
     switchToDark: '切換到深色模式',
+    switchToSystem: '切換到系統模式',
     smartSwitches: '智慧開關',
     logout: '登出',
     installApp: '安裝應用程式',
@@ -2140,7 +2141,7 @@ export default {
     styleClassic: '經典',
     styleGlow: '發光',
     styleVibrant: '鮮豔',
-    themeToggleHint: '使用側邊欄中的太陽/月亮圖示在深色和淺色模式之間切換。',
+    themeToggleHint: '使用側邊欄中的圖示在深色、淺色和系統模式之間切換。',
     autoArchivePrints: '自動歸檔列印',
     autoArchiveDescription: '列印完成時自動儲存3MF檔案',
     saveThumbnailsDescription: '從3MF檔案中提取並儲存預覽影像',

+ 26 - 5
frontend/src/pages/SettingsPage.tsx

@@ -158,9 +158,10 @@ export function SettingsPage() {
   const { showToast } = useToast();
   const { authEnabled, user, isAdmin, refreshAuth, hasPermission } = useAuth();
   const {
-    mode,
+    mode, resolvedMode,
     darkStyle, darkBackground, darkAccent,
     lightStyle, lightBackground, lightAccent,
+    setMode,
     setDarkStyle, setDarkBackground, setDarkAccent,
     setLightStyle, setLightBackground, setLightAccent,
   } = useTheme();
@@ -1638,11 +1639,31 @@ export function SettingsPage() {
               </h2>
             </CardHeader>
             <CardContent className="space-y-3">
+              {/* Theme Mode Selector */}
+              <div className="flex items-center gap-2 mb-2">
+                <label className="text-sm text-bambu-gray">{t('settings.theme')}:</label>
+                <div className="flex gap-1">
+                  {([
+                    { id: 'dark', label: t('settings.themeDark') },
+                    { id: 'light', label: t('settings.themeLight') },
+                    { id: 'system', label: t('settings.themeSystem') },
+                  ] as const).map(({ id, label }) => (
+                    <button
+                      key={id}
+                      onClick={() => { setMode(id); showToast(t('settings.toast.settingsSaved'), 'success'); }}
+                      className={`px-3 py-1 text-xs rounded-lg border transition-colors ${mode === id ? 'border-bambu-green bg-bambu-green/10 text-bambu-green' : 'border-gray-300 dark:border-bambu-dark-tertiary text-gray-500 dark:text-bambu-gray hover:text-gray-900 dark:hover:text-white cursor-pointer'}`}
+                    >
+                      {label}
+                    </button>
+                  ))}
+                </div>
+              </div>
+
               {/* Dark Mode Settings */}
-              <div className={`space-y-3 p-4 rounded-lg border ${mode === 'dark' ? 'border-bambu-green bg-bambu-green/5' : 'border-bambu-dark-tertiary'}`}>
+              <div className={`space-y-3 p-4 rounded-lg border ${resolvedMode === 'dark' ? 'border-bambu-green bg-bambu-green/5' : 'border-bambu-dark-tertiary'}`}>
                 <h3 className="text-sm font-medium text-white flex items-center gap-2">
                   {t('settings.darkMode')}
-                  {mode === 'dark' && <span className="text-xs text-bambu-green">{t('settings.active')}</span>}
+                  {resolvedMode === 'dark' && <span className="text-xs text-bambu-green">{t('settings.active')}</span>}
                 </h3>
                 <div className="grid grid-cols-3 gap-3">
                   <div>
@@ -1691,10 +1712,10 @@ export function SettingsPage() {
               </div>
 
               {/* Light Mode Settings */}
-              <div className={`space-y-3 p-4 rounded-lg border ${mode === 'light' ? 'border-bambu-green bg-bambu-green/5' : 'border-bambu-dark-tertiary'}`}>
+              <div className={`space-y-3 p-4 rounded-lg border ${resolvedMode === 'light' ? 'border-bambu-green bg-bambu-green/5' : 'border-bambu-dark-tertiary'}`}>
                 <h3 className="text-sm font-medium text-white flex items-center gap-2">
                   {t('settings.lightMode')}
-                  {mode === 'light' && <span className="text-xs text-bambu-green">{t('settings.active')}</span>}
+                  {resolvedMode === 'light' && <span className="text-xs text-bambu-green">{t('settings.active')}</span>}
                 </h3>
                 <div className="grid grid-cols-3 gap-3">
                   <div>