diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/AppearanceSettings/components/SystemThemeCard/SystemThemeCard.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/AppearanceSettings/components/SystemThemeCard/SystemThemeCard.tsx index e25ac770474..91867190d3d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/AppearanceSettings/components/SystemThemeCard/SystemThemeCard.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/AppearanceSettings/components/SystemThemeCard/SystemThemeCard.tsx @@ -1,27 +1,79 @@ +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@superset/ui/select"; import { cn } from "@superset/ui/utils"; import { HiCheck } from "react-icons/hi2"; -import { darkTheme, lightTheme } from "shared/themes"; +import { useThemeStore } from "renderer/stores"; +import { builtInThemes, type Theme } from "shared/themes"; interface SystemThemeCardProps { isSelected: boolean; onSelect: () => void; } +function ThemeMappingSelect({ + label, + value, + themes, + onChange, +}: { + label: string; + value: string; + themes: Theme[]; + onChange: (themeId: string) => void; +}) { + return ( +
+ {label} + +
+ ); +} + export function SystemThemeCard({ isSelected, onSelect, }: SystemThemeCardProps) { - const darkTerminal = darkTheme.terminal; - const lightTerminal = lightTheme.terminal; + const customThemes = useThemeStore((state) => state.customThemes); + const systemDarkThemeId = useThemeStore((state) => state.systemDarkThemeId); + const systemLightThemeId = useThemeStore((state) => state.systemLightThemeId); + const setSystemThemeMapping = useThemeStore( + (state) => state.setSystemThemeMapping, + ); + + const allThemes = [...builtInThemes, ...customThemes]; + const darkThemes = allThemes.filter((t) => t.type === "dark"); + const lightThemes = allThemes.filter((t) => t.type === "light"); + + const darkPreviewTheme = + allThemes.find((t) => t.id === systemDarkThemeId) ?? darkThemes[0]; + const lightPreviewTheme = + allThemes.find((t) => t.id === systemLightThemeId) ?? lightThemes[0]; + + const darkTerminal = darkPreviewTheme?.terminal; + const lightTerminal = lightPreviewTheme?.terminal; if (!darkTerminal || !lightTerminal) { return null; } return ( - {/* Theme Info */} -
+ + + + {/* Theme mapping selectors (only shown when selected) */} + {isSelected && ( +
+ setSystemThemeMapping("dark", id)} + /> + setSystemThemeMapping("light", id)} + /> +
+ )} +
); } diff --git a/apps/desktop/src/renderer/stores/theme/store.test.ts b/apps/desktop/src/renderer/stores/theme/store.test.ts new file mode 100644 index 00000000000..8c50608afc6 --- /dev/null +++ b/apps/desktop/src/renderer/stores/theme/store.test.ts @@ -0,0 +1,153 @@ +import { beforeEach, describe, expect, it, mock } from "bun:test"; + +// Mock matchMedia before importing the store +const mockMatchMedia = mock(() => ({ + matches: true, // default: OS prefers dark + addEventListener: mock(() => {}), + removeEventListener: mock(() => {}), +})); + +// @ts-expect-error - mocking global +globalThis.window = { + matchMedia: mockMatchMedia, +}; + +// Mock localStorage +const mockStorage = new Map(); +// @ts-expect-error - mocking global +globalThis.localStorage = { + getItem: (key: string) => mockStorage.get(key) ?? null, + setItem: (key: string, value: string) => mockStorage.set(key, value), + removeItem: (key: string) => mockStorage.delete(key), + clear: () => mockStorage.clear(), +}; + +// Mock document for applyUIColors / updateThemeClass +// @ts-expect-error - mocking global +globalThis.document = { + documentElement: { + style: { setProperty: mock(() => {}) }, + classList: { + add: mock(() => {}), + remove: mock(() => {}), + }, + }, +}; + +// Mock the trpc-storage module to use a simple in-memory storage +mock.module("../../lib/trpc-storage", () => ({ + trpcThemeStorage: { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + }, +})); + +const { useThemeStore, SYSTEM_THEME_ID } = await import("./store"); + +function setOsPrefersDark(prefersDark: boolean) { + mockMatchMedia.mockReturnValue({ + matches: prefersDark, + addEventListener: mock(() => {}), + removeEventListener: mock(() => {}), + }); +} + +describe("theme store - system theme mapping", () => { + beforeEach(() => { + mockStorage.clear(); + setOsPrefersDark(true); + + // Reset store to defaults + useThemeStore.setState({ + activeThemeId: "dark", + systemDarkThemeId: "dark", + systemLightThemeId: "light", + customThemes: [], + activeTheme: null, + terminalTheme: null, + }); + }); + + it("has correct default system theme mappings", () => { + const state = useThemeStore.getState(); + expect(state.systemDarkThemeId).toBe("dark"); + expect(state.systemLightThemeId).toBe("light"); + }); + + it("resolves system theme to dark mapping when OS prefers dark", () => { + setOsPrefersDark(true); + useThemeStore.getState().setTheme(SYSTEM_THEME_ID); + + const state = useThemeStore.getState(); + expect(state.activeThemeId).toBe(SYSTEM_THEME_ID); + expect(state.activeTheme?.id).toBe("dark"); + }); + + it("resolves system theme to light mapping when OS prefers light", () => { + setOsPrefersDark(false); + useThemeStore.getState().setTheme(SYSTEM_THEME_ID); + + const state = useThemeStore.getState(); + expect(state.activeThemeId).toBe(SYSTEM_THEME_ID); + expect(state.activeTheme?.id).toBe("light"); + }); + + it("resolves system theme to custom dark mapping", () => { + // Add a custom dark theme (monokai is built-in and dark) + setOsPrefersDark(true); + + // Set monokai as the dark mapping + useThemeStore.getState().setSystemThemeMapping("dark", "monokai"); + expect(useThemeStore.getState().systemDarkThemeId).toBe("monokai"); + + // Select system theme + useThemeStore.getState().setTheme(SYSTEM_THEME_ID); + + const state = useThemeStore.getState(); + expect(state.activeThemeId).toBe(SYSTEM_THEME_ID); + expect(state.activeTheme?.id).toBe("monokai"); + }); + + it("setSystemThemeMapping re-applies theme when system is active", () => { + setOsPrefersDark(true); + + // First select system theme (resolves to "dark") + useThemeStore.getState().setTheme(SYSTEM_THEME_ID); + expect(useThemeStore.getState().activeTheme?.id).toBe("dark"); + + // Change dark mapping to monokai — should re-apply immediately + useThemeStore.getState().setSystemThemeMapping("dark", "monokai"); + expect(useThemeStore.getState().activeTheme?.id).toBe("monokai"); + }); + + it("setSystemThemeMapping does not re-apply when system is not active", () => { + // Set a specific theme (not system) + useThemeStore.getState().setTheme("dark"); + expect(useThemeStore.getState().activeTheme?.id).toBe("dark"); + + // Change mapping — should NOT change active theme + useThemeStore.getState().setSystemThemeMapping("dark", "monokai"); + expect(useThemeStore.getState().systemDarkThemeId).toBe("monokai"); + expect(useThemeStore.getState().activeTheme?.id).toBe("dark"); + }); + + it("removeCustomTheme resets system mapping to built-in", () => { + const customTheme = { + id: "catppuccin", + name: "Catppuccin", + type: "dark" as const, + ui: {} as never, + }; + useThemeStore.getState().upsertCustomThemes([customTheme]); + useThemeStore.getState().setSystemThemeMapping("dark", "catppuccin"); + + expect(useThemeStore.getState().systemDarkThemeId).toBe("catppuccin"); + + // Remove the custom theme + useThemeStore.getState().removeCustomTheme("catppuccin"); + + // Should reset to built-in "dark" + expect(useThemeStore.getState().systemDarkThemeId).toBe("dark"); + }); +}); diff --git a/apps/desktop/src/renderer/stores/theme/store.ts b/apps/desktop/src/renderer/stores/theme/store.ts index 104c1edf332..75fc24f6ea4 100644 --- a/apps/desktop/src/renderer/stores/theme/store.ts +++ b/apps/desktop/src/renderer/stores/theme/store.ts @@ -19,6 +19,12 @@ interface ThemeState { /** Current active theme ID (can be "system" or a specific theme ID) */ activeThemeId: string; + /** Theme ID to use when OS is in light mode and system theme is active */ + systemLightThemeId: string; + + /** Theme ID to use when OS is in dark mode and system theme is active */ + systemDarkThemeId: string; + /** List of custom (user-imported) themes */ customThemes: Theme[]; @@ -31,6 +37,9 @@ interface ThemeState { /** Set the active theme by ID (can be "system" or a specific theme ID) */ setTheme: (themeId: string) => void; + /** Set the theme to use for a specific OS mode when system theme is active */ + setSystemThemeMapping: (osMode: "dark" | "light", themeId: string) => void; + /** Add a custom theme */ addCustomTheme: (theme: Theme) => void; /** Add or replace custom themes by ID */ @@ -62,11 +71,16 @@ function getSystemPreferredThemeType(): "dark" | "light" { /** * Resolve a theme ID to the actual theme ID to use. - * If "system" is passed, resolves to "dark" or "light" based on OS preference. + * If "system" is passed, resolves using the user's custom mapping for the current OS mode. */ -function resolveThemeId(themeId: string): string { +function resolveThemeId( + themeId: string, + systemDarkThemeId: string, + systemLightThemeId: string, +): string { if (themeId === SYSTEM_THEME_ID) { - return getSystemPreferredThemeType(); + const osMode = getSystemPreferredThemeType(); + return osMode === "dark" ? systemDarkThemeId : systemLightThemeId; } return themeId; } @@ -126,14 +140,19 @@ export const useThemeStore = create()( persist( (set, get) => ({ activeThemeId: DEFAULT_THEME_ID, + systemLightThemeId: "light", + systemDarkThemeId: "dark", customThemes: [], activeTheme: null, terminalTheme: null, setTheme: (themeId: string) => { const state = get(); - // Resolve system theme to actual theme ID - const resolvedId = resolveThemeId(themeId); + const resolvedId = resolveThemeId( + themeId, + state.systemDarkThemeId, + state.systemLightThemeId, + ); const theme = findTheme(resolvedId, state.customThemes); if (!theme) { @@ -150,6 +169,31 @@ export const useThemeStore = create()( }); }, + setSystemThemeMapping: (osMode: "dark" | "light", themeId: string) => { + const state = get(); + const update = + osMode === "dark" + ? { systemDarkThemeId: themeId } + : { systemLightThemeId: themeId }; + + set(update); + + // Re-apply if system theme is currently active + if (state.activeThemeId === SYSTEM_THEME_ID) { + const newState = get(); + const resolvedId = resolveThemeId( + SYSTEM_THEME_ID, + newState.systemDarkThemeId, + newState.systemLightThemeId, + ); + const theme = findTheme(resolvedId, newState.customThemes); + if (theme) { + const { terminalTheme } = applyTheme(theme); + set({ activeTheme: theme, terminalTheme }); + } + } + }, + addCustomTheme: (theme: Theme) => { get().upsertCustomThemes([theme]); }, @@ -184,7 +228,11 @@ export const useThemeStore = create()( } const customThemes = Array.from(customThemesById.values()); - const resolvedId = resolveThemeId(state.activeThemeId); + const resolvedId = resolveThemeId( + state.activeThemeId, + state.systemDarkThemeId, + state.systemLightThemeId, + ); const resolvedTheme = findTheme(resolvedId, customThemes); if (!resolvedTheme) { @@ -210,6 +258,14 @@ export const useThemeStore = create()( state.setTheme(DEFAULT_THEME_ID); } + // If removing a theme used in system mapping, reset to built-in + if (state.systemDarkThemeId === themeId) { + set({ systemDarkThemeId: "dark" }); + } + if (state.systemLightThemeId === themeId) { + set({ systemLightThemeId: "light" }); + } + set((state) => ({ customThemes: state.customThemes.filter((t) => t.id !== themeId), })); @@ -230,7 +286,11 @@ export const useThemeStore = create()( initializeTheme: () => { const state = get(); - const resolvedId = resolveThemeId(state.activeThemeId); + const resolvedId = resolveThemeId( + state.activeThemeId, + state.systemDarkThemeId, + state.systemLightThemeId, + ); const theme = findTheme(resolvedId, state.customThemes); if (theme) { @@ -264,6 +324,8 @@ export const useThemeStore = create()( storage: trpcThemeStorage, partialize: (state) => ({ activeThemeId: state.activeThemeId, + systemLightThemeId: state.systemLightThemeId, + systemDarkThemeId: state.systemDarkThemeId, customThemes: state.customThemes, }), onRehydrateStorage: () => (state) => {