From a0725cc090bd13aa46d949eafee56381a797f899 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 17 Mar 2026 18:13:32 +0000 Subject: [PATCH] feat(desktop): allow custom theme mapping for System theme mode Add systemLightThemeId and systemDarkThemeId fields to the theme store, letting users configure which theme to use for each OS mode when "System" is selected. The SystemThemeCard now shows dropdown selectors (filtered by theme type) when active. Defaults match current behavior (dark/light). Refs #2547 --- .../SystemThemeCard/SystemThemeCard.tsx | 102 ++++++++++-- .../src/renderer/stores/theme/store.test.ts | 153 ++++++++++++++++++ .../src/renderer/stores/theme/store.ts | 76 ++++++++- 3 files changed, 312 insertions(+), 19 deletions(-) create mode 100644 apps/desktop/src/renderer/stores/theme/store.test.ts 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) => {