[AI] Custom Themes - ability to define separate light/dark theme (#7145)

* [AI] Consolidate custom theme prefs and improve auto-mode UX

- Merge `installedCustomTheme` into `installedCustomLightTheme` so only
  two prefs exist (light + dark). The legacy asyncStorage key
  `installed-custom-theme` is preserved for backwards compatibility.
- In auto (System default) mode, the main Theme dropdown no longer
  surfaces the installed custom-light theme as an option; custom themes
  for light/dark are managed exclusively via the sub-selectors.
- Selecting "System default" resets both light and dark custom themes.
- Installing a custom theme from the main dropdown while in auto mode
  switches the base theme to "Light" so it applies directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Add release notes for PR #7145

* Change category from Features to Enhancement

Custom Themes: separate light and dark theme options when selecting 'system default' theme.

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #7145

* Enhance ThemeSettings and UI components by adding maxWidth styling for better responsiveness. This change ensures that buttons and columns adapt to the full width of their containers.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Matiss Janis Aboltins
2026-03-11 18:07:06 +00:00
committed by GitHub
parent a65ab2b4ce
commit 8a8fb2da51
8 changed files with 231 additions and 63 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 83 KiB

View File

@@ -30,86 +30,169 @@ import {
import type { InstalledTheme } from '@desktop-client/style/customThemes';
const INSTALL_NEW_VALUE = '__install_new__';
const INSTALL_CUSTOM_LIGHT = '__install_custom_light__';
const INSTALL_CUSTOM_DARK = '__install_custom_dark__';
export function ThemeSettings() {
const { t } = useTranslation();
const sidebar = useSidebar();
const [theme, switchTheme] = useTheme();
const [darkTheme, switchDarkTheme] = usePreferredDarkTheme();
const [showInstaller, setShowInstaller] = useState(false);
const [showInstaller, setShowInstaller] = useState<
'single' | 'light' | 'dark' | null
>(null);
const customThemesEnabled = useFeatureFlag('customThemes');
// Global prefs for custom themes
const [installedThemeJson, setInstalledThemeJson] = useGlobalPref(
'installedCustomTheme',
const [installedLightThemeJson, setInstalledLightThemeJson] = useGlobalPref(
'installedCustomLightTheme',
);
const [installedDarkThemeJson, setInstalledDarkThemeJson] = useGlobalPref(
'installedCustomDarkTheme',
);
const installedTheme = parseInstalledTheme(installedThemeJson);
const installedCustomLightTheme = parseInstalledTheme(
installedLightThemeJson,
);
const installedCustomDarkTheme = parseInstalledTheme(installedDarkThemeJson);
// Build the options list
// Build the options list for the single (non-auto) theme selector
const buildOptions = useCallback(() => {
const options: Array<readonly [string, string] | typeof Menu.line> = [
...themeOptions,
];
// Add custom theme options only if feature flag is enabled
if (customThemesEnabled) {
// Add installed custom theme if it exists
if (installedTheme) {
if (theme !== 'auto' && installedCustomLightTheme) {
options.push([
`custom:${installedTheme.id}`,
installedTheme.name,
`custom:${installedCustomLightTheme.id}`,
installedCustomLightTheme.name,
] as const);
}
// Add separator and "Custom theme" option
options.push(Menu.line);
options.push([INSTALL_NEW_VALUE, t('Custom theme')] as const);
}
return options;
}, [installedTheme, customThemesEnabled, t]);
}, [installedCustomLightTheme, customThemesEnabled, theme, t]);
// Determine current value for the select
// Build options for the auto-mode light theme selector
const buildLightOptions = useCallback(() => {
const options: Array<readonly [string, string] | typeof Menu.line> = [
['light', t('Light')],
];
if (customThemesEnabled) {
if (installedCustomLightTheme) {
options.push([
`custom-light:${installedCustomLightTheme.id}`,
installedCustomLightTheme.name,
] as const);
}
options.push(Menu.line);
options.push([INSTALL_CUSTOM_LIGHT, t('Custom theme')] as const);
}
return options;
}, [installedCustomLightTheme, customThemesEnabled, t]);
// Build options for the auto-mode dark theme selector
const buildDarkOptions = useCallback(() => {
const options: Array<readonly [string, string] | typeof Menu.line> = [
...darkThemeOptions,
];
if (customThemesEnabled) {
if (installedCustomDarkTheme) {
options.push([
`custom-dark:${installedCustomDarkTheme.id}`,
installedCustomDarkTheme.name,
] as const);
}
options.push(Menu.line);
options.push([INSTALL_CUSTOM_DARK, t('Custom theme')] as const);
}
return options;
}, [installedCustomDarkTheme, customThemesEnabled, t]);
// Determine current value for the single theme select
const getCurrentValue = useCallback(() => {
if (customThemesEnabled && installedTheme) {
return `custom:${installedTheme.id}`;
if (customThemesEnabled && installedCustomLightTheme && theme !== 'auto') {
return `custom:${installedCustomLightTheme.id}`;
}
return theme;
}, [customThemesEnabled, installedTheme, theme]);
}, [customThemesEnabled, installedCustomLightTheme, theme]);
// Handle theme selection
// Handle theme selection (non-auto mode)
const handleThemeChange = useCallback(
(value: string) => {
if (value === INSTALL_NEW_VALUE) {
setShowInstaller(true);
setShowInstaller('single');
return;
}
if (value.startsWith('custom:')) {
// Custom theme is already installed and active, no action needed
// (since there's only one theme, selecting it means it's already active)
} else {
// Built-in theme selected - clear the installed custom theme
setInstalledThemeJson(serializeInstalledTheme(null));
if (!value.startsWith('custom:')) {
setInstalledLightThemeJson(serializeInstalledTheme(null));
setInstalledDarkThemeJson(serializeInstalledTheme(null));
switchTheme(value as Theme);
}
},
[setInstalledThemeJson, switchTheme],
[setInstalledLightThemeJson, setInstalledDarkThemeJson, switchTheme],
);
// Handle light theme selection (auto mode)
const handleLightThemeChange = useCallback(
(value: string) => {
if (value === INSTALL_CUSTOM_LIGHT) {
setShowInstaller('light');
return;
}
if (value === 'light') {
setInstalledLightThemeJson(serializeInstalledTheme(null));
}
},
[setInstalledLightThemeJson],
);
// Handle dark theme selection (auto mode)
const handleDarkThemeChange = useCallback(
(value: string) => {
if (value === INSTALL_CUSTOM_DARK) {
setShowInstaller('dark');
return;
}
if (!value.startsWith('custom-dark:')) {
setInstalledDarkThemeJson(serializeInstalledTheme(null));
switchDarkTheme(value as DarkTheme);
}
},
[setInstalledDarkThemeJson, switchDarkTheme],
);
// Handle theme installation
const handleInstall = useCallback(
(newTheme: InstalledTheme) => {
setInstalledThemeJson(serializeInstalledTheme(newTheme));
if (showInstaller === 'light') {
setInstalledLightThemeJson(serializeInstalledTheme(newTheme));
} else if (showInstaller === 'dark') {
setInstalledDarkThemeJson(serializeInstalledTheme(newTheme));
} else {
setInstalledLightThemeJson(serializeInstalledTheme(newTheme));
if (theme === 'auto') {
switchTheme('light');
}
}
},
[setInstalledThemeJson],
[
showInstaller,
theme,
setInstalledLightThemeJson,
setInstalledDarkThemeJson,
switchTheme,
],
);
// Handle installer close
const handleInstallerClose = useCallback(() => {
setShowInstaller(false);
setShowInstaller(null);
}, []);
return (
@@ -146,24 +229,49 @@ export function ThemeSettings() {
'&[data-hovered]': {
backgroundColor: themeStyle.buttonNormalBackgroundHover,
},
maxWidth: '100%',
})}
/>
</Column>
{theme === 'auto' && !installedTheme && (
<Column title={t('Dark theme')}>
<Select<DarkTheme>
onChange={value => {
switchDarkTheme(value);
}}
value={darkTheme}
options={darkThemeOptions}
{theme === 'auto' && (
<>
<Column title={t('Light theme')}>
<Select<string>
onChange={handleLightThemeChange}
value={
customThemesEnabled && installedCustomLightTheme
? `custom-light:${installedCustomLightTheme.id}`
: 'light'
}
options={buildLightOptions()}
className={css({
'&[data-hovered]': {
backgroundColor: themeStyle.buttonNormalBackgroundHover,
backgroundColor:
themeStyle.buttonNormalBackgroundHover,
},
maxWidth: '100%',
})}
/>
</Column>
<Column title={t('Dark theme')}>
<Select<string>
onChange={handleDarkThemeChange}
value={
customThemesEnabled && installedCustomDarkTheme
? `custom-dark:${installedCustomDarkTheme.id}`
: darkTheme
}
options={buildDarkOptions()}
className={css({
'&[data-hovered]': {
backgroundColor:
themeStyle.buttonNormalBackgroundHover,
},
maxWidth: '100%',
})}
/>
</Column>
</>
)}
</View>
)}
@@ -172,7 +280,11 @@ export function ThemeSettings() {
<ThemeInstaller
onInstall={handleInstall}
onClose={handleInstallerClose}
installedTheme={installedTheme}
installedTheme={
showInstaller === 'dark'
? installedCustomDarkTheme
: installedCustomLightTheme
}
/>
)}
</View>

View File

@@ -118,7 +118,9 @@ export function Column({
}}
>
<Text style={{ fontWeight: 500 }}>{title}</Text>
<View style={{ alignItems: 'flex-start', gap: '1em' }}>{children}</View>
<View style={{ alignItems: 'flex-start', gap: '1em', width: '100%' }}>
{children}
</View>
</View>
);
}

