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 */}
-
+
System
@@ -125,7 +185,25 @@ export function SystemThemeCard({
)}
-
-
+
+
+ {/* 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) => {