Compare commits

...

7 Commits

Author SHA1 Message Date
Matiss Janis Aboltins
c2f6ecf3f4 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. 2026-03-10 22:46:11 +00:00
Matiss Janis Aboltins
0f6d1be4c0 Merge branch 'ai/custom-theme-dual-prefs' of github.com:actualbudget/actual into ai/custom-theme-dual-prefs 2026-03-10 22:29:53 +00:00
Matiss Janis Aboltins
aae8335fcf Merge branch 'master' into ai/custom-theme-dual-prefs 2026-03-10 22:29:33 +00:00
github-actions[bot]
b2267b8b0d Update VRT screenshots
Auto-generated by VRT workflow

PR: #7145
2026-03-07 20:34:14 +00:00
Matiss Janis Aboltins
13fcc408fa Change category from Features to Enhancement
Custom Themes: separate light and dark theme options when selecting 'system default' theme.
2026-03-06 22:03:50 +00:00
github-actions[bot]
dd6f27607e Add release notes for PR #7145 2026-03-06 21:59:52 +00:00
Matiss Janis Aboltins
95badf1608 [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>
2026-03-06 21:47:14 +00:00
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}
className={css({
'&[data-hovered]': {
backgroundColor: themeStyle.buttonNormalBackgroundHover,
},
})}
/>
</Column>
{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,
},
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,30 +103,67 @@ 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);
// Get CSS content from the theme (cssContent is required)
const { cssContent } = installedTheme ?? {};
// Memoize validated CSS to avoid re-validation on every render
const validatedCss = useMemo(() => {
if (!customThemesEnabled || !cssContent) {
return null;
if (!customThemesEnabled) return null;
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 ?? {};
if (!cssContent) return null;
try {
return validateThemeCss(cssContent);
} catch (error) {
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

@@ -118,7 +118,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?: {
@@ -147,7 +148,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.