From cdc1418bd79350ca31b2e9a99273d705b4898b31 Mon Sep 17 00:00:00 2001 From: Tim McKeage Date: Sat, 15 Aug 2026 08:17:32 -0400 Subject: [PATCH 1/7] feat(config): add follow-system theme fields to AppConfig --- src-tauri/src/config.rs | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 76b1d5ec..82818705 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -21,6 +21,13 @@ pub struct PluginConfig { #[serde(rename_all = "camelCase")] pub struct AppConfig { pub theme: Option, + /// When true, the app follows the OS light/dark appearance using + /// `light_theme_id` / `dark_theme_id`. None/false ⇒ static `theme`. + pub follow_system_theme: Option, + /// Theme applied while the OS is in light mode and follow-system is on. + pub light_theme_id: Option, + /// Theme applied while the OS is in dark mode and follow-system is on. + pub dark_theme_id: Option, pub language: Option, pub result_page_size: Option, pub font_family: Option, @@ -263,6 +270,15 @@ pub fn save_config(app: AppHandle, config: AppConfig) -> Result<(), String> { if config.theme.is_some() { existing_config.theme = config.theme; } + if config.follow_system_theme.is_some() { + existing_config.follow_system_theme = config.follow_system_theme; + } + if config.light_theme_id.is_some() { + existing_config.light_theme_id = config.light_theme_id; + } + if config.dark_theme_id.is_some() { + existing_config.dark_theme_id = config.dark_theme_id; + } if config.language.is_some() { existing_config.language = config.language; } @@ -853,6 +869,23 @@ pub fn save_config_json(app: AppHandle, json: String) -> Result<(), String> { mod tests { use super::*; + #[test] + fn app_config_deserializes_system_theme_fields() { + let json = r#"{"theme":"tabularis-dark","followSystemTheme":true,"lightThemeId":"tabularis-light","darkThemeId":"dracula"}"#; + let config: AppConfig = serde_json::from_str(json).unwrap(); + assert_eq!(config.follow_system_theme, Some(true)); + assert_eq!(config.light_theme_id.as_deref(), Some("tabularis-light")); + assert_eq!(config.dark_theme_id.as_deref(), Some("dracula")); + } + + #[test] + fn app_config_defaults_system_theme_fields_to_none() { + let config: AppConfig = serde_json::from_str("{}").unwrap(); + assert!(config.follow_system_theme.is_none()); + assert!(config.light_theme_id.is_none()); + assert!(config.dark_theme_id.is_none()); + } + #[test] fn selected_schemas_default_is_none() { let config = AppConfig::default(); From e629803468332300b2c19f1bac0e375a9497ff48 Mon Sep 17 00:00:00 2001 From: Tim McKeage Date: Sat, 15 Aug 2026 08:25:50 -0400 Subject: [PATCH 2/7] feat(theme): add resolveActiveThemeId helper --- src/utils/themeManagement.ts | 9 +++++++++ tests/utils/themeManagement.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/utils/themeManagement.ts b/src/utils/themeManagement.ts index 40339052..ea4570d4 100644 --- a/src/utils/themeManagement.ts +++ b/src/utils/themeManagement.ts @@ -190,3 +190,12 @@ export function getSystemThemeId( ): string { return isDark ? settings.darkThemeId : settings.lightThemeId; } + +export function resolveActiveThemeId( + settings: ThemeSettings, + systemIsDark: boolean +): string { + return settings.followSystemTheme + ? getSystemThemeId(systemIsDark, settings) + : settings.activeThemeId; +} diff --git a/tests/utils/themeManagement.test.ts b/tests/utils/themeManagement.test.ts index 975b99fd..9d5ae515 100644 --- a/tests/utils/themeManagement.test.ts +++ b/tests/utils/themeManagement.test.ts @@ -21,6 +21,7 @@ import { canEditTheme, isActiveTheme, getSystemThemeId, + resolveActiveThemeId, type ThemeMigrationResult, } from '../../src/utils/themeManagement'; import type { Theme, ThemeSettings } from '../../src/types/theme'; @@ -478,4 +479,29 @@ describe('themeManagement', () => { expect(result).toBe('light-custom'); }); }); + + describe('resolveActiveThemeId', () => { + const base: ThemeSettings = { + activeThemeId: 'monokai', + followSystemTheme: false, + lightThemeId: 'solarized-light', + darkThemeId: 'dracula', + customThemes: [], + }; + + it('returns activeThemeId when followSystemTheme is false', () => { + expect(resolveActiveThemeId(base, true)).toBe('monokai'); + expect(resolveActiveThemeId(base, false)).toBe('monokai'); + }); + + it('returns darkThemeId when following a dark system', () => { + const s = { ...base, followSystemTheme: true }; + expect(resolveActiveThemeId(s, true)).toBe('dracula'); + }); + + it('returns lightThemeId when following a light system', () => { + const s = { ...base, followSystemTheme: true }; + expect(resolveActiveThemeId(s, false)).toBe('solarized-light'); + }); + }); }); From fec0c97381e67cd799c263eb3914b36ff4ec50d6 Mon Sep 17 00:00:00 2001 From: Tim McKeage Date: Sat, 15 Aug 2026 08:36:52 -0400 Subject: [PATCH 3/7] feat(theme): follow system appearance with per-mode themes --- src/contexts/ThemeProvider.tsx | 103 ++++++++++++++++++-- tests/contexts/ThemeProvider.test.tsx | 135 ++++++++++++++++++++++++-- 2 files changed, 224 insertions(+), 14 deletions(-) diff --git a/src/contexts/ThemeProvider.tsx b/src/contexts/ThemeProvider.tsx index f2184604..bba7592e 100644 --- a/src/contexts/ThemeProvider.tsx +++ b/src/contexts/ThemeProvider.tsx @@ -6,9 +6,11 @@ import { type ReactNode, } from "react"; import { invoke } from "@tauri-apps/api/core"; +import { getCurrentWindow } from "@tauri-apps/api/window"; import { ThemeContext } from "./ThemeContext"; import { themeRegistry } from "../themes/themeRegistry"; import { applyThemeToCSS } from "../themes/themeUtils"; +import { getSystemThemeId, resolveActiveThemeId } from "../utils/themeManagement"; import type { Theme, ThemeSettings } from "../types/theme"; const DEFAULT_THEME_SETTINGS: ThemeSettings = { @@ -21,6 +23,9 @@ const DEFAULT_THEME_SETTINGS: ThemeSettings = { interface AppConfig { theme?: string; + followSystemTheme?: boolean; + lightThemeId?: string; + darkThemeId?: string; language?: string; resultPageSize?: number; fontFamily?: string; @@ -97,6 +102,28 @@ export const ThemeProvider = ({ children }: { children: ReactNode }) => { ).catch(() => [] as Theme[]); setCustomThemes(loadedCustomThemes.filter((t) => !t.isPreset)); + // Hydrate follow-system settings (absent ⇒ static mode) + const followSystemTheme = config.followSystemTheme ?? false; + const lightThemeId = config.lightThemeId ?? "tabularis-light"; + const darkThemeId = config.darkThemeId ?? "tabularis-dark"; + + // Resolve active theme: follow-system overrides config.theme + if (followSystemTheme) { + const systemIsDark = window.matchMedia( + "(prefers-color-scheme: dark)", + ).matches; + activeThemeId = resolveActiveThemeId( + { + ...DEFAULT_THEME_SETTINGS, + activeThemeId: activeThemeId ?? "tabularis-dark", + followSystemTheme, + lightThemeId, + darkThemeId, + }, + systemIsDark, + ); + } + // Set initial theme const allAvailableThemes = [ ...themeRegistry.getAllPresets(), @@ -110,6 +137,9 @@ export const ThemeProvider = ({ children }: { children: ReactNode }) => { setSettings({ ...DEFAULT_THEME_SETTINGS, activeThemeId: initialTheme.id, + followSystemTheme, + lightThemeId, + darkThemeId, }); } catch (error) { console.error("Failed to load themes:", error); @@ -125,6 +155,14 @@ export const ThemeProvider = ({ children }: { children: ReactNode }) => { useEffect(() => { if (currentTheme) { applyThemeToCSS(currentTheme); + const windowTheme = themeRegistry.isDarkTheme(currentTheme) + ? "dark" + : "light"; + getCurrentWindow() + .setTheme(windowTheme) + .catch((error) => + console.error("Failed to set window theme:", error), + ); } }, [currentTheme]); @@ -145,17 +183,21 @@ export const ThemeProvider = ({ children }: { children: ReactNode }) => { const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); const handleChange = (e: MediaQueryListEvent) => { - const newThemeId = e.matches ? "tabularis-dark" : "tabularis-light"; - const newTheme = allThemes.find((t) => t.id === newThemeId); + const newThemeId = getSystemThemeId(e.matches, settings); + const newTheme = + allThemes.find((t) => t.id === newThemeId) || + themeRegistry.getPreset( + e.matches ? "tabularis-dark" : "tabularis-light", + ); if (newTheme) { setCurrentTheme(newTheme); - setSettings((prev) => ({ ...prev, activeThemeId: newThemeId })); + setSettings((prev) => ({ ...prev, activeThemeId: newTheme.id })); } }; mediaQuery.addEventListener("change", handleChange); return () => mediaQuery.removeEventListener("change", handleChange); - }, [settings.followSystemTheme, allThemes]); + }, [settings, allThemes]); const setTheme = useCallback( async (themeId: string) => { @@ -240,6 +282,26 @@ export const ThemeProvider = ({ children }: { children: ReactNode }) => { customThemes: prev.customThemes.filter((id) => id !== themeId), })); + // Reset per-mode picks that referenced the deleted theme + setSettings((prev) => { + const lightThemeId = + prev.lightThemeId === themeId ? "tabularis-light" : prev.lightThemeId; + const darkThemeId = + prev.darkThemeId === themeId ? "tabularis-dark" : prev.darkThemeId; + if ( + lightThemeId !== prev.lightThemeId || + darkThemeId !== prev.darkThemeId + ) { + invoke("save_config", { + config: { lightThemeId, darkThemeId }, + }).catch((error) => + console.error("Failed to reset per-mode theme picks:", error), + ); + return { ...prev, lightThemeId, darkThemeId }; + } + return prev; + }); + // If the deleted theme was active, switch to default if (currentTheme.id === themeId) { const defaultTheme = themeRegistry.getDefault(); @@ -323,12 +385,37 @@ export const ThemeProvider = ({ children }: { children: ReactNode }) => { const updateSettings = useCallback( async (newSettings: Partial) => { - setSettings((prev) => ({ ...prev, ...newSettings })); + const merged = { ...settings, ...newSettings }; + setSettings(merged); + + await invoke("save_config", { + config: { + followSystemTheme: merged.followSystemTheme, + lightThemeId: merged.lightThemeId, + darkThemeId: merged.darkThemeId, + }, + }).catch((error) => { + console.error("Failed to save theme settings:", error); + }); - // If followSystemTheme is enabled, we would need to set up a media query listener - // This is handled separately in a dedicated effect + // Follow-system on: immediately apply the theme for the current OS mode + if (merged.followSystemTheme) { + const systemIsDark = window.matchMedia( + "(prefers-color-scheme: dark)", + ).matches; + const targetId = getSystemThemeId(systemIsDark, merged); + const target = + allThemes.find((t) => t.id === targetId) || + themeRegistry.getPreset( + systemIsDark ? "tabularis-dark" : "tabularis-light", + ); + if (target && target.id !== currentTheme.id) { + setCurrentTheme(target); + setSettings((prev) => ({ ...prev, activeThemeId: target.id })); + } + } }, - [], + [settings, allThemes, currentTheme.id], ); const value = useMemo( diff --git a/tests/contexts/ThemeProvider.test.tsx b/tests/contexts/ThemeProvider.test.tsx index b2a210d5..26cb20f6 100644 --- a/tests/contexts/ThemeProvider.test.tsx +++ b/tests/contexts/ThemeProvider.test.tsx @@ -8,6 +8,10 @@ import type { Theme } from "../../src/types/theme"; vi.mock("@tauri-apps/api/core"); +vi.mock("@tauri-apps/api/window", () => ({ + getCurrentWindow: () => ({ setTheme: vi.fn().mockResolvedValue(undefined) }), +})); + vi.mock("../../src/themes/themeUtils", () => ({ applyThemeToCSS: vi.fn(), })); @@ -107,15 +111,31 @@ const createMockTheme = (id: string, name: string): Theme => ({ }); const mockDarkTheme = createMockTheme("tabularis-dark", "Tabularis Dark"); -const mockLightTheme = createMockTheme("tabularis-light", "Tabularis Light"); +const mockLightTheme: Theme = { + ...createMockTheme("tabularis-light", "Tabularis Light"), + monacoTheme: { base: "vs", inherit: true }, +}; vi.mock("../../src/themes/themeRegistry", () => ({ themeRegistry: { getDefault: () => mockDarkTheme, getAllPresets: () => [mockDarkTheme, mockLightTheme], + getPreset: (id: string) => + [mockDarkTheme, mockLightTheme].find((t) => t.id === id), + isDarkTheme: (t: Theme) => + t.monacoTheme.base === "vs-dark" || t.monacoTheme.base === "hc-black", + isLightTheme: (t: Theme) => t.monacoTheme.base === "vs", }, })); +let systemIsDark = false; +let mediaListeners: Array<(e: { matches: boolean }) => void> = []; + +const fireSystemThemeChange = (isDark: boolean) => { + systemIsDark = isDark; + mediaListeners.forEach((l) => l({ matches: isDark })); +}; + describe("ThemeProvider", () => { const mockDefaultTheme = mockDarkTheme; beforeEach(() => { @@ -123,18 +143,27 @@ describe("ThemeProvider", () => { localStorage.clear(); // Mock matchMedia globally + mediaListeners = []; + systemIsDark = false; Object.defineProperty(window, "matchMedia", { writable: true, - value: vi.fn().mockImplementation((query) => ({ - matches: false, + value: (query: string) => ({ + matches: query === "(prefers-color-scheme: dark)" ? systemIsDark : false, media: query, onchange: null, addListener: vi.fn(), removeListener: vi.fn(), - addEventListener: vi.fn(), - removeEventListener: vi.fn(), + addEventListener: (_: string, cb: (e: { matches: boolean }) => void) => { + mediaListeners.push(cb); + }, + removeEventListener: ( + _: string, + cb: (e: { matches: boolean }) => void, + ) => { + mediaListeners = mediaListeners.filter((l) => l !== cb); + }, dispatchEvent: vi.fn(), - })), + }), }); // Default mock for invoke @@ -648,4 +677,98 @@ describe("ThemeProvider", () => { ); }); }); + + describe("system theme sync", () => { + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(ThemeProvider, null, children); + + const stubConfig = (config: Record) => { + vi.mocked(invoke).mockImplementation((cmd: string) => { + if (cmd === "get_config") return Promise.resolve(config); + if (cmd === "save_config") return Promise.resolve(undefined); + if (cmd === "get_all_themes") return Promise.resolve([]); + return Promise.reject(new Error(`Unexpected command: ${cmd}`)); + }); + }; + + it("applies darkThemeId on load when following a dark system", async () => { + systemIsDark = true; + stubConfig({ + theme: "tabularis-light", + followSystemTheme: true, + lightThemeId: "tabularis-light", + darkThemeId: "tabularis-dark", + }); + const { result } = renderHook(() => useTheme(), { wrapper }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.currentTheme.id).toBe("tabularis-dark"); + expect(result.current.settings.followSystemTheme).toBe(true); + expect(result.current.settings.lightThemeId).toBe("tabularis-light"); + }); + + it("persists settings when updateSettings is called", async () => { + stubConfig({ theme: "tabularis-dark" }); + const { result } = renderHook(() => useTheme(), { wrapper }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await act(async () => { + await result.current.updateSettings({ + followSystemTheme: true, + lightThemeId: "tabularis-light", + darkThemeId: "tabularis-dark", + }); + }); + + expect(invoke).toHaveBeenCalledWith("save_config", { + config: { + followSystemTheme: true, + lightThemeId: "tabularis-light", + darkThemeId: "tabularis-dark", + }, + }); + }); + + it("immediately applies the system-matching theme when follow-system is toggled on", async () => { + systemIsDark = false; // OS is light + stubConfig({ theme: "tabularis-dark" }); + const { result } = renderHook(() => useTheme(), { wrapper }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.currentTheme.id).toBe("tabularis-dark"); + + await act(async () => { + await result.current.updateSettings({ followSystemTheme: true }); + }); + + expect(result.current.currentTheme.id).toBe("tabularis-light"); + }); + + it("switches themes when the OS appearance changes", async () => { + systemIsDark = true; + stubConfig({ + theme: "tabularis-dark", + followSystemTheme: true, + lightThemeId: "tabularis-light", + darkThemeId: "tabularis-dark", + }); + const { result } = renderHook(() => useTheme(), { wrapper }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.currentTheme.id).toBe("tabularis-dark"); + + act(() => fireSystemThemeChange(false)); + expect(result.current.currentTheme.id).toBe("tabularis-light"); + + act(() => fireSystemThemeChange(true)); + expect(result.current.currentTheme.id).toBe("tabularis-dark"); + }); + + it("ignores OS appearance changes in static mode", async () => { + systemIsDark = true; + stubConfig({ theme: "tabularis-dark" }); + const { result } = renderHook(() => useTheme(), { wrapper }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + act(() => fireSystemThemeChange(false)); + expect(result.current.currentTheme.id).toBe("tabularis-dark"); + }); + }); }); From f838204192ec7e4fcbc5acd3ee2d6ca602b86c10 Mon Sep 17 00:00:00 2001 From: Tim McKeage Date: Sat, 15 Aug 2026 10:13:26 -0400 Subject: [PATCH 4/7] fix(theme): mode-correct load fallback, pure setState updater --- src/contexts/ThemeProvider.tsx | 54 ++++++++++++++++----------- tests/contexts/ThemeProvider.test.tsx | 13 +++++++ 2 files changed, 45 insertions(+), 22 deletions(-) diff --git a/src/contexts/ThemeProvider.tsx b/src/contexts/ThemeProvider.tsx index bba7592e..fc728c55 100644 --- a/src/contexts/ThemeProvider.tsx +++ b/src/contexts/ThemeProvider.tsx @@ -108,8 +108,9 @@ export const ThemeProvider = ({ children }: { children: ReactNode }) => { const darkThemeId = config.darkThemeId ?? "tabularis-dark"; // Resolve active theme: follow-system overrides config.theme + let systemIsDark: boolean | undefined; if (followSystemTheme) { - const systemIsDark = window.matchMedia( + systemIsDark = window.matchMedia( "(prefers-color-scheme: dark)", ).matches; activeThemeId = resolveActiveThemeId( @@ -124,13 +125,19 @@ export const ThemeProvider = ({ children }: { children: ReactNode }) => { ); } - // Set initial theme + // Set initial theme; follow-system falls back to the preset matching + // the OS mode, static mode keeps the registry default const allAvailableThemes = [ ...themeRegistry.getAllPresets(), ...loadedCustomThemes, ]; const initialTheme = allAvailableThemes.find((t) => t.id === activeThemeId) || + (followSystemTheme + ? themeRegistry.getPreset( + systemIsDark ? "tabularis-dark" : "tabularis-light", + ) + : undefined) || themeRegistry.getDefault(); setCurrentTheme(initialTheme); @@ -282,25 +289,28 @@ export const ThemeProvider = ({ children }: { children: ReactNode }) => { customThemes: prev.customThemes.filter((id) => id !== themeId), })); - // Reset per-mode picks that referenced the deleted theme - setSettings((prev) => { - const lightThemeId = - prev.lightThemeId === themeId ? "tabularis-light" : prev.lightThemeId; - const darkThemeId = - prev.darkThemeId === themeId ? "tabularis-dark" : prev.darkThemeId; - if ( - lightThemeId !== prev.lightThemeId || - darkThemeId !== prev.darkThemeId - ) { - invoke("save_config", { - config: { lightThemeId, darkThemeId }, - }).catch((error) => - console.error("Failed to reset per-mode theme picks:", error), - ); - return { ...prev, lightThemeId, darkThemeId }; - } - return prev; - }); + // Reset per-mode picks that referenced the deleted theme. + // Compute from current state and persist outside the updater so + // StrictMode double-invocation cannot fire save_config twice. + const lightThemeId = + settings.lightThemeId === themeId + ? "tabularis-light" + : settings.lightThemeId; + const darkThemeId = + settings.darkThemeId === themeId + ? "tabularis-dark" + : settings.darkThemeId; + if ( + lightThemeId !== settings.lightThemeId || + darkThemeId !== settings.darkThemeId + ) { + invoke("save_config", { + config: { lightThemeId, darkThemeId }, + }).catch((error) => + console.error("Failed to reset per-mode theme picks:", error), + ); + setSettings((prev) => ({ ...prev, lightThemeId, darkThemeId })); + } // If the deleted theme was active, switch to default if (currentTheme.id === themeId) { @@ -309,7 +319,7 @@ export const ThemeProvider = ({ children }: { children: ReactNode }) => { setSettings((prev) => ({ ...prev, activeThemeId: defaultTheme.id })); } }, - [allThemes, currentTheme.id], + [allThemes, currentTheme.id, settings.lightThemeId, settings.darkThemeId], ); const duplicateTheme = useCallback( diff --git a/tests/contexts/ThemeProvider.test.tsx b/tests/contexts/ThemeProvider.test.tsx index 26cb20f6..3dc1dc0a 100644 --- a/tests/contexts/ThemeProvider.test.tsx +++ b/tests/contexts/ThemeProvider.test.tsx @@ -761,6 +761,19 @@ describe("ThemeProvider", () => { expect(result.current.currentTheme.id).toBe("tabularis-dark"); }); + it("falls back to the light preset when a light-mode pick is unresolvable", async () => { + systemIsDark = false; // OS is light + stubConfig({ + theme: "tabularis-dark", + followSystemTheme: true, + lightThemeId: "deleted-custom-theme", + darkThemeId: "tabularis-dark", + }); + const { result } = renderHook(() => useTheme(), { wrapper }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.currentTheme.id).toBe("tabularis-light"); + }); + it("ignores OS appearance changes in static mode", async () => { systemIsDark = true; stubConfig({ theme: "tabularis-dark" }); From b9809a71972abafa508370043563eaee56f516c8 Mon Sep 17 00:00:00 2001 From: Tim McKeage Date: Sat, 15 Aug 2026 10:38:11 -0400 Subject: [PATCH 5/7] feat(settings): add static/follow-system theme mode UI --- src/components/settings/AppearanceTab.tsx | 66 ++++++++-- src/i18n/locales/de.json | 6 + src/i18n/locales/en.json | 6 + src/i18n/locales/es.json | 6 + src/i18n/locales/fr.json | 6 + src/i18n/locales/it.json | 6 + src/i18n/locales/ja.json | 6 + src/i18n/locales/ko.json | 6 + src/i18n/locales/pt-BR.json | 6 + src/i18n/locales/ru.json | 6 + src/i18n/locales/tl.json | 6 + src/i18n/locales/zh.json | 6 + .../settings/AppearanceTab.test.tsx | 116 ++++++++++++++++++ 13 files changed, 241 insertions(+), 7 deletions(-) create mode 100644 tests/components/settings/AppearanceTab.test.tsx diff --git a/src/components/settings/AppearanceTab.tsx b/src/components/settings/AppearanceTab.tsx index c702eb89..80315601 100644 --- a/src/components/settings/AppearanceTab.tsx +++ b/src/components/settings/AppearanceTab.tsx @@ -16,11 +16,18 @@ import { import { FontPicker } from "./FontPicker"; import { ThemePicker } from "./ThemePicker"; import { ResultColorsSection } from "./ResultColorsSection"; +import { themeRegistry } from "../../themes/themeRegistry"; export function AppearanceTab() { const { t } = useTranslation(); const { settings, updateSetting } = useSettings(); - const { currentTheme, allThemes, setTheme } = useTheme(); + const { + currentTheme, + allThemes, + setTheme, + settings: themeSettings, + updateSettings, + } = useTheme(); const [subTab, setSubTab] = useState<"general" | "editor">("general"); return ( @@ -57,13 +64,58 @@ export function AppearanceTab() { {subTab === "general" && ( <> -
- + + updateSettings({ followSystemTheme: mode === "system" }) + } + options={[ + { value: "static", label: t("settings.themeModeStatic") }, + { value: "system", label: t("settings.themeModeSystem") }, + ]} /> -
+ + + {themeSettings.followSystemTheme ? ( + <> +
+

+ {t("settings.lightTheme")} +

+ updateSettings({ lightThemeId: id })} + themes={allThemes.filter((theme) => + themeRegistry.isLightTheme(theme), + )} + /> +
+
+

+ {t("settings.darkTheme")} +

+ updateSettings({ darkThemeId: id })} + themes={allThemes.filter((theme) => + themeRegistry.isDarkTheme(theme), + )} + /> +
+ + ) : ( +
+ +
+ )}
diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 804caa19..66cb23f0 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -440,6 +440,12 @@ "appearance": "Darstellung", "localization": "Lokalisierung", "themeSelection": "Themaauswahl", + "themeMode": "Themenmodus", + "themeModeDesc": "Wähle ein festes Thema oder folge dem Hell-/Dunkelmodus des Systems.", + "themeModeStatic": "Statisch", + "themeModeSystem": "System folgen", + "lightTheme": "Helles Thema", + "darkTheme": "Dunkles Thema", "fontFamily": "Schriftfamilie", "fonts": { "system": "Systemstandard", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index e449d501..aa2dc6ac 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -458,6 +458,12 @@ "appearance": "Appearance", "localization": "Localization", "themeSelection": "Theme Selection", + "themeMode": "Theme Mode", + "themeModeDesc": "Choose a fixed theme, or follow your system's light/dark appearance.", + "themeModeStatic": "Static", + "themeModeSystem": "Follow System", + "lightTheme": "Light Theme", + "darkTheme": "Dark Theme", "fontFamily": "Font Family", "fonts": { "system": "System Default", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index fdc7a3ae..c10a790f 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -465,6 +465,12 @@ "appearance": "Apariencia", "localization": "Localización", "themeSelection": "Selección de Tema", + "themeMode": "Modo de tema", + "themeModeDesc": "Elige un tema fijo o sigue la apariencia clara/oscura del sistema.", + "themeModeStatic": "Estático", + "themeModeSystem": "Seguir sistema", + "lightTheme": "Tema claro", + "darkTheme": "Tema oscuro", "fontFamily": "Familia de Fuente", "fonts": { "system": "Sistema", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 6feefb32..0be9aa2d 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -458,6 +458,12 @@ "appearance": "Apparence", "localization": "Localisation", "themeSelection": "Choix du thème", + "themeMode": "Mode de thème", + "themeModeDesc": "Choisissez un thème fixe ou suivez l'apparence claire/sombre du système.", + "themeModeStatic": "Statique", + "themeModeSystem": "Suivre le système", + "lightTheme": "Thème clair", + "darkTheme": "Thème sombre", "fontFamily": "Famille de police", "fonts": { "system": "Police système par défaut", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 3e91ce2d..9f698309 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -465,6 +465,12 @@ "appearance": "Aspetto", "localization": "Localizzazione", "themeSelection": "Selezione Tema", + "themeMode": "Modalità tema", + "themeModeDesc": "Scegli un tema fisso o segui l'aspetto chiaro/scuro del sistema.", + "themeModeStatic": "Statico", + "themeModeSystem": "Segui sistema", + "lightTheme": "Tema chiaro", + "darkTheme": "Tema scuro", "fontFamily": "Famiglia Font", "fonts": { "system": "Sistema", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index bb3e40b5..ac225154 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -451,6 +451,12 @@ "appearance": "外観", "localization": "ローカライズ", "themeSelection": "テーマ選択", + "themeMode": "テーマモード", + "themeModeDesc": "固定テーマを選ぶか、システムのライト/ダーク表示に従うかを選択します。", + "themeModeStatic": "固定", + "themeModeSystem": "システムに従う", + "lightTheme": "ライトテーマ", + "darkTheme": "ダークテーマ", "fontFamily": "フォントファミリー", "fonts": { "system": "システム既定", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 8168bff4..46c390e1 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -403,6 +403,12 @@ "appearance": "모양", "localization": "지역화", "themeSelection": "테마 선택", + "themeMode": "테마 모드", + "themeModeDesc": "고정 테마를 사용하거나 시스템의 라이트/다크 모드를 따릅니다.", + "themeModeStatic": "고정", + "themeModeSystem": "시스템 따르기", + "lightTheme": "라이트 테마", + "darkTheme": "다크 테마", "fontFamily": "글꼴", "fonts": { "system": "시스템 기본값", diff --git a/src/i18n/locales/pt-BR.json b/src/i18n/locales/pt-BR.json index 646d730b..725f2278 100644 --- a/src/i18n/locales/pt-BR.json +++ b/src/i18n/locales/pt-BR.json @@ -429,6 +429,12 @@ "appearance": "Aparência", "localization": "Localização", "themeSelection": "Seleção de Tema", + "themeMode": "Modo de tema", + "themeModeDesc": "Escolha um tema fixo ou siga a aparência clara/escura do sistema.", + "themeModeStatic": "Estático", + "themeModeSystem": "Seguir sistema", + "lightTheme": "Tema claro", + "darkTheme": "Tema escuro", "fontFamily": "Família de Fonte", "fonts": { "system": "Padrão do Sistema", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index ef2096fc..c7109601 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -435,6 +435,12 @@ "appearance": "Внешний вид", "localization": "Локализация", "themeSelection": "Выбор темы", + "themeMode": "Режим темы", + "themeModeDesc": "Выберите фиксированную тему или следуйте светлому/тёмному оформлению системы.", + "themeModeStatic": "Статичная", + "themeModeSystem": "Как в системе", + "lightTheme": "Светлая тема", + "darkTheme": "Тёмная тема", "fontFamily": "Шрифт", "fonts": { "system": "Системный по умолчанию", diff --git a/src/i18n/locales/tl.json b/src/i18n/locales/tl.json index 6e3e2133..27ec6d32 100644 --- a/src/i18n/locales/tl.json +++ b/src/i18n/locales/tl.json @@ -458,6 +458,12 @@ "appearance": "Hitsura", "localization": "Lokalization", "themeSelection": "Pagpili ng Theme", + "themeMode": "Mode ng Theme", + "themeModeDesc": "Pumili ng nakapirming theme, o sundin ang light/dark na hitsura ng system.", + "themeModeStatic": "Static", + "themeModeSystem": "Sundin ang System", + "lightTheme": "Light na Theme", + "darkTheme": "Dark na Theme", "fontFamily": "Font Family", "fonts": { "system": "System Default", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 3ac11cd9..f73fa9c3 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -439,6 +439,12 @@ "appearance": "外观", "localization": "本地化", "themeSelection": "主题选择", + "themeMode": "主题模式", + "themeModeDesc": "选择固定主题,或跟随系统的浅色/深色外观。", + "themeModeStatic": "固定", + "themeModeSystem": "跟随系统", + "lightTheme": "浅色主题", + "darkTheme": "深色主题", "fontFamily": "字体", "fonts": { "system": "系统默认", diff --git a/tests/components/settings/AppearanceTab.test.tsx b/tests/components/settings/AppearanceTab.test.tsx new file mode 100644 index 00000000..e35b46a9 --- /dev/null +++ b/tests/components/settings/AppearanceTab.test.tsx @@ -0,0 +1,116 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import React from "react"; + +const updateSettings = vi.fn(); +const setTheme = vi.fn(); + +const lightTheme = { + id: "tabularis-light", + name: "Tabularis Light", + monacoTheme: { base: "vs" }, + colors: { + accent: { primary: "#007acc", secondary: "#0098ff" }, + bg: { base: "#ffffff" }, + surface: { primary: "#f5f5f5" }, + }, +}; +const darkTheme = { + id: "tabularis-dark", + name: "Tabularis Dark", + monacoTheme: { base: "vs-dark" }, + colors: { + accent: { primary: "#007acc", secondary: "#0098ff" }, + bg: { base: "#1a1a1a" }, + surface: { primary: "#2a2a2a" }, + }, +}; + +let themeSettings = { + activeThemeId: "tabularis-dark", + followSystemTheme: false, + lightThemeId: "tabularis-light", + darkThemeId: "tabularis-dark", + customThemes: [], +}; + +// Global setup mock only stubs a fixed subset of icons. +vi.mock("lucide-react", () => ({ + Monitor: () => null, + Code2: () => null, + CheckCircle2: () => null, +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock("../../../src/hooks/useSettings", () => ({ + useSettings: () => ({ + settings: { fontFamily: "System", fontSize: 14 }, + updateSetting: vi.fn(), + }), +})); + +vi.mock("../../../src/hooks/useTheme", () => ({ + useTheme: () => ({ + currentTheme: darkTheme, + allThemes: [lightTheme, darkTheme], + setTheme, + settings: themeSettings, + updateSettings, + }), +})); + +vi.mock("../../../src/components/settings/ResultColorsSection", () => ({ + ResultColorsSection: () => null, +})); + +import { AppearanceTab } from "../../../src/components/settings/AppearanceTab"; + +describe("AppearanceTab theme mode", () => { + beforeEach(() => { + vi.clearAllMocks(); + themeSettings = { ...themeSettings, followSystemTheme: false }; + }); + + it("shows a single theme picker in static mode", () => { + render(); + expect(screen.getByText("Tabularis Dark")).toBeTruthy(); + expect(screen.getByText("Tabularis Light")).toBeTruthy(); + expect(screen.queryByText("settings.lightTheme")).toBeNull(); + }); + + it("toggles follow-system via the mode button group", () => { + render(); + fireEvent.click(screen.getByText("settings.themeModeSystem")); + expect(updateSettings).toHaveBeenCalledWith({ followSystemTheme: true }); + }); + + it("shows filtered light/dark pickers in follow-system mode", () => { + themeSettings = { ...themeSettings, followSystemTheme: true }; + render(); + // Light picker: only light themes + const lightSection = screen.getByText("settings.lightTheme").parentElement!; + expect(lightSection.textContent).toContain("Tabularis Light"); + expect(lightSection.textContent).not.toContain("Tabularis Dark"); + // Dark picker: only dark themes + const darkSection = screen.getByText("settings.darkTheme").parentElement!; + expect(darkSection.textContent).toContain("Tabularis Dark"); + expect(darkSection.textContent).not.toContain("Tabularis Light"); + }); + + it("updates lightThemeId when a light theme is picked", () => { + themeSettings = { ...themeSettings, followSystemTheme: true }; + render(); + const lightSection = screen.getByText("settings.lightTheme").parentElement!; + fireEvent.click( + Array.from(lightSection.querySelectorAll("button")).find((b) => + b.textContent?.includes("Tabularis Light"), + )!, + ); + expect(updateSettings).toHaveBeenCalledWith({ + lightThemeId: "tabularis-light", + }); + }); +}); From c72147685d6bd7494550d040d62a2e7afefba9f3 Mon Sep 17 00:00:00 2001 From: Tim McKeage Date: Sat, 15 Aug 2026 14:09:24 -0400 Subject: [PATCH 6/7] fix(theme): replace active theme with OS-mode preset on delete in follow-system mode Kilo Code review (#650): in follow-system mode, deleting the active per-mode pick left the app stuck on themeRegistry.getDefault() (always dark) until the OS appearance changed. Resolve the replacement through the current OS mode and the just-reset per-mode picks, mirroring the listener and load path. Static mode keeps the existing getDefault() fallback. - ThemeProvider.tsx deleteCustomTheme active branch now branches on settings.followSystemTheme; follow-system path uses getSystemThemeId + preset fallback by current OS mode. - New test in system theme sync suite: deletes the active per-mode pick in follow-system mode (OS light) and asserts the replacement is the light preset, not getDefault(). --- src/contexts/ThemeProvider.tsx | 29 +++++++++++++++++---- tests/contexts/ThemeProvider.test.tsx | 36 +++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/src/contexts/ThemeProvider.tsx b/src/contexts/ThemeProvider.tsx index fc728c55..abe4e16a 100644 --- a/src/contexts/ThemeProvider.tsx +++ b/src/contexts/ThemeProvider.tsx @@ -312,14 +312,33 @@ export const ThemeProvider = ({ children }: { children: ReactNode }) => { setSettings((prev) => ({ ...prev, lightThemeId, darkThemeId })); } - // If the deleted theme was active, switch to default + // Active branch: in follow-system mode, resolve through the current OS + // mode and the just-reset per-mode picks (same fallback chain as the + // listener and load path). Static mode keeps the registry default. if (currentTheme.id === themeId) { - const defaultTheme = themeRegistry.getDefault(); - setCurrentTheme(defaultTheme); - setSettings((prev) => ({ ...prev, activeThemeId: defaultTheme.id })); + let replacement: Theme; + if (settings.followSystemTheme) { + const systemIsDark = window.matchMedia( + "(prefers-color-scheme: dark)", + ).matches; + const targetId = getSystemThemeId(systemIsDark, { + ...settings, + lightThemeId, + darkThemeId, + }); + replacement = + allThemes.find((t) => t.id === targetId) || + themeRegistry.getPreset( + systemIsDark ? "tabularis-dark" : "tabularis-light", + ); + } else { + replacement = themeRegistry.getDefault(); + } + setCurrentTheme(replacement); + setSettings((prev) => ({ ...prev, activeThemeId: replacement.id })); } }, - [allThemes, currentTheme.id, settings.lightThemeId, settings.darkThemeId], + [allThemes, currentTheme.id, settings.lightThemeId, settings.darkThemeId, settings.followSystemTheme], ); const duplicateTheme = useCallback( diff --git a/tests/contexts/ThemeProvider.test.tsx b/tests/contexts/ThemeProvider.test.tsx index 3dc1dc0a..f73c54bf 100644 --- a/tests/contexts/ThemeProvider.test.tsx +++ b/tests/contexts/ThemeProvider.test.tsx @@ -783,5 +783,41 @@ describe("ThemeProvider", () => { act(() => fireSystemThemeChange(false)); expect(result.current.currentTheme.id).toBe("tabularis-dark"); }); + + it("replaces the active theme with the OS-mode preset when deleting an active per-mode pick in follow-system mode", async () => { + const customLightTheme: Theme = { + ...mockLightTheme, + id: "custom-light-1", + name: "Custom Light", + isPreset: false, + isReadOnly: false, + }; + vi.mocked(invoke).mockImplementation((cmd: string) => { + if (cmd === "get_config") { + return Promise.resolve({ + theme: "custom-light-1", + followSystemTheme: true, + lightThemeId: "custom-light-1", + darkThemeId: "tabularis-dark", + }); + } + if (cmd === "save_config") return Promise.resolve(undefined); + if (cmd === "get_all_themes") return Promise.resolve([customLightTheme]); + if (cmd === "delete_custom_theme") return Promise.resolve(undefined); + return Promise.reject(new Error(`Unexpected command: ${cmd}`)); + }); + const { result } = renderHook(() => useTheme(), { wrapper }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + // OS is light; custom-light-1 resolves to active. + expect(result.current.currentTheme.id).toBe("custom-light-1"); + + await act(async () => { + await result.current.deleteCustomTheme("custom-light-1"); + }); + + // Replacement must match the current OS mode (light), not getDefault(). + expect(result.current.currentTheme.id).toBe("tabularis-light"); + expect(result.current.settings.lightThemeId).toBe("tabularis-light"); + }); }); }); From d0ca886d69a71b82bf29e608b43707aa48858cec Mon Sep 17 00:00:00 2001 From: Tim McKeage Date: Sat, 15 Aug 2026 14:12:59 -0400 Subject: [PATCH 7/7] fix(theme): add final fallback for delete-active replacement CI build failed: themeRegistry.getPreset returns Theme | undefined, and the find() || getPreset() chain can resolve to undefined. tsc -b rejects assigning to a Theme-typed binding. Add themeRegistry.getDefault() as the final fallback so the assignment is non-undefined, matching the load-path fallback chain. --- src/contexts/ThemeProvider.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/contexts/ThemeProvider.tsx b/src/contexts/ThemeProvider.tsx index abe4e16a..c05b23e0 100644 --- a/src/contexts/ThemeProvider.tsx +++ b/src/contexts/ThemeProvider.tsx @@ -330,7 +330,8 @@ export const ThemeProvider = ({ children }: { children: ReactNode }) => { allThemes.find((t) => t.id === targetId) || themeRegistry.getPreset( systemIsDark ? "tabularis-dark" : "tabularis-light", - ); + ) || + themeRegistry.getDefault(); } else { replacement = themeRegistry.getDefault(); }