Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions pr-body.md
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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 (
<div className="theme-config" data-testid="appearance-theme-section">
<div className="theme-config__content">
Expand Down Expand Up @@ -144,6 +170,42 @@ function AppearanceThemeSection() {
</div>
</div>
</ConfigPageRow>
{isSystemMode && (
<>
<ConfigPageRow
label={t('appearance.systemLightTheme')}
description={t('appearance.systemLightThemeHint')}
align="center"
>
<Select
value={systemLightId}
onChange={(value) => {
const lightId = String(Array.isArray(value) ? value[0] ?? '' : value);
void setSystemThemeOverride(lightId, systemDarkId);
}}
disabled={loading}
options={lightThemeOptions}
triggerTestId="appearance-system-light-select"
/>
</ConfigPageRow>
<ConfigPageRow
label={t('appearance.systemDarkTheme')}
description={t('appearance.systemDarkThemeHint')}
align="center"
>
<Select
value={systemDarkId}
onChange={(value) => {
const darkId = String(Array.isArray(value) ? value[0] ?? '' : value);
void setSystemThemeOverride(systemLightId, darkId);
}}
disabled={loading}
options={darkThemeOptions}
triggerTestId="appearance-system-dark-select"
/>
</ConfigPageRow>
</>
)}
</ConfigPageSection>
</div>
</div>
Expand Down
57 changes: 54 additions & 3 deletions src/web-ui/src/infrastructure/theme/core/ThemeService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -221,13 +221,17 @@ 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<ThemeEventType, Set<ThemeEventListener>> = new Map();
private hooks: ThemeHooks = {};
private initialized = false;
private userThemesLoaded = false;
private userThemesLoadPromise: Promise<void> | null = null;
private pendingUserThemeSelection: ThemeId | null = null;
private systemOverridesLoaded = false;

constructor() {
this.initializeBuiltinThemes();
Expand Down Expand Up @@ -395,6 +399,52 @@ export class ThemeService {
}
}

private async loadSystemThemeOverrides(): Promise<void> {
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<void> {
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);
}
}
}




Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions src/web-ui/src/infrastructure/theme/hooks/useTheme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@ export function useTheme() {
themes,
loading,
error,
systemLightId,
systemDarkId,
initialize,
setTheme,
setSystemThemeOverride,
refreshThemes,
} = useThemeStore();

Expand All @@ -31,9 +34,12 @@ export function useTheme() {
themes,
loading,
error,
systemLightId,
systemDarkId,


setTheme,
setSystemThemeOverride,
refreshThemes,


Expand Down
16 changes: 10 additions & 6 deletions src/web-ui/src/infrastructure/theme/presets/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down
23 changes: 23 additions & 0 deletions src/web-ui/src/infrastructure/theme/store/themeStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -15,10 +16,13 @@ interface ThemeState {
themes: ThemeMetadata[];
loading: boolean;
error: string | null;
systemLightId: ThemeId;
systemDarkId: ThemeId;


initialize: () => Promise<void>;
setTheme: (themeId: ThemeSelectionId) => Promise<void>;
setSystemThemeOverride: (lightId: ThemeId, darkId: ThemeId) => Promise<void>;
refreshThemes: () => void;
addTheme: (theme: ThemeConfig) => Promise<void>;
removeTheme: (themeId: ThemeId) => Promise<void>;
Expand All @@ -33,6 +37,8 @@ export const useThemeStore = create<ThemeState>((set) => ({
themes: [],
loading: false,
error: null,
systemLightId: DEFAULT_LIGHT_THEME_ID,
systemDarkId: DEFAULT_DARK_THEME_ID,


initialize: async () => {
Expand Down Expand Up @@ -69,6 +75,8 @@ export const useThemeStore = create<ThemeState>((set) => ({
loading: false,
currentTheme: themeService.getCurrentTheme(),
currentThemeId: themeService.getCurrentThemeId(),
systemLightId: themeService.getSystemLightId(),
systemDarkId: themeService.getSystemDarkId(),
});
} catch (error) {
log.error('Failed to initialize', error);
Expand Down Expand Up @@ -99,6 +107,21 @@ export const useThemeStore = create<ThemeState>((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 });
Expand Down
4 changes: 4 additions & 0 deletions src/web-ui/src/locales/en-US/settings/basics.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions src/web-ui/src/locales/zh-CN/settings/basics.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
"themeRowHint": "选择界面配色主题。",
"systemTheme": "跟随系统",
"systemThemeDescription": "根据系统浅色/深色外观自动在亮暗主题间切换,并随系统切换而更新。",
"systemLightTheme": "系统浅色主题",
"systemDarkTheme": "系统深色主题",
"systemLightThemeHint": "系统处于浅色模式时使用",
"systemDarkThemeHint": "系统处于深色模式时使用",
"presets": {
"bitfun-dark": {
"name": "暗色",
Expand Down
4 changes: 4 additions & 0 deletions src/web-ui/src/locales/zh-TW/settings/basics.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
"themeRowHint": "選擇界面配色主題。",
"systemTheme": "跟隨系統",
"systemThemeDescription": "根據系統淺色/深色外觀自動在亮暗主題間切換,並隨系統切換而更新。",
"systemLightTheme": "系統淺色主題",
"systemDarkTheme": "系統深色主題",
"systemLightThemeHint": "系統處於淺色模式時使用",
"systemDarkThemeHint": "系統處於深色模式時使用",
"presets": {
"bitfun-dark": {
"name": "暗色",
Expand Down