View File

@@ -103,22 +103,54 @@ export function ThemeStyle() {
/**
* CustomThemeStyle injects CSS from the installed custom theme (if any).
* This is rendered after ThemeStyle to allow custom themes to override base theme variables.
*
* When `theme === 'auto'`, separate custom themes can be set for light and dark modes,
* injected via @media (prefers-color-scheme) rules. Otherwise, a single custom theme applies.
*/
export function CustomThemeStyle() {
const customThemesEnabled = useFeatureFlag('customThemes');
const [installedThemeJson] = useGlobalPref('installedCustomTheme');
const [activeTheme] = useTheme();
const [installedCustomLightThemeJson] = useGlobalPref(
'installedCustomLightTheme',
);
const [installedCustomDarkThemeJson] = useGlobalPref(
'installedCustomDarkTheme',
);
// Parse installed theme (single theme, not array)
const installedTheme = parseInstalledTheme(installedThemeJson);
const validatedCss = useMemo(() => {
if (!customThemesEnabled) return null;
// Get CSS content from the theme (cssContent is required)
if (activeTheme === 'auto') {
const lightTheme = parseInstalledTheme(installedCustomLightThemeJson);
const darkTheme = parseInstalledTheme(installedCustomDarkThemeJson);
let css = '';
if (lightTheme?.cssContent) {
try {
const validated = validateThemeCss(lightTheme.cssContent);
css += `@media (prefers-color-scheme: light) { ${validated} }\n`;
} catch (error) {
console.error('Invalid custom light theme CSS', { error });
}
}
if (darkTheme?.cssContent) {
try {
const validated = validateThemeCss(darkTheme.cssContent);
css += `@media (prefers-color-scheme: dark) { ${validated} }\n`;
} catch (error) {
console.error('Invalid custom dark theme CSS', { error });
}
}
return css || null;
}
const installedTheme = parseInstalledTheme(installedCustomLightThemeJson);
const { cssContent } = installedTheme ?? {};
// Memoize validated CSS to avoid re-validation on every render
const validatedCss = useMemo(() => {
if (!customThemesEnabled || !cssContent) {
return null;
}
if (!cssContent) return null;
try {
return validateThemeCss(cssContent);
@@ -126,7 +158,12 @@ export function CustomThemeStyle() {
console.error('Invalid custom theme CSS', { error, cssContent });
return null;
}
}, [customThemesEnabled, cssContent]);
}, [
customThemesEnabled,
activeTheme,
installedCustomLightThemeJson,
installedCustomDarkThemeJson,
]);
if (!validatedCss) {
return null;

View File

@@ -99,10 +99,16 @@ async function saveGlobalPrefs(prefs: GlobalPrefs) {
prefs.preferredDarkTheme,
);
}
if (prefs.installedCustomTheme !== undefined) {
if (prefs.installedCustomLightTheme !== undefined) {
await asyncStorage.setItem(
'installed-custom-theme',
prefs.installedCustomTheme,
prefs.installedCustomLightTheme,
);
}
if (prefs.installedCustomDarkTheme !== undefined) {
await asyncStorage.setItem(
'installed-custom-dark-theme',
prefs.installedCustomDarkTheme,
);
}
if (prefs.serverSelfSignedCert !== undefined) {
@@ -133,7 +139,8 @@ async function loadGlobalPrefs(): Promise<GlobalPrefs> {
language,
theme,
'preferred-dark-theme': preferredDarkTheme,
'installed-custom-theme': installedCustomTheme,
'installed-custom-theme': installedCustomLightTheme,
'installed-custom-dark-theme': installedCustomDarkTheme,
'server-self-signed-cert': serverSelfSignedCert,
syncServerConfig,
notifyWhenUpdateIsAvailable,
@@ -147,6 +154,7 @@ async function loadGlobalPrefs(): Promise<GlobalPrefs> {
'theme',
'preferred-dark-theme',
'installed-custom-theme',
'installed-custom-dark-theme',
'server-self-signed-cert',
'syncServerConfig',
'notifyWhenUpdateIsAvailable',
@@ -170,7 +178,8 @@ async function loadGlobalPrefs(): Promise<GlobalPrefs> {
preferredDarkTheme === 'dark' || preferredDarkTheme === 'midnight'
? preferredDarkTheme
: 'dark',
installedCustomTheme: installedCustomTheme || undefined,
installedCustomLightTheme: installedCustomLightTheme || undefined,
installedCustomDarkTheme: installedCustomDarkTheme || undefined,
serverSelfSignedCert: serverSelfSignedCert || undefined,
syncServerConfig: syncServerConfig || undefined,
notifyWhenUpdateIsAvailable:

View File

@@ -121,7 +121,8 @@ export type GlobalPrefs = Partial<{
colors: Record<string, string>;
}
>; // Complete plugin theme metadata
installedCustomTheme?: string; // JSON string of installed custom theme
installedCustomLightTheme?: string; // JSON of InstalledTheme for light custom theme (also used as single custom theme in non-auto mode)
installedCustomDarkTheme?: string; // JSON of InstalledTheme for auto-mode dark custom theme
documentDir: string; // Electron only
serverSelfSignedCert: string; // Electron only
syncServerConfig?: {
@@ -150,7 +151,8 @@ export type GlobalPrefsJson = Partial<{
language?: GlobalPrefs['language'];
theme?: GlobalPrefs['theme'];
'preferred-dark-theme'?: GlobalPrefs['preferredDarkTheme'];
'installed-custom-theme'?: GlobalPrefs['installedCustomTheme'];
'installed-custom-theme'?: GlobalPrefs['installedCustomLightTheme'];
'installed-custom-dark-theme'?: GlobalPrefs['installedCustomDarkTheme'];
plugins?: string; // "true" or "false"
'plugin-theme'?: string; // JSON string of complete plugin theme (current selected plugin theme)
'server-self-signed-cert'?: GlobalPrefs['serverSelfSignedCert'];

View File

@@ -0,0 +1,6 @@
---
category: Enhancement
authors: [MatissJanis]
---
Custom Themes: separate light and dark theme options when selecting "system default" theme.