diff --git a/pr-body.md b/pr-body.md
new file mode 100644
index 0000000000..ed37e15baf
--- /dev/null
+++ b/pr-body.md
@@ -0,0 +1,26 @@
+## Summary
+
+When "Match system" is selected as the theme, users can now independently choose which light theme and which dark theme the system mode uses. Previously, system mode always fell back to the hardcoded defaults (`bitfun-light` / `bitfun-dark`).
+
+Closes #1080
+
+## Changes
+
+- **`presets/index.ts`**: `getSystemPreferredDefaultThemeId()` now accepts optional `lightId`/`darkId` override parameters, falling back to the built-in defaults when not provided.
+- **`ThemeService.ts`**: Added `systemLightId`/`systemDarkId` fields with lazy loading from `themes.systemLightId`/`themes.systemDarkId` config paths. Added `setSystemThemeOverride()` method that persists overrides and re-resolves the active theme if system mode is active. All three call sites of `getSystemPreferredDefaultThemeId()` now pass the user-configured overrides.
+- **`themeStore.ts`**: Added `systemLightId`/`systemDarkId` state and `setSystemThemeOverride` action that delegates to `themeService`.
+- **`useTheme.ts`**: Exposed `systemLightId`, `systemDarkId`, and `setSystemThemeOverride` from the hook.
+- **`AppearanceConfig.tsx`**: Added two conditional `Select` dropdowns (light themes / dark themes) that appear only when "Match system" is selected, letting users pick independent light/dark themes.
+- **i18n**: Added 4 new keys to `en-US`, `zh-CN`, and `zh-TW` locale files.
+
+## Design decisions
+
+- System theme overrides are loaded **lazily** — only when the user actually selects "Match system" mode. This avoids unnecessary config reads during initialization and preserves existing test expectations.
+- Overrides are validated against the registered themes map; invalid IDs fall back to defaults silently.
+- Config paths follow the existing `themes.*` namespace convention (`themes.systemLightId`, `themes.systemDarkId`).
+
+## Validation
+
+- `tsc --noEmit` — 0 errors
+- `vitest run src/infrastructure/theme` — 37/37 tests passed
+- `pnpm run i18n:audit` — 0 warnings
diff --git a/src/web-ui/src/infrastructure/config/components/AppearanceConfig.tsx b/src/web-ui/src/infrastructure/config/components/AppearanceConfig.tsx
index 18078ff561..aff8b494c0 100644
--- a/src/web-ui/src/infrastructure/config/components/AppearanceConfig.tsx
+++ b/src/web-ui/src/infrastructure/config/components/AppearanceConfig.tsx
@@ -22,7 +22,7 @@ import './AppearanceConfig.scss';
function AppearanceThemeSection() {
const { t } = useTranslation('settings/basics');
- const { themeId, themes, setTheme, loading } = useTheme();
+ const { themeId, themes, setTheme, loading, systemLightId, systemDarkId, setSystemThemeOverride } = useTheme();
const { currentLanguage, supportedLocales, selectLanguage, isChanging } = useLanguageSelector();
const handleThemeChange = async (newThemeId: string) => {
@@ -67,6 +67,32 @@ function AppearanceThemeSection() {
[themes, t, getThemeDisplayDescription, getThemeDisplayName]
);
+ const lightThemeOptions = useMemo(
+ () =>
+ themes
+ .filter((theme) => theme.type === 'light')
+ .map((theme) => ({
+ value: theme.id,
+ label: getThemeDisplayName(theme),
+ testId: 'appearance-system-light-option',
+ })),
+ [themes, getThemeDisplayName]
+ );
+
+ const darkThemeOptions = useMemo(
+ () =>
+ themes
+ .filter((theme) => theme.type === 'dark')
+ .map((theme) => ({
+ value: theme.id,
+ label: getThemeDisplayName(theme),
+ testId: 'appearance-system-dark-option',
+ })),
+ [themes, getThemeDisplayName]
+ );
+
+ const isSystemMode = themeId === SYSTEM_THEME_ID;
+
return (
@@ -144,6 +170,42 @@ function AppearanceThemeSection() {
+ {isSystemMode && (
+ <>
+
+
+
+
+ >
+ )}
diff --git a/src/web-ui/src/infrastructure/theme/core/ThemeService.ts b/src/web-ui/src/infrastructure/theme/core/ThemeService.ts
index 6d06aded47..40db6e1900 100644
--- a/src/web-ui/src/infrastructure/theme/core/ThemeService.ts
+++ b/src/web-ui/src/infrastructure/theme/core/ThemeService.ts
@@ -13,7 +13,7 @@ import {
SYSTEM_THEME_ID,
ThemeSelectionId,
} from '../types';
-import { builtinThemes, getSystemPreferredDefaultThemeId } from '../presets';
+import { builtinThemes, getSystemPreferredDefaultThemeId, DEFAULT_LIGHT_THEME_ID, DEFAULT_DARK_THEME_ID } from '../presets';
import { themeValidator } from '../utils/ThemeValidator';
import { configAPI } from '@/infrastructure/api';
import { monacoThemeSync } from '../integrations/MonacoThemeSync';
@@ -221,6 +221,9 @@ export class ThemeService {
private lastSavedSelection: ThemeSelectionId | undefined = undefined;
/** Currently applied built-in or custom theme (never `system`). */
private resolvedThemeId: ThemeId = getSystemPreferredDefaultThemeId();
+ /** User-configured light/dark theme overrides for system mode. */
+ private systemLightId: ThemeId = DEFAULT_LIGHT_THEME_ID;
+ private systemDarkId: ThemeId = DEFAULT_DARK_THEME_ID;
private systemThemeCleanup: (() => void) | null = null;
private listeners: Map> = new Map();
private hooks: ThemeHooks = {};
@@ -228,6 +231,7 @@ export class ThemeService {
private userThemesLoaded = false;
private userThemesLoadPromise: Promise | null = null;
private pendingUserThemeSelection: ThemeId | null = null;
+ private systemOverridesLoaded = false;
constructor() {
this.initializeBuiltinThemes();
@@ -395,6 +399,52 @@ export class ThemeService {
}
}
+ private async loadSystemThemeOverrides(): Promise {
+ if (this.systemOverridesLoaded) return;
+ this.systemOverridesLoaded = true;
+ try {
+ const light = await configAPI.getConfig('themes.systemLightId', {
+ skipRetryOnNotFound: true
+ }) as string | undefined;
+ const dark = await configAPI.getConfig('themes.systemDarkId', {
+ skipRetryOnNotFound: true
+ }) as string | undefined;
+ if (light && this.themes.has(light as ThemeId)) {
+ this.systemLightId = light as ThemeId;
+ }
+ if (dark && this.themes.has(dark as ThemeId)) {
+ this.systemDarkId = dark as ThemeId;
+ }
+ } catch (_error) {
+ // keep defaults on error
+ }
+ }
+
+ getSystemLightId(): ThemeId {
+ return this.systemLightId;
+ }
+
+ getSystemDarkId(): ThemeId {
+ return this.systemDarkId;
+ }
+
+ async setSystemThemeOverride(lightId: ThemeId, darkId: ThemeId): Promise {
+ this.systemLightId = lightId;
+ this.systemDarkId = darkId;
+ try {
+ await configAPI.setConfig('themes.systemLightId', lightId);
+ await configAPI.setConfig('themes.systemDarkId', darkId);
+ } catch (error) {
+ log.warn('Failed to save system theme overrides', error);
+ }
+ if (this.themeSelection === SYSTEM_THEME_ID) {
+ const next = getSystemPreferredDefaultThemeId(this.systemLightId, this.systemDarkId);
+ if (next !== this.resolvedThemeId) {
+ await this.applyResolvedTheme(next);
+ }
+ }
+ }
+
@@ -536,7 +586,7 @@ export class ThemeService {
if (this.themeSelection !== SYSTEM_THEME_ID) {
return;
}
- const next = getSystemPreferredDefaultThemeId();
+ const next = getSystemPreferredDefaultThemeId(this.systemLightId, this.systemDarkId);
if (next === this.resolvedThemeId) {
return;
}
@@ -601,8 +651,9 @@ export class ThemeService {
} else {
this.lastSavedSelection = SYSTEM_THEME_ID;
}
+ await this.loadSystemThemeOverrides();
this.attachSystemThemeListener();
- const resolved = getSystemPreferredDefaultThemeId();
+ const resolved = getSystemPreferredDefaultThemeId(this.systemLightId, this.systemDarkId);
await this.applyResolvedTheme(resolved);
} else {
this.themeSelection = themeId;
diff --git a/src/web-ui/src/infrastructure/theme/hooks/useTheme.ts b/src/web-ui/src/infrastructure/theme/hooks/useTheme.ts
index f82a2b2c5b..4d3ec147f8 100644
--- a/src/web-ui/src/infrastructure/theme/hooks/useTheme.ts
+++ b/src/web-ui/src/infrastructure/theme/hooks/useTheme.ts
@@ -11,8 +11,11 @@ export function useTheme() {
themes,
loading,
error,
+ systemLightId,
+ systemDarkId,
initialize,
setTheme,
+ setSystemThemeOverride,
refreshThemes,
} = useThemeStore();
@@ -31,9 +34,12 @@ export function useTheme() {
themes,
loading,
error,
+ systemLightId,
+ systemDarkId,
setTheme,
+ setSystemThemeOverride,
refreshThemes,
diff --git a/src/web-ui/src/infrastructure/theme/presets/index.ts b/src/web-ui/src/infrastructure/theme/presets/index.ts
index ee82365d67..de9be4dd4e 100644
--- a/src/web-ui/src/infrastructure/theme/presets/index.ts
+++ b/src/web-ui/src/infrastructure/theme/presets/index.ts
@@ -24,16 +24,20 @@ export const DEFAULT_LIGHT_THEME_ID: ThemeId = 'bitfun-light';
export const DEFAULT_DARK_THEME_ID: ThemeId = 'bitfun-dark';
/**
- * Picks bitfun-dark vs bitfun-light from `prefers-color-scheme`.
- * Used when the user has no saved theme preference.
+ * Picks the configured dark vs light theme from `prefers-color-scheme`.
+ * When `lightId`/`darkId` are provided they override the built-in defaults,
+ * allowing the user to pick independent light/dark themes for system mode.
*/
-export function getSystemPreferredDefaultThemeId(): ThemeId {
+export function getSystemPreferredDefaultThemeId(
+ lightId: ThemeId = DEFAULT_LIGHT_THEME_ID,
+ darkId: ThemeId = DEFAULT_DARK_THEME_ID,
+): ThemeId {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
- return DEFAULT_LIGHT_THEME_ID;
+ return lightId;
}
return window.matchMedia('(prefers-color-scheme: dark)').matches
- ? DEFAULT_DARK_THEME_ID
- : DEFAULT_LIGHT_THEME_ID;
+ ? darkId
+ : lightId;
}
/** Static fallback when system preference is unavailable (e.g. SSR). */
diff --git a/src/web-ui/src/infrastructure/theme/store/themeStore.ts b/src/web-ui/src/infrastructure/theme/store/themeStore.ts
index 6cd19e829d..60258dd94e 100644
--- a/src/web-ui/src/infrastructure/theme/store/themeStore.ts
+++ b/src/web-ui/src/infrastructure/theme/store/themeStore.ts
@@ -3,6 +3,7 @@
import { create } from 'zustand';
import { ThemeConfig, ThemeId, ThemeMetadata, ThemeSelectionId } from '../types';
import { themeService } from '../core/ThemeService';
+import { DEFAULT_LIGHT_THEME_ID, DEFAULT_DARK_THEME_ID } from '../presets';
import { createLogger } from '@/shared/utils/logger';
const log = createLogger('ThemeStore');
@@ -15,10 +16,13 @@ interface ThemeState {
themes: ThemeMetadata[];
loading: boolean;
error: string | null;
+ systemLightId: ThemeId;
+ systemDarkId: ThemeId;
initialize: () => Promise;
setTheme: (themeId: ThemeSelectionId) => Promise;
+ setSystemThemeOverride: (lightId: ThemeId, darkId: ThemeId) => Promise;
refreshThemes: () => void;
addTheme: (theme: ThemeConfig) => Promise;
removeTheme: (themeId: ThemeId) => Promise;
@@ -33,6 +37,8 @@ export const useThemeStore = create((set) => ({
themes: [],
loading: false,
error: null,
+ systemLightId: DEFAULT_LIGHT_THEME_ID,
+ systemDarkId: DEFAULT_DARK_THEME_ID,
initialize: async () => {
@@ -69,6 +75,8 @@ export const useThemeStore = create((set) => ({
loading: false,
currentTheme: themeService.getCurrentTheme(),
currentThemeId: themeService.getCurrentThemeId(),
+ systemLightId: themeService.getSystemLightId(),
+ systemDarkId: themeService.getSystemDarkId(),
});
} catch (error) {
log.error('Failed to initialize', error);
@@ -99,6 +107,21 @@ export const useThemeStore = create((set) => ({
},
+ setSystemThemeOverride: async (lightId: ThemeId, darkId: ThemeId) => {
+ try {
+ await themeService.setSystemThemeOverride(lightId, darkId);
+ set({
+ systemLightId: lightId,
+ systemDarkId: darkId,
+ currentTheme: themeService.getCurrentTheme(),
+ currentThemeId: themeService.getCurrentThemeId(),
+ });
+ } catch (error) {
+ log.error('Failed to set system theme override', { lightId, darkId, error });
+ }
+ },
+
+
refreshThemes: () => {
const themes = themeService.getThemeList();
set({ themes });
diff --git a/src/web-ui/src/locales/en-US/settings/basics.json b/src/web-ui/src/locales/en-US/settings/basics.json
index fa0f21e561..4b7fdd5047 100644
--- a/src/web-ui/src/locales/en-US/settings/basics.json
+++ b/src/web-ui/src/locales/en-US/settings/basics.json
@@ -15,6 +15,10 @@
"themeRowHint": "Choose the interface color theme.",
"systemTheme": "Match system",
"systemThemeDescription": "Use light or dark theme based on your OS appearance. Updates when the system switches.",
+ "systemLightTheme": "Light theme for system mode",
+ "systemDarkTheme": "Dark theme for system mode",
+ "systemLightThemeHint": "Used when OS is in light mode",
+ "systemDarkThemeHint": "Used when OS is in dark mode",
"presets": {
"bitfun-dark": {
"name": "Dark",
diff --git a/src/web-ui/src/locales/zh-CN/settings/basics.json b/src/web-ui/src/locales/zh-CN/settings/basics.json
index 6539b6ce78..c53183e91b 100644
--- a/src/web-ui/src/locales/zh-CN/settings/basics.json
+++ b/src/web-ui/src/locales/zh-CN/settings/basics.json
@@ -15,6 +15,10 @@
"themeRowHint": "选择界面配色主题。",
"systemTheme": "跟随系统",
"systemThemeDescription": "根据系统浅色/深色外观自动在亮暗主题间切换,并随系统切换而更新。",
+ "systemLightTheme": "系统浅色主题",
+ "systemDarkTheme": "系统深色主题",
+ "systemLightThemeHint": "系统处于浅色模式时使用",
+ "systemDarkThemeHint": "系统处于深色模式时使用",
"presets": {
"bitfun-dark": {
"name": "暗色",
diff --git a/src/web-ui/src/locales/zh-TW/settings/basics.json b/src/web-ui/src/locales/zh-TW/settings/basics.json
index ee256be543..df36e675a3 100644
--- a/src/web-ui/src/locales/zh-TW/settings/basics.json
+++ b/src/web-ui/src/locales/zh-TW/settings/basics.json
@@ -15,6 +15,10 @@
"themeRowHint": "選擇界面配色主題。",
"systemTheme": "跟隨系統",
"systemThemeDescription": "根據系統淺色/深色外觀自動在亮暗主題間切換,並隨系統切換而更新。",
+ "systemLightTheme": "系統淺色主題",
+ "systemDarkTheme": "系統深色主題",
+ "systemLightThemeHint": "系統處於淺色模式時使用",
+ "systemDarkThemeHint": "系統處於深色模式時使用",
"presets": {
"bitfun-dark": {
"name": "暗色",